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.
@@ -5,7 +5,7 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "A weight-based engineering pipeline for Claude Code and Codex: each change is weighed on six axes, runs light, guarded or full, edits only the files its plan names, and ends at human approval — enforced by hooks, reviewed adversarially, routed to the cheap model tier by default.",
8
- "version": "4.0.1"
8
+ "version": "4.1.0"
9
9
  },
10
10
  "plugins": [
11
11
  {
@@ -14,7 +14,7 @@
14
14
  "source": "url",
15
15
  "url": "https://github.com/vanssata/claude-agentic-sdlc-module.git"
16
16
  },
17
- "version": "4.0.1",
17
+ "version": "4.1.0",
18
18
  "strict": true
19
19
  }
20
20
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "riskrails",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "A weight-based engineering pipeline for Claude Code and Codex: each change is weighed on six axes, runs light, guarded or full, edits only the files its plan names, and ends at human approval — enforced by hooks, reviewed adversarially, routed to the cheap model tier by default.",
5
5
  "author": {
6
6
  "name": "Ivan Kakurov"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "riskrails",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "A weight-based engineering pipeline for Claude Code and Codex: each change is weighed on six axes, runs light, guarded or full, edits only the files its plan names, and ends at human approval — enforced by hooks, reviewed adversarially, routed to the cheap model tier by default.",
5
5
  "author": {
6
6
  "name": "Ivan Kakurov"
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # RiskRails
2
2
 
3
- [![Version](https://img.shields.io/badge/version-4.0.1-d2f878?labelColor=101210)](https://github.com/vanssata/claude-agentic-sdlc-module/releases/tag/v4.0.1)
3
+ [![Version](https://img.shields.io/badge/version-4.1.0-d2f878?labelColor=101210)](https://github.com/vanssata/claude-agentic-sdlc-module/releases/tag/v4.1.0)
4
4
  [![Runtimes](https://img.shields.io/badge/runtimes-Claude%20Code%20%7C%20Codex-d2f878?labelColor=101210)](docs/routing.md)
5
5
  [![Known risks](https://img.shields.io/badge/known%20risks-documented-d2f878?labelColor=101210)](docs/known-risks.md)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-d2f878?labelColor=101210)](LICENSE)
@@ -18,7 +18,7 @@ approval. Risky actions are stopped by a hook, not by a polite request in a
18
18
  prompt.
19
19
 
20
20
  ```bash
21
- npx riskrails@4.0.1 # detects Claude Code, Codex or both (Node 18+, Python 3.11+)
21
+ npx riskrails@4.1.0 # detects Claude Code, Codex or both (Node 18+, Python 3.11+)
22
22
  ```
23
23
 
24
24
  Then, in any project: `/rr-setup` once, and `/rr-build <what you want>` for
@@ -50,7 +50,7 @@ Tuned for **one developer who knows the codebase**, on a Claude Pro, Max or
50
50
  Team plan, or a ChatGPT Plus or Pro plan, following the
51
51
  [AI-native SDLC playbook](https://claude.com/blog/the-ai-native-sdlc-playbook).
52
52
 
53
- **Version 4.0.1.** The guards have limits: read [Known risks](docs/known-risks.md)
53
+ **Version 4.1.0.** The guards have limits: read [Known risks](docs/known-risks.md)
54
54
  before relying on them. What is decided but not built is in [ROADMAP.md](ROADMAP.md).
55
55
 
56
56
  ## Upgrade to 4.0
@@ -65,7 +65,7 @@ the plugin. Step by step, in this order:
65
65
  riskrails goes through the version that still converts it: install it
66
66
  (`npx riskrails@3.1.0`), run `/rr-setup --update` with it, then install 4.0.
67
67
  2. **Commit every project's `.ai/`** first; `git restore` is the rollback.
68
- 3. **Update the home** on every machine, for every runtime: `npx riskrails@4.0.1`
68
+ 3. **Update the home** on every machine, for every runtime: `npx riskrails@4.1.0`
69
69
  (the plugin route and the npm route are separate installs: reinstall both
70
70
  if you use both). Restart the runtime. In Codex, open `/hooks` and trust the
71
71
  changed entries.
@@ -84,16 +84,24 @@ are removed; use the `/rr-*` commands. Details are in
84
84
  ## Install
85
85
 
86
86
  ```bash
87
- npx riskrails@4.0.1 # detects Claude Code, Codex, or both; proposes the detected plan
88
- npx riskrails@4.0.1 --target codex # auto | claude | codex | both
89
- npx riskrails@4.0.1 --plan max --fable no # pro | team-pro | team-max | max | max20 | balanced-max
90
- npx riskrails@4.0.1 --codex-plan plus # plus | pro | balanced-max
91
- npx riskrails@4.0.1 --context economy # the plan's compaction window; the context guard also blocks
92
- npx riskrails@4.0.1 --statusline own # keep your own statusline command (from 3.2.1; default: the plugin's two-line statusline)
93
- npx riskrails@4.0.1 --dry-run # print what would be written, write nothing
87
+ npx riskrails@4.1.0 # detects Claude Code, Codex, or both; proposes the detected plan
88
+ npx riskrails@4.1.0 --target codex # auto | claude | codex | both
89
+ npx riskrails@4.1.0 --plan max --fable no # pro | team-pro | team-max | max | max20 | balanced-max
90
+ npx riskrails@4.1.0 --codex-plan plus # plus | pro | balanced-max
91
+ npx riskrails@4.1.0 --context economy # the plan's compaction window; the context guard also blocks
92
+ npx riskrails@4.1.0 --statusline own # keep your own statusline command (from 3.2.1; default: the plugin's two-line statusline)
93
+ npx riskrails@4.1.0 --dry-run # print what would be written, write nothing
94
94
  npx -y riskrails@latest --yes # update in place, keeping the recorded plan
95
95
  ```
96
96
 
97
+ On a terminal each question is a menu — runtime, plan, Fable, context,
98
+ statusline, Codex plan — with the proposal highlighted (the detected or
99
+ recorded runtime, plan, statusline and Codex plan; Fable `yes`, context `default`):
100
+ ↑/↓ (or k/j), Home/End or 1–9 to move, Enter to confirm, Esc, q or Ctrl-C to
101
+ stop with nothing written. Enter through every menu installs what detection
102
+ proposes. A flag skips its menu; `--yes` or no terminal asks nothing, and
103
+ `RISKRAILS_MENU=off` (or `TERM=dumb`) brings back the typed plan question.
104
+
97
105
  Pin the version: the hooks it installs see every command an agent runs. It
98
106
  installs the managed block, the plan's settings and limits, the five agents
99
107
  with their tiers baked in, the hooks, the skills, the shared tree at
@@ -165,7 +173,8 @@ until it passes. After three failed review passes the next verdict is yours.
165
173
  ### The record
166
174
 
167
175
  A closed task's who, what, how and why travel as a compact record: `state.py
168
- memory enable` (yours, in a terminal) switches a project to packages mode, in
176
+ memory enable` (yours, in a terminal, or as your pick in the `/rr-setup`
177
+ dialog) switches a project to packages mode, in
169
178
  which `close` appends the task's entries to a per-developer batch under
170
179
  `.ai/memory/` and a pre-push hook validates them; `state.py recall --files …`
171
180
  gives an agent what the record holds about the files it is about to touch,
@@ -234,7 +243,7 @@ python3 -I -m unittest discover -s tests -p 'test_*.py' # CI's serial oracl
234
243
  AI_E2E=1 python3 -I -m unittest discover -s tests -p test_end_to_end.py
235
244
  ```
236
245
 
237
- Fifty-eight suites, 1133 tests, about 75 seconds in parallel. The guards against JSON fixtures for both runtimes and a
246
+ Sixty suites, 1217 tests, about 75 seconds in parallel. The guards against JSON fixtures for both runtimes and a
238
247
  golden of 1 341 recorded decisions, byte for byte, with timed cases that hold
239
248
  the hot path under its budget; the state machine, the weight, the batch loop,
240
249
  the dialog contract and the documents rule; the compact record and its
package/bin/menu.js ADDED
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // The installer's arrow-key menu (spec interactive-setup-options R2, R7). Node 18+, stdlib only.
3
+ //
4
+ // bin/riskrails.js runs this file as a child, `spawnSync(process.execPath, [menu.js])` with
5
+ // stdio ['inherit', 'pipe', 'inherit'], and passes the menu in RISKRAILS_MENU_SPEC:
6
+ // {"title": "plan", "why": "detected from …", "initial": 0,
7
+ // "items": [{"value": "pro", "label": "pro", "note": "…"}, …]}
8
+ // The child owns the terminal while it runs: raw mode on stdin, the menu drawn on stderr.
9
+ // Enter erases the menu, prints `<title>: <value>` (and ` (<why>)` when the value is the
10
+ // initial one) on stderr, writes the value to stdout, exit 0
11
+ // Ctrl-C/Esc/q erases the menu, exit 130
12
+ // no terminal exit 3, nothing drawn: the parent falls back to its typed prompt
13
+ // Keys: ↑/k ↓/j (stop at the ends), Home/End, 1–9 jump to that row, Enter picks.
14
+ 'use strict';
15
+
16
+ // The key, as readline's keypress event gives it, as one of the reducer's tokens (or null).
17
+ function token(key) {
18
+ if (!key) return null;
19
+ if (key.ctrl && key.name === 'c') return 'abort';
20
+ if (key.ctrl || key.meta) return null;
21
+ const name = key.name || key.sequence;
22
+ if (name === 'up' || name === 'k') return 'up';
23
+ if (name === 'down' || name === 'j') return 'down';
24
+ if (name === 'home' || name === 'end') return name;
25
+ if (name === 'return' || name === 'enter') return 'enter';
26
+ if (name === 'escape' || name === 'q') return 'abort';
27
+ if (/^[1-9]$/.test(String(name))) return String(name);
28
+ return null;
29
+ }
30
+
31
+ // The pure step: {index, count, done} and a token → the next state. done is null, 'pick' or 'abort'.
32
+ function reduce(state, tok) {
33
+ const last = state.count - 1;
34
+ switch (tok) {
35
+ case 'up': return { ...state, index: Math.max(0, state.index - 1) };
36
+ case 'down': return { ...state, index: Math.min(last, state.index + 1) };
37
+ case 'home': return { ...state, index: 0 };
38
+ case 'end': return { ...state, index: last };
39
+ case 'enter': return { ...state, done: 'pick' };
40
+ case 'abort': return { ...state, done: 'abort' };
41
+ default: {
42
+ const n = /^[1-9]$/.test(String(tok)) ? Number(tok) : 0;
43
+ return n >= 1 && n <= state.count ? { ...state, index: n - 1 } : state;
44
+ }
45
+ }
46
+ }
47
+
48
+ // The menu's lines, each cut to the terminal's width: a wrapped line would break the redraw's count.
49
+ function lines(spec, index, columns) {
50
+ const fit = (t) => (columns > 1 && [...t].length > columns - 1 ? [...t].slice(0, columns - 2).join('') + '…' : t);
51
+ const head = `${spec.title}${spec.why ? ` (${spec.why})` : ''} — ↑/↓ to move, Enter to confirm, Esc to abort`;
52
+ const width = Math.max(...spec.items.map((it) => String(it.label).length));
53
+ return [fit(head), ...spec.items.map((it, i) => {
54
+ const mark = i === index ? '❯' : ' ';
55
+ const row = fit(`${mark} ${i + 1}. ${String(it.label).padEnd(width)}${it.note ? ` ${it.note}` : ''}`);
56
+ return i === index ? `\x1b[7m${row}\x1b[0m` : row;
57
+ })];
58
+ }
59
+
60
+ function main() {
61
+ let spec;
62
+ try { spec = JSON.parse(process.env.RISKRAILS_MENU_SPEC || ''); } catch (e) { process.exit(3); }
63
+ if (!spec || !Array.isArray(spec.items) || spec.items.length === 0) process.exit(3);
64
+ const stdin = process.stdin;
65
+ if (!stdin.isTTY || !process.stderr.isTTY) process.exit(3);
66
+ try { stdin.setRawMode(true); } catch (e) { process.exit(3); }
67
+
68
+ const write = (s) => process.stderr.write(s);
69
+ let drawn = 0;
70
+ const erase = () => { if (drawn) write(`\x1b[${drawn}A\r\x1b[J`); drawn = 0; };
71
+ const draw = (index) => {
72
+ erase();
73
+ const shown = lines(spec, index, process.stderr.columns || 0);
74
+ write(shown.join('\n') + '\n');
75
+ drawn = shown.length;
76
+ };
77
+ const restore = () => {
78
+ try { stdin.setRawMode(false); } catch (e) { /* already gone */ }
79
+ stdin.pause();
80
+ write('\x1b[?25h');
81
+ };
82
+ const finish = (code, value) => {
83
+ erase();
84
+ restore();
85
+ if (code === 0) {
86
+ const why = state.index === initial && spec.why ? ` (${spec.why})` : '';
87
+ write(`${spec.title}: ${value}${why}\n`);
88
+ process.stdout.write(value, () => process.exit(0));
89
+ } else {
90
+ process.exit(code);
91
+ }
92
+ };
93
+ for (const sig of ['SIGTERM', 'SIGHUP', 'SIGINT']) process.on(sig, () => finish(130));
94
+
95
+ const initial = Number.isInteger(spec.initial) && spec.initial >= 0 && spec.initial < spec.items.length
96
+ ? spec.initial : 0;
97
+ let state = { index: initial, count: spec.items.length, done: null };
98
+ // A lone Esc: emitKeypressEvents without a readline interface has no escape timeout and would
99
+ // wait for the rest of a sequence. An arrow key arrives as one chunk (ESC [ B), never as ESC alone.
100
+ stdin.on('data', (chunk) => { if (chunk.length === 1 && chunk[0] === 0x1b) finish(130); });
101
+ require('readline').emitKeypressEvents(stdin);
102
+ write('\x1b[?25l');
103
+ draw(state.index);
104
+ stdin.on('keypress', (_str, key) => {
105
+ const tok = token(key);
106
+ if (tok === null) return;
107
+ const next = reduce(state, tok);
108
+ if (next.done === 'pick') { state = next; finish(0, String(spec.items[state.index].value)); return; }
109
+ if (next.done === 'abort') { finish(130); return; }
110
+ if (next.index !== state.index) { state = next; draw(state.index); }
111
+ });
112
+ stdin.resume();
113
+ }
114
+
115
+ module.exports = { reduce, token, lines };
116
+ if (require.main === module) main();
package/bin/riskrails.js CHANGED
@@ -37,6 +37,12 @@
37
37
  // variables CLAUDE_DIR and CODEX_DIR mean the same). Naming exactly one of them under
38
38
  // --target auto installs that runtime alone.
39
39
  // --dry-run prints everything that would be written, per runtime, and writes nothing.
40
+ // On a terminal each choice no flag names is an arrow-key menu, in this order: runtime (only
41
+ // without --target, --home and --codex-home), plan, fable (max, max20, team-max),
42
+ // context, statusline, codex plan. The proposal above is highlighted and Enter
43
+ // confirms it (an undetected plan proposes pro); ↑/↓ or k/j, Home/End and 1-9 move;
44
+ // Esc, q or Ctrl-C stop with nothing written (exit 130). RISKRAILS_MENU=off or 0, or
45
+ // TERM=dumb: the typed plan question instead. --yes or no terminal: no question.
40
46
  //
41
47
  // This is the port of the install.sh that shipped up to 2.0.2: the same tree, the same
42
48
  // messages, the same backups (backups/<date>/<path> under the runtime's home). JSON merges
@@ -65,6 +71,8 @@ const HOME = env.HOME || os.homedir();
65
71
  let PLAN = '', FABLE = 'auto', DRY = false, TARGET = 'auto', PLAN_GIVEN = false;
66
72
  let CODEX_PLAN = '', CODEX_PLAN_GIVEN = false, YES = false;
67
73
  let CONTEXT = 'default', STATUSLINE_CONTEXT = 'yes', STATUSLINE = 'riskrails', STATUSLINE_GIVEN = false;
74
+ // A flag that names a choice skips its menu on a terminal (bin/menu.js).
75
+ let TARGET_GIVEN = false, FABLE_GIVEN = false, CONTEXT_GIVEN = false;
68
76
 
69
77
  function version() {
70
78
  try { return JSON.parse(fs.readFileSync(path.join(SRC, 'package.json'), 'utf8')).version || 'unknown'; }
@@ -90,11 +98,11 @@ function usage() {
90
98
  return v;
91
99
  };
92
100
  switch (a) {
93
- case '--target': TARGET = take(); break;
101
+ case '--target': TARGET = take(); TARGET_GIVEN = true; break;
94
102
  case '--plan': PLAN = take(); PLAN_GIVEN = true; break;
95
- case '--fable': FABLE = take(); PLAN_GIVEN = true; break;
103
+ case '--fable': FABLE = take(); PLAN_GIVEN = true; FABLE_GIVEN = true; break;
96
104
  case '--codex-plan': CODEX_PLAN = take(); CODEX_PLAN_GIVEN = true; break;
97
- case '--context': CONTEXT = take(); break;
105
+ case '--context': CONTEXT = take(); CONTEXT_GIVEN = true; break;
98
106
  case '--statusline-context': STATUSLINE_CONTEXT = take(); break;
99
107
  case '--statusline': STATUSLINE = take(); STATUSLINE_GIVEN = true; break;
100
108
  case '--home': CLAUDE_DIR = path.resolve(take()); CLAUDE_DIR_GIVEN = true; break;
@@ -260,6 +268,51 @@ function ask(prompt) {
260
268
  }
261
269
  const interactive = () => !YES && process.stdin.isTTY === true;
262
270
 
271
+ // A choice on a terminal, as an arrow-key menu: bin/menu.js runs as a child that owns the
272
+ // terminal (raw mode, the menu on stderr) and writes the picked value to stdout. null means
273
+ // no menu — not a terminal, RISKRAILS_MENU=off|0, TERM=dumb, or the child could not take the
274
+ // terminal (exit 3) — and the caller asks as before. An abort exits 130 here, before anything
275
+ // is written: every menu runs in claudeRender/codexRender, ahead of the apply steps.
276
+ const menuOff = () => !interactive() || ['off', '0'].includes(env.RISKRAILS_MENU) || env.TERM === 'dumb';
277
+ function choose(title, items, initial, why) {
278
+ if (menuOff() || initial < 0) return null;
279
+ const spec = JSON.stringify({ title, why: why || '', items, initial });
280
+ const r = spawnSync(process.execPath, [path.join(__dirname, 'menu.js')],
281
+ { stdio: ['inherit', 'pipe', 'inherit'], env: { ...env, RISKRAILS_MENU_SPEC: spec }, encoding: 'utf8' });
282
+ if (r.status === 0 && items.some((it) => it.value === r.stdout)) return r.stdout;
283
+ if (r.status === 130 || r.signal) {
284
+ if (r.signal) err(`riskrails: the menu was stopped by ${r.signal}; run \`stty sane\` if the terminal misbehaves`);
285
+ die('riskrails: aborted — nothing was written', 130);
286
+ }
287
+ return null;
288
+ }
289
+ const pick = (values, notes) => values.map((v, i) => ({ value: v, label: v, note: notes[i] || '' }));
290
+
291
+ // --context on a terminal, asked once: by the Claude branch, else before the Codex plan.
292
+ let CONTEXT_ASKED = false;
293
+ function askContext() {
294
+ if (CONTEXT_GIVEN || CONTEXT_ASKED) return;
295
+ CONTEXT_ASKED = true;
296
+ const c = choose('context', pick(['default', 'economy'], ["each runtime's own context window and compaction",
297
+ "the plan's autoCompactWindow; context-guard also holds a prompt back past 120% of it"]), 0);
298
+ if (c) CONTEXT = c;
299
+ }
300
+ // --statusline on a terminal: the mode riskrails/statusline.json records is the proposal, read as
301
+ // statuslineGate reads it. Only a different pick counts as given, so Enter keeps today's run.
302
+ function askStatusline() {
303
+ if (STATUSLINE_GIVEN) return;
304
+ let mode = '';
305
+ try {
306
+ const r = readJSON(path.join(CLAUDE_DIR, 'riskrails', 'statusline.json'));
307
+ if (isObj(r) && ['riskrails', 'own'].includes(r.mode)) mode = r.mode;
308
+ } catch (e) { /* none yet */ }
309
+ const MODES = ['riskrails', 'own'];
310
+ const v = choose('statusline', pick(MODES, ["the plugin's statusline over your own command (kept, --statusline own brings it back)",
311
+ 'your own statusline command, behind the quota check']), MODES.indexOf(mode || 'riskrails'),
312
+ mode ? 'recorded by the previous install' : '');
313
+ if (v && v !== (mode || 'riskrails')) { STATUSLINE = v; STATUSLINE_GIVEN = true; }
314
+ }
315
+
263
316
  // ------------------------------------------------------------------ runtime detection
264
317
  let DO_CLAUDE = false, DO_CODEX = false;
265
318
  const skippedRuntime = (rt, dir) => err(`${rt}: ${dir} found but no ${rt} on PATH — skipped (--target ${rt} for it alone, --target both for both)`);
@@ -287,7 +340,16 @@ Code or Codex first, or name the runtime yourself:
287
340
  npx riskrails --target both # both, whether or not they are detected`);
288
341
  process.exit(1);
289
342
  }
290
- if (TARGET === 'auto') out('detected runtime:' + (DO_CLAUDE ? ' claude' : '') + (DO_CODEX ? ' codex' : ''));
343
+ // On a terminal, with nothing naming the runtime, the detected set is the menu's first proposal.
344
+ let RUNTIME_PICKED = false;
345
+ if (TARGET === 'auto' && !TARGET_GIVEN && !CLAUDE_DIR_GIVEN && !CODEX_DIR_GIVEN) {
346
+ const RUNTIMES = ['claude', 'codex', 'both'];
347
+ const detected = DO_CLAUDE && DO_CODEX ? 'both' : DO_CLAUDE ? 'claude' : 'codex';
348
+ const rt = choose('runtime', pick(RUNTIMES, ['Claude Code', 'Codex', 'Claude Code and Codex']),
349
+ RUNTIMES.indexOf(detected), `detected: ${detected}`);
350
+ if (rt && rt !== detected) { DO_CLAUDE = rt !== 'codex'; DO_CODEX = rt !== 'claude'; RUNTIME_PICKED = true; }
351
+ }
352
+ if (TARGET === 'auto' && !RUNTIME_PICKED) out('detected runtime:' + (DO_CLAUDE ? ' claude' : '') + (DO_CODEX ? ' codex' : ''));
291
353
 
292
354
  const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'riskrails-'));
293
355
  process.on('exit', () => rmrf(TMP));
@@ -389,21 +451,37 @@ function detectPlan(runtime) {
389
451
  return {};
390
452
  }
391
453
 
454
+ // The plan question on a terminal: the plan the previous install recorded, else the detected
455
+ // one, is proposed and Enter confirms it; a Team seat, or a plan nothing detected, is typed.
456
+ // On a terminal with the menu on, the six plans are a menu instead: the proposal is the same,
457
+ // and without one it is team-pro for a Team org, else pro (Enter installs it rather than exiting).
458
+ const CLAUDE_PLANS = ['pro', 'team-pro', 'team-max', 'max', 'max20', 'balanced-max'];
459
+ const CLAUDE_PLAN_NOTES = ['Pro', 'Team Standard seat', 'Team Premium seat', 'Max',
460
+ "Max 20x: max's settings, larger budgets", 'Max account, only the session runs Opus 5.5 (200k window)'];
461
+ function pickClaudePlan(prev, detected, from, org) {
462
+ const proposed = prev || detected;
463
+ const first = proposed || (from === 'team' ? 'team-pro' : 'pro');
464
+ const menuWhy = prev ? 'recorded by the previous install' : detected ? `detected from ${from}`
465
+ : from === 'team' ? `Team org ${org}, seat unknown` : `could not detect the plan, organizationType='${org}'`;
466
+ const picked = choose('plan', pick(CLAUDE_PLANS, CLAUDE_PLAN_NOTES), CLAUDE_PLANS.indexOf(first), menuWhy);
467
+ if (picked !== null) return picked;
468
+ if (proposed) {
469
+ const why = prev ? 'recorded by the previous install' : `detected from ${from}`;
470
+ return ask(`plan: ${proposed} (${why}) — Enter to confirm, or type pro/team-pro/team-max/max/max20/balanced-max: `) || proposed;
471
+ } else if (from === 'team') {
472
+ return ask(`Team org detected (${org}) but not the seat. Enter plan [team-pro/team-max]: `);
473
+ } else {
474
+ return ask(`Could not detect plan (organizationType='${org}'). Enter plan [pro/team-pro/team-max/max/max20]: `);
475
+ }
476
+ }
477
+
392
478
  function claudeRender() {
393
479
  const PREV_PLAN = prevPlan(CLAUDE_DIR);
394
480
  if (!PLAN) {
395
481
  const live = detectPlan('claude');
396
482
  const org = live.org || '', detected = live.plan || '', from = live.from || '';
397
483
  if (interactive()) {
398
- const proposed = PREV_PLAN || detected;
399
- if (proposed) {
400
- const why = PREV_PLAN ? 'recorded by the previous install' : `detected from ${from}`;
401
- PLAN = ask(`plan: ${proposed} (${why}) — Enter to confirm, or type pro/team-pro/team-max/max/max20/balanced-max: `) || proposed;
402
- } else if (from === 'team') {
403
- PLAN = ask(`Team org detected (${org}) but not the seat. Enter plan [team-pro/team-max]: `);
404
- } else {
405
- PLAN = ask(`Could not detect plan (organizationType='${org}'). Enter plan [pro/team-pro/team-max/max/max20]: `);
406
- }
484
+ PLAN = pickClaudePlan(PREV_PLAN, detected, from, org);
407
485
  } else if (detected) {
408
486
  PLAN = detected; err(`detected plan: ${PLAN} (${from})`);
409
487
  } else if (from === 'team') {
@@ -425,6 +503,14 @@ function claudeRender() {
425
503
  if (['balanced_max', 'balancedmax'].includes(PLAN)) PLAN = 'balanced-max';
426
504
  if (!plans[PLAN]) die(`--plan must be pro, team-pro, team-max, max, max20 or balanced-max (got '${PLAN}')`, 2);
427
505
  const [TIER, PROFILE_NAME, PLAN_LABEL] = plans[PLAN];
506
+ // The option menus on a terminal, each skipped by its own flag: fable, context, statusline.
507
+ if (!FABLE_GIVEN && TIER === 'max' && PLAN !== 'balanced-max') {
508
+ const f = choose('fable', pick(['yes', 'no'], ['Fable 5.1 pinned on architect, the runtime gate on',
509
+ 'no Fable anywhere in this install']), 0, `auto on ${PLAN}`);
510
+ if (f === 'no') FABLE = 'no';
511
+ }
512
+ askContext();
513
+ askStatusline();
428
514
  if (FABLE === 'auto') FABLE = TIER === 'max' && PLAN !== 'balanced-max' ? 'yes' : 'no';
429
515
  else if (!['yes', 'no'].includes(FABLE)) die('--fable must be auto|yes|no', 2);
430
516
  if ((TIER === 'pro' || PLAN === 'balanced-max') && FABLE === 'yes') {
@@ -1264,22 +1350,35 @@ function claudeSummary() {
1264
1350
  // ~/.codex/auth.json carries chatgpt_plan_type.
1265
1351
  const X = {}; // the Codex branch's rendered values
1266
1352
 
1353
+ // The Codex plan question on a terminal: the previous install's plan, else the detected one,
1354
+ // is proposed and Enter confirms it; without either the plan is typed.
1355
+ const CODEX_PLANS = ['plus', 'pro', 'balanced-max'];
1356
+ const CODEX_PLAN_NOTES = ['ChatGPT Plus', 'ChatGPT Pro', 'only the session runs the strong model'];
1357
+ function pickCodexPlan(prev, det, why, detected) {
1358
+ const proposed = prev || det;
1359
+ const menuWhy = prev ? 'recorded by the previous install' : det ? `detected from ${why}`
1360
+ : `could not detect the ChatGPT plan, chatgpt_plan_type='${detected}'`;
1361
+ const picked = choose('codex plan', pick(CODEX_PLANS, CODEX_PLAN_NOTES), CODEX_PLANS.indexOf(proposed || 'pro'), menuWhy);
1362
+ if (picked !== null) return picked;
1363
+ if (proposed) {
1364
+ const w = prev ? 'recorded by the previous install' : `detected from ${why}`;
1365
+ return ask(`codex plan: ${proposed} (${w}) — Enter to confirm, or type plus/pro/balanced-max: `) || proposed;
1366
+ } else {
1367
+ return ask(`Could not detect the ChatGPT plan (chatgpt_plan_type='${detected}'). Enter plan [plus/pro]: `);
1368
+ }
1369
+ }
1370
+
1267
1371
  function codexRender() {
1268
1372
  const prev = prevPlan(CODEX_DIR);
1269
1373
  // BalancedMax is one routing idea on both runtimes: --plan balanced-max selects the Codex
1270
1374
  // balanced-max profile too, unless --codex-plan says otherwise.
1271
1375
  if (!CODEX_PLAN && PLAN === 'balanced-max') CODEX_PLAN = 'balanced-max';
1376
+ askContext();
1272
1377
  if (!CODEX_PLAN) {
1273
1378
  const live = detectPlan('codex');
1274
1379
  const detected = live.raw || '', det = live.plan || '', why = live.from || '';
1275
1380
  if (interactive()) {
1276
- const proposed = prev || det;
1277
- if (proposed) {
1278
- const w = prev ? 'recorded by the previous install' : `detected from ${why}`;
1279
- CODEX_PLAN = ask(`codex plan: ${proposed} (${w}) — Enter to confirm, or type plus/pro/balanced-max: `) || proposed;
1280
- } else {
1281
- CODEX_PLAN = ask(`Could not detect the ChatGPT plan (chatgpt_plan_type='${detected}'). Enter plan [plus/pro]: `);
1282
- }
1381
+ CODEX_PLAN = pickCodexPlan(prev, det, why, detected);
1283
1382
  } else if (det) {
1284
1383
  CODEX_PLAN = det; err(`detected codex plan: ${CODEX_PLAN} (${why})`);
1285
1384
  } else if (prev) {
@@ -216,12 +216,16 @@ def validate_label(label):
216
216
  return None
217
217
  if state_label_ok(tokens):
218
218
  return None
219
- return "a label starts with git commit, git push, a human-only state.py command or npx riskrails@<v>"
219
+ return ("a label starts with git commit, git push, a human-only state.py command (approve, reject, "
220
+ "profile set, verify/review/start --by, memory enable) or npx riskrails@<v>")
220
221
 
221
222
 
222
223
  def state_label_ok(tokens):
223
224
  """python3 [-I|-S…] <…/state.py> approve|reject|profile set|verify --by|
224
- review --by|start --mode … --by."""
225
+ review --by|start --mode … --by|memory enable [--author S] [--period month|quarter|year]
226
+ [--index none|sqlite]: each option at most once, as two words, in any order, and no other
227
+ memory verb. rr_state/labels.py keeps the same rule; tests/test_dialog_contract.py pins the
228
+ two equal."""
225
229
  if not tokens or not INTERPRETER_RE.match(tokens[0]):
226
230
  return False
227
231
  i = 1
@@ -236,6 +240,14 @@ def state_label_ok(tokens):
236
240
  return True
237
241
  if rest[:1] in (["verify"], ["review"]):
238
242
  return "--by" in rest
243
+ if rest[:2] == ["memory", "enable"]:
244
+ # The pick is the exact command the human read: no `--opt=value`, no abbreviation argparse would
245
+ # take, no positional, an option once, a value of its own kind (an author never starts with '-').
246
+ names, values = rest[2::2], rest[3::2]
247
+ kinds = {"--author": None, "--period": ("month", "quarter", "year"), "--index": ("none", "sqlite")}
248
+ return (len(names) == len(values) == len(set(names)) and set(names) <= set(kinds)
249
+ and all((v[:1] not in ("", "-")) if kinds[n] is None else v in kinds[n]
250
+ for n, v in zip(names, values)))
239
251
  return rest[:1] == ["start"] and "--mode" in rest and "--by" in rest
240
252
 
241
253
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "riskrails",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "RiskRails: a weight-based engineering pipeline for Claude Code and Codex — scoped steps, enforcement hooks, adversarial review, model routing.",
5
5
  "keywords": ["claude-code", "codex", "sdlc", "agents", "guardrails", "code-review"],
6
6
  "homepage": "https://github.com/vanssata/claude-agentic-sdlc-module#readme",
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # Idempotent .ai/ scaffold for RiskRails.
3
- # scaffold-ai.sh [project-dir] [--runtime auto|claude|codex|both] (default: $PWD, auto)
3
+ # scaffold-ai.sh [project-dir] [--runtime auto|claude|codex|both|none] (default: $PWD, auto)
4
+ # none: .ai/ and .gitignore only, no instruction file; a whole value, never part of a comma list
4
5
  #
5
6
  # A shim: the scaffold lives in skills/rr-setup/scaffold_ai.py and reads its
6
7
  # templates from this directory. This file keeps every caller's command working.
@@ -29,9 +29,10 @@ section, and the human decides whether they become their own task.
29
29
 
30
30
  The compact record is the human's to switch: `state.py memory enable`, `disable`,
31
31
  `cleanup`, `pack`, `redact` and `migrate --move-logs` take the human route, as
32
- `approve` does, and `.ai/memory/` and `.ai/local/memory/` are written by
33
- `state.py` alone — an agent never edits them (`state.py memory --help`; the
34
- plugin's `docs/local-memory.md`).
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`).
35
36
 
36
37
  ## Scope
37
38
 
@@ -179,7 +179,7 @@ def main():
179
179
 
180
180
  p = sub.add_parser("decision"); p.add_argument("action", choices=["show", "open", "settle", "fallback"])
181
181
  p.add_argument("--id"); p.add_argument("--kind"); p.add_argument("--command", dest="commands", action="append", default=[])
182
- p.add_argument("--pick", type=int); p.add_argument("--why", default="")
182
+ p.add_argument("--spec"); p.add_argument("--pick", type=int); p.add_argument("--why", default="")
183
183
  p.add_argument("--json", action="store_true"); p.set_defaults(func=dialog_cmd.cmd_decision)
184
184
 
185
185
  p = sub.add_parser("consent"); p.add_argument("action", nargs="?", choices=["audit"])