riskrails 3.1.0 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +12 -11
- package/bin/riskrails.js +109 -18
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +14 -11
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +109 -7
- package/hooks/project-scaffold.py +2 -1
- package/hooks/runtime-gate.py +128 -27
- package/hooks/update_notice.py +3 -3
- package/package.json +1 -1
- package/skills/ai-init/templates/.ai/state/README.md +4 -4
- package/skills/ai-task/rr_state/__init__.py +1 -0
- package/skills/ai-task/rr_state/cli.py +210 -0
- package/skills/ai-task/rr_state/close.py +171 -0
- package/skills/ai-task/rr_state/dialog.py +541 -0
- package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
- package/skills/ai-task/rr_state/fs.py +220 -0
- package/skills/ai-task/rr_state/gate.py +125 -0
- package/skills/ai-task/rr_state/gate_cmd.py +128 -0
- package/skills/ai-task/rr_state/handoff.py +226 -0
- package/skills/ai-task/rr_state/journal.py +126 -0
- package/skills/ai-task/rr_state/labels.py +99 -0
- package/skills/ai-task/rr_state/ledger.py +145 -0
- package/skills/ai-task/rr_state/locks.py +84 -0
- package/skills/ai-task/rr_state/memory_cmd.py +310 -0
- package/skills/ai-task/rr_state/policy.py +378 -0
- package/skills/ai-task/rr_state/profile.py +373 -0
- package/skills/ai-task/rr_state/questions.py +336 -0
- package/skills/ai-task/rr_state/questions_cmd.py +321 -0
- package/skills/ai-task/rr_state/report.py +271 -0
- package/skills/ai-task/rr_state/report_cmd.py +230 -0
- package/skills/ai-task/rr_state/review.py +165 -0
- package/skills/ai-task/rr_state/runtime.py +80 -0
- package/skills/ai-task/rr_state/start.py +257 -0
- package/skills/ai-task/rr_state/steps.py +131 -0
- package/skills/ai-task/rr_state/task.py +396 -0
- package/skills/ai-task/rr_state/verify.py +231 -0
- package/skills/ai-task/sensors.py +16 -3
- package/skills/ai-task/state.py +24 -5161
- package/skills/project-update/adopt.py +38 -24
- package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
- package/skills/project-update/history/index.json +2 -1
- package/skills/project-update/migrations/0003_context_diet.py +1 -1
- package/skills/project-update/migrations/0007_weight_model.py +130 -23
- package/skills/project-update/migrations/README.md +16 -1
- package/skills/project-update/update.py +202 -26
- package/skills/rr-setup/SKILL.md +9 -2
- package/skills/rr-status/status.py +36 -9
|
@@ -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)
|