riskrails 3.0.1 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +29 -15
  5. package/ai/policies/model-routing.md +7 -7
  6. package/ai/policies/production.md +3 -3
  7. package/ai/policies/release.md +2 -2
  8. package/ai/policies/review-economy.md +27 -30
  9. package/bin/riskrails.js +114 -23
  10. package/hooks/ai-path-guard-defaults.json +3 -2
  11. package/hooks/context-guard.py +15 -12
  12. package/hooks/dialog-gate.py +1 -1
  13. package/hooks/guard/common.py +2 -0
  14. package/hooks/guard/dialog.py +107 -27
  15. package/hooks/guard/git.py +12 -3
  16. package/hooks/guard/paths.py +150 -37
  17. package/hooks/project-scaffold.py +3 -6
  18. package/hooks/runtime-gate.py +134 -33
  19. package/hooks/update_notice.py +22 -16
  20. package/instructions/routing.md +1 -1
  21. package/instructions/stub.md +3 -3
  22. package/package.json +1 -1
  23. package/skills/AGENTS.md +1 -1
  24. package/skills/CLAUDE.md +1 -1
  25. package/skills/ai-init/templates/.ai/AGENTS.md +1 -1
  26. package/skills/ai-init/templates/.ai/VERSION +1 -1
  27. package/skills/ai-init/templates/.ai/policies/security.md +7 -1
  28. package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
  29. package/skills/ai-init/templates/.ai/state/README.md +4 -4
  30. package/skills/ai-init/templates/AGENTS.block.md +2 -2
  31. package/skills/ai-init/templates/CLAUDE.block.md +2 -2
  32. package/skills/ai-init/templates/GEMINI.block.md +2 -2
  33. package/skills/ai-init/templates/gitignore.snippet +1 -0
  34. package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
  35. package/skills/ai-task/rr_state/__init__.py +1 -0
  36. package/skills/ai-task/rr_state/cli.py +210 -0
  37. package/skills/ai-task/rr_state/close.py +171 -0
  38. package/skills/ai-task/rr_state/dialog.py +541 -0
  39. package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
  40. package/skills/ai-task/rr_state/fs.py +220 -0
  41. package/skills/ai-task/rr_state/gate.py +125 -0
  42. package/skills/ai-task/rr_state/gate_cmd.py +128 -0
  43. package/skills/ai-task/rr_state/handoff.py +226 -0
  44. package/skills/ai-task/rr_state/journal.py +126 -0
  45. package/skills/ai-task/rr_state/labels.py +99 -0
  46. package/skills/ai-task/rr_state/ledger.py +145 -0
  47. package/skills/ai-task/rr_state/locks.py +84 -0
  48. package/skills/ai-task/rr_state/memory_cmd.py +310 -0
  49. package/skills/ai-task/rr_state/policy.py +378 -0
  50. package/skills/ai-task/rr_state/profile.py +373 -0
  51. package/skills/ai-task/rr_state/questions.py +336 -0
  52. package/skills/ai-task/rr_state/questions_cmd.py +321 -0
  53. package/skills/ai-task/rr_state/report.py +271 -0
  54. package/skills/ai-task/rr_state/report_cmd.py +230 -0
  55. package/skills/ai-task/rr_state/review.py +165 -0
  56. package/skills/ai-task/rr_state/runtime.py +80 -0
  57. package/skills/ai-task/rr_state/start.py +257 -0
  58. package/skills/ai-task/rr_state/steps.py +131 -0
  59. package/skills/ai-task/rr_state/task.py +396 -0
  60. package/skills/ai-task/rr_state/verify.py +231 -0
  61. package/skills/ai-task/sensors.py +37 -4
  62. package/skills/ai-task/state.py +24 -5138
  63. package/skills/project-init/templates/constitution.md +1 -1
  64. package/skills/project-init/templates/sdlc-README.md +20 -14
  65. package/skills/project-update/adopt.py +38 -24
  66. package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
  67. package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
  68. package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
  69. package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
  70. package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
  71. package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
  72. package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
  73. package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
  74. package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
  75. package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
  76. package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
  77. package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
  78. package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
  79. package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
  80. package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
  81. package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
  82. package/skills/project-update/history/index.json +38 -16
  83. package/skills/project-update/migrations/0003_context_diet.py +1 -1
  84. package/skills/project-update/migrations/0007_weight_model.py +130 -23
  85. package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
  86. package/skills/project-update/migrations/README.md +16 -1
  87. package/skills/project-update/migrations/__init__.py +6 -1
  88. package/skills/project-update/render_instructions.py +1 -1
  89. package/skills/project-update/update.py +569 -106
  90. package/skills/rr-build-all/SKILL.md +2 -2
  91. package/skills/rr-plan/SKILL.md +3 -1
  92. package/skills/rr-review/SKILL.md +13 -8
  93. package/skills/rr-setup/SKILL.md +24 -4
  94. package/skills/rr-setup/setup.py +7 -1
  95. package/skills/rr-spec/SKILL.md +4 -2
  96. package/skills/rr-status/status.py +36 -9
  97. package/skills/usage-report/usage-report.py +4 -2
  98. package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
  99. package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
@@ -54,7 +54,7 @@ TEST_KEYS = ("timeout_seconds", "env_retries", "log_max_bytes")
54
54
  def _loads(raw):
55
55
  try:
56
56
  doc = json.loads(raw, object_pairs_hook=OrderedDict)
57
- except (TypeError, ValueError):
57
+ except (TypeError, ValueError, RecursionError): # nested past the parser's limit: edit_json holds the schema
58
58
  return None
59
59
  return doc if isinstance(doc, dict) else None
60
60
 
@@ -111,21 +111,17 @@ def _hint(doc, axis, paths, note):
111
111
  _add(hint.setdefault("paths", []), paths)
112
112
 
113
113
 
114
- def convert(old, base, shipped):
115
- """The weight table `base` plus what the old table `old` added on top of the
116
- `shipped` (examples, scopes, scalars, weight template) it was installed
117
- with. A scalar is carried only when the old table changed it and `base`
118
- still holds the shipped weight value — a project's own weight.json edit
119
- wins. `base` is not changed, and converting twice adds nothing new."""
114
+ def _writes(old, base, shipped):
115
+ """What converting `old` into `base` writes, in order: ("list", path, values) appends to a list,
116
+ ("set", path, value) sets a scalar, ("hint", axis, (paths, note)) adds to a hint."""
120
117
  shipped_examples, shipped_scopes, shipped_scalars, template = shipped
121
- new = json.loads(json.dumps(base), object_pairs_hook=OrderedDict)
122
- axes = new.setdefault("axes", OrderedDict())
118
+ out = []
123
119
  for tier, entry in (old.get("tiers") or {}).items():
124
120
  if tier in EXAMPLE_MODE and isinstance(entry, dict):
125
121
  extra = [e for e in entry.get("examples") or []
126
122
  if isinstance(e, str) and e not in shipped_examples.get(tier, set())]
127
123
  if extra:
128
- _add(new.setdefault("examples", OrderedDict()).setdefault(EXAMPLE_MODE[tier], []), extra)
124
+ out.append(("list", ("examples", EXAMPLE_MODE[tier]), extra))
129
125
  for scope in old.get("path_scopes") or []:
130
126
  if not isinstance(scope, dict):
131
127
  continue
@@ -136,23 +132,41 @@ def convert(old, base, shipped):
136
132
  continue
137
133
  if name in DOMAIN or (name not in HINTS and name not in INFRA | NEEDS_STEP
138
134
  and scope.get("min_tier") in ("T5", "T6")):
139
- _add(axes.setdefault("domain", OrderedDict()).setdefault("modules", []), paths)
135
+ out.append(("list", ("axes", "domain", "modules"), paths))
140
136
  elif name in HINTS:
141
- _hint(new, HINTS[name], paths, "%s (from risk-tiers.json)" % name)
137
+ out.append(("hint", HINTS[name], (paths, "%s (from risk-tiers.json)" % name)))
142
138
  elif name in INFRA | NEEDS_STEP:
143
- rev = axes.setdefault("reversibility", OrderedDict())
144
139
  two = [p for p in paths if name in INFRA and "prod" in p]
145
- _add(rev.setdefault("irreversible", []), two)
146
- _add(rev.setdefault("needs_step", []), [p for p in paths if p not in two])
140
+ out.append(("list", ("axes", "reversibility", "irreversible"), two))
141
+ out.append(("list", ("axes", "reversibility", "needs_step"), [p for p in paths if p not in two]))
147
142
  for key, value in _scalars(old).items():
148
143
  if value in shipped_scalars.get(key, []) or _get(base, key) != _get(template, key):
149
144
  continue
150
145
  if key == "tests.max_suite_runs" and isinstance(value, int):
151
146
  value = min(value, 2)
152
- if key.startswith("tests."):
153
- new.setdefault("tests", OrderedDict())[key[len("tests."):]] = value
147
+ out.append(("set", tuple(key.split(".")), value))
148
+ return out
149
+
150
+
151
+ def convert(old, base, shipped):
152
+ """The weight table `base` plus what the old table `old` added on top of the
153
+ `shipped` (examples, scopes, scalars, weight template) it was installed
154
+ with. A scalar is carried only when the old table changed it and `base`
155
+ still holds the shipped weight value — a project's own weight.json edit
156
+ wins. `base` is not changed, and converting twice adds nothing new."""
157
+ new = json.loads(json.dumps(base), object_pairs_hook=OrderedDict)
158
+ new.setdefault("axes", OrderedDict())
159
+ for kind, path, value in _writes(old, base, shipped):
160
+ if kind == "hint":
161
+ _hint(new, path, *value)
162
+ continue
163
+ node = new
164
+ for part in path[:-1]:
165
+ node = node.setdefault(part, OrderedDict())
166
+ if kind == "list":
167
+ _add(node.setdefault(path[-1], []), value)
154
168
  else:
155
- new[key] = value
169
+ node[path[-1]] = value
156
170
  return new
157
171
 
158
172
 
@@ -168,19 +182,112 @@ def _mode(state):
168
182
  return state
169
183
 
170
184
 
185
+ def _kind(value):
186
+ """the JSON name of a value's type"""
187
+ if value is None:
188
+ return "null"
189
+ if isinstance(value, bool): # before int: True is an int
190
+ return "boolean"
191
+ for types, name in (((int, float), "number"), (str, "string"), (list, "list"), (dict, "object")):
192
+ if isinstance(value, types):
193
+ return name
194
+ return type(value).__name__
195
+
196
+
197
+ def _an(word):
198
+ return word if word == "null" else ("an " if word[0] in "aeiou" else "a ") + word
199
+
200
+
201
+ def _bad(doc, key, want, what):
202
+ """A reason when `doc[key]`, present and not null, is not a `want`; else None."""
203
+ value = doc.get(key)
204
+ if value is not None and not isinstance(value, want):
205
+ return "%s is %s, not %s" % (what, _an(_kind(value)), _an("object" if want is dict else "list"))
206
+ return None
207
+
208
+
209
+ def _old_problem(old):
210
+ """Why the old table cannot be converted (a wrong type where convert reads), or None."""
211
+ if not isinstance(old, dict):
212
+ return "risk-tiers.json is %s, not an object" % _an(_kind(old))
213
+ why = _bad(old, "tiers", dict, "tiers") if old.get("tiers") else None # falsy: convert reads no tiers
214
+ for tier, entry in (old.get("tiers") or {}).items() if why is None else ():
215
+ if tier in EXAMPLE_MODE and isinstance(entry, dict) and entry.get("examples"): # convert reads only these
216
+ why = why or _bad(entry, "examples", (list, dict), "tiers.%s.examples" % tier) # an object iterates by key
217
+ scopes = old.get("path_scopes")
218
+ if scopes and not isinstance(scopes, (list, dict, str)): # an object or a string iterates, its members skipped
219
+ why = why or "path_scopes is %s, not a list" % _an(_kind(scopes))
220
+ for scope in scopes if isinstance(scopes, list) and not why else ():
221
+ if isinstance(scope, dict):
222
+ name = scope.get("scope")
223
+ if scope.get("paths"):
224
+ why = _bad(scope, "paths", (list, dict), "a path scope's paths") # an object iterates by key
225
+ # the name is hashed only against a str path
226
+ if not why and isinstance(name, (list, dict)) and any(isinstance(p, str) for p in scope.get("paths") or []):
227
+ why = "a path scope's name is %s, not a string" % _an(_kind(name))
228
+ if why:
229
+ break
230
+ return why
231
+
232
+
233
+ def _new_problem(doc, writes):
234
+ """Why the weight table `doc` cannot take the `writes` of convert (a wrong type where convert writes), or None."""
235
+ if not isinstance(doc, dict):
236
+ return "weight.json is %s, not an object" % _an(_kind(doc))
237
+ for kind, path, value in writes:
238
+ if kind == "hint":
239
+ if "hints" not in doc:
240
+ continue
241
+ if not isinstance(doc["hints"], list):
242
+ return "hints is %s, not a list" % _an(_kind(doc["hints"]))
243
+ for i, hint in enumerate(doc["hints"]):
244
+ if isinstance(hint, dict) and hint.get("axis") == path:
245
+ if "paths" in hint and not isinstance(hint["paths"], list):
246
+ return "hints[%d].paths is %s, not a list" % (i, _an(_kind(hint["paths"])))
247
+ break
248
+ continue
249
+ node = doc
250
+ for i, part in enumerate(path):
251
+ if part not in node:
252
+ break
253
+ node = node[part]
254
+ what = ".".join(path[:i + 1])
255
+ if i < len(path) - 1:
256
+ if not isinstance(node, dict):
257
+ return "%s is %s, not an object" % (what, _an(_kind(node)))
258
+ elif kind == "list" and value and not isinstance(node, list):
259
+ return "%s is %s, not a list" % (what, _an(_kind(node)))
260
+ return None
261
+
262
+
171
263
  def plan(ctx):
172
264
  old = ctx.read_json(OLD)
265
+ why = _old_problem(old) if old is not None else None
266
+ if why:
267
+ ctx.conflict(OLD, "%s, so this migration could not run" % why)
268
+ old = None
173
269
  shipped = _shipped(ctx)
174
270
  before = ctx.read(NEW)
175
271
  if before is None:
176
272
  template = ctx.template(NEW)
177
273
  doc = convert(old, _loads(template), shipped) if old is not None else None
178
- if doc is None or doc == _loads(template):
179
- ctx.create(NEW, template)
180
- else:
181
- ctx.create(NEW, (json.dumps(doc, indent=2, ensure_ascii=False) + "\n").encode())
274
+ try:
275
+ body = None if doc is None or doc == _loads(template) else \
276
+ (json.dumps(doc, indent=2, ensure_ascii=False) + "\n").encode()
277
+ except RecursionError: # a deep value carried from the old table: the default beside it holds the schema
278
+ ctx.conflict(OLD, "nested too deeply, so this migration could not run")
279
+ body = None
280
+ ctx.create(NEW, template if body is None else body)
182
281
  elif old is not None:
183
- ctx.edit_json(NEW, lambda new: convert(old, new, shipped))
282
+ try:
283
+ current = json.loads(before)
284
+ except (ValueError, RecursionError): # invalid or too deep: edit_json holds the schema
285
+ current = {}
286
+ why = _new_problem(current, _writes(old, current, shipped))
287
+ if why:
288
+ ctx.conflict(NEW, "%s, so this migration could not run" % why)
289
+ else:
290
+ ctx.edit_json(NEW, lambda new: convert(old, new, shipped))
184
291
  after = ctx.read(NEW)
185
292
  if old is not None and _loads(after) != _loads(before or ctx.template(NEW)):
186
293
  ctx.hint("%s now carries this project's risk-tier edits: run `state.py weight --mirror` to "
@@ -0,0 +1,37 @@
1
+ """0011: the old tier policy is retired (register #20, #50, #58, #62).
2
+
3
+ `.ai/policies/risk-tiers.json` and its generated mirror `risk-tiers.md` were
4
+ replaced by `.ai/policies/weight.json` (migration 0007 converted the project's
5
+ edits) and nothing in the plugin reads them any more. The plugin stops shipping
6
+ both, and this migration proposes deleting the project's copies.
7
+
8
+ * Each file the project has is proposed for deletion: a `delete?` item that
9
+ waits for --apply --confirm-delete NAME, with the original kept under
10
+ `.ai/reports/project-update-<date>/original/`. Git is the rollback.
11
+ * A copy the project edited is proposed too, and the reason says it was
12
+ edited: the human decides, and may keep the file by never confirming (the
13
+ schema then stays at 10) or by copying what they need into weight.json first.
14
+ * A project without the files gets nothing, and so does a second run.
15
+ * The templates are retired in `migrations.RETIRED`, so the template walk never
16
+ re-creates a deleted file.
17
+ """
18
+
19
+ VERSION = 11
20
+ TITLE = "retire risk-tiers.json and risk-tiers.md (weight.json replaced them)"
21
+ MOVES = []
22
+
23
+ PATHS = (".ai/policies/risk-tiers.json", ".ai/policies/risk-tiers.md")
24
+ REASON = "replaced by .ai/policies/weight.json; nothing reads it any more"
25
+ EDITED = REASON + " — your project edited this copy, check weight.json holds what you need"
26
+
27
+
28
+ def plan(ctx):
29
+ for path in PATHS:
30
+ if not ctx.exists(path):
31
+ continue
32
+ why = ctx.unsafe_target(path)
33
+ if why:
34
+ ctx.hint("%s: %s, so this update never deletes it — remove it by hand once weight.json holds "
35
+ "what you need" % (path, why))
36
+ continue
37
+ ctx.delete(path, REASON if ctx.is_shipped(path, ctx.read(path)) else EDITED)
@@ -21,7 +21,17 @@ this directory, named `NNNN_slug.py`, numbered contiguously from `0001`, with
21
21
  moves a path a task's step may touch must patch it, or the scope guard will
22
22
  stop that task on a file that no longer exists.
23
23
  - Operations are planned, never written: the dry run must list everything.
24
- Paths are project-relative and normalised, and content is `bytes`.
24
+ Paths are project-relative and normalised, and content is `bytes`; the
25
+ `note` of `ctx.conflict` / `ctx.edit_text` and the `reason` of `ctx.delete` are
26
+ `str`. The type is checked when the operation is planned: any other type stops
27
+ the update with a SchemaError (exit 2). A call that changes nothing (an
28
+ `edit_text` whose `fn` returns its input, a `delete` of a path that is not
29
+ there) plans nothing and is not checked.
30
+ - `ctx.edit_json(path, fn)` hands `fn` a deep copy of the document and takes the
31
+ new one back. `fn` must not recurse in Python over the document (a file that
32
+ passed the deep copy can still be ~470-490 levels deep), or it guards
33
+ `RecursionError` itself; an unguarded `fn`'s `RecursionError` fails the run as
34
+ the migration's own bug, naming no file.
25
35
  - `plan(ctx)` runs again on every run until the schema version advances, and a
26
36
  conflict or an unconfirmed deletion holds it back. Every `fn` must therefore be
27
37
  idempotent: append a line only when it is not already there, or the next run
@@ -45,6 +55,11 @@ own text instead of matching a pattern against the human's prose:
45
55
  else.
46
56
  - `ctx.hint(text)` — say something to the human that is not an operation on a
47
57
  file. The right answer whenever the text is no longer the plugin's.
58
+ - `ctx.conflict(path, note)` — a file the migration cannot handle (wrong JSON
59
+ types, say) is a conflict: it holds the schema version, so the next run tries
60
+ again. `note` says why, and must be `str`: any other type stops the update
61
+ with a SchemaError (`%04d: <path> note must be str, not <type>`, exit 2), as
62
+ content that is not `bytes` does.
48
63
  - `ctx.template(path)` — the bytes the plugin ships today at that path, from the
49
64
  scaffold or the shared tree. `ctx.is_shipped(path, data)` — whether these bytes
50
65
  are a version ever shipped there (CRLF counts; `index.json` alone answers).
@@ -29,7 +29,12 @@ NAME_RE = re.compile(r"(\d{4})_[a-z0-9_]+\.py")
29
29
  # A successor key here joins the rename chain, so an edited file still merges
30
30
  # against the version it was installed from. None means the template is simply
31
31
  # gone: the key is not an orphan, and there is nothing to merge against.
32
- RETIRED = {}
32
+ RETIRED = {
33
+ # Migration 0011: weight.json replaced the tier policy; the project's copies
34
+ # are proposed for deletion and the plugin no longer ships either file.
35
+ "ai-init/.ai/policies/risk-tiers.json": None,
36
+ "ai-init/.ai/policies/risk-tiers.md": None,
37
+ }
33
38
 
34
39
 
35
40
  class SchemaError(Exception):
@@ -166,7 +166,7 @@ def vocabulary(source, runtime):
166
166
 
167
167
  # The budgets an installed plugin uses: install.sh copies skills/, not
168
168
  # instructions/, so runtimes.json is only there in a checkout. Kept equal to
169
- # `_budgets` in instructions/runtimes.json by tests/test-project-update.sh.
169
+ # `_budgets` in instructions/runtimes.json by tests/test_project_update.py.
170
170
  DEFAULT_BUDGETS = {"global": 2560, "project": 2048, "skeleton": 2048, "skeleton-sdlc": 2048}
171
171
 
172
172