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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +29 -15
- package/ai/policies/model-routing.md +7 -7
- package/ai/policies/production.md +3 -3
- package/ai/policies/release.md +2 -2
- package/ai/policies/review-economy.md +27 -30
- package/bin/riskrails.js +114 -23
- package/hooks/ai-path-guard-defaults.json +3 -2
- package/hooks/context-guard.py +15 -12
- package/hooks/dialog-gate.py +1 -1
- package/hooks/guard/common.py +2 -0
- package/hooks/guard/dialog.py +107 -27
- package/hooks/guard/git.py +12 -3
- package/hooks/guard/paths.py +150 -37
- package/hooks/project-scaffold.py +3 -6
- package/hooks/runtime-gate.py +134 -33
- package/hooks/update_notice.py +22 -16
- package/instructions/routing.md +1 -1
- package/instructions/stub.md +3 -3
- package/package.json +1 -1
- package/skills/AGENTS.md +1 -1
- package/skills/CLAUDE.md +1 -1
- package/skills/ai-init/templates/.ai/AGENTS.md +1 -1
- package/skills/ai-init/templates/.ai/VERSION +1 -1
- package/skills/ai-init/templates/.ai/policies/security.md +7 -1
- package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
- package/skills/ai-init/templates/.ai/state/README.md +4 -4
- package/skills/ai-init/templates/AGENTS.block.md +2 -2
- package/skills/ai-init/templates/CLAUDE.block.md +2 -2
- package/skills/ai-init/templates/GEMINI.block.md +2 -2
- package/skills/ai-init/templates/gitignore.snippet +1 -0
- package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
- 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 +37 -4
- package/skills/ai-task/state.py +24 -5138
- package/skills/project-init/templates/constitution.md +1 -1
- package/skills/project-init/templates/sdlc-README.md +20 -14
- package/skills/project-update/adopt.py +38 -24
- package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
- package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
- package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
- package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
- package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
- package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
- package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
- package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
- package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
- package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
- package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
- package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
- package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
- package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
- package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
- package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
- package/skills/project-update/history/index.json +38 -16
- 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/0011_retire_risk_tiers.py +37 -0
- package/skills/project-update/migrations/README.md +16 -1
- package/skills/project-update/migrations/__init__.py +6 -1
- package/skills/project-update/render_instructions.py +1 -1
- package/skills/project-update/update.py +569 -106
- package/skills/rr-build-all/SKILL.md +2 -2
- package/skills/rr-plan/SKILL.md +3 -1
- package/skills/rr-review/SKILL.md +13 -8
- package/skills/rr-setup/SKILL.md +24 -4
- package/skills/rr-setup/setup.py +7 -1
- package/skills/rr-spec/SKILL.md +4 -2
- package/skills/rr-status/status.py +36 -9
- package/skills/usage-report/usage-report.py +4 -2
- package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
- package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
|
@@ -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
|