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,336 @@
1
+ """questions: the questions file — its regexes and constants, how it is parsed, rendered and written.
2
+
3
+ shell: question_lines, parse_questions, write_questions, answer_line, question_block, parse_prose, prose_id, parse_assignment, set_pending
4
+ core: QUESTIONS_HEADER, TASK_QUESTIONS_NOTE, QUESTIONS_SECTION, OTHER_OPTION, HEADING_RE, OPTION_RE, OTHER_RE, ANSWER_RE, META_RE, CONTEXT_RE, VIA_VALUES, TRAILER_RE, CHOICE_RE, CHOICE_TEXT_RE, OTHER_TEXT_RE, LETTER_RE, PROSE_CHOICE_RE, PROSE_TEXT_RE, OPTION_INPUT_RE, OPTION_KEY_RE, OPTION_HEAD_RE, in_task_md, split_task_md, with_preamble, parse_answer_body, is_pending, render_prose, render_md, render_questions, unquote
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+
11
+ from rr_state import fs
12
+
13
+
14
+ # The questions are written by state.py and never by a model (R1). One regex
15
+ # per line (I2), so a hand edit that keeps the shape round-trips exactly. A
16
+ # task's questions live in task.md §Questions (R7, R8); a topic's in its own file.
17
+ QUESTIONS_HEADER = """# Questions — %s
18
+ <!-- Written by state.py. Answer with `state.py answer Q1=B`, or fill the [Answer]: lines and run
19
+ `state.py questions --sync`. Do not edit the questions themselves. -->
20
+ """
21
+ TASK_QUESTIONS_NOTE = """<!-- Written by state.py. Answer with `state.py answer Q1=B`, or fill the [Answer]: lines here and run
22
+ `state.py questions --sync`; the rest of task.md is rewritten from the state, this section is kept. -->"""
23
+ QUESTIONS_SECTION = "## Questions"
24
+ OTHER_OPTION = "X. Other — answer as `X: <text>`"
25
+ HEADING_RE = re.compile(r"^## (Q|G)(\d+)\. (.+)$")
26
+ OPTION_RE = re.compile(r"^([A-W])\. (.+?)( \(recommended\))?$")
27
+ OTHER_RE = re.compile(r"^X\. Other")
28
+ ANSWER_RE = re.compile(r"^\[Answer\]:\s*(.*)$")
29
+ META_RE = re.compile(r"^asked: (\S+) · stage: (\S+)(?: · by: (.+))?$")
30
+ CONTEXT_RE = re.compile(r"^context: (.+)$")
31
+ # The trailer must be a suffix free text cannot produce: a closed `via`
32
+ # vocabulary, an exact timestamp, and a name that cannot span another em dash —
33
+ # otherwise `X: we chose Adyen — by the way it is cheaper` eats its own answer.
34
+ VIA_VALUES = "picker|prose|file|terminal|unattended|dialog"
35
+ TRAILER_RE = re.compile(
36
+ r"\s+—\s+by ([^—]+?) via (%s) at (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\s*$" % VIA_VALUES)
37
+ CHOICE_RE = re.compile(r"^[A-W]$")
38
+ CHOICE_TEXT_RE = re.compile(r"^([A-W])\s*[—:-]\s*(.+)$")
39
+ OTHER_TEXT_RE = re.compile(r"^X\s*:\s*(.+)$")
40
+ LETTER_RE = re.compile(r"^[A-Z]$")
41
+ PROSE_CHOICE_RE = re.compile(r"\b(\d+)\s*([A-W])\b")
42
+ PROSE_TEXT_RE = re.compile(r"\b(\d+)\s*:")
43
+ OPTION_INPUT_RE = re.compile(r"^([A-W])\s*[:.]\s*(.+)$")
44
+ OPTION_KEY_RE = re.compile(r"^[A-W]$")
45
+ OPTION_HEAD_RE = re.compile(r"^([A-Za-z0-9]{1,3})\s*[:.]\s")
46
+
47
+
48
+ def in_task_md(path):
49
+ return os.path.basename(path) == "task.md"
50
+
51
+
52
+ def split_task_md(lines):
53
+ """task.md as (before, section, after): `before` ends with the §Questions
54
+ heading, `section` runs to the next `## ` heading that is not a question's.
55
+ No heading: everything is `before` and the section is None."""
56
+ try:
57
+ start = lines.index(QUESTIONS_SECTION) + 1
58
+ except ValueError:
59
+ return lines, None, []
60
+ end = start
61
+ while end < len(lines) and not (lines[end].startswith("## ") and not HEADING_RE.match(lines[end])):
62
+ end += 1
63
+ return lines[:start], lines[start:end], lines[end:]
64
+
65
+
66
+ def question_lines(path):
67
+ """The lines the questions live in. For task.md that is §Questions only; a
68
+ task started before the fold may still hold its questions in questions.md
69
+ beside it, which is read until the first write carries them into task.md."""
70
+ lines = fs.read_lines(path)
71
+ if not in_task_md(path):
72
+ return lines or []
73
+ section = split_task_md(lines or [])[1] or []
74
+ if not any(HEADING_RE.match(line) for line in section):
75
+ legacy = fs.read_lines(os.path.join(os.path.dirname(path), "questions.md"))
76
+ if legacy and any(HEADING_RE.match(line) for line in legacy):
77
+ first = next(i for i, line in enumerate(legacy) if HEADING_RE.match(line))
78
+ return [""] + TASK_QUESTIONS_NOTE.split("\n") + [""] + legacy[first:]
79
+ return section
80
+
81
+
82
+ def with_preamble(path, lines, name):
83
+ """An existing but empty (or header-less) question store still gets the
84
+ notice that tells a human not to edit the questions."""
85
+ if in_task_md(path):
86
+ if not any(line.startswith("<!-- Written by state.py") for line in lines):
87
+ lines = [""] + TASK_QUESTIONS_NOTE.split("\n") + [line for line in lines
88
+ if line.strip() not in ("", "none")]
89
+ return lines
90
+ if not any(line.startswith("# Questions") for line in lines):
91
+ lines = (QUESTIONS_HEADER % name).rstrip("\n").split("\n") + lines
92
+ return lines
93
+
94
+
95
+ def parse_answer_body(question, body):
96
+ """`LETTER`, `LETTER: text`, `X: text` or bare text, with the trailer this
97
+ command writes parsed back off the end and ignored (I2)."""
98
+ question["choice"] = question["text"] = None
99
+ question["invalid"] = None
100
+ trailer = TRAILER_RE.search(body)
101
+ if trailer:
102
+ question["answered_by"], question["via"], question["answered_at"] = trailer.groups()
103
+ body = body[:trailer.start()]
104
+ body = body.strip()
105
+ if not body:
106
+ return
107
+ letters = [letter for letter, _ in question["options"]]
108
+ if question["other"]:
109
+ letters.append("X")
110
+ other = OTHER_TEXT_RE.match(body)
111
+ pair = CHOICE_TEXT_RE.match(body)
112
+ if other:
113
+ choice, text = "X", other.group(1).strip()
114
+ elif pair:
115
+ choice, text = pair.group(1), pair.group(2).strip()
116
+ elif CHOICE_RE.match(body) or LETTER_RE.match(body):
117
+ choice, text = body, None
118
+ else:
119
+ choice, text = ("X" if question["other"] else None), body
120
+ if choice is not None and choice not in letters:
121
+ question["invalid"] = choice
122
+ return
123
+ question["choice"], question["text"] = choice, text
124
+
125
+
126
+ def parse_questions(path):
127
+ """One regex per line (I2). Returns the store's lines beside the questions, so
128
+ a writer can replace an [Answer]: line and leave every other byte alone."""
129
+ lines = question_lines(path)
130
+ questions, current = [], None
131
+ for index, line in enumerate(lines):
132
+ heading = HEADING_RE.match(line)
133
+ if heading:
134
+ current = {
135
+ "id": heading.group(1) + heading.group(2), "kind": heading.group(1),
136
+ "number": int(heading.group(2)), "question": heading.group(3),
137
+ "asked": None, "stage": None, "by": None, "context": None,
138
+ "options": [], "recommend": None, "other": False,
139
+ "answer_at": None, "choice": None, "text": None, "invalid": None,
140
+ "answered_by": None, "via": None, "answered_at": None,
141
+ }
142
+ questions.append(current)
143
+ continue
144
+ if current is None:
145
+ continue
146
+ meta = META_RE.match(line)
147
+ if meta:
148
+ current["asked"], current["stage"], current["by"] = meta.groups()
149
+ continue
150
+ context = CONTEXT_RE.match(line)
151
+ if context:
152
+ current["context"] = context.group(1)
153
+ continue
154
+ answer = ANSWER_RE.match(line)
155
+ if answer:
156
+ current["answer_at"] = index
157
+ parse_answer_body(current, answer.group(1))
158
+ current = None # the block ends at its answer line
159
+ continue
160
+ if OTHER_RE.match(line):
161
+ current["other"] = True
162
+ continue
163
+ option = OPTION_RE.match(line)
164
+ if option:
165
+ current["options"].append((option.group(1), option.group(2)))
166
+ if option.group(3):
167
+ current["recommend"] = option.group(1)
168
+ return lines, questions
169
+
170
+
171
+ def is_pending(question):
172
+ return question["choice"] is None
173
+
174
+
175
+ def write_questions(path, lines):
176
+ os.makedirs(os.path.dirname(path), exist_ok=True)
177
+ if in_task_md(path):
178
+ # Only §Questions is replaced; the other sections stay as rendered.
179
+ before, section, after = split_task_md(fs.read_lines(path) or [])
180
+ lines = lines + ([""] if lines and lines[-1] else [])
181
+ if section is not None:
182
+ lines = before + lines + after
183
+ else:
184
+ lines = before + (["", QUESTIONS_SECTION] if before else [QUESTIONS_SECTION]) + lines
185
+ tmp = "%s.%d.tmp" % (path, os.getpid())
186
+ with open(tmp, "w", encoding="utf-8") as fh:
187
+ fh.write("\n".join(lines).rstrip("\n") + "\n")
188
+ os.replace(tmp, path)
189
+
190
+
191
+ def answer_line(choice, text, by, via):
192
+ text = fs.one_line(text)
193
+ if TRAILER_RE.search(text):
194
+ # An answer that ends in the trailer's exact shape would re-parse as
195
+ # somebody else's; the em dash is the anchor, so it goes.
196
+ text = text.replace("—", "-")
197
+ by = fs.one_line(by).replace("—", "-")
198
+ if choice and text:
199
+ body = "%s: %s" % (choice, text)
200
+ elif choice:
201
+ body = choice
202
+ else:
203
+ body = text
204
+ return "[Answer]: %s — by %s via %s at %s" % (body, by or "unknown", via, fs.now())
205
+
206
+
207
+ def question_block(question, stage, by):
208
+ by = fs.one_line(by)
209
+ block = ["", "## %s. %s" % (question["id"], fs.one_line(question["question"])),
210
+ "asked: %s · stage: %s%s" % (fs.now(), stage, " · by: " + by if by else "")]
211
+ if question.get("context"):
212
+ block.append("context: %s" % fs.one_line(question["context"]))
213
+ for letter, text in question["options"]:
214
+ block.append("%s. %s%s" % (letter, fs.one_line(text),
215
+ " (recommended)" if letter == question.get("recommend") else ""))
216
+ block.append(OTHER_OPTION)
217
+ block.append("[Answer]:")
218
+ return block
219
+
220
+
221
+ def render_prose(questions):
222
+ """The rendering every runtime can show. The number is the question's own id
223
+ number, so the same reply means the same thing whenever it is given."""
224
+ out = []
225
+ for question in questions:
226
+ answered = "" if is_pending(question) else " — answered: %s" % question["choice"]
227
+ out.append("%s. %s [%s]%s"
228
+ % (question["number"], question["question"], question["id"], answered))
229
+ if question["context"]:
230
+ out.append(" context: %s" % question["context"])
231
+ for letter, text in question["options"]:
232
+ out.append(" %s. %s%s" % (letter, text,
233
+ " (recommended)" if letter == question["recommend"] else ""))
234
+ if question["other"]:
235
+ out.append(" X. Other — answer as `X: <text>`")
236
+ pending = [q for q in questions if is_pending(q)]
237
+ if pending:
238
+ out.append("Reply `%s`, a free-text answer last, or run: state.py answer %s"
239
+ % (" ".join("%s<letter>" % q["number"] for q in pending),
240
+ " ".join("%s=<letter>" % q["id"] for q in pending)))
241
+ return out
242
+
243
+
244
+ def render_md(questions):
245
+ out = []
246
+ for question in questions:
247
+ if is_pending(question):
248
+ out.append("- **%s** — _unanswered_ (%s)" % (question["question"], question["id"]))
249
+ continue
250
+ chosen = dict(question["options"]).get(question["choice"], question["text"] or "Other")
251
+ detail = "%s — %s" % (chosen, question["text"]) if question["text"] and question["choice"] != "X" else chosen
252
+ out.append("- **%s** — %s *(%s, %s)*" % (
253
+ question["question"], detail, question["id"],
254
+ "by %s via %s at %s" % (question["answered_by"], question["via"], question["answered_at"])
255
+ if question["answered_by"] else "answered"))
256
+ return out
257
+
258
+
259
+ def render_questions(questions, fmt):
260
+ if fmt == "json":
261
+ shown = []
262
+ for question in questions:
263
+ item = {k: v for k, v in question.items() if k != "answer_at"}
264
+ item["options"] = [{"key": k, "text": t} for k, t in question["options"]]
265
+ item["pending"] = is_pending(question)
266
+ shown.append(item)
267
+ return [json.dumps(shown, ensure_ascii=False, indent=2)]
268
+ if fmt == "md":
269
+ return render_md(questions)
270
+ return render_prose(questions)
271
+
272
+
273
+ def parse_prose(text, by_id):
274
+ """`3B` is a choice for Q3, `3: …` is free text for Q3.
275
+
276
+ The number is the question's own id number, not its position in a rendering
277
+ — a rendering the human saw yesterday must not answer a different question
278
+ today. Free text runs to the end of the reply, because a sentence may
279
+ legitimately contain `2B` or `step 2:` and half an answer is worse than a
280
+ refusal; so a free-text answer comes last.
281
+ """
282
+ assignments = []
283
+ start = PROSE_TEXT_RE.search(text)
284
+ head = text[:start.start()] if start else text
285
+ for token in PROSE_CHOICE_RE.finditer(head):
286
+ assignments.append((prose_id(int(token.group(1)), by_id), token.group(2), None))
287
+ if start:
288
+ assignments.append((prose_id(int(start.group(1)), by_id), "X",
289
+ text[start.end():].strip()))
290
+ if not assignments:
291
+ fs.die("no answers found in %r — reply like `1B 2A 3: free text`" % text)
292
+ return assignments
293
+
294
+
295
+ def prose_id(number, by_id):
296
+ qid = "Q%d" % number
297
+ if qid not in by_id:
298
+ fs.die("prose answer %d has no question (have: %s)"
299
+ % (number, ", ".join(i for i in by_id if i.startswith("Q")) or "none"))
300
+ return qid
301
+
302
+
303
+ def unquote(text):
304
+ """I1 writes the free-text form as X:"free text"; the quotes belong to the
305
+ body and are stripped only as a matching pair."""
306
+ text = text.strip()
307
+ if len(text) > 1 and text[0] == text[-1] and text[0] in "\"'":
308
+ return text[1:-1]
309
+ return text
310
+
311
+
312
+ def parse_assignment(token):
313
+ """Q1=B, Q2=X:free text, Q3=B: because."""
314
+ if "=" not in token:
315
+ fs.die("answer takes Q1=B, not %r" % token)
316
+ qid, value = token.split("=", 1)
317
+ value = value.strip()
318
+ other = OTHER_TEXT_RE.match(value)
319
+ if other:
320
+ return (qid.strip(), "X", unquote(other.group(1)))
321
+ pair = CHOICE_TEXT_RE.match(value)
322
+ if pair:
323
+ return (qid.strip(), pair.group(1), unquote(pair.group(2)))
324
+ # A bare letter is a choice even when it is not one of the options, so a
325
+ # typo is refused rather than silently recorded as free text.
326
+ if LETTER_RE.match(value):
327
+ return (qid.strip(), value, None)
328
+ return (qid.strip(), None, unquote(value))
329
+
330
+
331
+ def set_pending(root, state, questions, path):
332
+ """questions.pending is a cache; the file is the truth (spec alternative 4)."""
333
+ state["questions"] = {
334
+ "file": os.path.relpath(path, root),
335
+ "pending": [q["id"] for q in questions if is_pending(q)],
336
+ }
@@ -0,0 +1,321 @@
1
+ """questions_cmd: the question file's commands -- ask, answer, the sync of hand edits, and the gate closed from its own answer line.
2
+
3
+ shell: cmd_ask, apply_answers, cmd_answer, sync_answers, cmd_questions, close_gate_from_file
4
+ """
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import shlex
9
+ import sys
10
+
11
+ from rr_state import dialog, fs, gate, handoff, journal, locks, task
12
+ from rr_state import questions as rr_questions
13
+ from rr_state import runtime as rr_runtime
14
+
15
+
16
+ def cmd_ask(args, root, rt=rr_runtime.Runtime()):
17
+ if args.gate:
18
+ # A gate the agent can author is a gate the agent can have signed: the
19
+ # human would approve whichever G was pending last.
20
+ fs.die("a gate is requested by 'state.py close' in full mode, not by ask")
21
+ state = None if args.topic else task.load(root, rt=rt)
22
+ path = fs.questions_path(root, state, args.topic)
23
+ stage = "intent" if args.topic else state["current_stage"]
24
+ asked = []
25
+ if args.batch:
26
+ try:
27
+ with open(args.batch, encoding="utf-8") as fh:
28
+ batch = json.load(fh)
29
+ except (OSError, ValueError, RecursionError) as exc:
30
+ fs.die("cannot read batch file %s (%s)" % (args.batch, exc))
31
+ if not isinstance(batch, list) or not batch:
32
+ fs.die("the batch file must contain a non-empty JSON array")
33
+ for item in batch:
34
+ if not isinstance(item, dict):
35
+ fs.die("every batch item must be an object with question and options")
36
+ options = item.get("options") or []
37
+ if not all(isinstance(o, dict) and "key" in o and "text" in o for o in options):
38
+ fs.die("every option needs a key and a text (%r)" % item.get("question"))
39
+ asked.append({"question": item.get("question"),
40
+ "options": [(o["key"], o["text"]) for o in options],
41
+ "recommend": item.get("recommend"), "context": item.get("context")})
42
+ else:
43
+ if not args.question:
44
+ fs.die("ask takes a question, or --batch FILE.json")
45
+ options, letters = [], "ABCDEFGHIJKLMNOPQRSTUVW"
46
+ for option in args.option:
47
+ # Collapsed first: a key is only recognisable once the value is one
48
+ # line, and one line is what the file will hold either way.
49
+ option = fs.one_line(option)
50
+ if len(options) >= len(letters):
51
+ fs.die("a question takes at most %d options" % len(letters))
52
+ head = rr_questions.OPTION_INPUT_RE.match(option)
53
+ if head:
54
+ options.append((head.group(1), head.group(2).strip()))
55
+ continue
56
+ labelled = rr_questions.OPTION_HEAD_RE.match(option)
57
+ if labelled:
58
+ # "1: one" or "aa: one" is a key the I2 grammar cannot parse, not
59
+ # an option whose text happens to start that way.
60
+ fs.die("an option key is one letter A–W, not %r" % labelled.group(1))
61
+ options.append((letters[len(options)], option))
62
+ asked.append({"question": args.question, "options": options,
63
+ "recommend": args.recommend, "context": args.context})
64
+ for item in asked:
65
+ if not item["question"]:
66
+ fs.die("every question needs a question line")
67
+ if len(item["options"]) < 2:
68
+ fs.die("a question needs at least two options (%r has %d)"
69
+ % (item["question"], len(item["options"])))
70
+ keys = [key for key, _ in item["options"]]
71
+ # An option the I2 grammar cannot parse would round-trip as no option at
72
+ # all, and the answer to it would be recorded as free text (R1).
73
+ for key in keys:
74
+ if not rr_questions.OPTION_KEY_RE.match(key):
75
+ fs.die("an option key is one letter A–W, not %r" % key)
76
+ if len(set(keys)) != len(keys):
77
+ fs.die("option keys must be distinct (%s)" % ", ".join(keys))
78
+ for _, text in item["options"]:
79
+ if text.endswith(" (recommended)"):
80
+ fs.die("an option text cannot end with ' (recommended)' — use --recommend")
81
+ if item["recommend"] and item["recommend"] not in keys:
82
+ fs.die("--recommend %s is not one of the options (%s)"
83
+ % (item["recommend"], ", ".join(keys)))
84
+
85
+ new_ids = []
86
+ with locks.question_lock(path):
87
+ lines, existing = rr_questions.parse_questions(path)
88
+ while lines and not lines[-1].strip():
89
+ lines.pop()
90
+ lines = rr_questions.with_preamble(path, lines, args.topic or state["task_id"])
91
+ kind = "G" if args.gate else "Q"
92
+ number = max([q["number"] for q in existing if q["kind"] == kind] or [0])
93
+ for item in asked:
94
+ number += 1
95
+ item["id"] = "%s%d" % (kind, number)
96
+ if args.gate:
97
+ item["question"] = "%s (gate: %s)" % (item["question"], args.gate)
98
+ new_ids.append(item["id"])
99
+ lines.extend(rr_questions.question_block(item, stage, args.by))
100
+ rr_questions.write_questions(path, lines)
101
+ lines, questions = rr_questions.parse_questions(path)
102
+ relative = os.path.relpath(path, root)
103
+ if state is not None:
104
+ for item in asked:
105
+ journal.emit(root, state, "question_asked", "%s: %s" % (item["id"], item["question"]),
106
+ {"id": item["id"], "options": [letter for letter, _ in item["options"]],
107
+ "recommended": item["recommend"], "gate": args.gate}, rt=rt)
108
+ rr_questions.set_pending(root, state, questions, path)
109
+ handoff.write_handoff(root, state, "stage", rt=rt)
110
+ task.save(root, state)
111
+ print("%s asked → %s" % (" ".join(new_ids), relative))
112
+ for line in rr_questions.render_prose([q for q in questions if rr_questions.is_pending(q)]):
113
+ print(line)
114
+ for item in asked if state is not None else []:
115
+ # A question with at most three options fits the dialog beside "Not now";
116
+ # a longer one stays on the file route (R36.3).
117
+ if len(item["options"]) <= 3:
118
+ dialog.offer_decision(root, state, "question", item["id"],
119
+ "%s%s" % (item["question"], "\ncontext: %s" % item["context"] if item.get("context") else ""),
120
+ [{"kind": "choice", "effect": "defer", "label": "Not now — leave it pending"}] +
121
+ [{"kind": "choice", "effect": "answer", "answer": key,
122
+ "label": "%s — %s" % (key, fs.one_line(text, 100)),
123
+ "description": "%s%s" % (fs.one_line(text, 300),
124
+ " (recommended)" if key == item.get("recommend") else "")}
125
+ for key, text in item["options"]], "ask", rt=rt, question_id=item["id"])
126
+ if fs.ai_unattended():
127
+ # The literal line an unattended launcher greps for (R5).
128
+ print("WAITING_FOR_ANSWERS %s %s" % (relative, " ".join(new_ids)))
129
+
130
+
131
+ def apply_answers(lines, by_id, assignments, args, relative):
132
+ for qid, choice, text in assignments:
133
+ question = by_id.get(qid)
134
+ if question is None:
135
+ fs.die("no question %s in %s (have: %s)" % (qid, relative, ", ".join(by_id) or "none"))
136
+ if question["kind"] == "G":
137
+ fs.die("%s is a gate: run 'state.py approve' in your terminal or fill its [Answer]: line" % qid)
138
+ letters = [letter for letter, _ in question["options"]]
139
+ if question["other"]:
140
+ letters.append("X")
141
+ if choice is None:
142
+ choice = "X" if question["other"] else None
143
+ if choice not in letters:
144
+ fs.die("%s: invalid choice %r (options: %s)" % (qid, choice, ", ".join(letters)))
145
+ lines[question["answer_at"]] = rr_questions.answer_line(choice, text, args.by, args.via)
146
+
147
+
148
+ def cmd_answer(args, root, rt=rr_runtime.Runtime()):
149
+ state = None if args.topic else task.load(root, rt=rt)
150
+ path = fs.questions_path(root, state, args.topic)
151
+ with locks.question_lock(path):
152
+ lines, questions = rr_questions.parse_questions(path)
153
+ if not questions:
154
+ fs.die("no questions in %s" % os.path.relpath(path, root))
155
+ by_id = {q["id"]: q for q in questions}
156
+ assignments = []
157
+ if args.prose:
158
+ assignments = rr_questions.parse_prose(args.prose, by_id)
159
+ for token in args.assignment:
160
+ assignments.append(rr_questions.parse_assignment(token))
161
+ if not assignments:
162
+ fs.die("answer takes Q1=B …, or --prose \"1B 2A 3: text\"")
163
+ apply_answers(lines, by_id, assignments, args, os.path.relpath(path, root))
164
+ rr_questions.write_questions(path, lines)
165
+ lines, questions = rr_questions.parse_questions(path)
166
+ by_id = {q["id"]: q for q in questions}
167
+ if state is not None:
168
+ for qid, _, _ in assignments:
169
+ question = by_id[qid]
170
+ # actor is derived from the route, never asserted by the caller:
171
+ # `human` is reserved for the file route and the gate (I3).
172
+ journal.emit(root, state, "question_answered", "%s = %s" % (qid, question["choice"]),
173
+ {"id": qid, "choice": question["choice"], "text": question["text"],
174
+ "via": args.via, "by": args.by}, actor="agent", rt=rt)
175
+ rr_questions.set_pending(root, state, questions, path)
176
+ handoff.write_handoff(root, state, "stage", rt=rt)
177
+ task.save(root, state)
178
+ pending = [q["id"] for q in questions if rr_questions.is_pending(q)]
179
+ print("%s answered; %s" % (", ".join(qid for qid, _, _ in assignments),
180
+ ("still pending: " + ", ".join(pending)) if pending else "none pending"))
181
+
182
+
183
+ def sync_answers(root, state, path, lines, questions, args, rt=rr_runtime.Runtime()):
184
+ """Pick up hand edits: a filled line with no trailer is a fresh answer, a
185
+ line this command already wrote has one, so syncing twice changes nothing."""
186
+ changed, gates = [], []
187
+ for question in questions:
188
+ if question["invalid"]:
189
+ print("state.py: %s: invalid choice %r — it stays pending"
190
+ % (question["id"], question["invalid"]), file=sys.stderr)
191
+ continue
192
+ if question["kind"] == "G":
193
+ # A gate is not answered, it is granted or rejected: a filled answer
194
+ # on the open gate goes through the file route, and any other G block
195
+ # is left exactly as it is rather than consumed.
196
+ if (state is not None and question["id"] == task.open_gate(state)
197
+ and question["choice"] and not question["answered_by"] and not gates):
198
+ if question["choice"] not in ("A", "B"):
199
+ # A gate has two answers. Free text — the likeliest thing a
200
+ # human writes into a question file — must not be read as a
201
+ # rejection with their own approving sentence as the reason.
202
+ print("state.py: %s is the approval gate: answer it A or B, not %r"
203
+ % (question["id"], question["text"] or question["choice"]),
204
+ file=sys.stderr)
205
+ continue
206
+ gates.append(question)
207
+ continue
208
+ if question["choice"] is not None and not question["answered_by"]:
209
+ lines[question["answer_at"]] = rr_questions.answer_line(
210
+ question["choice"], question["text"], args.by, "file")
211
+ changed.append(question)
212
+ if not changed:
213
+ return gates
214
+ if state is not None:
215
+ # Recording answers changes the task, so it takes ownership as ask does.
216
+ task.claim_runtime(root, state, rt=rt)
217
+ rr_questions.write_questions(path, lines)
218
+ if state is not None:
219
+ by_id = {q["id"]: q for q in rr_questions.parse_questions(path)[1]}
220
+ for question in changed:
221
+ if question["kind"] == "G":
222
+ continue # the gate's file route is step 6's
223
+ current = by_id[question["id"]]
224
+ journal.emit(root, state, "question_answered", "%s = %s" % (current["id"], current["choice"]),
225
+ {"id": current["id"], "choice": current["choice"], "text": current["text"],
226
+ "via": "file", "by": args.by}, actor="human", rt=rt)
227
+ return changed + gates
228
+
229
+
230
+ def cmd_questions(args, root, rt=rr_runtime.Runtime()):
231
+ state = None if args.topic else task.load(root, rt=rt)
232
+ path = fs.questions_path(root, state, args.topic)
233
+ granted, touched = None, []
234
+ with locks.question_lock(path) if args.sync else contextlib.nullcontext():
235
+ lines, questions = rr_questions.parse_questions(path)
236
+ if args.sync:
237
+ touched = sync_answers(root, state, path, lines, questions, args, rt=rt)
238
+ if state is not None and touched:
239
+ # Persisted before the gate is evaluated, because a refusal there
240
+ # exits 5 and never returns: the answers this sync already wrote
241
+ # into the file must be in the state and the history too.
242
+ # Only a sync that changed something writes at all — questions.pending
243
+ # is a cache, and guard_pending re-parses the file anyway, so a read
244
+ # (which /ai-status and the session-start hook both perform) never
245
+ # races a command that is mid-write.
246
+ _, questions = rr_questions.parse_questions(path)
247
+ rr_questions.set_pending(root, state, questions, path)
248
+ task.save(root, state)
249
+ for question in [q for q in touched if q["kind"] == "G"]:
250
+ granted = close_gate_from_file(root, state, question, args.by, rt=rt)
251
+ if touched:
252
+ _, questions = rr_questions.parse_questions(path)
253
+ if state is not None:
254
+ rr_questions.set_pending(root, state, questions, path)
255
+ task.save(root, state)
256
+ if granted is not None:
257
+ print("gate %s by %s via the file" % ("approved" if granted else "rejected", args.by))
258
+ shown = [q for q in questions if rr_questions.is_pending(q)] if args.pending else questions
259
+ for line in rr_questions.render_questions(shown, args.format):
260
+ print(line)
261
+
262
+
263
+ def close_gate_from_file(root, state, question, by, rt=rr_runtime.Runtime()):
264
+ """R13: an [Answer]: filled into the gate's own question grants or rejects,
265
+ but only behind a human turn in the session the plan was presented in — or
266
+ AI_UNATTENDED, which says there is nobody to take one.
267
+
268
+ The whole decision happens inside the questions lock and on a state re-read,
269
+ because the human may be typing `reject` in their terminal in exactly these
270
+ seconds: deciding before the lock and applying after it would overwrite them.
271
+ """
272
+ gate_id = question["id"]
273
+ unattended = fs.ai_unattended()
274
+ with locks.question_lock(fs.questions_path(root, state, None)):
275
+ fresh = task.load(root, claim=False, rt=rt)
276
+ if task.open_gate(fresh) != gate_id:
277
+ # Somebody closed it between the parse and here: their outcome wins.
278
+ return None
279
+ state.clear()
280
+ state.update(fresh)
281
+ seen, why = (True, "") if unattended else dialog.human_turn(root, state)
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)
287
+ approved = question["choice"] == "A"
288
+ session = fs.read_session(root)
289
+ task.claim_runtime(root, state, rt=rt)
290
+ state["human_approval"].update({
291
+ "required": True, "granted": approved,
292
+ "granted_by": by if approved else None,
293
+ "granted_at": fs.now() if approved else None,
294
+ "requested_at": None, "gate_id": None, "requested_session": None,
295
+ "via": "file" if approved else None, "unattended": approved and unattended,
296
+ })
297
+ if approved:
298
+ state["human_approval"]["rejected_at"] = None
299
+ if approved:
300
+ journal.record(state, "human_approval", "granted by %s via file" % by)
301
+ else:
302
+ state["next_action"] = "address the rejection: %s" % fs.one_line(question["text"])
303
+ state["human_approval"]["rejected_at"] = fs.now()
304
+ journal.record(state, "gate_rejected", "%s: %s" % (by, question["text"] or "no reason given"))
305
+ handoff.write_handoff(root, state, "stage", rt=rt)
306
+ task.save(root, state) # durable first: the answer line is derived
307
+ # The evidence, recorded beside the outcome: this is the weakest of the
308
+ # three routes, and an audit has to be able to see what it rested on.
309
+ evidence = {"session": session.get("last_prompt_session"),
310
+ "at": session.get("last_prompt_at"), "unattended": unattended}
311
+ journal.append_journal(root, state["task_id"], journal.journal_line(
312
+ state["task_id"], "gate_approved" if approved else "gate_rejected",
313
+ "human" if not unattended else "agent",
314
+ state.get("current_stage"), "%s by %s via file" % (gate_id, by),
315
+ {"by": by, "gate_id": gate_id, "via": "file", "unattended": unattended,
316
+ "tty": False, "evidence": evidence}
317
+ if approved else {"by": by, "gate_id": gate_id, "why": question["text"],
318
+ "via": "file", "evidence": evidence}, runtime=rt.name))
319
+ gate.close_gate(root, state, gate_id, question["choice"], question["text"], by, "file")
320
+ task.save(root, state)
321
+ return approved