riskrails 4.0.1 → 4.2.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +22 -13
- package/bin/menu.js +116 -0
- package/bin/riskrails.js +119 -20
- package/hooks/AGENTS.md +0 -6
- package/hooks/CLAUDE.md +0 -6
- package/hooks/guard/dialog.py +14 -2
- package/hooks/guard/paths.py +48 -1406
- package/hooks/guard/paths_base.py +59 -0
- package/hooks/guard/paths_bash.py +176 -0
- package/hooks/guard/paths_classify.py +205 -0
- package/hooks/guard/paths_fs.py +137 -0
- package/hooks/guard/paths_human.py +105 -0
- package/hooks/guard/paths_interp.py +162 -0
- package/hooks/guard/paths_movers.py +393 -0
- package/hooks/guard/paths_shell.py +288 -0
- package/package.json +1 -1
- package/skills/AGENTS.md +0 -6
- package/skills/CLAUDE.md +0 -6
- package/skills/ai-init/scaffold-ai.sh +2 -1
- package/skills/ai-init/templates/.ai/policies/safety.md +4 -3
- package/skills/ai-init/templates/gitignore.snippet +2 -0
- package/skills/ai-task/memory_cleanup.py +213 -0
- package/skills/ai-task/memory_compact.py +25 -0
- package/skills/ai-task/memory_config.py +120 -0
- package/skills/ai-task/memory_export.py +246 -0
- package/skills/ai-task/memory_format.py +7 -3
- package/skills/ai-task/memory_hook.py +172 -0
- package/skills/ai-task/memory_io.py +122 -0
- package/skills/ai-task/memory_migrate.py +187 -0
- package/skills/ai-task/memory_packages.py +83 -0
- package/skills/ai-task/memory_risks.py +84 -0
- package/skills/ai-task/memory_status.py +76 -0
- package/skills/ai-task/memory_sync.py +20 -1481
- package/skills/ai-task/memory_verify.py +316 -0
- package/skills/ai-task/rr_state/cli.py +2 -1
- package/skills/ai-task/rr_state/close.py +2 -2
- package/skills/ai-task/rr_state/dialog_cmd.py +183 -6
- package/skills/ai-task/rr_state/labels.py +14 -2
- package/skills/ai-task/rr_state/memory_cmd.py +91 -68
- package/skills/project-update/history/blobs/8ec171f0fbdf38cc6996d496f01491fd1f21f04a435a118ad979b127a8be06f1 +85 -0
- package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
- package/skills/project-update/history/index.json +4 -2
- package/skills/project-update/rr_update/__init__.py +1 -0
- package/skills/project-update/rr_update/apply.py +188 -0
- package/skills/project-update/rr_update/blocks.py +38 -0
- package/skills/project-update/rr_update/build.py +96 -0
- package/skills/project-update/rr_update/cleanup.py +194 -0
- package/skills/project-update/rr_update/cli.py +102 -0
- package/skills/project-update/rr_update/files.py +106 -0
- package/skills/project-update/rr_update/gate.py +60 -0
- package/skills/project-update/rr_update/hints.py +128 -0
- package/skills/project-update/rr_update/history.py +117 -0
- package/skills/project-update/rr_update/kept.py +120 -0
- package/skills/project-update/rr_update/paths.py +108 -0
- package/skills/project-update/rr_update/plan.py +123 -0
- package/skills/project-update/rr_update/report.py +51 -0
- package/skills/project-update/rr_update/rules.py +178 -0
- package/skills/project-update/rr_update/shared.py +74 -0
- package/skills/project-update/rr_update/siblings.py +37 -0
- package/skills/project-update/rr_update/weights.py +171 -0
- package/skills/project-update/update.py +8 -1682
- package/skills/rr-setup/SKILL.md +79 -21
- package/skills/rr-setup/scaffold_ai.py +8 -2
- package/skills/rr-setup/setup.py +371 -4
|
@@ -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
|
|
8
|
+
"version": "4.2.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
|
|
17
|
+
"version": "4.2.0",
|
|
18
18
|
"strict": true
|
|
19
19
|
}
|
|
20
20
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "riskrails",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.2.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
|
|
3
|
+
"version": "4.2.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
|
-
[](https://github.com/vanssata/claude-agentic-sdlc-module/releases/tag/v4.2.0)
|
|
4
4
|
[](docs/routing.md)
|
|
5
5
|
[](docs/known-risks.md)
|
|
6
6
|
[](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
|
|
21
|
+
npx riskrails@4.2.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
|
|
53
|
+
**Version 4.2.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
|
|
68
|
+
3. **Update the home** on every machine, for every runtime: `npx riskrails@4.2.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
|
|
88
|
-
npx riskrails@4.0
|
|
89
|
-
npx riskrails@4.0
|
|
90
|
-
npx riskrails@4.0
|
|
91
|
-
npx riskrails@4.0
|
|
92
|
-
npx riskrails@4.0
|
|
93
|
-
npx riskrails@4.0
|
|
87
|
+
npx riskrails@4.2.0 # detects Claude Code, Codex, or both; proposes the detected plan
|
|
88
|
+
npx riskrails@4.2.0 --target codex # auto | claude | codex | both
|
|
89
|
+
npx riskrails@4.2.0 --plan max --fable no # pro | team-pro | team-max | max | max20 | balanced-max
|
|
90
|
+
npx riskrails@4.2.0 --codex-plan plus # plus | pro | balanced-max
|
|
91
|
+
npx riskrails@4.2.0 --context economy # the plan's compaction window; the context guard also blocks
|
|
92
|
+
npx riskrails@4.2.0 --statusline own # keep your own statusline command (from 3.2.1; default: the plugin's two-line statusline)
|
|
93
|
+
npx riskrails@4.2.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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/hooks/AGENTS.md
CHANGED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
<!-- riskrails:rule:hooks:start -->
|
|
2
|
-
# Hooks rules
|
|
3
|
-
- Weigh a change by what it does to a user project's decisions, never by the hook being a process every call runs through (constitution C6). The mode is what W gives (`state.py weight`); the rows below set the behaviour axis.
|
|
4
|
-
- behaviour 0: no guard decision moves, golden byte-identical. behaviour 1: adds a deny or changes a deny's text, golden re-recorded. behaviour 2: allows what was denied or weakens a refusal — declare it, `--declare behaviour=2:"<the effect>"`. Start such a task with `start --mode full --by <name>`: its weight lands in guarded, and these effects keep a STRONG review and the gate.
|
|
5
|
-
- The guards defend against an agent's mistakes, not a constructed command: such a finding is accepted in `known-risks.md` under the threat model in `.ai/policies/safety.md`.
|
|
6
|
-
<!-- riskrails:rule:hooks:end -->
|
package/hooks/CLAUDE.md
CHANGED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
<!-- riskrails:rule:hooks:start -->
|
|
2
|
-
# Hooks rules
|
|
3
|
-
- Weigh a change by what it does to a user project's decisions, never by the hook being a process every call runs through (constitution C6). The mode is what W gives (`state.py weight`); the rows below set the behaviour axis.
|
|
4
|
-
- behaviour 0: no guard decision moves, golden byte-identical. behaviour 1: adds a deny or changes a deny's text, golden re-recorded. behaviour 2: allows what was denied or weakens a refusal — declare it, `--declare behaviour=2:"<the effect>"`. Start such a task with `start --mode full --by <name>`: its weight lands in guarded, and these effects keep a STRONG review and the gate.
|
|
5
|
-
- The guards defend against an agent's mistakes, not a constructed command: such a finding is accepted in `known-risks.md` under the threat model in `.ai/policies/safety.md`.
|
|
6
|
-
<!-- riskrails:rule:hooks:end -->
|
package/hooks/guard/dialog.py
CHANGED
|
@@ -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
|
|
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
|
|