riskrails 3.0.0 → 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 (112) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/README.md +259 -812
  5. package/agents/ai-reviewer.md.tmpl +6 -4
  6. package/ai/policies/model-routing.md +7 -7
  7. package/ai/policies/production.md +3 -3
  8. package/ai/policies/release.md +2 -2
  9. package/ai/policies/review-economy.md +34 -32
  10. package/ai/using-riskrails.md +15 -0
  11. package/bin/claude-1m +15 -14
  12. package/bin/riskrails.js +113 -37
  13. package/codex/hooks.json +25 -2
  14. package/hooks/AGENTS.md +2 -2
  15. package/hooks/CLAUDE.md +2 -2
  16. package/hooks/ai-guard.py +29 -8
  17. package/hooks/ai-path-guard-defaults.json +3 -1
  18. package/hooks/codex-hooks.json +25 -2
  19. package/hooks/context-guard.py +252 -36
  20. package/hooks/dialog-gate.py +105 -0
  21. package/hooks/guard/common.py +7 -0
  22. package/hooks/guard/decision.py +123 -0
  23. package/hooks/guard/dialog.py +598 -0
  24. package/hooks/guard/git.py +49 -3
  25. package/hooks/guard/paths.py +89 -19
  26. package/hooks/hooks.json +37 -2
  27. package/hooks/memory-pre-push.py +28 -0
  28. package/hooks/project-scaffold.py +1 -5
  29. package/hooks/runtime-gate.py +65 -18
  30. package/hooks/update_notice.py +141 -0
  31. package/instructions/routing.md +4 -4
  32. package/instructions/stub.md +3 -3
  33. package/package.json +1 -1
  34. package/profiles/max.json +3 -1
  35. package/profiles/pro.json +3 -1
  36. package/scripts/merge-codex-config.py +134 -0
  37. package/scripts/resolve-profile.py +15 -3
  38. package/settings.common.json +39 -2
  39. package/skills/AGENTS.md +3 -3
  40. package/skills/CLAUDE.md +3 -3
  41. package/skills/ai-init/templates/.ai/AGENTS.md +3 -3
  42. package/skills/ai-init/templates/.ai/VERSION +1 -1
  43. package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
  44. package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
  45. package/skills/ai-init/templates/.ai/policies/security.md +7 -1
  46. package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
  47. package/skills/ai-init/templates/.ai/policies/weight.json +1 -0
  48. package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
  49. package/skills/ai-init/templates/.ai/state/README.md +9 -6
  50. package/skills/ai-init/templates/AGENTS.block.md +2 -2
  51. package/skills/ai-init/templates/CLAUDE.block.md +2 -2
  52. package/skills/ai-init/templates/GEMINI.block.md +2 -2
  53. package/skills/ai-init/templates/gitignore.snippet +1 -0
  54. package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
  55. package/skills/ai-task/memory_compact.py +968 -0
  56. package/skills/ai-task/memory_format.py +775 -0
  57. package/skills/ai-task/memory_store.py +528 -0
  58. package/skills/ai-task/memory_sync.py +1571 -0
  59. package/skills/ai-task/sensors.py +25 -5
  60. package/skills/ai-task/state.py +999 -79
  61. package/skills/ai-task/weight.py +3 -3
  62. package/skills/project-init/templates/constitution.md +1 -1
  63. package/skills/project-init/templates/sdlc-README.md +20 -14
  64. package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
  65. package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
  66. package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
  67. package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
  68. package/skills/project-update/history/blobs/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
  69. package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
  70. package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
  71. package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -0
  72. package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
  73. package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
  74. package/skills/project-update/history/blobs/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
  75. package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
  76. package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -0
  77. package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
  78. package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
  79. package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
  80. package/skills/project-update/history/blobs/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
  81. package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
  82. package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
  83. package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
  84. package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
  85. package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
  86. package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
  87. package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
  88. package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +13 -0
  89. package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
  90. package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
  91. package/skills/project-update/history/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
  92. package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
  93. package/skills/project-update/history/index.json +60 -20
  94. package/skills/project-update/migrations/0009_state_file.py +25 -0
  95. package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
  96. package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
  97. package/skills/project-update/migrations/README.md +2 -1
  98. package/skills/project-update/migrations/__init__.py +6 -1
  99. package/skills/project-update/render_instructions.py +1 -1
  100. package/skills/project-update/update.py +385 -89
  101. package/skills/rr-build/SKILL.md +14 -5
  102. package/skills/rr-build-all/SKILL.md +190 -0
  103. package/skills/rr-plan/SKILL.md +16 -4
  104. package/skills/rr-review/SKILL.md +24 -9
  105. package/skills/rr-setup/SKILL.md +15 -2
  106. package/skills/rr-setup/setup.py +7 -1
  107. package/skills/rr-ship/SKILL.md +16 -8
  108. package/skills/rr-spec/SKILL.md +4 -2
  109. package/skills/rr-status/SKILL.md +2 -2
  110. package/skills/usage-report/usage-report.py +45 -10
  111. package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
  112. 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,13 +85,20 @@ 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
- LEGACY_STATE_FILE = ".ai/state/current.json" # R34: the name until migration 0008 moves it
90
+ LEGACY_STATE_FILE = ".ai/state/current.json" # R34: the name until migration 0009 moves it
91
+
92
+
93
+ # Templates a migration creates exactly once, on the step to the given schema.
94
+ # From that schema on, a missing file is the human's choice (dialog.json missing
95
+ # means the dialog is off), so the template walk must not re-create it.
96
+ CREATE_ONCE = {".ai/policies/dialog.json": 10}
85
97
 
86
98
 
87
99
  def state_file(plan):
88
100
  """The task state's path as this run leaves the project: state.json once it
89
- exists there (migration 0008 moved it, or the task started under it), else
101
+ exists there (migration 0009 moved it, or the task started under it), else
90
102
  the legacy current.json."""
91
103
  if not plan.exists(STATE_FILE) and plan.exists(LEGACY_STATE_FILE):
92
104
  return LEGACY_STATE_FILE
@@ -113,14 +125,14 @@ def renamed_markers(block):
113
125
  find_block = render_instructions.find_block
114
126
  current_markers = render_instructions.current_markers
115
127
  MISSING = object()
116
- HASH_RE = re.compile(rb"sha256:[0-9a-f]{64}")
117
128
 
118
129
  PROJECT_INIT_MAP = [ # template -> target, kind
119
130
  ("sdlc-README.md", "docs/sdlc/README.md", "update"),
120
- ("intent.md", "docs/sdlc/intent/TEMPLATE.md", "update"),
121
- ("spec.md", "docs/sdlc/specs/TEMPLATE.md", "update"),
122
- ("plan.md", "docs/sdlc/plans/TEMPLATE.md", "update"),
123
- ("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"),
124
136
  # the project's own principles: created once, then it is the project's file
125
137
  ("constitution.md", "docs/sdlc/constitution.md", "create"),
126
138
  ]
@@ -296,6 +308,53 @@ def git_dirty_ai(root):
296
308
  return res.returncode == 0 and bool(res.stdout.strip())
297
309
 
298
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
+
299
358
  # ---------------------------------------------------------------- merging
300
359
  def closest(candidates, ours, score):
301
360
  best, best_score = None, -1.0
@@ -401,6 +460,22 @@ def detect_version(plan):
401
460
  return int(text)
402
461
 
403
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
+
404
479
  class MigrationContext:
405
480
  """What a migration sees: the project as this run leaves it, not the disk."""
406
481
 
@@ -517,16 +592,7 @@ class MigrationContext:
517
592
  """Why a file must not be deleted through this path, or None: it is a
518
593
  symlink, it is reached through one, or it resolves outside the project."""
519
594
  self._check_path(path)
520
- root = os.path.realpath(self.root)
521
- here = self.root
522
- for part in path.split("/"):
523
- here = os.path.join(here, part)
524
- if os.path.islink(here):
525
- return "%s is a symlink" % os.path.relpath(here, self.root).replace(os.sep, "/")
526
- real = os.path.realpath(os.path.join(self.root, path))
527
- if real != root and not real.startswith(root + os.sep):
528
- return "%s resolves outside the project (%s)" % (path, real)
529
- return None
595
+ return unsafe_path(self.root, path)
530
596
 
531
597
  def override_note(self, path):
532
598
  """What an edited copy of a shared file now means."""
@@ -704,11 +770,14 @@ class Plan:
704
770
  self.schema = None # (from, to, [migration modules]) when migrations are pending
705
771
  self.confirm_delete = None # the human who confirmed this run's deletions
706
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
707
775
  self.held = None # the schema version kept until a migration item is settled
708
776
  self.final = {} # target -> bytes after this run (for cross-file fixes)
709
777
  self.removed = set() # targets this run takes away
710
778
  self.seen = {} # target -> sha of what was on disk when it was first read,
711
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
712
781
  self.report_dir = ".ai/reports/project-update-%s" % datetime.now(timezone.utc).strftime("%Y-%m-%d")
713
782
 
714
783
  # The project as this run leaves it: planned content first, then the disk.
@@ -759,17 +828,60 @@ class Plan:
759
828
  return base
760
829
 
761
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
+
762
876
  def three_way(plan, history, key, target, theirs):
763
877
  ours = plan.read(target)
764
878
  if ours is None:
765
879
  plan.add("create", target, content=theirs)
766
880
  return
767
- is_mirror = target.endswith(".ai/policies/risk-tiers.md")
768
- norm = (lambda b: HASH_RE.sub(b"sha256:" + b"0" * 64, b)) if is_mirror else (lambda b: b)
769
- if norm(ours) == norm(theirs):
881
+ if ours == theirs:
770
882
  return
771
- versions = [v for v in history.versions(key) if norm(v) != norm(theirs)]
772
- 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):
773
885
  policy = None
774
886
  if target.endswith(".json") and "/policies/" in "/" + target:
775
887
  try:
@@ -778,7 +890,7 @@ def three_way(plan, history, key, target, theirs):
778
890
  except ValueError:
779
891
  policy = None
780
892
  plan.add("update", target, "unchanged since it was installed",
781
- content=fix_mirror(plan, target, theirs, ours), policy=policy)
893
+ content=theirs, policy=policy)
782
894
  return
783
895
 
784
896
  if target.endswith(".json"):
@@ -799,28 +911,21 @@ def three_way(plan, history, key, target, theirs):
799
911
  plan.add("merge", target, "your edits kept", content=dump_json(merged), policy=policy)
800
912
  if conflicts:
801
913
  plan.add("conflict", target, "kept your value for: " + ", ".join(conflicts), conflict_copy=theirs)
802
- model_keys = [c for c in conflicts if c.split(".")[0] == "model_tiers" or ".review_model" in "." + c]
803
- if target.endswith("policies/risk-tiers.json") and model_keys:
804
- hint = ("%s: %s — the plugin now names tiers (FAST/BALANCED/STRONG/EXPERT), not models; "
805
- "the model of a tier comes from the installed plan: state.py profile --tier <TIER>"
806
- % (target, ", ".join(model_keys)))
807
- if hint not in plan.hints:
808
- plan.hints.append(hint)
809
914
  return
810
915
 
811
916
  if target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(ours)) == 1:
812
917
  router_merge(plan, ours, versions, theirs)
813
918
  return
814
- base = closest(versions, norm(ours), lambda b, o: text_ratio(norm(b), o)) if versions else None
815
- 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
816
921
  if merged is not None and target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(merged)) != 1:
817
922
  plan.add("conflict", target, "the merge would not name the shared files exactly once "
818
923
  "(## Shared files)", conflict_copy=theirs)
819
924
  return
820
925
  if merged is None:
821
926
  plan.add("conflict", target, "edited here and changed in the plugin", conflict_copy=theirs)
822
- elif merged != norm(ours):
823
- 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)
824
929
 
825
930
 
826
931
  def split_shared_section(text):
@@ -832,8 +937,9 @@ def split_shared_section(text):
832
937
  return None
833
938
  start = found[0].start()
834
939
  nxt = re.compile(rb"(?m)^## ").search(text, found[0].end())
835
- if nxt is None:
836
- 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""
837
943
  end = nxt.start()
838
944
  eol = text.find(b"\n", end)
839
945
  follow = text[end:eol + 1] if eol >= 0 else text[end:]
@@ -857,7 +963,9 @@ def router_merge(plan, ours, versions, theirs):
857
963
  if o_rest == t_rest or (base is not None and base == t_rest):
858
964
  return
859
965
  merged = merge_text(o_rest, base, t_rest) if base is not None else None
860
- 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:
861
969
  merged = put_section(merged, o_sec, [follow, t_split[2] if t_split else b""])
862
970
  if merged is None or len(SHARED_SECTION_RE.findall(merged)) != 1:
863
971
  plan.add("conflict", ROUTER_FILE, "edited here and changed in the plugin", conflict_copy=theirs)
@@ -880,27 +988,6 @@ def put_section(text, section, follows):
880
988
  return text + (b"" if text.endswith(b"\n") else b"\n") + section
881
989
 
882
990
 
883
- def fix_mirror(plan, target, content, ours):
884
- """risk-tiers.md carries the sha256 of risk-tiers.json. Carry the project's sync
885
- state forward: a mirror that matched its JSON before this run points at the JSON
886
- this run leaves behind; a mirror that was already stale, or a JSON left with a
887
- conflict, keeps its old hash so /ai-status keeps warning."""
888
- if not target.endswith(".ai/policies/risk-tiers.md"):
889
- return content
890
- old = HASH_RE.search(ours)
891
- # The disk, not the plan: the question is whether the mirror matched the JSON
892
- # the project had before this run.
893
- json_target = target[:-3] + ".json"
894
- json_path = os.path.join(plan.root, json_target)
895
- before = read(json_path) if os.path.exists(json_path) else None
896
- in_sync = old is not None and before is not None and old.group(0) == b"sha256:" + sha(before).encode()
897
- conflicted = any(i["action"] == "conflict" and i["target"] == json_target for i in plan.items)
898
- after = plan.final.get(json_target, None if json_target in plan.removed else before)
899
- if in_sync and not conflicted and after is not None:
900
- return HASH_RE.sub(b"sha256:" + sha(after).encode(), content, count=1)
901
- return HASH_RE.sub(old.group(0), content, count=1) if old else content
902
-
903
-
904
991
  def block_update(plan, history, runtime):
905
992
  name, block_tpl, minimal_tpl = INSTRUCTION_FILE[runtime]
906
993
  if name in plan.removed: # a migration moved this instruction file away
@@ -979,7 +1066,7 @@ def shared_update(plan, history, version):
979
1066
  edited.append(path)
980
1067
  elif not named:
981
1068
  ctx.hint("%s: unedited, but %s does not name the shared files (## Shared files), so the "
982
- "copy stays — restore that section, then run /project-update again"
1069
+ "copy stays — restore that section, then run /rr-setup --update again"
983
1070
  % (path, ROUTER_FILE))
984
1071
  else:
985
1072
  candidates.append(path)
@@ -1002,9 +1089,145 @@ def shared_update(plan, history, version):
1002
1089
  "adds nothing" % (len(edited), ", ".join(edited), SHARED))
1003
1090
 
1004
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
+
1005
1224
  def deletion_hint(plan):
1006
- """M4: git is the rollback of a deletion; it must hold .ai/ first."""
1007
- 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) \
1008
1231
  and git_dirty_ai(plan.root):
1009
1232
  plan.hints.append("commit .ai/ first: git is this migration's rollback "
1010
1233
  "(`git status -- .ai` lists what it does not hold yet)")
@@ -1192,8 +1415,10 @@ def constitution_hint(plan):
1192
1415
  plan.hints.append(line.replace(path, rel))
1193
1416
 
1194
1417
 
1195
- 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):
1196
1419
  plan = Plan(root)
1420
+ plan.confirm_cleanup = confirm_cleanup
1421
+ plan.cleanup_via = cleanup_via
1197
1422
  plan.confirm_delete = confirm_delete
1198
1423
  plan.confirm_via = confirm_via
1199
1424
  has_ai = os.path.isdir(os.path.join(root, ".ai"))
@@ -1225,7 +1450,7 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1225
1450
  if stuck:
1226
1451
  plan.held = version
1227
1452
  plan.hints.append("the schema stays at %d until %s — resolve a conflict by hand, confirm a "
1228
- "deletion with --apply --confirm-delete NAME, then run /project-update again"
1453
+ "deletion with --apply --confirm-delete NAME, then run /rr-setup --update again"
1229
1454
  % (version, ", ".join("%s (%s)" % (i["target"], i["action"]) for i in stuck)))
1230
1455
  else:
1231
1456
  # Last of the migration items: an apply that stops short leaves the
@@ -1237,18 +1462,22 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1237
1462
  if has_sdlc:
1238
1463
  tpl = TEMPLATES["project-init"]
1239
1464
  entries = list(PROJECT_INIT_MAP)
1465
+ retired = []
1240
1466
  for rt in runtimes:
1241
1467
  entries += RUNTIME_MAP[rt]
1242
1468
  for src, target, kind in entries:
1243
1469
  content = read(os.path.join(tpl, src))
1244
1470
  if target in plan.removed: # a migration moved it away in this run
1245
1471
  continue
1246
- if kind == "create":
1472
+ if kind == "retire":
1473
+ retired.append(("project-init/" + src, target))
1474
+ elif kind == "create":
1247
1475
  if not plan.exists(target):
1248
1476
  plan.add("create", target, content=content.replace(
1249
1477
  b"{{PROJECT}}", os.path.basename(root).encode()))
1250
1478
  else:
1251
1479
  three_way(plan, history, "project-init/" + src, target, content)
1480
+ retire_templates(plan, history, retired)
1252
1481
  gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
1253
1482
 
1254
1483
  if has_ai:
@@ -1257,19 +1486,25 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1257
1486
  for dirpath, _, names in os.walk(os.path.join(tpl, ".ai")):
1258
1487
  for n in names:
1259
1488
  files.append(os.path.relpath(os.path.join(dirpath, n), tpl))
1260
- # 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.
1261
1490
  for rel in sorted(files, key=lambda r: (not r.endswith(".json"), r)):
1262
1491
  if rel == VERSION_FILE: # owned by the migration step, never merged
1263
1492
  continue
1264
1493
  if rel in plan.removed: # a migration moved it away in this run
1265
1494
  continue
1495
+ if rel in CREATE_ONCE and version >= CREATE_ONCE[rel] and not plan.exists(rel):
1496
+ continue # past the step that created it: its absence is the human's choice
1266
1497
  content = read(os.path.join(tpl, rel))
1267
1498
  if rel.startswith(".ai/project/") or rel.startswith(".ai/reports/"):
1268
1499
  if not plan.exists(rel):
1269
1500
  plan.add("create", rel, content=content)
1270
1501
  continue
1502
+ if rel == WEIGHT_MD:
1503
+ weight_mirror(plan, history, content)
1504
+ continue
1271
1505
  three_way(plan, history, "ai-init/" + rel, rel, content)
1272
1506
  shared_update(plan, history, version)
1507
+ cleanup_update(plan)
1273
1508
  deletion_hint(plan)
1274
1509
  for rt in runtimes:
1275
1510
  block_update(plan, history, rt)
@@ -1282,8 +1517,8 @@ def build_plan(root, confirm_delete=None, confirm_via=None):
1282
1517
  ("verify_command", "the feedback loop needs it"),
1283
1518
  ("step_test_command", "a step runs only its own tests"),
1284
1519
  ("e2e_command", "e2e runs once at the end of a task; write 'none' if there is no suite"),
1285
- ("lint_command", "a sensor reads it; run `sensors.py detect`, and write 'none' if there is none"),
1286
- ("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"),
1287
1522
  ):
1288
1523
  if re.search(rb"(?m)^" + field.encode() + rb":[ \t]*(#.*)?$", testing):
1289
1524
  plan.hints.append(".ai/policies/testing.md: %s is empty — %s" % (field, why))
@@ -1424,23 +1659,34 @@ def apply(plan):
1424
1659
 
1425
1660
  def apply_item(plan, item, written, recorded):
1426
1661
  target, action = item["target"], item["action"]
1662
+ cleanup = (item.get("reason") or "").startswith("[cleanup]")
1427
1663
  if action == "delete?": # listed only, until a human confirms it
1428
1664
  return
1429
1665
  mode = None
1430
1666
  if action in ("move", "delete"):
1431
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)
1432
1672
  check(plan, item, source, item["src_expect"] if action == "move" else item["expect"], written)
1433
1673
  if action == "move":
1434
1674
  check(plan, item, target, item["expect"], written)
1435
1675
  mode = os.stat(os.path.join(plan.root, source)).st_mode & 0o7777
1436
- keep_original(plan, source)
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):
1681
+ keep_original(plan, source)
1437
1682
  recorded.append(target)
1438
1683
  # Recorded before the file is gone: an abort later must not lose the note
1439
1684
  # of what was taken away, or of who approved a deletion.
1440
1685
  record_migration(plan,
1441
1686
  [dict(action=action, migration=item["migration"],
1442
1687
  **(dict(src=item["src"], dst=target) if action == "move" else dict(path=target)))],
1443
- [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())]
1444
1690
  if action == "delete" else [])
1445
1691
  elif item["content"] is not None:
1446
1692
  check(plan, item, target, item["expect"], written)
@@ -1460,10 +1706,25 @@ def apply_item(plan, item, written, recorded):
1460
1706
  elif action == "delete":
1461
1707
  os.remove(os.path.join(plan.root, target))
1462
1708
  written[target] = None
1709
+ if (item["reason"] or "").startswith("[cleanup]"):
1710
+ prune_empty(plan.root, target)
1463
1711
  if item["conflict_copy"] is not None:
1464
1712
  write_file(plan.root, os.path.join(plan.local_dir, target), item["conflict_copy"])
1465
1713
 
1466
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
+
1467
1728
  def plain_pending(root, ignore=()):
1468
1729
  """True when the plain --check would exit 1: an automatic item or a schema
1469
1730
  step is pending. Targets in `ignore` (an adopt resuming its own writes) do
@@ -1514,20 +1775,42 @@ def report(plan, applied):
1514
1775
  print(" up to date with the installed plugin")
1515
1776
  def shown(i):
1516
1777
  return "%s -> %s" % (i["src"], i["target"]) if i["src"] else i["target"]
1517
- 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])
1518
1796
  if plan.schema:
1519
1797
  frm, to, mods = plan.schema
1520
1798
  print(" %-9s %-*s (%d migration%s: %s)" % (
1521
1799
  "schema", width, "%d -> %d" % (frm, to), len(mods), "" if len(mods) == 1 else "s",
1522
1800
  ", ".join("%04d %s" % (m.VERSION, m.TITLE) for m in mods)))
1523
- for i in plan.items:
1801
+ for i in rows:
1524
1802
  note = i["note"]
1525
1803
  if i["migration"] is not None:
1526
1804
  note = "[%04d]%s" % (i["migration"], " " + note if note else "")
1527
1805
  if i.get("tool") and not note.startswith("[%s]" % i["tool"]):
1528
1806
  note = "[%s] %s" % (i["tool"], note)
1529
1807
  if i["action"] == "delete?":
1530
- 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"
1531
1814
  if i["action"] == "conflict" and i["conflict_copy"] is not None:
1532
1815
  note += "; plugin version %s %s/%s" % ("at" if applied else "will be at", plan.local_dir, i["target"])
1533
1816
  print(" %-9s %-*s %s" % (i["action"], width, shown(i), note))
@@ -1567,30 +1850,42 @@ def main():
1567
1850
  ap.add_argument("--confirm-delete", metavar="NAME",
1568
1851
  help="a human confirms this run's proposed deletions, and is recorded in "
1569
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")
1570
1857
  args = ap.parse_args()
1571
- confirm_via = None
1572
- 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
1573
1862
  if not args.apply:
1574
- ap.error("--confirm-delete only makes sense with --apply")
1575
- if not args.confirm_delete.strip():
1576
- ap.error("--confirm-delete needs the name of the human who confirmed the deletion")
1577
- if adopt.placeholder_name(args.confirm_delete):
1578
- ap.error("--confirm-delete got the placeholder %r; type your own name, it is the audit trail"
1579
- % args.confirm_delete.strip())
1580
- 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()
1581
1870
  if not passed:
1582
1871
  # Under an agent-session marker the AI_UNATTENDED route is the launcher's
1583
1872
  # to declare, not the agent's to type: the hint names it only otherwise.
1584
1873
  hint = "run the same command yourself in a terminal outside Claude Code or Codex"
1585
1874
  if "agent session" not in why:
1586
1875
  hint += ", or set AI_UNATTENDED=1 if a launcher runs it unattended on your behalf"
1587
- 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
1588
1881
  if args.budget and not args.check:
1589
1882
  ap.error("--budget only makes sense with --check")
1590
1883
  if not args.adopt and (args.tool or args.mode != "migrate" or args.split_request or args.split or args.diff
1591
1884
  or args.cleanup):
1592
1885
  ap.error("--mode, --tool, --split, --split-request, --diff and --cleanup only make sense with --adopt")
1593
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")
1594
1889
  if args.adopt:
1595
1890
  if args.confirm_delete is not None and not args.cleanup:
1596
1891
  ap.error("--adopt --confirm-delete belongs to --cleanup")
@@ -1623,13 +1918,14 @@ def main():
1623
1918
  print("project-update: %s templates not found at %s (run riskrails/install.sh)" % (name, tpl), file=sys.stderr)
1624
1919
  return 2
1625
1920
  try:
1626
- 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)
1627
1923
  except SchemaError as exc:
1628
1924
  # --check is read by /ai-status, which reads stdout: it must see the reason.
1629
1925
  print("project-update: %s" % exc, file=sys.stdout if args.check else sys.stderr)
1630
1926
  return 2
1631
1927
  if plan is None:
1632
- 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,
1633
1929
  file=sys.stderr)
1634
1930
  return 2
1635
1931
  if not args.check:
@@ -1656,7 +1952,7 @@ def main():
1656
1952
  bits.append("%d deletion(s) a human must confirm" % deletions)
1657
1953
  if plan.held is not None:
1658
1954
  bits.append("the schema stays at %d until they are settled" % plan.held)
1659
- 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))
1660
1956
  return 1
1661
1957
  print("project matches the installed plugin" + (" (%d file(s) differ by hand-merge choice)" % conflicts if conflicts else ""))
1662
1958
  return 0