riskrails 3.2.1 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) 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 +40 -83
  5. package/agents/ai-reviewer.md.tmpl +1 -1
  6. package/ai/using-riskrails.md +0 -3
  7. package/bin/riskrails.js +13 -59
  8. package/hooks/ai-path-guard-defaults.json +1 -1
  9. package/hooks/context-guard.py +2 -4
  10. package/hooks/guard/paths.py +3 -6
  11. package/hooks/guard/scope.py +1 -3
  12. package/hooks/runtime-gate.py +18 -58
  13. package/instructions/routing.md +2 -2
  14. package/package.json +1 -1
  15. package/scripts/ai-path +1 -1
  16. package/scripts/resolve-profile.py +0 -3
  17. package/skills/ai-audit/templates/ai-sdlc-adoption-plan.md +1 -1
  18. package/skills/ai-audit/templates/play-scoring-rubric.md +1 -1
  19. package/skills/ai-init/templates/.ai/AGENTS.md +6 -6
  20. package/skills/ai-init/templates/.ai/VERSION +1 -1
  21. package/skills/ai-init/templates/.ai/policies/security.md +1 -2
  22. package/skills/ai-init/templates/.ai/policies/testing.md +5 -12
  23. package/skills/ai-init/templates/.ai/policies/verification.md +18 -0
  24. package/skills/ai-init/templates/.ai/policies/weight.json +0 -1
  25. package/skills/ai-init/templates/.ai/policies/weight.md +1 -2
  26. package/skills/ai-init/templates/.ai/policies/weight.project.json +4 -0
  27. package/skills/ai-init/templates/.ai/project/architecture.md +1 -1
  28. package/skills/ai-init/templates/.ai/project/initial-assessment.md +2 -2
  29. package/skills/ai-init/templates/.ai/project/overview.md +1 -1
  30. package/skills/ai-init/templates/.ai/rules/README.md +2 -2
  31. package/skills/ai-init/templates/.ai/state/README.md +1 -1
  32. package/skills/ai-task/memory_store.py +8 -9
  33. package/skills/ai-task/rr_state/cli.py +1 -1
  34. package/skills/ai-task/rr_state/dialog.py +2 -4
  35. package/skills/ai-task/rr_state/fs.py +5 -13
  36. package/skills/ai-task/rr_state/profile.py +2 -4
  37. package/skills/ai-task/rr_state/questions_cmd.py +1 -1
  38. package/skills/ai-task/rr_state/report.py +3 -4
  39. package/skills/ai-task/rr_state/runtime.py +1 -1
  40. package/skills/ai-task/rr_state/start.py +7 -7
  41. package/skills/ai-task/sensors.py +10 -11
  42. package/skills/ai-task/state.py +4 -24
  43. package/skills/ai-task/weight.py +17 -17
  44. package/skills/ai-task/weight_overlay.py +38 -0
  45. package/skills/project-init/templates/constitution.md +2 -2
  46. package/skills/project-update/history/blobs/00f5e27ce2d66e3a1d8b222b013ae8fa1fed32612d91a551e0c661cee3ef9c2d +34 -0
  47. package/skills/project-update/history/blobs/0a6b1b6dd06a35582b2e9989a1f41c40abc29254b8e482714fcc6f0a7b16c406 +32 -0
  48. package/skills/project-update/history/blobs/0aeb04b04262683cac6513545683486a73dc473b447bba43b5aaab18403957a6 +34 -0
  49. package/skills/project-update/history/blobs/0ce913a28996f546f72d570b0e5a376362e993ce4d805bc4aae57d38caa44212 +41 -0
  50. package/skills/project-update/history/blobs/0ea9e36446b624b73b2a821231b2ee66a357b43d584a5b23da48b3c800855e65 +41 -0
  51. package/skills/project-update/history/blobs/0fb1d68678a1349195b2e79433bb20174d25874e3bfd0b70410bb4e428b4e4b8 +35 -0
  52. package/skills/project-update/history/blobs/119b2b9d0f9c647a243540724edac6b29afa020c69aa205797bcfa9eb06bc021 +35 -0
  53. package/skills/project-update/history/blobs/1d0fe3ee5fb8260ba610942d4bbdfff7410fbe80ed826ac07f32985031dc2b3f +34 -0
  54. package/skills/project-update/history/blobs/1de9a37ed6402893dcab6e61ddbf15ffd35f15a6d84b18e675a0c5bf4e981f85 +42 -0
  55. package/skills/project-update/history/blobs/1f44e1a236233dcbab58064f39b69f798b35d1d9a29f8c615b5b12854ce633cb +15 -0
  56. package/skills/project-update/history/blobs/1f824645299579cbdd8141642ddbb4bca9fd5fe9d3112f036d8221f0811e0abf +40 -0
  57. package/skills/project-update/history/blobs/235fb88393cfe20dd58b35fe9785cc45baf5ffe21a13d926652d76ec68cea8b7 +35 -0
  58. package/skills/project-update/history/blobs/3918b4ee011d9477b9d2779e2893ab599749c406d4b09439a9748b64fbff58f3 +18 -0
  59. package/skills/project-update/history/blobs/449273200dfec2a56885c881ff34d40e8ecc00da53c24c2d09b7fbeb1004d255 +40 -0
  60. package/skills/project-update/history/blobs/4c45aee9b9fc5f6b4e1d9739e434c4094975a5a8dec6b9703fb25f3080680c77 +124 -0
  61. package/skills/project-update/history/blobs/4dd3fa12bc45acc8c836ab65d790248ca7504a021bca6677499d17bb3d798ed9 +64 -0
  62. package/skills/project-update/history/blobs/51e3f15095d7123ea8566b512479ee10a6cb2f336b7352a259a083a887bd7116 +33 -0
  63. package/skills/project-update/history/blobs/5497a204967bea7dab29079a6e1dfa3d8713b2d2cb294c2779378039f03a55fc +36 -0
  64. package/skills/project-update/history/blobs/60c4d7e7fde7049d92d84295fc13ca9a91eb2de423be08f093b3ee5d5e817ff8 +64 -0
  65. package/skills/project-update/history/blobs/6221b7c14cc04634e485e562d464c6860d45666a7f63e293c06bb2944ff556cc +27 -0
  66. package/skills/project-update/history/blobs/637971cfb844ef4285e2886a4b1c834fb2ed5c46e773adb88c2c147fea626ad3 +33 -0
  67. package/skills/project-update/history/blobs/65af2282eea2a51fcc27a4acf2c26c727b0961c30e1be13176369200c460c219 +34 -0
  68. package/skills/project-update/history/blobs/66ecd73e303e7bbc1aa8ee72435d0d10812ff7b2eff0adac59bbf0a303e2cc01 +41 -0
  69. package/skills/project-update/history/blobs/6a43bf32461b22b87fafe2865529dc04fa66cd00182a2a7598aa071667ca255a +35 -0
  70. package/skills/project-update/history/blobs/6d6dee8634abf9ac057bb206828b83a893a2afcf4f86bbe69e839a3b47d0adde +48 -0
  71. package/skills/project-update/history/blobs/7224ede803ea81e65a456e21b6028a0e848e8a880456c6503eebe7534ca4f6aa +74 -0
  72. package/skills/project-update/history/blobs/765a39a202617096c232017822ae738d37e4405430c71700dc7bc14bf1ba794e +33 -0
  73. package/skills/project-update/history/blobs/77577440a933d1cabd8c6f4a6401cf361a784aa09a25500a6f7b44ead8ab5e55 +41 -0
  74. package/skills/project-update/history/blobs/7bae90789625d6fb9071ecb412d14a8d1eb0bc5659d8d284c4eaf213e1b5de56 +67 -0
  75. package/skills/project-update/history/blobs/7bcdf9a0e6b85277b9b60669150224b9414d2e32821ff06f4d148eb1d18785bb +42 -0
  76. package/skills/project-update/history/blobs/7f834103e9d97aea44e76717bcdcbbe349c560e5b81a42ce85a1cec341041281 +4 -0
  77. package/skills/project-update/history/blobs/7fb3acb0211818efebc2234863db0b8c846022a7280a6d2d69f267dbf63db48c +34 -0
  78. package/skills/project-update/history/blobs/87f0855859ff68cc6c988cc0b631ee7f56af7fbba062172bf80e01f4982cc054 +35 -0
  79. package/skills/project-update/history/blobs/8abab74759766d02b5cb7b0c3e59a3d440b25a454216cd8fea325c23fd822d36 +33 -0
  80. package/skills/project-update/history/blobs/a1fb50e6c86fae1679ef3351296fd6713411a08cf8dd1790a4fd05fae8688164 +1 -0
  81. package/skills/project-update/history/blobs/a3df7268feb0780be0d2cf9acced6ed81780a7790f36fe024b6ea0b1bd6eb7a6 +35 -0
  82. package/skills/project-update/history/blobs/b28721136482dafb9fd2801af340364cb97e9f6eaa987908606c786045b8d334 +28 -0
  83. package/skills/project-update/history/blobs/b3726422d767f9219fea633bea77f0c99ad9af843c591e6e928e1ccd2a47f374 +27 -0
  84. package/skills/project-update/history/blobs/b3839425f9d0ab2efa42a6f5e9488ec2d26ca5fb8dcd694b8ab098e10743ca46 +32 -0
  85. package/skills/project-update/history/blobs/ba14a457e83f9dde65e4190fdfab83b912f7398c1442a2a1cb3d89bfac741cf3 +8 -0
  86. package/skills/project-update/history/blobs/bceb7189444bb6291b262ca08e34db01010dda2f6e64d696231ec3c343c95c66 +34 -0
  87. package/skills/project-update/history/blobs/bf82a2bb4fce1bd349ff66118930a01d61a7af8e2bd8d200c721d43afc58c713 +34 -0
  88. package/skills/project-update/history/blobs/c4dbeb180292a6199ad73a3be861ece07c1f8bf6bf0cc7ca204412bc8e1bb1d8 +31 -0
  89. package/skills/project-update/history/blobs/c644838866df2001a42501a988dd7b77aacf862bf09d22b465f3cd8a22c6b6eb +33 -0
  90. package/skills/project-update/history/blobs/c980d74f76a4bdfb38c94b573ac10a54eb50b850b052e15fa6f2442889b36ccb +32 -0
  91. package/skills/project-update/history/blobs/ceed2f3bd937f862d0e6ca59ce1b1e3603e19a0d7f85d90d6dfeb7bf690153be +29 -0
  92. package/skills/project-update/history/blobs/d51ea2e893482a20c5882056cf8043327ca1dc1c957927f6746929a47ee8368e +45 -0
  93. package/skills/project-update/history/blobs/d78f5e0bb1380259fce2cce009785a9a11f6de8b87a160080b9872c059f95060 +34 -0
  94. package/skills/project-update/history/blobs/d97f46a2cca020406252b1f7baa4184617fa71faca3885e2379c44ae4e1d9327 +67 -0
  95. package/skills/project-update/history/blobs/da3ecd4c4c414c93d04488685931df39a56bf959bfeb9c45cb1aea2c13bd991b +38 -0
  96. package/skills/project-update/history/blobs/e129bf71a5a922e24b2c1bc8daa8a5d2b5589b0069770e17ce17e7db5ed4e7a4 +27 -0
  97. package/skills/project-update/history/blobs/e504529519a371892838725f8b89b5d32e3300e345bcbf2643a3a1a48434b3dd +24 -0
  98. package/skills/project-update/history/blobs/e9e62d03ca70b3dffccef8dceeb214173352cfef005145e519f0e037993f1c03 +36 -0
  99. package/skills/project-update/history/blobs/effefbaaf4beb8f1ba55081d6b7e74bf25ebb91045dd10613418f17b95fa7182 +33 -0
  100. package/skills/project-update/history/blobs/f6af4581bafbf62b257d57e06c3ae2c5998aeca608ee7df037fcc3c3e4b563a9 +34 -0
  101. package/skills/project-update/history/blobs/fc35045777d29630303430752a651b3e553580a581036a02cc103eb96a46f4b0 +34 -0
  102. package/skills/project-update/history/blobs/fdc21345c77f92a42d244aa104f393f439a0ff9981a9fd462bd837e8a47931bb +48 -0
  103. package/skills/project-update/history/index.json +32 -13
  104. package/skills/project-update/render_instructions.py +12 -45
  105. package/skills/project-update/update.py +757 -1172
  106. package/skills/rr-setup/SKILL.md +42 -81
  107. package/skills/rr-setup/scaffold_ai.py +7 -7
  108. package/skills/rr-setup/setup.py +96 -164
  109. package/skills/rr-status/status.py +7 -10
  110. package/skills/usage-report/usage-report.py +4 -5
  111. package/skills/ai-audit/SKILL.md +0 -9
  112. package/skills/ai-init/SKILL.md +0 -9
  113. package/skills/ai-status/SKILL.md +0 -9
  114. package/skills/ai-task/SKILL.md +0 -9
  115. package/skills/project-init/SKILL.md +0 -9
  116. package/skills/project-update/SKILL.md +0 -9
  117. package/skills/project-update/adopt-map.json +0 -91
  118. package/skills/project-update/adopt.py +0 -2225
  119. package/skills/project-update/migrations/0001_schema_version.py +0 -13
  120. package/skills/project-update/migrations/0002_task_journal.py +0 -123
  121. package/skills/project-update/migrations/0003_context_diet.py +0 -65
  122. package/skills/project-update/migrations/0004_deterministic_gates.py +0 -57
  123. package/skills/project-update/migrations/0005_runtimes_and_plans.py +0 -35
  124. package/skills/project-update/migrations/0006_shared_files.py +0 -154
  125. package/skills/project-update/migrations/0007_weight_model.py +0 -295
  126. package/skills/project-update/migrations/0008_renames.py +0 -43
  127. package/skills/project-update/migrations/0009_state_file.py +0 -25
  128. package/skills/project-update/migrations/0010_dialog_switch.py +0 -30
  129. package/skills/project-update/migrations/0011_retire_risk_tiers.py +0 -37
  130. package/skills/project-update/migrations/README.md +0 -76
  131. package/skills/project-update/migrations/__init__.py +0 -186
  132. package/skills/sdlc-intent/SKILL.md +0 -9
  133. package/skills/sdlc-plan/SKILL.md +0 -9
  134. package/skills/sdlc-spec/SKILL.md +0 -9
  135. package/skills/usage-report/SKILL.md +0 -10
  136. /package/{ai/workflows/refactoring.md → skills/project-update/history/blobs/36cb593293496fbcf38df828a9b9b2bc0d5a53333f584a5319ebc3d799016e24} +0 -0
  137. /package/{ai/workflows/feature.md → skills/project-update/history/blobs/56e16072c2f3edc6c8897c690d09a11d12fc414c3e3f361f359f7dc609a21282} +0 -0
  138. /package/{ai/workflows/hotfix.md → skills/project-update/history/blobs/8cbefbf1aa412bae3e5019acd40b8fe0598fceba6c781906b3bc6dbd014f6b23} +0 -0
  139. /package/{ai/workflows/bugfix.md → skills/project-update/history/blobs/af56dc16e44ae1d8c3dac4c3ca82e2163b719553c02ea455e77430bab9a14089} +0 -0
  140. /package/{ai/workflows/investigation.md → skills/project-update/history/blobs/b9e09cb24958cd6831589997558974bd6356305306e8f9685e226bcdc30d60ac} +0 -0
  141. /package/{ai/agents/manager.md → skills/project-update/history/blobs/d0cf6388f35a92a0cf607d33909278382f6d7b6a4c32e281b9f9e0ea652c7b91} +0 -0
@@ -1,69 +1,76 @@
1
1
  #!/usr/bin/env python3
2
2
  """Bring a project's RiskRails files up to date with the installed plugin.
3
3
 
4
- update.py [project-dir] [--apply [--confirm-delete NAME]] [--check [--budget]]
4
+ update.py [project-dir] [--apply [--confirm-delete NAME] [--confirm-cleanup NAME]] [--check [--budget]]
5
5
 
6
6
  Default is a dry run: print what would change, write nothing.
7
7
  --apply write the changes
8
- --check print one line and exit 1 when an automatic update is pending
8
+ --check print one line and exit 1 when an update is pending
9
9
  --check --budget print one line per root instruction file over its byte
10
10
  budget (block over `project`, file over `skeleton`) and exit 1
11
- --confirm-delete NAME a human confirms this run's proposed deletions; refused
12
- (exit 5) unless a terminal outside Claude Code or Codex (no agent-session
13
- marker) or AI_UNATTENDED; the record says which (via)
11
+ --confirm-delete NAME a human confirms this run's gated rows (`regenerate?`,
12
+ `delete?`); refused (exit 5, CONFIRM_REFUSED) unless a terminal outside
13
+ Claude Code or Codex (no agent-session marker) or AI_UNATTENDED
14
+ --confirm-cleanup NAME the same for the [cleanup] rows
14
15
 
15
16
  Exit codes:
16
17
  0 done (or, with --check, the project is current)
17
18
  1 --check: an update is pending; --check --budget: a file is over budget
18
- 2 usage error, missing templates, a schema the plugin cannot read
19
+ 2 usage error, missing templates, .ai/VERSION unreadable or newer than the plugin
19
20
  3 an apply aborted part-way; nothing after the failing item was written
20
- 5 refused: --confirm-delete without a human (ADOPT_REFUSED on stdout line 1)
21
-
22
- What it manages, and how:
23
- .ai/** the project owns (weight, path-guard, three-way update
24
- testing, coding, safety, security, AGENTS.md)
25
- .ai/** the plugin shares (the other policies, read from the plugin's shared tree; a project
26
- agents, workflows, templates) copy is an override and is never merged — an
27
- unedited one is offered for deletion
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
34
- .claude/memory/README.md, .codex/memory/README.md three-way update, per runtime the project uses
35
- the managed block in CLAUDE.md and/or AGENTS.md three-way update of the block only
36
- .gitignore missing entries appended
37
- .ai/project/**, the instruction files themselves,
38
- .claude/settings.json, .codex/config.toml created when missing, never updated
21
+ 5 refused: --confirm-* without a human (CONFIRM_REFUSED on stdout line 1)
22
+ 6 a file this update keeps cannot be read (KEPT_UNREADABLE on stdout line 1); nothing written
23
+
24
+ This version regenerates its own files and converts nothing, except one split:
25
+ a project that still has the mixed files gets .ai/policies/verification.md (the
26
+ command lines of testing.md, verbatim) and .ai/policies/weight.project.json
27
+ (what its weight.json changed against the plugin's table) once, when they are absent.
28
+
29
+ What it does, per file:
30
+ GENERATED .ai/AGENTS.md, .ai/VERSION, .ai/policies/{path-guard.json, coding.md,
31
+ safety.md, security.md, testing.md, weight.md}, .ai/rules/README.md,
32
+ .ai/state/README.md, <runtime>/memory/README.md, the managed block of each
33
+ instruction file: rewritten from the plugin. A copy that is a version the plugin
34
+ once shipped is replaced (regenerate); an edited one waits for --confirm-delete
35
+ (regenerate?) and its original is kept under .ai/reports/project-update-<date>/original/.
36
+ KEPT .ai/project, .ai/reports, .ai/memory, .ai/state, docs/sdlc, verification.md,
37
+ weight.project.json, dialog.json, memory.json, .ai/local/memory, edited shared
38
+ overrides, the text outside the managed blocks, .claude/settings.json,
39
+ .codex/config.toml, .gitignore (entries appended): never rewritten.
40
+ REMOVED .ai/rules/<slug>.md, .ai/local/plugin-update/, the old weight.json after its
41
+ split, an unedited copy of a shared file: delete?, gated, with a copy in original/.
42
+ [cleanup] rows are unchanged: proposals only, settled with --confirm-cleanup.
39
43
 
40
44
  A project uses the runtime(s) it declares: CLAUDE.md or .claude/ means Claude
41
45
  Code, AGENTS.md or .codex/ means Codex, both means both. A project that declares
42
- neither gets the runtime this copy of the plugin was installed for, exactly as
43
- the scaffolds do.
44
-
45
- Three-way update: a file whose content is a version this plugin once shipped was
46
- never edited, so it is replaced. An edited file is merged against the shipped
47
- version closest to it (git merge-file for text, per key for JSON), keeping every
48
- edit. A real conflict leaves the project file alone and puts the plugin's version
49
- in a git-ignored local directory for a human or /rr-setup --update to merge.
46
+ neither gets the runtime this copy of the plugin was installed for.
50
47
  """
51
- import argparse, contextlib, copy, difflib, hashlib, importlib.util, io, json, os, re, shlex, subprocess, sys, tempfile
48
+ import argparse, contextlib, hashlib, importlib.util, io, json, os, re, shlex, subprocess, sys, tempfile
52
49
  from collections import OrderedDict
53
- from types import SimpleNamespace
54
50
  from datetime import datetime, timedelta, timezone
55
51
 
52
+ try:
53
+ import tomllib
54
+ except ImportError: # Python before 3.11: .codex/config.toml is not checked
55
+ tomllib = None
56
+
56
57
  sys.dont_write_bytecode = True # never leave __pycache__ in an installed plugin
57
58
  HERE = os.path.dirname(os.path.abspath(__file__))
58
59
  sys.path.insert(0, HERE)
59
- import migrations # noqa: E402 # lives next to this script
60
- from migrations import SchemaError # noqa: E402
61
60
  import render_instructions # noqa: E402 # lives next to this script
62
- import adopt # noqa: E402 # lives next to this script
63
61
  SKILLS = os.path.dirname(HERE)
62
+ LAST_MIGRATING_VERSION = "3.1.0" # the last riskrails that converts older trees; I3 names it
63
+ EXIT_KEPT_UNREADABLE = 6
64
+ REFUSED = "CONFIRM_REFUSED"
65
+
66
+
67
+ class SchemaError(Exception):
68
+ """The project's schema cannot be read or is newer than this plugin."""
69
+
70
+
64
71
  def env(name, default=None):
65
- """RISKRAILS_<name>, else the pre-3.4b CLAUDE_AGENTIC_<name> (R35, one version)."""
66
- return os.environ.get("RISKRAILS_" + name, os.environ.get("CLAUDE_AGENTIC_" + name, default))
72
+ """RISKRAILS_<name>."""
73
+ return os.environ.get("RISKRAILS_" + name, default)
67
74
 
68
75
 
69
76
  TEMPLATES = {
@@ -72,37 +79,58 @@ TEMPLATES = {
72
79
  }
73
80
  HISTORY = env("HISTORY", os.path.join(HERE, "history"))
74
81
  # The shared tree every project reads unless it keeps its own copy: installed
75
- # beside the skills as riskrails/ai (claude-agentic/ai before 3.4b), or ai/ at
76
- # the root of a checkout.
82
+ # beside the skills as riskrails/ai, or ai/ at the root of a checkout.
77
83
  PLUGIN_HOME = os.path.dirname(SKILLS)
78
84
  SHARED = env("SHARED") or next(
79
- (p for p in (os.path.join(PLUGIN_HOME, "riskrails", "ai"), os.path.join(PLUGIN_HOME, "claude-agentic", "ai"),
80
- os.path.join(PLUGIN_HOME, "ai"))
85
+ (p for p in (os.path.join(PLUGIN_HOME, "riskrails", "ai"), os.path.join(PLUGIN_HOME, "ai"))
81
86
  if os.path.isdir(p)), os.path.join(PLUGIN_HOME, "riskrails", "ai"))
82
87
  VERSION_FILE = ".ai/VERSION"
83
88
  ROUTER_FILE = ".ai/AGENTS.md"
84
- # The router must name the shared tree exactly once: a merge that drops the
85
- # section, or doubles it, is a conflict for a human.
86
- SHARED_SECTION_RE = re.compile(rb"(?m)^## Shared files[ \t]*\r?$")
87
- SHARED_SCHEMA = 6 # the schema from which shared copies are overrides (0006)
88
89
  STALE_DAYS = 7 # an unclosed task whose last event is older is proposed for cleanup
89
90
  STATE_FILE = ".ai/state/state.json"
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}
97
-
98
-
99
- def state_file(plan):
100
- """The task state's path as this run leaves the project: state.json once it
101
- exists there (migration 0009 moved it, or the task started under it), else
102
- the legacy current.json."""
103
- if not plan.exists(STATE_FILE) and plan.exists(LEGACY_STATE_FILE):
104
- return LEGACY_STATE_FILE
105
- return STATE_FILE
91
+ TESTING_MD = ".ai/policies/testing.md"
92
+ VERIFICATION_MD = ".ai/policies/verification.md"
93
+ WEIGHT_JSON, WEIGHT_MD = ".ai/policies/weight.json", ".ai/policies/weight.md"
94
+ WEIGHT_PROJECT = ".ai/policies/weight.project.json"
95
+ LOCAL_CACHE = ".ai/local/plugin-update"
96
+
97
+ # D2. GENERATED targets copied from a template as they are. weight.md is rendered,
98
+ # .ai/VERSION is written last, the managed blocks and the runtimes' memory READMEs
99
+ # come from the maps below.
100
+ GENERATED = (ROUTER_FILE, ".ai/policies/path-guard.json", ".ai/policies/coding.md", ".ai/policies/safety.md",
101
+ ".ai/policies/security.md", TESTING_MD, ".ai/rules/README.md", ".ai/state/README.md")
102
+ # Template files created when missing and never touched again: the project's own from then on.
103
+ CREATE_ONLY = (VERIFICATION_MD, WEIGHT_PROJECT, ".ai/reports/.gitkeep")
104
+ CREATE_ONLY_DIRS = (".ai/project/",)
105
+ # Template files that are not copied: written last, the plugin's own table, the human's switch.
106
+ NOT_COPIED = (VERSION_FILE, WEIGHT_JSON, WEIGHT_MD, ".ai/policies/dialog.json")
107
+ KEPT = (".ai/project/", ".ai/reports/", ".ai/memory/", ".ai/state/", "docs/sdlc/", ".ai/local/memory/",
108
+ WEIGHT_PROJECT, VERIFICATION_MD, ".ai/policies/dialog.json", ".ai/policies/memory.json",
109
+ ".claude/settings.json", ".codex/config.toml", ".gitignore")
110
+
111
+
112
+ def is_kept(path):
113
+ """True for a path update.py never rewrites. A GENERATED path under a KEPT
114
+ directory (.ai/state/README.md) is generated: the two sets are disjoint."""
115
+ if path in GENERATED:
116
+ return False
117
+ return any(path == k or (k.endswith("/") and path.startswith(k)) for k in KEPT)
118
+
119
+
120
+ # The shared set, literally: the plugin's tree must equal it (tested). A project
121
+ # copy identical to a shipped version is offered for deletion; an edited one is an override.
122
+ SHARED_SET = frozenset((
123
+ ".ai/policies/production.md", ".ai/policies/database.md", ".ai/policies/git.md", ".ai/policies/release.md",
124
+ ".ai/policies/context-management.md", ".ai/policies/tooling.md", ".ai/policies/model-routing.md",
125
+ ".ai/policies/review-economy.md",
126
+ ".ai/agents/context.md", ".ai/agents/discovery.md", ".ai/agents/implementer.md",
127
+ ".ai/agents/planner.md", ".ai/agents/release.md", ".ai/agents/reviewer.md", ".ai/agents/risk.md",
128
+ ".ai/agents/security.md", ".ai/agents/tester.md",
129
+ ".ai/templates/task-context.md", ".ai/templates/impact-report.md", ".ai/templates/review-report.md",
130
+ ".ai/templates/release-report.md", ".ai/templates/architecture-decision.md",
131
+ ".ai/templates/implementation-plan.md",
132
+ ".ai/using-riskrails.md", ".ai/workflows.md",
133
+ ))
106
134
  RULES_DIR = ".ai/rules"
107
135
  # Directories a stale rule block is never hunted in: a dependency's instruction
108
136
  # file is not ours (the path guard refuses it too), and .git is not text.
@@ -114,38 +142,17 @@ RULES_SKIP = {".git", ".ai", "vendor", "node_modules", "bower_components",
114
142
  "third_party", "Pods", "site-packages", ".venv", ".pnpm", ".yarn"}
115
143
  # Compared case-insensitively: on a case-insensitive filesystem `Vendor/` is `vendor/`.
116
144
  RULES_SKIP_FOLDED = frozenset(n.casefold() for n in RULES_SKIP)
117
- START, END = "<!-- riskrails:start -->", "<!-- riskrails:end -->"
118
- OLD_START, OLD_END = b"<!-- claude-agentic:start -->", b"<!-- claude-agentic:end -->" # R35, one version
119
-
120
-
121
- def renamed_markers(block):
122
- """A block the plugin shipped before 3.4b, as it reads under the current
123
- markers: the version an unedited block was installed from still matches it."""
124
- return block.replace(OLD_START, START.encode()).replace(OLD_END, END.encode())
125
- # The marker lookup is shared with render_instructions (block_of) and adopt:
126
- # one place knows the legacy Codex-agentic pair.
127
145
  find_block = render_instructions.find_block
128
- current_markers = render_instructions.current_markers
129
- MISSING = object()
130
-
131
- PROJECT_INIT_MAP = [ # template -> target, kind
132
- ("sdlc-README.md", "docs/sdlc/README.md", "update"),
133
- # read in place by /rr-spec and /rr-plan; a project copy is an override: never created
134
- ("intent.md", "docs/sdlc/intent/TEMPLATE.md", "retire"),
135
- ("spec.md", "docs/sdlc/specs/TEMPLATE.md", "retire"),
136
- ("plan.md", "docs/sdlc/plans/TEMPLATE.md", "retire"),
137
- ("adr.md", "docs/sdlc/adr/TEMPLATE.md", "retire"),
138
- # the project's own principles: created once, then it is the project's file
139
- ("constitution.md", "docs/sdlc/constitution.md", "create"),
140
- ]
141
- RUNTIME_MAP = { # the per-runtime layer of the project-init scaffold
142
- "claude": [("memory-README.md", ".claude/memory/README.md", "update"),
146
+
147
+ RUNTIME_MAP = { # the per-runtime layer of the project-init scaffold: template, target, kind
148
+ "claude": [("memory-README.md", ".claude/memory/README.md", "regenerate"),
143
149
  ("project-settings.json", ".claude/settings.json", "create")],
144
- "codex": [("memory-README.md", ".codex/memory/README.md", "update"),
150
+ "codex": [("memory-README.md", ".codex/memory/README.md", "regenerate"),
145
151
  ("project-config.toml", ".codex/config.toml", "create")],
146
- "gemini": [("memory-README.md", ".gemini/memory/README.md", "update")],
147
- "junie": [("memory-README.md", ".junie/memory/README.md", "update")],
152
+ "gemini": [("memory-README.md", ".gemini/memory/README.md", "regenerate")],
153
+ "junie": [("memory-README.md", ".junie/memory/README.md", "regenerate")],
148
154
  }
155
+ SDLC_CREATE = (("sdlc-README.md", "docs/sdlc/README.md"), ("constitution.md", "docs/sdlc/constitution.md"))
149
156
  INSTRUCTION_FILE = { # runtime -> (file, block template, minimal template)
150
157
  "claude": ("CLAUDE.md", "CLAUDE.block.md", "CLAUDE.minimal.md"),
151
158
  "codex": ("AGENTS.md", "AGENTS.block.md", "AGENTS.minimal.md"),
@@ -153,33 +160,6 @@ INSTRUCTION_FILE = { # runtime -> (file, block template, minimal template)
153
160
  "junie": (".junie/guidelines.md", "junie-guidelines.block.md",
154
161
  "junie-guidelines.minimal.md"),
155
162
  }
156
- SKELETON = { # runtime -> the project-init template that scaffolds its instruction file
157
- "claude": "CLAUDE.md",
158
- "codex": "AGENTS.md",
159
- "gemini": "GEMINI.md",
160
- "junie": "junie-guidelines.md",
161
- }
162
-
163
-
164
- def instruction_history_keys():
165
- """{instruction file: [history keys]} — a root instruction file is shipped
166
- twice, as the project-init skeleton that scaffolds it and as the ai-init
167
- minimal template, so a migration that matches shipped text must see both."""
168
- out = {}
169
- for runtime, (name, _, minimal_tpl) in INSTRUCTION_FILE.items():
170
- out[name] = ["project-init/" + SKELETON[runtime], "ai-init/" + minimal_tpl]
171
- return out
172
-
173
-
174
- def template_targets():
175
- """{project target: history key} for the templates that land outside .ai/.
176
- Every runtime's entries, not only this project's, so a rename resolves the
177
- same way for a Claude, a Codex and a dual-runtime project."""
178
- out = {target: "project-init/" + src for src, target, _ in PROJECT_INIT_MAP}
179
- for entries in RUNTIME_MAP.values():
180
- for src, target, _ in entries:
181
- out[target] = "project-init/" + src
182
- return out
183
163
 
184
164
 
185
165
  def project_runtimes(root):
@@ -212,75 +192,41 @@ def read(path):
212
192
 
213
193
 
214
194
  class History:
215
- def __init__(self, root, renames=None):
195
+ """index.json answers "was this ever shipped" from hashes alone; the blobs
196
+ give the one-time split its base values."""
197
+
198
+ def __init__(self, root):
216
199
  try:
217
200
  with open(os.path.join(root, "index.json"), encoding="utf-8") as fh:
218
201
  self.index = json.load(fh)
219
202
  except (OSError, ValueError, RecursionError):
220
203
  self.index = {}
221
204
  self.root = root
222
- # {new key: [old keys]} from the migrations' MOVES: a template that moved
223
- # keeps the versions it was shipped under its old name.
224
- self.renames = renames or {}
225
-
226
- def chain(self, key):
227
- """A template's keys, the oldest name first, ending with this one. The
228
- registry rules out cycles; the visited set makes the walk safe anyway."""
229
- out, seen = [], set()
230
-
231
- def walk(k):
232
- if k in seen:
233
- return
234
- seen.add(k)
235
- for old in self.renames.get(k, []):
236
- walk(old)
237
- out.append(k)
238
- walk(key)
239
- return out
240
205
 
241
206
  def versions(self, key):
242
- """Every shipped version of a template, oldest first, as bytes. A file
243
- the user edited before a rename is merged against the version it was
244
- installed from, which was shipped under the old key."""
207
+ """Every shipped version of a template, oldest first, as bytes."""
245
208
  out = []
246
- for k in self.chain(key):
247
- for h in self.index.get(k, []):
248
- try:
249
- out.append(read(os.path.join(self.root, "blobs", h)))
250
- except OSError:
251
- pass
209
+ for h in self.index.get(key, []):
210
+ try:
211
+ out.append(read(os.path.join(self.root, "blobs", h)))
212
+ except OSError:
213
+ pass
252
214
  return out
253
215
 
254
216
  def hashes(self, key):
255
- """The sha of every version shipped under this key or a name it had
256
- before. index.json alone answers it: a version whose blob is gone still
257
- counts as shipped."""
258
- return {h for k in self.chain(key) for h in self.index.get(k, [])}
259
-
260
-
261
- _DECLARED = None
217
+ """The sha of every version shipped under this key; a version whose blob is gone still counts."""
218
+ return set(self.index.get(key, []))
262
219
 
263
-
264
- def declared_shared():
265
- """The shared set as 0006 declares it, literally — read from the migration
266
- shipped beside this script, whatever registry RISKRAILS_MIGRATIONS
267
- points at. What the installed tree holds never widens it: a file planted
268
- there must not turn a project's own file into a deletion candidate."""
269
- global _DECLARED # pylint: disable=global-statement # loaded once per run
270
- if _DECLARED is None:
271
- path = os.path.join(HERE, "migrations", "0006_shared_files.py")
272
- spec = importlib.util.spec_from_file_location("riskrails_shared_set", path)
273
- mod = importlib.util.module_from_spec(spec)
274
- spec.loader.exec_module(mod)
275
- _DECLARED = frozenset(mod.SHARED)
276
- return _DECLARED
220
+ def shipped(self, key, data):
221
+ """True when these bytes are a version the plugin shipped, also after a checkout turned its line endings into CRLF."""
222
+ known = self.hashes(key)
223
+ return sha(data) in known or sha(data.replace(b"\r\n", b"\n")) in known
277
224
 
278
225
 
279
226
  def shared_files():
280
- """{".ai/<rel>": bytes} for every declared shared file the shared tree
281
- holds; empty when the tree is not installed."""
227
+ """{".ai/<rel>": bytes} for every shared file the shared tree holds; empty when the tree is not installed."""
282
228
  out = {}
283
- for path in sorted(declared_shared()):
229
+ for path in sorted(SHARED_SET):
284
230
  full = os.path.join(SHARED, path[len(".ai/"):])
285
231
  if os.path.isfile(full):
286
232
  out[path] = read(full)
@@ -288,12 +234,12 @@ def shared_files():
288
234
 
289
235
 
290
236
  def undeclared_shared():
291
- """Files in the shared tree outside the declared set, as .ai/ paths."""
237
+ """Files in the shared tree outside the shared set, as .ai/ paths."""
292
238
  out = []
293
239
  for here, _dirs, files in os.walk(SHARED):
294
240
  for name in files:
295
241
  rel = ".ai/" + os.path.relpath(os.path.join(here, name), SHARED).replace(os.sep, "/")
296
- if rel not in declared_shared():
242
+ if rel not in SHARED_SET:
297
243
  out.append(rel)
298
244
  return sorted(out)
299
245
 
@@ -357,98 +303,115 @@ def git_states(root, rel_dir):
357
303
  return classify
358
304
 
359
305
 
360
- # ---------------------------------------------------------------- merging
361
- def closest(candidates, ours, score):
362
- best, best_score = None, -1.0
363
- for c in candidates: # oldest first; ties keep the older one
364
- s = score(c, ours)
365
- if s > best_score:
366
- best, best_score = c, s
367
- return best
306
+ def dump_json(value):
307
+ return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode()
308
+
368
309
 
310
+ def unsafe_path(root, path):
311
+ """Why a project-relative path must not be deleted: a symlink on the way to it,
312
+ or a real path outside the project; None when it is safe. Run at plan time and
313
+ again right before the delete."""
314
+ real_root = os.path.realpath(root)
315
+ here = root
316
+ for part in path.split("/"):
317
+ here = os.path.join(here, part)
318
+ if os.path.islink(here):
319
+ return "%s is a symlink" % os.path.relpath(here, root).replace(os.sep, "/")
320
+ real = os.path.realpath(os.path.join(root, path))
321
+ if real != real_root and not real.startswith(real_root + os.sep):
322
+ return "%s resolves outside the project (%s)" % (path, real)
323
+ return None
369
324
 
370
- def text_ratio(a, b):
371
- return difflib.SequenceMatcher(None, a.splitlines(), b.splitlines(), autojunk=False).ratio()
325
+ # ---------------------------------------------------------------- the one gate
326
+ # Set in the environment of a shell an agent runs: Claude Code's CLAUDECODE,
327
+ # and the names Codex gives its tool calls. Present counts, an empty value too.
328
+ AGENT_MARKERS = ("CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "CODEX_CI",
329
+ "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_VERSION")
372
330
 
373
331
 
374
- def merge_text(ours, base, theirs):
375
- with tempfile.TemporaryDirectory() as d:
376
- paths = [os.path.join(d, n) for n in ("ours", "base", "theirs")]
377
- for p, c in zip(paths, (ours, base, theirs)):
378
- with open(p, "wb") as fh:
379
- fh.write(c)
380
- r = subprocess.run(["git", "merge-file", "-p", "--quiet", *paths], capture_output=True, check=False)
381
- return r.stdout if r.returncode == 0 else None
332
+ def ai_unattended():
333
+ """AI_UNATTENDED is set when it is non-empty and not 0, false or no (case
334
+ and blanks ignored): one rule in every reader (risk 0587)."""
335
+ return os.environ.get("AI_UNATTENDED", "").strip().lower() not in ("", "0", "false", "no")
382
336
 
383
337
 
384
- def flatten(value, prefix=""):
385
- if isinstance(value, dict):
386
- out = {}
387
- for k, v in value.items():
388
- out.update(flatten(v, prefix + ("." if prefix else "") + str(k)))
389
- return out or {prefix: {}}
390
- return {prefix: value}
338
+ def human_gate():
339
+ """(passed, via, why) for --confirm-delete. A terminal outside any agent
340
+ session is a human: via "terminal". Otherwise a launcher that declared the
341
+ run unattended (AI_UNATTENDED) passes: via "unattended", as state.py's
342
+ approve records it. Anything else is refused, and `why` says what was seen —
343
+ a terminal alone is not enough, since an agent can open a pty (F3).
391
344
 
345
+ Duplicated in spirit from skills/ai-task/state.py (human_present) on purpose:
346
+ the two skills are installed independently and neither imports the other."""
347
+ markers = [name for name in AGENT_MARKERS if name in os.environ]
348
+ if os.isatty(0) and not markers:
349
+ return True, "terminal", ""
350
+ if ai_unattended():
351
+ return True, "unattended", ""
352
+ if markers:
353
+ return False, None, ("this run is inside an agent session (%s %s set); run it in a terminal "
354
+ "outside Claude Code or Codex, not through the `!` prefix"
355
+ % (", ".join(markers), "is" if len(markers) == 1 else "are"))
356
+ return False, None, "this run has no terminal"
392
357
 
393
- def json_score(base, ours):
394
- fb, fo = flatten(base), flatten(ours)
395
- return sum(1 for k, v in fo.items() if k in fb and fb[k] == v)
396
-
397
-
398
- def merge_json(base, ours, theirs, path, conflicts):
399
- if ours == theirs:
400
- return ours
401
- if ours == base:
402
- return theirs
403
- if theirs == base:
404
- return ours
405
- if isinstance(ours, dict) and isinstance(theirs, dict) and (base is MISSING or isinstance(base, dict)):
406
- b = base if isinstance(base, dict) else {}
407
- out = OrderedDict()
408
- for k in list(ours) + [k for k in theirs if k not in ours]:
409
- v = merge_json(b.get(k, MISSING), ours.get(k, MISSING), theirs.get(k, MISSING), path + [k], conflicts)
410
- if v is not MISSING:
411
- out[k] = v
412
- return out
413
- conflicts.append(".".join(map(str, path)) or "(root)")
414
- return ours
415
358
 
359
+ # The name typed into --confirm-delete is the audit trail; the template's own
360
+ # "<your name>" copied verbatim names no one. The whole value, not a substring.
416
361
 
417
- def dump_json(value):
418
- return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode()
419
362
 
363
+ # The name typed into --confirm-delete is the audit trail; the template's own
364
+ # "<your name>" copied verbatim names no one. The whole value, not a substring.
365
+ PLACEHOLDER_NAME_RE = re.compile(r"\A\s*(?:<[^>]*>|your\s+name)\s*\Z", re.IGNORECASE)
420
366
 
421
- def leaf_changes(before, after):
422
- """Changed leaf paths; a whole new or removed subtree is reported once, at its top."""
423
- fb, fa = flatten(before), flatten(after)
424
-
425
- def top(key, other):
426
- parts = key.split(".")
427
- for n in range(1, len(parts) + 1):
428
- prefix = ".".join(parts[:n])
429
- if not any(k == prefix or k.startswith(prefix + ".") for k in other):
430
- return prefix
431
- return key
432
-
433
- out = set()
434
- for k in set(fb) | set(fa):
435
- if k not in fb:
436
- out.add("+ " + top(k, fb))
437
- elif k not in fa:
438
- out.add("- " + top(k, fa))
439
- elif fb[k] != fa[k]:
440
- out.add("~ " + k)
441
- return sorted(out, key=lambda c: c[2:])
442
-
443
-
444
- # ---------------------------------------------------------------- schema
445
- def detect_version(plan):
446
- """A project's schema version: None when .ai/ is absent (not an agentic project),
447
- 0 when .ai/ has no VERSION (every project scaffolded before schema 1)."""
448
- if not os.path.isdir(os.path.join(plan.root, ".ai")):
449
- return None
367
+
368
+ def placeholder_name(name):
369
+ return bool(PLACEHOLDER_NAME_RE.match(name or ""))
370
+
371
+
372
+ def refuse(reason, how):
373
+ """Print the refusal the way every gate does — the marker on the first
374
+ stdout line, then what to do — and return exit code 5."""
375
+ print("%s: %s" % (REFUSED, reason))
376
+ print(" %s" % how)
377
+ return 5
378
+
379
+
380
+ def budget_offenders(root, files, caps, block_of):
381
+ """R21: one line per root instruction file over budget — its managed block
382
+ over `caps["project"]`, or the whole file over `caps["skeleton"]`. Files that
383
+ do not exist are skipped; a file under both budgets prints nothing."""
384
+ out = []
385
+ for name in files:
386
+ path = os.path.join(root, name)
387
+ if not os.path.isfile(path):
388
+ continue
389
+ with open(path, encoding="utf-8") as fh:
390
+ text = fh.read()
391
+ block = block_of(text)
392
+ size = len(text.encode("utf-8"))
393
+ if block is not None and caps.get("project") and len(block.encode("utf-8")) > caps["project"]:
394
+ out.append("%s block %d B over the project budget of %d B"
395
+ % (name, len(block.encode("utf-8")), caps["project"]))
396
+ if caps.get("skeleton") and size > caps["skeleton"]:
397
+ out.append("%s file %d B over the skeleton budget of %d B" % (name, size, caps["skeleton"]))
398
+ return out
399
+
400
+
401
+
402
+
403
+ # ---------------------------------------------------------------- the kept files
404
+ def template_version():
405
+ """The schema number this plugin ships (its .ai/VERSION template)."""
406
+ return int(read(os.path.join(TEMPLATES["ai-init"], VERSION_FILE)).decode().strip())
407
+
408
+
409
+ def project_version(root):
410
+ """The project's .ai/VERSION: 0 when there is none; SchemaError when it cannot
411
+ be read, is not a number, or is newer than this plugin's."""
412
+ path = os.path.join(root, VERSION_FILE)
450
413
  try:
451
- raw = plan.read(VERSION_FILE)
414
+ raw = read(path) if os.path.lexists(path) else None
452
415
  except OSError as exc: # a directory, a mode-000 file, a dangling link
453
416
  raise SchemaError("%s cannot be read: %s" % (VERSION_FILE, exc)) from exc
454
417
  if raw is None:
@@ -459,363 +422,151 @@ def detect_version(plan):
459
422
  text = ""
460
423
  if not re.fullmatch(r"\d+", text):
461
424
  raise SchemaError("%s is not a schema number: %r" % (VERSION_FILE, raw[:40]))
425
+ ours = template_version()
426
+ if int(text) > ours:
427
+ raise SchemaError("%s says schema %s, this plugin ships schema %d — update the plugin "
428
+ "(riskrails/install.sh)" % (VERSION_FILE, text, ours))
462
429
  return int(text)
463
430
 
464
431
 
465
- def unsafe_path(root, path):
466
- """Why a project-relative path must not be deleted: a symlink on the way to it,
467
- or a real path outside the project; None when it is safe. Run at plan time and
468
- again right before the delete."""
469
- real_root = os.path.realpath(root)
470
- here = root
471
- for part in path.split("/"):
472
- here = os.path.join(here, part)
473
- if os.path.islink(here):
474
- return "%s is a symlink" % os.path.relpath(here, root).replace(os.sep, "/")
475
- real = os.path.realpath(os.path.join(root, path))
476
- if real != real_root and not real.startswith(real_root + os.sep):
477
- return "%s resolves outside the project (%s)" % (path, real)
478
- return None
432
+ _MODULES = {}
479
433
 
480
434
 
481
- class MigrationContext:
482
- """What a migration sees: the project as this run leaves it, not the disk."""
483
-
484
- def __init__(self, plan, module, version_from, state, history):
485
- self.plan = plan
486
- self.module = module
487
- self.version_from = version_from
488
- self.state = state
489
- self.history = history
490
- self.root = plan.root
491
- self.runtimes = project_runtimes(plan.root)
492
-
493
- # ---- what the plugin shipped, so a migration can recognise its own text
494
- # instead of matching a regular expression against the project's prose.
495
- def instruction_files(self):
496
- """The root instruction files this project actually has, one per runtime
497
- it declares."""
498
- return [INSTRUCTION_FILE[rt][0] for rt in self.runtimes
499
- if self.exists(INSTRUCTION_FILE[rt][0])]
500
-
501
- def shipped(self, path):
502
- """Every version of every template the plugin ever shipped at this
503
- project path, oldest first. Renamed templates come with it: History
504
- follows the MOVES chain and RETIRED."""
505
- keys = list(instruction_history_keys().get(path, []))
506
- key = migrations.template_key(path, template_targets())
507
- if key and key not in keys:
508
- keys.append(key)
509
- out = []
510
- for k in keys:
511
- for version in self.history.versions(k):
512
- if version not in out:
513
- out.append(version)
514
- return out
435
+ def sibling(name, *parts):
436
+ """A module of another skill, loaded by path once per run (skills are installed independently)."""
437
+ if name not in _MODULES:
438
+ spec = importlib.util.spec_from_file_location("riskrails_" + name, os.path.join(SKILLS, *parts))
439
+ _MODULES[name] = importlib.util.module_from_spec(spec)
440
+ spec.loader.exec_module(_MODULES[name])
441
+ return _MODULES[name]
515
442
 
516
- def block_status(self, path):
517
- """"none" when this file has no managed block, "shipped" when the block
518
- is one the plugin installed, "edited" when a human changed it inside the
519
- markers — the one case where the plugin never writes over it."""
520
- text = self.read(path)
521
- runtime = next((rt for rt, entry in INSTRUCTION_FILE.items() if entry[0] == path), None)
522
- if text is None or runtime is None:
523
- return "none"
524
- found = find_block(text)
525
- if found is None:
526
- return "none"
527
- start, end, start_marker, end_marker = found
528
- ours = current_markers(text[start:end], start_marker, end_marker) + b"\n"
529
- block_tpl = INSTRUCTION_FILE[runtime][1]
530
- tpl = read(os.path.join(TEMPLATES["ai-init"], block_tpl))
531
- if ours == tpl or ours in map(renamed_markers, self.history.versions("ai-init/" + block_tpl)):
532
- return "shipped"
533
- return "edited"
534
-
535
- def template(self, path):
536
- """The bytes this plugin ships today for a project path: the scaffold
537
- template, or the shared tree's copy once the template is retired."""
538
- self._check_path(path)
539
- if path.startswith(".ai/"):
540
- rel = path[len(".ai/"):]
541
- candidates = [os.path.join(TEMPLATES["ai-init"], ".ai", rel), os.path.join(SHARED, rel)]
542
- else:
543
- key = template_targets().get(path)
544
- candidates = [os.path.join(TEMPLATES["project-init"], key.split("/", 1)[1])] if key else []
545
- for full in candidates:
546
- if os.path.isfile(full):
547
- return read(full)
548
- raise SchemaError("%04d: the plugin ships no template for %s" % (self.module.VERSION, path))
549
-
550
- def is_shipped(self, path, data):
551
- """True when these bytes are a version the plugin shipped at this path,
552
- also after a checkout turned its line endings into CRLF."""
553
- key = migrations.template_key(path, template_targets())
554
- if key is None or data is None:
555
- return False
556
- known = self.history.hashes(key)
557
- return sha(data) in known or sha(data.replace(b"\r\n", b"\n")) in known
558
443
 
559
- def require_shared(self, paths):
560
- """Refuse, before anything is written, to drop a project copy whose
561
- shared fallback is missing: in this plugin's tree, or in another
562
- installed home of a runtime the project declares. A home this machine
563
- does not have is not checked."""
564
- undeclared = sorted(p for p in paths if (p if p.startswith(".ai/") else ".ai/" + p) not in declared_shared())
565
- if undeclared:
566
- raise SchemaError("%04d: not in the shared set, never deleted as a shared copy: %s"
567
- % (self.module.VERSION, ", ".join(undeclared)))
568
- rels = sorted(p[len(".ai/"):] if p.startswith(".ai/") else p for p in paths)
569
- missing = [r for r in rels if not os.path.isfile(os.path.join(SHARED, r))]
570
- if missing:
571
- raise SchemaError("%04d: the plugin's shared tree %s lacks %d file(s) a project copy would "
572
- "fall back to: %s — reinstall the plugin (install.sh), then run this again"
573
- % (self.module.VERSION, SHARED, len(missing), ", ".join(missing)))
574
- homes = {"claude": os.path.join(os.path.expanduser("~"), ".claude"),
575
- "codex": os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")}
576
- mine = os.path.realpath(PLUGIN_HOME)
577
- for runtime in self.runtimes:
578
- home = homes.get(runtime)
579
- if (not home or os.path.realpath(home) == mine
580
- or not os.path.isdir(os.path.join(home, "skills", "project-update"))):
581
- continue
582
- tree = os.path.join(home, "riskrails", "ai")
583
- if not os.path.isdir(tree): # installed before 3.4b (R35, one version)
584
- tree = os.path.join(home, "claude-agentic", "ai")
585
- missing = [r for r in rels if not os.path.isfile(os.path.join(tree, r))]
586
- if missing:
587
- raise SchemaError("%04d: %s has this plugin installed for %s, but %d of the %d shared "
588
- "file(s) a project copy would fall back to are missing from %s — "
589
- "run `install.sh --target %s` from this build, then run this again"
590
- % (self.module.VERSION, home, runtime, len(missing), len(rels),
591
- tree, runtime))
592
-
593
- def unsafe_target(self, path):
594
- """Why a file must not be deleted through this path, or None: it is a
595
- symlink, it is reached through one, or it resolves outside the project."""
596
- self._check_path(path)
597
- return unsafe_path(self.root, path)
598
-
599
- def override_note(self, path):
600
- """What an edited copy of a shared file now means."""
601
- rel = path[len(".ai/"):] if path.startswith(".ai/") else path
602
- return ("%s overrides the plugin's shared copy and no longer receives its changes — "
603
- "diff against %s; `ai-path %s` shows which applies; delete it when it adds nothing"
604
- % (path, os.path.join(SHARED, rel), rel))
605
-
606
- def hint(self, text):
607
- """Say something to the human that is not an operation on a file."""
608
- if text not in self.plan.hints:
609
- self.plan.hints.append(text)
610
-
611
- def conflict(self, path, note):
612
- """Hold the schema version on a file this migration could not handle; `note` says why."""
613
- self._add("conflict", path, note)
614
-
615
- def exists(self, path):
616
- return self.read(path) is not None
617
-
618
- def read(self, path):
619
- self._check_path(path)
620
- try:
621
- return self.plan.read(path)
622
- except OSError as exc:
623
- raise SchemaError("%04d: %s cannot be read: %s" % (self.module.VERSION, path, exc)) from exc
444
+ def weight_module():
445
+ """skills/ai-task/weight.py, the renderer behind `state.py weight --mirror`."""
446
+ return sibling("weight", "ai-task", "weight.py")
624
447
 
625
- def _check_path(self, path):
626
- why = migrations.path_error(path)
627
- if why:
628
- raise SchemaError("%04d: %r is %s" % (self.module.VERSION, path, why))
629
448
 
630
- def read_json(self, path):
631
- raw = self.read(path)
632
- if raw is None:
449
+ def weight_overlay():
450
+ """skills/ai-task/weight_overlay.py: the plugin's table with the project's laid over it."""
451
+ return sibling("weight_overlay", "ai-task", "weight_overlay.py")
452
+
453
+
454
+ def shipped_weight():
455
+ return os.path.join(TEMPLATES["ai-init"], WEIGHT_JSON)
456
+
457
+
458
+ # Old shapes the update no longer converts: literal checks, nothing parsed (D4).
459
+ OLD_MARKERS = (b"<!-- claude-agentic:", b"<!-- Codex-agentic:")
460
+
461
+
462
+ def check_kept(root):
463
+ """[(path, reason)] for every kept file its own reader cannot take and every old
464
+ shape this version no longer converts. Reads only: the caller refuses on a
465
+ non-empty answer before anything is written (R12)."""
466
+ bad = []
467
+
468
+ def raw_of(rel):
469
+ path = os.path.join(root, rel)
470
+ if not os.path.lexists(path):
633
471
  return None
634
472
  try:
635
- return json.loads(raw, object_pairs_hook=OrderedDict)
636
- except ValueError:
637
- return None
638
- except RecursionError:
639
- # Not "absent": a file nested past the parser's limit is there. The migration goes on
640
- # as if it were absent (0007 then creates a default weight.json beside it, overwriting
641
- # nothing); the conflict holds the schema version, so the next run tries again.
642
- self._add("conflict", path, "nested too deeply, so this migration could not read it")
473
+ return read(path)
474
+ except OSError as exc:
475
+ bad.append((rel, "cannot be read: %s" % (exc.strerror or exc)))
643
476
  return None
644
477
 
645
- # ---- operations. Each is idempotent: a target already in its final state
646
- # records nothing, so a re-run after an interrupted apply is a no-op.
647
- def _add(self, action, target, note="", **kw):
648
- self._check_path(target)
649
- if not isinstance(kw.get("content", b""), bytes):
650
- raise SchemaError("%04d: %s content must be bytes, not %s"
651
- % (self.module.VERSION, target, type(kw["content"]).__name__))
652
- if not isinstance(note, str):
653
- raise SchemaError("%04d: %s note must be str, not %s"
654
- % (self.module.VERSION, target, type(note).__name__))
655
- deep = action == "conflict" and isinstance(note, str) and note.startswith("nested too deeply")
656
- if action == "conflict" and any(i["action"] == "conflict" and i["target"] == target
657
- and i["migration"] == self.module.VERSION
658
- and (i["note"] == note or deep and isinstance(i["note"], str)
659
- and i["note"].startswith("nested too deeply"))
660
- for i in self.plan.items):
661
- return # one deep file read twice, or read then edited: one row, one held schema
662
- self.plan.add(action, target, note, migration=self.module.VERSION, **kw)
663
-
664
- def create(self, path, content):
665
- """Add a file the project does not have yet."""
666
- if not self.exists(path):
667
- self._add("create", path, content=content)
668
-
669
- def move(self, src, dst):
670
- """Move a file the project may have edited. The pair must be in MOVES, so
671
- the rename is visible to the history lookup without running the migration."""
672
- if (src, dst) not in self.module.MOVES:
673
- raise SchemaError("%04d: move(%r, %r) is not in MOVES" % (self.module.VERSION, src, dst))
674
- data = self.read(src)
675
- if data is None: # already moved, or the project never had it
676
- return
677
- there = self.read(dst)
678
- if there is not None and there != data:
679
- self._add("conflict", dst, "%s was moved here, but this file already exists" % src,
680
- conflict_copy=data, src=src)
681
- return
682
- # there == data means a previous apply wrote dst and stopped before
683
- # removing src: the move simply finishes.
684
- self._add("move", dst, content=data, src=src)
685
- self.plan.remove(src)
686
-
687
- def edit_text(self, path, fn, note="migrated"):
688
- """Rewrite a text file the project has; fn returns its input to do nothing."""
689
- data = self.read(path)
690
- if data is None:
691
- return
692
- new = fn(data)
693
- if new != data:
694
- self._add("update", path, note, content=new)
695
-
696
- def edit_json(self, path, fn):
697
- """Rewrite a JSON file the project has. A policy file's leaf changes are
698
- listed for confirmation, exactly as a merged policy change is. fn gets a
699
- deep copy of the document and returns the new one. It must not walk the
700
- document with Python recursion (a file that just passed copy.deepcopy can
701
- be 470-490 levels deep at the default limit), or it guards RecursionError
702
- itself: an unguarded fn's RecursionError is reported as its own, the run
703
- fails and no file is blamed."""
704
- raw = self.read(path)
705
- if raw is None:
706
- return
478
+ def json_object(rel, data):
707
479
  try:
708
- before = json.loads(raw, object_pairs_hook=OrderedDict)
709
- except ValueError as exc:
710
- # Not "nothing to do": the migration could not run. A conflict holds
711
- # the schema version, so the next run tries again.
712
- self._add("conflict", path, "is not valid JSON, so this migration could not run (%s)" % exc)
713
- return
714
- except RecursionError:
715
- self._add("conflict", path, "nested too deeply, so this migration could not run")
716
- return
480
+ doc = json.loads(data.decode("utf-8"))
481
+ except (ValueError, RecursionError) as exc: # UnicodeDecodeError is a ValueError
482
+ bad.append((rel, "is not valid JSON (%s)" % (type(exc).__name__ if isinstance(exc, RecursionError) else exc)))
483
+ return None
484
+ if not isinstance(doc, dict):
485
+ bad.append((rel, "is not a JSON object"))
486
+ return None
487
+ return doc
488
+
489
+ for rel in (STATE_FILE, ".ai/policies/dialog.json", ".ai/policies/memory.json", ".claude/settings.json"):
490
+ data = raw_of(rel)
491
+ if data is not None:
492
+ json_object(rel, data)
493
+ data = raw_of(WEIGHT_PROJECT)
494
+ if data is not None:
495
+ doc = json_object(WEIGHT_PROJECT, data)
496
+ if doc is not None and doc.get("version") != 1:
497
+ bad.append((WEIGHT_PROJECT, "has no \"version\": 1"))
498
+ elif doc is not None:
499
+ try:
500
+ weight_overlay().load(shipped_weight(), os.path.join(root, WEIGHT_PROJECT))
501
+ except ValueError as exc:
502
+ bad.append((WEIGHT_PROJECT, str(exc).split(": ", 1)[-1]))
503
+ data = raw_of(VERIFICATION_MD)
504
+ if data is not None:
717
505
  try:
718
- copied = copy.deepcopy(before)
719
- except RecursionError:
720
- self._add("conflict", path, "nested too deeply, so this migration could not run")
721
- return
722
- # Outside the guard: an unguarded fn's RecursionError is reported as its own, not the file's.
723
- after = fn(copied)
506
+ data.decode("utf-8")
507
+ except UnicodeDecodeError:
508
+ bad.append((VERIFICATION_MD, "is not UTF-8 text"))
509
+ data = raw_of(".codex/config.toml")
510
+ if data is not None and tomllib is not None:
724
511
  try:
725
- if after == before:
726
- return
727
- policy = leaf_changes(before, after) if "/policies/" in "/" + path else None
728
- content = dump_json(after)
729
- except RecursionError: # comparing or writing past the limit
730
- self._add("conflict", path, "nested too deeply, so this migration could not run")
731
- return
732
- self._add("update", path, "migrated", content=content, policy=policy)
512
+ tomllib.loads(data.decode("utf-8"))
513
+ except ValueError as exc: # TOMLDecodeError and UnicodeDecodeError
514
+ bad.append((".codex/config.toml", "is not valid TOML (%s)" % exc))
515
+ # the old weight.json is read once, by the split, so it must parse then
516
+ if not os.path.lexists(os.path.join(root, WEIGHT_PROJECT)):
517
+ data = raw_of(WEIGHT_JSON)
518
+ if data is not None:
519
+ json_object(WEIGHT_JSON, data)
520
+ old_shapes(root, bad)
521
+ return bad
522
+
523
+
524
+ def old_shapes(root, bad):
525
+ """The four literal detectors of an older riskrails' files (D4)."""
526
+ exists = lambda rel: os.path.lexists(os.path.join(root, rel)) # noqa: E731
527
+ if exists(".ai/state/current.json") and not exists(STATE_FILE):
528
+ bad.append((".ai/state/current.json", "is the task state of a riskrails before 3.4b (there is no state.json)"))
529
+ if exists(".ai/policies/risk-tiers.json") and not exists(WEIGHT_JSON) and not exists(WEIGHT_PROJECT):
530
+ bad.append((".ai/policies/risk-tiers.json", "is the risk-tier policy of a riskrails before 2.2 (there is no weight table)"))
531
+ names = set(render_instructions.RULE_FILE.values()) | {e[0] for e in INSTRUCTION_FILE.values()}
532
+ for dirpath, dirnames, filenames in os.walk(root):
533
+ dirnames[:] = sorted(d for d in dirnames if d not in RULES_SKIP
534
+ and not os.path.lexists(os.path.join(dirpath, d, ".git")))
535
+ for name in filenames:
536
+ rel = os.path.relpath(os.path.join(dirpath, name), root).replace(os.sep, "/")
537
+ if name not in names and rel not in names:
538
+ continue
539
+ try:
540
+ data = read(os.path.join(root, rel))
541
+ except OSError:
542
+ continue # an unreadable instruction file is the plan's to report, not an old shape
543
+ found = next((m for m in OLD_MARKERS if m in data), None)
544
+ if found:
545
+ bad.append((rel, "carries %s of a riskrails before 3.4b" % found.decode()))
733
546
 
734
- def patch_state(self, fn): # noqa: D401
735
- """Change the task in flight. update.py writes the state file the
736
- way state.py does — atomically, with a history entry — so a task started
737
- before the migration keeps working after it."""
738
- if self.state is None:
739
- return
740
- after = fn(copy.deepcopy(self.state))
741
- if not isinstance(after, dict):
742
- raise SchemaError("%04d: patch_state must return the state object, not %s"
743
- % (self.module.VERSION, type(after).__name__))
744
- if after == self.state:
745
- return
746
- after["updated_at"] = utc_now()
747
- after.setdefault("history", []).append(
748
- {"at": utc_now(), "event": "schema_migrated",
749
- "detail": "%04d %s" % (self.module.VERSION, self.module.TITLE)})
750
- self.state = after
751
- self._add("update", state_file(self.plan), "task state migrated",
752
- content=(json.dumps(after, indent=2, ensure_ascii=False) + "\n").encode())
753
-
754
- def delete(self, path, reason):
755
- """Propose a deletion. It is only listed until a human passes
756
- --apply --confirm-delete NAME. Three places remove a file, each behind a
757
- human confirmation or a block the plugin owns: this one (a migration),
758
- rules_update (a rendered rule block whose rule is gone) and
759
- adopt.remove_confirmed (--adopt --cleanup, behind --confirm-delete)."""
760
- if not self.exists(path):
761
- return
762
- if not self.plan.confirm_delete:
763
- self._add("delete?", path, reason, reason=reason)
764
- return
765
- self._add("delete", path, reason, reason=reason)
766
- # Out of the virtual tree, so this run's template walk does not put it
767
- # back. A template the plugin still ships is re-created by the next run:
768
- # a migration that deletes must retire the template in the same release.
769
- self.plan.remove(path)
770
-
771
-
772
- def run_migrations(plan, version, history):
773
- """Plan every migration this project is missing, oldest first, before the
774
- three-way merge sees a single file. Returns the modules that ran."""
775
- mods = [m for m in migrations.load() if version < m.VERSION <= migrations.CURRENT]
776
- state = None
777
- path = state_file(plan)
778
- try:
779
- raw = plan.read(path)
780
- except OSError as exc:
781
- raise SchemaError("%s cannot be read: %s — a migration must see the task in flight"
782
- % (path, exc)) from exc
783
- if raw is not None:
784
- # state.py dies loudly on a corrupt state file because ai-scope-guard
785
- # derives its boundaries from it; a migration must not quietly skip it.
786
- try:
787
- state = json.loads(raw, object_pairs_hook=OrderedDict)
788
- except ValueError as exc:
789
- raise SchemaError("%s is not valid JSON (%s) — inspect it by hand" % (path, exc)) from exc
790
- except RecursionError as exc:
791
- raise SchemaError("%s is nested too deeply to parse — inspect it by hand" % path) from exc
792
- if not isinstance(state, dict):
793
- raise SchemaError("%s is not a task state object" % path)
794
- for mod in mods:
795
- # A copy each, taken from the plan: a migration that mutates ctx.state in
796
- # place without patch_state changes nothing for the next one.
797
- planned = plan.final.get(state_file(plan))
798
- if planned is not None:
799
- state = json.loads(planned, object_pairs_hook=OrderedDict)
800
- mod.plan(MigrationContext(plan, mod, version, copy.deepcopy(state), history))
801
- if state is not None:
802
- plan.hints.append("%s: a task is in flight while the schema changes — check that the files "
803
- "its current step may touch still exist afterwards" % state_file(plan))
804
- return mods
547
+
548
+ def kept_refusal(bad):
549
+ """I3, verbatim: one line per failing file, then the rest."""
550
+ for rel, why in bad:
551
+ print("KEPT_UNREADABLE: %s: %s" % (rel, why))
552
+ print("project-update: nothing was deleted or written. This version regenerates its own files and no longer "
553
+ "converts older ones.")
554
+ print("Install riskrails@%s (npx riskrails@%s), run /rr-setup --update with it, then install this version "
555
+ "and run /rr-setup --update again." % (LAST_MIGRATING_VERSION, LAST_MIGRATING_VERSION))
556
+ return EXIT_KEPT_UNREADABLE
805
557
 
806
558
 
807
559
  # ---------------------------------------------------------------- planning
808
560
  class Plan:
809
561
  def __init__(self, root):
810
562
  self.root = root
811
- self.items = [] # dicts: action, target, note, content, conflict_copy, policy
563
+ self.items = [] # dicts: action, target, note, content, reason, keep, expect
812
564
  self.hints = []
813
- self.schema = None # (from, to, [migration modules]) when migrations are pending
814
- self.confirm_delete = None # the human who confirmed this run's deletions
565
+ self.confirm_delete = None # the human who confirmed this run's gated rows
815
566
  self.confirm_via = None # why that gate passed: "terminal" or "unattended"
816
567
  self.confirm_cleanup = None # the human who confirmed this run's [cleanup] deletions
817
568
  self.cleanup_via = None
818
- self.held = None # the schema version kept until a migration item is settled
569
+ self.version_from = None # the .ai/VERSION this run started from
819
570
  self.final = {} # target -> bytes after this run (for cross-file fixes)
820
571
  self.removed = set() # targets this run takes away
821
572
  self.seen = {} # target -> sha of what was on disk when it was first read,
@@ -846,229 +597,115 @@ class Plan:
846
597
  def before(self, target):
847
598
  """The sha of the target as this run first read it, None when it was
848
599
  absent. Recorded by the read that planned the change, so the precondition
849
- and the merge input are the same bytes. A second item for the same target
850
- (a move and then its merge) is checked against what this run wrote."""
600
+ and the planning input are the same bytes."""
851
601
  if target not in self.seen:
852
602
  path = os.path.join(self.root, target)
853
603
  self.seen[target] = sha(read(path)) if os.path.isfile(path) and not os.path.islink(path) else None
854
604
  return self.seen[target]
855
605
 
856
- def add(self, action, target, note="", content=None, conflict_copy=None, policy=None,
857
- migration=None, src=None, reason=None, tool=None):
858
- expect = self.before(target)
606
+ def add(self, action, target, note="", content=None, reason=None, keep=False):
859
607
  self.items.append(dict(action=action, target=target, note=note, content=content,
860
- conflict_copy=conflict_copy, policy=policy, migration=migration,
861
- src=src, reason=reason, tool=tool, expect=expect,
862
- src_expect=self.before(src) if src else None))
608
+ reason=reason, keep=keep, expect=self.before(target)))
863
609
  if content is not None:
864
610
  self.final[target] = content
865
611
  self.removed.discard(target) # planned content wins over an earlier removal
866
612
 
867
- @property
868
- def local_dir(self):
869
- base = ".ai/local/plugin-update" if os.path.isdir(os.path.join(self.root, ".ai")) \
870
- else ".claude/memory/local/plugin-update"
871
- return base
613
+ def original_of(self, target):
614
+ return "%s/original/%s" % (self.report_dir, target)
615
+
616
+ def gated(self, action, target, note, content=None, reason=None):
617
+ """A change that waits for a human: `regenerate?` / `delete?` until
618
+ --apply --confirm-delete NAME, then `regenerate` / `delete` with the original kept
619
+ under original/ (R7)."""
620
+ if self.confirm_delete:
621
+ self.add(action, target, note, content=content, reason=reason, keep=True)
622
+ if action == "delete":
623
+ self.remove(target)
624
+ else:
625
+ self.add(action + "?", target, note, reason=reason)
872
626
 
873
627
 
874
- WEIGHT_JSON, WEIGHT_MD = ".ai/policies/weight.json", ".ai/policies/weight.md"
875
- _WEIGHT = None
628
+ # ---------------------------------------------------------------- regenerate
629
+ def lost_lines(ours, theirs):
630
+ """The lines of the project's copy that the plugin's version does not have."""
631
+ kept = set(theirs.decode("utf-8", "replace").splitlines())
632
+ return [line.strip() for line in ours.decode("utf-8", "replace").splitlines()
633
+ if line.strip() and line not in kept]
876
634
 
877
635
 
878
- def weight_module():
879
- """skills/ai-task/weight.py, the renderer behind `state.py weight --mirror`."""
880
- global _WEIGHT # pylint: disable=global-statement # loaded once per run
881
- if _WEIGHT is None:
882
- spec = importlib.util.spec_from_file_location("riskrails_weight", os.path.join(SKILLS, "ai-task", "weight.py"))
883
- _WEIGHT = importlib.util.module_from_spec(spec)
884
- spec.loader.exec_module(_WEIGHT)
885
- return _WEIGHT
636
+ def patterns_of(data):
637
+ """The count of non-empty deny and allow patterns in a path-guard.json, or None when it is not one."""
638
+ try:
639
+ doc = json.loads(data)
640
+ return sum(1 for key in ("deny_patterns", "allow_patterns") for p in doc.get(key) or []
641
+ if isinstance(p, str) and p.strip())
642
+ except (ValueError, AttributeError, TypeError, RecursionError):
643
+ return None
886
644
 
887
645
 
888
- def render_weight(data):
889
- """weight.md for the weight.json bytes `data`; the renderer reads a path."""
890
- with tempfile.TemporaryDirectory() as tmp:
891
- path = os.path.join(tmp, "weight.json")
892
- with open(path, "wb") as fh:
893
- fh.write(data)
894
- return weight_module().mirror(path).encode("utf-8")
646
+ def edited_note(plan, target, ours, theirs):
647
+ """What a gated regenerate would lose, and where the original goes (R6)."""
648
+ lost = lost_lines(ours, theirs)
649
+ shown = "; ".join("`%s`" % (line[:48] + ("…" if len(line) > 48 else "")) for line in lost[:3])
650
+ note = "edited here: %d line(s) of yours are not in the plugin's version%s" % (len(lost), " (" + shown + ")" if shown else "")
651
+ if target.endswith("path-guard.json"):
652
+ count = patterns_of(ours)
653
+ note += "; drops %s deny/allow pattern(s)" % ("an unreadable number of" if count is None else count)
654
+ return "%s; original kept at %s" % (note, plan.original_of(target))
895
655
 
896
656
 
897
- def weight_mirror(plan, history, theirs):
898
- """weight.md is generated from weight.json. A mirror that is exactly the render of
899
- the project's JSON on disk is rendered again from the JSON this run leaves behind:
900
- merged as text with the template's, whose sha256 line names the template's JSON, it
901
- was a conflict on every update. A mirror edited by hand, or stale, merges as before."""
902
- ours = plan.read(WEIGHT_MD)
903
- path = os.path.join(plan.root, WEIGHT_JSON)
904
- disk = read(path) if os.path.isfile(path) and not os.path.islink(path) else None
905
- after = plan.read(WEIGHT_JSON)
906
- fresh = None
907
- rendering_raised = False
908
- if ours is not None and disk is not None and after is not None:
909
- try:
910
- if ours == render_weight(disk):
911
- fresh = render_weight(after)
912
- except Exception as exc: # pylint: disable=broad-except
913
- # not a table the renderer reads (or too deep, or no temp dir, or no renderer):
914
- # the text merge decides, and it is always safe
915
- fresh = None
916
- rendering_raised = True
917
- # L3: say so, so a renderer bug is not read as a hand-edited mirror
918
- plan.hints.append("%s: not rendered from weight.json (%s); merged as text"
919
- % (WEIGHT_MD, type(exc).__name__))
920
- if fresh is None:
921
- three_way(plan, history, "ai-init/" + WEIGHT_MD, WEIGHT_MD, theirs)
922
- # L4: not when the renderer raised: `state.py weight --mirror` would fail the same way
923
- stale = None if rendering_raised else stale_mirror_hint(ours, disk)
924
- if stale:
925
- plan.hints.append(stale)
926
- elif fresh != ours:
927
- plan.add("update", WEIGHT_MD, "rendered from weight.json", content=fresh)
928
-
929
-
930
- def stale_mirror_hint(ours, disk):
931
- """L3: the sha256 in weight.md's header names a weight.json other than the one on disk
932
- (an --apply interrupted between the two writes, or a JSON edited without regenerating)."""
933
- if ours is None or disk is None:
934
- return None
935
- head = ours[:2000]
936
- found = re.search(r"^<!--.*\bsha256:\s*([0-9a-fA-F]{64})\b.*-->", head if isinstance(head, str) else head.decode("utf-8", "replace"), re.M)
937
- if not found or found.group(1).lower() == hashlib.sha256(disk if isinstance(disk, bytes) else disk.encode("utf-8")).hexdigest():
938
- return None
939
- return ("%s: rendered from a different weight.json than the one on disk (stale: an interrupted "
940
- "--apply, or the JSON edited without regenerating) — `state.py weight --mirror` "
941
- "regenerates it from the JSON and REPLACES any hand edits in it, so keep or move "
942
- "them first" % WEIGHT_MD)
943
-
944
-
945
- def three_way(plan, history, key, target, theirs):
657
+ def regenerate(plan, history, key, target, theirs):
658
+ """D5: absent -> create; equal -> nothing; a version the plugin once shipped -> regenerate;
659
+ anything else is the human's edit -> regenerate? behind the gate."""
946
660
  ours = plan.read(target)
947
661
  if ours is None:
948
662
  plan.add("create", target, content=theirs)
663
+ elif ours == theirs:
949
664
  return
950
- if ours == theirs:
951
- return
952
- versions = [v for v in history.versions(key) if v != theirs]
953
- if any(v == ours for v in versions):
954
- policy = None
955
- if target.endswith(".json") and "/policies/" in "/" + target:
956
- try:
957
- policy = leaf_changes(json.loads(ours, object_pairs_hook=OrderedDict),
958
- json.loads(theirs, object_pairs_hook=OrderedDict))
959
- except (ValueError, RecursionError): # not JSON, or too deep to read or flatten: no policy lines
960
- policy = None
961
- plan.add("update", target, "unchanged since it was installed",
962
- content=theirs, policy=policy)
963
- return
665
+ elif history.shipped(key, ours):
666
+ plan.add("regenerate", target, "unchanged since it was installed", content=theirs)
667
+ else:
668
+ plan.gated("regenerate", target, edited_note(plan, target, ours, theirs), content=theirs)
964
669
 
965
- if target.endswith(".json"):
966
- try:
967
- o = json.loads(ours, object_pairs_hook=OrderedDict)
968
- t = json.loads(theirs, object_pairs_hook=OrderedDict)
969
- bases = [json.loads(v, object_pairs_hook=OrderedDict) for v in versions]
970
- except ValueError:
971
- plan.add("conflict", target, "not valid JSON; not merged", conflict_copy=theirs)
972
- return
973
- except RecursionError: # parsing, flatten or merge_json past the recursion limit
974
- plan.add("conflict", target, "nested too deeply; not merged", conflict_copy=theirs)
975
- return
976
- try:
977
- base = closest(bases, o, json_score) if bases else MISSING
978
- conflicts = []
979
- merged = merge_json(base, o, t, [], conflicts)
980
- policy = leaf_changes(o, merged) if "/policies/" in "/" + target else None
981
- content = dump_json(merged) if merged != o else None
982
- except RecursionError:
983
- plan.add("conflict", target, "nested too deeply; not merged", conflict_copy=theirs)
984
- return
985
- if merged == o and not conflicts:
986
- return
987
- if merged != o:
988
- plan.add("merge", target, "your edits kept", content=content, policy=policy)
989
- if conflicts:
990
- plan.add("conflict", target, "kept your value for: " + ", ".join(conflicts), conflict_copy=theirs)
991
- return
992
670
 
993
- if target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(ours)) == 1:
994
- router_merge(plan, ours, versions, theirs)
995
- return
996
- base = closest(versions, ours, text_ratio) if versions else None
997
- merged = merge_text(ours, base, theirs) if base is not None else None
998
- if merged is not None and target == ROUTER_FILE and len(SHARED_SECTION_RE.findall(merged)) != 1:
999
- plan.add("conflict", target, "the merge would not name the shared files exactly once "
1000
- "(## Shared files)", conflict_copy=theirs)
1001
- return
1002
- if merged is None:
1003
- plan.add("conflict", target, "edited here and changed in the plugin", conflict_copy=theirs)
1004
- elif merged != ours:
1005
- plan.add("merge", target, "your edits kept", content=merged)
1006
-
1007
-
1008
- def split_shared_section(text):
1009
- """(text without its `## Shared files` section, the section, the heading
1010
- line that followed it) — the section runs from its heading to the next
1011
- `## ` heading or the end; None when it is not there exactly once."""
1012
- found = list(SHARED_SECTION_RE.finditer(text))
1013
- if len(found) != 1:
1014
- return None
1015
- start = found[0].start()
1016
- nxt = re.compile(rb"(?m)^## ").search(text, found[0].end())
1017
- if nxt is None: # at the end: the blank line before it belongs to the section
1018
- head = text[:start]
1019
- return (head[:-1] if head.endswith(b"\n\n") else head), text[start:], b""
1020
- end = nxt.start()
1021
- eol = text.find(b"\n", end)
1022
- follow = text[end:eol + 1] if eol >= 0 else text[end:]
1023
- return text[:start] + text[end:], text[start:end], follow
1024
-
1025
-
1026
- def router_merge(plan, ours, versions, theirs):
1027
- """A router that already names the shared files once. 0006 put the section
1028
- in by insertion, so no shipped version is a fair base for it: merge the
1029
- rest of the router without the section, against the shipped version that
1030
- rest is closest to, then put the project's section back before the heading
1031
- it preceded (or where the plugin puts it, or at the end). Nothing to do
1032
- only when the plugin changed nothing but the section since that base."""
1033
- crlf = b"\r\n" in ours
1034
- lf = (lambda b: b.replace(b"\r\n", b"\n")) if crlf else (lambda b: b)
1035
- o_rest, o_sec, follow = split_shared_section(lf(ours))
1036
- t_split = split_shared_section(theirs)
1037
- t_rest = t_split[0] if t_split else theirs
1038
- rests = [(split_shared_section(v) or (v,))[0] for v in versions]
1039
- base = closest(rests, o_rest, text_ratio) if rests else None
1040
- if o_rest == t_rest or (base is not None and base == t_rest):
1041
- return
1042
- merged = merge_text(o_rest, base, t_rest) if base is not None else None
1043
- if merged is not None and not follow: # the project had it at the end: it stays there
1044
- merged = merged + (b"" if merged.endswith(b"\n") else b"\n") + b"\n" + o_sec
1045
- elif merged is not None:
1046
- merged = put_section(merged, o_sec, [follow, t_split[2] if t_split else b""])
1047
- if merged is None or len(SHARED_SECTION_RE.findall(merged)) != 1:
1048
- plan.add("conflict", ROUTER_FILE, "edited here and changed in the plugin", conflict_copy=theirs)
1049
- return
1050
- if crlf:
1051
- merged = merged.replace(b"\n", b"\r\n")
1052
- if merged != ours:
1053
- plan.add("merge", ROUTER_FILE, "your edits kept", content=merged)
671
+ def effective_weight(plan):
672
+ """The weight table as this run leaves it: the plugin's with weight.project.json laid over it."""
673
+ own = plan.read(WEIGHT_PROJECT)
674
+ with tempfile.TemporaryDirectory() as tmp:
675
+ path = os.path.join(tmp, "weight.project.json")
676
+ if own is not None:
677
+ with open(path, "wb") as fh:
678
+ fh.write(own)
679
+ return weight_overlay().load(shipped_weight(), path)
1054
680
 
1055
681
 
1056
- def put_section(text, section, follows):
1057
- """The section inserted before the first of these heading lines that the
1058
- text has at the start of a line, else appended."""
1059
- for follow in follows:
1060
- if not follow:
1061
- continue
1062
- m = re.compile(rb"(?m)^" + re.escape(follow.rstrip(b"\n")) + rb"$").search(text)
1063
- if m:
1064
- return text[:m.start()] + section + text[m.start():]
1065
- return text + (b"" if text.endswith(b"\n") else b"\n") + section
682
+ GENERATED_HEADER = b"generated by `state.py weight --mirror`"
683
+
684
+
685
+ def weight_md(plan, history):
686
+ """weight.md is always rendered from the shipped table and the overlay (spec :161). A file that is
687
+ a rendering or a shipped version is replaced; one a human wrote is gated."""
688
+ try:
689
+ theirs = weight_module().mirror(effective_weight(plan)).encode("utf-8")
690
+ except (ValueError, OSError, KeyError, TypeError, AttributeError, RecursionError) as exc:
691
+ plan.hints.append("%s: not rendered (%s: %s); fix %s, then run this again"
692
+ % (WEIGHT_MD, type(exc).__name__, exc, WEIGHT_PROJECT))
693
+ return
694
+ ours = plan.read(WEIGHT_MD)
695
+ if ours is None:
696
+ plan.add("create", WEIGHT_MD, "rendered from the weight table", content=theirs)
697
+ elif ours == theirs:
698
+ return
699
+ elif GENERATED_HEADER in ours[:600] or history.shipped("ai-init/" + WEIGHT_MD, ours):
700
+ plan.add("regenerate", WEIGHT_MD, "rendered from the weight table", content=theirs)
701
+ else:
702
+ plan.gated("regenerate", WEIGHT_MD, edited_note(plan, WEIGHT_MD, ours, theirs), content=theirs)
1066
703
 
1067
704
 
1068
705
  def block_update(plan, history, runtime):
706
+ """R15, D5: the managed block of one instruction file. Rewritten between its markers; no block
707
+ -> appended; no file -> a minimal one with the block. Text outside the markers is never touched."""
1069
708
  name, block_tpl, minimal_tpl = INSTRUCTION_FILE[runtime]
1070
- if name in plan.removed: # a migration moved this instruction file away
1071
- return
1072
709
  tpl = read(os.path.join(TEMPLATES["ai-init"], block_tpl))
1073
710
  text = plan.read(name)
1074
711
  if text is None:
@@ -1078,116 +715,222 @@ def block_update(plan, history, runtime):
1078
715
  return
1079
716
  found = find_block(text)
1080
717
  if found is None:
1081
- plan.add("update", name, "managed block appended",
1082
- content=text.rstrip(b"\n") + b"\n\n" + tpl)
718
+ plan.add("regenerate", name, "managed block appended", content=text.rstrip(b"\n") + b"\n\n" + tpl)
1083
719
  return
1084
- s, e, start_marker, end_marker = found
1085
- legacy = start_marker != START.encode()
1086
- renamed = " (legacy %s markers renamed)" % start_marker.decode()[len("<!-- "):-len(":start -->")] if legacy else ""
1087
- ours = current_markers(text[s:e], start_marker, end_marker) + b"\n"
720
+ s, e = found[0], found[1]
721
+ ours = text[s:e] + b"\n"
1088
722
  if ours == tpl:
1089
- if legacy:
1090
- plan.add("update", name, "managed block kept" + renamed,
1091
- content=text[:s] + tpl.rstrip(b"\n") + text[e:])
1092
723
  return
1093
- versions = [v for v in map(renamed_markers, history.versions("ai-init/" + block_tpl)) if v != tpl]
1094
- if ours in versions:
1095
- new_block = tpl
1096
- note = "managed block replaced (unchanged since it was installed)" + renamed
724
+ content = text[:s] + tpl.rstrip(b"\n") + text[e:]
725
+ if history.shipped("ai-init/" + block_tpl, ours):
726
+ plan.add("regenerate", name, "managed block replaced (unchanged since it was installed)", content=content)
1097
727
  else:
1098
- base = closest(versions, ours, text_ratio) if versions else None
1099
- new_block = merge_text(ours, base, tpl) if base is not None else None
1100
- note = "managed block merged, your edits kept" + renamed
1101
- if new_block is None and legacy:
1102
- # The edit cannot be merged, but the markers can still be renamed: the
1103
- # block stays as edited, and the next run treats it as an edited
1104
- # current block — a conflict with the plugin's copy beside it.
1105
- new_block = ours
1106
- note = "managed block kept as edited" + renamed
1107
- if new_block is None:
1108
- plan.add("conflict", name, "the managed block was edited here and changed in the plugin",
1109
- conflict_copy=tpl)
1110
- return
1111
- if new_block == ours and not legacy:
1112
- # The merge already holds the plugin's changes: writing it back is no
1113
- # change, and planning it would keep --check "behind" on every run.
1114
- return
1115
- plan.add("update", name, note, content=text[:s] + new_block.rstrip(b"\n") + text[e:])
728
+ plan.gated("regenerate", name, "managed block " + edited_note(plan, name, ours, tpl), content=content)
1116
729
 
1117
730
 
1118
- def shared_update(plan, history, version):
1119
- """A shared copy in a project that is already at SHARED_SCHEMA: 0006 is not
1120
- pending, so this is where an unedited copy that came back (a restore, a
1121
- copy-paste, an old branch) is offered for deletion again, and where edited
1122
- copies are named once as overrides. Same gate as a migration's deletion."""
1123
- if version is None or version < SHARED_SCHEMA:
1124
- return
1125
- ctx = MigrationContext(plan, SimpleNamespace(VERSION=SHARED_SCHEMA, TITLE="shared", MOVES=[]),
1126
- version, None, history)
1127
- router = plan.read(ROUTER_FILE)
1128
- named = router is not None and SHARED_SECTION_RE.search(router)
731
+ # ---------------------------------------------------------------- the one-time split
732
+ FIELD_LINE = re.compile(r"(?:%s):" % "|".join(
733
+ ("verify_command", "step_test_command", "e2e_command", "lint_command", "typecheck_command",
734
+ "single_test", "doc_tests", "bite_codes", "healthy_output", "runtime")))
735
+ SPECIFICS_RE = re.compile(r"(?m)^## Project specifics[ \t]*$")
736
+
737
+
738
+ def split_testing(testing, template):
739
+ """R11: verification.md from testing.md. The field lines are copied verbatim with their continuation
740
+ lines (indented, after a field line), then `## Project specifics` to the end of the file."""
741
+ lines = testing.decode("utf-8").splitlines(keepends=True)
742
+ fields, taking = [], False
743
+ for line in lines:
744
+ if FIELD_LINE.match(line):
745
+ fields.append(line)
746
+ taking = True
747
+ elif taking and line[:1] in (" ", "\t") and line.strip():
748
+ fields.append(line)
749
+ else:
750
+ taking = False
751
+ text = template.decode("utf-8")
752
+ if not fields:
753
+ return template, 0
754
+ head = text[:re.search(r"(?m)^verify_command:", text).start()]
755
+ tail = SPECIFICS_RE.search(testing.decode("utf-8"))
756
+ specifics = testing.decode("utf-8")[tail.start():] if tail else text[SPECIFICS_RE.search(text).start():]
757
+ body = "".join(fields)
758
+ return (head + body + ("" if body.endswith("\n") else "\n") + "\n" + specifics).encode("utf-8"), len(fields)
759
+
760
+
761
+ def leaves(value, prefix=()):
762
+ """{path tuple: leaf} — objects are walked, a list or scalar (or an empty object) is one leaf."""
763
+ if isinstance(value, dict) and value:
764
+ out = {}
765
+ for k, v in value.items():
766
+ out.update(leaves(v, prefix + (k,)))
767
+ return out
768
+ return {prefix: value}
769
+
770
+
771
+ def split_weight(old, history, template):
772
+ """R11: weight.project.json from the old weight.json: the leaves that differ from that leaf in every
773
+ version the plugin shipped (lists whole). -> (bytes, carried paths, uncarried paths that differ from today's table)."""
774
+ mine = json.loads(old.decode("utf-8"), object_pairs_hook=OrderedDict)
775
+ tables = []
776
+ for blob in history.versions("ai-init/" + WEIGHT_JSON) + [read(shipped_weight())]:
777
+ try:
778
+ tables.append(leaves(json.loads(blob.decode("utf-8"))))
779
+ except (ValueError, RecursionError):
780
+ continue
781
+ today = tables[-1] if tables else {}
782
+ carried, uncarried = OrderedDict(), []
783
+ for path, value in leaves(mine).items():
784
+ if path[:1] in (("version",), ("_comment",)) or not path:
785
+ continue
786
+ if any(path in t and t[path] == value for t in tables):
787
+ if today.get(path, ...) != value:
788
+ uncarried.append(".".join(map(str, path)))
789
+ continue
790
+ here = carried
791
+ for part in path[:-1]:
792
+ here = here.setdefault(part, OrderedDict())
793
+ here[path[-1]] = value
794
+ doc = json.loads(template.decode("utf-8"), object_pairs_hook=OrderedDict)
795
+ doc.update(carried)
796
+ paths = [".".join(map(str, p)) for p in leaves(carried) if p]
797
+ return dump_json(doc), paths, uncarried
798
+
799
+
800
+ def listed(names, limit=6):
801
+ shown = ", ".join(names[:limit])
802
+ return shown + (" and %d more" % (len(names) - limit) if len(names) > limit else "")
803
+
804
+
805
+ def unwritten(plan, history, target, template):
806
+ """True when the new file is absent, or is still a shipped template (today's or any in the history index):
807
+ a scaffold run over an old tree can leave one, and it carries nothing of the project."""
808
+ if not plan.exists(target):
809
+ return True
810
+ data = plan.read(target)
811
+ return data is not None and (data.replace(b"\r\n", b"\n") == template.replace(b"\r\n", b"\n")
812
+ or history.shipped("ai-init/" + target, data))
813
+
814
+
815
+ def split_once(plan, history):
816
+ """R11: when verification.md / weight.project.json are absent (or only a shipped template), make them from
817
+ testing.md / weight.json (once); with a project's own file present the old file is not read again.
818
+ The old weight.json is then REMOVED, gated."""
819
+ tpl = TEMPLATES["ai-init"]
820
+ template = read(os.path.join(tpl, VERIFICATION_MD))
821
+ if unwritten(plan, history, VERIFICATION_MD, template):
822
+ absent = not plan.exists(VERIFICATION_MD)
823
+ testing = plan.read(TESTING_MD)
824
+ content, count = split_testing(testing, template) if testing is not None else (template, 0)
825
+ if count:
826
+ plan.add("split", VERIFICATION_MD, "%d command line(s) of testing.md copied verbatim, with its "
827
+ "Project specifics" % count, content=content)
828
+ elif absent:
829
+ plan.add("create", VERIFICATION_MD, content=content)
830
+ template = read(os.path.join(tpl, WEIGHT_PROJECT))
831
+ if unwritten(plan, history, WEIGHT_PROJECT, template):
832
+ absent = not plan.exists(WEIGHT_PROJECT)
833
+ old = plan.read(WEIGHT_JSON)
834
+ if old is None:
835
+ if absent:
836
+ plan.add("create", WEIGHT_PROJECT, content=template)
837
+ else:
838
+ content, carried, uncarried = split_weight(old, history, template)
839
+ if absent or content != plan.read(WEIGHT_PROJECT):
840
+ note = "%d leaf/leaves of weight.json that differ from the plugin's table carried (%s)" % (
841
+ len(carried), listed(carried) or "none")
842
+ if uncarried:
843
+ note += "; %d not carried, equal to an older shipped value: %s" % (len(uncarried), listed(uncarried))
844
+ plan.add("split", WEIGHT_PROJECT, note, content=content)
845
+ if plan.exists(WEIGHT_JSON):
846
+ why = "[split] superseded by %s; the plugin's table is read from the plugin" % WEIGHT_PROJECT
847
+ plan.gated("delete", WEIGHT_JSON, why, reason=why)
848
+
849
+
850
+ # ---------------------------------------------------------------- shared files, rules, caches
851
+ def require_shared(paths, runtimes):
852
+ """Refuse, before anything is written, to drop a project copy whose shared fallback is missing:
853
+ in this plugin's tree, or in another installed home of a runtime the project declares. A home
854
+ this machine does not have is not checked."""
855
+ undeclared = sorted(p for p in paths if p not in SHARED_SET)
856
+ if undeclared:
857
+ raise SchemaError("not in the shared set, never deleted as a shared copy: %s" % ", ".join(undeclared))
858
+ rels = sorted(p[len(".ai/"):] for p in paths)
859
+ missing = [r for r in rels if not os.path.isfile(os.path.join(SHARED, r))]
860
+ if missing:
861
+ raise SchemaError("the plugin's shared tree %s lacks %d file(s) a project copy would fall back to: %s — "
862
+ "reinstall the plugin (install.sh), then run this again" % (SHARED, len(missing), ", ".join(missing)))
863
+ homes = {"claude": os.path.join(os.path.expanduser("~"), ".claude"),
864
+ "codex": os.environ.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")}
865
+ mine = os.path.realpath(PLUGIN_HOME)
866
+ for runtime in runtimes:
867
+ home = homes.get(runtime)
868
+ if (not home or os.path.realpath(home) == mine
869
+ or not os.path.isdir(os.path.join(home, "skills", "project-update"))):
870
+ continue
871
+ tree = os.path.join(home, "riskrails", "ai")
872
+ missing = [r for r in rels if not os.path.isfile(os.path.join(tree, r))]
873
+ if missing:
874
+ raise SchemaError("%s has this plugin installed for %s, but %d of the %d shared file(s) a project copy "
875
+ "would fall back to are missing from %s — run `install.sh --target %s` from this "
876
+ "build, then run this again" % (home, runtime, len(missing), len(rels), tree, runtime))
877
+
878
+
879
+ def shared_update(plan, history, runtimes):
880
+ """R17: an unedited copy of a shared file is offered for deletion; an edited one is an
881
+ override, kept, and named once (OQ8)."""
1129
882
  edited, candidates = [], []
1130
883
  extra = undeclared_shared()
1131
884
  if extra:
1132
- ctx.hint("the shared tree %s holds %d file(s) outside the shared set, ignored — reinstall the "
1133
- "plugin (install.sh) if you did not put them there: %s" % (SHARED, len(extra), ", ".join(extra)))
885
+ plan.hints.append("the shared tree %s holds %d file(s) outside the shared set, ignored — reinstall the "
886
+ "plugin (install.sh) if you did not put them there: %s" % (SHARED, len(extra), ", ".join(extra)))
1134
887
  for path in sorted(shared_files()):
1135
888
  data = plan.read(path)
1136
889
  if data is None:
1137
890
  continue
1138
- why = ctx.unsafe_target(path)
891
+ why = unsafe_path(plan.root, path)
1139
892
  if why:
1140
- ctx.hint("%s: %s, so this update never deletes it — check by hand whether it is still "
1141
- "needed" % (path, why))
1142
- elif not ctx.is_shipped(path, data):
893
+ plan.hints.append("%s: %s, so this update never deletes it — check by hand whether it is still "
894
+ "needed" % (path, why))
895
+ elif not history.shipped("ai-init/" + path, data):
1143
896
  edited.append(path)
1144
- elif not named:
1145
- ctx.hint("%s: unedited, but %s does not name the shared files (## Shared files), so the "
1146
- "copy stays — restore that section, then run /rr-setup --update again"
1147
- % (path, ROUTER_FILE))
1148
897
  else:
1149
898
  candidates.append(path)
1150
899
  if candidates:
1151
900
  try:
1152
- ctx.require_shared(candidates)
901
+ require_shared(candidates, runtimes)
1153
902
  except SchemaError as exc:
1154
- ctx.hint("%s — the copies stay until then" % exc)
903
+ plan.hints.append("%s — the copies stay until then" % exc)
1155
904
  candidates = []
1156
905
  for path in candidates:
1157
906
  why = "[shared] identical to a shipped version"
1158
- if plan.confirm_delete:
1159
- plan.add("delete", path, why, reason=why)
1160
- plan.remove(path)
1161
- else:
1162
- plan.add("delete?", path, why, reason=why)
907
+ plan.gated("delete", path, why, reason=why)
1163
908
  if edited:
1164
- ctx.hint("%d file(s) override the plugin's shared copy and no longer receive its changes: %s — "
1165
- "diff each against %s/<path>; `ai-path <path>` shows which applies; delete one when it "
1166
- "adds nothing" % (len(edited), ", ".join(edited), SHARED))
1167
-
1168
-
1169
- def retire_templates(plan, history, retired):
1170
- """[(history key, target)] the plugin now supplies from its own tree. A
1171
- missing copy stays missing; an unedited one is offered for deletion, same
1172
- gate as a shared override; an edited one is a project override, named once."""
1173
- edited = []
1174
- for key, target in retired:
1175
- data = plan.read(target)
1176
- if data is None:
1177
- continue
1178
- known = history.hashes(key)
1179
- if sha(data) not in known and sha(data.replace(b"\r\n", b"\n")) not in known:
1180
- edited.append(target)
1181
- continue
1182
- why = "[sdlc] the plugin supplies it: skills/project-init/templates/" + key.split("/", 1)[1]
1183
- if plan.confirm_delete:
1184
- plan.add("delete", target, why, reason=why)
1185
- plan.remove(target)
1186
- else:
1187
- plan.add("delete?", target, why, reason=why)
1188
- if edited:
1189
- plan.hints.append("%d file(s) override the plugin's SDLC template and no longer receive its "
1190
- "changes: %s" % (len(edited), ", ".join(edited)))
909
+ plan.hints.append("%d file(s) override the plugin's shared copy and no longer receive its changes: %s — "
910
+ "diff each against %s/<path>; `ai-path <path>` shows which applies; delete one when it "
911
+ "adds nothing" % (len(edited), ", ".join(edited), SHARED))
912
+
913
+
914
+ def rule_sources(plan):
915
+ """OQ1: a rule source is not kept. Each `.ai/rules/<slug>.md` is `delete?` behind the gate with its
916
+ original copied; until it is confirmed the rule stays rendered."""
917
+ rules = os.path.join(plan.root, RULES_DIR)
918
+ for name in sorted(os.listdir(rules)) if os.path.isdir(rules) else []:
919
+ rel = "%s/%s" % (RULES_DIR, name)
920
+ if name.endswith(".md") and name != "README.md" and os.path.isfile(os.path.join(rules, name)) \
921
+ and not unsafe_path(plan.root, rel):
922
+ why = "[rules] rule sources are not kept by an update: the rendered blocks go with it"
923
+ plan.gated("delete", rel, why, reason=why)
924
+
925
+
926
+ def local_cache(plan):
927
+ """R18: .ai/local/plugin-update/ (the old merge's conflict copies) is removed; .ai/local/memory/ is never touched."""
928
+ for here, _dirs, names in os.walk(os.path.join(plan.root, LOCAL_CACHE)):
929
+ for name in sorted(names):
930
+ rel = os.path.relpath(os.path.join(here, name), plan.root).replace(os.sep, "/")
931
+ if not unsafe_path(plan.root, rel):
932
+ why = "[local] the old merge's cache, not written any more"
933
+ plan.gated("delete", rel, why, reason=why)
1191
934
 
1192
935
 
1193
936
  def task_journal(root, task_id):
@@ -1242,14 +985,12 @@ def cleanup_update(plan, now=None):
1242
985
  file=sys.stderr)
1243
986
  raise SystemExit(2) from None
1244
987
  flight = None
1245
- for name in (STATE_FILE, LEGACY_STATE_FILE):
1246
- raw = plan.read(name)
1247
- if raw is not None:
1248
- try:
1249
- flight = json.loads(raw).get("task_id")
1250
- except (ValueError, AttributeError, RecursionError):
1251
- return # unreadable: a task may be in flight, so no task is proposed as stale
1252
- break
988
+ raw = plan.read(STATE_FILE)
989
+ if raw is not None:
990
+ try:
991
+ flight = json.loads(raw).get("task_id")
992
+ except (ValueError, AttributeError, RecursionError):
993
+ return # unreadable: a task may be in flight, so no task is proposed as stale
1253
994
  reports =os.path.join(plan.root, ".ai", "reports")
1254
995
  logs = os.path.join(plan.root, ".ai", "state", "logs")
1255
996
 
@@ -1307,14 +1048,14 @@ def cleanup_update(plan, now=None):
1307
1048
 
1308
1049
 
1309
1050
  def deletion_hint(plan):
1310
- """M4: git is the rollback of a migration's deletion; it must hold .ai/ first.
1051
+ """M4: git is the rollback of a deletion or an overwrite; it must hold .ai/ first.
1311
1052
  A [cleanup] deletion of git-ignored run state is not counted: git does not roll it back;
1312
1053
  one under .ai/reports/ that git tracks, or should, is."""
1313
- if any(i["action"] in ("delete?", "delete") and i["target"].startswith(".ai/")
1054
+ if any(i["action"] in ("delete?", "delete", "regenerate?") and i["target"].startswith(".ai/")
1314
1055
  and (not (i.get("reason") or "").startswith("[cleanup]") or i["target"] in plan.git_kept)
1315
1056
  for i in plan.items) \
1316
1057
  and git_dirty_ai(plan.root):
1317
- plan.hints.append("commit .ai/ first: git is this migration's rollback "
1058
+ plan.hints.append("commit .ai/ first: git is this update's rollback "
1318
1059
  "(`git status -- .ai` lists what it does not hold yet)")
1319
1060
 
1320
1061
 
@@ -1438,25 +1179,17 @@ def _frozen_block(plan, target, slug):
1438
1179
  return held is not None and slug in render_instructions.rule_slugs_in(held.decode("utf-8", "replace"))
1439
1180
 
1440
1181
 
1441
- def rules_update(plan, runtimes, planned=None):
1182
+ def rules_update(plan, runtimes):
1442
1183
  """Render `.ai/rules/<slug>.md` into the instruction file of every directory
1443
1184
  it names, and take back what a rule no longer asks for. The text around a
1444
1185
  block belongs to the project and is never read for meaning, only preserved.
1445
-
1446
- `planned` ({".ai/rules/<slug>.md": text}) are rules this same run is about
1447
- to write (an adopt): they replace or join the ones on disk by slug, so their
1448
- blocks render in the same run. The plain update passes nothing."""
1186
+ A rule whose source this run deletes is no longer rendered."""
1449
1187
  rules_path = os.path.join(plan.root, RULES_DIR)
1450
- if not os.path.isdir(rules_path) and not planned:
1188
+ if not os.path.isdir(rules_path):
1451
1189
  return
1452
1190
  try:
1453
- rules = render_instructions.load_rules(rules_path)
1454
- if planned:
1455
- by_slug = OrderedDict((r.slug, r) for r in rules)
1456
- for rel, text in sorted(planned.items()):
1457
- slug = os.path.basename(rel)[:-3]
1458
- by_slug[slug] = render_instructions.parse_rule(slug, text, where=rel)
1459
- rules = list(by_slug.values())
1191
+ rules = [r for r in render_instructions.load_rules(rules_path)
1192
+ if "%s/%s.md" % (RULES_DIR, r.slug) not in plan.removed]
1460
1193
  except render_instructions.RenderError as exc:
1461
1194
  plan.hints.append("%s — that rule is not rendered; the rest are" % exc)
1462
1195
  return
@@ -1488,7 +1221,7 @@ def rules_update(plan, runtimes, planned=None):
1488
1221
  if current is None:
1489
1222
  plan.add("create", target, "rendered from " + source, content=new)
1490
1223
  elif current != new:
1491
- plan.add("update", target, "rendered from " + source, content=new)
1224
+ plan.add("regenerate", target, "rendered from " + source, content=new)
1492
1225
  continue
1493
1226
  text = "" if current is None else current.decode("utf-8")
1494
1227
  new = render_instructions.splice_rule(text, item["slug"], content).encode()
@@ -1496,7 +1229,7 @@ def rules_update(plan, runtimes, planned=None):
1496
1229
  plan.add("create", target, "rule block %s from %s" % (item["slug"], source),
1497
1230
  content=new)
1498
1231
  elif current != new:
1499
- plan.add("update", target, "rule block %s from %s" % (item["slug"], source),
1232
+ plan.add("regenerate", target, "rule block %s from %s" % (item["slug"], source),
1500
1233
  content=new)
1501
1234
 
1502
1235
  blocks, docs = rendered_rules(plan)
@@ -1505,7 +1238,7 @@ def rules_update(plan, runtimes, planned=None):
1505
1238
  continue
1506
1239
  text = plan.read(target).decode("utf-8")
1507
1240
  # The block is ours; the file is not. Take the block, leave the file.
1508
- plan.add("update", target, "rule block %s removed (%s/%s.md no longer renders here)"
1241
+ plan.add("regenerate", target, "rule block %s removed (%s/%s.md no longer renders here)"
1509
1242
  % (slug, RULES_DIR, slug),
1510
1243
  content=render_instructions.strip_rule(text, slug).encode())
1511
1244
  for target in docs:
@@ -1536,8 +1269,8 @@ def gitignore_update(plan, snippet_path):
1536
1269
  sep = b"\n" if current and not current.endswith(b"\n") else b""
1537
1270
  new = current + sep + add.encode()
1538
1271
  # Replace an earlier .gitignore item from this run instead of stacking two.
1539
- plan.items = [i for i in plan.items if i["target"] != ".gitignore" or i["migration"] is not None]
1540
- plan.add("update", ".gitignore", "entries appended: " + ", ".join(missing), content=new)
1272
+ plan.items = [i for i in plan.items if i["target"] != ".gitignore"]
1273
+ plan.add("regenerate", ".gitignore", "entries appended: " + ", ".join(missing), content=new)
1541
1274
 
1542
1275
 
1543
1276
  def budget_hints(plan, runtimes):
@@ -1586,7 +1319,30 @@ def constitution_hint(plan):
1586
1319
  plan.hints.append(line.replace(path, rel))
1587
1320
 
1588
1321
 
1322
+ def verification_hints(plan):
1323
+ """R10: update's empty-field hints read verification.md."""
1324
+ text = plan.read(VERIFICATION_MD)
1325
+ if text is None:
1326
+ return
1327
+ for field, why in (
1328
+ ("verify_command", "the feedback loop needs it"),
1329
+ ("step_test_command", "a step runs only its own tests"),
1330
+ ("e2e_command", "e2e runs once at the end of a task; write 'none' if there is no suite"),
1331
+ ("lint_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
1332
+ ("typecheck_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
1333
+ ):
1334
+ if re.search(rb"(?m)^" + field.encode() + rb":[ \t]*(#.*)?$", text):
1335
+ plan.hints.append("%s: %s is empty — %s" % (VERIFICATION_MD, field, why))
1336
+ elif not re.search(rb"(?m)^" + field.encode() + rb":", text):
1337
+ plan.hints.append("%s: %s is missing — %s" % (VERIFICATION_MD, field, why))
1338
+
1339
+
1340
+ PHASE = {"split": 0, "delete": 2, "delete?": 2}
1341
+
1342
+
1589
1343
  def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None, cleanup_via=None):
1344
+ """D3: split_once -> regenerate -> shared -> block per runtime -> rules -> local -> gitignore, cleanup, hints.
1345
+ The caller has run check_kept: nothing here reads a kept file for meaning."""
1590
1346
  plan = Plan(root)
1591
1347
  plan.confirm_cleanup = confirm_cleanup
1592
1348
  plan.cleanup_via = cleanup_via
@@ -1596,110 +1352,66 @@ def build_plan(root, confirm_delete=None, confirm_via=None, confirm_cleanup=None
1596
1352
  has_sdlc = os.path.isdir(os.path.join(root, "docs", "sdlc"))
1597
1353
  if not (has_ai or has_sdlc):
1598
1354
  return None
1599
- # Only a project with .ai/ runs migrations, so only it can be carrying a
1600
- # moved file — and a faulty registry must not break the projects that cannot.
1601
- # renames() validates the registry, raising the same SchemaError as load().
1602
- history = History(HISTORY, migrations.renames(template_targets()) if has_ai else {})
1603
-
1355
+ history = History(HISTORY)
1604
1356
  runtimes = project_runtimes(root)
1605
- version = None
1606
- if has_ai:
1607
- version = detect_version(plan)
1608
- migrations.load() # CURRENT comes from the file names; this validates the registry
1609
- if version > migrations.CURRENT:
1610
- raise SchemaError("%s says schema %d, this plugin ships schema %d — update the plugin "
1611
- "(riskrails/install.sh)" % (VERSION_FILE, version, migrations.CURRENT))
1612
- if version < migrations.CURRENT:
1613
- mods = run_migrations(plan, version, history)
1614
- plan.schema = (version, migrations.CURRENT, mods)
1615
- # The schema only advances when its migrations are done. Writing
1616
- # VERSION over an unresolved conflict, or over a deletion nobody has
1617
- # confirmed, would drop the operation: the next run would see a
1618
- # current project and plan nothing.
1619
- stuck = [i for i in plan.items if i["migration"] is not None
1620
- and i["action"] in ("conflict", "delete?")]
1621
- if stuck:
1622
- plan.held = version
1623
- plan.hints.append("the schema stays at %d until %s — resolve a conflict by hand, confirm a "
1624
- "deletion with --apply --confirm-delete NAME, then run /rr-setup --update again"
1625
- % (version, ", ".join("%s (%s)" % (i["target"], i["action"]) for i in stuck)))
1626
- else:
1627
- # Last of the migration items: an apply that stops short leaves the
1628
- # version alone, and the next run plans the same migrations again.
1629
- plan.add("update" if plan.exists(VERSION_FILE) else "create", VERSION_FILE,
1630
- "schema %d" % migrations.CURRENT, content=b"%d\n" % migrations.CURRENT,
1631
- migration=migrations.CURRENT)
1357
+ plan.version_from = project_version(root) if has_ai else None
1632
1358
 
1633
1359
  if has_sdlc:
1634
1360
  tpl = TEMPLATES["project-init"]
1635
- entries = list(PROJECT_INIT_MAP)
1636
- retired = []
1361
+ for src, target in SDLC_CREATE: # the project's own from then on
1362
+ if not plan.exists(target):
1363
+ plan.add("create", target, content=read(os.path.join(tpl, src)).replace(
1364
+ b"{{PROJECT}}", os.path.basename(root).encode()))
1637
1365
  for rt in runtimes:
1638
- entries += RUNTIME_MAP[rt]
1639
- for src, target, kind in entries:
1640
- content = read(os.path.join(tpl, src))
1641
- if target in plan.removed: # a migration moved it away in this run
1642
- continue
1643
- if kind == "retire":
1644
- retired.append(("project-init/" + src, target))
1645
- elif kind == "create":
1646
- if not plan.exists(target):
1647
- plan.add("create", target, content=content.replace(
1648
- b"{{PROJECT}}", os.path.basename(root).encode()))
1649
- else:
1650
- three_way(plan, history, "project-init/" + src, target, content)
1651
- retire_templates(plan, history, retired)
1366
+ for src, target, kind in RUNTIME_MAP[rt]:
1367
+ content = read(os.path.join(tpl, src))
1368
+ if kind == "create":
1369
+ if not plan.exists(target):
1370
+ plan.add("create", target, content=content)
1371
+ else:
1372
+ regenerate(plan, history, "project-init/" + src, target, content)
1652
1373
  gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
1653
1374
 
1654
1375
  if has_ai:
1655
1376
  tpl = TEMPLATES["ai-init"]
1377
+ split_once(plan, history)
1656
1378
  files = []
1657
1379
  for dirpath, _, names in os.walk(os.path.join(tpl, ".ai")):
1658
1380
  for n in names:
1659
- files.append(os.path.relpath(os.path.join(dirpath, n), tpl))
1660
- # JSON before markdown, so the weight mirror is rendered from the merged JSON.
1661
- for rel in sorted(files, key=lambda r: (not r.endswith(".json"), r)):
1662
- if rel == VERSION_FILE: # owned by the migration step, never merged
1663
- continue
1664
- if rel in plan.removed: # a migration moved it away in this run
1381
+ files.append(os.path.relpath(os.path.join(dirpath, n), tpl).replace(os.sep, "/"))
1382
+ for rel in sorted(files):
1383
+ if rel in NOT_COPIED or rel in (VERIFICATION_MD, WEIGHT_PROJECT):
1665
1384
  continue
1666
- if rel in CREATE_ONCE and version >= CREATE_ONCE[rel] and not plan.exists(rel):
1667
- continue # past the step that created it: its absence is the human's choice
1668
1385
  content = read(os.path.join(tpl, rel))
1669
- if rel.startswith(".ai/project/") or rel.startswith(".ai/reports/"):
1386
+ if rel in CREATE_ONLY or rel.startswith(CREATE_ONLY_DIRS):
1670
1387
  if not plan.exists(rel):
1671
1388
  plan.add("create", rel, content=content)
1672
- continue
1673
- if rel == WEIGHT_MD:
1674
- weight_mirror(plan, history, content)
1675
- continue
1676
- three_way(plan, history, "ai-init/" + rel, rel, content)
1677
- shared_update(plan, history, version)
1389
+ else:
1390
+ regenerate(plan, history, "ai-init/" + rel, rel, content)
1391
+ weight_md(plan, history)
1392
+ shared_update(plan, history, runtimes)
1393
+ rule_sources(plan)
1394
+ local_cache(plan)
1678
1395
  cleanup_update(plan)
1679
- deletion_hint(plan)
1680
1396
  for rt in runtimes:
1681
1397
  block_update(plan, history, rt)
1682
1398
  rules_update(plan, runtimes)
1399
+ deletion_hint(plan)
1683
1400
  gitignore_update(plan, os.path.join(tpl, "gitignore.snippet"))
1684
1401
  tracked_ignored_hint(plan)
1685
-
1686
- testing = plan.read(".ai/policies/testing.md")
1687
- if testing is not None:
1688
- for field, why in (
1689
- ("verify_command", "the feedback loop needs it"),
1690
- ("step_test_command", "a step runs only its own tests"),
1691
- ("e2e_command", "e2e runs once at the end of a task; write 'none' if there is no suite"),
1692
- ("lint_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
1693
- ("typecheck_command", "a sensor reads it; read the project's config files for it, and write 'none' if there is none"),
1694
- ):
1695
- if re.search(rb"(?m)^" + field.encode() + rb":[ \t]*(#.*)?$", testing):
1696
- plan.hints.append(".ai/policies/testing.md: %s is empty — %s" % (field, why))
1697
- elif not re.search(rb"(?m)^" + field.encode() + rb":", testing):
1698
- plan.hints.append(".ai/policies/testing.md: %s is missing — %s" % (field, why))
1402
+ verification_hints(plan)
1699
1403
  budget_hints(plan, runtimes)
1700
-
1404
+ if plan.items and plan.exists(STATE_FILE):
1405
+ plan.hints.append("%s: a task is in flight while files are regenerated — check that the files "
1406
+ "its current step may touch still exist afterwards" % STATE_FILE)
1407
+ # Last of the items: an apply that stops short leaves the version alone, and the next run plans the rest again.
1408
+ ours = template_version()
1409
+ if plan.version_from != ours:
1410
+ plan.add("regenerate" if plan.exists(VERSION_FILE) else "create", VERSION_FILE,
1411
+ "schema %d" % ours, content=b"%d\n" % ours)
1701
1412
  if has_sdlc:
1702
1413
  constitution_hint(plan)
1414
+ plan.items.sort(key=lambda i: 3 if i["target"] == VERSION_FILE else PHASE.get(i["action"], 1))
1703
1415
  return plan
1704
1416
 
1705
1417
 
@@ -1772,10 +1484,11 @@ def keep_original(plan, target):
1772
1484
  read(path), mode=os.stat(path).st_mode & 0o7777)
1773
1485
 
1774
1486
 
1775
- def record_migration(plan, ops, deletions):
1776
- """The record of what was taken away, rewritten after every such operation:
1777
- an abort must not lose the note of a deletion a human approved. A second run
1778
- on the same day appends to the record instead of replacing it."""
1487
+
1488
+ def record_run(plan, ops, deletions):
1489
+ """The record of what was taken away or overwritten, rewritten after every such operation:
1490
+ an abort must not lose the note of a change a human approved. A second run on the same
1491
+ day appends to the record instead of replacing it."""
1779
1492
  path = os.path.join(plan.report_dir, "migration.json")
1780
1493
  existing = {}
1781
1494
  full = os.path.join(plan.root, path)
@@ -1787,17 +1500,12 @@ def record_migration(plan, ops, deletions):
1787
1500
  if not isinstance(existing, dict): # hand-edited into another shape
1788
1501
  existing = {}
1789
1502
  record = OrderedDict()
1790
- record["from"] = existing.get("from", plan.schema[0] if plan.schema else None)
1791
- # The version the project is actually at now, read from the disk and not from
1792
- # the plan: this record is written before the files move, and an aborted run
1793
- # never reaches CURRENT.
1503
+ record["from"] = existing.get("from", plan.version_from)
1504
+ # The version the project is actually at now, read from the disk: this record is
1505
+ # written before the files change, and an aborted run never reaches the template's.
1794
1506
  version_path = os.path.join(plan.root, VERSION_FILE)
1795
1507
  on_disk = read(version_path).decode().strip() if os.path.isfile(version_path) else ""
1796
1508
  record["to"] = int(on_disk) if on_disk.isdigit() else record["from"]
1797
- if plan.schema and record["to"] != plan.schema[1]:
1798
- record["target"] = plan.schema[1]
1799
- if plan.held is not None:
1800
- record["held_at"] = plan.held
1801
1509
  record["applied_at"] = utc_now()
1802
1510
  # A recovery re-run replays the same operation: record it once.
1803
1511
  record["ops"] = list(existing.get("ops", []))
@@ -1805,6 +1513,8 @@ def record_migration(plan, ops, deletions):
1805
1513
  record["deletions"] = list(existing.get("deletions", []))
1806
1514
  record["deletions"] += [d for d in deletions
1807
1515
  if not any(e.get("path") == d["path"] for e in record["deletions"])]
1516
+ if existing and all(existing.get(k) == record[k] for k in ("from", "to", "ops", "deletions")):
1517
+ return # a run that changed nothing leaves the record as it was
1808
1518
  write_file(plan.root, path, dump_json(record))
1809
1519
 
1810
1520
 
@@ -1820,74 +1530,58 @@ def apply(plan):
1820
1530
  raise Abort(exc.item or item, exc.why) from exc
1821
1531
  except OSError as exc:
1822
1532
  raise Abort(item, str(exc)) from exc
1823
- # The run finished: say which version it actually reached. Also after a run
1824
- # that replayed an aborted one and so recorded no operation of its own.
1533
+ # The run finished: say which version it actually reached.
1825
1534
  try:
1826
1535
  if recorded or os.path.isfile(os.path.join(plan.root, plan.report_dir, "migration.json")):
1827
- record_migration(plan, [], [])
1536
+ record_run(plan, [], [])
1828
1537
  except OSError as exc:
1829
1538
  raise Abort(None, str(exc)) from exc
1830
1539
 
1831
1540
 
1832
1541
  def apply_item(plan, item, written, recorded):
1833
1542
  target, action = item["target"], item["action"]
1834
- cleanup = (item.get("reason") or "").startswith("[cleanup]")
1835
- if action == "delete?": # listed only, until a human confirms it
1543
+ reason = item.get("reason") or ""
1544
+ cleanup = reason.startswith("[cleanup]")
1545
+ if action in ("delete?", "regenerate?"): # listed only, until a human confirms it
1836
1546
  return
1837
- mode = None
1838
- if action in ("move", "delete"):
1839
- source = item["src"] if action == "move" else target
1547
+ by = dict(confirmed_by=plan.confirm_cleanup if cleanup else plan.confirm_delete,
1548
+ via=plan.cleanup_via if cleanup else plan.confirm_via, at=utc_now())
1549
+ if action == "delete":
1840
1550
  # the plan-time symlink check is repeated: a path swapped since is refused
1841
- why = unsafe_path(plan.root, source)
1551
+ why = unsafe_path(plan.root, target)
1842
1552
  if why:
1843
1553
  raise Abort(item, why)
1844
- check(plan, item, source, item["src_expect"] if action == "move" else item["expect"], written)
1845
- if action == "move":
1846
- check(plan, item, target, item["expect"], written)
1847
- mode = os.stat(os.path.join(plan.root, source)).st_mode & 0o7777
1848
- # git-ignored task state: a move is byte-identical; a [cleanup] deletion of it is NOT
1849
- # recoverable through git (the logs are git-ignored), and copying what it removes
1850
- # would defeat the cleanup, so no original/ copy is kept. A report file git tracks,
1851
- # or should, is kept like any other deletion.
1852
- if not source.startswith(".ai/state/") and (not cleanup or source in plan.git_kept):
1853
- keep_original(plan, source)
1854
- recorded.append(target)
1855
- # Recorded before the file is gone: an abort later must not lose the note
1856
- # of what was taken away, or of who approved a deletion.
1857
- record_migration(plan,
1858
- [dict(action=action, migration=item["migration"],
1859
- **(dict(src=item["src"], dst=target) if action == "move" else dict(path=target)))],
1860
- [dict(path=target, confirmed_by=plan.confirm_cleanup if cleanup else plan.confirm_delete,
1861
- via=plan.cleanup_via if cleanup else plan.confirm_via, at=utc_now())]
1862
- if action == "delete" else [])
1863
- elif item["content"] is not None:
1864
1554
  check(plan, item, target, item["expect"], written)
1865
- # A migration rewriting a file outside .ai/ is rewriting a file the
1866
- # project owns. Keep what it had, exactly as a move or a deletion does,
1867
- # so a rewrite a human disagrees with can be read back and undone.
1868
- if item["migration"] is not None and not target.startswith(".ai/") \
1869
- and item["expect"] is not None and target not in written \
1870
- and not os.path.exists(os.path.join(plan.root, plan.report_dir, "original", target)):
1555
+ # git-ignored task state: a [cleanup] deletion of it is NOT recoverable through git, and
1556
+ # copying what it removes would defeat the cleanup, so no original/ copy is kept. A report
1557
+ # file git tracks, or should, is kept like any other deletion.
1558
+ if not target.startswith(".ai/state/") and (not cleanup or target in plan.git_kept):
1871
1559
  keep_original(plan, target)
1872
- if item["content"] is not None:
1873
- write_file(plan.root, target, item["content"], mode=mode)
1874
- written[target] = sha(item["content"])
1875
- if action == "move":
1876
- os.remove(os.path.join(plan.root, item["src"]))
1877
- written[item["src"]] = None
1878
- elif action == "delete":
1560
+ recorded.append(target)
1561
+ # Recorded before the file is gone: an abort later must not lose the note
1562
+ # of what was taken away, or of who approved it.
1563
+ record_run(plan, [dict(action=action, path=target)], [dict(path=target, **by)])
1879
1564
  os.remove(os.path.join(plan.root, target))
1880
1565
  written[target] = None
1881
- if (item["reason"] or "").startswith("[cleanup]"):
1566
+ if reason.startswith(("[cleanup]", "[local]")):
1882
1567
  prune_empty(plan.root, target)
1883
- if item["conflict_copy"] is not None:
1884
- write_file(plan.root, os.path.join(plan.local_dir, target), item["conflict_copy"])
1568
+ return
1569
+ check(plan, item, target, item["expect"], written)
1570
+ if item["keep"] and item["expect"] is not None and target not in written:
1571
+ # A change a human confirmed overwrites what they wrote: keep it, so it can be read back.
1572
+ if not os.path.exists(os.path.join(plan.root, plan.original_of(target))):
1573
+ keep_original(plan, target)
1574
+ recorded.append(target)
1575
+ record_run(plan, [dict(action=action, path=target, **by)], [])
1576
+ write_file(plan.root, target, item["content"])
1577
+ written[target] = sha(item["content"])
1885
1578
 
1886
1579
 
1887
1580
  def prune_empty(root, target):
1888
- """The directories a cleanup deletion left empty, up to (not including)
1889
- .ai/reports and .ai/state/logs; a directory with anything in it stays."""
1890
- top = ".ai/state/logs" if target.startswith(".ai/state/logs/") else ".ai/reports"
1581
+ """The directories a cleanup or cache deletion left empty, up to (not including)
1582
+ .ai/reports, .ai/state/logs and .ai/local; a directory with anything in it stays."""
1583
+ top = ".ai/state/logs" if target.startswith(".ai/state/logs/") else \
1584
+ ".ai/local" if target.startswith(LOCAL_CACHE + "/") else ".ai/reports"
1891
1585
  here = os.path.dirname(target)
1892
1586
  while here.startswith(top + "/"):
1893
1587
  try:
@@ -1897,56 +1591,13 @@ def prune_empty(root, target):
1897
1591
  here = os.path.dirname(here)
1898
1592
 
1899
1593
 
1900
- def plain_pending(root, ignore=()):
1901
- """True when the plain --check would exit 1: an automatic item or a schema
1902
- step is pending. Targets in `ignore` (an adopt resuming its own writes) do
1903
- not count. Same condition as main()'s --check branch."""
1904
- plan = build_plan(root)
1905
- if plan is None:
1906
- return False
1907
- pending = [i for i in plan.items if i["action"] not in ("conflict", "delete?") and i["target"] not in ignore]
1908
- return bool(pending or plan.schema)
1909
-
1910
-
1911
- def shipped_ai_files():
1912
- """{project-relative path: current shipped bytes} for every file this
1913
- plugin ships unedited into a scaffold: `.ai/**` from the ai-init templates,
1914
- and the project-init docs (`docs/sdlc/README.md`, the `TEMPLATE.md`s). Used
1915
- only to tell adopt's no-dangling check that an untouched plugin file
1916
- mentioning a tool's name in passing (a generic example, a policy) is not a
1917
- project-specific dependency on it — the same "unedited = ours, not the
1918
- project's words" rule the three-way update already applies."""
1919
- out = {}
1920
- base = os.path.join(TEMPLATES["ai-init"], ".ai")
1921
- for here, _dirs, files in os.walk(base):
1922
- for name in files:
1923
- full = os.path.join(here, name)
1924
- rel = ".ai/" + os.path.relpath(full, base).replace(os.sep, "/")
1925
- out[rel] = read(full)
1926
- for rel, data in shared_files().items(): # read from the plugin, never scaffolded
1927
- out.setdefault(rel, data)
1928
- for src, target, _kind in PROJECT_INIT_MAP:
1929
- path = os.path.join(TEMPLATES["project-init"], src)
1930
- if os.path.isfile(path):
1931
- out[target] = read(path)
1932
- return out
1933
-
1934
-
1935
- def shipped_block(runtime):
1936
- """The managed block the installed plugin ships for a runtime, as text."""
1937
- return read(os.path.join(TEMPLATES["ai-init"], INSTRUCTION_FILE[runtime][1])).decode("utf-8").rstrip("\n")
1938
-
1939
-
1940
1594
  def report(plan, applied):
1941
- auto = [i for i in plan.items if i["action"] not in ("conflict", "delete?")]
1942
- conflicts = [i for i in plan.items if i["action"] == "conflict"]
1943
- deletions = [i for i in plan.items if i["action"] == "delete?"]
1595
+ auto = [i for i in plan.items if i["action"] not in ("delete?", "regenerate?")]
1596
+ gated = [i for i in plan.items if i["action"] in ("delete?", "regenerate?")]
1944
1597
  head = "applied" if applied else "dry run, nothing written; --apply to write"
1945
1598
  print("project-update: %s (%s)" % (plan.root, head))
1946
1599
  if not plan.items:
1947
1600
  print(" up to date with the installed plugin")
1948
- def shown(i):
1949
- return "%s -> %s" % (i["src"], i["target"]) if i["src"] else i["target"]
1950
1601
  # a cleanup is one line per directory, however many files it holds
1951
1602
  rows, groups = [], {}
1952
1603
  for i in plan.items:
@@ -1957,49 +1608,34 @@ def report(plan, applied):
1957
1608
  if key in groups:
1958
1609
  groups[key]["count"] += 1
1959
1610
  continue
1960
- row = dict(i, migration=None, src=None, count=1)
1611
+ row = dict(i, count=1)
1961
1612
  groups[key] = row
1962
1613
  row["target"] = where
1963
1614
  i = row
1964
1615
  rows.append(i)
1965
1616
  for (where, _action, _note), row in groups.items():
1966
1617
  row["target"] = "%s/ (%d file%s)" % (where, row["count"], "" if row["count"] == 1 else "s")
1967
- width = max([len(shown(i)) for i in rows] + [10])
1968
- if plan.schema:
1969
- frm, to, mods = plan.schema
1970
- print(" %-9s %-*s (%d migration%s: %s)" % (
1971
- "schema", width, "%d -> %d" % (frm, to), len(mods), "" if len(mods) == 1 else "s",
1972
- ", ".join("%04d %s" % (m.VERSION, m.TITLE) for m in mods)))
1618
+ width = max([len(i["target"]) for i in rows] + [10])
1973
1619
  for i in rows:
1974
1620
  note = i["note"]
1975
- if i["migration"] is not None:
1976
- note = "[%04d]%s" % (i["migration"], " " + note if note else "")
1977
- if i.get("tool") and not note.startswith("[%s]" % i["tool"]):
1978
- note = "[%s] %s" % (i["tool"], note)
1979
- if i["action"] == "delete?":
1621
+ if i["action"] in ("delete?", "regenerate?"):
1980
1622
  if (i["reason"] or "").startswith("[cleanup]"):
1981
1623
  note += "; needs --apply --confirm-cleanup NAME"
1982
1624
  if i["target"].startswith(".ai/state/"):
1983
1625
  note += " (git-ignored: not recoverable through git)"
1984
1626
  else:
1985
1627
  note += "; needs --apply --confirm-delete NAME"
1986
- if i["action"] == "conflict" and i["conflict_copy"] is not None:
1987
- note += "; plugin version %s %s/%s" % ("at" if applied else "will be at", plan.local_dir, i["target"])
1988
- print(" %-9s %-*s %s" % (i["action"], width, shown(i), note))
1989
- for change in i["policy"] or []:
1990
- print(" %-9s %-*s %s" % ("policy", width, "", change))
1628
+ print(" %-9s %-*s %s" % (i["action"], width, i["target"], note))
1991
1629
  for h in plan.hints:
1992
1630
  print(" hint " + h)
1993
1631
  tail = ""
1994
- if any(i["policy"] for i in auto):
1995
- tail += ", %d policy change(s) to confirm" % sum(len(i["policy"] or []) for i in auto)
1996
1632
  # the summary names the flag each row names: [cleanup] rows settle with --confirm-cleanup only
1997
- cleanups = [i for i in deletions if (i["reason"] or "").startswith("[cleanup]")]
1998
- if len(deletions) > len(cleanups):
1999
- tail += ", %d deletion(s) awaiting --confirm-delete" % (len(deletions) - len(cleanups))
1633
+ cleanups = [i for i in gated if (i["reason"] or "").startswith("[cleanup]")]
1634
+ if len(gated) > len(cleanups):
1635
+ tail += ", %d change(s) awaiting --confirm-delete" % (len(gated) - len(cleanups))
2000
1636
  if cleanups:
2001
1637
  tail += ", %d cleanup file(s) awaiting --confirm-cleanup" % len(cleanups)
2002
- print("%d automatic, %d conflict(s)%s" % (len(auto), len(conflicts), tail))
1638
+ print("%d automatic%s" % (len(auto), tail))
2003
1639
 
2004
1640
 
2005
1641
  def main():
@@ -2007,24 +1643,10 @@ def main():
2007
1643
  ap.add_argument("root", nargs="?", default=".")
2008
1644
  ap.add_argument("--apply", action="store_true")
2009
1645
  ap.add_argument("--check", action="store_true")
2010
- ap.add_argument("--adopt", action="store_true",
2011
- help="adopt a foreign AI-tool structure (Spec Kit, Kiro, Cursor, ...); a dry run unless --apply")
2012
- ap.add_argument("--mode", choices=("migrate", "coexist"), default="migrate",
2013
- help="with --adopt: migrate (default) moves the foreign files; coexist only routes to them")
2014
- ap.add_argument("--tool", metavar="LIST", help="with --adopt: only these tools, comma-separated")
2015
- ap.add_argument("--split-request", action="store_true",
2016
- help="with --adopt: write split-request.json for each oversized instruction file, nothing else")
2017
- ap.add_argument("--split", choices=("proposal", "fallback"),
2018
- help="with --adopt: split by the session's proposal, or move every line outside the "
2019
- "block verbatim to .ai/policies/adopted/ (default: the proposal if one matches)")
2020
- ap.add_argument("--diff", action="store_true", help="with --adopt: print the unified diff of the dry run")
2021
- ap.add_argument("--cleanup", action="store_true",
2022
- help="with --adopt: list the adopted foreign files for deletion; they are deleted only "
2023
- "with --apply --confirm-delete NAME, typed by a human")
2024
1646
  ap.add_argument("--budget", action="store_true",
2025
1647
  help="with --check: exit 1 when a root instruction file is over its byte budget")
2026
1648
  ap.add_argument("--confirm-delete", metavar="NAME",
2027
- help="a human confirms this run's proposed deletions, and is recorded in "
1649
+ help="a human confirms this run's gated rows (regenerate?, delete?), and is recorded in "
2028
1650
  "the migration report; only with --apply")
2029
1651
  ap.add_argument("--confirm-cleanup", metavar="NAME",
2030
1652
  help="a human confirms this run's [cleanup] deletions (run logs and stale task files, "
@@ -2038,52 +1660,27 @@ def main():
2038
1660
  if not args.apply:
2039
1661
  ap.error("%s only makes sense with --apply" % flag)
2040
1662
  if not name.strip():
2041
- ap.error("%s needs the name of the human who confirmed the deletion" % flag)
2042
- if adopt.placeholder_name(name):
1663
+ ap.error("%s needs the name of the human who confirmed the change" % flag)
1664
+ if placeholder_name(name):
2043
1665
  ap.error("%s got the placeholder %r; type your own name, it is the audit trail"
2044
1666
  % (flag, name.strip()))
2045
- passed, via, why = adopt.human_gate()
1667
+ passed, via, why = human_gate()
2046
1668
  if not passed:
2047
1669
  # Under an agent-session marker the AI_UNATTENDED route is the launcher's
2048
1670
  # to declare, not the agent's to type: the hint names it only otherwise.
2049
1671
  hint = "run the same command yourself in a terminal outside Claude Code or Codex"
2050
1672
  if "agent session" not in why:
2051
1673
  hint += ", or set AI_UNATTENDED=1 if a launcher runs it unattended on your behalf"
2052
- return adopt.refuse("%s is typed by a human, and %s" % (flag, why), hint)
1674
+ return refuse("%s is typed by a human, and %s" % (flag, why), hint)
2053
1675
  if flag == "--confirm-delete":
2054
1676
  confirm_via = via
2055
1677
  else:
2056
1678
  cleanup_via = via
2057
1679
  if args.budget and not args.check:
2058
1680
  ap.error("--budget only makes sense with --check")
2059
- if not args.adopt and (args.tool or args.mode != "migrate" or args.split_request or args.split or args.diff
2060
- or args.cleanup):
2061
- ap.error("--mode, --tool, --split, --split-request, --diff and --cleanup only make sense with --adopt")
2062
1681
  root = os.path.abspath(args.root)
2063
- if args.adopt and args.confirm_cleanup is not None:
2064
- ap.error("--confirm-cleanup does not apply to --adopt")
2065
- if args.adopt:
2066
- if args.confirm_delete is not None and not args.cleanup:
2067
- ap.error("--adopt --confirm-delete belongs to --cleanup")
2068
- if args.cleanup and (args.check or args.split_request or args.split or args.diff or args.tool):
2069
- ap.error("--cleanup takes only --apply --confirm-delete NAME")
2070
- if args.cleanup and args.apply and args.confirm_delete is None:
2071
- ap.error("--cleanup --apply needs --confirm-delete NAME, typed by the human who confirmed it")
2072
- if sum((args.apply, args.check, args.split_request)) > 1:
2073
- ap.error("--adopt takes one of --apply, --check and --split-request")
2074
- if args.diff and (args.apply or args.check or args.split_request):
2075
- ap.error("--diff is a dry run; it takes no --apply, --check or --split-request")
2076
- caps = render_instructions.budgets(render_instructions.DEFAULT_SOURCE)
2077
- instruction_files = frozenset(INSTRUCTION_FILE[rt][0] for rt in INSTRUCTION_FILE)
2078
- if args.cleanup:
2079
- return adopt.cleanup_run(Plan(root), args, sys.modules[__name__], caps.get("skeleton"),
2080
- instruction_files, shipped_ai_files())
2081
- if args.apply:
2082
- return adopt.apply_run(Plan(root), args, sys.modules[__name__], caps.get("skeleton"),
2083
- instruction_files, shipped_ai_files())
2084
- return adopt.run(Plan(root), args, shipped_block, caps.get("skeleton"), instruction_files, shipped_ai_files())
2085
1682
  if args.check and args.budget:
2086
- offenders = adopt.budget_offenders(
1683
+ offenders = budget_offenders(
2087
1684
  root, [INSTRUCTION_FILE[rt][0] for rt in project_runtimes(root)],
2088
1685
  render_instructions.budgets(render_instructions.DEFAULT_SOURCE), render_instructions.block_of)
2089
1686
  for line in offenders:
@@ -2093,44 +1690,32 @@ def main():
2093
1690
  if not os.path.isdir(tpl):
2094
1691
  print("project-update: %s templates not found at %s (run riskrails/install.sh)" % (name, tpl), file=sys.stderr)
2095
1692
  return 2
1693
+ if not (os.path.isdir(os.path.join(root, ".ai")) or os.path.isdir(os.path.join(root, "docs", "sdlc"))):
1694
+ print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /rr-setup first" % root, file=sys.stderr)
1695
+ return 2
2096
1696
  try:
2097
- plan = build_plan(root, confirm_delete=getattr(args, "confirm_delete", None), confirm_via=confirm_via,
1697
+ if os.path.isdir(os.path.join(root, ".ai")):
1698
+ project_version(root)
1699
+ bad = check_kept(root)
1700
+ if bad:
1701
+ return kept_refusal(bad)
1702
+ plan = build_plan(root, confirm_delete=args.confirm_delete, confirm_via=confirm_via,
2098
1703
  confirm_cleanup=args.confirm_cleanup, cleanup_via=cleanup_via)
2099
1704
  except SchemaError as exc:
2100
- # --check is read by /ai-status, which reads stdout: it must see the reason.
1705
+ # --check is read by /rr-status, which reads stdout: it must see the reason.
2101
1706
  print("project-update: %s" % exc, file=sys.stdout if args.check else sys.stderr)
2102
1707
  return 2
2103
- if plan is None:
2104
- print("project-update: %s has neither .ai/ nor docs/sdlc/ — run /rr-setup first" % root,
2105
- file=sys.stderr)
2106
- return 2
2107
- if not args.check:
2108
- # D5: the plain dry run says when a foreign structure waits for --adopt
2109
- # (never --check, whose one line /ai-status reads byte for byte).
2110
- caps = render_instructions.budgets(render_instructions.DEFAULT_SOURCE)
2111
- code, line = adopt.state_line(root, shipped_block, caps.get("skeleton"))
2112
- if code:
2113
- plan.hints.append(line)
2114
- pending = [i for i in plan.items if i["action"] not in ("conflict", "delete?")]
2115
1708
  if args.check:
2116
- conflicts = sum(1 for i in plan.items if i["action"] == "conflict")
2117
- if pending or plan.schema:
2118
- bits = []
2119
- if plan.schema:
2120
- bits.append("schema %d -> %d" % plan.schema[:2])
2121
- files = sum(1 for i in pending if i["migration"] is None)
2122
- if files:
2123
- bits.append("%d file(s) to update" % files)
2124
- if conflicts:
2125
- bits.append("%d need a manual merge" % conflicts)
2126
- deletions = sum(1 for i in plan.items if i["action"] == "delete?")
2127
- if deletions:
2128
- bits.append("%d deletion(s) a human must confirm" % deletions)
2129
- if plan.held is not None:
2130
- bits.append("the schema stays at %d until they are settled" % plan.held)
1709
+ pending = [i for i in plan.items if not i["action"].endswith("?")]
1710
+ waiting = [i for i in plan.items if i["action"].endswith("?") and not (i["reason"] or "").startswith("[cleanup]")]
1711
+ if pending:
1712
+ bits = ["%d file(s) to update" % len(pending)]
1713
+ if waiting:
1714
+ bits.append("%d change(s) a human must confirm" % len(waiting))
2131
1715
  print("project is behind the installed plugin: %s — run /rr-setup --update" % ", ".join(bits))
2132
1716
  return 1
2133
- print("project matches the installed plugin" + (" (%d file(s) differ by hand-merge choice)" % conflicts if conflicts else ""))
1717
+ print("project matches the installed plugin"
1718
+ + (" (%d change(s) wait for --confirm-delete)" % len(waiting) if waiting else ""))
2134
1719
  return 0
2135
1720
  if args.apply:
2136
1721
  try: