riskrails 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) 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 +28 -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 +5 -5
  10. package/hooks/context-guard.py +1 -1
  11. package/hooks/guard/common.py +2 -0
  12. package/hooks/guard/paths.py +41 -30
  13. package/hooks/project-scaffold.py +1 -5
  14. package/hooks/runtime-gate.py +6 -6
  15. package/hooks/update_notice.py +20 -14
  16. package/instructions/routing.md +1 -1
  17. package/instructions/stub.md +3 -3
  18. package/package.json +1 -1
  19. package/skills/AGENTS.md +1 -1
  20. package/skills/CLAUDE.md +1 -1
  21. package/skills/ai-init/templates/.ai/AGENTS.md +1 -1
  22. package/skills/ai-init/templates/.ai/VERSION +1 -1
  23. package/skills/ai-init/templates/.ai/policies/security.md +7 -1
  24. package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
  25. package/skills/ai-init/templates/AGENTS.block.md +2 -2
  26. package/skills/ai-init/templates/CLAUDE.block.md +2 -2
  27. package/skills/ai-init/templates/GEMINI.block.md +2 -2
  28. package/skills/ai-init/templates/gitignore.snippet +1 -0
  29. package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
  30. package/skills/ai-task/sensors.py +24 -4
  31. package/skills/ai-task/state.py +37 -14
  32. package/skills/project-init/templates/constitution.md +1 -1
  33. package/skills/project-init/templates/sdlc-README.md +20 -14
  34. package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
  35. package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
  36. package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
  37. package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
  38. package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
  39. package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
  40. package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
  41. package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
  42. package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
  43. package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
  44. package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
  45. package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
  46. package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
  47. package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
  48. package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
  49. package/skills/project-update/history/index.json +36 -15
  50. package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
  51. package/skills/project-update/migrations/__init__.py +6 -1
  52. package/skills/project-update/render_instructions.py +1 -1
  53. package/skills/project-update/update.py +374 -87
  54. package/skills/rr-build-all/SKILL.md +2 -2
  55. package/skills/rr-plan/SKILL.md +3 -1
  56. package/skills/rr-review/SKILL.md +13 -8
  57. package/skills/rr-setup/SKILL.md +15 -2
  58. package/skills/rr-setup/setup.py +7 -1
  59. package/skills/rr-spec/SKILL.md +4 -2
  60. package/skills/usage-report/usage-report.py +4 -2
  61. package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
  62. 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
51
  import argparse, contextlib, copy, difflib, hashlib, importlib.util, io, json, os, re, 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
 
@@ -119,14 +125,14 @@ def renamed_markers(block):
119
125
  find_block = render_instructions.find_block
120
126
  current_markers = render_instructions.current_markers
121
127
  MISSING = object()
122
- HASH_RE = re.compile(rb"sha256:[0-9a-f]{64}")
123
128
 
124
129
  PROJECT_INIT_MAP = [ # template -> target, kind
125
130
  ("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"),
131
+ # read in place by /rr-spec and /rr-plan; a project copy is an override: never created
132
+ ("intent.md", "docs/sdlc/intent/TEMPLATE.md", "retire"),
133
+ ("spec.md", "docs/sdlc/specs/TEMPLATE.md", "retire"),
134
+ ("plan.md", "docs/sdlc/plans/TEMPLATE.md", "retire"),
135
+ ("adr.md", "docs/sdlc/adr/TEMPLATE.md", "retire"),
130
136
  # the project's own principles: created once, then it is the project's file
131
137
  ("constitution.md", "docs/sdlc/constitution.md", "create"),
132
138
  ]
@@ -302,6 +308,53 @@ def git_dirty_ai(root):
302
308
  return res.returncode == 0 and bool(res.stdout.strip())
303
309
 
304
310
 
311
+ def git_states(root, rel_dir):
312
+ """A function path -> how git holds a file under `rel_dir` (project-relative):
313
+ "tracked" (committed and unchanged), "uncommitted" (changed, staged or not yet
314
+ tracked, so git has no copy to restore) or "ignored"; None outside a work tree
315
+ or for a path git does not know."""
316
+ def run(*args):
317
+ res = subprocess.run(["git", "-C", root] + list(args), stdout=subprocess.PIPE,
318
+ stderr=subprocess.DEVNULL, timeout=30)
319
+ return res.stdout.decode("utf-8", "replace") if res.returncode == 0 else None
320
+ try:
321
+ prefix = run("rev-parse", "--show-prefix")
322
+ tracked = run("ls-files", "-z", "--", rel_dir)
323
+ status = run("status", "--porcelain=v1", "-z", "--ignored", "-uall", "--", rel_dir)
324
+ except (OSError, subprocess.SubprocessError):
325
+ return lambda path: None
326
+ if prefix is None or tracked is None or status is None:
327
+ return lambda path: None
328
+ prefix = prefix.strip()
329
+ tracked = set(tracked.split("\0"))
330
+ dirty, ignored = set(), []
331
+ entries = status.split("\0")
332
+ skip = False
333
+ for entry in entries:
334
+ if skip: # the origin path of a rename
335
+ skip = False
336
+ continue
337
+ if len(entry) < 4:
338
+ continue
339
+ xy, path = entry[:2], entry[3:]
340
+ if prefix and path.startswith(prefix):
341
+ path = path[len(prefix):]
342
+ if "R" in xy or "C" in xy:
343
+ skip = True
344
+ if xy == "!!":
345
+ ignored.append(path)
346
+ else:
347
+ dirty.add(path)
348
+
349
+ def classify(path):
350
+ if path in dirty:
351
+ return "uncommitted"
352
+ if any(path == i or (i.endswith("/") and path.startswith(i)) for i in ignored):
353
+ return "ignored"
354
+ return "tracked" if path in tracked else None
355
+ return classify
356
+
357
+
305
358
  # ---------------------------------------------------------------- merging
306
359
  def closest(candidates, ours, score):
307
360
  best, best_score = None, -1.0
@@ -407,6 +460,22 @@ def detect_version(plan):
407
460
  return int(text)
408
461
 
409
462
 
463
+ def unsafe_path(root, path):
464
+ """Why a project-relative path must not be deleted: a symlink on the way to it,
465
+ or a real path outside the project; None when it is safe. Run at plan time and
466
+ again right before the delete."""
467
+ real_root = os.path.realpath(root)
468
+ here = root
469
+ for part in path.split("/"):
470
+ here = os.path.join(here, part)
471
+ if os.path.islink(here):
472
+ return "%s is a symlink" % os.path.relpath(here, root).replace(os.sep, "/")
473
+ real = os.path.realpath(os.path.join(root, path))
474
+ if real != real_root and not real.startswith(real_root + os.sep):
475
+ return "%s resolves outside the project (%s)" % (path, real)
476
+ return None
477
+
478
+
410
479
  class MigrationContext:
411
480
  """What a migration sees: the project as this run leaves it, not the disk."""
412
481
 
@@ -523,16 +592,7 @@ class MigrationContext:
523
592
  """Why a file must not be deleted through this path, or None: it is a
524
593
  symlink, it is reached through one, or it resolves outside the project."""
525
594
  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
595
+ return unsafe_path(self.root, path)
536
596
 
537
597
  def override_note(self, path):
538
598
  """What an edited copy of a shared file now means."""
@@ -710,11 +770,14 @@ class Plan:
710
770
  self.schema = None # (from, to, [migration modules]) when migrations are pending
711
771
  self.confirm_delete = None # the human who confirmed this run's deletions
712
772
  self.confirm_via = None # why that gate passed: "terminal" or "unattended"
773
+ self.confirm_cleanup = None # the human who confirmed this run's [cleanup] deletions
774
+ self.cleanup_via = None
713
775
  self.held = None # the schema version kept until a migration item is settled
714
776
  self.final = {} # target -> bytes after this run (for cross-file fixes)
715
777
  self.removed = set() # targets this run takes away
716
778
  self.seen = {} # target -> sha of what was on disk when it was first read,
717
779
  # or None when nothing was there: apply's precondition
780
+ self.git_kept = set() # [cleanup] targets git holds or should hold: a hint and an original/ copy
718
781
  self.report_dir = ".ai/reports/project-update-%s" % datetime.now(timezone.utc).strftime("%Y-%m-%d")
719
782
 
720
783
  # The project as this run leaves it: planned content first, then the disk.
@@ -765,17 +828,60 @@ class Plan:
765
828
  return base
766
829
 
767
830
 
831
+ WEIGHT_JSON, WEIGHT_MD = ".ai/policies/weight.json", ".ai/policies/weight.md"
832
+ _WEIGHT = None
833
+
834
+
835
+ def weight_module():
836
+ """skills/ai-task/weight.py, the renderer behind `state.py weight --mirror`."""
837
+ global _WEIGHT # pylint: disable=global-statement # loaded once per run
838
+ if _WEIGHT is None:
839
+ spec = importlib.util.spec_from_file_location("riskrails_weight", os.path.join(SKILLS, "ai-task", "weight.py"))
840
+ _WEIGHT = importlib.util.module_from_spec(spec)
841
+ spec.loader.exec_module(_WEIGHT)
842
+ return _WEIGHT
843
+
844
+
845
+ def render_weight(data):
846
+ """weight.md for the weight.json bytes `data`; the renderer reads a path."""
847
+ with tempfile.TemporaryDirectory() as tmp:
848
+ path = os.path.join(tmp, "weight.json")
849
+ with open(path, "wb") as fh:
850
+ fh.write(data)
851
+ return weight_module().mirror(path).encode("utf-8")
852
+
853
+
854
+ def weight_mirror(plan, history, theirs):
855
+ """weight.md is generated from weight.json. A mirror that is exactly the render of
856
+ the project's JSON on disk is rendered again from the JSON this run leaves behind:
857
+ merged as text with the template's, whose sha256 line names the template's JSON, it
858
+ was a conflict on every update. A mirror edited by hand, or stale, merges as before."""
859
+ ours = plan.read(WEIGHT_MD)
860
+ path = os.path.join(plan.root, WEIGHT_JSON)
861
+ disk = read(path) if os.path.isfile(path) and not os.path.islink(path) else None
862
+ after = plan.read(WEIGHT_JSON)
863
+ fresh = None
864
+ if ours is not None and disk is not None and after is not None:
865
+ try:
866
+ if ours == render_weight(disk):
867
+ fresh = render_weight(after)
868
+ except (ValueError, TypeError, AttributeError, KeyError):
869
+ fresh = None # not a table the renderer reads: the text merge decides
870
+ if fresh is None:
871
+ three_way(plan, history, "ai-init/" + WEIGHT_MD, WEIGHT_MD, theirs)
872
+ elif fresh != ours:
873
+ plan.add("update", WEIGHT_MD, "rendered from weight.json", content=fresh)
874
+
875
+
768
876
  def three_way(plan, history, key, target, theirs):
769
877
  ours = plan.read(target)
770
878
  if ours is None:
771
879
  plan.add("create", target, content=theirs)
772
880
  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):
881
+ if ours == theirs:
776
882
  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):
883
+ versions = [v for v in history.versions(key) if v != theirs]
884
+ if any(v == ours for v in versions):
779
885
  policy = None
780
886
  if target.endswith(".json") and "/policies/" in "/" + target:
781
887
  try:
@@ -784,7 +890,7 @@ def three_way(plan, history, key, target, theirs):
784
890
  except ValueError:
785
891
  policy = None
786
892
  plan.add("update", target, "unchanged since it was installed",
787
- content=fix_mirror(plan, target, theirs, ours), policy=policy)
893
+ content=theirs, policy=policy)
788
894
  return
789
895
 
790
896
  if target.endswith(".json"):
@@ -805,28 +911,21 @@ def three_way(plan, history, key, target, theirs):
805
911
  plan.add("merge", target, "your edits kept", content=dump_json(merged), policy=policy)
806
912
  if conflicts:
807
913
  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
914
  return
816
915
 
817
916
  if target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(ours)) == 1:
818
917
  router_merge(plan, ours, versions, theirs)
819
918
  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
919
+ base = closest(versions, ours, text_ratio) if versions else None
920
+ merged = merge_text(ours, base, theirs) if base is not None else None
822
921
  if merged is not None and target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(merged)) != 1:
823
922
  plan.add("conflict", target, "the merge would not name the shared files exactly once "
824
923
  "(## Shared files)", conflict_copy=theirs)
825
924
  return
826
925
  if merged is None:
827
926
  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))
927
+ elif merged != ours:
928
+ plan.add("merge", target, "your edits kept", content=merged)
830
929
 
831
930
 
832
931
  def split_shared_section(text):
@@ -838,8 +937,9 @@ def split_shared_section(text):
838
937
  return None
839
938
  start = found[0].start()
840
939
  nxt = re.compile(rb"(?m)^## ").search(text, found[0].end())
841
- if nxt is None:
842
- return text[:start], text[start:], b""
940
+ if nxt is None: # at the end: the blank line before it belongs to the section
941
+ head = text[:start]
942
+ return (head[:-1] if head.endswith(b"\n\n") else head), text[start:], b""
843
943
  end = nxt.start()
844
944
  eol = text.find(b"\n", end)
845
945
  follow = text[end:eol + 1] if eol >= 0 else text[end:]
@@ -863,7 +963,9 @@ def router_merge(plan, ours, versions, theirs):
863
963
  if o_rest == t_rest or (base is not None and base == t_rest):
864
964
  return
865
965
  merged = merge_text(o_rest, base, t_rest) if base is not None else None
866
- if merged is not None:
966
+ if merged is not None and not follow: # the project had it at the end: it stays there
967
+ merged = merged + (b"" if merged.endswith(b"\n") else b"\n") + b"\n" + o_sec
968
+ elif merged is not None:
867
969
  merged = put_section(merged, o_sec, [follow, t_split[2] if t_split else b""])
868
970
  if merged is None or len(SHARED_SECTION_RE.findall(merged)) != 1:
869
971
  plan.add("conflict", ROUTER_FILE, "edited here and changed in the plugin", conflict_copy=theirs)
@@ -886,27 +988,6 @@ def put_section(text, section, follows):
886
988
  return text + (b"" if text.endswith(b"\n") else b"\n") + section
887
989
 
888
990
 
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
991
  def block_update(plan, history, runtime):
911
992
  name, block_tpl, minimal_tpl = INSTRUCTION_FILE[runtime]
912
993
  if name in plan.removed: # a migration moved this instruction file away
@@ -985,7 +1066,7 @@ def shared_update(plan, history, version):
985
1066
  edited.append(path)
986
1067
  elif not named:
987
1068
  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"
1069
+ "copy stays — restore that section, then run /rr-setup --update again"
989
1070
  % (path, ROUTER_FILE))
990
1071
  else:
991
1072
  candidates.append(path)
@@ -1008,9 +1089,145 @@ def shared_update(plan, history, version):
1008
1089
  "adds nothing" % (len(edited), ", ".join(edited), SHARED))
1009
1090
 
1010
1091
 
1092
+ def retire_templates(plan, history, retired):
1093
+ """[(history key, target)] the plugin now supplies from its own tree. A
1094
+ missing copy stays missing; an unedited one is offered for deletion, same
1095
+ gate as a shared override; an edited one is a project override, named once."""
1096
+ edited = []
1097
+ for key, target in retired:
1098
+ data = plan.read(target)
1099
+ if data is None:
1100
+ continue
1101
+ known = history.hashes(key)
1102
+ if sha(data) not in known and sha(data.replace(b"\r\n", b"\n")) not in known:
1103
+ edited.append(target)
1104
+ continue
1105
+ why = "[sdlc] the plugin supplies it: skills/project-init/templates/" + key.split("/", 1)[1]
1106
+ if plan.confirm_delete:
1107
+ plan.add("delete", target, why, reason=why)
1108
+ plan.remove(target)
1109
+ else:
1110
+ plan.add("delete?", target, why, reason=why)
1111
+ if edited:
1112
+ plan.hints.append("%d file(s) override the plugin's SDLC template and no longer receive its "
1113
+ "changes: %s" % (len(edited), ", ".join(edited)))
1114
+
1115
+
1116
+ def task_journal(root, task_id):
1117
+ """(finished, abandoned, last event as a datetime) from the task's journal,
1118
+ or None when it holds no readable event. A task is finished when its journal
1119
+ has task_closed or gate_approved, or when logs/<id>/state.json records a
1120
+ result (so a torn task_closed line does not make a closed task look unclosed)."""
1121
+ closed = abandoned = False
1122
+ last = None
1123
+ try:
1124
+ with open(os.path.join(root, ".ai", "reports", task_id, "events.jsonl"), encoding="utf-8") as fh:
1125
+ lines = fh.read().splitlines()
1126
+ except (OSError, UnicodeDecodeError):
1127
+ return None
1128
+ for line in lines:
1129
+ try:
1130
+ ev = json.loads(line)
1131
+ when = datetime.strptime(ev["ts"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
1132
+ except (ValueError, KeyError, TypeError):
1133
+ continue
1134
+ if last is None or when > last:
1135
+ last = when
1136
+ if ev.get("event") == "task_closed":
1137
+ closed = True
1138
+ data = ev.get("data")
1139
+ abandoned = (isinstance(data, dict) and data.get("abandoned") is True) or ev.get("detail") == "abandoned"
1140
+ elif ev.get("event") == "gate_approved":
1141
+ closed = True
1142
+ try:
1143
+ with open(os.path.join(root, ".ai", "state", "logs", task_id, "state.json"), encoding="utf-8") as fh:
1144
+ result = json.load(fh).get("result")
1145
+ except (OSError, ValueError, AttributeError, UnicodeDecodeError):
1146
+ result = None
1147
+ if isinstance(result, str) and result:
1148
+ closed = True
1149
+ abandoned = abandoned or result == "abandoned"
1150
+ return None if last is None else (closed, abandoned, last)
1151
+
1152
+
1153
+ def cleanup_update(plan, now=None):
1154
+ """Old run logs and stale unclosed tasks, proposed for deletion and never
1155
+ deleted unasked (only with --confirm-cleanup). A closed task's `*.log` files
1156
+ go (its state.json stays); an abandoned task loses every file under
1157
+ .ai/reports/<id>/ and .ai/state/logs/<id>/ except that state.json; an unclosed
1158
+ one idle for STALE_DAYS loses every file in both. The task in flight is
1159
+ never touched. `now` is a datetime; RISKRAILS_NOW (ISO, Z) overrides it."""
1160
+ if now is None:
1161
+ try:
1162
+ now = datetime.strptime(env("NOW") or utc_now(), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
1163
+ except ValueError:
1164
+ print("project-update: RISKRAILS_NOW %r is not a UTC time like 2026-01-31T12:00:00Z" % env("NOW"),
1165
+ file=sys.stderr)
1166
+ raise SystemExit(2)
1167
+ flight = None
1168
+ for name in (STATE_FILE, LEGACY_STATE_FILE):
1169
+ raw = plan.read(name)
1170
+ if raw is not None:
1171
+ try:
1172
+ flight = json.loads(raw).get("task_id")
1173
+ except (ValueError, AttributeError):
1174
+ return # unreadable: a task may be in flight, so no task is proposed as stale
1175
+ break
1176
+ reports =os.path.join(plan.root, ".ai", "reports")
1177
+ logs = os.path.join(plan.root, ".ai", "state", "logs")
1178
+
1179
+ def files(base, rel, only_logs=False, keep=()):
1180
+ out = []
1181
+ for here, _dirs, names in os.walk(os.path.join(base, rel)):
1182
+ for n in names:
1183
+ full = os.path.join(here, n)
1184
+ if os.path.isfile(full) and not os.path.islink(full) and (not only_logs or n.endswith(".log")) \
1185
+ and n not in keep:
1186
+ out.append(os.path.relpath(full, plan.root).replace(os.sep, "/"))
1187
+ return out
1188
+
1189
+ for task_id in sorted(os.listdir(reports)) if os.path.isdir(reports) else []:
1190
+ journal = task_journal(plan.root, task_id)
1191
+ if journal is None or task_id == flight:
1192
+ continue
1193
+ closed, abandoned, last = journal
1194
+ if abandoned:
1195
+ why, targets = "[cleanup] abandoned task %s" % task_id, \
1196
+ files(reports, task_id) + files(logs, task_id, keep=("state.json",))
1197
+ elif not closed and now - last > timedelta(days=STALE_DAYS):
1198
+ why = "[cleanup] unclosed task %s, last event %s" % (task_id, last.strftime("%Y-%m-%d"))
1199
+ targets = files(reports, task_id) + files(logs, task_id)
1200
+ elif closed:
1201
+ why, targets = "[cleanup] log of closed task %s" % task_id, files(logs, task_id, only_logs=True)
1202
+ else:
1203
+ continue
1204
+ # .ai/reports/ is usually git-tracked; only .ai/state/ is git-ignored run state
1205
+ held = git_states(plan.root, ".ai/reports/" + task_id)
1206
+ for target in sorted(targets):
1207
+ note = why
1208
+ state = held(target) if target.startswith(".ai/reports/") else None
1209
+ if state == "tracked":
1210
+ note += "; tracked: git can restore a committed copy"
1211
+ elif state == "uncommitted":
1212
+ note += "; uncommitted: git holds no copy of it"
1213
+ elif state == "ignored":
1214
+ note += "; git-ignored: not recoverable through git"
1215
+ if state in ("tracked", "uncommitted"):
1216
+ plan.git_kept.add(target)
1217
+ if plan.confirm_cleanup:
1218
+ plan.add("delete", target, note, reason=why)
1219
+ plan.remove(target)
1220
+ else:
1221
+ plan.add("delete?", target, note, reason=why)
1222
+
1223
+
1011
1224
  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) \
1225
+ """M4: git is the rollback of a migration's deletion; it must hold .ai/ first.
1226
+ A [cleanup] deletion of git-ignored run state is not counted: git does not roll it back;
1227
+ one under .ai/reports/ that git tracks, or should, is."""
1228
+ if any(i["action"] in ("delete?", "delete") and i["target"].startswith(".ai/")
1229
+ and (not (i.get("reason") or "").startswith("[cleanup]") or i["target"] in plan.git_kept)
1230
+ for i in plan.items) \
1014
1231
  and git_dirty_ai(plan.root):
1015
1232
  plan.hints.append("commit .ai/ first: git is this migration's rollback "
1016
1233
  "(`git status -- .ai` lists what it does not hold yet)")
@@ -1198,8 +1415,10 @@ def constitution_hint(plan):
1198
1415
  plan.hints.append(line.replace(path, rel))
1199
1416
 
1200
1417
 
1201
- def build_plan(root, confirm_delete=None, confirm_via=None):
1418
+ def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None, cleanup_via=None):
1202
1419
  plan = Plan(root)
1420
+ plan.confirm_cleanup = confirm_cleanup
1421
+ plan.cleanup_via = cleanup_via
1203
1422
  plan.confirm_delete = confirm_delete
1204
1423
  plan.confirm_via = confirm_via
1205
1424
  has_ai = os.path.isdir(os.path.join(root, ".ai"))
@@ -1231,7 +1450,7 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1231
1450
  if stuck:
1232
1451
  plan.held = version
1233
1452
  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"
1453
+ "deletion with --apply --confirm-delete NAME, then run /rr-setup --update again"
1235
1454
  % (version, ", ".join("%s (%s)" % (i["target"], i["action"]) for i in stuck)))
1236
1455
  else:
1237
1456
  # Last of the migration items: an apply that stops short leaves the
@@ -1243,18 +1462,22 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1243
1462
  if has_sdlc:
1244
1463
  tpl = TEMPLATES["project-init"]
1245
1464
  entries = list(PROJECT_INIT_MAP)
1465
+ retired = []
1246
1466
  for rt in runtimes:
1247
1467
  entries += RUNTIME_MAP[rt]
1248
1468
  for src, target, kind in entries:
1249
1469
  content = read(os.path.join(tpl, src))
1250
1470
  if target in plan.removed: # a migration moved it away in this run
1251
1471
  continue
1252
- if kind == "create":
1472
+ if kind == "retire":
1473
+ retired.append(("project-init/" + src, target))
1474
+ elif kind == "create":
1253
1475
  if not plan.exists(target):
1254
1476
  plan.add("create", target, content=content.replace(
1255
1477
  b"{{PROJECT}}", os.path.basename(root).encode()))
1256
1478
  else:
1257
1479
  three_way(plan, history, "project-init/" + src, target, content)
1480
+ retire_templates(plan, history, retired)
1258
1481
  gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
1259
1482
 
1260
1483
  if has_ai:
@@ -1263,7 +1486,7 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1263
1486
  for dirpath, _, names in os.walk(os.path.join(tpl, ".ai")):
1264
1487
  for n in names:
1265
1488
  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.
1489
+ # JSON before markdown, so the weight mirror is rendered from the merged JSON.
1267
1490
  for rel in sorted(files, key=lambda r: (not r.endswith(".json"), r)):
1268
1491
  if rel == VERSION_FILE: # owned by the migration step, never merged
1269
1492
  continue
@@ -1276,8 +1499,12 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1276
1499
  if not plan.exists(rel):
1277
1500
  plan.add("create", rel, content=content)
1278
1501
  continue
1502
+ if rel == WEIGHT_MD:
1503
+ weight_mirror(plan, history, content)
1504
+ continue
1279
1505
  three_way(plan, history, "ai-init/" + rel, rel, content)
1280
1506
  shared_update(plan, history, version)
1507
+ cleanup_update(plan)
1281
1508
  deletion_hint(plan)
1282
1509
  for rt in runtimes:
1283
1510
  block_update(plan, history, rt)
@@ -1290,8 +1517,8 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1290
1517
  ("verify_command", "the feedback loop needs it"),
1291
1518
  ("step_test_command", "a step runs only its own tests"),
1292
1519
  ("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"),
1520
+ ("lint_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
1521
+ ("typecheck_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
1295
1522
  ):
1296
1523
  if re.search(rb"(?m)^" + field.encode() + rb":[ \t]*(#.*)?$", testing):
1297
1524
  plan.hints.append(".ai/policies/testing.md: %s is empty — %s" % (field, why))
@@ -1432,16 +1659,25 @@ def apply(plan):
1432
1659
 
1433
1660
  def apply_item(plan, item, written, recorded):
1434
1661
  target, action = item["target"], item["action"]
1662
+ cleanup = (item.get("reason") or "").startswith("[cleanup]")
1435
1663
  if action == "delete?": # listed only, until a human confirms it
1436
1664
  return
1437
1665
  mode = None
1438
1666
  if action in ("move", "delete"):
1439
1667
  source = item["src"] if action == "move" else target
1668
+ # the plan-time symlink check is repeated: a path swapped since is refused
1669
+ why = unsafe_path(plan.root, source)
1670
+ if why:
1671
+ raise Abort(item, why)
1440
1672
  check(plan, item, source, item["src_expect"] if action == "move" else item["expect"], written)
1441
1673
  if action == "move":
1442
1674
  check(plan, item, target, item["expect"], written)
1443
1675
  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
1676
+ # git-ignored task state: a move is byte-identical; a [cleanup] deletion of it is NOT
1677
+ # recoverable through git (the logs are git-ignored), and copying what it removes
1678
+ # would defeat the cleanup, so no original/ copy is kept. A report file git tracks,
1679
+ # or should, is kept like any other deletion.
1680
+ if not source.startswith(".ai/state/") and (not cleanup or source in plan.git_kept):
1445
1681
  keep_original(plan, source)
1446
1682
  recorded.append(target)
1447
1683
  # Recorded before the file is gone: an abort later must not lose the note
@@ -1449,7 +1685,8 @@ def apply_item(plan, item, written, recorded):
1449
1685
  record_migration(plan,
1450
1686
  [dict(action=action, migration=item["migration"],
1451
1687
  **(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())]
1688
+ [dict(path=target, confirmed_by=plan.confirm_cleanup if cleanup else plan.confirm_delete,
1689
+ via=plan.cleanup_via if cleanup else plan.confirm_via, at=utc_now())]
1453
1690
  if action == "delete" else [])
1454
1691
  elif item["content"] is not None:
1455
1692
  check(plan, item, target, item["expect"], written)
@@ -1469,10 +1706,25 @@ def apply_item(plan, item, written, recorded):
1469
1706
  elif action == "delete":
1470
1707
  os.remove(os.path.join(plan.root, target))
1471
1708
  written[target] = None
1709
+ if (item["reason"] or "").startswith("[cleanup]"):
1710
+ prune_empty(plan.root, target)
1472
1711
  if item["conflict_copy"] is not None:
1473
1712
  write_file(plan.root, os.path.join(plan.local_dir, target), item["conflict_copy"])
1474
1713
 
1475
1714
 
1715
+ def prune_empty(root, target):
1716
+ """The directories a cleanup deletion left empty, up to (not including)
1717
+ .ai/reports and .ai/state/logs; a directory with anything in it stays."""
1718
+ top = ".ai/state/logs" if target.startswith(".ai/state/logs/") else ".ai/reports"
1719
+ here = os.path.dirname(target)
1720
+ while here.startswith(top + "/"):
1721
+ try:
1722
+ os.rmdir(os.path.join(root, here))
1723
+ except OSError:
1724
+ return
1725
+ here = os.path.dirname(here)
1726
+
1727
+
1476
1728
  def plain_pending(root, ignore=()):
1477
1729
  """True when the plain --check would exit 1: an automatic item or a schema
1478
1730
  step is pending. Targets in `ignore` (an adopt resuming its own writes) do
@@ -1523,20 +1775,42 @@ def report(plan, applied):
1523
1775
  print(" up to date with the installed plugin")
1524
1776
  def shown(i):
1525
1777
  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])
1778
+ # a cleanup is one line per directory, however many files it holds
1779
+ rows, groups = [], {}
1780
+ for i in plan.items:
1781
+ if (i["reason"] or "").startswith("[cleanup]"):
1782
+ parts = i["target"].split("/")
1783
+ where = "/".join(parts[:4] if i["target"].startswith(".ai/state/logs/") else parts[:3])
1784
+ key = (where, i["action"], i["note"])
1785
+ if key in groups:
1786
+ groups[key]["count"] += 1
1787
+ continue
1788
+ row = dict(i, migration=None, src=None, count=1)
1789
+ groups[key] = row
1790
+ row["target"] = where
1791
+ i = row
1792
+ rows.append(i)
1793
+ for (where, _action, _note), row in groups.items():
1794
+ row["target"] = "%s/ (%d file%s)" % (where, row["count"], "" if row["count"] == 1 else "s")
1795
+ width = max([len(shown(i)) for i in rows] + [10])
1527
1796
  if plan.schema:
1528
1797
  frm, to, mods = plan.schema
1529
1798
  print(" %-9s %-*s (%d migration%s: %s)" % (
1530
1799
  "schema", width, "%d -> %d" % (frm, to), len(mods), "" if len(mods) == 1 else "s",
1531
1800
  ", ".join("%04d %s" % (m.VERSION, m.TITLE) for m in mods)))
1532
- for i in plan.items:
1801
+ for i in rows:
1533
1802
  note = i["note"]
1534
1803
  if i["migration"] is not None:
1535
1804
  note = "[%04d]%s" % (i["migration"], " " + note if note else "")
1536
1805
  if i.get("tool") and not note.startswith("[%s]" % i["tool"]):
1537
1806
  note = "[%s] %s" % (i["tool"], note)
1538
1807
  if i["action"] == "delete?":
1539
- note += "; needs --apply --confirm-delete NAME"
1808
+ if (i["reason"] or "").startswith("[cleanup]"):
1809
+ note += "; needs --apply --confirm-cleanup NAME"
1810
+ if i["target"].startswith(".ai/state/"):
1811
+ note += " (git-ignored: not recoverable through git)"
1812
+ else:
1813
+ note += "; needs --apply --confirm-delete NAME"
1540
1814
  if i["action"] == "conflict" and i["conflict_copy"] is not None:
1541
1815
  note += "; plugin version %s %s/%s" % ("at" if applied else "will be at", plan.local_dir, i["target"])
1542
1816
  print(" %-9s %-*s %s" % (i["action"], width, shown(i), note))
@@ -1576,30 +1850,42 @@ def main():
1576
1850
  ap.add_argument("--confirm-delete", metavar="NAME",
1577
1851
  help="a human confirms this run's proposed deletions, and is recorded in "
1578
1852
  "the migration report; only with --apply")
1853
+ ap.add_argument("--confirm-cleanup", metavar="NAME",
1854
+ help="a human confirms this run's [cleanup] deletions (run logs and stale task files, "
1855
+ "git-ignored and not recoverable through git); only with --apply. "
1856
+ "--confirm-delete does not settle them")
1579
1857
  args = ap.parse_args()
1580
- confirm_via = None
1581
- if args.confirm_delete is not None:
1858
+ confirm_via = cleanup_via = None
1859
+ for flag, name in (("--confirm-delete", args.confirm_delete), ("--confirm-cleanup", args.confirm_cleanup)):
1860
+ if name is None:
1861
+ continue
1582
1862
  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()
1863
+ ap.error("%s only makes sense with --apply" % flag)
1864
+ if not name.strip():
1865
+ ap.error("%s needs the name of the human who confirmed the deletion" % flag)
1866
+ if adopt.placeholder_name(name):
1867
+ ap.error("%s got the placeholder %r; type your own name, it is the audit trail"
1868
+ % (flag, name.strip()))
1869
+ passed, via, why = adopt.human_gate()
1590
1870
  if not passed:
1591
1871
  # Under an agent-session marker the AI_UNATTENDED route is the launcher's
1592
1872
  # to declare, not the agent's to type: the hint names it only otherwise.
1593
1873
  hint = "run the same command yourself in a terminal outside Claude Code or Codex"
1594
1874
  if "agent session" not in why:
1595
1875
  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)
1876
+ return adopt.refuse("%s is typed by a human, and %s" % (flag, why), hint)
1877
+ if flag == "--confirm-delete":
1878
+ confirm_via = via
1879
+ else:
1880
+ cleanup_via = via
1597
1881
  if args.budget and not args.check:
1598
1882
  ap.error("--budget only makes sense with --check")
1599
1883
  if not args.adopt and (args.tool or args.mode != "migrate" or args.split_request or args.split or args.diff
1600
1884
  or args.cleanup):
1601
1885
  ap.error("--mode, --tool, --split, --split-request, --diff and --cleanup only make sense with --adopt")
1602
1886
  root = os.path.abspath(args.root)
1887
+ if args.adopt and args.confirm_cleanup is not None:
1888
+ ap.error("--confirm-cleanup does not apply to --adopt")
1603
1889
  if args.adopt:
1604
1890
  if args.confirm_delete is not None and not args.cleanup:
1605
1891
  ap.error("--adopt --confirm-delete belongs to --cleanup")
@@ -1632,13 +1918,14 @@ def main():
1632
1918
  print("project-update: %s templates not found at %s (run riskrails/install.sh)" % (name, tpl), file=sys.stderr)
1633
1919
  return 2
1634
1920
  try:
1635
- plan = build_plan(root, confirm_delete=getattr(args, "confirm_delete", None), confirm_via=confirm_via)
1921
+ plan = build_plan(root, confirm_delete=getattr(args, "confirm_delete", None), confirm_via=confirm_via,
1922
+ confirm_cleanup=args.confirm_cleanup, cleanup_via=cleanup_via)
1636
1923
  except SchemaError as exc:
1637
1924
  # --check is read by /ai-status, which reads stdout: it must see the reason.
1638
1925
  print("project-update: %s" % exc, file=sys.stdout if args.check else sys.stderr)
1639
1926
  return 2
1640
1927
  if plan is None:
1641
- print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /ai-init or /project-init first" % root,
1928
+ print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /rr-setup first" % root,
1642
1929
  file=sys.stderr)
1643
1930
  return 2
1644
1931
  if not args.check:
@@ -1665,7 +1952,7 @@ def main():
1665
1952
  bits.append("%d deletion(s) a human must confirm" % deletions)
1666
1953
  if plan.held is not None:
1667
1954
  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))
1955
+ print("project is behind the installed plugin: %s — run /rr-setup --update" % ", ".join(bits))
1669
1956
  return 1
1670
1957
  print("project matches the installed plugin" + (" (%d file(s) differ by hand-merge choice)" % conflicts if conflicts else ""))
1671
1958
  return 0