riskrails 3.2.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) 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 +50 -84
  5. package/agents/ai-reviewer.md.tmpl +1 -1
  6. package/ai/using-riskrails.md +0 -3
  7. package/bin/menu.js +116 -0
  8. package/bin/riskrails.js +132 -79
  9. package/hooks/ai-path-guard-defaults.json +1 -1
  10. package/hooks/context-guard.py +2 -4
  11. package/hooks/guard/dialog.py +14 -2
  12. package/hooks/guard/paths.py +3 -6
  13. package/hooks/guard/scope.py +1 -3
  14. package/hooks/runtime-gate.py +18 -58
  15. package/instructions/routing.md +2 -2
  16. package/package.json +1 -1
  17. package/scripts/ai-path +1 -1
  18. package/scripts/resolve-profile.py +0 -3
  19. package/skills/ai-audit/templates/ai-sdlc-adoption-plan.md +1 -1
  20. package/skills/ai-audit/templates/play-scoring-rubric.md +1 -1
  21. package/skills/ai-init/scaffold-ai.sh +2 -1
  22. package/skills/ai-init/templates/.ai/AGENTS.md +6 -6
  23. package/skills/ai-init/templates/.ai/VERSION +1 -1
  24. package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
  25. package/skills/ai-init/templates/.ai/policies/security.md +1 -2
  26. package/skills/ai-init/templates/.ai/policies/testing.md +5 -12
  27. package/skills/ai-init/templates/.ai/policies/verification.md +18 -0
  28. package/skills/ai-init/templates/.ai/policies/weight.json +0 -1
  29. package/skills/ai-init/templates/.ai/policies/weight.md +1 -2
  30. package/skills/ai-init/templates/.ai/policies/weight.project.json +4 -0
  31. package/skills/ai-init/templates/.ai/project/architecture.md +1 -1
  32. package/skills/ai-init/templates/.ai/project/initial-assessment.md +2 -2
  33. package/skills/ai-init/templates/.ai/project/overview.md +1 -1
  34. package/skills/ai-init/templates/.ai/rules/README.md +2 -2
  35. package/skills/ai-init/templates/.ai/state/README.md +1 -1
  36. package/skills/ai-task/memory_store.py +8 -9
  37. package/skills/ai-task/rr_state/cli.py +2 -2
  38. package/skills/ai-task/rr_state/dialog.py +2 -4
  39. package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
  40. package/skills/ai-task/rr_state/fs.py +5 -13
  41. package/skills/ai-task/rr_state/labels.py +14 -2
  42. package/skills/ai-task/rr_state/memory_cmd.py +7 -4
  43. package/skills/ai-task/rr_state/profile.py +2 -4
  44. package/skills/ai-task/rr_state/questions_cmd.py +1 -1
  45. package/skills/ai-task/rr_state/report.py +3 -4
  46. package/skills/ai-task/rr_state/runtime.py +1 -1
  47. package/skills/ai-task/rr_state/start.py +7 -7
  48. package/skills/ai-task/sensors.py +10 -11
  49. package/skills/ai-task/state.py +4 -24
  50. package/skills/ai-task/weight.py +17 -17
  51. package/skills/ai-task/weight_overlay.py +38 -0
  52. package/skills/project-init/templates/constitution.md +2 -2
  53. package/skills/project-update/history/blobs/00f5e27ce2d66e3a1d8b222b013ae8fa1fed32612d91a551e0c661cee3ef9c2d +34 -0
  54. package/skills/project-update/history/blobs/0a6b1b6dd06a35582b2e9989a1f41c40abc29254b8e482714fcc6f0a7b16c406 +32 -0
  55. package/skills/project-update/history/blobs/0aeb04b04262683cac6513545683486a73dc473b447bba43b5aaab18403957a6 +34 -0
  56. package/skills/project-update/history/blobs/0ce913a28996f546f72d570b0e5a376362e993ce4d805bc4aae57d38caa44212 +41 -0
  57. package/skills/project-update/history/blobs/0ea9e36446b624b73b2a821231b2ee66a357b43d584a5b23da48b3c800855e65 +41 -0
  58. package/skills/project-update/history/blobs/0fb1d68678a1349195b2e79433bb20174d25874e3bfd0b70410bb4e428b4e4b8 +35 -0
  59. package/skills/project-update/history/blobs/119b2b9d0f9c647a243540724edac6b29afa020c69aa205797bcfa9eb06bc021 +35 -0
  60. package/skills/project-update/history/blobs/1d0fe3ee5fb8260ba610942d4bbdfff7410fbe80ed826ac07f32985031dc2b3f +34 -0
  61. package/skills/project-update/history/blobs/1de9a37ed6402893dcab6e61ddbf15ffd35f15a6d84b18e675a0c5bf4e981f85 +42 -0
  62. package/skills/project-update/history/blobs/1f44e1a236233dcbab58064f39b69f798b35d1d9a29f8c615b5b12854ce633cb +15 -0
  63. package/skills/project-update/history/blobs/1f824645299579cbdd8141642ddbb4bca9fd5fe9d3112f036d8221f0811e0abf +40 -0
  64. package/skills/project-update/history/blobs/235fb88393cfe20dd58b35fe9785cc45baf5ffe21a13d926652d76ec68cea8b7 +35 -0
  65. package/skills/project-update/history/blobs/3918b4ee011d9477b9d2779e2893ab599749c406d4b09439a9748b64fbff58f3 +18 -0
  66. package/skills/project-update/history/blobs/449273200dfec2a56885c881ff34d40e8ecc00da53c24c2d09b7fbeb1004d255 +40 -0
  67. package/skills/project-update/history/blobs/4c45aee9b9fc5f6b4e1d9739e434c4094975a5a8dec6b9703fb25f3080680c77 +124 -0
  68. package/skills/project-update/history/blobs/4dd3fa12bc45acc8c836ab65d790248ca7504a021bca6677499d17bb3d798ed9 +64 -0
  69. package/skills/project-update/history/blobs/51e3f15095d7123ea8566b512479ee10a6cb2f336b7352a259a083a887bd7116 +33 -0
  70. package/skills/project-update/history/blobs/5497a204967bea7dab29079a6e1dfa3d8713b2d2cb294c2779378039f03a55fc +36 -0
  71. package/skills/project-update/history/blobs/60c4d7e7fde7049d92d84295fc13ca9a91eb2de423be08f093b3ee5d5e817ff8 +64 -0
  72. package/skills/project-update/history/blobs/6221b7c14cc04634e485e562d464c6860d45666a7f63e293c06bb2944ff556cc +27 -0
  73. package/skills/project-update/history/blobs/637971cfb844ef4285e2886a4b1c834fb2ed5c46e773adb88c2c147fea626ad3 +33 -0
  74. package/skills/project-update/history/blobs/65af2282eea2a51fcc27a4acf2c26c727b0961c30e1be13176369200c460c219 +34 -0
  75. package/skills/project-update/history/blobs/66ecd73e303e7bbc1aa8ee72435d0d10812ff7b2eff0adac59bbf0a303e2cc01 +41 -0
  76. package/skills/project-update/history/blobs/6a43bf32461b22b87fafe2865529dc04fa66cd00182a2a7598aa071667ca255a +35 -0
  77. package/skills/project-update/history/blobs/6d6dee8634abf9ac057bb206828b83a893a2afcf4f86bbe69e839a3b47d0adde +48 -0
  78. package/skills/project-update/history/blobs/7224ede803ea81e65a456e21b6028a0e848e8a880456c6503eebe7534ca4f6aa +74 -0
  79. package/skills/project-update/history/blobs/765a39a202617096c232017822ae738d37e4405430c71700dc7bc14bf1ba794e +33 -0
  80. package/skills/project-update/history/blobs/77577440a933d1cabd8c6f4a6401cf361a784aa09a25500a6f7b44ead8ab5e55 +41 -0
  81. package/skills/project-update/history/blobs/7bae90789625d6fb9071ecb412d14a8d1eb0bc5659d8d284c4eaf213e1b5de56 +67 -0
  82. package/skills/project-update/history/blobs/7bcdf9a0e6b85277b9b60669150224b9414d2e32821ff06f4d148eb1d18785bb +42 -0
  83. package/skills/project-update/history/blobs/7f834103e9d97aea44e76717bcdcbbe349c560e5b81a42ce85a1cec341041281 +4 -0
  84. package/skills/project-update/history/blobs/7fb3acb0211818efebc2234863db0b8c846022a7280a6d2d69f267dbf63db48c +34 -0
  85. package/skills/project-update/history/blobs/87f0855859ff68cc6c988cc0b631ee7f56af7fbba062172bf80e01f4982cc054 +35 -0
  86. package/skills/project-update/history/blobs/8abab74759766d02b5cb7b0c3e59a3d440b25a454216cd8fea325c23fd822d36 +33 -0
  87. package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
  88. package/skills/project-update/history/blobs/a1fb50e6c86fae1679ef3351296fd6713411a08cf8dd1790a4fd05fae8688164 +1 -0
  89. package/skills/project-update/history/blobs/a3df7268feb0780be0d2cf9acced6ed81780a7790f36fe024b6ea0b1bd6eb7a6 +35 -0
  90. package/skills/project-update/history/blobs/b28721136482dafb9fd2801af340364cb97e9f6eaa987908606c786045b8d334 +28 -0
  91. package/skills/project-update/history/blobs/b3726422d767f9219fea633bea77f0c99ad9af843c591e6e928e1ccd2a47f374 +27 -0
  92. package/skills/project-update/history/blobs/b3839425f9d0ab2efa42a6f5e9488ec2d26ca5fb8dcd694b8ab098e10743ca46 +32 -0
  93. package/skills/project-update/history/blobs/ba14a457e83f9dde65e4190fdfab83b912f7398c1442a2a1cb3d89bfac741cf3 +8 -0
  94. package/skills/project-update/history/blobs/bceb7189444bb6291b262ca08e34db01010dda2f6e64d696231ec3c343c95c66 +34 -0
  95. package/skills/project-update/history/blobs/bf82a2bb4fce1bd349ff66118930a01d61a7af8e2bd8d200c721d43afc58c713 +34 -0
  96. package/skills/project-update/history/blobs/c4dbeb180292a6199ad73a3be861ece07c1f8bf6bf0cc7ca204412bc8e1bb1d8 +31 -0
  97. package/skills/project-update/history/blobs/c644838866df2001a42501a988dd7b77aacf862bf09d22b465f3cd8a22c6b6eb +33 -0
  98. package/skills/project-update/history/blobs/c980d74f76a4bdfb38c94b573ac10a54eb50b850b052e15fa6f2442889b36ccb +32 -0
  99. package/skills/project-update/history/blobs/ceed2f3bd937f862d0e6ca59ce1b1e3603e19a0d7f85d90d6dfeb7bf690153be +29 -0
  100. package/skills/project-update/history/blobs/d51ea2e893482a20c5882056cf8043327ca1dc1c957927f6746929a47ee8368e +45 -0
  101. package/skills/project-update/history/blobs/d78f5e0bb1380259fce2cce009785a9a11f6de8b87a160080b9872c059f95060 +34 -0
  102. package/skills/project-update/history/blobs/d97f46a2cca020406252b1f7baa4184617fa71faca3885e2379c44ae4e1d9327 +67 -0
  103. package/skills/project-update/history/blobs/da3ecd4c4c414c93d04488685931df39a56bf959bfeb9c45cb1aea2c13bd991b +38 -0
  104. package/skills/project-update/history/blobs/e129bf71a5a922e24b2c1bc8daa8a5d2b5589b0069770e17ce17e7db5ed4e7a4 +27 -0
  105. package/skills/project-update/history/blobs/e504529519a371892838725f8b89b5d32e3300e345bcbf2643a3a1a48434b3dd +24 -0
  106. package/skills/project-update/history/blobs/e9e62d03ca70b3dffccef8dceeb214173352cfef005145e519f0e037993f1c03 +36 -0
  107. package/skills/project-update/history/blobs/effefbaaf4beb8f1ba55081d6b7e74bf25ebb91045dd10613418f17b95fa7182 +33 -0
  108. package/skills/project-update/history/blobs/f6af4581bafbf62b257d57e06c3ae2c5998aeca608ee7df037fcc3c3e4b563a9 +34 -0
  109. package/skills/project-update/history/blobs/fc35045777d29630303430752a651b3e553580a581036a02cc103eb96a46f4b0 +34 -0
  110. package/skills/project-update/history/blobs/fdc21345c77f92a42d244aa104f393f439a0ff9981a9fd462bd837e8a47931bb +48 -0
  111. package/skills/project-update/history/index.json +34 -14
  112. package/skills/project-update/render_instructions.py +12 -45
  113. package/skills/project-update/update.py +757 -1172
  114. package/skills/rr-setup/SKILL.md +114 -97
  115. package/skills/rr-setup/scaffold_ai.py +15 -9
  116. package/skills/rr-setup/setup.py +462 -164
  117. package/skills/rr-status/status.py +7 -10
  118. package/skills/usage-report/usage-report.py +4 -5
  119. package/skills/ai-audit/SKILL.md +0 -9
  120. package/skills/ai-init/SKILL.md +0 -9
  121. package/skills/ai-status/SKILL.md +0 -9
  122. package/skills/ai-task/SKILL.md +0 -9
  123. package/skills/project-init/SKILL.md +0 -9
  124. package/skills/project-update/SKILL.md +0 -9
  125. package/skills/project-update/adopt-map.json +0 -91
  126. package/skills/project-update/adopt.py +0 -2225
  127. package/skills/project-update/migrations/0001_schema_version.py +0 -13
  128. package/skills/project-update/migrations/0002_task_journal.py +0 -123
  129. package/skills/project-update/migrations/0003_context_diet.py +0 -65
  130. package/skills/project-update/migrations/0004_deterministic_gates.py +0 -57
  131. package/skills/project-update/migrations/0005_runtimes_and_plans.py +0 -35
  132. package/skills/project-update/migrations/0006_shared_files.py +0 -154
  133. package/skills/project-update/migrations/0007_weight_model.py +0 -295
  134. package/skills/project-update/migrations/0008_renames.py +0 -43
  135. package/skills/project-update/migrations/0009_state_file.py +0 -25
  136. package/skills/project-update/migrations/0010_dialog_switch.py +0 -30
  137. package/skills/project-update/migrations/0011_retire_risk_tiers.py +0 -37
  138. package/skills/project-update/migrations/README.md +0 -76
  139. package/skills/project-update/migrations/__init__.py +0 -186
  140. package/skills/sdlc-intent/SKILL.md +0 -9
  141. package/skills/sdlc-plan/SKILL.md +0 -9
  142. package/skills/sdlc-spec/SKILL.md +0 -9
  143. package/skills/usage-report/SKILL.md +0 -10
  144. /package/{ai/workflows/refactoring.md → skills/project-update/history/blobs/36cb593293496fbcf38df828a9b9b2bc0d5a53333f584a5319ebc3d799016e24} +0 -0
  145. /package/{ai/workflows/feature.md → skills/project-update/history/blobs/56e16072c2f3edc6c8897c690d09a11d12fc414c3e3f361f359f7dc609a21282} +0 -0
  146. /package/{ai/workflows/hotfix.md → skills/project-update/history/blobs/8cbefbf1aa412bae3e5019acd40b8fe0598fceba6c781906b3bc6dbd014f6b23} +0 -0
  147. /package/{ai/workflows/bugfix.md → skills/project-update/history/blobs/af56dc16e44ae1d8c3dac4c3ca82e2163b719553c02ea455e77430bab9a14089} +0 -0
  148. /package/{ai/workflows/investigation.md → skills/project-update/history/blobs/b9e09cb24958cd6831589997558974bd6356305306e8f9685e226bcdc30d60ac} +0 -0
  149. /package/{ai/agents/manager.md → skills/project-update/history/blobs/d0cf6388f35a92a0cf607d33909278382f6d7b6a4c32e281b9f9e0ea652c7b91} +0 -0
@@ -1,19 +1,27 @@
1
1
  """dialog_cmd: the consent and decision commands -- decision open/show/settle/fallback, the pick applied, and the read-only consent view.
2
2
 
3
- core: SETTLE_EFFECT
4
- shell: cmd_decision, decision_open, decision_settle, apply_choice, cmd_consent
3
+ core: SETTLE_EFFECT, SETUP_VALUES, SETUP_VALUE_MAX, SETUP_SPEC_MAX, setup_option_why, setup_decision_why, setup_spec_why
4
+ shell: cmd_decision, decision_open, read_setup_spec, setup_command_why, check_setup_labels, setup_open, decision_settle, apply_choice, cmd_consent
5
5
  """
6
6
  # pylint: disable=unused-argument # uniform cli dispatch: cmd_consent takes rt like every command
7
7
  import argparse
8
8
  import json
9
9
  import os
10
+ import shlex
10
11
  import sys
11
12
 
12
13
  from rr_state import dialog, fs, labels, profile, questions_cmd, task
13
14
  from rr_state import ledger as rr_ledger
14
15
  from rr_state import runtime as rr_runtime
15
16
 
16
- SETTLE_EFFECT = {"defer": "defer", "keep": "ack", "ack": "ack", "answer": "answered"}
17
+ SETTLE_EFFECT = {"defer": "defer", "keep": "ack", "ack": "ack", "answer": "answered", "set": "set"}
18
+ # What `decision open --kind setup` accepts (R11): the questions /rr-setup asks, each with the words a choice may set
19
+ # (what the skill applies is what the human read: one of these words, or, for verify only, a command shown verbatim
20
+ # in its own label), the longest value, and the most characters of a spec it reads.
21
+ SETUP_VALUES = {"memory": ("off", "keep"), "runtime": ("auto", "claude", "codex", "both"),
22
+ "survey": ("light", "full", "keep"), "verify": ("", "other", "keep", "edit")}
23
+ SETUP_VALUE_MAX = 200
24
+ SETUP_SPEC_MAX = 262144
17
25
 
18
26
 
19
27
  def cmd_decision(args, root, rt=rr_runtime.Runtime()):
@@ -53,10 +61,16 @@ def cmd_decision(args, root, rt=rr_runtime.Runtime()):
53
61
 
54
62
 
55
63
  def decision_open(args, root, rt=rr_runtime.Runtime()):
56
- """The one opener the model may call: commit and push, nothing else."""
64
+ """The openers the model may call: --kind commit (git commit and git push) and --kind setup (the /rr-setup
65
+ questions: choices that set a value, and this project's `memory enable`); nothing else."""
66
+ if args.kind == "setup":
67
+ setup_open(args, root, rt=rt)
68
+ return
57
69
  if args.kind != "commit":
58
- fs.die("decision open takes --kind commit; every other decision is opened by the command that "
59
- "needs it (close, ask, the budgets, start --mode)")
70
+ fs.die("decision open takes --kind commit or --kind setup; every other decision is opened by the "
71
+ "command that needs it (close, ask, the budgets, start --mode)")
72
+ if args.spec is not None:
73
+ fs.die("decision open --kind commit takes --command, not --spec", 2)
60
74
  if not args.commands:
61
75
  fs.die("decision open --kind commit needs --command \"git commit …\" [--command \"git push <remote> <branch>\"]")
62
76
  for command in args.commands:
@@ -83,6 +97,165 @@ def decision_open(args, root, rt=rr_runtime.Runtime()):
83
97
  dialog.print_dialog([decision], rt=rt)
84
98
 
85
99
 
100
+ def read_setup_spec(path):
101
+ """--spec FILE|- as JSON: a spec that cannot be read, is longer than SETUP_SPEC_MAX characters, is not JSON or
102
+ nests deeper than the dialog's own cap is a bad spec (exit 2)."""
103
+ try:
104
+ if path == "-":
105
+ text = sys.stdin.read(SETUP_SPEC_MAX + 1)
106
+ else:
107
+ with open(path, encoding="utf-8") as fh:
108
+ text = fh.read(SETUP_SPEC_MAX + 1)
109
+ if len(text) > SETUP_SPEC_MAX:
110
+ fs.die("bad spec — more than %d characters" % SETUP_SPEC_MAX, 2)
111
+ spec = dialog.json_loads_capped(text)
112
+ # A lone surrogate ("\ud800") parses, yet no file can hold it: refused here, not after a first decision is written.
113
+ json.dumps(spec, ensure_ascii=False).encode("utf-8")
114
+ return spec
115
+ except (OSError, ValueError, RecursionError) as exc:
116
+ fs.die("bad spec — %s" % exc, 2)
117
+
118
+
119
+ def setup_option_why(option, key):
120
+ """Why one option of the decision `key` is not {kind: choice, label, value[, description]} or {kind: command,
121
+ label[, description]}, else None. A choice sets one of its key's words (SETUP_VALUES); a verify choice may also
122
+ set a command, and check_setup_labels (exit 6) makes its label show it, like every rule of the labels."""
123
+ if not isinstance(option, dict) or option.get("kind") not in ("choice", "command"):
124
+ return "an option is an object whose kind is choice or command"
125
+ need = {"kind", "label", "value"} if option["kind"] == "choice" else {"kind", "label"}
126
+ if not need <= set(option) <= need | {"description"}:
127
+ return "a %s option holds %s, and a description if it has one" % (option["kind"], ", ".join(sorted(need)))
128
+ text, value = option.get("description", ""), option.get("value", "")
129
+ if not isinstance(option["label"], str) or not isinstance(text, str) or len(text) > dialog.QUESTION_MAX:
130
+ return "a label and a description are text, a description at most %d characters" % dialog.QUESTION_MAX
131
+ if not isinstance(value, str) or len(value) > SETUP_VALUE_MAX or not value.isprintable():
132
+ return "a choice value is at most %d printable characters (empty is allowed)" % SETUP_VALUE_MAX
133
+ if option["kind"] == "choice" and key != "verify" and value not in SETUP_VALUES[key]:
134
+ return "a %s choice sets one of: %s" % (key, ", ".join(SETUP_VALUES[key]))
135
+ return None
136
+
137
+
138
+ def setup_decision_why(item):
139
+ """Why one decision of a spec is not {key, header, question, options}, else None."""
140
+ if not isinstance(item, dict) or set(item) != {"key", "header", "question", "options"}:
141
+ return "a decision is an object holding exactly key, header, question and options"
142
+ if not isinstance(item["key"], str) or item["key"] not in SETUP_VALUES:
143
+ return "a decision key is one of %s" % ", ".join(SETUP_VALUES)
144
+ if not all(isinstance(item[name], str) and item[name].strip() for name in ("header", "question")):
145
+ return "a header and a question are text and not empty"
146
+ if not isinstance(item["options"], list) or not 2 <= len(item["options"]) <= 4:
147
+ return "a decision has 2 to 4 options"
148
+ for number, option in enumerate(item["options"], 1):
149
+ why = setup_option_why(option, item["key"])
150
+ if why:
151
+ return "option %d: %s" % (number, why)
152
+ return None
153
+
154
+
155
+ def setup_spec_why(spec):
156
+ """Why a parsed spec is not {"schema": 1, "decisions": [...]} with 1 to DECISIONS_MAX decisions, each key
157
+ once, else None (R11)."""
158
+ if not isinstance(spec, dict) or set(spec) != {"schema", "decisions"}:
159
+ return "a spec is an object holding exactly schema and decisions"
160
+ schema, decisions = spec["schema"], spec["decisions"]
161
+ if not isinstance(schema, int) or isinstance(schema, bool) or schema != 1:
162
+ return "schema is 1"
163
+ if not isinstance(decisions, list) or not 1 <= len(decisions) <= dialog.DECISIONS_MAX:
164
+ return "decisions is a list of 1 to %d decisions" % dialog.DECISIONS_MAX
165
+ for number, item in enumerate(decisions, 1):
166
+ why = setup_decision_why(item)
167
+ if why:
168
+ return "decision %d: %s" % (number, why)
169
+ keys = [item["key"] for item in decisions]
170
+ return None if len(set(keys)) == len(keys) else "a decision key is used once"
171
+
172
+
173
+ def setup_command_why(root, label):
174
+ """Why `label` is not exactly `python3 <this state.py> --root <this project> memory enable [options]`, else
175
+ None. validate_label alone also passes another --root, a --runtime, interpreter flags, approve and git commit
176
+ (R11, B1); the paths must be absolute, as the labels state_label writes."""
177
+ why = labels.validate_label(label)
178
+ if why:
179
+ return why
180
+ words = shlex.split(label)
181
+ if len(words) >= 6 and words[0] == "python3" and words[2] == "--root" and words[4:6] == ["memory", "enable"] \
182
+ and os.path.isabs(words[1]) and os.path.realpath(words[1]) == os.path.realpath(fs.ENTRY) \
183
+ and os.path.isabs(words[3]) and os.path.realpath(words[3]) == os.path.realpath(root):
184
+ return None
185
+ return ("a setup command is exactly python3 <this state.py> --root <this project> memory enable "
186
+ "[--author S] [--period P] [--index none|sqlite]")
187
+
188
+
189
+ def check_setup_labels(root, decisions):
190
+ """Every label of the spec, before anything is written (exit 6): only the memory decision offers a command, and
191
+ that is setup_command_why's; a choice never reads as a command, because the gate passes a Bash command equal
192
+ to any pick and dialog_pick runs a pick that is a state.py label; and a verify choice that sets a command
193
+ shows it, whole, as one `span` of its label, because the skill applies the value and the human reads the
194
+ label (R11, B1)."""
195
+ for item in decisions:
196
+ options = item["options"]
197
+ if len({option["label"] for option in options}) != len(options):
198
+ fs.die("CONSENT_LABEL_INVALID — two options of the %s decision carry the same label" % item["key"], 6)
199
+ for option in options:
200
+ label = option["label"]
201
+ if option["kind"] == "command":
202
+ why = setup_command_why(root, label) if item["key"] == "memory" else \
203
+ "only the memory decision offers a command"
204
+ elif not 0 < len(label) <= labels.LABEL_MAX or not label.isprintable():
205
+ why = "a choice label is 1 to %d printable characters" % labels.LABEL_MAX
206
+ elif label.startswith(("python3", "git", "npx")) or labels.label_argv(label) \
207
+ or labels.validate_label(label) is None:
208
+ why = "a choice label never reads as a command"
209
+ elif item["key"] == "verify" and option["value"] not in SETUP_VALUES["verify"] \
210
+ and option["value"] not in label.split("`")[1:-1:2]:
211
+ why = "a verify command is shown verbatim in its label, inside backticks"
212
+ else:
213
+ why = None
214
+ if why:
215
+ fs.die("CONSENT_LABEL_INVALID — %s: %s" % (why, label[:120]), 6)
216
+
217
+
218
+ def setup_open(args, root, rt=rr_runtime.Runtime()):
219
+ """decision open --kind setup --spec FILE|-: every check runs for every decision of the spec before the first
220
+ write, then all are opened in the spec's order, or none (R11). The caller sends the one dialog."""
221
+ if args.commands or args.spec is None:
222
+ fs.die("decision open --kind setup takes --spec FILE|- and no --command", 2)
223
+ spec = read_setup_spec(args.spec)
224
+ why = setup_spec_why(spec)
225
+ if why:
226
+ fs.die("bad spec — %s" % why, 2)
227
+ decisions = spec["decisions"]
228
+ check_setup_labels(root, decisions)
229
+ if not dialog.dialog_on(root, runtime=rt.name):
230
+ fs.die("the dialog is off here (.ai/policies/dialog.json, the runtime's dialog tool, a session id): "
231
+ "suggest the commands; the human runs them outside the agent")
232
+ data = rr_ledger.read_decisions(root)
233
+ questions = [item["question"].strip()[:dialog.QUESTION_MAX] for item in decisions] # as open_decision stores them
234
+ if len(set(questions)) != len(questions):
235
+ fs.die("DECISION_DUPLICATE — two decisions of the spec carry one question text", 6)
236
+ twin = next((d for d in data["open"] if d.get("question") in questions), None)
237
+ if twin:
238
+ fs.die("DECISION_DUPLICATE — %s is open with this very question; settle it, or 'state.py decision fallback "
239
+ "--id %s --why …', before opening it again" % (twin.get("id"), twin.get("id")), 6)
240
+ open_questions = sum(len(dialog.dialog_questions(d)) for d in data["open"])
241
+ if open_questions + len(decisions) > dialog.DECISIONS_MAX:
242
+ fs.die("DECISION_LIMIT — %d questions are open and the spec adds %d; one dialog call carries %d"
243
+ % (open_questions, len(decisions), dialog.DECISIONS_MAX), 6)
244
+ state = task.load(root, required=False, claim=False, rt=rt)
245
+ state = state if state and task.is_v7(state) else None
246
+ opened = []
247
+ for item in decisions:
248
+ options = [dict(o, effect="set") if o["kind"] == "choice" else o for o in item["options"]]
249
+ decision, why = dialog.open_decision(root, state, "setup", item["header"], item["question"], options,
250
+ "decision open --kind setup", rt=rt, setup_key=item["key"])
251
+ if decision is None:
252
+ for done in opened: # a race since the checks above: none of this call stays open
253
+ dialog.settle_decision(root, done["id"], "withdrawn")
254
+ fs.die(why, 6)
255
+ opened.append(decision)
256
+ dialog.print_dialog(opened, rt=rt)
257
+
258
+
86
259
  def decision_settle(args, root, decision, rt=rr_runtime.Runtime()):
87
260
  """(b) apply what the recorder wrote — a choice pick or a decline; (c) a
88
261
  human in a terminal picks an option by number (R36.14)."""
@@ -112,6 +285,8 @@ def decision_settle(args, root, decision, rt=rr_runtime.Runtime()):
112
285
  dialog.settle_decision(root, decision["id"], "declined")
113
286
  print("%s settled: declined in the dialog (%s); nothing runs"
114
287
  % (decision["id"], mine[-1].get("why") or "no answer"))
288
+ if decision.get("kind") == "setup":
289
+ print("SETUP_PICK %s=" % decision.get("setup_key"))
115
290
  return
116
291
  live = [p for p in rr_ledger.live_picks(ledger, (dialog.dialog_on(root, runtime=rt.name) or {}).get("session"))
117
292
  if p.get("decision") == decision["id"]]
@@ -132,6 +307,8 @@ def apply_choice(root, decision, option, pick_id, by, via, rt=rr_runtime.Runtime
132
307
  root, rt=rt)
133
308
  dialog.settle_decision(root, decision["id"], effect, pick_id)
134
309
  print("%s settled: %s (%s)" % (decision["id"], option["label"], effect))
310
+ if decision.get("kind") == "setup":
311
+ print("SETUP_PICK %s=%s" % (decision.get("setup_key"), option.get("value", "")))
135
312
 
136
313
 
137
314
  def cmd_consent(args, root, rt=rr_runtime.Runtime()):
@@ -1,7 +1,7 @@
1
1
  """The store: where the state, the session, the journal and the logs live, and the clock, the root search and the one subprocess runner.
2
2
 
3
3
  shell: ENTRY, now, now_ms, find_root, find_root_quiet, die, state_path, remove_state, read_session, safe_sensor, find_docs_root, questions_path, read_lines, run_text, read_text, current_tree, ensure_logs
4
- core: STATE_NAMES, session_path, journal_path, _split_files, SLUG_RE, one_line, handoff_path, decision_path, ledger_path, logs_dir, task_md_path
4
+ core: session_path, journal_path, _split_files, SLUG_RE, one_line, handoff_path, decision_path, ledger_path, logs_dir, task_md_path
5
5
  """
6
6
 
7
7
  import contextlib
@@ -59,22 +59,14 @@ def die(message, code=1):
59
59
  sys.exit(code)
60
60
 
61
61
 
62
- STATE_NAMES = ("state.json", "current.json")
63
-
64
-
65
62
  def state_path(root):
66
- """The task's state file, read and written where it is: state.json, or
67
- current.json when that is the file a task in flight sits in. A new task
68
- starts in state.json (R34's contract half, migration 0009 moves an old one);
69
- both names stay readable, so a revert of that move still finds the task."""
70
- new, legacy = (os.path.join(root, ".ai", "state", name) for name in STATE_NAMES)
71
- return legacy if not os.path.exists(new) and os.path.exists(legacy) else new
63
+ """The task's state file: state.json, the only name read."""
64
+ return os.path.join(root, ".ai", "state", "state.json")
72
65
 
73
66
 
74
67
  def remove_state(root):
75
- for path in (os.path.join(root, ".ai", "state", name) for name in STATE_NAMES):
76
- with contextlib.suppress(FileNotFoundError):
77
- os.remove(path)
68
+ with contextlib.suppress(FileNotFoundError):
69
+ os.remove(os.path.join(root, ".ai", "state", "state.json"))
78
70
 
79
71
 
80
72
  def session_path(root):
@@ -43,12 +43,23 @@ def subcommand(argv):
43
43
 
44
44
 
45
45
  def state_label_ok(argv):
46
- """approve | reject | profile set | verify --by | review --by | start --mode … --by."""
46
+ """approve | reject | profile set | verify --by | review --by | start --mode … --by |
47
+ memory enable [--author S] [--period month|quarter|year] [--index none|sqlite]: each option at most
48
+ once, as two words, in any order, and no other memory verb. hooks/guard/dialog.py keeps the same
49
+ rule; tests/test_dialog_contract.py pins the two equal."""
47
50
  rest = subcommand(argv)
48
51
  if rest[:1] in (["approve"], ["reject"]) or rest[:2] == ["profile", "set"]:
49
52
  return True
50
53
  if rest[:1] in (["verify"], ["review"]):
51
54
  return "--by" in rest
55
+ if rest[:2] == ["memory", "enable"]:
56
+ # The pick is the exact command the human read: no `--opt=value`, no abbreviation argparse would
57
+ # take, no positional, an option once, a value of its own kind (an author never starts with '-').
58
+ names, values = rest[2::2], rest[3::2]
59
+ kinds = {"--author": None, "--period": ("month", "quarter", "year"), "--index": ("none", "sqlite")}
60
+ return (len(names) == len(values) == len(set(names)) and set(names) <= set(kinds)
61
+ and all((v[:1] not in ("", "-")) if kinds[n] is None else v in kinds[n]
62
+ for n, v in zip(names, values)))
52
63
  return rest[:1] == ["start"] and "--mode" in rest and "--by" in rest
53
64
 
54
65
 
@@ -78,7 +89,8 @@ def validate_label(label):
78
89
  and state_label_ok(script[1]):
79
90
  return None
80
91
  return ("a label starts with git commit, git push <remote> <branch>, state.py approve|reject|"
81
- "profile set|verify --by|review --by|start --mode --by, or npx riskrails@<v> … --yes")
92
+ "profile set|verify --by|review --by|start --mode --by|memory enable [--author S] "
93
+ "[--period P] [--index none|sqlite], or npx riskrails@<v> … --yes")
82
94
 
83
95
 
84
96
  def human_name(root):
@@ -39,9 +39,10 @@ def memory_config(sync, root):
39
39
  return None
40
40
 
41
41
 
42
- def memory_human(root, what, rt=rr_runtime.Runtime()):
43
- """R16: enable, disable, cleanup, pack, redact and move-logs take the human's route, as approve does."""
44
- via, why = rr_dialog.human_route(root, rt=rt)
42
+ def memory_human(root, what, allow_dialog=False, rt=rr_runtime.Runtime()):
43
+ """R16: enable, disable, cleanup, pack, redact and move-logs take the human's route, as approve does. Only
44
+ enable passes allow_dialog: a live pick of this session that is this very command is the human's answer."""
45
+ via, why = rr_dialog.human_route(root, allow_dialog=allow_dialog, rt=rt)
45
46
  if via is None:
46
47
  fs.die("APPROVAL_REFUSED — 'memory %s' is the human's, from their own terminal:\n python3 %s --root %s "
47
48
  "%s\nRefused because %s." % (what, shlex.quote(fs.ENTRY), shlex.quote(root),
@@ -98,7 +99,9 @@ def memory_status(args, root, sync, _store, rt=rr_runtime.Runtime()):
98
99
 
99
100
 
100
101
  def memory_enable(args, root, sync, _store, rt=rr_runtime.Runtime()):
101
- memory_human(root, "enable", rt=rt)
102
+ if memory_human(root, "enable", allow_dialog=True, rt=rt) == "dialog":
103
+ pick = rr_dialog.pick_data(rr_dialog.DIALOG_PICK[0])
104
+ print("memory: via dialog (consent %s, decision %s)" % (pick["consent"], pick["decision"]))
102
105
  hook = sync.enable(root, args.author, args.period, args.index)
103
106
  print("memory: packages mode on (%s); every teammate's next close exports" % sync.POLICY_REL)
104
107
  print("pre-push: %s" % hook)
@@ -22,10 +22,8 @@ from rr_state import task
22
22
 
23
23
 
24
24
  def profile_file(home):
25
- """<home>/riskrails/profile.json, or <home>/claude-agentic/profile.json where
26
- the installer has not run since 3.4b (R35, one version)."""
27
- path = os.path.join(home, "riskrails", "profile.json")
28
- return path if os.path.exists(path) else os.path.join(home, "claude-agentic", "profile.json")
25
+ """<home>/riskrails/profile.json."""
26
+ return os.path.join(home, "riskrails", "profile.json")
29
27
 
30
28
 
31
29
  def read_profile(runtime=None, rt=rr_runtime.Runtime()):
@@ -241,7 +241,7 @@ def cmd_questions(args, root, rt=rr_runtime.Runtime()):
241
241
  # into the file must be in the state and the history too.
242
242
  # Only a sync that changed something writes at all — questions.pending
243
243
  # is a cache, and guard_pending re-parses the file anyway, so a read
244
- # (which /ai-status and the session-start hook both perform) never
244
+ # (which /rr-status and the session-start hook both perform) never
245
245
  # races a command that is mid-write.
246
246
  _, questions = rr_questions.parse_questions(path)
247
247
  rr_questions.set_pending(root, state, questions, path)
@@ -154,11 +154,10 @@ def schema_line(root):
154
154
  project = "0"
155
155
  here = os.path.dirname(os.path.dirname(fs.ENTRY))
156
156
  try:
157
- names = os.listdir(os.path.join(here, "project-update", "migrations"))
157
+ with open(os.path.join(here, "ai-init", "templates", ".ai", "VERSION"), encoding="utf-8") as fh:
158
+ plugin = fh.read().strip() or "?"
158
159
  except OSError:
159
- names = []
160
- numbers = [int(n[:4]) for n in names if re.match(r"\d{4}_", n)]
161
- plugin = str(max(numbers)) if numbers else "?"
160
+ plugin = "?"
162
161
  behind = project.isdigit() and plugin.isdigit() and int(project) < int(plugin)
163
162
  return "schema: project %s · plugin %s%s" % (project, plugin, " — behind: run /rr-setup --update" if behind else "")
164
163
 
@@ -69,7 +69,7 @@ def installed(runtime):
69
69
 
70
70
 
71
71
  # Set by the runtimes in every process they start, the `!` prefix's shell
72
- # included. Duplicated on purpose from skills/project-update/adopt.py
72
+ # included. Duplicated on purpose from the removed adopt script
73
73
  # (AGENT_MARKERS): the two skills are installed independently and neither
74
74
  # imports the other.
75
75
  AGENT_MARKERS = ("CLAUDECODE", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "CODEX_CI",
@@ -39,15 +39,15 @@ def cmd_weight(args, root, rt=rr_runtime.Runtime()):
39
39
  except (OSError, ValueError, RecursionError) as exc:
40
40
  fs.die("cannot read the weight table: %s" % exc)
41
41
  if args.mirror:
42
- own = os.path.join(root, ".ai", "policies", "weight.json")
43
- if path != own:
44
- fs.die("%s has no .ai/policies/weight.json: the mirror is written beside the project's own table"
42
+ policies = os.path.join(root, ".ai", "policies")
43
+ if not os.path.isdir(policies):
44
+ fs.die("%s has no .ai/policies/: the mirror is written beside the project's own table"
45
45
  % root)
46
- target = os.path.join(os.path.dirname(own), "weight.md")
46
+ target = os.path.join(policies, "weight.md")
47
47
  tmp = "%s.%d.tmp" % (target, os.getpid())
48
48
  try:
49
49
  with open(tmp, "w", encoding="utf-8") as fh:
50
- fh.write(weight.mirror(own))
50
+ fh.write(weight.mirror(policy))
51
51
  os.replace(tmp, target)
52
52
  except OSError as exc:
53
53
  with contextlib.suppress(OSError):
@@ -70,9 +70,9 @@ def cmd_weight(args, root, rt=rr_runtime.Runtime()):
70
70
  except ValueError as exc:
71
71
  fs.die(str(exc))
72
72
  except (TypeError, AttributeError) as exc: # a table whose values have the wrong shape
73
- fs.die("%s is malformed: %s" % (path, exc))
73
+ fs.die("%s is malformed: %s" % (path[-1] if os.path.isfile(path[-1]) else path[0], exc))
74
74
  if args.json:
75
- out = dict(result.as_dict(), policy=path)
75
+ out = dict(result.as_dict(), policy=path[0])
76
76
  if result.documents_only is not None:
77
77
  out["documents_only"] = result.documents_only
78
78
  print(json.dumps(out, indent=2, ensure_ascii=False))
@@ -3,7 +3,7 @@
3
3
 
4
4
  Why a separate module: state.py owns every write to state.json and has no
5
5
  subprocess use at all; this module shells out to git and to the commands written
6
- in .ai/policies/testing.md, imports nothing from state.py, and never opens
6
+ in .ai/policies/verification.md, imports nothing from state.py, and never opens
7
7
  state.json for writing. state.py imports it, not the reverse. Since redesign
8
8
  2.6 it measures nothing else: the size budget, the re-score and the static
9
9
  sensors (bite, duplicates, traceability, plan sections, lint, typecheck, detect)
@@ -213,11 +213,11 @@ SCOPE_FIELD = {"suite": "verify_command", "step": "step_test_command",
213
213
 
214
214
 
215
215
  def testing_path(root):
216
- return os.path.join(root, ".ai", "policies", "testing.md")
216
+ return os.path.join(root, ".ai", "policies", "verification.md")
217
217
 
218
218
 
219
219
  def read_commands(root):
220
- """The project's own commands, as a human wrote them into testing.md.
220
+ """The project's own commands, as a human wrote them into verification.md.
221
221
 
222
222
  A line's value is everything after the colon, minus a trailing comment; the
223
223
  template ships the fields with an explanatory comment and no value, and that
@@ -253,7 +253,7 @@ def _escaped(text):
253
253
 
254
254
  def bite_codes(root):
255
255
  """The exit codes that count as "the test fails" under --expect fail: the
256
- testing.md `bite_codes` list of integers 1-255, 1 when absent or not a clean list
256
+ verification.md `bite_codes` list of integers 1-255, 1 when absent or not a clean list
257
257
  (an empty part, a non-integer, 0, a negative or a value over 255 all fall back)."""
258
258
  raw = read_commands(root).get("bite_codes", "")
259
259
  try:
@@ -349,8 +349,6 @@ def resolve_trees(root, args):
349
349
  from_tree = args.from_tree
350
350
  if not from_tree:
351
351
  state = os.path.join(root, ".ai", "state", "state.json")
352
- if not os.path.isfile(state):
353
- state = os.path.join(root, ".ai", "state", "current.json") # R34: legacy name, one version
354
352
  try:
355
353
  with open(state, encoding="utf-8") as fh:
356
354
  from_tree = ((json.load(fh) or {}).get("diff") or {}).get("base_tree")
@@ -387,11 +385,12 @@ def cmd_diff(args, root):
387
385
 
388
386
 
389
387
  def tests_config(root):
390
- """The runner's caps from weight.json `tests`, the template's numbers otherwise."""
388
+ """The runner's caps from the weight table's `tests` (the plugin's, the project's
389
+ weight.project.json over it), {} when the table cannot be read."""
391
390
  try:
392
- with open(os.path.join(root, ".ai", "policies", "weight.json"), encoding="utf-8") as fh:
393
- config = (json.load(fh) or {}).get("tests") or {}
394
- except (OSError, ValueError, AttributeError):
391
+ import weight # pylint: disable=import-outside-toplevel # sits beside this file; only cmd_run needs it
392
+ config = weight.load_policy(root)[0].get("tests") or {}
393
+ except (ImportError, OSError, ValueError, AttributeError):
395
394
  config = {}
396
395
  return config if isinstance(config, dict) else {}
397
396
 
@@ -400,7 +399,7 @@ def cmd_run(args, root):
400
399
  config = tests_config(root)
401
400
  command = command_for(root, args.scope)
402
401
  if not command:
403
- print("%-13s UNAVAIL %s is not written down in .ai/policies/testing.md"
402
+ print("%-13s UNAVAIL %s is not written down in .ai/policies/verification.md"
404
403
  % (args.scope, SCOPE_FIELD.get(args.scope, args.scope)))
405
404
  return UNKNOWNCODE
406
405
  files = [f.strip() for f in (args.files or "").split(",") if f.strip()]
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env python3
2
- """Task state for /ai-task — the small, machine-readable record of one task.
2
+ """Task state for /rr-build — the small, machine-readable record of one task.
3
3
 
4
4
  Why a file and not the conversation: a conversation is expensive to re-read and
5
5
  impossible for a hook to consult. This file is what ai-scope-guard enforces
6
- against, what /ai-status reports, and what lets a task survive a /clear or a
6
+ against, what /rr-status reports, and what lets a task survive a /clear or a
7
7
  crash. It holds facts, never transcripts.
8
8
 
9
9
  Everything is stdlib. Writes are atomic (temp file + os.replace), so a hook that
10
10
  reads the file while a stage is being recorded never sees half a document.
11
11
 
12
12
  Every state change is also appended to .ai/reports/<task-id>/events.jsonl, the
13
- append-only journal /ai-status and /usage-report read at zero model cost. The
13
+ append-only journal /rr-status and /rr-status --cost read at zero model cost. The
14
14
  journal is best effort by contract: a line that cannot be written never fails
15
15
  the command that emitted it, and nothing here depends on it being lossless.
16
16
  history[] keeps its exact shape and its original event names beside it.
@@ -57,8 +57,7 @@ redesign 2.6) answers status, handoff and questions, and close --abandon drops
57
57
  it; every other command refuses it.
58
58
  """
59
59
 
60
- # pylint: disable=unused-import,wrong-import-position
61
- import json
60
+ # pylint: disable=wrong-import-position
62
61
  import os
63
62
  import sys
64
63
 
@@ -68,26 +67,7 @@ try:
68
67
  except ImportError:
69
68
  sys.exit("rr_state/ is missing beside state.py: re-run `npx riskrails`")
70
69
  fs.ENTRY = os.path.abspath(__file__) # R6a: the one path the package prints and compares
71
-
72
- # The names the suites reach as state_mod.<name> (R6) and json_loads_capped, which
73
- # tests/test_dialog_contract.py:176,189 reach as module.json_loads_capped over (dialog, state_mod);
74
- # json and os are imported above for the same reason: the suites patch state_mod.json.dump and state_mod.os.
75
70
  from rr_state.cli import main
76
- from rr_state.fs import current_tree, read_session, session_path, state_path
77
- from rr_state.runtime import AGENT_MARKERS
78
- from rr_state.journal import append_journal, journal_line, read_journal
79
- from rr_state.task import TASK_ID_RE, abandon_old, load, next_task_id, sealed_task_ids, valid_task_id
80
- from rr_state.policy import NEVER_DOCUMENTS, is_document, mode_for, rescore_weight
81
- from rr_state.locks import LOCK_STALE
82
- from rr_state.questions import parse_questions
83
- from rr_state.questions_cmd import close_gate_from_file, sync_answers
84
- from rr_state.ledger import LEDGER_LINE_MAX, LEDGER_TYPES, live_picks, read_ledger
85
- from rr_state.dialog import DIALOG_TOOL, JSON_DEPTH_MAX, QUESTION_MAX, SESSION_ENV, json_loads_capped
86
- from rr_state.dialog import dialog_on, dialog_payload, dialog_policy, dialog_tool_verified, task_facts
87
- from rr_state.labels import LABEL_MAX, validate_label
88
- from rr_state.gate import confirm_names
89
- from rr_state.profile import jwt_plan_type, read_json_object, read_profile, read_quota
90
- from rr_state.close import archive_v7
91
71
 
92
72
  if __name__ == "__main__":
93
73
  main()
@@ -1,4 +1,4 @@
1
- """Weight — six axes, 0 to 2 each, from .ai/policies/weight.json.
1
+ """Weight — six axes, 0 to 2 each, from the plugin's weight table with .ai/policies/weight.project.json laid over it.
2
2
 
3
3
  Pure computation: it reads the declared files, the tree listing and `git grep`,
4
4
  and writes nothing. A measured value is a floor: the session may raise an axis
@@ -39,15 +39,10 @@ class Weight:
39
39
 
40
40
 
41
41
  def load_policy(root):
42
- """(policy, path): the project's table, else the one this plugin ships.
43
- A broken table raises ValueError, never a silent default."""
44
- own = os.path.join(root, ".ai", "policies", "weight.json")
45
- path = own if os.path.isfile(own) else SHIPPED
46
- with open(path, encoding="utf-8") as fh:
47
- policy = json.load(fh)
48
- if not isinstance(policy, dict) or not all(isinstance(policy.get(k), dict) for k in ("axes", "modes")):
49
- raise ValueError("%s has no axes or modes table" % path)
50
- return policy, path
42
+ """(policy, [shipped, project]): the shipped table with the project's weight.project.json laid over it."""
43
+ import weight_overlay # pylint: disable=import-outside-toplevel # beside this file; update.py loads this file by path
44
+ own = os.path.join(root, ".ai", "policies", "weight.project.json")
45
+ return weight_overlay.load(SHIPPED, own), [SHIPPED, own]
51
46
 
52
47
 
53
48
  @functools.lru_cache(maxsize=None)
@@ -268,14 +263,19 @@ def render(result, long=False):
268
263
  return "\n".join(lines)
269
264
 
270
265
 
271
- def mirror(path):
272
- """weight.md for the JSON at `path`, carrying its sha256."""
273
- with open(path, "rb") as fh:
274
- raw = fh.read()
275
- policy = json.loads(raw.decode("utf-8"))
266
+ def mirror(source):
267
+ """weight.md for `source`: a policy dict (the effective table, sha256 of its canonical JSON) or
268
+ the path of one JSON file (sha256 of its bytes)."""
269
+ if isinstance(source, dict):
270
+ policy, origin = source, "the plugin's weight table plus weight.project.json"
271
+ raw = json.dumps(source, sort_keys=True, ensure_ascii=False).encode("utf-8")
272
+ else:
273
+ with open(source, "rb") as fh:
274
+ raw = fh.read()
275
+ policy, origin = json.loads(raw.decode("utf-8")), "weight.json"
276
276
  out = ["# Weight table", "",
277
- "<!-- generated by `state.py weight --mirror` from weight.json; edit the JSON and regenerate."
278
- " sha256: %s -->" % hashlib.sha256(raw).hexdigest(), "",
277
+ "<!-- generated by `state.py weight --mirror` from %s; edit the JSON and regenerate."
278
+ " sha256: %s -->" % (origin, hashlib.sha256(raw).hexdigest()), "",
279
279
  "Six axes, 0–2 each; W is their sum. Why each rule is what it is: the plugin's `docs/weight.md`.", "",
280
280
  "| Axis | Measured by | Table |", "|---|---|---|"]
281
281
  for axis in AXES:
@@ -0,0 +1,38 @@
1
+ """The weight table a project sees: the plugin's shipped table with the project's
2
+ weight.project.json laid over it (R9). weight.py's `load_policy` calls `load`.
3
+
4
+ Objects merge recursively, any other value (a list too) replaces. A file that is not
5
+ a JSON object, or a result without the axes and modes tables, raises ValueError
6
+ naming the file; nothing falls back to a default.
7
+ """
8
+ import json
9
+ import os
10
+
11
+
12
+ def _table(path):
13
+ """One JSON object from `path`; ValueError naming the file when it is not one."""
14
+ try:
15
+ with open(path, encoding="utf-8") as fh:
16
+ table = json.load(fh)
17
+ if isinstance(table, dict):
18
+ return table
19
+ raise ValueError("not a JSON object")
20
+ except ValueError as exc:
21
+ raise ValueError("%s: %s" % (path, exc)) from exc
22
+
23
+
24
+ def _overlay(base, top):
25
+ """`top` laid over `base`: dicts merge, any other value (a list too) replaces."""
26
+ for key, value in top.items():
27
+ base[key] = _overlay(base[key], value) if isinstance(value, dict) and isinstance(base.get(key), dict) else value
28
+ return base
29
+
30
+
31
+ def load(shipped, own):
32
+ """The table at `shipped`, with `own` laid over it when that file exists."""
33
+ policy = _table(shipped)
34
+ if os.path.isfile(own):
35
+ _overlay(policy, _table(own))
36
+ if not all(isinstance(policy.get(k), dict) for k in ("axes", "modes")):
37
+ raise ValueError("%s has no axes or modes table" % own)
38
+ return policy