riskrails 4.0.1 → 4.1.0

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.
@@ -1,19 +1,27 @@
1
1
  """dialog_cmd: the consent and decision commands -- decision open/show/settle/fallback, the pick applied, and the read-only consent view.
2
2
 
3
- core: SETTLE_EFFECT
4
- shell: cmd_decision, decision_open, decision_settle, apply_choice, cmd_consent
3
+ core: SETTLE_EFFECT, SETUP_VALUES, SETUP_VALUE_MAX, SETUP_SPEC_MAX, setup_option_why, setup_decision_why, setup_spec_why
4
+ shell: cmd_decision, decision_open, read_setup_spec, setup_command_why, check_setup_labels, setup_open, decision_settle, apply_choice, cmd_consent
5
5
  """
6
6
  # pylint: disable=unused-argument # uniform cli dispatch: cmd_consent takes rt like every command
7
7
  import argparse
8
8
  import json
9
9
  import os
10
+ import shlex
10
11
  import sys
11
12
 
12
13
  from rr_state import dialog, fs, labels, profile, questions_cmd, task
13
14
  from rr_state import ledger as rr_ledger
14
15
  from rr_state import runtime as rr_runtime
15
16
 
16
- SETTLE_EFFECT = {"defer": "defer", "keep": "ack", "ack": "ack", "answer": "answered"}
17
+ SETTLE_EFFECT = {"defer": "defer", "keep": "ack", "ack": "ack", "answer": "answered", "set": "set"}
18
+ # What `decision open --kind setup` accepts (R11): the questions /rr-setup asks, each with the words a choice may set
19
+ # (what the skill applies is what the human read: one of these words, or, for verify only, a command shown verbatim
20
+ # in its own label), the longest value, and the most characters of a spec it reads.
21
+ SETUP_VALUES = {"memory": ("off", "keep"), "runtime": ("auto", "claude", "codex", "both"),
22
+ "survey": ("light", "full", "keep"), "verify": ("", "other", "keep", "edit")}
23
+ SETUP_VALUE_MAX = 200
24
+ SETUP_SPEC_MAX = 262144
17
25
 
18
26
 
19
27
  def cmd_decision(args, root, rt=rr_runtime.Runtime()):
@@ -53,10 +61,16 @@ def cmd_decision(args, root, rt=rr_runtime.Runtime()):
53
61
 
54
62
 
55
63
  def decision_open(args, root, rt=rr_runtime.Runtime()):
56
- """The one opener the model may call: commit and push, nothing else."""
64
+ """The openers the model may call: --kind commit (git commit and git push) and --kind setup (the /rr-setup
65
+ questions: choices that set a value, and this project's `memory enable`); nothing else."""
66
+ if args.kind == "setup":
67
+ setup_open(args, root, rt=rt)
68
+ return
57
69
  if args.kind != "commit":
58
- fs.die("decision open takes --kind commit; every other decision is opened by the command that "
59
- "needs it (close, ask, the budgets, start --mode)")
70
+ fs.die("decision open takes --kind commit or --kind setup; every other decision is opened by the "
71
+ "command that needs it (close, ask, the budgets, start --mode)")
72
+ if args.spec is not None:
73
+ fs.die("decision open --kind commit takes --command, not --spec", 2)
60
74
  if not args.commands:
61
75
  fs.die("decision open --kind commit needs --command \"git commit …\" [--command \"git push <remote> <branch>\"]")
62
76
  for command in args.commands:
@@ -83,6 +97,165 @@ def decision_open(args, root, rt=rr_runtime.Runtime()):
83
97
  dialog.print_dialog([decision], rt=rt)
84
98
 
85
99
 
100
+ def read_setup_spec(path):
101
+ """--spec FILE|- as JSON: a spec that cannot be read, is longer than SETUP_SPEC_MAX characters, is not JSON or
102
+ nests deeper than the dialog's own cap is a bad spec (exit 2)."""
103
+ try:
104
+ if path == "-":
105
+ text = sys.stdin.read(SETUP_SPEC_MAX + 1)
106
+ else:
107
+ with open(path, encoding="utf-8") as fh:
108
+ text = fh.read(SETUP_SPEC_MAX + 1)
109
+ if len(text) > SETUP_SPEC_MAX:
110
+ fs.die("bad spec — more than %d characters" % SETUP_SPEC_MAX, 2)
111
+ spec = dialog.json_loads_capped(text)
112
+ # A lone surrogate ("\ud800") parses, yet no file can hold it: refused here, not after a first decision is written.
113
+ json.dumps(spec, ensure_ascii=False).encode("utf-8")
114
+ return spec
115
+ except (OSError, ValueError, RecursionError) as exc:
116
+ fs.die("bad spec — %s" % exc, 2)
117
+
118
+
119
+ def setup_option_why(option, key):
120
+ """Why one option of the decision `key` is not {kind: choice, label, value[, description]} or {kind: command,
121
+ label[, description]}, else None. A choice sets one of its key's words (SETUP_VALUES); a verify choice may also
122
+ set a command, and check_setup_labels (exit 6) makes its label show it, like every rule of the labels."""
123
+ if not isinstance(option, dict) or option.get("kind") not in ("choice", "command"):
124
+ return "an option is an object whose kind is choice or command"
125
+ need = {"kind", "label", "value"} if option["kind"] == "choice" else {"kind", "label"}
126
+ if not need <= set(option) <= need | {"description"}:
127
+ return "a %s option holds %s, and a description if it has one" % (option["kind"], ", ".join(sorted(need)))
128
+ text, value = option.get("description", ""), option.get("value", "")
129
+ if not isinstance(option["label"], str) or not isinstance(text, str) or len(text) > dialog.QUESTION_MAX:
130
+ return "a label and a description are text, a description at most %d characters" % dialog.QUESTION_MAX
131
+ if not isinstance(value, str) or len(value) > SETUP_VALUE_MAX or not value.isprintable():
132
+ return "a choice value is at most %d printable characters (empty is allowed)" % SETUP_VALUE_MAX
133
+ if option["kind"] == "choice" and key != "verify" and value not in SETUP_VALUES[key]:
134
+ return "a %s choice sets one of: %s" % (key, ", ".join(SETUP_VALUES[key]))
135
+ return None
136
+
137
+
138
+ def setup_decision_why(item):
139
+ """Why one decision of a spec is not {key, header, question, options}, else None."""
140
+ if not isinstance(item, dict) or set(item) != {"key", "header", "question", "options"}:
141
+ return "a decision is an object holding exactly key, header, question and options"
142
+ if not isinstance(item["key"], str) or item["key"] not in SETUP_VALUES:
143
+ return "a decision key is one of %s" % ", ".join(SETUP_VALUES)
144
+ if not all(isinstance(item[name], str) and item[name].strip() for name in ("header", "question")):
145
+ return "a header and a question are text and not empty"
146
+ if not isinstance(item["options"], list) or not 2 <= len(item["options"]) <= 4:
147
+ return "a decision has 2 to 4 options"
148
+ for number, option in enumerate(item["options"], 1):
149
+ why = setup_option_why(option, item["key"])
150
+ if why:
151
+ return "option %d: %s" % (number, why)
152
+ return None
153
+
154
+
155
+ def setup_spec_why(spec):
156
+ """Why a parsed spec is not {"schema": 1, "decisions": [...]} with 1 to DECISIONS_MAX decisions, each key
157
+ once, else None (R11)."""
158
+ if not isinstance(spec, dict) or set(spec) != {"schema", "decisions"}:
159
+ return "a spec is an object holding exactly schema and decisions"
160
+ schema, decisions = spec["schema"], spec["decisions"]
161
+ if not isinstance(schema, int) or isinstance(schema, bool) or schema != 1:
162
+ return "schema is 1"
163
+ if not isinstance(decisions, list) or not 1 <= len(decisions) <= dialog.DECISIONS_MAX:
164
+ return "decisions is a list of 1 to %d decisions" % dialog.DECISIONS_MAX
165
+ for number, item in enumerate(decisions, 1):
166
+ why = setup_decision_why(item)
167
+ if why:
168
+ return "decision %d: %s" % (number, why)
169
+ keys = [item["key"] for item in decisions]
170
+ return None if len(set(keys)) == len(keys) else "a decision key is used once"
171
+
172
+
173
+ def setup_command_why(root, label):
174
+ """Why `label` is not exactly `python3 <this state.py> --root <this project> memory enable [options]`, else
175
+ None. validate_label alone also passes another --root, a --runtime, interpreter flags, approve and git commit
176
+ (R11, B1); the paths must be absolute, as the labels state_label writes."""
177
+ why = labels.validate_label(label)
178
+ if why:
179
+ return why
180
+ words = shlex.split(label)
181
+ if len(words) >= 6 and words[0] == "python3" and words[2] == "--root" and words[4:6] == ["memory", "enable"] \
182
+ and os.path.isabs(words[1]) and os.path.realpath(words[1]) == os.path.realpath(fs.ENTRY) \
183
+ and os.path.isabs(words[3]) and os.path.realpath(words[3]) == os.path.realpath(root):
184
+ return None
185
+ return ("a setup command is exactly python3 <this state.py> --root <this project> memory enable "
186
+ "[--author S] [--period P] [--index none|sqlite]")
187
+
188
+
189
+ def check_setup_labels(root, decisions):
190
+ """Every label of the spec, before anything is written (exit 6): only the memory decision offers a command, and
191
+ that is setup_command_why's; a choice never reads as a command, because the gate passes a Bash command equal
192
+ to any pick and dialog_pick runs a pick that is a state.py label; and a verify choice that sets a command
193
+ shows it, whole, as one `span` of its label, because the skill applies the value and the human reads the
194
+ label (R11, B1)."""
195
+ for item in decisions:
196
+ options = item["options"]
197
+ if len({option["label"] for option in options}) != len(options):
198
+ fs.die("CONSENT_LABEL_INVALID — two options of the %s decision carry the same label" % item["key"], 6)
199
+ for option in options:
200
+ label = option["label"]
201
+ if option["kind"] == "command":
202
+ why = setup_command_why(root, label) if item["key"] == "memory" else \
203
+ "only the memory decision offers a command"
204
+ elif not 0 < len(label) <= labels.LABEL_MAX or not label.isprintable():
205
+ why = "a choice label is 1 to %d printable characters" % labels.LABEL_MAX
206
+ elif label.startswith(("python3", "git", "npx")) or labels.label_argv(label) \
207
+ or labels.validate_label(label) is None:
208
+ why = "a choice label never reads as a command"
209
+ elif item["key"] == "verify" and option["value"] not in SETUP_VALUES["verify"] \
210
+ and option["value"] not in label.split("`")[1:-1:2]:
211
+ why = "a verify command is shown verbatim in its label, inside backticks"
212
+ else:
213
+ why = None
214
+ if why:
215
+ fs.die("CONSENT_LABEL_INVALID — %s: %s" % (why, label[:120]), 6)
216
+
217
+
218
+ def setup_open(args, root, rt=rr_runtime.Runtime()):
219
+ """decision open --kind setup --spec FILE|-: every check runs for every decision of the spec before the first
220
+ write, then all are opened in the spec's order, or none (R11). The caller sends the one dialog."""
221
+ if args.commands or args.spec is None:
222
+ fs.die("decision open --kind setup takes --spec FILE|- and no --command", 2)
223
+ spec = read_setup_spec(args.spec)
224
+ why = setup_spec_why(spec)
225
+ if why:
226
+ fs.die("bad spec — %s" % why, 2)
227
+ decisions = spec["decisions"]
228
+ check_setup_labels(root, decisions)
229
+ if not dialog.dialog_on(root, runtime=rt.name):
230
+ fs.die("the dialog is off here (.ai/policies/dialog.json, the runtime's dialog tool, a session id): "
231
+ "suggest the commands; the human runs them outside the agent")
232
+ data = rr_ledger.read_decisions(root)
233
+ questions = [item["question"].strip()[:dialog.QUESTION_MAX] for item in decisions] # as open_decision stores them
234
+ if len(set(questions)) != len(questions):
235
+ fs.die("DECISION_DUPLICATE — two decisions of the spec carry one question text", 6)
236
+ twin = next((d for d in data["open"] if d.get("question") in questions), None)
237
+ if twin:
238
+ fs.die("DECISION_DUPLICATE — %s is open with this very question; settle it, or 'state.py decision fallback "
239
+ "--id %s --why …', before opening it again" % (twin.get("id"), twin.get("id")), 6)
240
+ open_questions = sum(len(dialog.dialog_questions(d)) for d in data["open"])
241
+ if open_questions + len(decisions) > dialog.DECISIONS_MAX:
242
+ fs.die("DECISION_LIMIT — %d questions are open and the spec adds %d; one dialog call carries %d"
243
+ % (open_questions, len(decisions), dialog.DECISIONS_MAX), 6)
244
+ state = task.load(root, required=False, claim=False, rt=rt)
245
+ state = state if state and task.is_v7(state) else None
246
+ opened = []
247
+ for item in decisions:
248
+ options = [dict(o, effect="set") if o["kind"] == "choice" else o for o in item["options"]]
249
+ decision, why = dialog.open_decision(root, state, "setup", item["header"], item["question"], options,
250
+ "decision open --kind setup", rt=rt, setup_key=item["key"])
251
+ if decision is None:
252
+ for done in opened: # a race since the checks above: none of this call stays open
253
+ dialog.settle_decision(root, done["id"], "withdrawn")
254
+ fs.die(why, 6)
255
+ opened.append(decision)
256
+ dialog.print_dialog(opened, rt=rt)
257
+
258
+
86
259
  def decision_settle(args, root, decision, rt=rr_runtime.Runtime()):
87
260
  """(b) apply what the recorder wrote — a choice pick or a decline; (c) a
88
261
  human in a terminal picks an option by number (R36.14)."""
@@ -112,6 +285,8 @@ def decision_settle(args, root, decision, rt=rr_runtime.Runtime()):
112
285
  dialog.settle_decision(root, decision["id"], "declined")
113
286
  print("%s settled: declined in the dialog (%s); nothing runs"
114
287
  % (decision["id"], mine[-1].get("why") or "no answer"))
288
+ if decision.get("kind") == "setup":
289
+ print("SETUP_PICK %s=" % decision.get("setup_key"))
115
290
  return
116
291
  live = [p for p in rr_ledger.live_picks(ledger, (dialog.dialog_on(root, runtime=rt.name) or {}).get("session"))
117
292
  if p.get("decision") == decision["id"]]
@@ -132,6 +307,8 @@ def apply_choice(root, decision, option, pick_id, by, via, rt=rr_runtime.Runtime
132
307
  root, rt=rt)
133
308
  dialog.settle_decision(root, decision["id"], effect, pick_id)
134
309
  print("%s settled: %s (%s)" % (decision["id"], option["label"], effect))
310
+ if decision.get("kind") == "setup":
311
+ print("SETUP_PICK %s=%s" % (decision.get("setup_key"), option.get("value", "")))
135
312
 
136
313
 
137
314
  def cmd_consent(args, root, rt=rr_runtime.Runtime()):
@@ -43,12 +43,23 @@ def subcommand(argv):
43
43
 
44
44
 
45
45
  def state_label_ok(argv):
46
- """approve | reject | profile set | verify --by | review --by | start --mode … --by."""
46
+ """approve | reject | profile set | verify --by | review --by | start --mode … --by |
47
+ memory enable [--author S] [--period month|quarter|year] [--index none|sqlite]: each option at most
48
+ once, as two words, in any order, and no other memory verb. hooks/guard/dialog.py keeps the same
49
+ rule; tests/test_dialog_contract.py pins the two equal."""
47
50
  rest = subcommand(argv)
48
51
  if rest[:1] in (["approve"], ["reject"]) or rest[:2] == ["profile", "set"]:
49
52
  return True
50
53
  if rest[:1] in (["verify"], ["review"]):
51
54
  return "--by" in rest
55
+ if rest[:2] == ["memory", "enable"]:
56
+ # The pick is the exact command the human read: no `--opt=value`, no abbreviation argparse would
57
+ # take, no positional, an option once, a value of its own kind (an author never starts with '-').
58
+ names, values = rest[2::2], rest[3::2]
59
+ kinds = {"--author": None, "--period": ("month", "quarter", "year"), "--index": ("none", "sqlite")}
60
+ return (len(names) == len(values) == len(set(names)) and set(names) <= set(kinds)
61
+ and all((v[:1] not in ("", "-")) if kinds[n] is None else v in kinds[n]
62
+ for n, v in zip(names, values)))
52
63
  return rest[:1] == ["start"] and "--mode" in rest and "--by" in rest
53
64
 
54
65
 
@@ -78,7 +89,8 @@ def validate_label(label):
78
89
  and state_label_ok(script[1]):
79
90
  return None
80
91
  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")
92
+ "profile set|verify --by|review --by|start --mode --by|memory enable [--author S] "
93
+ "[--period P] [--index none|sqlite], or npx riskrails@<v> … --yes")
82
94
 
83
95
 
84
96
  def human_name(root):
@@ -39,9 +39,10 @@ def memory_config(sync, root):
39
39
  return None
40
40
 
41
41
 
42
- def memory_human(root, what, rt=rr_runtime.Runtime()):
43
- """R16: enable, disable, cleanup, pack, redact and move-logs take the human's route, as approve does."""
44
- via, why = rr_dialog.human_route(root, rt=rt)
42
+ def memory_human(root, what, allow_dialog=False, rt=rr_runtime.Runtime()):
43
+ """R16: enable, disable, cleanup, pack, redact and move-logs take the human's route, as approve does. Only
44
+ enable passes allow_dialog: a live pick of this session that is this very command is the human's answer."""
45
+ via, why = rr_dialog.human_route(root, allow_dialog=allow_dialog, rt=rt)
45
46
  if via is None:
46
47
  fs.die("APPROVAL_REFUSED — 'memory %s' is the human's, from their own terminal:\n python3 %s --root %s "
47
48
  "%s\nRefused because %s." % (what, shlex.quote(fs.ENTRY), shlex.quote(root),
@@ -98,7 +99,9 @@ def memory_status(args, root, sync, _store, rt=rr_runtime.Runtime()):
98
99
 
99
100
 
100
101
  def memory_enable(args, root, sync, _store, rt=rr_runtime.Runtime()):
101
- memory_human(root, "enable", rt=rt)
102
+ if memory_human(root, "enable", allow_dialog=True, rt=rt) == "dialog":
103
+ pick = rr_dialog.pick_data(rr_dialog.DIALOG_PICK[0])
104
+ print("memory: via dialog (consent %s, decision %s)" % (pick["consent"], pick["decision"]))
102
105
  hook = sync.enable(root, args.author, args.period, args.index)
103
106
  print("memory: packages mode on (%s); every teammate's next close exports" % sync.POLICY_REL)
104
107
  print("pre-push: %s" % hook)
@@ -0,0 +1,85 @@
1
+ # Safety policy
2
+
3
+ This is the file every agent reads first. It is short on purpose. It binds every
4
+ agent in every runtime — Claude Code and Codex alike.
5
+
6
+ ## The one rule
7
+
8
+ **Production behaviour is the source of truth until proven otherwise.** Code that
9
+ looks wrong, dead, or badly written may be load-bearing. The repository's history
10
+ is evidence; your intuition about how the code "should" look is not.
11
+
12
+ ## What an agent must never do
13
+
14
+ - Change application code that the current task did not ask about.
15
+ - Rename, move, reorganise or "clean up" anything as a side effect.
16
+ - Remove code because it looks unused. Find the callers first; if there are none,
17
+ say so in the report and leave the code alone.
18
+ - Combine a refactoring with a feature change in the same step.
19
+ - Modify database schemas, dependencies or framework versions outside a task that
20
+ is explicitly about that.
21
+ - Weaken a test, a linter rule or a static-analysis level to make a check pass.
22
+ - Commit, push, merge or deploy on its own initiative.
23
+ - Read production secrets or customer data into the model context.
24
+
25
+ If you find a problem outside the current task: **write it down, do not fix it.**
26
+ Unrelated findings are recorded with `state.py memory risk add` (in files mode it
27
+ writes `.ai/project/known-risks.md`) or go into the report's "observations"
28
+ section, and the human decides whether they become their own task.
29
+
30
+ The compact record is the human's to switch: `state.py memory enable`, `disable`,
31
+ `cleanup`, `pack`, `redact` and `migrate --move-logs` take the human route, as
32
+ `approve` does. `enable` may also run as the command the human picked in the
33
+ `/rr-setup` dialog; the rest have no dialog route. `.ai/memory/` and
34
+ `.ai/local/memory/` are written by `state.py` alone — an agent never edits them
35
+ (`state.py memory --help`; the plugin's `docs/local-memory.md`).
36
+
37
+ ## Scope
38
+
39
+ Every implementation step names the files it may touch. Editing anything else is
40
+ refused by `ai-scope-guard` and must be answered with `SCOPE_CHANGE_REQUIRED`:
41
+ stop, say which file you need and why, and let the plan be amended. A step whose
42
+ scope keeps growing is a step that was planned wrong.
43
+
44
+ `ai-git-guard`, `ai-path-guard` and `ai-scope-guard` all run in both runtimes. A
45
+ multi-file edit in one call — Codex's `apply_patch` — is checked path by path, so
46
+ a single out-of-scope or protected file rejects the whole patch. What a guard
47
+ refuses is not a hint: do not reword the command to get past it.
48
+
49
+ A guard is a backstop, not the policy. Some checks exist in only one runtime —
50
+ Codex has no hookable read tool, so the large-read cap is Claude-only there — and
51
+ an agent that behaves correctly only because a hook is watching is not behaving
52
+ correctly. The rules above hold whether or not a hook enforces them.
53
+
54
+ **The guards' threat model.** The guards defend against an agent's mistakes — a
55
+ careless `rm`, a write to a frozen policy during a task, a commit with a
56
+ secret — not against an agent that constructs a command to exhaust them. A
57
+ command built to time the guard out is visible in the transcript and in the
58
+ journal, and the human gate in full and full_irreversible mode is the boundary
59
+ for anything that matters.
60
+ Approval rests on a guard plus a ledger: a command the agent constructs to pass
61
+ for the human's pick is outside the threat model, and `state.py consent audit`
62
+ detects it afterwards.
63
+
64
+ ## Evidence labels
65
+
66
+ Anything an agent writes into `.ai/project/` carries one of four labels:
67
+
68
+ - **KNOWN FACT** — read from the code, a config file, a migration or a test.
69
+ Cite `file:line`.
70
+ - **INFERENCE** — a conclusion drawn from facts. Say what it is drawn from.
71
+ - **UNKNOWN** — something that matters and could not be determined. Say what
72
+ would answer it.
73
+ - **RISK** — something that looks dangerous. Say what could go wrong.
74
+
75
+ Never present an inference as a fact. When documentation and code disagree,
76
+ record both under "Documented behaviour" and "Observed behaviour", with the
77
+ evidence for each, and let the human decide which is the bug.
78
+
79
+ ## When in doubt
80
+
81
+ Stop and ask. An agent that pauses costs a message; an agent that guesses on a
82
+ payment path costs an incident.
83
+
84
+ Doubt on reversibility or domain is a blocker; other doubt is written to
85
+ `task.md` and the work continues.
@@ -171,7 +171,8 @@
171
171
  "ae9a22907ba9acbb7e80fac1ba4e3d1ff09e5f1df5c4456afb9099d7090fcaa6",
172
172
  "fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96",
173
173
  "e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7",
174
- "ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4"
174
+ "ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4",
175
+ "8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1"
175
176
  ],
176
177
  "ai-init/.ai/policies/security.md": [
177
178
  "cc808d5b940109f1703abfb3ca0334a3fcfd34267feefb2323627d0decc8b89a",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: rr-setup
3
- description: Set up and maintain the .ai/ layer of an existing production codebase. With no flag, scaffold docs/sdlc/, the runtime directory and .ai/, survey the code into .ai/project/ (light or full), write the Verification commands, the weight table's project entries and the constitution — read-only towards application code. --update regenerates a project's files from the installed plugin (a copy that is a shipped version is replaced, an edited one waits for the human, the project's own files are kept); run it right after installing a new riskrails; --audit scores the repository against the twelve plays and writes docs/ai-sdlc-adoption-plan.md. Use on "/rr-setup", "set up this project", "analyse this project for AI work", "update the rules", "обнови правилата", "audit our AI setup", or when /rr-status says the project is behind.
4
- argument-hint: [--survey light|full] | --update [--apply] | --audit [context]
3
+ description: Set up and maintain the .ai/ layer of an existing production codebase. With no flag, scaffold docs/sdlc/, the runtime directory and .ai/, ask the open options (memory, runtimes, survey depth, verify command) in one dialog, survey the code into .ai/project/ (light or full), write the Verification commands, the weight table's project entries and the constitution — read-only towards application code. --options asks them all again; --update regenerates a project's files from the installed plugin (a copy that is a shipped version is replaced, an edited one waits for the human, the project's own files are kept); run it right after installing a new riskrails; --audit scores the repository against the twelve plays and writes docs/ai-sdlc-adoption-plan.md. Use on "/rr-setup", "set up this project", "analyse this project for AI work", "update the rules", "обнови правилата", "audit our AI setup", or when /rr-status says the project is behind.
4
+ argument-hint: [--survey light|full] | --options | --update [--apply] | --audit [context]
5
5
  ---
6
6
 
7
7
  # /rr-setup $ARGUMENTS
@@ -17,29 +17,74 @@ UPDATE="python3 $AI_HOME/skills/project-update/update.py $PWD"
17
17
  SETUP="python3 $AI_HOME/skills/rr-setup/setup.py"
18
18
  ```
19
19
 
20
- `--update` and `--audit` go to their sections below; anything else
20
+ `--options`, `--update` and `--audit` go to their sections below; anything else
21
21
  is **Setup**. In every mode: no application code changes — not a rename, not a
22
22
  formatting fix. Problems you find are written down, never fixed. Never read
23
23
  `.env`, secrets, dumps or production logs. Do not commit; suggest the `git add`.
24
24
 
25
25
  ## Setup
26
26
 
27
+ 0. **Options.** Project memory, the runtimes, the survey depth and `verify_command`
28
+ are asked once, in one dialog, before any work; the picks feed steps 1, 3 and 5.
29
+
30
+ ```bash
31
+ "$AI_HOME/skills/ai-init/scaffold-ai.sh" "$PWD" --runtime none # 0a
32
+ $SETUP options --open # 0b; --options adds --all, a given --survey adds --skip survey
33
+ ```
34
+
35
+ **0a** makes `.ai/` (so `.ai/policies/dialog.json`) and its `.gitignore` lines, with no
36
+ instruction file and no runtime directory; only this script takes `none`
37
+ (`project-scaffold.sh` refuses it). If its output does **not** list
38
+ `.ai/project/overview.md`, the project was set up before.
39
+
40
+ **0b** prints one of three first lines:
41
+ - `OPTIONS_DIALOG D<n> …`, then state.py's `DIALOG_PENDING` line and a JSON: send that
42
+ JSON exactly as printed with the dialog tool the line names (AskUserQuestion; Codex:
43
+ request_user_input).
44
+ - `OPTIONS_FALLBACK <why>`: nothing was opened. Ask the numbered questions after it in
45
+ chat as printed, the answers being the picks; show its `memory is the human's` line
46
+ as printed and never run it.
47
+ - `OPTIONS_NONE`: nothing to ask.
48
+
49
+ Exit 6 (no first line, the reason on stderr): `DECISION_LIMIT` or `DECISION_DUPLICATE`
50
+ means a decision is already open — settle every one first (`$STATE decision show` and
51
+ send it, or `$STATE decision fallback --id <Dn> --why "<why>"`), then run 0b once more.
52
+ Any other refusal, or a second one: ask the questions of steps 1, 3 and 5 in prose and,
53
+ in a git work tree, give the human for their own terminal, with `$AI_HOME` and `$PWD`
54
+ substituted: `python3 "<home>/skills/ai-task/state.py" --root "<project>" memory enable
55
+ --period quarter [--index sqlite]`.
56
+
57
+ **0c** — the human answers. After a dialog, a memory command they picked is run first,
58
+ at once, verbatim and alone; it prints `memory: via dialog (consent C<n>, decision D<n>)`
59
+ and its pick settles its decision. Then `$STATE decision settle --id <Dn>` for each other
60
+ decision: it prints `D<n> settled: <label> (set)` and `SETUP_PICK <key>=<value>`, after
61
+ a decline `SETUP_PICK <key>=`. Apply each pick where its step needs it:
62
+ - `runtime=<v>` → step 1's two scaffolds with `--runtime <v>`;
63
+ - `verify=<cmd>` → `$SETUP verify --set "<cmd>"` after step 2; `keep` and `edit` → nothing;
64
+ - `survey=light` or `full` → step 3's depth; `keep` → no new survey;
65
+ - `memory=off` or `keep` → nothing: a picked command already ran.
66
+
67
+ An empty value (`verify=` is "Not now"; any key after a decline) or `other` → the step
68
+ as it was without the dialog: step 1 passes `auto`, step 3 surveys `light`, step 5 asks
69
+ in chat.
27
70
  1. **Scaffold.** Both scripts are idempotent, never overwrite, and print what
28
- they created; note it. They take `--runtime auto|claude|codex|both` (`auto`
29
- follows `CLAUDE.md`/`.claude/` and `AGENTS.md`/`.codex/`); pass `both` when
30
- the repository is worked on from both runtimes.
71
+ they created; note it, 0a's too. They take `--runtime auto|claude|codex|both`
72
+ (`auto` follows `CLAUDE.md`/`.claude/` and `AGENTS.md`/`.codex/`); pass the
73
+ `runtime` pick, else `auto` — `both` when the repository is worked on from
74
+ both runtimes.
31
75
 
32
76
  ```bash
33
- "$AI_HOME/hooks/project-scaffold.sh" "$PWD" # docs/sdlc/, the runtime dir, .gitignore
34
- "$AI_HOME/skills/ai-init/scaffold-ai.sh" "$PWD" # .ai/
77
+ "$AI_HOME/hooks/project-scaffold.sh" "$PWD" --runtime <pick> # docs/sdlc/, the runtime dir, .gitignore
78
+ "$AI_HOME/skills/ai-init/scaffold-ai.sh" "$PWD" --runtime <pick> # the instruction file; .ai/ is 0a's
35
79
  ```
36
80
 
37
- If `.ai/project/overview.md` was **not** created, the project was set up
81
+ If 0a did **not** create `.ai/project/overview.md`, the project was set up
38
82
  before: run **--update** below, then ask whether the survey should be
39
83
  refreshed; a refresh never overwrites a `.ai/project/` file a human edited
40
- without saying so first. If only an instruction file was created (no `.ai/`
41
- wanted), fill its Commands / Conventions / Architecture from the manifests —
42
- one line per command, five sentences of architecture — and stop.
84
+ without saying so first. If 0a and this step together created only an
85
+ instruction file (no `.ai/` wanted), fill its Commands / Conventions /
86
+ Architecture from the manifests — one line per command, five sentences of
87
+ architecture — and stop.
43
88
  2. **Stack.** `$SETUP survey` first: the project's own toolbox
44
89
  (`.claude/{skills,commands,rules,agents,hooks}`, `.codex/*`, `bin/*.sh`,
45
90
  Makefile targets) with each item's purpose, the guards a project hook
@@ -51,13 +96,15 @@ formatting fix. Problems you find are written down, never fixed. Never read
51
96
  (`phpunit.xml*`, `phpstan.neon*`, `playwright.config.*` …). For Symfony or
52
97
  Sylius also: bundles, entities, state machines, Messenger, event
53
98
  subscribers, payment, shipping, tax, promotions, ERP or fiscal integrations.
54
- 3. **Survey.** `--survey light` is the default, for a developer who knows the
55
- project: ask for a ten-minute brain dump (entry points, modules that matter,
56
- integrations, unwritten business rules, areas nobody dares touch), confirm
99
+ Then write the `verify=<cmd>` pick, if any (0c).
100
+ 3. **Survey.** The depth is the `survey` pick or `--survey light|full`. `light`,
101
+ the default, is for a developer who knows the project: ask for a ten-minute
102
+ brain dump (entry points, modules that matter, integrations,
103
+ unwritten business rules, areas nobody dares touch), confirm
57
104
  each claim with `grep -n`, then fan out exactly **two** `ai-discovery` agents
58
105
  in one message: (1) legacy, risks and code that looks unused; (2) tests,
59
106
  CI/CD, data and configuration — how secrets are supplied, never their
60
- values. `--survey full`, for a codebase new to you: one `Explore` for the
107
+ values. `full`, for a codebase new to you: one `Explore` for the
61
108
  inventory, then six `ai-discovery` agents in one message — entry points;
62
109
  domain modules; integrations and their failure handling; business rules and
63
110
  legacy; tests and CI/CD; data and configuration. Hand each the slice of the
@@ -68,14 +115,15 @@ formatting fix. Problems you find are written down, never fixed. Never read
68
115
  write `Documented behaviour / Observed behaviour / Evidence / Risk` and do
69
116
  not resolve it.
70
117
  5. **Policies.** Fill the project sections of `.ai/policies/coding.md` and
71
- `testing.md` only if step 1 created them. The **Verification** block of
118
+ `testing.md` only if 0a created them. The **Verification** block of
72
119
  `testing.md` is the feedback loop every task runs:
73
120
  - `verify_command` — the fast suite to the end, the e2e group **excluded**;
74
121
  - `step_test_command` — the same runner on given paths (`{files}`);
75
122
  - `e2e_command` — the end-to-end suite, or `none` when there is none;
76
123
  - two or three lines of what healthy output looks like.
77
124
 
78
- `verify_command` comes from the human: show the one `$SETUP survey`
125
+ `verify_command` comes from the human: the `verify=<cmd>` pick is their yes,
126
+ written at the end of step 2; without one, show the one `$SETUP survey`
79
127
  proposed, ask, and only after a yes in chat write it with
80
128
  `$SETUP verify --set "<command>"` (exit 3: one is already set — the human
81
129
  edits it). `$SETUP check` then exits 0; `VERIFY_COMMAND_MISSING` (exit 1)
@@ -109,7 +157,7 @@ formatting fix. Problems you find are written down, never fixed. Never read
109
157
  casually, the weight-table entries you chose, recommended next improvements —
110
158
  recommendations only, none implemented. When the survey found a toolbox,
111
159
  name each toolbox item the project keeps using; they stay where they are.
112
- 9. **Report.** Files created (a tree from the scaffold output), surprising
160
+ 9. **Report.** Files created (a tree from the scaffold output, 0a's too), surprising
113
161
  findings, the modes and what puts a change in each, the safety restrictions
114
162
  in force (hooks versus policy), the tooling the workflows reuse, assumptions,
115
163
  unresolved questions and who can answer them, the survey's superpowers
@@ -118,6 +166,14 @@ formatting fix. Problems you find are written down, never fixed. Never read
118
166
  `/rr-plan <it>`. Claim nothing was validated that was not run. Suggest
119
167
  `git add .ai .gitignore docs/sdlc` plus the instruction files created.
120
168
 
169
+ ## --options
170
+
171
+ Reopens every option on a set-up project: 0a, then `$SETUP options --open --all`, then
172
+ send, settle and apply as in step 0 — a `survey` pick runs steps 3–4 at that depth, as a
173
+ refresh (step 1), and a `verify` command is written at once. Say what changed and stop;
174
+ no other step reruns. A project 0a just scaffolded (it listed
175
+ `.ai/project/overview.md`) continues as the plain **Setup**.
176
+
121
177
  ## --update
122
178
 
123
179
  The script regenerates; you show it, confirm what is owed, and merge what it
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """Idempotent .ai/ scaffold for RiskRails.
3
- scaffold_ai.py [project-dir] [--runtime auto|claude|codex|both] (default: $PWD, auto)
3
+ scaffold_ai.py [project-dir] [--runtime auto|claude|codex|both|none] (default: $PWD, auto)
4
4
  skills/ai-init/scaffold-ai.sh execs it; the templates stay in skills/ai-init/.
5
5
 
6
6
  Never overwrites an existing file. Run it as often as you like: it creates what
@@ -14,6 +14,10 @@ that points at it differs — CLAUDE.md for Claude Code, AGENTS.md for Codex.
14
14
  A port of the shell script it replaced, kept to its bytes: the same files and
15
15
  modes, the same listing in the same order, the same messages and exit codes
16
16
  (tests/test_scaffold_differential.py runs the frozen shell beside it).
17
+
18
+ One value is new, and the shell refuses it: `--runtime none` (or AI_RUNTIMES=none) scaffolds .ai/ and its
19
+ .gitignore lines and no instruction file, so /rr-setup can open .ai/ before the project's runtimes are chosen.
20
+ Like `both` and `auto` it is a whole value: inside a list it is an unknown runtime, as in the shell.
17
21
  """
18
22
  import errno
19
23
  import json
@@ -143,7 +147,9 @@ def logical_cd(root):
143
147
 
144
148
 
145
149
  def runtimes(runtime, root):
146
- """The runtimes in scope: what the project declares (auto), or the list given."""
150
+ """The runtimes in scope: what the project declares (auto), the list given, or none at all."""
151
+ if runtime == "none":
152
+ return set()
147
153
  if runtime == "both":
148
154
  runtime = "claude,codex"
149
155
  if runtime == "auto":