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
@@ -0,0 +1,226 @@
1
+ """handoff: the resume point, the recent notes, the handoff file and the move of a task to the other runtime.
2
+
3
+ core: TIERS, RESUME_COMMAND
4
+ shell: set_resume_point, recent_notes, handoff_lines, write_handoff, handoff_to
5
+ """
6
+ import os
7
+ import shlex
8
+
9
+ from rr_state import fs
10
+ from rr_state import journal
11
+ from rr_state import policy as rr_policy
12
+ from rr_state import profile
13
+ from rr_state import questions
14
+ from rr_state import runtime as rr_runtime
15
+ from rr_state import task
16
+
17
+ # weight.py sits beside state.py; the entry puts that directory on sys.path.
18
+ try:
19
+ import weight # pure computation; it writes nothing
20
+ except ImportError:
21
+ weight = None
22
+
23
+ TIERS = ["T0", "T1", "T2", "T3", "T4", "T5", "T6"]
24
+
25
+
26
+ def set_resume_point(state, rt=rr_runtime.Runtime()):
27
+ """Where a session that lost its context picks the work up again."""
28
+ plan = state.get("approved_plan") or {}
29
+ state["resume_point"] = {
30
+ "stage": state.get("current_stage"),
31
+ "step_id": plan.get("current_step_id"),
32
+ "next_action": state.get("next_action"),
33
+ "at": fs.now(),
34
+ "runtime": rt.name if rt.name in rr_runtime.RUNTIMES else state.get("owner_runtime"),
35
+ }
36
+
37
+
38
+ def recent_notes(root, state, limit=3):
39
+ """One pass for all three kinds — the journal is read on every stage-moving
40
+ command — and only this task's own notes: a report directory can be deleted,
41
+ and then next_task_id hands the same id to a different task."""
42
+ events, _ = journal.read_journal(root, state.get("task_id"))
43
+ found = {"decision": [], "rejected": [], "failed": []}
44
+ for event in reversed(events):
45
+ # A report directory can be deleted, and next_task_id then hands the same
46
+ # id to a different task. The scan stops at this task's own task_started
47
+ # rather than at a timestamp: two tasks can share a second, but not a
48
+ # position in an append-only file.
49
+ if event.get("event") == "task_started":
50
+ break
51
+ if event.get("event") != "note":
52
+ continue
53
+ kind = (event.get("data") or {}).get("kind")
54
+ if kind in found and len(found[kind]) < limit:
55
+ found[kind].append(event)
56
+ return found
57
+
58
+
59
+ def handoff_lines(root, state, reason, rt=rr_runtime.Runtime()):
60
+ """The six parts of I4, ≤ 30 lines by construction: a header, the three
61
+ facts a session needs first, three sections of three notes, and the user's
62
+ own last words. Everything here is a fact the runtime cannot lose — the
63
+ state, the journal, task.md §Questions and session.json — never a transcript."""
64
+ plan = state.get("approved_plan") or {}
65
+ steps = plan.get("steps") or []
66
+ # step ids and file scopes come from a model-authored plan file, so they are
67
+ # collapsed like every other free text before they reach the frame.
68
+ step = " · step %s/%d" % (fs.one_line(plan.get("current_step_id"), 40) or "-", len(steps)) if steps else ""
69
+ out = ["# Handoff — %s (%s, %s) · stage %s%s · owner %s · written %s (%s)"
70
+ % (state.get("task_id"), state.get("workflow"),
71
+ state.get("risk_tier") or state.get("mode") or "untiered",
72
+ state.get("current_stage"), step, state.get("owner_runtime") or "unknown",
73
+ fs.now(), reason),
74
+ "Goal: %s" % fs.one_line(state.get("goal"))[:160]]
75
+
76
+ resume = state.get("resume_point") or {}
77
+ allowed = [s for s in steps if s.get("step_id") == resume.get("step_id")]
78
+ where = ""
79
+ if resume.get("step_id") and allowed:
80
+ # A step the plan no longer has is not a resume point: saying nothing
81
+ # beats pointing a context-less session at a deleted scope.
82
+ where = " ← resume point (step %s: %s)" % (
83
+ fs.one_line(resume["step_id"], 40),
84
+ fs.one_line(", ".join(str(f) for f in (allowed[0].get("allowed_files") or [])[:2]), 120))
85
+ out.append("Next: %s%s" % (fs.one_line(state.get("next_action"), 200) or "none recorded", where))
86
+
87
+ path = fs.questions_path(root, state, None)
88
+ pending = [q["id"] for q in questions.parse_questions(path)[1] if questions.is_pending(q)]
89
+ out.append("Pending questions: %s" % (
90
+ "%s — %s" % (", ".join(pending), os.path.relpath(path, root)) if pending else "none"))
91
+ if rr_policy.batch_on(state):
92
+ try:
93
+ policy = weight.load_policy(root)[0] if weight else {}
94
+ except (OSError, ValueError, RecursionError):
95
+ policy = {} # best effort: the handoff never fails a command
96
+ out.append("Batch: on · ready %s · review %d/%d · suite %d/%d" % (
97
+ ", ".join(fs.one_line(s["id"], 40) for s in task.ready_steps(state)) or "none",
98
+ (state.get("review") or {}).get("passes", 0), rr_policy.budget(policy, state, "review_rounds_max")[0],
99
+ (state.get("verify") or {}).get("suite_runs", 0), rr_policy.budget(policy, state, "max_suite_runs")[0]))
100
+
101
+ notes = recent_notes(root, state)
102
+ for kind, heading in (("decision", "Decisions"), ("rejected", "Rejected"),
103
+ ("failed", "Failed attempts")):
104
+ out.append("## %s (latest 3)" % heading)
105
+ if not notes[kind]:
106
+ out.append("- none")
107
+ continue
108
+ for note in notes[kind]:
109
+ data = note.get("data") or {}
110
+ text = fs.one_line(data.get("text"), 200)
111
+ if kind == "failed":
112
+ out.append("- %s — error: %s" % (text, fs.one_line(data.get("error"), 160) or "none given"))
113
+ else:
114
+ why = fs.one_line(data.get("why"), 200)
115
+ out.append("- %s%s" % (text, " — because %s" % why if why else ""))
116
+
117
+ pending_to = (state.get("handoff") or {}).get("pending_to")
118
+ if pending_to:
119
+ review = state.get("cross_vendor_review") or {}
120
+ out.append("Handed to %s at %s%s — resume there with /rr-build" % (
121
+ pending_to, (state.get("handoff") or {}).get("pending_since"),
122
+ " for the cross-vendor review" if review.get("status") == "requested" else ""))
123
+ advice = profile.advisory_line(state, rt=rt)
124
+ if advice:
125
+ out.append(advice)
126
+
127
+ session = fs.read_session(root)
128
+ out.append("## Latest user instruction (verbatim, %s, %s)"
129
+ % (session.get("last_prompt_at") or "never", session.get("runtime") or "unknown"))
130
+ if os.environ.get("AI_HANDOFF_NO_PROMPT"):
131
+ # The prompt may carry a secret; recording it is the default, not a duty.
132
+ out.append("> omitted (AI_HANDOFF_NO_PROMPT=1)")
133
+ else:
134
+ out.append("> %s" % (fs.one_line(session.get("last_prompt"), 300) or "none recorded"))
135
+ return out
136
+
137
+
138
+ def write_handoff(root, state, reason, rt=rr_runtime.Runtime()):
139
+ """A pure function of the state, the journal, task.md §Questions and session.json,
140
+ written atomically — rendering it twice gives the same file but its
141
+ timestamp. Best effort end to end, rendering included: a handoff that cannot
142
+ be built or written must never fail the command that moved the stage, and
143
+ its inputs include a hook-written sidecar and an append-only journal that
144
+ nothing validates.
145
+
146
+ It mutates `state` in memory only. The caller that is already changing the
147
+ task persists it; `handoff` on its own never writes the state file, because
148
+ the hook calls it while another command may be mid-write."""
149
+ path = fs.handoff_path(root)
150
+ try:
151
+ lines = handoff_lines(root, state, reason, rt=rt)
152
+ os.makedirs(os.path.dirname(path), exist_ok=True)
153
+ tmp = "%s.%d.tmp" % (path, os.getpid())
154
+ with open(tmp, "w", encoding="utf-8") as fh:
155
+ fh.write("\n".join(lines) + "\n")
156
+ # It carries the user's verbatim instruction, so it is theirs to read.
157
+ # events.jsonl is committed with the task and stays 0644; this one is
158
+ # git-ignored and describes a session.
159
+ os.chmod(tmp, 0o600)
160
+ os.replace(tmp, path)
161
+ except Exception: # pylint: disable=broad-exception-caught # derived artefact: never fatal
162
+ return None
163
+ # Updated, not replaced: pending_to/pending_since (schema 5) live here too.
164
+ if not isinstance(state.get("handoff"), dict):
165
+ state["handoff"] = {}
166
+ state["handoff"].update({"file": os.path.relpath(path, root), "written_at": fs.now(), "reason": reason})
167
+ # Journal-only, like note: the handoff is derived, and history[] records
168
+ # what changed the task, not what was rendered from it.
169
+ journal.append_journal(root, state.get("task_id"), journal.journal_line(
170
+ state.get("task_id"), "handoff_written", "agent", state.get("current_stage"),
171
+ reason, {"reason": reason, "lines": len(lines)}, runtime=rt.name))
172
+ return lines
173
+
174
+
175
+ RESUME_COMMAND = {"claude": "claude -p '/rr-build'", "codex": "codex exec '/rr-build'"}
176
+
177
+
178
+ def handoff_to(args, root, rt=rr_runtime.Runtime()):
179
+ """Move the task to the other runtime on purpose (WP5 R10). Ownership moves
180
+ now; the receiving runtime's first change completes it, and until then any
181
+ other runtime's change is refused with exit 7. Pending questions do not
182
+ block it: they travel with the task."""
183
+ state = task.load(root, claim=False, rt=rt)
184
+ source = state.get("owner_runtime") or (rt.name if rt.name in rr_runtime.RUNTIMES else None)
185
+ if not state.get("owner_runtime") and args.to == source:
186
+ fs.die("task %s is already with %s; nothing to hand over" % (state.get("task_id"), args.to), 2)
187
+ if args.to == state.get("owner_runtime"):
188
+ fs.die("task %s is already owned by %s%s" % (
189
+ state.get("task_id"), args.to,
190
+ " (handed over, waiting for it to resume)" if (state.get("handoff") or {}).get("pending_to") else ""), 2)
191
+ if state.get("current_stage") == "done":
192
+ fs.die("task %s is closed; start a new one with start" % state.get("task_id"), 2)
193
+ if not rr_runtime.installed(args.to):
194
+ fs.die("%s is not installed here (%s has no skills/ai-task/state.py); run npx riskrails --target %s"
195
+ % (args.to, rr_runtime.runtime_home(args.to), args.to), 2)
196
+ tier = state.get("risk_tier")
197
+ via = "review" if args.for_ == "review" else "manual"
198
+ review = state.get("cross_vendor_review")
199
+ if isinstance(review, dict) and review.get("status") == "requested":
200
+ # Any move of the task ends an open request: taken back, it was not done;
201
+ # handed on again, a new --for review opens a fresh one. Left open, it
202
+ # sent a later plain handoff straight into an unasked-for review.
203
+ review.update({"status": "cancelled", "cancelled_at": fs.now(), "by_runtime": rt.name})
204
+ data = {"from": source, "to": args.to, "via": via, "reason": args.why or "", "tier": tier,
205
+ "tty": bool(os.isatty(0) and os.isatty(1))}
206
+ if via == "review":
207
+ threshold = (profile.read_profile(rt=rt).get("preferred_runtime") or {}).get("cross_vendor_review_from") or "T5"
208
+ if tier in TIERS and threshold in TIERS and TIERS.index(tier) >= TIERS.index(threshold):
209
+ state["cross_vendor_review"] = {"from": source, "to": args.to, "tier": tier,
210
+ "requested_at": fs.now(), "status": "requested",
211
+ "by_runtime": None}
212
+ else:
213
+ data["below_threshold"] = threshold
214
+ print("note: %s is below cross_vendor_review_from %s — handed over for review anyway, "
215
+ "not recorded as a cross-vendor review" % (tier or "an untiered task", threshold))
216
+ state["owner_runtime"] = args.to
217
+ set_resume_point(state, rt=rt)
218
+ state["resume_point"]["runtime"] = args.to
219
+ state["handoff"]["pending_to"] = args.to
220
+ state["handoff"]["pending_since"] = fs.now()
221
+ journal.emit(root, state, "runtime_handoff", "%s -> %s (%s)" % (source, args.to, via), data, rt=rt)
222
+ write_handoff(root, state, "manual", rt=rt)
223
+ task.save(root, state)
224
+ print("Handed %s to %s (%s). In %s, run /rr-build: it resumes from the state file in .ai/state/."
225
+ % (state.get("task_id"), args.to, via, args.to))
226
+ print("headless (not run): cd %s && %s" % (shlex.quote(root), RESUME_COMMAND[args.to]))
@@ -0,0 +1,126 @@
1
+ """journal: the event vocabulary, the journal line and its append-only file, and the one function that records a state change in both the history and the journal.
2
+
3
+ core: EVENT_TYPES, FORGEABLE_EVENTS, JOURNAL_MAX_BYTES, DETAIL_MAX, ERROR_MAX
4
+ shell: journal_line, append_journal, read_journal, record, emit
5
+ """
6
+ import json
7
+ import os
8
+
9
+ try:
10
+ import fcntl
11
+ except ImportError: # not POSIX: the journal falls back to O_APPEND alone
12
+ fcntl = None
13
+
14
+ from rr_state import fs
15
+ from rr_state import runtime as rr_runtime
16
+
17
+ # The journal vocabulary (I3): flat, append-only, one type per command that
18
+ # changes something. A new type is added only when it has its own reader.
19
+ EVENT_TYPES = [
20
+ "task_started", "stage_started", "tier_set", "tier_raised", "plan_registered",
21
+ "scope_change", "step_started", "step_done", "field_set", "question_asked",
22
+ "question_answered", "gate_requested", "gate_approved", "gate_rejected", "note",
23
+ "handoff_written", "runtime_handoff", "model_fallback", "missed_reroute", "schema_migrated",
24
+ "task_closed", "findings_closed", "review_budget_refused", "human_set",
25
+ # schema 7 (redesign 2.2a): the weight model's surface.
26
+ "weight_set", "weight_raised", "mode_set", "verify_run", "review_verdict",
27
+ # R36 (7c): the dialog's decisions and the consent ledger, mirrored by state.py.
28
+ "decision_opened", "consent_recorded", "consent_used", "consent_voided", "decision_declined",
29
+ "decision_settled", "decision_fallback", "plan_drift",
30
+ # compact-project-memory §D9: a risk recorded with `memory risk`, compacted at close.
31
+ "risk_recorded",
32
+ # the batch loop (/rr-build-all): the switch, at start or during the task.
33
+ "batch_set",
34
+ ]
35
+ # The types `event` will not write: each is the record of an authorization or a
36
+ # lifecycle decision, and is emitted by the transition that made it. `event` is
37
+ # for a hook reporting something that happened outside the state machine.
38
+ FORGEABLE_EVENTS = {"gate_requested", "gate_approved", "gate_rejected",
39
+ "task_started", "task_closed", "weight_set", "weight_raised", "mode_set",
40
+ "verify_run", "review_verdict", "decision_opened", "consent_recorded",
41
+ "consent_used", "consent_voided", "decision_declined", "decision_settled",
42
+ "decision_fallback", "plan_drift", "risk_recorded", "batch_set"}
43
+
44
+ JOURNAL_MAX_BYTES = 4096
45
+ DETAIL_MAX = 500
46
+ ERROR_MAX = 1000
47
+
48
+
49
+ def journal_line(task_id, event, actor, stage, detail, data, runtime="unknown"):
50
+ line = {
51
+ "ts": fs.now_ms(), "task": task_id, "event": event, "actor": actor,
52
+ "runtime": runtime, "stage": stage or "", "detail": (detail or "")[:DETAIL_MAX],
53
+ "data": dict(data or {}),
54
+ }
55
+ if isinstance(line["data"].get("error"), str):
56
+ line["data"]["error"] = line["data"]["error"][:ERROR_MAX]
57
+ text = json.dumps(line, ensure_ascii=False) + "\n"
58
+ if len(text.encode("utf-8")) > JOURNAL_MAX_BYTES:
59
+ # A line that cannot fit loses its payload, never its existence. The cap
60
+ # covers the terminator too: it is the precondition for the single write.
61
+ line["data"] = {"truncated": True}
62
+ text = json.dumps(line, ensure_ascii=False) + "\n"
63
+ return text
64
+
65
+
66
+ def append_journal(root, task_id, text):
67
+ """One complete line, one os.write, O_APPEND. Best effort: every failure is
68
+ swallowed, because the state write is the contract and the journal is not."""
69
+ if not task_id:
70
+ return False
71
+ path = fs.journal_path(root, task_id)
72
+ try:
73
+ os.makedirs(os.path.dirname(path), exist_ok=True)
74
+ fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
75
+ except OSError:
76
+ return False
77
+ payload = text.encode("utf-8")
78
+ try:
79
+ if fcntl is not None:
80
+ fcntl.flock(fd, fcntl.LOCK_EX)
81
+ # A short write (ENOSPC) returns a count rather than raising, and a torn
82
+ # line is exactly what the single write exists to prevent — so say so.
83
+ written = os.write(fd, payload)
84
+ except OSError:
85
+ return False
86
+ finally:
87
+ os.close(fd)
88
+ return written == len(payload)
89
+
90
+
91
+ def read_journal(root, task_id):
92
+ """Tolerant by design (I3): an unparseable line is counted, never fatal."""
93
+ events, skipped = [], 0
94
+ try:
95
+ with open(fs.journal_path(root, task_id), encoding="utf-8") as fh:
96
+ for line in fh:
97
+ line = line.strip()
98
+ if not line:
99
+ continue
100
+ try:
101
+ events.append(json.loads(line))
102
+ except (ValueError, RecursionError):
103
+ skipped += 1
104
+ except OSError:
105
+ return [], skipped
106
+ return events, skipped
107
+
108
+
109
+ def record(state, event, detail=""):
110
+ state.setdefault("history", []).append(
111
+ {"at": fs.now(), "event": event, "detail": detail}
112
+ )
113
+
114
+
115
+ def emit(root, state, event, detail="", data=None, legacy=None, actor="agent", rt=rr_runtime.Runtime()):
116
+ """One state change: today's history entry plus one journal line.
117
+
118
+ `legacy` keeps history[]'s original event name where the journal's is newer,
119
+ so every reader and every test written before the journal still sees what it
120
+ expects and the two records stay one-to-one.
121
+ """
122
+ if event not in EVENT_TYPES:
123
+ fs.die("unknown event type '%s' (have: %s)" % (event, ", ".join(EVENT_TYPES)))
124
+ record(state, legacy or event, detail)
125
+ append_journal(root, state.get("task_id"), journal_line(
126
+ state.get("task_id"), event, actor, state.get("current_stage"), detail, data, runtime=rt.name))
@@ -0,0 +1,99 @@
1
+ """labels: the consent labels a decision may offer as commands -- the one-simple-command rule, the state.py command a label runs, and the absolute command the agent and the human share.
2
+
3
+ shell: validate_label, human_name, state_label
4
+ core: LABEL_MAX, LABEL_FORBIDDEN, INTERPRETER_RE, PUSH_REF_RE, INTERPRETER_FLAG_RE, NPX_PACKAGE_RE, label_argv, subcommand, state_label_ok, subcommand_prefix
5
+ """
6
+
7
+ import os
8
+ import re
9
+ import shlex
10
+
11
+ from rr_state import fs
12
+
13
+ LABEL_MAX = 400
14
+ LABEL_FORBIDDEN = (";", "&", "|", "\n", "\r", "`", "$(", "<", ">")
15
+ INTERPRETER_RE = re.compile(r"^python3?$") # a bare name: a path could be any program
16
+ PUSH_REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$") # no '+' (a force push), no ':' (a refspec), no option
17
+ INTERPRETER_FLAG_RE = re.compile(r"^-[IS]+$")
18
+ NPX_PACKAGE_RE = re.compile(r"^riskrails@[0-9A-Za-z.+-]+$")
19
+
20
+
21
+ def label_argv(label):
22
+ """(script, argv) when a label runs a state.py, else None."""
23
+ try:
24
+ tokens = shlex.split(label)
25
+ except ValueError:
26
+ return None
27
+ if not tokens or not INTERPRETER_RE.match(tokens[0]):
28
+ return None
29
+ i = 1
30
+ while i < len(tokens) and INTERPRETER_FLAG_RE.match(tokens[i]):
31
+ i += 1
32
+ if i >= len(tokens) or os.path.basename(tokens[i]) != "state.py":
33
+ return None
34
+ return tokens[i], tokens[i + 1:]
35
+
36
+
37
+ def subcommand(argv):
38
+ """argv without the global options: what the command is."""
39
+ rest = list(argv)
40
+ while rest[:1] in (["--root"], ["--runtime"]) and len(rest) >= 2:
41
+ rest = rest[2:]
42
+ return rest
43
+
44
+
45
+ def state_label_ok(argv):
46
+ """approve | reject | profile set | verify --by | review --by | start --mode … --by."""
47
+ rest = subcommand(argv)
48
+ if rest[:1] in (["approve"], ["reject"]) or rest[:2] == ["profile", "set"]:
49
+ return True
50
+ if rest[:1] in (["verify"], ["review"]):
51
+ return "--by" in rest
52
+ return rest[:1] == ["start"] and "--mode" in rest and "--by" in rest
53
+
54
+
55
+ def validate_label(label):
56
+ """None when `label` may be offered as a command (R36.4), else why not."""
57
+ if not isinstance(label, str) or not label or label != label.strip():
58
+ return "a label is a non-empty string without surrounding blanks"
59
+ if len(label) > LABEL_MAX or not label.isprintable():
60
+ return "a label is at most %d printable characters" % LABEL_MAX
61
+ bad = [c for c in LABEL_FORBIDDEN if c in label]
62
+ if bad:
63
+ return "a label is one simple command (it contains %s)" % " ".join(repr(c) for c in bad)
64
+ try:
65
+ tokens = shlex.split(label)
66
+ except ValueError as exc:
67
+ return "a label must split as a shell command (%s)" % exc
68
+ if tokens[:2] == ["git", "commit"]:
69
+ return None
70
+ if tokens[:2] == ["git", "push"]:
71
+ return None if len(tokens) == 4 and all(PUSH_REF_RE.match(t) and not t.startswith("-")
72
+ for t in tokens[2:]) else \
73
+ "a push label is exactly 'git push <remote> <branch>', no '+', ':' or option"
74
+ if tokens[:1] == ["npx"] and len(tokens) >= 3 and NPX_PACKAGE_RE.match(tokens[1]) and tokens[-1] == "--yes":
75
+ return None
76
+ script = label_argv(label)
77
+ if script and os.path.realpath(script[0]) == os.path.realpath(fs.ENTRY) \
78
+ and state_label_ok(script[1]):
79
+ return None
80
+ 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")
82
+
83
+
84
+ def human_name(root):
85
+ """Who the labels name as --by: the repository's git user, else $USER."""
86
+ return fs.one_line(fs.run_text(root, ["git", "config", "user.name"]) or os.environ.get("USER") or "human", 80)
87
+
88
+
89
+ def state_label(root, argv):
90
+ """The command that runs this state.py with argv: absolute, quoted, with
91
+ --root, so the human's pick and the agent's call are one string."""
92
+ if "--root" not in subcommand_prefix(argv):
93
+ argv = ["--root", root] + list(argv)
94
+ return "python3 %s %s" % (shlex.quote(fs.ENTRY), shlex.join(argv))
95
+
96
+
97
+ def subcommand_prefix(argv):
98
+ """The global options in front of the subcommand."""
99
+ return list(argv)[:len(argv) - len(subcommand(argv))]
@@ -0,0 +1,145 @@
1
+ """ledger: the consent ledger and the decision file, read and written as plain data.
2
+
3
+ core: LEDGER_LINE_MAX, LEDGER_TYPES, parse_ts, latest_rounds, next_decision_id
4
+ shell: ledger_append, read_ledger, live_picks, read_decisions, write_decisions
5
+ """
6
+ import contextlib
7
+ import json
8
+ import os
9
+ import re
10
+ from datetime import datetime, timezone
11
+
12
+ from rr_state import fs
13
+
14
+ LEDGER_LINE_MAX = 4096
15
+ LEDGER_TYPES = {"offer", "pick", "use", "void", "decline", "settle"}
16
+
17
+
18
+ def ledger_append(root, entry):
19
+ """One line, one write, O_APPEND; called under ledger_lock."""
20
+ line = dict({"v": 1}, **entry)
21
+ line["at"] = fs.now()
22
+ payload = (json.dumps(line, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
23
+ if len(payload) > LEDGER_LINE_MAX:
24
+ raise OSError("a consent line is over %d bytes" % LEDGER_LINE_MAX)
25
+ fd = os.open(fs.ledger_path(root), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
26
+ try:
27
+ if os.write(fd, payload) != len(payload):
28
+ raise OSError("short write to consent.jsonl")
29
+ finally:
30
+ os.close(fd)
31
+
32
+
33
+ def read_ledger(root, path=None):
34
+ """Every well-formed line, in order. A torn or foreign line is skipped, so a
35
+ damaged ledger can only show fewer picks than were written (fails closed).
36
+ The hooks keep their own reader (hooks/guard/dialog.py, 7d1); the contract
37
+ test feeds both the same fixtures."""
38
+ entries = []
39
+ try:
40
+ with open(path or fs.ledger_path(root), encoding="utf-8") as fh:
41
+ for raw in fh:
42
+ try:
43
+ entry = json.loads(raw)
44
+ except (ValueError, RecursionError):
45
+ continue
46
+ if isinstance(entry, dict) and entry.get("v") == 1 and entry.get("type") in LEDGER_TYPES:
47
+ entries.append(entry)
48
+ except OSError:
49
+ return []
50
+ return entries
51
+
52
+
53
+ def parse_ts(text):
54
+ try:
55
+ return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
56
+ except (TypeError, ValueError):
57
+ return None
58
+
59
+
60
+ def latest_rounds(ledger):
61
+ """decision -> the picks of its latest dialog call (one tool_use_id). A
62
+ re-offer's picks replace the earlier round's whatever became of them, so an
63
+ expired, voided or other-session pick never blocks the new one (R36.8)."""
64
+ rounds = {}
65
+ for entry in ledger:
66
+ if entry["type"] == "pick" and isinstance(entry.get("order"), int):
67
+ call, picks = rounds.get(entry.get("decision"), (None, []))
68
+ if not picks or call != entry.get("tool_use_id"):
69
+ rounds[entry.get("decision")] = (entry.get("tool_use_id"), [entry])
70
+ else:
71
+ picks.append(entry)
72
+ return {decision: picks for decision, (_, picks) in rounds.items()}
73
+
74
+
75
+ def live_picks(ledger, session, at=None):
76
+ """The picks a command may consume now (R36.7): of each decision's latest
77
+ round, unused, not voided, the decision not settled, same session, inside
78
+ the window — and only the first unused one in its order. A later pick's
79
+ window restarts when the one before it is used."""
80
+ at = at or datetime.now(timezone.utc)
81
+ used, voided, settled = {}, set(), set()
82
+ for entry in ledger:
83
+ kind = entry["type"]
84
+ if kind == "use":
85
+ used[entry.get("id")] = parse_ts(entry.get("at"))
86
+ elif kind == "void":
87
+ voided.update(entry.get("ids") or [])
88
+ elif kind == "settle":
89
+ settled.add(entry.get("decision"))
90
+ live = []
91
+ for decision, chain in latest_rounds(ledger).items():
92
+ if decision in settled:
93
+ continue
94
+ previous_use = None
95
+ for pick in sorted(chain, key=lambda p: p["order"]):
96
+ if pick.get("id") in used:
97
+ previous_use = used[pick.get("id")]
98
+ continue
99
+ picked, expires = parse_ts(pick.get("at")), parse_ts(pick.get("expires_at"))
100
+ if pick.get("id") in voided or pick.get("session") != session or not picked or not expires:
101
+ break
102
+ if previous_use and previous_use > picked:
103
+ expires = previous_use + (expires - picked)
104
+ if at <= expires:
105
+ live.append(pick)
106
+ break
107
+ return live
108
+
109
+
110
+ def read_decisions(root):
111
+ """decision.json, or an empty document when there is none."""
112
+ try:
113
+ with open(fs.decision_path(root), encoding="utf-8") as fh:
114
+ data = json.load(fh)
115
+ except FileNotFoundError:
116
+ return {"version": 1, "open": []}
117
+ except (OSError, ValueError, RecursionError) as exc:
118
+ fs.die("%s is unreadable (%s); only state.py writes it. Remove it to drop every open decision."
119
+ % (fs.decision_path(root), exc))
120
+ if not isinstance(data, dict) or not isinstance(data.get("open"), list):
121
+ fs.die("%s is not a decision document; remove it to drop every open decision." % fs.decision_path(root))
122
+ return data
123
+
124
+
125
+ def write_decisions(root, data):
126
+ """Atomic; the file is deleted when nothing is open (R36.14)."""
127
+ path = fs.decision_path(root)
128
+ if not data["open"]:
129
+ with contextlib.suppress(FileNotFoundError):
130
+ os.remove(path)
131
+ return
132
+ os.makedirs(os.path.dirname(path), exist_ok=True)
133
+ tmp = "%s.%d.tmp" % (path, os.getpid())
134
+ with open(tmp, "w", encoding="utf-8") as fh:
135
+ json.dump(data, fh, indent=2, ensure_ascii=False)
136
+ fh.write("\n")
137
+ os.replace(tmp, path)
138
+
139
+
140
+ def next_decision_id(data, ledger):
141
+ """One more than any id the file or the ledger has seen: ids are never reused."""
142
+ seen = [d.get("id") for d in data["open"]] + [e.get("decision") for e in ledger] + \
143
+ [d for e in ledger for d in (e.get("decisions") or [])]
144
+ numbers = [int(x[1:]) for x in seen if isinstance(x, str) and re.match(r"^D\d+$", x)]
145
+ return "D%d" % (max(numbers or [0]) + 1)