riskrails 3.1.0 → 3.2.2

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 (53) 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 +12 -11
  5. package/bin/riskrails.js +109 -18
  6. package/hooks/ai-path-guard-defaults.json +3 -2
  7. package/hooks/context-guard.py +14 -11
  8. package/hooks/dialog-gate.py +1 -1
  9. package/hooks/guard/dialog.py +107 -27
  10. package/hooks/guard/git.py +12 -3
  11. package/hooks/guard/paths.py +109 -7
  12. package/hooks/project-scaffold.py +2 -1
  13. package/hooks/runtime-gate.py +128 -27
  14. package/hooks/update_notice.py +3 -3
  15. package/package.json +1 -1
  16. package/skills/ai-init/templates/.ai/state/README.md +4 -4
  17. package/skills/ai-task/rr_state/__init__.py +1 -0
  18. package/skills/ai-task/rr_state/cli.py +210 -0
  19. package/skills/ai-task/rr_state/close.py +171 -0
  20. package/skills/ai-task/rr_state/dialog.py +541 -0
  21. package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
  22. package/skills/ai-task/rr_state/fs.py +220 -0
  23. package/skills/ai-task/rr_state/gate.py +125 -0
  24. package/skills/ai-task/rr_state/gate_cmd.py +128 -0
  25. package/skills/ai-task/rr_state/handoff.py +226 -0
  26. package/skills/ai-task/rr_state/journal.py +126 -0
  27. package/skills/ai-task/rr_state/labels.py +99 -0
  28. package/skills/ai-task/rr_state/ledger.py +145 -0
  29. package/skills/ai-task/rr_state/locks.py +84 -0
  30. package/skills/ai-task/rr_state/memory_cmd.py +310 -0
  31. package/skills/ai-task/rr_state/policy.py +378 -0
  32. package/skills/ai-task/rr_state/profile.py +373 -0
  33. package/skills/ai-task/rr_state/questions.py +336 -0
  34. package/skills/ai-task/rr_state/questions_cmd.py +321 -0
  35. package/skills/ai-task/rr_state/report.py +271 -0
  36. package/skills/ai-task/rr_state/report_cmd.py +230 -0
  37. package/skills/ai-task/rr_state/review.py +165 -0
  38. package/skills/ai-task/rr_state/runtime.py +80 -0
  39. package/skills/ai-task/rr_state/start.py +257 -0
  40. package/skills/ai-task/rr_state/steps.py +131 -0
  41. package/skills/ai-task/rr_state/task.py +396 -0
  42. package/skills/ai-task/rr_state/verify.py +231 -0
  43. package/skills/ai-task/sensors.py +16 -3
  44. package/skills/ai-task/state.py +24 -5161
  45. package/skills/project-update/adopt.py +38 -24
  46. package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
  47. package/skills/project-update/history/index.json +2 -1
  48. package/skills/project-update/migrations/0003_context_diet.py +1 -1
  49. package/skills/project-update/migrations/0007_weight_model.py +130 -23
  50. package/skills/project-update/migrations/README.md +16 -1
  51. package/skills/project-update/update.py +202 -26
  52. package/skills/rr-setup/SKILL.md +9 -2
  53. package/skills/rr-status/status.py +36 -9
@@ -0,0 +1,84 @@
1
+ """locks: the two locks that serialise writers of the questions file and the consent ledger.
2
+
3
+ LOCK_DEPTH: shell
4
+ question_lock: shell
5
+ LOCK_STALE: core
6
+ ledger_lock: shell
7
+ """
8
+ import contextlib
9
+ import os
10
+ import time
11
+
12
+ try:
13
+ import fcntl
14
+ except ImportError: # not POSIX: the journal falls back to O_APPEND alone
15
+ fcntl = None
16
+
17
+
18
+ # One counter per process, held in a list changed in place: `global` is refused under
19
+ # rr_state/ (R4), and a per-object counter would deadlock on the second descriptor
20
+ # (the gate locks the questions file and write_task_md locks it again inside).
21
+ LOCK_DEPTH = [0]
22
+
23
+
24
+ @contextlib.contextmanager
25
+ def question_lock(path):
26
+ """The questions are read, modified and written back. Without a lock across
27
+ that window two `ask` calls lose a block — and the journal would then name a
28
+ question the file does not hold, which nothing would ever block on.
29
+
30
+ Re-entrant within the process: flock is per open file description, so a
31
+ nested acquisition on a second fd would wait for the first for ever. That is
32
+ what lets the gate decide and apply inside one critical section."""
33
+ directory = os.path.dirname(path)
34
+ os.makedirs(directory, exist_ok=True)
35
+ if fcntl is None or LOCK_DEPTH[0]:
36
+ LOCK_DEPTH[0] += 1
37
+ try:
38
+ yield
39
+ finally:
40
+ LOCK_DEPTH[0] -= 1
41
+ return
42
+ # The lock is taken on the directory itself, so the audit trail gains no
43
+ # lock file and the lock survives the os.replace that swaps the inode.
44
+ try:
45
+ fd = os.open(directory, os.O_RDONLY)
46
+ except OSError:
47
+ yield
48
+ return
49
+ LOCK_DEPTH[0] += 1
50
+ try:
51
+ fcntl.flock(fd, fcntl.LOCK_EX)
52
+ yield
53
+ finally:
54
+ LOCK_DEPTH[0] -= 1
55
+ os.close(fd)
56
+
57
+
58
+ LOCK_STALE = 2.0
59
+
60
+
61
+ @contextlib.contextmanager
62
+ def ledger_lock(root):
63
+ """consent.lock by O_EXCL, portable where flock is not; a lock older than
64
+ LOCK_STALE belongs to a writer that died and is taken over (R36.6)."""
65
+ path = os.path.join(root, ".ai", "state", "consent.lock")
66
+ os.makedirs(os.path.dirname(path), exist_ok=True)
67
+ deadline = time.time() + 3 * LOCK_STALE
68
+ while True:
69
+ try:
70
+ os.close(os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
71
+ break
72
+ except FileExistsError:
73
+ with contextlib.suppress(OSError):
74
+ if time.time() - os.stat(path).st_mtime > LOCK_STALE:
75
+ os.remove(path)
76
+ continue
77
+ if time.time() > deadline:
78
+ raise OSError("consent.lock is held and cannot be taken over") from None
79
+ time.sleep(0.02)
80
+ try:
81
+ yield
82
+ finally:
83
+ with contextlib.suppress(OSError):
84
+ os.remove(path)
@@ -0,0 +1,310 @@
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.
2
+
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
+ core: MEMORY_ACTIONS
5
+ """
6
+ # pylint: disable=protected-access # cmd_recall, memory_search and memory_risk call fs._split_files, which partition.json assigns to fs
7
+ # pylint: disable=unused-argument # every action in MEMORY_ACTIONS is called with the same arguments, rt included; most never read it
8
+ import json
9
+ import os
10
+ import shlex
11
+ import sys
12
+
13
+ from rr_state import fs, journal, task
14
+ from rr_state import dialog as rr_dialog
15
+ from rr_state import runtime as rr_runtime
16
+
17
+ # sensors.py sits beside state.py; the entry puts that directory on sys.path.
18
+ try:
19
+ import sensors # measurement only; it never writes this file
20
+ except ImportError: # an old install that shipped state.py alone
21
+ sensors = None
22
+
23
+
24
+ def memory_modules():
25
+ """memory_sync and memory_store, imported only by the commands that use them."""
26
+ try:
27
+ import memory_store # pylint: disable=import-outside-toplevel
28
+ import memory_sync # pylint: disable=import-outside-toplevel
29
+ except ImportError as exc:
30
+ fs.die("the memory modules are not installed beside state.py (%s)" % exc, 2)
31
+ return memory_sync, memory_store
32
+
33
+
34
+ def memory_config(sync, root):
35
+ try:
36
+ return sync.load_config(root)
37
+ except sync.MemoryError_ as exc:
38
+ fs.die(str(exc), exc.code)
39
+ return None
40
+
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)
45
+ if via is None:
46
+ fs.die("APPROVAL_REFUSED — 'memory %s' is the human's, from their own terminal:\n python3 %s --root %s "
47
+ "%s\nRefused because %s." % (what, shlex.quote(fs.ENTRY), shlex.quote(root),
48
+ " ".join(shlex.quote(a) for a in sys.argv[sys.argv.index("memory"):]
49
+ if "memory" in sys.argv), why), 5)
50
+ return via
51
+
52
+
53
+ def memory_confirm(cfg, name, what):
54
+ if not name:
55
+ fs.die("'memory %s' deletes or moves files: add --confirm-delete %s" % (what, cfg.author), 2)
56
+ if name != cfg.author:
57
+ fs.die("--confirm-delete names %r; this clone's author is %r (.ai/local/memory/config.json)"
58
+ % (name, cfg.author), 2)
59
+
60
+
61
+ def cmd_recall(args, root, rt=rr_runtime.Runtime()):
62
+ """R10: the open risks, residual findings, decisions, scope changes and task
63
+ one-liners that touch these files, within the budget. Read-only."""
64
+ sync, store = memory_modules()
65
+ cfg = memory_config(sync, root)
66
+ files = fs._split_files(args.files)
67
+ if not files:
68
+ fs.die("recall needs --files \"a,b,dir/**\"", 2)
69
+ kinds = fs._split_files(args.kinds) or None
70
+ history = store.open_history(root, cfg)
71
+ budget = args.budget if args.budget is not None else cfg.recall_budget
72
+ text, result = history.recall(files, budget, cfg.recall_max_items, kinds, args.all_risks, args.offset)
73
+ if args.json:
74
+ print(json.dumps({"schema": 1, "files": files, "budget": budget, "used": result["used"],
75
+ "items": [{k: i[k] for k in ("rid", "kind", "task", "ts", "line", "src")}
76
+ for i in result["items"]],
77
+ "more": result["more"], "next": result["next"], "source": history.source()},
78
+ ensure_ascii=False, sort_keys=True))
79
+ return
80
+ sys.stdout.write(text)
81
+
82
+
83
+ def cmd_memory(args, root, rt=rr_runtime.Runtime()):
84
+ sync, store = memory_modules()
85
+ action = args.memory_action
86
+ try:
87
+ MEMORY_ACTIONS[action](args, root, sync, store, rt=rt)
88
+ except sync.MemoryError_ as exc:
89
+ fs.die(str(exc), exc.code)
90
+
91
+
92
+ def memory_status(args, root, sync, _store, rt=rr_runtime.Runtime()):
93
+ lines = sync.status_lines(root)
94
+ if args.json:
95
+ print(json.dumps(dict(line.split(": ", 1) for line in lines), ensure_ascii=False, sort_keys=True))
96
+ return
97
+ print("\n".join(lines))
98
+
99
+
100
+ def memory_enable(args, root, sync, _store, rt=rr_runtime.Runtime()):
101
+ memory_human(root, "enable", rt=rt)
102
+ hook = sync.enable(root, args.author, args.period, args.index)
103
+ print("memory: packages mode on (%s); every teammate's next close exports" % sync.POLICY_REL)
104
+ print("pre-push: %s" % hook)
105
+ if hook in ("foreign", "hookspath"):
106
+ print("not written — %s. Add these lines to your own pre-push hook:\n%s" % (
107
+ "core.hooksPath is set" if hook == "hookspath" else "a pre-push hook exists", sync.snippet_text()))
108
+ print("\n".join(sync.status_lines(root)))
109
+
110
+
111
+ def memory_disable(_args, root, sync, _store, rt=rr_runtime.Runtime()):
112
+ memory_human(root, "disable", rt=rt)
113
+ print("memory: files mode; packages and local data kept; pre-push: %s" % sync.disable(root))
114
+
115
+
116
+ def memory_export(args, root, sync, _store, rt=rr_runtime.Runtime()):
117
+ cfg = memory_config(sync, root)
118
+ ids = "pending" if args.pending or not args.ids else [task.valid_task_id(t, "ID") for t in args.ids]
119
+ result = sync.export(root, ids, cfg)
120
+ for line in sync.record_lines(result):
121
+ print(line)
122
+ print("exported: +%d entries%s" % (result.added, "" if result.added else " — nothing pending"))
123
+ for task_id, why in result.failures:
124
+ print("memory: export of %s failed (%s)" % (task_id, why))
125
+ if result.failures:
126
+ sys.exit(1)
127
+
128
+
129
+ def memory_verify(args, root, sync, _store, rt=rr_runtime.Runtime()):
130
+ stats = None
131
+ if args.range:
132
+ if ".." not in args.range:
133
+ fs.die("--range takes OLD..NEW", 2)
134
+ old, new = args.range.split("..", 1)
135
+ findings, stats = sync.validate_range(root, sync.rev(root, old), sync.rev(root, new) or new, local=False)
136
+ findings = findings or []
137
+ elif args.fresh_clone:
138
+ findings = sync.verify_fresh_clone(root)
139
+ else:
140
+ findings = sync.verify(root, "all", [task.valid_task_id(t, "ID") for t in args.ids] or None)
141
+ failing = [f for f in findings if not f.check.endswith("-warn")]
142
+ if args.json:
143
+ print(json.dumps({"findings": [f._asdict() for f in findings], "ok": not failing, "stats": stats},
144
+ ensure_ascii=False, sort_keys=True))
145
+ else:
146
+ for f in findings:
147
+ print(sync.format_finding(f))
148
+ print("memory verify: %s" % ("ok" if not failing else "%d finding(s)" % len(failing)))
149
+ if failing:
150
+ sys.exit(1)
151
+
152
+
153
+ def memory_show(args, root, sync, _store, rt=rr_runtime.Runtime()):
154
+ print("\n".join(sync.show(root, task.valid_task_id(args.id, "ID"), "json" if args.json else "md", args.entry)))
155
+
156
+
157
+ def memory_search(args, root, sync, store, rt=rr_runtime.Runtime()):
158
+ cfg = memory_config(sync, root)
159
+ filters = {"kind": args.kind, "by": args.by, "task": args.task, "since": args.since, "until": args.until,
160
+ "files": fs._split_files(args.file) or None}
161
+ history = store.open_history(root, cfg)
162
+ hits, total = history.search(args.query or "", filters, args.limit, args.offset)
163
+ if args.json:
164
+ print(json.dumps({"schema": 1, "query": args.query or "", "total": total, "offset": args.offset,
165
+ "items": [{"rid": e["rid"], "kind": e["kind"], "task": e.get("task"), "ts": e.get("ts"),
166
+ "line": store.mf.search_line(e)} for e in hits],
167
+ "source": history.source()}, ensure_ascii=False, sort_keys=True))
168
+ return
169
+ print("# Search — \"%s\" · %d of %d matches · offset %d · index %s" % (
170
+ args.query or "", len(hits), total, args.offset, history.source()["index"]))
171
+ for e in hits:
172
+ print("- %s" % store.mf.search_line(e))
173
+ if args.offset + len(hits) < total:
174
+ print("… %d more: --offset %d" % (total - args.offset - len(hits), args.offset + len(hits)))
175
+
176
+
177
+ def risk_scope(state):
178
+ """C4 (§D9): with a task in flight, files mode edits known-risks.md only
179
+ when the task names it."""
180
+ rel = ".ai/project/known-risks.md"
181
+ if state and not sensors.scope_any(rel, task.task_files(state)):
182
+ fs.die("SCOPE_CHANGE_REQUIRED — 'memory risk' writes %s, which task %s does not name:\n"
183
+ " state.py start --amend --files \"+%s\" --why \"<the risk you record>\"" % (rel, state["task_id"], rel),
184
+ 6)
185
+
186
+
187
+ def memory_risk(args, root, sync, store, rt=rr_runtime.Runtime()):
188
+ cfg = memory_config(sync, root)
189
+ state = task.in_flight(root, rt=rt)
190
+ if args.risk_action == "add":
191
+ where = fs._split_files(args.where)
192
+ if args.label not in store.mf.LABELS:
193
+ fs.die("--label takes one of: %s" % ", ".join(store.mf.LABELS), 2)
194
+ if args.status not in store.mf.RISK_STATUS:
195
+ fs.die("--status takes one of: %s" % ", ".join(store.mf.RISK_STATUS), 2)
196
+ if not cfg.packages:
197
+ risk_scope(state)
198
+ found = "%s, by %s" % (fs.now()[:10], state["task_id"] if state else "no task")
199
+ rkey = sync.risk_add_files(root, args.title, where, args.label, args.what, args.likely, args.fix,
200
+ args.status, found)
201
+ print("risk: %s added to .ai/project/known-risks.md" % rkey)
202
+ return
203
+ data = sync.risk_data(args.title, where, args.label, args.what, args.likely, args.fix, args.status)
204
+ else:
205
+ if args.status not in ("fixed", "accepted", "ticketed"):
206
+ fs.die("--status takes fixed, accepted or ticketed", 2)
207
+ if not cfg.packages:
208
+ risk_scope(state)
209
+ sync.risk_close_files(root, args.rkey, args.status, args.why)
210
+ print("risk: %s is %s" % (args.rkey, args.status))
211
+ return
212
+ history = store.FileHistory(root, cfg)
213
+ found = [e for e in history.risks("all") if e.get("rkey") == args.rkey]
214
+ if not found:
215
+ fs.die("no risk %s ('state.py memory risks --status all')" % args.rkey, 1)
216
+ last = found[0]
217
+ data = sync.risk_data(last["title"], last.get("where"), last.get("label"), last.get("what"),
218
+ last.get("likelihood"), last.get("fix"), args.status, args.why, last["rid"])
219
+ if state:
220
+ state = task.load(root, rt=rt)
221
+ journal.emit(root, state, "risk_recorded", "%s: %s" % (data["rkey"], fs.one_line(data["title"], 200)), data, rt=rt)
222
+ task.save(root, state)
223
+ print("risk: %s recorded in %s's journal (exported at close)" % (data["rkey"], state["task_id"]))
224
+ return
225
+ entry = sync.standalone_risk(root, cfg, data, rt.name)
226
+ print("risk: %s recorded (%s)" % (data["rkey"], entry["rid"]))
227
+
228
+
229
+ def memory_risks(args, root, sync, store, rt=rr_runtime.Runtime()):
230
+ cfg = memory_config(sync, root)
231
+ risks = store.open_history(root, cfg).risks(args.status)
232
+ if args.json:
233
+ for e in risks:
234
+ print(store.mf.canonical(e).decode("utf-8"))
235
+ return
236
+ if args.md:
237
+ out = ["# Known risks", ""]
238
+ for e in risks:
239
+ out += (e["text"].split("\n") if e.get("text") else store.mc.risk_section(
240
+ e.get("title"), store.mf.where_list(e.get("where")), e.get("label"), e.get("what"),
241
+ e.get("likelihood"), e.get("fix"), "%s%s" % (e.get("status"), " — %s" % e["status_why"]
242
+ if e.get("status_why") else ""),
243
+ e.get("found_by") or "-")) + [""]
244
+ print("\n".join(out).rstrip("\n"))
245
+ return
246
+ print("# Risks — %s · %d" % (args.status, len(risks)))
247
+ for e in risks:
248
+ where = store.mf.where_list(e.get("where"))
249
+ print("- [%s %s] %s — %s (%s)" % (e.get("status"), e.get("label"), where[0] if where else "-",
250
+ e.get("title"), e.get("rkey")))
251
+
252
+
253
+ def memory_migrate(args, root, sync, _store, rt=rr_runtime.Runtime()):
254
+ cfg = memory_config(sync, root)
255
+ if args.move_logs:
256
+ memory_human(root, "migrate --move-logs", rt=rt)
257
+ memory_confirm(cfg, args.confirm_delete, "migrate --move-logs")
258
+ moved, command = sync.move_logs(root)
259
+ print("moved %d committed log(s) into .ai/state/logs/<id>/legacy/; now, as its own commit (C3):\n %s"
260
+ % (len(moved), command))
261
+ return
262
+ if args.apply:
263
+ print("\n".join(sync.migrate_apply(root, risks=args.risks)))
264
+ return
265
+ report = sync.migrate_report(root)
266
+ print("report: %s (%d lines) — read it, then 'state.py memory migrate --apply'"
267
+ % (os.path.join(sync.LOCAL_REL, "migration-report.md"), report.count("\n")))
268
+
269
+
270
+ def memory_rebuild(_args, root, sync, store, rt=rr_runtime.Runtime()):
271
+ cfg = memory_config(sync, root)
272
+ print("index: rebuilt from %d sources (%s)" % (store.SqliteHistory(root, cfg).rebuild(), store.INDEX_REL))
273
+
274
+
275
+ def memory_cleanup(args, root, sync, _store, rt=rr_runtime.Runtime()):
276
+ cfg = memory_config(sync, root)
277
+ memory_human(root, "cleanup", rt=rt)
278
+ memory_confirm(cfg, args.confirm_delete, "cleanup")
279
+ if not args.all and not args.ids:
280
+ fs.die("memory cleanup takes IDs or --all", 2)
281
+ print("\n".join(sync.cleanup(root, "all" if args.all else [task.valid_task_id(t, "ID") for t in args.ids])))
282
+
283
+
284
+ def memory_restore(args, root, sync, _store, rt=rr_runtime.Runtime()):
285
+ if not args.all and not args.ids:
286
+ fs.die("memory restore takes IDs or --all", 2)
287
+ print("\n".join(sync.restore(root, "all" if args.all else [task.valid_task_id(t, "ID") for t in args.ids])))
288
+
289
+
290
+ def memory_pack(args, root, sync, _store, rt=rr_runtime.Runtime()):
291
+ cfg = memory_config(sync, root)
292
+ memory_human(root, "pack", rt=rt)
293
+ memory_confirm(cfg, args.confirm_delete, "pack")
294
+ target, removed, count = sync.pack(root, args.period)
295
+ print("pack: %s (%d records; %d batch(es) removed)" % (os.path.join(sync.MEMORY_REL, target), count,
296
+ len(removed)))
297
+
298
+
299
+ def memory_redact(args, root, sync, _store, rt=rr_runtime.Runtime()):
300
+ via = memory_human(root, "redact", rt=rt)
301
+ rid, name = sync.redact(root, args.rid, args.field, args.why, by="%s via %s" % (os.environ.get("USER") or
302
+ "unknown", via))
303
+ print("redacted: %s in %s (new rid %s)" % (args.field, os.path.join(sync.MEMORY_REL, name), rid))
304
+
305
+
306
+ MEMORY_ACTIONS = {"status": memory_status, "enable": memory_enable, "disable": memory_disable,
307
+ "export": memory_export, "verify": memory_verify, "show": memory_show, "search": memory_search,
308
+ "risk": memory_risk, "risks": memory_risks, "migrate": memory_migrate, "rebuild": memory_rebuild,
309
+ "cleanup": memory_cleanup, "restore": memory_restore, "pack": memory_pack,
310
+ "redact": memory_redact}