riskrails 4.1.0 → 4.3.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 (92) 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 +25 -15
  5. package/agents/ai-reviewer.md.tmpl +14 -1
  6. package/ai/agents/implementer.md +6 -0
  7. package/ai/agents/reviewer.md +8 -1
  8. package/bin/ansi.js +12 -0
  9. package/bin/menu.js +71 -12
  10. package/bin/riskrails.js +240 -14
  11. package/bin/uninstall.js +115 -0
  12. package/bin/wizard.js +72 -0
  13. package/hooks/AGENTS.md +0 -6
  14. package/hooks/CLAUDE.md +0 -6
  15. package/hooks/context-guard.py +11 -1
  16. package/hooks/dialog-gate.py +3 -2
  17. package/hooks/guard/decision.py +4 -3
  18. package/hooks/guard/dialog.py +12 -2
  19. package/hooks/guard/ere.py +29 -10
  20. package/hooks/guard/git.py +2 -1
  21. package/hooks/guard/paths.py +48 -1406
  22. package/hooks/guard/paths_base.py +66 -0
  23. package/hooks/guard/paths_bash.py +176 -0
  24. package/hooks/guard/paths_classify.py +205 -0
  25. package/hooks/guard/paths_fs.py +137 -0
  26. package/hooks/guard/paths_human.py +105 -0
  27. package/hooks/guard/paths_interp.py +162 -0
  28. package/hooks/guard/paths_movers.py +393 -0
  29. package/hooks/guard/paths_shell.py +288 -0
  30. package/hooks/runtime-gate.py +87 -0
  31. package/hooks/update_notice.py +23 -8
  32. package/package.json +1 -1
  33. package/skills/AGENTS.md +0 -6
  34. package/skills/CLAUDE.md +0 -6
  35. package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
  36. package/skills/ai-init/templates/gitignore.snippet +2 -0
  37. package/skills/ai-task/memory_cleanup.py +213 -0
  38. package/skills/ai-task/memory_compact.py +25 -0
  39. package/skills/ai-task/memory_config.py +120 -0
  40. package/skills/ai-task/memory_export.py +246 -0
  41. package/skills/ai-task/memory_format.py +7 -3
  42. package/skills/ai-task/memory_hook.py +172 -0
  43. package/skills/ai-task/memory_io.py +122 -0
  44. package/skills/ai-task/memory_migrate.py +187 -0
  45. package/skills/ai-task/memory_packages.py +83 -0
  46. package/skills/ai-task/memory_risks.py +84 -0
  47. package/skills/ai-task/memory_status.py +76 -0
  48. package/skills/ai-task/memory_sync.py +20 -1481
  49. package/skills/ai-task/memory_verify.py +316 -0
  50. package/skills/ai-task/rr_state/cli.py +1 -0
  51. package/skills/ai-task/rr_state/close.py +2 -2
  52. package/skills/ai-task/rr_state/dialog.py +32 -7
  53. package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
  54. package/skills/ai-task/rr_state/gate.py +10 -4
  55. package/skills/ai-task/rr_state/labels.py +33 -4
  56. package/skills/ai-task/rr_state/memory_cmd.py +84 -64
  57. package/skills/ai-task/rr_state/questions_cmd.py +5 -4
  58. package/skills/ai-task/rr_state/report.py +73 -10
  59. package/skills/ai-task/rr_state/report_cmd.py +2 -0
  60. package/skills/ai-task/rr_state/review.py +18 -14
  61. package/skills/ai-task/rr_state/start.py +12 -9
  62. package/skills/ai-task/rr_state/steps.py +16 -7
  63. package/skills/ai-task/rr_state/verify.py +17 -12
  64. package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
  65. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  66. package/skills/project-update/history/index.json +8 -4
  67. package/skills/project-update/rr_update/__init__.py +1 -0
  68. package/skills/project-update/rr_update/apply.py +188 -0
  69. package/skills/project-update/rr_update/blocks.py +38 -0
  70. package/skills/project-update/rr_update/build.py +96 -0
  71. package/skills/project-update/rr_update/cleanup.py +194 -0
  72. package/skills/project-update/rr_update/cli.py +102 -0
  73. package/skills/project-update/rr_update/files.py +106 -0
  74. package/skills/project-update/rr_update/gate.py +60 -0
  75. package/skills/project-update/rr_update/hints.py +128 -0
  76. package/skills/project-update/rr_update/history.py +117 -0
  77. package/skills/project-update/rr_update/kept.py +120 -0
  78. package/skills/project-update/rr_update/paths.py +108 -0
  79. package/skills/project-update/rr_update/plan.py +123 -0
  80. package/skills/project-update/rr_update/report.py +51 -0
  81. package/skills/project-update/rr_update/rules.py +178 -0
  82. package/skills/project-update/rr_update/shared.py +74 -0
  83. package/skills/project-update/rr_update/siblings.py +37 -0
  84. package/skills/project-update/rr_update/weights.py +171 -0
  85. package/skills/project-update/update.py +8 -1682
  86. package/skills/rr-build/SKILL.md +5 -2
  87. package/skills/rr-setup/SKILL.md +4 -2
  88. package/skills/rr-setup/setup.py +13 -12
  89. package/skills/rr-ship/SKILL.md +2 -2
  90. package/skills/rr-spec/SKILL.md +6 -0
  91. package/skills/rr-status/SKILL.md +2 -0
  92. package/skills/rr-status/status.py +5 -3
@@ -1,4 +1,4 @@
1
- """memory_cmd: the `memory` and `recall` commands over the memory_store and memory_sync modules -- the actions, the human's route, the risk record.
1
+ """memory_cmd: the `memory` and `recall` commands over the memory_store module and the owner modules memory_io, memory_config, memory_verify, memory_hook, memory_risks, memory_cleanup, memory_migrate, memory_export and memory_status -- the actions, the human's route, the risk record.
2
2
 
3
3
  shell: memory_modules, memory_config, memory_human, memory_confirm, cmd_recall, cmd_memory, memory_status, memory_enable, memory_disable, memory_export, memory_verify, memory_show, memory_search, risk_scope, memory_risk, memory_risks, memory_migrate, memory_rebuild, memory_cleanup, memory_restore, memory_pack, memory_redact
4
4
  core: MEMORY_ACTIONS
@@ -9,6 +9,7 @@ import json
9
9
  import os
10
10
  import shlex
11
11
  import sys
12
+ import types
12
13
 
13
14
  from rr_state import fs, journal, task
14
15
  from rr_state import dialog as rr_dialog
@@ -22,19 +23,31 @@ except ImportError: # an old install that shipped state.py
22
23
 
23
24
 
24
25
  def memory_modules():
25
- """memory_sync and memory_store, imported only by the commands that use them."""
26
+ """The owner modules of the memory commands as one namespace (mem.io, mem.config, mem.verify, mem.hook,
27
+ mem.risks, mem.cleanup, mem.migrate, mem.export, mem.status -- each name is read on its owner at call time)
28
+ and memory_store, imported only by the commands that use them."""
26
29
  try:
27
30
  import memory_store # pylint: disable=import-outside-toplevel
28
- import memory_sync # pylint: disable=import-outside-toplevel
31
+ import memory_cleanup as cleanup # pylint: disable=import-outside-toplevel
32
+ import memory_config as config # pylint: disable=import-outside-toplevel
33
+ import memory_export as export # pylint: disable=import-outside-toplevel
34
+ import memory_hook as hook # pylint: disable=import-outside-toplevel
35
+ import memory_io as io # pylint: disable=import-outside-toplevel
36
+ import memory_migrate as migrate # pylint: disable=import-outside-toplevel
37
+ import memory_risks as risks # pylint: disable=import-outside-toplevel
38
+ import memory_status as status # pylint: disable=import-outside-toplevel
39
+ import memory_verify as verify # pylint: disable=import-outside-toplevel
29
40
  except ImportError as exc:
30
41
  fs.die("the memory modules are not installed beside state.py (%s)" % exc, 2)
31
- return memory_sync, memory_store
42
+ mem = types.SimpleNamespace(cleanup=cleanup, config=config, export=export, hook=hook, io=io, migrate=migrate,
43
+ risks=risks, status=status, verify=verify)
44
+ return mem, memory_store
32
45
 
33
46
 
34
- def memory_config(sync, root):
47
+ def memory_config(mem, root):
35
48
  try:
36
- return sync.load_config(root)
37
- except sync.MemoryError_ as exc:
49
+ return mem.config.load_config(root)
50
+ except mem.io.MemoryError_ as exc:
38
51
  fs.die(str(exc), exc.code)
39
52
  return None
40
53
 
@@ -62,8 +75,8 @@ def memory_confirm(cfg, name, what):
62
75
  def cmd_recall(args, root, rt=rr_runtime.Runtime()):
63
76
  """R10: the open risks, residual findings, decisions, scope changes and task
64
77
  one-liners that touch these files, within the budget. Read-only."""
65
- sync, store = memory_modules()
66
- cfg = memory_config(sync, root)
78
+ mem, store = memory_modules()
79
+ cfg = memory_config(mem, root)
67
80
  files = fs._split_files(args.files)
68
81
  if not files:
69
82
  fs.die("recall needs --files \"a,b,dir/**\"", 2)
@@ -82,45 +95,49 @@ def cmd_recall(args, root, rt=rr_runtime.Runtime()):
82
95
 
83
96
 
84
97
  def cmd_memory(args, root, rt=rr_runtime.Runtime()):
85
- sync, store = memory_modules()
98
+ mem, store = memory_modules()
86
99
  action = args.memory_action
87
100
  try:
88
- MEMORY_ACTIONS[action](args, root, sync, store, rt=rt)
89
- except sync.MemoryError_ as exc:
101
+ MEMORY_ACTIONS[action](args, root, mem, store, rt=rt)
102
+ except mem.io.MemoryError_ as exc:
90
103
  fs.die(str(exc), exc.code)
91
104
 
92
105
 
93
- def memory_status(args, root, sync, _store, rt=rr_runtime.Runtime()):
94
- lines = sync.status_lines(root)
106
+ def memory_status(args, root, mem, _store, rt=rr_runtime.Runtime()):
107
+ lines = mem.status.status_lines(root)
95
108
  if args.json:
96
109
  print(json.dumps(dict(line.split(": ", 1) for line in lines), ensure_ascii=False, sort_keys=True))
97
110
  return
98
111
  print("\n".join(lines))
99
112
 
100
113
 
101
- def memory_enable(args, root, sync, _store, rt=rr_runtime.Runtime()):
114
+ def memory_enable(args, root, mem, _store, rt=rr_runtime.Runtime()):
102
115
  if memory_human(root, "enable", allow_dialog=True, rt=rt) == "dialog":
103
116
  pick = rr_dialog.pick_data(rr_dialog.DIALOG_PICK[0])
104
117
  print("memory: via dialog (consent %s, decision %s)" % (pick["consent"], pick["decision"]))
105
- hook = sync.enable(root, args.author, args.period, args.index)
106
- print("memory: packages mode on (%s); every teammate's next close exports" % sync.POLICY_REL)
107
- print("pre-push: %s" % hook)
118
+ hook = mem.hook.enable(root, args.author, args.period, args.index, args.share)
119
+ print("memory: packages mode on (%s); every teammate's next close exports" % mem.io.POLICY_REL)
120
+ if hook == "local":
121
+ print("record: local — .ai/memory/ and .ai/reports/ are git-ignored; 'state.py memory enable --share git' "
122
+ "in your own terminal shares them through git and checks pushes")
123
+ else:
124
+ print("pre-push: %s" % hook)
108
125
  if hook in ("foreign", "hookspath"):
109
126
  print("not written — %s. Add these lines to your own pre-push hook:\n%s" % (
110
- "core.hooksPath is set" if hook == "hookspath" else "a pre-push hook exists", sync.snippet_text()))
111
- print("\n".join(sync.status_lines(root)))
127
+ "core.hooksPath is set" if hook == "hookspath" else "a pre-push hook exists", mem.hook.snippet_text()))
128
+ print("\n".join(mem.status.status_lines(root)))
112
129
 
113
130
 
114
- def memory_disable(_args, root, sync, _store, rt=rr_runtime.Runtime()):
131
+ def memory_disable(_args, root, mem, _store, rt=rr_runtime.Runtime()):
115
132
  memory_human(root, "disable", rt=rt)
116
- print("memory: files mode; packages and local data kept; pre-push: %s" % sync.disable(root))
133
+ print("memory: files mode; packages and local data kept; pre-push: %s" % mem.hook.disable(root))
117
134
 
118
135
 
119
- def memory_export(args, root, sync, _store, rt=rr_runtime.Runtime()):
120
- cfg = memory_config(sync, root)
136
+ def memory_export(args, root, mem, _store, rt=rr_runtime.Runtime()):
137
+ cfg = memory_config(mem, root)
121
138
  ids = "pending" if args.pending or not args.ids else [task.valid_task_id(t, "ID") for t in args.ids]
122
- result = sync.export(root, ids, cfg)
123
- for line in sync.record_lines(result):
139
+ result = mem.export.export(root, ids, cfg)
140
+ for line in mem.export.record_lines(result):
124
141
  print(line)
125
142
  print("exported: +%d entries%s" % (result.added, "" if result.added else " — nothing pending"))
126
143
  for task_id, why in result.failures:
@@ -129,36 +146,39 @@ def memory_export(args, root, sync, _store, rt=rr_runtime.Runtime()):
129
146
  sys.exit(1)
130
147
 
131
148
 
132
- def memory_verify(args, root, sync, _store, rt=rr_runtime.Runtime()):
149
+ def memory_verify(args, root, mem, _store, rt=rr_runtime.Runtime()):
133
150
  stats = None
134
151
  if args.range:
135
152
  if ".." not in args.range:
136
153
  fs.die("--range takes OLD..NEW", 2)
137
154
  old, new = args.range.split("..", 1)
138
- findings, stats = sync.validate_range(root, sync.rev(root, old), sync.rev(root, new) or new, local=False)
155
+ findings, stats = mem.verify.validate_range(root, mem.verify.rev(root, old), mem.verify.rev(root, new) or new, local=False)
139
156
  findings = findings or []
140
157
  elif args.fresh_clone:
141
- findings = sync.verify_fresh_clone(root)
158
+ if not mem.io.shared(root):
159
+ fs.die("the record is local (.ai/memory/ is git-ignored): a fresh clone has none — "
160
+ "'state.py memory enable --share git' shares it", 2)
161
+ findings = mem.verify.verify_fresh_clone(root)
142
162
  else:
143
- findings = sync.verify(root, "all", [task.valid_task_id(t, "ID") for t in args.ids] or None)
163
+ findings = mem.verify.verify(root, "all", [task.valid_task_id(t, "ID") for t in args.ids] or None)
144
164
  failing = [f for f in findings if not f.check.endswith("-warn")]
145
165
  if args.json:
146
166
  print(json.dumps({"findings": [f._asdict() for f in findings], "ok": not failing, "stats": stats},
147
167
  ensure_ascii=False, sort_keys=True))
148
168
  else:
149
169
  for f in findings:
150
- print(sync.format_finding(f))
170
+ print(mem.verify.format_finding(f))
151
171
  print("memory verify: %s" % ("ok" if not failing else "%d finding(s)" % len(failing)))
152
172
  if failing:
153
173
  sys.exit(1)
154
174
 
155
175
 
156
- def memory_show(args, root, sync, _store, rt=rr_runtime.Runtime()):
157
- print("\n".join(sync.show(root, task.valid_task_id(args.id, "ID"), "json" if args.json else "md", args.entry)))
176
+ def memory_show(args, root, mem, _store, rt=rr_runtime.Runtime()):
177
+ print("\n".join(mem.status.show(root, task.valid_task_id(args.id, "ID"), "json" if args.json else "md", args.entry)))
158
178
 
159
179
 
160
- def memory_search(args, root, sync, store, rt=rr_runtime.Runtime()):
161
- cfg = memory_config(sync, root)
180
+ def memory_search(args, root, mem, store, rt=rr_runtime.Runtime()):
181
+ cfg = memory_config(mem, root)
162
182
  filters = {"kind": args.kind, "by": args.by, "task": args.task, "since": args.since, "until": args.until,
163
183
  "files": fs._split_files(args.file) or None}
164
184
  history = store.open_history(root, cfg)
@@ -187,8 +207,8 @@ def risk_scope(state):
187
207
  6)
188
208
 
189
209
 
190
- def memory_risk(args, root, sync, store, rt=rr_runtime.Runtime()):
191
- cfg = memory_config(sync, root)
210
+ def memory_risk(args, root, mem, store, rt=rr_runtime.Runtime()):
211
+ cfg = memory_config(mem, root)
192
212
  state = task.in_flight(root, rt=rt)
193
213
  if args.risk_action == "add":
194
214
  where = fs._split_files(args.where)
@@ -199,17 +219,17 @@ def memory_risk(args, root, sync, store, rt=rr_runtime.Runtime()):
199
219
  if not cfg.packages:
200
220
  risk_scope(state)
201
221
  found = "%s, by %s" % (fs.now()[:10], state["task_id"] if state else "no task")
202
- rkey = sync.risk_add_files(root, args.title, where, args.label, args.what, args.likely, args.fix,
222
+ rkey = mem.risks.risk_add_files(root, args.title, where, args.label, args.what, args.likely, args.fix,
203
223
  args.status, found)
204
224
  print("risk: %s added to .ai/project/known-risks.md" % rkey)
205
225
  return
206
- data = sync.risk_data(args.title, where, args.label, args.what, args.likely, args.fix, args.status)
226
+ data = mem.risks.risk_data(args.title, where, args.label, args.what, args.likely, args.fix, args.status)
207
227
  else:
208
228
  if args.status not in ("fixed", "accepted", "ticketed"):
209
229
  fs.die("--status takes fixed, accepted or ticketed", 2)
210
230
  if not cfg.packages:
211
231
  risk_scope(state)
212
- sync.risk_close_files(root, args.rkey, args.status, args.why)
232
+ mem.risks.risk_close_files(root, args.rkey, args.status, args.why)
213
233
  print("risk: %s is %s" % (args.rkey, args.status))
214
234
  return
215
235
  history = store.FileHistory(root, cfg)
@@ -217,7 +237,7 @@ def memory_risk(args, root, sync, store, rt=rr_runtime.Runtime()):
217
237
  if not found:
218
238
  fs.die("no risk %s ('state.py memory risks --status all')" % args.rkey, 1)
219
239
  last = found[0]
220
- data = sync.risk_data(last["title"], last.get("where"), last.get("label"), last.get("what"),
240
+ data = mem.risks.risk_data(last["title"], last.get("where"), last.get("label"), last.get("what"),
221
241
  last.get("likelihood"), last.get("fix"), args.status, args.why, last["rid"])
222
242
  if state:
223
243
  state = task.load(root, rt=rt)
@@ -225,12 +245,12 @@ def memory_risk(args, root, sync, store, rt=rr_runtime.Runtime()):
225
245
  task.save(root, state)
226
246
  print("risk: %s recorded in %s's journal (exported at close)" % (data["rkey"], state["task_id"]))
227
247
  return
228
- entry = sync.standalone_risk(root, cfg, data, rt.name)
248
+ entry = mem.risks.standalone_risk(root, cfg, data, rt.name)
229
249
  print("risk: %s recorded (%s)" % (data["rkey"], entry["rid"]))
230
250
 
231
251
 
232
- def memory_risks(args, root, sync, store, rt=rr_runtime.Runtime()):
233
- cfg = memory_config(sync, root)
252
+ def memory_risks(args, root, mem, store, rt=rr_runtime.Runtime()):
253
+ cfg = memory_config(mem, root)
234
254
  risks = store.open_history(root, cfg).risks(args.status)
235
255
  if args.json:
236
256
  for e in risks:
@@ -253,57 +273,57 @@ def memory_risks(args, root, sync, store, rt=rr_runtime.Runtime()):
253
273
  e.get("title"), e.get("rkey")))
254
274
 
255
275
 
256
- def memory_migrate(args, root, sync, _store, rt=rr_runtime.Runtime()):
257
- cfg = memory_config(sync, root)
276
+ def memory_migrate(args, root, mem, _store, rt=rr_runtime.Runtime()):
277
+ cfg = memory_config(mem, root)
258
278
  if args.move_logs:
259
279
  memory_human(root, "migrate --move-logs", rt=rt)
260
280
  memory_confirm(cfg, args.confirm_delete, "migrate --move-logs")
261
- moved, command = sync.move_logs(root)
281
+ moved, command = mem.migrate.move_logs(root)
262
282
  print("moved %d committed log(s) into .ai/state/logs/<id>/legacy/; now, as its own commit (C3):\n %s"
263
283
  % (len(moved), command))
264
284
  return
265
285
  if args.apply:
266
- print("\n".join(sync.migrate_apply(root, risks=args.risks)))
286
+ print("\n".join(mem.migrate.migrate_apply(root, risks=args.risks)))
267
287
  return
268
- report = sync.migrate_report(root)
288
+ report = mem.migrate.migrate_report(root)
269
289
  print("report: %s (%d lines) — read it, then 'state.py memory migrate --apply'"
270
- % (os.path.join(sync.LOCAL_REL, "migration-report.md"), report.count("\n")))
290
+ % (os.path.join(mem.io.LOCAL_REL, "migration-report.md"), report.count("\n")))
271
291
 
272
292
 
273
- def memory_rebuild(_args, root, sync, store, rt=rr_runtime.Runtime()):
274
- cfg = memory_config(sync, root)
293
+ def memory_rebuild(_args, root, mem, store, rt=rr_runtime.Runtime()):
294
+ cfg = memory_config(mem, root)
275
295
  print("index: rebuilt from %d sources (%s)" % (store.SqliteHistory(root, cfg).rebuild(), store.INDEX_REL))
276
296
 
277
297
 
278
- def memory_cleanup(args, root, sync, _store, rt=rr_runtime.Runtime()):
279
- cfg = memory_config(sync, root)
298
+ def memory_cleanup(args, root, mem, _store, rt=rr_runtime.Runtime()):
299
+ cfg = memory_config(mem, root)
280
300
  memory_human(root, "cleanup", rt=rt)
281
301
  memory_confirm(cfg, args.confirm_delete, "cleanup")
282
302
  if not args.all and not args.ids:
283
303
  fs.die("memory cleanup takes IDs or --all", 2)
284
- print("\n".join(sync.cleanup(root, "all" if args.all else [task.valid_task_id(t, "ID") for t in args.ids])))
304
+ print("\n".join(mem.cleanup.cleanup(root, "all" if args.all else [task.valid_task_id(t, "ID") for t in args.ids])))
285
305
 
286
306
 
287
- def memory_restore(args, root, sync, _store, rt=rr_runtime.Runtime()):
307
+ def memory_restore(args, root, mem, _store, rt=rr_runtime.Runtime()):
288
308
  if not args.all and not args.ids:
289
309
  fs.die("memory restore takes IDs or --all", 2)
290
- print("\n".join(sync.restore(root, "all" if args.all else [task.valid_task_id(t, "ID") for t in args.ids])))
310
+ print("\n".join(mem.cleanup.restore(root, "all" if args.all else [task.valid_task_id(t, "ID") for t in args.ids])))
291
311
 
292
312
 
293
- def memory_pack(args, root, sync, _store, rt=rr_runtime.Runtime()):
294
- cfg = memory_config(sync, root)
313
+ def memory_pack(args, root, mem, _store, rt=rr_runtime.Runtime()):
314
+ cfg = memory_config(mem, root)
295
315
  memory_human(root, "pack", rt=rt)
296
316
  memory_confirm(cfg, args.confirm_delete, "pack")
297
- target, removed, count = sync.pack(root, args.period)
298
- print("pack: %s (%d records; %d batch(es) removed)" % (os.path.join(sync.MEMORY_REL, target), count,
317
+ target, removed, count = mem.cleanup.pack(root, args.period)
318
+ print("pack: %s (%d records; %d batch(es) removed)" % (os.path.join(mem.io.MEMORY_REL, target), count,
299
319
  len(removed)))
300
320
 
301
321
 
302
- def memory_redact(args, root, sync, _store, rt=rr_runtime.Runtime()):
322
+ def memory_redact(args, root, mem, _store, rt=rr_runtime.Runtime()):
303
323
  via = memory_human(root, "redact", rt=rt)
304
- rid, name = sync.redact(root, args.rid, args.field, args.why, by="%s via %s" % (os.environ.get("USER") or
324
+ rid, name = mem.cleanup.redact(root, args.rid, args.field, args.why, by="%s via %s" % (os.environ.get("USER") or
305
325
  "unknown", via))
306
- print("redacted: %s in %s (new rid %s)" % (args.field, os.path.join(sync.MEMORY_REL, name), rid))
326
+ print("redacted: %s in %s (new rid %s)" % (args.field, os.path.join(mem.io.MEMORY_REL, name), rid))
307
327
 
308
328
 
309
329
  MEMORY_ACTIONS = {"status": memory_status, "enable": memory_enable, "disable": memory_disable,
@@ -280,10 +280,11 @@ def close_gate_from_file(root, state, question, by, rt=rr_runtime.Runtime()):
280
280
  state.update(fresh)
281
281
  seen, why = (True, "") if unattended else dialog.human_turn(root, state)
282
282
  if not seen:
283
- fs.die("APPROVAL_REFUSED — %s. Fill the answer, tell the session, and sync again, or run "
284
- "in your own terminal:\n python3 %s --root %s approve --by %s"
285
- % (why, shlex.quote(fs.ENTRY), shlex.quote(root),
286
- shlex.quote(by)), 5)
283
+ cmd = "python3 %s --root %s approve --by %s" % (shlex.quote(fs.ENTRY), shlex.quote(root), shlex.quote(by))
284
+ asked = dialog.human_refusal("'state.py approve'", cmd, why, dialog.open_approval_id(root, rt=rt))
285
+ fs.die(asked + " Or fill the [Answer]: line, tell the session, and sync again." if asked else
286
+ "APPROVAL_REFUSED — %s. Fill the answer, tell the session, and sync again, or run "
287
+ "in your own terminal:\n %s" % (why, cmd), 5)
287
288
  approved = question["choice"] == "A"
288
289
  session = fs.read_session(root)
289
290
  task.claim_runtime(root, state, rt=rt)
@@ -1,14 +1,16 @@
1
1
  """report: the text the state is rendered to — the two close facts, the status lines, the schema line, task.md and its writer.
2
2
 
3
- core: md_cell
4
- shell: two_facts, status_lines, schema_line, task_md_lines, write_task_md
3
+ core: md_cell, waiting_line
4
+ shell: two_facts, pending_questions, waiting_decisions, waiting, status_lines, schema_line, task_md_lines, write_task_md
5
5
  """
6
6
  import contextlib
7
+ import json
7
8
  import os
8
9
  import re
9
10
  import sys
10
11
 
11
12
  from rr_state import fs, journal, locks, questions, task
13
+ from rr_state import ledger as rr_ledger
12
14
  from rr_state import policy as rr_policy # status_lines names a local `policy`, as state.py does
13
15
 
14
16
  # weight.py and sensors.py sit beside state.py; the entry puts that directory on sys.path.
@@ -73,20 +75,81 @@ def two_facts(root, state, tree):
73
75
  b = (True, "%d changed files, all in the task's files" % len(diff))
74
76
  return {"a": a, "b": b, "diff": diff or [], "outside": outside, "documents": documents}
75
77
 
78
+ def pending_questions(root, state):
79
+ """The ids the status shows as pending: the cache, and for schema 7 `## Questions`
80
+ as the file has it (the cache lags a hand-filled [Answer]:, as guard_pending reads it)
81
+ plus an open gate's id."""
82
+ pending = ((state or {}).get("questions") or {}).get("pending") or []
83
+ if task.is_v7(state):
84
+ with contextlib.suppress(OSError, ValueError, KeyError):
85
+ _, asked = questions.parse_questions(fs.questions_path(root, state, None))
86
+ gate_id = task.open_gate(state)
87
+ pending = [q["id"] for q in asked if (q["kind"] != "G" and questions.is_pending(q)) or q["id"] == gate_id]
88
+ return pending
89
+
90
+
91
+ def waiting_decisions(root):
92
+ """The open decisions that still wait — dialog.prune_decisions' rule without its
93
+ writes: not settled, and not every command pick of the latest dialog call used.
94
+ An unreadable decision.json waits for nobody here; the decision commands say why."""
95
+ try:
96
+ with open(fs.decision_path(root), encoding="utf-8") as fh:
97
+ opened = json.load(fh)["open"]
98
+ except (OSError, ValueError, KeyError, TypeError, RecursionError):
99
+ return []
100
+ ledger = rr_ledger.read_ledger(root)
101
+ settled = {e.get("decision") for e in ledger if e["type"] == "settle"}
102
+ used = {e.get("id") for e in ledger if e["type"] == "use"}
103
+ rounds = rr_ledger.latest_rounds(ledger)
104
+ out = []
105
+ for decision in opened:
106
+ commands = {o.get("n") for o in decision.get("options") or [] if o.get("kind") == "command"}
107
+ picks = [e for e in rounds.get(decision.get("id"), []) if e.get("option") in commands]
108
+ if decision.get("id") not in settled and not (picks and all(p.get("id") in used for p in picks)):
109
+ out.append(decision)
110
+ return out
111
+
112
+
113
+ def waiting(root, state, pending):
114
+ """R12: who the task waits on and for what — decisions, then the gate, then questions."""
115
+ decisions = waiting_decisions(root)
116
+ gate = task.open_gate(state) if task.is_v7(state) else None
117
+ asked = [q for q in pending if q != gate]
118
+ on = "you" if decisions or gate or asked else "agent" if (state or {}).get("task_id") else "nobody"
119
+ return {"on": on, "decisions": decisions, "gate": gate, "questions": asked}
120
+
121
+
122
+ def waiting_line(root, state, wait):
123
+ """Line 1 of the status, or None with no task in flight and nothing open. When
124
+ several wait, one line joins them in this order: decisions, the gate, questions."""
125
+ if wait["on"] == "nobody":
126
+ return None
127
+ if wait["on"] == "agent":
128
+ return "waiting on the agent: %s — %s" % (state.get("stage") or state.get("current_stage") or "?",
129
+ fs.one_line(state.get("next_action"), 140) or "?")
130
+ parts = []
131
+ if wait["decisions"]:
132
+ parts.append("%s — the dialog ('state.py decision show' sends it again)" % ", ".join(
133
+ "%s %s" % (d.get("id"), fs.one_line(d.get("header"), 40)) for d in wait["decisions"][:4]))
134
+ if wait["gate"]:
135
+ parts.append("gate %s — approve in the dialog ('state.py close' offers it) or in your own terminal"
136
+ % wait["gate"])
137
+ if wait["questions"]:
138
+ parts.append("%s — answer in the dialog or the chat, or fill [Answer]: in %s" % (
139
+ ", ".join(map(str, wait["questions"][:4])),
140
+ os.path.relpath(fs.questions_path(root, state, None), root)))
141
+ return "waiting on you: " + "; ".join(parts)
142
+
143
+
76
144
  def status_lines(root, state):
77
145
  """≤ 20 lines; read-only (the facts are measured, nothing is written)."""
78
- lines = ["root: %s" % root]
146
+ pending = pending_questions(root, state)
147
+ head = waiting_line(root, state, waiting(root, state, pending))
148
+ lines = ([head] if head else []) + ["root: %s" % root]
79
149
  if not state or not state.get("task_id"):
80
150
  return lines + ["task: none — no task in flight", schema_line(root)]
81
151
  events, _ = journal.read_journal(root, state["task_id"])
82
152
  last = events[-1] if events else {}
83
- pending = (state.get("questions") or {}).get("pending") or []
84
- if task.is_v7(state):
85
- # The cache lags a hand-filled [Answer]:; the file is the truth, as guard_pending reads it.
86
- with contextlib.suppress(OSError, ValueError, KeyError):
87
- _, asked = questions.parse_questions(fs.questions_path(root, state, None))
88
- gate_id = task.open_gate(state)
89
- pending = [q["id"] for q in asked if (q["kind"] != "G" and questions.is_pending(q)) or q["id"] == gate_id]
90
153
  lines.append("task: %s — %s" % (state["task_id"], fs.one_line(state.get("goal"), 140)))
91
154
  if not task.is_v7(state):
92
155
  lines += ["tier: %s · workflow %s · stage %s" % (state.get("risk_tier") or "?", state.get("workflow"),
@@ -225,6 +225,8 @@ def cmd_status(args, root, rt=rr_runtime.Runtime()):
225
225
  "operations")}
226
226
  out["facts"] = {"a": list(facts["a"]), "b": list(facts["b"]), "tree": tree}
227
227
  out["questions_pending"] = len((state.get("questions") or {}).get("pending") or [])
228
+ wait = report.waiting(root, state, report.pending_questions(root, state))
229
+ out["waiting"] = dict(wait, decisions=[d.get("id") for d in wait["decisions"]])
228
230
  print(json.dumps(out, indent=2, ensure_ascii=False))
229
231
  return
230
232
  print("\n".join(report.status_lines(root, state)))
@@ -84,20 +84,24 @@ def cmd_review(args, root, rt=rr_runtime.Runtime()):
84
84
  if review["passes"] >= rounds:
85
85
  if not args.by:
86
86
  report = ["--report", os.path.abspath(args.report)] if args.report and os.path.isfile(args.report) else []
87
- dialog.offer_decision(root, state, "human_set", "Review",
88
- "%s\nREVIEW_BUDGET: %d review passes; weight.json allows %d%s. The next verdict is yours."
89
- % (dialog.task_facts(root, state), review["passes"], rounds,
90
- " (%s)" % budget_name if rr_policy.batch_on(state) else ""),
91
- [{"kind": "choice", "effect": "keep", "label": "Keep it open — no verdict now"}] +
92
- [{"kind": "command",
93
- "label": rr_labels.state_label(root, ["review", "--verdict", verdict, "--tier", args.tier] +
94
- report + ["--by", rr_labels.human_name(root)])}
95
- for verdict in ("pass", "pass_with_findings", "blockers_open")], "review", rt=rt)
96
- fs.die("REVIEW_BUDGET — %d review passes; weight.json allows %d (%s). The next "
97
- "verdict is the human's, in their own terminal:\n python3 %s --root %s review --verdict "
98
- "<verdict> --tier %s --by \"<name>\"" % (review["passes"], rounds, budget_name,
99
- shlex.quote(fs.ENTRY),
100
- shlex.quote(root), args.tier), 6)
87
+ decision = dialog.offer_decision(
88
+ root, state, "human_set", "Review",
89
+ "%s\n%d review passes so far; this task allows %d%s. The next verdict is yours."
90
+ % (dialog.task_facts(root, state), review["passes"], rounds,
91
+ " (%s)" % budget_name if rr_policy.batch_on(state) else ""),
92
+ [{"kind": "choice", "effect": "keep", "label": "Keep it open — no verdict now"}] +
93
+ [{"kind": "command",
94
+ "label": rr_labels.state_label(root, ["review", "--verdict", verdict, "--tier", args.tier] +
95
+ report + ["--by", rr_labels.human_name(root)])}
96
+ for verdict in ("pass", "pass_with_findings", "blockers_open")], "review", rt=rt)
97
+ cmd = "python3 %s --root %s review --verdict <verdict> --tier %s --by \"<name>\"" % (
98
+ shlex.quote(fs.ENTRY), shlex.quote(root), args.tier)
99
+ asked = dialog.human_refusal("the next review verdict", cmd, "the review budget is spent: %d passes, %d allowed"
100
+ % (review["passes"], rounds), decision and decision["id"])
101
+ fs.die(("REVIEW_BUDGET — %d review passes; weight.json allows %d (%s).\n%s"
102
+ % (review["passes"], rounds, budget_name, asked)) if asked else
103
+ "REVIEW_BUDGET — %d review passes; weight.json allows %d (%s). The next verdict is the human's, "
104
+ "in their own terminal:\n %s" % (review["passes"], rounds, budget_name, cmd), 6)
101
105
  via, why = dialog.human_route(root, allow_dialog=True, rt=rt)
102
106
  if via is None:
103
107
  fs.die("APPROVAL_REFUSED — a verdict past the review budget is the human's, from their own "
@@ -159,15 +159,18 @@ def cmd_start(args, root, rt=rr_runtime.Runtime()):
159
159
  if rr_policy.MODES.index(args.mode) < rr_policy.MODES.index(mode):
160
160
  via, why = dialog.human_route(root, allow_dialog=True, rt=rt)
161
161
  if via is None:
162
- dialog.offer_decision(root, None, "human_set", "Mode",
163
- "%s — W %s, the weight says %s.\nLower the mode from %s to %s (by %s)?"
164
- % (fs.one_line(args.goal, 300), result.w, mode, mode, args.mode, args.by),
165
- [{"kind": "choice", "effect": "keep", "label": "Keep mode %s" % mode},
166
- {"kind": "command", "label": labels.state_label(root, sys.argv[1:])}], "start --mode", rt=rt)
167
- fs.die("APPROVAL_REFUSED — lowering the mode from %s to %s is a human's decision. Run it in "
168
- "your own terminal:\n python3 %s --root %s start … --mode %s --by %s\nRefused because %s."
169
- % (mode, args.mode, shlex.quote(fs.ENTRY), shlex.quote(root),
170
- args.mode, shlex.quote(args.by), why), 5)
162
+ decision = dialog.offer_decision(
163
+ root, None, "human_set", "Mode",
164
+ "%s\nThe task's weight says mode %s. Lower it to %s (by %s)? A lower mode means lighter checks "
165
+ "and a lighter review." % (fs.one_line(args.goal, 300), mode, args.mode, args.by),
166
+ [{"kind": "choice", "effect": "keep", "label": "Keep mode %s" % mode},
167
+ {"kind": "command", "label": labels.state_label(root, sys.argv[1:])}], "start --mode", rt=rt)
168
+ cmd = "python3 %s --root %s start … --mode %s --by %s" % (
169
+ shlex.quote(fs.ENTRY), shlex.quote(root), args.mode, shlex.quote(args.by))
170
+ what = "lowering the mode from %s to %s" % (mode, args.mode)
171
+ fs.die(dialog.human_refusal(what, cmd, why, decision and decision["id"])
172
+ or "APPROVAL_REFUSED — %s is a human's decision. Run it in your own terminal:\n %s\n"
173
+ "Refused because %s." % (what, cmd, why), 5)
171
174
  override = {"from": mode, "to": args.mode, "by": args.by, "why": args.why, "ts": fs.now()}
172
175
  mode = args.mode
173
176
  if args.slug is not None:
@@ -6,8 +6,9 @@ shell: cmd_step_done_v7, cmd_steps, cmd_batch
6
6
  # (uniform cli dispatch: every cmd_* takes rt last, whether it uses it or not)
7
7
  import json
8
8
  import shlex
9
+ import sys
9
10
 
10
- from rr_state import dialog, fs, handoff, journal, report, task
11
+ from rr_state import dialog, fs, handoff, journal, labels, report, task
11
12
  from rr_state import policy as rr_policy # cmd_batch binds a local `policy`
12
13
  from rr_state import runtime as rr_runtime
13
14
 
@@ -103,11 +104,18 @@ def cmd_batch(args, root, rt=rr_runtime.Runtime()):
103
104
  return
104
105
  via = None
105
106
  if want:
106
- via, why = dialog.human_route(root, rt=rt)
107
+ via, why = dialog.human_route(root, allow_dialog=True, rt=rt)
107
108
  if via is None:
108
- fs.die("APPROVAL_REFUSED — turning the batch loop on during a task lowers the rigor, so it is the "
109
- "human's. In their own terminal:\n python3 %s --root %s batch on --by \"<name>\"\nRefused "
110
- "because %s." % (shlex.quote(fs.ENTRY), shlex.quote(root), why), 5)
109
+ decision = dialog.offer_decision(
110
+ root, state, "human_set", "Batch",
111
+ "%s\nTurn the batch loop on for this task? It lowers the rigor for the rest of the task: the batch "
112
+ "budgets replace the standard ones." % dialog.task_facts(root, state),
113
+ [{"kind": "choice", "effect": "keep", "label": "Keep the standard loop"},
114
+ {"kind": "command", "label": labels.state_label(root, sys.argv[1:])}], "batch on", rt=rt)
115
+ cmd = "python3 %s --root %s batch on --by \"<name>\"" % (shlex.quote(fs.ENTRY), shlex.quote(root))
116
+ fs.die(dialog.human_refusal("turning the batch loop on during a task", cmd, why, decision and decision["id"])
117
+ or "APPROVAL_REFUSED — turning the batch loop on during a task lowers the rigor, so it is the "
118
+ "human's. In their own terminal:\n %s\nRefused because %s." % (cmd, why), 5)
111
119
  elif not (args.why or "").strip():
112
120
  fs.die("batch off needs --why \"<why the standard loop from here>\"")
113
121
  policy = rr_policy.weight_policy(root)
@@ -118,8 +126,9 @@ def cmd_batch(args, root, rt=rr_runtime.Runtime()):
118
126
  # At the gate (stage ship) the next action is the human's and stays so.
119
127
  state["next_action"] = rr_policy.review_next_action(state)
120
128
  journal.emit(root, state, "batch_set", "%s by %s%s" % (args.action, args.by, " via %s" % via if via else ""),
121
- {"on": want, "by": args.by, "why": args.why or None, "via": via,
122
- "review_passes": state["review"]["passes"], "suite_runs": state["verify"]["suite_runs"]},
129
+ dict({"on": want, "by": args.by, "why": args.why or None, "via": via,
130
+ "review_passes": state["review"]["passes"], "suite_runs": state["verify"]["suite_runs"]},
131
+ **dialog.pick_data(dialog.DIALOG_PICK[0])),
123
132
  actor="human" if via == "terminal" else "agent", rt=rt)
124
133
  handoff.set_resume_point(state, rt=rt)
125
134
  handoff.write_handoff(root, state, "batch", rt=rt)
@@ -171,18 +171,23 @@ def cmd_verify(args, root, rt=rr_runtime.Runtime()):
171
171
  max_runs, budget_name = rr_policy.budget(policy, state, "max_suite_runs")
172
172
  if scope in ("suite", "all") and state["verify"]["suite_runs"] >= max_runs:
173
173
  if not args.by:
174
- dialog.offer_decision(root, state, "human_set", "Budget",
175
- "%s\nSUITE_BUDGET: %d suite runs; weight.json allows %d%s. Run it once more?"
176
- % (dialog.task_facts(root, state), state["verify"]["suite_runs"], max_runs,
177
- " (%s)" % budget_name if rr_policy.batch_on(state) else ""),
178
- [{"kind": "choice", "effect": "keep", "label": "Keep the budget — stop here"},
179
- {"kind": "command",
180
- "label": labels.state_label(root, sys.argv[1:] + ["--by", labels.human_name(root)])}], "verify", rt=rt)
181
- fs.die("SUITE_BUDGET — %d suite runs; weight.json allows %d (%s). Running it "
182
- "again is not the next move: the human decides. In their own terminal:\n python3 %s "
183
- "--root %s verify --scope %s --by \"<name>\""
184
- % (state["verify"]["suite_runs"], max_runs, budget_name, shlex.quote(fs.ENTRY),
185
- shlex.quote(root), scope), 6)
174
+ runs = state["verify"]["suite_runs"]
175
+ decision = dialog.offer_decision(
176
+ root, state, "human_set", "Budget",
177
+ "%s\nThe test suite has run %d times; this task allows %d%s. Run it once more?"
178
+ % (dialog.task_facts(root, state), runs, max_runs,
179
+ " (%s)" % budget_name if rr_policy.batch_on(state) else ""),
180
+ [{"kind": "choice", "effect": "keep", "label": "Keep the budget — stop here"},
181
+ {"kind": "command",
182
+ "label": labels.state_label(root, sys.argv[1:] + ["--by", labels.human_name(root)])}], "verify", rt=rt)
183
+ cmd = "python3 %s --root %s verify --scope %s --by \"<name>\"" % (
184
+ shlex.quote(fs.ENTRY), shlex.quote(root), scope)
185
+ asked = dialog.human_refusal("a run past the suite budget", cmd, "the budget is spent: %d runs, %d allowed"
186
+ % (runs, max_runs), decision and decision["id"])
187
+ fs.die(("SUITE_BUDGET — %d suite runs; weight.json allows %d (%s).\n%s" % (runs, max_runs, budget_name, asked))
188
+ if asked else
189
+ "SUITE_BUDGET — %d suite runs; weight.json allows %d (%s). Running it again is not the next "
190
+ "move: the human decides. In their own terminal:\n %s" % (runs, max_runs, budget_name, cmd), 6)
186
191
  via, why = dialog.human_route(root, allow_dialog=True, rt=rt)
187
192
  if via is None:
188
193
  fs.die("APPROVAL_REFUSED — a run past the suite budget is the human's, from their own terminal."