riskrails 3.0.1 → 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 (99) 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 +29 -15
  5. package/ai/policies/model-routing.md +7 -7
  6. package/ai/policies/production.md +3 -3
  7. package/ai/policies/release.md +2 -2
  8. package/ai/policies/review-economy.md +27 -30
  9. package/bin/riskrails.js +114 -23
  10. package/hooks/ai-path-guard-defaults.json +3 -2
  11. package/hooks/context-guard.py +15 -12
  12. package/hooks/dialog-gate.py +1 -1
  13. package/hooks/guard/common.py +2 -0
  14. package/hooks/guard/dialog.py +107 -27
  15. package/hooks/guard/git.py +12 -3
  16. package/hooks/guard/paths.py +150 -37
  17. package/hooks/project-scaffold.py +3 -6
  18. package/hooks/runtime-gate.py +134 -33
  19. package/hooks/update_notice.py +22 -16
  20. package/instructions/routing.md +1 -1
  21. package/instructions/stub.md +3 -3
  22. package/package.json +1 -1
  23. package/skills/AGENTS.md +1 -1
  24. package/skills/CLAUDE.md +1 -1
  25. package/skills/ai-init/templates/.ai/AGENTS.md +1 -1
  26. package/skills/ai-init/templates/.ai/VERSION +1 -1
  27. package/skills/ai-init/templates/.ai/policies/security.md +7 -1
  28. package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
  29. package/skills/ai-init/templates/.ai/state/README.md +4 -4
  30. package/skills/ai-init/templates/AGENTS.block.md +2 -2
  31. package/skills/ai-init/templates/CLAUDE.block.md +2 -2
  32. package/skills/ai-init/templates/GEMINI.block.md +2 -2
  33. package/skills/ai-init/templates/gitignore.snippet +1 -0
  34. package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
  35. package/skills/ai-task/rr_state/__init__.py +1 -0
  36. package/skills/ai-task/rr_state/cli.py +210 -0
  37. package/skills/ai-task/rr_state/close.py +171 -0
  38. package/skills/ai-task/rr_state/dialog.py +541 -0
  39. package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
  40. package/skills/ai-task/rr_state/fs.py +220 -0
  41. package/skills/ai-task/rr_state/gate.py +125 -0
  42. package/skills/ai-task/rr_state/gate_cmd.py +128 -0
  43. package/skills/ai-task/rr_state/handoff.py +226 -0
  44. package/skills/ai-task/rr_state/journal.py +126 -0
  45. package/skills/ai-task/rr_state/labels.py +99 -0
  46. package/skills/ai-task/rr_state/ledger.py +145 -0
  47. package/skills/ai-task/rr_state/locks.py +84 -0
  48. package/skills/ai-task/rr_state/memory_cmd.py +310 -0
  49. package/skills/ai-task/rr_state/policy.py +378 -0
  50. package/skills/ai-task/rr_state/profile.py +373 -0
  51. package/skills/ai-task/rr_state/questions.py +336 -0
  52. package/skills/ai-task/rr_state/questions_cmd.py +321 -0
  53. package/skills/ai-task/rr_state/report.py +271 -0
  54. package/skills/ai-task/rr_state/report_cmd.py +230 -0
  55. package/skills/ai-task/rr_state/review.py +165 -0
  56. package/skills/ai-task/rr_state/runtime.py +80 -0
  57. package/skills/ai-task/rr_state/start.py +257 -0
  58. package/skills/ai-task/rr_state/steps.py +131 -0
  59. package/skills/ai-task/rr_state/task.py +396 -0
  60. package/skills/ai-task/rr_state/verify.py +231 -0
  61. package/skills/ai-task/sensors.py +37 -4
  62. package/skills/ai-task/state.py +24 -5138
  63. package/skills/project-init/templates/constitution.md +1 -1
  64. package/skills/project-init/templates/sdlc-README.md +20 -14
  65. package/skills/project-update/adopt.py +38 -24
  66. package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
  67. package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
  68. package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
  69. package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
  70. package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
  71. package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
  72. package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
  73. package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
  74. package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
  75. package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
  76. package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
  77. package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
  78. package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
  79. package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
  80. package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
  81. package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
  82. package/skills/project-update/history/index.json +38 -16
  83. package/skills/project-update/migrations/0003_context_diet.py +1 -1
  84. package/skills/project-update/migrations/0007_weight_model.py +130 -23
  85. package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
  86. package/skills/project-update/migrations/README.md +16 -1
  87. package/skills/project-update/migrations/__init__.py +6 -1
  88. package/skills/project-update/render_instructions.py +1 -1
  89. package/skills/project-update/update.py +569 -106
  90. package/skills/rr-build-all/SKILL.md +2 -2
  91. package/skills/rr-plan/SKILL.md +3 -1
  92. package/skills/rr-review/SKILL.md +13 -8
  93. package/skills/rr-setup/SKILL.md +24 -4
  94. package/skills/rr-setup/setup.py +7 -1
  95. package/skills/rr-spec/SKILL.md +4 -2
  96. package/skills/rr-status/status.py +36 -9
  97. package/skills/usage-report/usage-report.py +4 -2
  98. package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
  99. package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
@@ -20,12 +20,17 @@ Exit codes:
20
20
  5 refused: --confirm-delete without a human (ADOPT_REFUSED on stdout line 1)
21
21
 
22
22
  What it manages, and how:
23
- .ai/** the project owns (risk-tiers, path-guard, three-way update
23
+ .ai/** the project owns (weight, path-guard, three-way update
24
24
  testing, coding, safety, security, AGENTS.md)
25
25
  .ai/** the plugin shares (the other policies, read from the plugin's shared tree; a project
26
26
  agents, workflows, templates) copy is an override and is never merged — an
27
27
  unedited one is offered for deletion
28
- docs/sdlc/*/TEMPLATE.md three-way update
28
+ docs/sdlc/*/TEMPLATE.md the plugin supplies them (read in place); an
29
+ unedited copy is offered for deletion, an
30
+ edited one is a project override and stays
31
+ .ai/reports/<task>/, .ai/state/logs/<task>/ proposals only, never run on their own: the
32
+ of closed, abandoned or long-idle tasks logs of a closed task, every file of an
33
+ abandoned or STALE_DAYS-idle unclosed one
29
34
  .claude/memory/README.md, .codex/memory/README.md three-way update, per runtime the project uses
30
35
  the managed block in CLAUDE.md and/or AGENTS.md three-way update of the block only
31
36
  .gitignore missing entries appended
@@ -41,12 +46,12 @@ Three-way update: a file whose content is a version this plugin once shipped was
41
46
  never edited, so it is replaced. An edited file is merged against the shipped
42
47
  version closest to it (git merge-file for text, per key for JSON), keeping every
43
48
  edit. A real conflict leaves the project file alone and puts the plugin's version
44
- in a git-ignored local directory for a human or /project-update to merge.
49
+ in a git-ignored local directory for a human or /rr-setup --update to merge.
45
50
  """
46
- 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
47
52
  from collections import OrderedDict
48
53
  from types import SimpleNamespace
49
- from datetime import datetime, timezone
54
+ from datetime import datetime, timedelta, timezone
50
55
 
51
56
  sys.dont_write_bytecode = True # never leave __pycache__ in an installed plugin
52
57
  HERE = os.path.dirname(os.path.abspath(__file__))
@@ -80,6 +85,7 @@ ROUTER_FILE = ".ai/AGENTS.md"
80
85
  # section, or doubles it, is a conflict for a human.
81
86
  SHARED_SECTION_RE = re.compile(rb"(?m)^## Shared files[ \t]*\r?$")
82
87
  SHARED_SCHEMA = 6 # the schema from which shared copies are overrides (0006)
88
+ STALE_DAYS = 7 # an unclosed task whose last event is older is proposed for cleanup
83
89
  STATE_FILE = ".ai/state/state.json"
84
90
  LEGACY_STATE_FILE = ".ai/state/current.json" # R34: the name until migration 0009 moves it
85
91
 
@@ -102,10 +108,12 @@ RULES_DIR = ".ai/rules"
102
108
  # file is not ours (the path guard refuses it too), and .git is not text.
103
109
  # A child holding a .git entry (worktree, nested repository, submodule) is pruned
104
110
  # too, by the take-back walk (rendered_rules) and by the writer (nested_tree). The
105
- # two still disagree on a dirs: entry under a RULES_SKIP name or through a
106
- # 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.
107
113
  RULES_SKIP = {".git", ".ai", "vendor", "node_modules", "bower_components",
108
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)
109
117
  START, END = "<!-- riskrails:start -->", "<!-- riskrails:end -->"
110
118
  OLD_START, OLD_END = b"<!-- claude-agentic:start -->", b"<!-- claude-agentic:end -->" # R35, one version
111
119
 
@@ -119,14 +127,14 @@ def renamed_markers(block):
119
127
  find_block = render_instructions.find_block
120
128
  current_markers = render_instructions.current_markers
121
129
  MISSING = object()
122
- HASH_RE = re.compile(rb"sha256:[0-9a-f]{64}")
123
130
 
124
131
  PROJECT_INIT_MAP = [ # template -> target, kind
125
132
  ("sdlc-README.md", "docs/sdlc/README.md", "update"),
126
- ("intent.md", "docs/sdlc/intent/TEMPLATE.md", "update"),
127
- ("spec.md", "docs/sdlc/specs/TEMPLATE.md", "update"),
128
- ("plan.md", "docs/sdlc/plans/TEMPLATE.md", "update"),
129
- ("adr.md", "docs/sdlc/adr/TEMPLATE.md", "update"),
133
+ # read in place by /rr-spec and /rr-plan; a project copy is an override: never created
134
+ ("intent.md", "docs/sdlc/intent/TEMPLATE.md", "retire"),
135
+ ("spec.md", "docs/sdlc/specs/TEMPLATE.md", "retire"),
136
+ ("plan.md", "docs/sdlc/plans/TEMPLATE.md", "retire"),
137
+ ("adr.md", "docs/sdlc/adr/TEMPLATE.md", "retire"),
130
138
  # the project's own principles: created once, then it is the project's file
131
139
  ("constitution.md", "docs/sdlc/constitution.md", "create"),
132
140
  ]
@@ -208,7 +216,7 @@ class History:
208
216
  try:
209
217
  with open(os.path.join(root, "index.json"), encoding="utf-8") as fh:
210
218
  self.index = json.load(fh)
211
- except (OSError, ValueError):
219
+ except (OSError, ValueError, RecursionError):
212
220
  self.index = {}
213
221
  self.root = root
214
222
  # {new key: [old keys]} from the migrations' MOVES: a template that moved
@@ -296,12 +304,59 @@ def git_dirty_ai(root):
296
304
  tree, where there is nothing to say."""
297
305
  try:
298
306
  res = subprocess.run(["git", "-C", root, "status", "--porcelain", "--", ".ai"],
299
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30)
307
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=30, check=False)
300
308
  except (OSError, subprocess.SubprocessError):
301
309
  return False
302
310
  return res.returncode == 0 and bool(res.stdout.strip())
303
311
 
304
312
 
313
+ def git_states(root, rel_dir):
314
+ """A function path -> how git holds a file under `rel_dir` (project-relative):
315
+ "tracked" (committed and unchanged), "uncommitted" (changed, staged or not yet
316
+ tracked, so git has no copy to restore) or "ignored"; None outside a work tree
317
+ or for a path git does not know."""
318
+ def run(*args):
319
+ res = subprocess.run(["git", "-C", root] + list(args), stdout=subprocess.PIPE,
320
+ stderr=subprocess.DEVNULL, timeout=30, check=False)
321
+ return res.stdout.decode("utf-8", "replace") if res.returncode == 0 else None
322
+ try:
323
+ prefix = run("rev-parse", "--show-prefix")
324
+ tracked = run("ls-files", "-z", "--", rel_dir)
325
+ status = run("status", "--porcelain=v1", "-z", "--ignored", "-uall", "--", rel_dir)
326
+ except (OSError, subprocess.SubprocessError):
327
+ return lambda path: None
328
+ if prefix is None or tracked is None or status is None:
329
+ return lambda path: None
330
+ prefix = prefix.strip()
331
+ tracked = set(tracked.split("\0"))
332
+ dirty, ignored = set(), []
333
+ entries = status.split("\0")
334
+ skip = False
335
+ for entry in entries:
336
+ if skip: # the origin path of a rename
337
+ skip = False
338
+ continue
339
+ if len(entry) < 4:
340
+ continue
341
+ xy, path = entry[:2], entry[3:]
342
+ if prefix and path.startswith(prefix):
343
+ path = path[len(prefix):]
344
+ if "R" in xy or "C" in xy:
345
+ skip = True
346
+ if xy == "!!":
347
+ ignored.append(path)
348
+ else:
349
+ dirty.add(path)
350
+
351
+ def classify(path):
352
+ if path in dirty:
353
+ return "uncommitted"
354
+ if any(path == i or (i.endswith("/") and path.startswith(i)) for i in ignored):
355
+ return "ignored"
356
+ return "tracked" if path in tracked else None
357
+ return classify
358
+
359
+
305
360
  # ---------------------------------------------------------------- merging
306
361
  def closest(candidates, ours, score):
307
362
  best, best_score = None, -1.0
@@ -407,6 +462,22 @@ def detect_version(plan):
407
462
  return int(text)
408
463
 
409
464
 
465
+ def unsafe_path(root, path):
466
+ """Why a project-relative path must not be deleted: a symlink on the way to it,
467
+ or a real path outside the project; None when it is safe. Run at plan time and
468
+ again right before the delete."""
469
+ real_root = os.path.realpath(root)
470
+ here = root
471
+ for part in path.split("/"):
472
+ here = os.path.join(here, part)
473
+ if os.path.islink(here):
474
+ return "%s is a symlink" % os.path.relpath(here, root).replace(os.sep, "/")
475
+ real = os.path.realpath(os.path.join(root, path))
476
+ if real != real_root and not real.startswith(real_root + os.sep):
477
+ return "%s resolves outside the project (%s)" % (path, real)
478
+ return None
479
+
480
+
410
481
  class MigrationContext:
411
482
  """What a migration sees: the project as this run leaves it, not the disk."""
412
483
 
@@ -523,16 +594,7 @@ class MigrationContext:
523
594
  """Why a file must not be deleted through this path, or None: it is a
524
595
  symlink, it is reached through one, or it resolves outside the project."""
525
596
  self._check_path(path)
526
- root = os.path.realpath(self.root)
527
- here = self.root
528
- for part in path.split("/"):
529
- here = os.path.join(here, part)
530
- if os.path.islink(here):
531
- return "%s is a symlink" % os.path.relpath(here, self.root).replace(os.sep, "/")
532
- real = os.path.realpath(os.path.join(self.root, path))
533
- if real != root and not real.startswith(root + os.sep):
534
- return "%s resolves outside the project (%s)" % (path, real)
535
- return None
597
+ return unsafe_path(self.root, path)
536
598
 
537
599
  def override_note(self, path):
538
600
  """What an edited copy of a shared file now means."""
@@ -546,6 +608,10 @@ class MigrationContext:
546
608
  if text not in self.plan.hints:
547
609
  self.plan.hints.append(text)
548
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
+
549
615
  def exists(self, path):
550
616
  return self.read(path) is not None
551
617
 
@@ -569,6 +635,12 @@ class MigrationContext:
569
635
  return json.loads(raw, object_pairs_hook=OrderedDict)
570
636
  except ValueError:
571
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
572
644
 
573
645
  # ---- operations. Each is idempotent: a target already in its final state
574
646
  # records nothing, so a re-run after an interrupted apply is a no-op.
@@ -577,6 +649,16 @@ class MigrationContext:
577
649
  if not isinstance(kw.get("content", b""), bytes):
578
650
  raise SchemaError("%04d: %s content must be bytes, not %s"
579
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
580
662
  self.plan.add(action, target, note, migration=self.module.VERSION, **kw)
581
663
 
582
664
  def create(self, path, content):
@@ -613,7 +695,12 @@ class MigrationContext:
613
695
 
614
696
  def edit_json(self, path, fn):
615
697
  """Rewrite a JSON file the project has. A policy file's leaf changes are
616
- 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."""
617
704
  raw = self.read(path)
618
705
  if raw is None:
619
706
  return
@@ -624,11 +711,25 @@ class MigrationContext:
624
711
  # the schema version, so the next run tries again.
625
712
  self._add("conflict", path, "is not valid JSON, so this migration could not run (%s)" % exc)
626
713
  return
627
- after = fn(copy.deepcopy(before))
628
- 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")
629
721
  return
630
- policy = leaf_changes(before, after) if "/policies/" in "/" + path else None
631
- self._add("update", path, "migrated", content=dump_json(after), policy=policy)
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")
731
+ return
732
+ self._add("update", path, "migrated", content=content, policy=policy)
632
733
 
633
734
  def patch_state(self, fn): # noqa: D401
634
735
  """Change the task in flight. update.py writes the state file the
@@ -686,6 +787,8 @@ def run_migrations(plan, version, history):
686
787
  state = json.loads(raw, object_pairs_hook=OrderedDict)
687
788
  except ValueError as exc:
688
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
689
792
  if not isinstance(state, dict):
690
793
  raise SchemaError("%s is not a task state object" % path)
691
794
  for mod in mods:
@@ -710,11 +813,14 @@ class Plan:
710
813
  self.schema = None # (from, to, [migration modules]) when migrations are pending
711
814
  self.confirm_delete = None # the human who confirmed this run's deletions
712
815
  self.confirm_via = None # why that gate passed: "terminal" or "unattended"
816
+ self.confirm_cleanup = None # the human who confirmed this run's [cleanup] deletions
817
+ self.cleanup_via = None
713
818
  self.held = None # the schema version kept until a migration item is settled
714
819
  self.final = {} # target -> bytes after this run (for cross-file fixes)
715
820
  self.removed = set() # targets this run takes away
716
821
  self.seen = {} # target -> sha of what was on disk when it was first read,
717
822
  # or None when nothing was there: apply's precondition
823
+ self.git_kept = set() # [cleanup] targets git holds or should hold: a hint and an original/ copy
718
824
  self.report_dir = ".ai/reports/project-update-%s" % datetime.now(timezone.utc).strftime("%Y-%m-%d")
719
825
 
720
826
  # The project as this run leaves it: planned content first, then the disk.
@@ -765,26 +871,95 @@ class Plan:
765
871
  return base
766
872
 
767
873
 
874
+ WEIGHT_JSON, WEIGHT_MD = ".ai/policies/weight.json", ".ai/policies/weight.md"
875
+ _WEIGHT = None
876
+
877
+
878
+ def weight_module():
879
+ """skills/ai-task/weight.py, the renderer behind `state.py weight --mirror`."""
880
+ global _WEIGHT # pylint: disable=global-statement # loaded once per run
881
+ if _WEIGHT is None:
882
+ spec = importlib.util.spec_from_file_location("riskrails_weight", os.path.join(SKILLS, "ai-task", "weight.py"))
883
+ _WEIGHT = importlib.util.module_from_spec(spec)
884
+ spec.loader.exec_module(_WEIGHT)
885
+ return _WEIGHT
886
+
887
+
888
+ def render_weight(data):
889
+ """weight.md for the weight.json bytes `data`; the renderer reads a path."""
890
+ with tempfile.TemporaryDirectory() as tmp:
891
+ path = os.path.join(tmp, "weight.json")
892
+ with open(path, "wb") as fh:
893
+ fh.write(data)
894
+ return weight_module().mirror(path).encode("utf-8")
895
+
896
+
897
+ def weight_mirror(plan, history, theirs):
898
+ """weight.md is generated from weight.json. A mirror that is exactly the render of
899
+ the project's JSON on disk is rendered again from the JSON this run leaves behind:
900
+ merged as text with the template's, whose sha256 line names the template's JSON, it
901
+ was a conflict on every update. A mirror edited by hand, or stale, merges as before."""
902
+ ours = plan.read(WEIGHT_MD)
903
+ path = os.path.join(plan.root, WEIGHT_JSON)
904
+ disk = read(path) if os.path.isfile(path) and not os.path.islink(path) else None
905
+ after = plan.read(WEIGHT_JSON)
906
+ fresh = None
907
+ rendering_raised = False
908
+ if ours is not None and disk is not None and after is not None:
909
+ try:
910
+ if ours == render_weight(disk):
911
+ fresh = render_weight(after)
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__))
920
+ if fresh is None:
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)
926
+ elif fresh != ours:
927
+ plan.add("update", WEIGHT_MD, "rendered from weight.json", content=fresh)
928
+
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
+
768
945
  def three_way(plan, history, key, target, theirs):
769
946
  ours = plan.read(target)
770
947
  if ours is None:
771
948
  plan.add("create", target, content=theirs)
772
949
  return
773
- is_mirror = target.endswith(".ai/policies/risk-tiers.md")
774
- norm = (lambda b: HASH_RE.sub(b"sha256:" + b"0" * 64, b)) if is_mirror else (lambda b: b)
775
- if norm(ours) == norm(theirs):
950
+ if ours == theirs:
776
951
  return
777
- versions = [v for v in history.versions(key) if norm(v) != norm(theirs)]
778
- if any(norm(v) == norm(ours) for v in versions):
952
+ versions = [v for v in history.versions(key) if v != theirs]
953
+ if any(v == ours for v in versions):
779
954
  policy = None
780
955
  if target.endswith(".json") and "/policies/" in "/" + target:
781
956
  try:
782
957
  policy = leaf_changes(json.loads(ours, object_pairs_hook=OrderedDict),
783
958
  json.loads(theirs, object_pairs_hook=OrderedDict))
784
- except ValueError:
959
+ except (ValueError, RecursionError): # not JSON, or too deep to read or flatten: no policy lines
785
960
  policy = None
786
961
  plan.add("update", target, "unchanged since it was installed",
787
- content=fix_mirror(plan, target, theirs, ours), policy=policy)
962
+ content=theirs, policy=policy)
788
963
  return
789
964
 
790
965
  if target.endswith(".json"):
@@ -795,38 +970,39 @@ def three_way(plan, history, key, target, theirs):
795
970
  except ValueError:
796
971
  plan.add("conflict", target, "not valid JSON; not merged", conflict_copy=theirs)
797
972
  return
798
- base = closest(bases, o, json_score) if bases else MISSING
799
- conflicts = []
800
- merged = merge_json(base, o, t, [], conflicts)
801
- 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
802
985
  if merged == o and not conflicts:
803
986
  return
804
987
  if merged != o:
805
- 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)
806
989
  if conflicts:
807
990
  plan.add("conflict", target, "kept your value for: " + ", ".join(conflicts), conflict_copy=theirs)
808
- model_keys = [c for c in conflicts if c.split(".")[0] == "model_tiers" or ".review_model" in "." + c]
809
- if target.endswith("policies/risk-tiers.json") and model_keys:
810
- hint = ("%s: %s — the plugin now names tiers (FAST/BALANCED/STRONG/EXPERT), not models; "
811
- "the model of a tier comes from the installed plan: state.py profile --tier <TIER>"
812
- % (target, ", ".join(model_keys)))
813
- if hint not in plan.hints:
814
- plan.hints.append(hint)
815
991
  return
816
992
 
817
993
  if target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(ours)) == 1:
818
994
  router_merge(plan, ours, versions, theirs)
819
995
  return
820
- base = closest(versions, norm(ours), lambda b, o: text_ratio(norm(b), o)) if versions else None
821
- merged = merge_text(norm(ours), norm(base), norm(theirs)) if base is not None else None
996
+ base = closest(versions, ours, text_ratio) if versions else None
997
+ merged = merge_text(ours, base, theirs) if base is not None else None
822
998
  if merged is not None and target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(merged)) != 1:
823
999
  plan.add("conflict", target, "the merge would not name the shared files exactly once "
824
1000
  "(## Shared files)", conflict_copy=theirs)
825
1001
  return
826
1002
  if merged is None:
827
1003
  plan.add("conflict", target, "edited here and changed in the plugin", conflict_copy=theirs)
828
- elif merged != norm(ours):
829
- plan.add("merge", target, "your edits kept", content=fix_mirror(plan, target, merged, ours))
1004
+ elif merged != ours:
1005
+ plan.add("merge", target, "your edits kept", content=merged)
830
1006
 
831
1007
 
832
1008
  def split_shared_section(text):
@@ -838,8 +1014,9 @@ def split_shared_section(text):
838
1014
  return None
839
1015
  start = found[0].start()
840
1016
  nxt = re.compile(rb"(?m)^## ").search(text, found[0].end())
841
- if nxt is None:
842
- return text[:start], text[start:], b""
1017
+ if nxt is None: # at the end: the blank line before it belongs to the section
1018
+ head = text[:start]
1019
+ return (head[:-1] if head.endswith(b"\n\n") else head), text[start:], b""
843
1020
  end = nxt.start()
844
1021
  eol = text.find(b"\n", end)
845
1022
  follow = text[end:eol + 1] if eol >= 0 else text[end:]
@@ -863,7 +1040,9 @@ def router_merge(plan, ours, versions, theirs):
863
1040
  if o_rest == t_rest or (base is not None and base == t_rest):
864
1041
  return
865
1042
  merged = merge_text(o_rest, base, t_rest) if base is not None else None
866
- if merged is not None:
1043
+ if merged is not None and not follow: # the project had it at the end: it stays there
1044
+ merged = merged + (b"" if merged.endswith(b"\n") else b"\n") + b"\n" + o_sec
1045
+ elif merged is not None:
867
1046
  merged = put_section(merged, o_sec, [follow, t_split[2] if t_split else b""])
868
1047
  if merged is None or len(SHARED_SECTION_RE.findall(merged)) != 1:
869
1048
  plan.add("conflict", ROUTER_FILE, "edited here and changed in the plugin", conflict_copy=theirs)
@@ -886,27 +1065,6 @@ def put_section(text, section, follows):
886
1065
  return text + (b"" if text.endswith(b"\n") else b"\n") + section
887
1066
 
888
1067
 
889
- def fix_mirror(plan, target, content, ours):
890
- """risk-tiers.md carries the sha256 of risk-tiers.json. Carry the project's sync
891
- state forward: a mirror that matched its JSON before this run points at the JSON
892
- this run leaves behind; a mirror that was already stale, or a JSON left with a
893
- conflict, keeps its old hash so /ai-status keeps warning."""
894
- if not target.endswith(".ai/policies/risk-tiers.md"):
895
- return content
896
- old = HASH_RE.search(ours)
897
- # The disk, not the plan: the question is whether the mirror matched the JSON
898
- # the project had before this run.
899
- json_target = target[:-3] + ".json"
900
- json_path = os.path.join(plan.root, json_target)
901
- before = read(json_path) if os.path.exists(json_path) else None
902
- in_sync = old is not None and before is not None and old.group(0) == b"sha256:" + sha(before).encode()
903
- conflicted = any(i["action"] == "conflict" and i["target"] == json_target for i in plan.items)
904
- after = plan.final.get(json_target, None if json_target in plan.removed else before)
905
- if in_sync and not conflicted and after is not None:
906
- return HASH_RE.sub(b"sha256:" + sha(after).encode(), content, count=1)
907
- return HASH_RE.sub(old.group(0), content, count=1) if old else content
908
-
909
-
910
1068
  def block_update(plan, history, runtime):
911
1069
  name, block_tpl, minimal_tpl = INSTRUCTION_FILE[runtime]
912
1070
  if name in plan.removed: # a migration moved this instruction file away
@@ -985,7 +1143,7 @@ def shared_update(plan, history, version):
985
1143
  edited.append(path)
986
1144
  elif not named:
987
1145
  ctx.hint("%s: unedited, but %s does not name the shared files (## Shared files), so the "
988
- "copy stays — restore that section, then run /project-update again"
1146
+ "copy stays — restore that section, then run /rr-setup --update again"
989
1147
  % (path, ROUTER_FILE))
990
1148
  else:
991
1149
  candidates.append(path)
@@ -1008,14 +1166,201 @@ def shared_update(plan, history, version):
1008
1166
  "adds nothing" % (len(edited), ", ".join(edited), SHARED))
1009
1167
 
1010
1168
 
1169
+ def retire_templates(plan, history, retired):
1170
+ """[(history key, target)] the plugin now supplies from its own tree. A
1171
+ missing copy stays missing; an unedited one is offered for deletion, same
1172
+ gate as a shared override; an edited one is a project override, named once."""
1173
+ edited = []
1174
+ for key, target in retired:
1175
+ data = plan.read(target)
1176
+ if data is None:
1177
+ continue
1178
+ known = history.hashes(key)
1179
+ if sha(data) not in known and sha(data.replace(b"\r\n", b"\n")) not in known:
1180
+ edited.append(target)
1181
+ continue
1182
+ why = "[sdlc] the plugin supplies it: skills/project-init/templates/" + key.split("/", 1)[1]
1183
+ if plan.confirm_delete:
1184
+ plan.add("delete", target, why, reason=why)
1185
+ plan.remove(target)
1186
+ else:
1187
+ plan.add("delete?", target, why, reason=why)
1188
+ if edited:
1189
+ plan.hints.append("%d file(s) override the plugin's SDLC template and no longer receive its "
1190
+ "changes: %s" % (len(edited), ", ".join(edited)))
1191
+
1192
+
1193
+ def task_journal(root, task_id):
1194
+ """(finished, abandoned, last event as a datetime) from the task's journal,
1195
+ or None when it holds no readable event. A task is finished when its journal
1196
+ has task_closed or gate_approved, or when logs/<id>/state.json records a
1197
+ result (so a torn task_closed line does not make a closed task look unclosed)."""
1198
+ closed = abandoned = False
1199
+ last = None
1200
+ try:
1201
+ with open(os.path.join(root, ".ai", "reports", task_id, "events.jsonl"), encoding="utf-8") as fh:
1202
+ lines = fh.read().splitlines()
1203
+ except (OSError, UnicodeDecodeError):
1204
+ return None
1205
+ for line in lines:
1206
+ try:
1207
+ ev = json.loads(line)
1208
+ when = datetime.strptime(ev["ts"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
1209
+ except (ValueError, KeyError, TypeError, RecursionError):
1210
+ continue
1211
+ if last is None or when > last:
1212
+ last = when
1213
+ if ev.get("event") == "task_closed":
1214
+ closed = True
1215
+ data = ev.get("data")
1216
+ abandoned = (isinstance(data, dict) and data.get("abandoned") is True) or ev.get("detail") == "abandoned"
1217
+ elif ev.get("event") == "gate_approved":
1218
+ closed = True
1219
+ try:
1220
+ with open(os.path.join(root, ".ai", "state", "logs", task_id, "state.json"), encoding="utf-8") as fh:
1221
+ result = json.load(fh).get("result")
1222
+ except (OSError, ValueError, AttributeError, UnicodeDecodeError, RecursionError):
1223
+ result = None
1224
+ if isinstance(result, str) and result:
1225
+ closed = True
1226
+ abandoned = abandoned or result == "abandoned"
1227
+ return None if last is None else (closed, abandoned, last)
1228
+
1229
+
1230
+ def cleanup_update(plan, now=None):
1231
+ """Old run logs and stale unclosed tasks, proposed for deletion and never
1232
+ deleted unasked (only with --confirm-cleanup). A closed task's `*.log` files
1233
+ go (its state.json stays); an abandoned task loses every file under
1234
+ .ai/reports/<id>/ and .ai/state/logs/<id>/ except that state.json; an unclosed
1235
+ one idle for STALE_DAYS loses every file in both. The task in flight is
1236
+ never touched. `now` is a datetime; RISKRAILS_NOW (ISO, Z) overrides it."""
1237
+ if now is None:
1238
+ try:
1239
+ now = datetime.strptime(env("NOW") or utc_now(), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
1240
+ except ValueError:
1241
+ print("project-update: RISKRAILS_NOW %r is not a UTC time like 2026-01-31T12:00:00Z" % env("NOW"),
1242
+ file=sys.stderr)
1243
+ raise SystemExit(2) from None
1244
+ flight = None
1245
+ for name in (STATE_FILE, LEGACY_STATE_FILE):
1246
+ raw = plan.read(name)
1247
+ if raw is not None:
1248
+ try:
1249
+ flight = json.loads(raw).get("task_id")
1250
+ except (ValueError, AttributeError, RecursionError):
1251
+ return # unreadable: a task may be in flight, so no task is proposed as stale
1252
+ break
1253
+ reports =os.path.join(plan.root, ".ai", "reports")
1254
+ logs = os.path.join(plan.root, ".ai", "state", "logs")
1255
+
1256
+ def files(base, rel, only_logs=False, keep=()):
1257
+ out = []
1258
+ for here, _dirs, names in os.walk(os.path.join(base, rel)):
1259
+ for n in names:
1260
+ full = os.path.join(here, n)
1261
+ if os.path.isfile(full) and not os.path.islink(full) and (not only_logs or n.endswith(".log")) \
1262
+ and n not in keep:
1263
+ out.append(os.path.relpath(full, plan.root).replace(os.sep, "/"))
1264
+ return out
1265
+
1266
+ for task_id in sorted(os.listdir(reports)) if os.path.isdir(reports) else []:
1267
+ journal = task_journal(plan.root, task_id)
1268
+ if journal is None or task_id == flight:
1269
+ continue
1270
+ closed, abandoned, last = journal
1271
+ if abandoned:
1272
+ why, targets = "[cleanup] abandoned task %s" % task_id, \
1273
+ files(reports, task_id) + files(logs, task_id, keep=("state.json",))
1274
+ elif not closed and now - last > timedelta(days=STALE_DAYS):
1275
+ why = "[cleanup] unclosed task %s, last event %s" % (task_id, last.strftime("%Y-%m-%d"))
1276
+ targets = files(reports, task_id) + files(logs, task_id)
1277
+ elif closed:
1278
+ why, targets = "[cleanup] log of closed task %s" % task_id, files(logs, task_id, only_logs=True)
1279
+ else:
1280
+ continue
1281
+ # .ai/reports/ is usually git-tracked; only .ai/state/ is git-ignored run state
1282
+ held = git_states(plan.root, ".ai/reports/" + task_id)
1283
+ skipped = set()
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
1292
+ note = why
1293
+ state = held(target) if target.startswith(".ai/reports/") else None
1294
+ if state == "tracked":
1295
+ note += "; tracked: git can restore a committed copy"
1296
+ elif state == "uncommitted":
1297
+ note += "; uncommitted: git holds no copy of it"
1298
+ elif state == "ignored":
1299
+ note += "; git-ignored: not recoverable through git"
1300
+ if state in ("tracked", "uncommitted"):
1301
+ plan.git_kept.add(target)
1302
+ if plan.confirm_cleanup:
1303
+ plan.add("delete", target, note, reason=why)
1304
+ plan.remove(target)
1305
+ else:
1306
+ plan.add("delete?", target, note, reason=why)
1307
+
1308
+
1011
1309
  def deletion_hint(plan):
1012
- """M4: git is the rollback of a deletion; it must hold .ai/ first."""
1013
- if any(i["action"] in ("delete?", "delete") and i["target"].startswith(".ai/") for i in plan.items) \
1310
+ """M4: git is the rollback of a migration's deletion; it must hold .ai/ first.
1311
+ A [cleanup] deletion of git-ignored run state is not counted: git does not roll it back;
1312
+ one under .ai/reports/ that git tracks, or should, is."""
1313
+ if any(i["action"] in ("delete?", "delete") and i["target"].startswith(".ai/")
1314
+ and (not (i.get("reason") or "").startswith("[cleanup]") or i["target"] in plan.git_kept)
1315
+ for i in plan.items) \
1014
1316
  and git_dirty_ai(plan.root):
1015
1317
  plan.hints.append("commit .ai/ first: git is this migration's rollback "
1016
1318
  "(`git status -- .ai` lists what it does not hold yet)")
1017
1319
 
1018
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
+
1019
1364
  def rendered_rules(plan):
1020
1365
  """Every (file, slug) in the project that carries a rule block today, plus
1021
1366
  every path-scoped copy under .claude/rules/. Walked rather than remembered:
@@ -1058,6 +1403,41 @@ def nested_tree(root, rel_dir):
1058
1403
  return None
1059
1404
 
1060
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
+
1061
1441
  def rules_update(plan, runtimes, planned=None):
1062
1442
  """Render `.ai/rules/<slug>.md` into the instruction file of every directory
1063
1443
  it names, and take back what a rule no longer asks for. The text around a
@@ -1094,6 +1474,14 @@ def rules_update(plan, runtimes, planned=None):
1094
1474
  plan.hints.append("%s: %s is a nested git tree (worktree, repository or submodule)"
1095
1475
  " — rule %s is not rendered there" % (target, tree, item["slug"]))
1096
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
1097
1485
  current = plan.read(target)
1098
1486
  if item["kind"] == "doc":
1099
1487
  new = content.encode()
@@ -1198,8 +1586,10 @@ def constitution_hint(plan):
1198
1586
  plan.hints.append(line.replace(path, rel))
1199
1587
 
1200
1588
 
1201
- def build_plan(root, confirm_delete=None, confirm_via=None):
1589
+ def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None, cleanup_via=None):
1202
1590
  plan = Plan(root)
1591
+ plan.confirm_cleanup = confirm_cleanup
1592
+ plan.cleanup_via = cleanup_via
1203
1593
  plan.confirm_delete = confirm_delete
1204
1594
  plan.confirm_via = confirm_via
1205
1595
  has_ai = os.path.isdir(os.path.join(root, ".ai"))
@@ -1231,7 +1621,7 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1231
1621
  if stuck:
1232
1622
  plan.held = version
1233
1623
  plan.hints.append("the schema stays at %d until %s — resolve a conflict by hand, confirm a "
1234
- "deletion with --apply --confirm-delete NAME, then run /project-update again"
1624
+ "deletion with --apply --confirm-delete NAME, then run /rr-setup --update again"
1235
1625
  % (version, ", ".join("%s (%s)" % (i["target"], i["action"]) for i in stuck)))
1236
1626
  else:
1237
1627
  # Last of the migration items: an apply that stops short leaves the
@@ -1243,18 +1633,22 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1243
1633
  if has_sdlc:
1244
1634
  tpl = TEMPLATES["project-init"]
1245
1635
  entries = list(PROJECT_INIT_MAP)
1636
+ retired = []
1246
1637
  for rt in runtimes:
1247
1638
  entries += RUNTIME_MAP[rt]
1248
1639
  for src, target, kind in entries:
1249
1640
  content = read(os.path.join(tpl, src))
1250
1641
  if target in plan.removed: # a migration moved it away in this run
1251
1642
  continue
1252
- if kind == "create":
1643
+ if kind == "retire":
1644
+ retired.append(("project-init/" + src, target))
1645
+ elif kind == "create":
1253
1646
  if not plan.exists(target):
1254
1647
  plan.add("create", target, content=content.replace(
1255
1648
  b"{{PROJECT}}", os.path.basename(root).encode()))
1256
1649
  else:
1257
1650
  three_way(plan, history, "project-init/" + src, target, content)
1651
+ retire_templates(plan, history, retired)
1258
1652
  gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
1259
1653
 
1260
1654
  if has_ai:
@@ -1263,7 +1657,7 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1263
1657
  for dirpath, _, names in os.walk(os.path.join(tpl, ".ai")):
1264
1658
  for n in names:
1265
1659
  files.append(os.path.relpath(os.path.join(dirpath, n), tpl))
1266
- # JSON before markdown, so the risk-tier mirror can point at the merged JSON.
1660
+ # JSON before markdown, so the weight mirror is rendered from the merged JSON.
1267
1661
  for rel in sorted(files, key=lambda r: (not r.endswith(".json"), r)):
1268
1662
  if rel == VERSION_FILE: # owned by the migration step, never merged
1269
1663
  continue
@@ -1276,13 +1670,18 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1276
1670
  if not plan.exists(rel):
1277
1671
  plan.add("create", rel, content=content)
1278
1672
  continue
1673
+ if rel == WEIGHT_MD:
1674
+ weight_mirror(plan, history, content)
1675
+ continue
1279
1676
  three_way(plan, history, "ai-init/" + rel, rel, content)
1280
1677
  shared_update(plan, history, version)
1678
+ cleanup_update(plan)
1281
1679
  deletion_hint(plan)
1282
1680
  for rt in runtimes:
1283
1681
  block_update(plan, history, rt)
1284
1682
  rules_update(plan, runtimes)
1285
1683
  gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
1684
+ tracked_ignored_hint(plan)
1286
1685
 
1287
1686
  testing = plan.read(".ai/policies/testing.md")
1288
1687
  if testing is not None:
@@ -1290,8 +1689,8 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1290
1689
  ("verify_command", "the feedback loop needs it"),
1291
1690
  ("step_test_command", "a step runs only its own tests"),
1292
1691
  ("e2e_command", "e2e runs once at the end of a task; write 'none' if there is no suite"),
1293
- ("lint_command", "a sensor reads it; run `sensors.py detect`, and write 'none' if there is none"),
1294
- ("typecheck_command", "a sensor reads it; run `sensors.py detect`, and write 'none' if there is none"),
1692
+ ("lint_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
1693
+ ("typecheck_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
1295
1694
  ):
1296
1695
  if re.search(rb"(?m)^" + field.encode() + rb":[ \t]*(#.*)?$", testing):
1297
1696
  plan.hints.append(".ai/policies/testing.md: %s is empty — %s" % (field, why))
@@ -1383,7 +1782,7 @@ def record_migration(plan, ops, deletions):
1383
1782
  if os.path.isfile(full):
1384
1783
  try:
1385
1784
  existing = json.loads(read(full), object_pairs_hook=OrderedDict)
1386
- except ValueError:
1785
+ except (ValueError, RecursionError): # too deep reads as malformed
1387
1786
  existing = {}
1388
1787
  if not isinstance(existing, dict): # hand-edited into another shape
1389
1788
  existing = {}
@@ -1432,16 +1831,25 @@ def apply(plan):
1432
1831
 
1433
1832
  def apply_item(plan, item, written, recorded):
1434
1833
  target, action = item["target"], item["action"]
1834
+ cleanup = (item.get("reason") or "").startswith("[cleanup]")
1435
1835
  if action == "delete?": # listed only, until a human confirms it
1436
1836
  return
1437
1837
  mode = None
1438
1838
  if action in ("move", "delete"):
1439
1839
  source = item["src"] if action == "move" else target
1840
+ # the plan-time symlink check is repeated: a path swapped since is refused
1841
+ why = unsafe_path(plan.root, source)
1842
+ if why:
1843
+ raise Abort(item, why)
1440
1844
  check(plan, item, source, item["src_expect"] if action == "move" else item["expect"], written)
1441
1845
  if action == "move":
1442
1846
  check(plan, item, target, item["expect"], written)
1443
1847
  mode = os.stat(os.path.join(plan.root, source)).st_mode & 0o7777
1444
- if not source.startswith(".ai/state/"): # git-ignored task state: a move is byte-identical
1848
+ # git-ignored task state: a move is byte-identical; a [cleanup] deletion of it is NOT
1849
+ # recoverable through git (the logs are git-ignored), and copying what it removes
1850
+ # would defeat the cleanup, so no original/ copy is kept. A report file git tracks,
1851
+ # or should, is kept like any other deletion.
1852
+ if not source.startswith(".ai/state/") and (not cleanup or source in plan.git_kept):
1445
1853
  keep_original(plan, source)
1446
1854
  recorded.append(target)
1447
1855
  # Recorded before the file is gone: an abort later must not lose the note
@@ -1449,7 +1857,8 @@ def apply_item(plan, item, written, recorded):
1449
1857
  record_migration(plan,
1450
1858
  [dict(action=action, migration=item["migration"],
1451
1859
  **(dict(src=item["src"], dst=target) if action == "move" else dict(path=target)))],
1452
- [dict(path=target, confirmed_by=plan.confirm_delete, via=plan.confirm_via, at=utc_now())]
1860
+ [dict(path=target, confirmed_by=plan.confirm_cleanup if cleanup else plan.confirm_delete,
1861
+ via=plan.cleanup_via if cleanup else plan.confirm_via, at=utc_now())]
1453
1862
  if action == "delete" else [])
1454
1863
  elif item["content"] is not None:
1455
1864
  check(plan, item, target, item["expect"], written)
@@ -1469,10 +1878,25 @@ def apply_item(plan, item, written, recorded):
1469
1878
  elif action == "delete":
1470
1879
  os.remove(os.path.join(plan.root, target))
1471
1880
  written[target] = None
1881
+ if (item["reason"] or "").startswith("[cleanup]"):
1882
+ prune_empty(plan.root, target)
1472
1883
  if item["conflict_copy"] is not None:
1473
1884
  write_file(plan.root, os.path.join(plan.local_dir, target), item["conflict_copy"])
1474
1885
 
1475
1886
 
1887
+ def prune_empty(root, target):
1888
+ """The directories a cleanup deletion left empty, up to (not including)
1889
+ .ai/reports and .ai/state/logs; a directory with anything in it stays."""
1890
+ top = ".ai/state/logs" if target.startswith(".ai/state/logs/") else ".ai/reports"
1891
+ here = os.path.dirname(target)
1892
+ while here.startswith(top + "/"):
1893
+ try:
1894
+ os.rmdir(os.path.join(root, here))
1895
+ except OSError:
1896
+ return
1897
+ here = os.path.dirname(here)
1898
+
1899
+
1476
1900
  def plain_pending(root, ignore=()):
1477
1901
  """True when the plain --check would exit 1: an automatic item or a schema
1478
1902
  step is pending. Targets in `ignore` (an adopt resuming its own writes) do
@@ -1523,20 +1947,42 @@ def report(plan, applied):
1523
1947
  print(" up to date with the installed plugin")
1524
1948
  def shown(i):
1525
1949
  return "%s -> %s" % (i["src"], i["target"]) if i["src"] else i["target"]
1526
- width = max([len(shown(i)) for i in plan.items] + [10])
1950
+ # a cleanup is one line per directory, however many files it holds
1951
+ rows, groups = [], {}
1952
+ for i in plan.items:
1953
+ if (i["reason"] or "").startswith("[cleanup]"):
1954
+ parts = i["target"].split("/")
1955
+ where = "/".join(parts[:4] if i["target"].startswith(".ai/state/logs/") else parts[:3])
1956
+ key = (where, i["action"], i["note"])
1957
+ if key in groups:
1958
+ groups[key]["count"] += 1
1959
+ continue
1960
+ row = dict(i, migration=None, src=None, count=1)
1961
+ groups[key] = row
1962
+ row["target"] = where
1963
+ i = row
1964
+ rows.append(i)
1965
+ for (where, _action, _note), row in groups.items():
1966
+ row["target"] = "%s/ (%d file%s)" % (where, row["count"], "" if row["count"] == 1 else "s")
1967
+ width = max([len(shown(i)) for i in rows] + [10])
1527
1968
  if plan.schema:
1528
1969
  frm, to, mods = plan.schema
1529
1970
  print(" %-9s %-*s (%d migration%s: %s)" % (
1530
1971
  "schema", width, "%d -> %d" % (frm, to), len(mods), "" if len(mods) == 1 else "s",
1531
1972
  ", ".join("%04d %s" % (m.VERSION, m.TITLE) for m in mods)))
1532
- for i in plan.items:
1973
+ for i in rows:
1533
1974
  note = i["note"]
1534
1975
  if i["migration"] is not None:
1535
1976
  note = "[%04d]%s" % (i["migration"], " " + note if note else "")
1536
1977
  if i.get("tool") and not note.startswith("[%s]" % i["tool"]):
1537
1978
  note = "[%s] %s" % (i["tool"], note)
1538
1979
  if i["action"] == "delete?":
1539
- note += "; needs --apply --confirm-delete NAME"
1980
+ if (i["reason"] or "").startswith("[cleanup]"):
1981
+ note += "; needs --apply --confirm-cleanup NAME"
1982
+ if i["target"].startswith(".ai/state/"):
1983
+ note += " (git-ignored: not recoverable through git)"
1984
+ else:
1985
+ note += "; needs --apply --confirm-delete NAME"
1540
1986
  if i["action"] == "conflict" and i["conflict_copy"] is not None:
1541
1987
  note += "; plugin version %s %s/%s" % ("at" if applied else "will be at", plan.local_dir, i["target"])
1542
1988
  print(" %-9s %-*s %s" % (i["action"], width, shown(i), note))
@@ -1547,8 +1993,12 @@ def report(plan, applied):
1547
1993
  tail = ""
1548
1994
  if any(i["policy"] for i in auto):
1549
1995
  tail += ", %d policy change(s) to confirm" % sum(len(i["policy"] or []) for i in auto)
1550
- if deletions:
1551
- 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)
1552
2002
  print("%d automatic, %d conflict(s)%s" % (len(auto), len(conflicts), tail))
1553
2003
 
1554
2004
 
@@ -1576,30 +2026,42 @@ def main():
1576
2026
  ap.add_argument("--confirm-delete", metavar="NAME",
1577
2027
  help="a human confirms this run's proposed deletions, and is recorded in "
1578
2028
  "the migration report; only with --apply")
2029
+ ap.add_argument("--confirm-cleanup", metavar="NAME",
2030
+ help="a human confirms this run's [cleanup] deletions (run logs and stale task files, "
2031
+ "git-ignored and not recoverable through git); only with --apply. "
2032
+ "--confirm-delete does not settle them")
1579
2033
  args = ap.parse_args()
1580
- confirm_via = None
1581
- if args.confirm_delete is not None:
2034
+ confirm_via = cleanup_via = None
2035
+ for flag, name in (("--confirm-delete", args.confirm_delete), ("--confirm-cleanup", args.confirm_cleanup)):
2036
+ if name is None:
2037
+ continue
1582
2038
  if not args.apply:
1583
- ap.error("--confirm-delete only makes sense with --apply")
1584
- if not args.confirm_delete.strip():
1585
- ap.error("--confirm-delete needs the name of the human who confirmed the deletion")
1586
- if adopt.placeholder_name(args.confirm_delete):
1587
- ap.error("--confirm-delete got the placeholder %r; type your own name, it is the audit trail"
1588
- % args.confirm_delete.strip())
1589
- passed, confirm_via, why = adopt.human_gate()
2039
+ ap.error("%s only makes sense with --apply" % flag)
2040
+ if not name.strip():
2041
+ ap.error("%s needs the name of the human who confirmed the deletion" % flag)
2042
+ if adopt.placeholder_name(name):
2043
+ ap.error("%s got the placeholder %r; type your own name, it is the audit trail"
2044
+ % (flag, name.strip()))
2045
+ passed, via, why = adopt.human_gate()
1590
2046
  if not passed:
1591
2047
  # Under an agent-session marker the AI_UNATTENDED route is the launcher's
1592
2048
  # to declare, not the agent's to type: the hint names it only otherwise.
1593
2049
  hint = "run the same command yourself in a terminal outside Claude Code or Codex"
1594
2050
  if "agent session" not in why:
1595
2051
  hint += ", or set AI_UNATTENDED=1 if a launcher runs it unattended on your behalf"
1596
- return adopt.refuse("--confirm-delete is typed by a human, and %s" % why, hint)
2052
+ return adopt.refuse("%s is typed by a human, and %s" % (flag, why), hint)
2053
+ if flag == "--confirm-delete":
2054
+ confirm_via = via
2055
+ else:
2056
+ cleanup_via = via
1597
2057
  if args.budget and not args.check:
1598
2058
  ap.error("--budget only makes sense with --check")
1599
2059
  if not args.adopt and (args.tool or args.mode != "migrate" or args.split_request or args.split or args.diff
1600
2060
  or args.cleanup):
1601
2061
  ap.error("--mode, --tool, --split, --split-request, --diff and --cleanup only make sense with --adopt")
1602
2062
  root = os.path.abspath(args.root)
2063
+ if args.adopt and args.confirm_cleanup is not None:
2064
+ ap.error("--confirm-cleanup does not apply to --adopt")
1603
2065
  if args.adopt:
1604
2066
  if args.confirm_delete is not None and not args.cleanup:
1605
2067
  ap.error("--adopt --confirm-delete belongs to --cleanup")
@@ -1632,13 +2094,14 @@ def main():
1632
2094
  print("project-update: %s templates not found at %s (run riskrails/install.sh)" % (name, tpl), file=sys.stderr)
1633
2095
  return 2
1634
2096
  try:
1635
- plan = build_plan(root, confirm_delete=getattr(args, "confirm_delete", None), confirm_via=confirm_via)
2097
+ plan = build_plan(root, confirm_delete=getattr(args, "confirm_delete", None), confirm_via=confirm_via,
2098
+ confirm_cleanup=args.confirm_cleanup, cleanup_via=cleanup_via)
1636
2099
  except SchemaError as exc:
1637
2100
  # --check is read by /ai-status, which reads stdout: it must see the reason.
1638
2101
  print("project-update: %s" % exc, file=sys.stdout if args.check else sys.stderr)
1639
2102
  return 2
1640
2103
  if plan is None:
1641
- print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /ai-init or /project-init first" % root,
2104
+ print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /rr-setup first" % root,
1642
2105
  file=sys.stderr)
1643
2106
  return 2
1644
2107
  if not args.check:
@@ -1665,7 +2128,7 @@ def main():
1665
2128
  bits.append("%d deletion(s) a human must confirm" % deletions)
1666
2129
  if plan.held is not None:
1667
2130
  bits.append("the schema stays at %d until they are settled" % plan.held)
1668
- print("project is behind the installed plugin: %s — run /project-update" % ", ".join(bits))
2131
+ print("project is behind the installed plugin: %s — run /rr-setup --update" % ", ".join(bits))
1669
2132
  return 1
1670
2133
  print("project matches the installed plugin" + (" (%d file(s) differ by hand-merge choice)" % conflicts if conflicts else ""))
1671
2134
  return 0