riskrails 4.2.0 → 4.3.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 +25 -15
- package/agents/ai-reviewer.md.tmpl +14 -1
- package/ai/agents/implementer.md +6 -0
- package/ai/agents/reviewer.md +8 -1
- package/bin/ansi.js +12 -0
- package/bin/menu.js +71 -12
- package/bin/riskrails.js +240 -14
- package/bin/uninstall.js +115 -0
- package/bin/wizard.js +72 -0
- package/hooks/context-guard.py +11 -1
- package/hooks/dialog-gate.py +3 -2
- package/hooks/guard/decision.py +4 -3
- package/hooks/guard/dialog.py +12 -2
- package/hooks/guard/ere.py +29 -10
- package/hooks/guard/git.py +2 -1
- package/hooks/guard/paths_base.py +7 -0
- package/hooks/guard/paths_bash.py +3 -3
- package/hooks/runtime-gate.py +87 -0
- package/hooks/update_notice.py +23 -8
- package/package.json +1 -1
- package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
- package/skills/ai-task/rr_state/dialog.py +32 -7
- package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
- package/skills/ai-task/rr_state/gate.py +10 -4
- package/skills/ai-task/rr_state/labels.py +33 -4
- package/skills/ai-task/rr_state/questions_cmd.py +5 -4
- package/skills/ai-task/rr_state/report.py +73 -10
- package/skills/ai-task/rr_state/report_cmd.py +2 -0
- package/skills/ai-task/rr_state/review.py +18 -14
- package/skills/ai-task/rr_state/start.py +12 -9
- package/skills/ai-task/rr_state/steps.py +16 -7
- package/skills/ai-task/rr_state/verify.py +17 -12
- package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
- package/skills/project-update/history/index.json +6 -3
- package/skills/rr-build/SKILL.md +5 -2
- package/skills/rr-ship/SKILL.md +2 -2
- package/skills/rr-spec/SKILL.md +6 -0
- package/skills/rr-status/SKILL.md +2 -0
- package/skills/rr-status/status.py +5 -3
package/bin/riskrails.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// [--fable auto|yes|no] [--codex-plan plus|pro|balanced-max] [--dry-run]
|
|
5
5
|
// [--context default|economy] [--statusline-context yes|no]
|
|
6
6
|
// [--statusline riskrails|own] [--home DIR] [--codex-home DIR] [--yes] [--version]
|
|
7
|
+
// npx riskrails --uninstall [--target T] [--home DIR] [--codex-home DIR] [--dry-run] [--yes]
|
|
7
8
|
// --target defaults to auto: each runtime is installed only if its executable is on PATH.
|
|
8
9
|
// --plan Claude only: pro, team-pro (Team Standard seat), team-max (Team Premium seat), max
|
|
9
10
|
// or max20 (Max 20x: max's settings, larger budgets). Defaults to auto-detect from
|
|
@@ -37,6 +38,14 @@
|
|
|
37
38
|
// variables CLAUDE_DIR and CODEX_DIR mean the same). Naming exactly one of them under
|
|
38
39
|
// --target auto installs that runtime alone.
|
|
39
40
|
// --dry-run prints everything that would be written, per runtime, and writes nothing.
|
|
41
|
+
// --uninstall moves what the installer wrote out of each home into backups/<stamp>/ and edits the
|
|
42
|
+
// shared files: our hook entries, the statusline, the settings keys the recorded plan
|
|
43
|
+
// renders (only where still equal) and the managed block. Nothing is deleted. Codex
|
|
44
|
+
// config.toml, the plugin and every project's .ai/ stay. Only --target, --home,
|
|
45
|
+
// --codex-home, --dry-run and --yes go with it (exit 2 otherwise); without a terminal it
|
|
46
|
+
// needs --yes or --dry-run. A second run finds nothing to do.
|
|
47
|
+
// A run's backups go to backups/<YYYY-MM-DD>/ in each home; when that exists already, to
|
|
48
|
+
// backups/<YYYY-MM-DD>T<HHMMSS>/, then the same with -2, -3: a backup never overwrites another.
|
|
40
49
|
// On a terminal each choice no flag names is an arrow-key menu, in this order: runtime (only
|
|
41
50
|
// without --target, --home and --codex-home), plan, fable (max, max20, team-max),
|
|
42
51
|
// context, statusline, codex plan. The proposal above is highlighted and Enter
|
|
@@ -55,6 +64,8 @@ const fs = require('fs');
|
|
|
55
64
|
const os = require('os');
|
|
56
65
|
const path = require('path');
|
|
57
66
|
const { spawnSync } = require('child_process');
|
|
67
|
+
const uninstall = require('./uninstall');
|
|
68
|
+
const wizard = require('./wizard');
|
|
58
69
|
|
|
59
70
|
const SRC = path.resolve(__dirname, '..');
|
|
60
71
|
const out = (s) => fs.writeSync(1, s + '\n');
|
|
@@ -72,7 +83,8 @@ let PLAN = '', FABLE = 'auto', DRY = false, TARGET = 'auto', PLAN_GIVEN = false;
|
|
|
72
83
|
let CODEX_PLAN = '', CODEX_PLAN_GIVEN = false, YES = false;
|
|
73
84
|
let CONTEXT = 'default', STATUSLINE_CONTEXT = 'yes', STATUSLINE = 'riskrails', STATUSLINE_GIVEN = false;
|
|
74
85
|
// 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;
|
|
86
|
+
let TARGET_GIVEN = false, FABLE_GIVEN = false, CONTEXT_GIVEN = false, UNINSTALL = false;
|
|
87
|
+
const UNINSTALL_FLAGS = ['--uninstall', '--target', '--home', '--codex-home', '--dry-run', '--yes', '-y'];
|
|
76
88
|
|
|
77
89
|
function version() {
|
|
78
90
|
try { return JSON.parse(fs.readFileSync(path.join(SRC, 'package.json'), 'utf8')).version || 'unknown'; }
|
|
@@ -109,11 +121,17 @@ function usage() {
|
|
|
109
121
|
case '--codex-home': CODEX_DIR = path.resolve(take()); CODEX_DIR_GIVEN = true; break;
|
|
110
122
|
case '--dry-run': DRY = true; break;
|
|
111
123
|
case '--yes': case '-y': YES = true; break;
|
|
124
|
+
case '--uninstall': UNINSTALL = true; break;
|
|
112
125
|
case '--version': case '-v': out(version()); process.exit(0); break;
|
|
113
126
|
case '-h': case '--help': usage(); process.exit(0); break;
|
|
114
127
|
default: die('unknown option: ' + argv[i], 2);
|
|
115
128
|
}
|
|
116
129
|
}
|
|
130
|
+
const stray = UNINSTALL ? argv.filter((a) => a.startsWith('-')).map((a) => a.split('=')[0]).find((a) => !UNINSTALL_FLAGS.includes(a)) : '';
|
|
131
|
+
if (stray) die(`riskrails: ${stray} does not go with --uninstall; expected only ${UNINSTALL_FLAGS.filter((f) => f !== '-y').join(', ')} (see --help)`, 2);
|
|
132
|
+
if (UNINSTALL && !DRY && !YES && process.stdin.isTTY !== true) {
|
|
133
|
+
die('riskrails: --uninstall needs a terminal to confirm and this is not one; pass --yes to uninstall anyway, or --dry-run to print the plan', 2);
|
|
134
|
+
}
|
|
117
135
|
}
|
|
118
136
|
if (!['auto', 'claude', 'codex', 'both'].includes(TARGET)) {
|
|
119
137
|
die(`--target must be auto|claude|codex|both (got '${TARGET}')`, 2);
|
|
@@ -249,8 +267,21 @@ function python(args, opts = {}) {
|
|
|
249
267
|
}
|
|
250
268
|
|
|
251
269
|
const pad2 = (n) => String(n).padStart(2, '0');
|
|
252
|
-
|
|
270
|
+
// RISKRAILS_NOW names the run's clock (a local time, 2026-03-04T05:06:07): the backup stamp is the one place it matters.
|
|
271
|
+
const now = env.RISKRAILS_NOW ? new Date(env.RISKRAILS_NOW) : new Date();
|
|
253
272
|
const BACKUP_DATE = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
|
|
273
|
+
|
|
274
|
+
// <date> when backups/<date> is free in the home, else <date>T<HHMMSS>, else that with -2, -3: pure, `taken` asks the disk.
|
|
275
|
+
function backupStamp(at, taken) {
|
|
276
|
+
const day = `${at.getFullYear()}-${pad2(at.getMonth() + 1)}-${pad2(at.getDate())}`;
|
|
277
|
+
if (!taken(day)) return day;
|
|
278
|
+
const time = `${day}T${pad2(at.getHours())}${pad2(at.getMinutes())}${pad2(at.getSeconds())}`;
|
|
279
|
+
let name = time;
|
|
280
|
+
for (let n = 2; taken(name); n++) name = `${time}-${n}`;
|
|
281
|
+
return name;
|
|
282
|
+
}
|
|
283
|
+
const STAMPS = {}; // one stamp per home per run, chosen at its first backup
|
|
284
|
+
const stampOf = (root) => STAMPS[root] || (STAMPS[root] = backupStamp(now, (n) => fs.existsSync(path.join(root, 'backups', n))));
|
|
254
285
|
const utcStamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
255
286
|
|
|
256
287
|
// A line from the terminal, as `read -r -p`: the prompt on stderr.
|
|
@@ -340,9 +371,101 @@ Code or Codex first, or name the runtime yourself:
|
|
|
340
371
|
npx riskrails --target both # both, whether or not they are detected`);
|
|
341
372
|
process.exit(1);
|
|
342
373
|
}
|
|
343
|
-
//
|
|
374
|
+
// ------------------------------------------------------------------ wizard
|
|
375
|
+
// With the menu on, one screen per choice and a review before any write (spec ui-first-approvals
|
|
376
|
+
// R15-R17). It sets every choice and its *_GIVEN flag, so the single menus below never ask.
|
|
377
|
+
// A dry run, --yes, no terminal, RISKRAILS_MENU=off|0 and TERM=dumb keep today's prompts.
|
|
378
|
+
let WIZARD_COMMAND = '';
|
|
379
|
+
const STEP_KEY = { 'codex plan': 'codexPlan' };
|
|
380
|
+
function wizardPick(spec) {
|
|
381
|
+
const menu = { title: spec.title, why: '', initial: spec.initial, step: { n: spec.step, names: spec.trail },
|
|
382
|
+
items: spec.items.map(({ value, label, note }) => ({ value, label, desc: note })), back: spec.step > 1, color: true };
|
|
383
|
+
if (spec.multi) { menu.multi = true; menu.checked = spec.items.filter((it) => it.checked).map((it) => it.value); }
|
|
384
|
+
const r = spawnSync(process.execPath, [path.join(__dirname, 'menu.js')],
|
|
385
|
+
{ stdio: ['inherit', 'pipe', 'inherit'], env: { ...env, RISKRAILS_MENU_SPEC: JSON.stringify(menu) }, encoding: 'utf8' });
|
|
386
|
+
if (r.status === 0) return { value: spec.multi ? r.stdout.split(',').filter(Boolean) : r.stdout };
|
|
387
|
+
if (r.status === 4) return { back: true };
|
|
388
|
+
if (r.status === 130 || r.signal) {
|
|
389
|
+
if (r.signal) err(`riskrails: the menu was stopped by ${r.signal}; run \`stty sane\` if the terminal misbehaves`);
|
|
390
|
+
die('riskrails: aborted — nothing was written', 130);
|
|
391
|
+
}
|
|
392
|
+
return die(`riskrails: the wizard menu could not take the terminal (exit ${r.status}); expected a terminal, run with --yes or RISKRAILS_MENU=off instead`, 1);
|
|
393
|
+
}
|
|
394
|
+
const shellWord = (w) => (/^[\w@%+=:,./-]+$/.test(w) ? w : `'${w.replace(/'/g, "'\\''")}'`);
|
|
395
|
+
// The flag command the picks stand for, with the homes this run was given; its --dry-run is the review.
|
|
396
|
+
function wizardCommand(choices) {
|
|
397
|
+
const argv = [...wizard.flags(choices).argv];
|
|
398
|
+
if (CLAUDE_DIR_GIVEN) argv.push('--home', CLAUDE_DIR);
|
|
399
|
+
if (CODEX_DIR_GIVEN) argv.push('--codex-home', CODEX_DIR);
|
|
400
|
+
return argv;
|
|
401
|
+
}
|
|
402
|
+
function wizardReview(argv) {
|
|
403
|
+
const r = spawnSync(process.execPath, [__filename, ...argv.slice(2), '--dry-run'], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
404
|
+
if (r.status !== 0) die(`riskrails: the review could not be built: the dry run exited ${r.status}\n${r.stderr}expected a dry run that prints the plan; fix the cause above, or install with the flags of --help`, 1);
|
|
405
|
+
return r.stdout;
|
|
406
|
+
}
|
|
407
|
+
function runWizard() {
|
|
408
|
+
const seen = {};
|
|
409
|
+
const live = (rt) => { seen[rt] = seen[rt] || detectPlan(rt); return seen[rt]; };
|
|
410
|
+
const picks = { claude: DO_CLAUDE, codex: DO_CODEX, context: CONTEXT_GIVEN ? CONTEXT : '' };
|
|
411
|
+
try {
|
|
412
|
+
const rec = readJSON(path.join(CLAUDE_DIR, 'riskrails', 'statusline.json'));
|
|
413
|
+
if (isObj(rec) && ['riskrails', 'own'].includes(rec.mode)) picks.statusline = rec.mode;
|
|
414
|
+
} catch (e) { /* none yet */ }
|
|
415
|
+
if (STATUSLINE_GIVEN) picks.statusline = STATUSLINE;
|
|
416
|
+
const detected = () => ({ claude: picks.claude, codex: picks.codex,
|
|
417
|
+
plan: picks.plan || (PLAN_GIVEN ? PLAN : picks.claude ? prevPlan(CLAUDE_DIR) || live('claude').plan || (live('claude').from === 'team' ? 'team-pro' : 'pro') : ''),
|
|
418
|
+
codexPlan: picks.codexPlan || (CODEX_PLAN_GIVEN ? CODEX_PLAN : (picks.plan || PLAN) === 'balanced-max' ? 'balanced-max'
|
|
419
|
+
: picks.codex ? prevPlan(CODEX_DIR) || live('codex').plan || 'pro' : '') });
|
|
420
|
+
const choices = () => {
|
|
421
|
+
const d = detected(), has = (id) => wizard.steps(d).some((s) => s.id === id);
|
|
422
|
+
return { target: d.claude && d.codex ? 'both' : d.claude ? 'claude' : 'codex', plan: d.claude ? d.plan : undefined,
|
|
423
|
+
fable: has('fable') ? picks.fable || 'yes' : undefined, statusline: d.claude ? picks.statusline || 'riskrails' : undefined,
|
|
424
|
+
codexPlan: d.codex ? d.codexPlan : undefined, context: picks.context || 'default', version: version() };
|
|
425
|
+
};
|
|
426
|
+
const history = [];
|
|
427
|
+
let id = 'runtimes';
|
|
428
|
+
for (;;) {
|
|
429
|
+
let spec = wizard.steps(detected()).find((s) => s.id === id);
|
|
430
|
+
const kept = picks[STEP_KEY[id] || id];
|
|
431
|
+
const at = typeof kept === 'string' ? spec.items.findIndex((it) => it.value === kept) : -1;
|
|
432
|
+
if (at >= 0) spec = { ...spec, initial: at };
|
|
433
|
+
if (id === 'review') {
|
|
434
|
+
const argv = wizardCommand(choices()), command = argv.map(shellWord).join(' ');
|
|
435
|
+
err(wizard.reviewLines(wizardReview(argv), command).join('\n'));
|
|
436
|
+
const r = wizardPick(spec);
|
|
437
|
+
if (!r.back && r.value === 'install') { WIZARD_COMMAND = command; break; }
|
|
438
|
+
if (!r.back && r.value === 'cancel') die('riskrails: aborted — nothing was written', 130);
|
|
439
|
+
id = history.pop();
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const r = wizardPick(spec);
|
|
443
|
+
if (r.back) { id = history.pop(); continue; }
|
|
444
|
+
if (id === 'runtimes') {
|
|
445
|
+
if (r.value.length === 0) continue; // nothing checked: ask again
|
|
446
|
+
picks.claude = r.value.includes('claude'); picks.codex = r.value.includes('codex');
|
|
447
|
+
} else {
|
|
448
|
+
picks[STEP_KEY[id] || id] = r.value;
|
|
449
|
+
}
|
|
450
|
+
history.push(id);
|
|
451
|
+
const next = wizard.steps(detected());
|
|
452
|
+
id = next[next.findIndex((s) => s.id === id) + 1].id;
|
|
453
|
+
}
|
|
454
|
+
const c = choices();
|
|
455
|
+
DO_CLAUDE = c.target !== 'codex'; DO_CODEX = c.target !== 'claude';
|
|
456
|
+
CONTEXT = c.context; CONTEXT_GIVEN = true;
|
|
457
|
+
if (DO_CLAUDE) {
|
|
458
|
+
PLAN = c.plan; PLAN_GIVEN = true; FABLE_GIVEN = true;
|
|
459
|
+
if (c.fable) FABLE = c.fable;
|
|
460
|
+
STATUSLINE = c.statusline; STATUSLINE_GIVEN = true;
|
|
461
|
+
}
|
|
462
|
+
if (DO_CODEX) { CODEX_PLAN = c.codexPlan; CODEX_PLAN_GIVEN = true; }
|
|
463
|
+
}
|
|
344
464
|
let RUNTIME_PICKED = false;
|
|
345
|
-
if (
|
|
465
|
+
if (!UNINSTALL && !DRY && !menuOff()) { runWizard(); RUNTIME_PICKED = true; }
|
|
466
|
+
|
|
467
|
+
// On a terminal, with nothing naming the runtime, the detected set is the menu's first proposal.
|
|
468
|
+
if (!RUNTIME_PICKED && !UNINSTALL && TARGET === 'auto' && !TARGET_GIVEN && !CLAUDE_DIR_GIVEN && !CODEX_DIR_GIVEN) {
|
|
346
469
|
const RUNTIMES = ['claude', 'codex', 'both'];
|
|
347
470
|
const detected = DO_CLAUDE && DO_CODEX ? 'both' : DO_CLAUDE ? 'claude' : 'codex';
|
|
348
471
|
const rt = choose('runtime', pick(RUNTIMES, ['Claude Code', 'Codex', 'Claude Code and Codex']),
|
|
@@ -841,24 +964,24 @@ function claudeDryRun() {
|
|
|
841
964
|
}
|
|
842
965
|
|
|
843
966
|
// Every backup goes to one dated directory, <root>/backups/<date>/<path>: a backup beside
|
|
844
|
-
// the file it saves would be loaded too.
|
|
967
|
+
// the file it saves would be loaded too. The directory is the run's stamp for that home (stampOf).
|
|
845
968
|
function backupFile(dst, root, src = dst) {
|
|
846
969
|
const rel = path.relative(root, dst);
|
|
847
|
-
const bak = path.join(root, 'backups',
|
|
970
|
+
const bak = path.join(root, 'backups', stampOf(root), rel);
|
|
848
971
|
mkdirp(path.dirname(bak));
|
|
849
972
|
rmrf(bak);
|
|
850
973
|
// A file is copied through a symlink: a CLAUDE.md linked from dotfiles is saved as its content.
|
|
851
974
|
if (isDir(src)) copyTree(src, bak); else fs.copyFileSync(fs.realpathSync(src), bak);
|
|
852
|
-
out(`backup: ${rel} -> backups/${
|
|
975
|
+
out(`backup: ${rel} -> backups/${stampOf(root)}/${rel}`);
|
|
853
976
|
}
|
|
854
977
|
|
|
855
978
|
function retire(root, rel) { // move what nothing installs any more into backups/<date>/
|
|
856
979
|
if (!exists(path.join(root, rel))) return;
|
|
857
|
-
const dest = path.join(root, 'backups',
|
|
980
|
+
const dest = path.join(root, 'backups', stampOf(root), rel);
|
|
858
981
|
mkdirp(path.dirname(dest));
|
|
859
982
|
rmrf(dest);
|
|
860
983
|
fs.renameSync(path.join(root, rel), dest);
|
|
861
|
-
out(`retired: ${rel} -> backups/${
|
|
984
|
+
out(`retired: ${rel} -> backups/${stampOf(root)}/${rel}`);
|
|
862
985
|
}
|
|
863
986
|
|
|
864
987
|
// sweep_bak — every *.bak an older installer left beside a live file moves into
|
|
@@ -870,10 +993,10 @@ function sweepBak(root, ...names) {
|
|
|
870
993
|
for (const bak of candidates) {
|
|
871
994
|
if (!isFile(bak) || !exists(bak.slice(0, -4))) continue;
|
|
872
995
|
const rel = path.relative(root, bak);
|
|
873
|
-
const dest = path.join(root, 'backups',
|
|
996
|
+
const dest = path.join(root, 'backups', stampOf(root), rel);
|
|
874
997
|
mkdirp(path.dirname(dest));
|
|
875
998
|
fs.renameSync(bak, dest);
|
|
876
|
-
out(`moved: ${rel} -> backups/${
|
|
999
|
+
out(`moved: ${rel} -> backups/${stampOf(root)}/${rel}`);
|
|
877
1000
|
}
|
|
878
1001
|
}
|
|
879
1002
|
|
|
@@ -901,7 +1024,7 @@ function installFile(src, dst, root = CLAUDE_DIR) { // back up on a real change
|
|
|
901
1024
|
// skill goes in without its SKILL.md: the plugin loads the skill, and the skills still find
|
|
902
1025
|
// their scripts (state.py, ai-path, ...) through $AI_HOME/skills/ as on the npx route.
|
|
903
1026
|
function installSkills(root, plugin) {
|
|
904
|
-
const backups = path.join(root, 'backups',
|
|
1027
|
+
const backups = path.join(root, 'backups', stampOf(root), 'skills');
|
|
905
1028
|
// /status became /rr-status (2.3) and the ai-status and sdlc-* aliases ended in 4.0: a leftover would load as a second command.
|
|
906
1029
|
for (const n of RETIRED_SKILLS) retire(root, `skills/${n}`);
|
|
907
1030
|
for (const name of ls(path.join(SRC, 'skills')).filter((n) => isDir(path.join(SRC, 'skills', n)))) {
|
|
@@ -921,7 +1044,7 @@ function installSkills(root, plugin) {
|
|
|
921
1044
|
const base = path.basename(stale).slice(0, -4);
|
|
922
1045
|
rmrf(path.join(backups, base + '-old'));
|
|
923
1046
|
fs.renameSync(stale, path.join(backups, base + '-old'));
|
|
924
|
-
out(`moved: skills/${path.basename(stale)} -> backups/${
|
|
1047
|
+
out(`moved: skills/${path.basename(stale)} -> backups/${stampOf(root)}/skills/ (it was loading as a duplicate skill)`);
|
|
925
1048
|
}
|
|
926
1049
|
for (const f of ['skills/ai-init/scaffold-ai.sh', 'skills/ai-task/state.py', 'skills/ai-task/sensors.py',
|
|
927
1050
|
'skills/project-update/update.py', 'skills/rr-status/status.py']) chmodX(path.join(root, f));
|
|
@@ -1558,11 +1681,113 @@ function codexSummary() {
|
|
|
1558
1681
|
' /skills lists rr-setup, rr-spec, rr-plan, rr-build, rr-build-all, rr-review, rr-ship, rr-status',
|
|
1559
1682
|
' /rr-setup in a project, to survey it and build .ai/',
|
|
1560
1683
|
'',
|
|
1561
|
-
`Your previous default model was replaced. backups/${
|
|
1684
|
+
`Your previous default model was replaced. backups/${stampOf(CODEX_DIR)}/config.toml holds the old one.`,
|
|
1562
1685
|
].join('\n'));
|
|
1563
1686
|
}
|
|
1564
1687
|
|
|
1688
|
+
// ================================================================= uninstall
|
|
1689
|
+
// --uninstall: bin/uninstall.js plans it from what a home holds; this part reads the home, prints the
|
|
1690
|
+
// plan and moves through the helpers above. Nothing is deleted: a path goes to backups/<stamp>/.
|
|
1691
|
+
const lexists = (p) => isLink(p) || exists(p);
|
|
1692
|
+
const relToSrc = (files) => files.map((f) => path.relative(SRC, f));
|
|
1693
|
+
const skillDirs = () => ls(path.join(SRC, 'skills')).filter((n) => isDir(path.join(SRC, 'skills', n))).map((n) => 'skills/' + n);
|
|
1694
|
+
const sharedShipped = () => ['riskrails', 'hooks/ai-path-guard-defaults.json', 'hooks/ai-git-guard-defaults.json',
|
|
1695
|
+
...SHIMS.map((n) => 'hooks/' + n), ...skillDirs()];
|
|
1696
|
+
const claudeShipped = () => [...glob(path.join(SRC, 'agents'), '.md.tmpl').map((f) => 'agents/' + path.basename(f, '.tmpl')),
|
|
1697
|
+
...relToSrc(claudeHookFiles()), 'bin/claude-1m', 'hooks/fable-gate.py', ...sharedShipped()];
|
|
1698
|
+
const codexShipped = () => [...glob(path.join(SRC, 'agents'), '.md.tmpl').map((f) => 'agents/' + path.basename(f, '.md.tmpl') + '.toml'),
|
|
1699
|
+
...relToSrc([...codexHookFiles(), ...glob(path.join(SRC, 'hooks/guard'), '.py'), path.join(SRC, 'hooks/lib/ai-hook-common.sh')]),
|
|
1700
|
+
'hooks/codex-model-gate.py', ...sharedShipped()];
|
|
1701
|
+
|
|
1702
|
+
function readHomeJSON(file) {
|
|
1703
|
+
if (!isFile(file)) return null;
|
|
1704
|
+
try { return readJSON(file); } catch (e) {
|
|
1705
|
+
return die(`riskrails: ${file} is not valid JSON (${e.message}); expected a JSON file, fix it and run --uninstall again`, 1);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// The settings keys the recorded plan renders: the plan, fable and context the install wrote to profile.json.
|
|
1710
|
+
function recordedSnippet() {
|
|
1711
|
+
const rec = readHomeJSON(path.join(CLAUDE_DIR, 'riskrails/profile.json'));
|
|
1712
|
+
if (!isObj(rec) || !rec.plan) return {};
|
|
1713
|
+
PLAN = String(rec.plan); FABLE = rec.fable === true ? 'yes' : 'no';
|
|
1714
|
+
CONTEXT = isObj(rec.context) && rec.context.mode === 'economy' ? 'economy' : 'default';
|
|
1715
|
+
FABLE_GIVEN = CONTEXT_GIVEN = STATUSLINE_GIVEN = true; // the record answers every menu
|
|
1716
|
+
claudeRender();
|
|
1717
|
+
return C.snippet;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// The plan for one home; `names` are the home's JSON and text files, which the planner calls settings.json and CLAUDE.md.
|
|
1721
|
+
// The oldest backup of a home's settings file: backups/<stamp>/<name> with the smallest stamp; null when none.
|
|
1722
|
+
function oldestBackup(home, name) {
|
|
1723
|
+
const dir = path.join(home, 'backups');
|
|
1724
|
+
return (isDir(dir) ? ls(dir) : []).map((d) => path.join(dir, d, name)).find(isFile) || null;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function homePlan(home, shipped, names, snippet) {
|
|
1728
|
+
const text = path.join(home, names[1]);
|
|
1729
|
+
const plan = uninstall.planUninstall({
|
|
1730
|
+
files: shipped.filter((f) => lexists(path.join(home, f))),
|
|
1731
|
+
shippedHooks: shipped.filter((f) => f.startsWith('hooks/')).map((f) => f.slice('hooks/'.length)),
|
|
1732
|
+
earlierSettings: oldestBackup(home, names[0]),
|
|
1733
|
+
settings: readHomeJSON(path.join(home, names[0])), snippet,
|
|
1734
|
+
statusline: readHomeJSON(path.join(home, 'riskrails/statusline.json')),
|
|
1735
|
+
claudeMd: isFile(text) ? read(text) : null,
|
|
1736
|
+
});
|
|
1737
|
+
for (const e of plan.edited) e.file = e.file === 'settings.json' ? names[0] : names[1];
|
|
1738
|
+
return plan;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const noopIO = (log) => ({ move() {}, backup() {}, writeJSON() {}, writeText() {}, log });
|
|
1742
|
+
function homeIO(home, log) {
|
|
1743
|
+
const into = (rel) => { const d = path.join(home, 'backups', stampOf(home), rel); mkdirp(path.dirname(d)); return d; };
|
|
1744
|
+
return {
|
|
1745
|
+
move: (rel) => fs.renameSync(path.join(home, rel), into(rel)),
|
|
1746
|
+
backup: (rel) => fs.copyFileSync(fs.realpathSync(path.join(home, rel)), into(rel)),
|
|
1747
|
+
writeJSON: (rel, doc) => writeJSONFile(path.join(home, rel), doc),
|
|
1748
|
+
writeText: (rel, text) => fs.writeFileSync(path.join(home, rel), text),
|
|
1749
|
+
log,
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// The launcher link the install made in ~/.local/bin: ours only when it points at this home's bin/claude-1m.
|
|
1754
|
+
const LAUNCHER = path.join(HOME, '.local/bin/claude-1m');
|
|
1755
|
+
const launcherIsOurs = (home) => isLink(LAUNCHER) && path.resolve(path.dirname(LAUNCHER), fs.readlinkSync(LAUNCHER)) === path.join(home, 'bin/claude-1m');
|
|
1756
|
+
|
|
1757
|
+
function runUninstall() {
|
|
1758
|
+
const targets = [];
|
|
1759
|
+
if (DO_CLAUDE) targets.push({ home: CLAUDE_DIR, plan: homePlan(CLAUDE_DIR, claudeShipped(), ['settings.json', 'CLAUDE.md'], recordedSnippet()), link: launcherIsOurs(CLAUDE_DIR) });
|
|
1760
|
+
if (DO_CODEX) targets.push({ home: CODEX_DIR, plan: homePlan(CODEX_DIR, codexShipped(), ['hooks.json', 'AGENTS.md'], {}), link: false });
|
|
1761
|
+
const work = targets.filter((t) => t.plan.moved.length > 0 || t.plan.edited.length > 0 || t.link);
|
|
1762
|
+
for (const t of targets.filter((x) => !work.includes(x))) out(`nothing to uninstall in ${t.home}: no file the installer ships and no entry of its is left`);
|
|
1763
|
+
for (const t of work) {
|
|
1764
|
+
out(`== uninstall: ${t.home}${DRY ? ' (dry run, nothing written)' : ''}`);
|
|
1765
|
+
out(` backups: ${t.home}/backups/${stampOf(t.home)}/`);
|
|
1766
|
+
if (t.link) out('moved: ~/.local/bin/claude-1m -> backups/');
|
|
1767
|
+
uninstall.applyUninstall(t.plan, noopIO(out));
|
|
1768
|
+
}
|
|
1769
|
+
if (DRY || work.length === 0) process.exit(0);
|
|
1770
|
+
if (!YES && !/^y(es)?$/i.test(ask('Move these into backups and edit the files above? [y/N] ').trim())) {
|
|
1771
|
+
die('riskrails: cancelled — nothing was written', 130);
|
|
1772
|
+
}
|
|
1773
|
+
for (const t of work) {
|
|
1774
|
+
try { uninstall.applyUninstall(t.plan, homeIO(t.home, () => {})); } catch (e) {
|
|
1775
|
+
die(`${e.message} (backups: ${t.home}/backups/${stampOf(t.home)}/)`, 1);
|
|
1776
|
+
}
|
|
1777
|
+
if (t.link) { // the link itself is renamed, never followed; after the edits, so a failure here leaves settings clean
|
|
1778
|
+
const dest = path.join(t.home, 'backups', stampOf(t.home), '.local/bin/claude-1m');
|
|
1779
|
+
mkdirp(path.dirname(dest));
|
|
1780
|
+
try { fs.renameSync(LAUNCHER, dest); } catch (e) {
|
|
1781
|
+
die(`riskrails: could not move ${LAUNCHER} into ${dest} (${e.message}); done: the files and settings of ${t.home}; left: the launcher link; nothing was deleted. Move it by hand, or run --uninstall again once the cause is gone`, 1);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
out(`uninstalled: ${t.home} (${t.plan.moved.length} moved, ${t.plan.edited.length} edited; backups/${stampOf(t.home)}/)`);
|
|
1785
|
+
}
|
|
1786
|
+
process.exit(0);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1565
1789
|
// ======================================================================= run
|
|
1790
|
+
if (UNINSTALL) runUninstall();
|
|
1566
1791
|
if (DO_CLAUDE) claudeRender();
|
|
1567
1792
|
if (DO_CODEX) codexRender();
|
|
1568
1793
|
if (DRY) {
|
|
@@ -1575,4 +1800,5 @@ if (DO_CLAUDE) claudeApply();
|
|
|
1575
1800
|
if (DO_CODEX) codexApply();
|
|
1576
1801
|
if (DO_CLAUDE) claudeSummary();
|
|
1577
1802
|
if (DO_CODEX) codexSummary();
|
|
1803
|
+
if (WIZARD_COMMAND) out(wizard.finalBox({ command: WIZARD_COMMAND }).join('\n'));
|
|
1578
1804
|
process.exit(0);
|
package/bin/uninstall.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// --uninstall as a plan (spec ui-first-approvals R18). Pure: planUninstall takes what a home holds as
|
|
3
|
+
// data and returns what to do; applyUninstall does it only through the injected `io`. Nothing is deleted.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
7
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
8
|
+
const START = '<!-- riskrails:start -->', END = '<!-- riskrails:end -->';
|
|
9
|
+
const GATE = '"$HOME/.claude/hooks/runtime-gate.py" statusline';
|
|
10
|
+
|
|
11
|
+
// Our hook entries out of hooks: a command naming hooks/<one of our files>. Returns the count removed.
|
|
12
|
+
function dropOurHooks(hooks, names) {
|
|
13
|
+
const ours = (h) => isObj(h) && typeof h.command === 'string' && names.some((n) => h.command.includes(`/hooks/${n}`));
|
|
14
|
+
let n = 0;
|
|
15
|
+
for (const ev of Object.keys(hooks)) {
|
|
16
|
+
if (!Array.isArray(hooks[ev])) continue;
|
|
17
|
+
for (const g of hooks[ev]) {
|
|
18
|
+
if (isObj(g) && Array.isArray(g.hooks)) { const k = g.hooks.length; g.hooks = g.hooks.filter((h) => !ours(h)); n += k - g.hooks.length; }
|
|
19
|
+
}
|
|
20
|
+
hooks[ev] = hooks[ev].filter((g) => !(isObj(g) && Array.isArray(g.hooks) && g.hooks.length === 0));
|
|
21
|
+
if (hooks[ev].length === 0) delete hooks[ev];
|
|
22
|
+
}
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The snippet's keys out of doc where equal (their dotted paths go to `removed`); a key that differs goes to `differs`.
|
|
27
|
+
function dropSnippet(doc, snippet, path, differs, removed) {
|
|
28
|
+
for (const k of Object.keys(snippet)) {
|
|
29
|
+
if (!(k in doc)) continue;
|
|
30
|
+
if (isObj(doc[k]) && isObj(snippet[k])) {
|
|
31
|
+
dropSnippet(doc[k], snippet[k], `${path}${k}.`, differs, removed);
|
|
32
|
+
if (Object.keys(doc[k]).length === 0) delete doc[k];
|
|
33
|
+
} else if (same(doc[k], snippet[k])) { delete doc[k]; removed.push(path + k); }
|
|
34
|
+
else differs.push(path + k);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The user's command our statusline wraps, or the one the record saved; '' when there is none.
|
|
39
|
+
function ownCommand(line, record) {
|
|
40
|
+
if (record && typeof record.replaced === 'string' && record.replaced.trim()) return record.replaced;
|
|
41
|
+
const m = /--then '((?:[^']|'\\'')*)'$/.exec(line.command);
|
|
42
|
+
return m ? m[1].replace(/'\\''/g, "'") : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function planSettings(inv, ours) {
|
|
46
|
+
const doc = JSON.parse(JSON.stringify(inv.settings));
|
|
47
|
+
const notes = [];
|
|
48
|
+
if (isObj(doc.hooks) && dropOurHooks(doc.hooks, ours)) notes.push('our hook entries removed');
|
|
49
|
+
if (isObj(doc.hooks) && Object.keys(doc.hooks).length === 0) delete doc.hooks;
|
|
50
|
+
const line = doc.statusLine;
|
|
51
|
+
if (isObj(line) && typeof line.command === 'string' && line.command.startsWith(GATE)) {
|
|
52
|
+
const own = ownCommand(line, inv.statusline);
|
|
53
|
+
if (own) doc.statusLine = { type: 'command', command: own }; else delete doc.statusLine;
|
|
54
|
+
notes.push(own ? 'statusLine restored to your own command' : 'our statusLine removed');
|
|
55
|
+
}
|
|
56
|
+
const { hooks, statusLine, ...rest } = inv.snippet || {};
|
|
57
|
+
const differs = [], removed = [];
|
|
58
|
+
dropSnippet(doc, rest, '', differs, removed);
|
|
59
|
+
if (!same(doc, inv.settings) && notes.length === 0) notes.push('settings keys of the recorded plan removed');
|
|
60
|
+
return { doc, notes, differs, removed };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function blockRemoved(text) {
|
|
64
|
+
const i = text.indexOf(START), j = text.indexOf(END);
|
|
65
|
+
if (i < 0 || j < i) return null;
|
|
66
|
+
return (text.slice(0, i) + text.slice(j + END.length)).replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n+$/, '') + '\n';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// inventory: { files: [paths relative to the home that exist and that the installer ships: agents/x.md,
|
|
70
|
+
// hooks/…, skills/x, riskrails, bin/claude-1m], settings: object|null, snippet: object (what the recorded
|
|
71
|
+
// plan renders into settings.json), statusline: object|null (riskrails/statusline.json), claudeMd: string|null,
|
|
72
|
+
// shippedHooks: [names of the hook files the installer ships, relative to hooks/, whether or not they exist],
|
|
73
|
+
// earlierSettings: path of the oldest settings backup|null }.
|
|
74
|
+
// Returns { moved: [path], edited: [{ file, kind: 'json'|'text', content, notes: [string], removedKeys: [string],
|
|
75
|
+
// earlier: string }], kept: [string] }.
|
|
76
|
+
function planUninstall(inv) {
|
|
77
|
+
const moved = [...inv.files], edited = [], kept = [];
|
|
78
|
+
if (isObj(inv.settings)) {
|
|
79
|
+
const { doc, notes, differs, removed } = planSettings(inv, inv.shippedHooks);
|
|
80
|
+
const earlier = inv.earlierSettings ? `an earlier value of yours, if you had one, is in ${inv.earlierSettings}`
|
|
81
|
+
: 'no settings backup under backups/; a value of yours that the install replaced is not recoverable from here';
|
|
82
|
+
if (!same(doc, inv.settings)) edited.push({ file: 'settings.json', kind: 'json', content: doc, notes, removedKeys: removed, earlier });
|
|
83
|
+
for (const k of differs) kept.push(`settings.json: ${k} differs from what the recorded plan writes; left as it is`);
|
|
84
|
+
}
|
|
85
|
+
const text = typeof inv.claudeMd === 'string' ? blockRemoved(inv.claudeMd) : null;
|
|
86
|
+
if (text !== null) edited.push({ file: 'CLAUDE.md', kind: 'text', content: text, notes: ['the managed block removed'] });
|
|
87
|
+
if (moved.length === 0 && edited.length === 0) return { moved, edited, kept: [] };
|
|
88
|
+
kept.push('Codex config.toml: values stay', 'the riskrails plugin: stays installed', "every project's .ai/: stays");
|
|
89
|
+
return { moved, edited, kept };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// io: { move(rel) — to backups/<stamp>/<rel>, backup(rel) — a copy there, writeJSON(rel, obj),
|
|
93
|
+
// writeText(rel, text), log(line) }. Nothing else is touched. The files are edited first, so a move that
|
|
94
|
+
// fails leaves no setting pointing at a file of ours; the failure stops the run with what was done and what is left.
|
|
95
|
+
function applyUninstall(plan, io) {
|
|
96
|
+
const edited = [];
|
|
97
|
+
for (const e of plan.edited) {
|
|
98
|
+
io.backup(e.file);
|
|
99
|
+
if (e.kind === 'json') io.writeJSON(e.file, e.content); else io.writeText(e.file, e.content);
|
|
100
|
+
edited.push(e.file);
|
|
101
|
+
io.log(`edited: ${e.file} — ${e.notes.join(', ')}`);
|
|
102
|
+
for (const k of e.removedKeys || []) io.log(`removed: ${e.file}: ${k}`);
|
|
103
|
+
if ((e.removedKeys || []).length > 0) io.log(`earlier: ${e.earlier}`);
|
|
104
|
+
}
|
|
105
|
+
for (const [i, f] of plan.moved.entries()) {
|
|
106
|
+
try { io.move(f); } catch (err) {
|
|
107
|
+
throw new Error(`riskrails: could not move ${f} into backups/ (${err.message}); done: edited ${edited.join(', ') || 'nothing'}, ${i} of ${plan.moved.length} moved; `
|
|
108
|
+
+ `left: ${plan.moved.slice(i).join(', ')}; nothing was deleted. Move the rest by hand into backups/, or run --uninstall again once the cause is gone`);
|
|
109
|
+
}
|
|
110
|
+
io.log(`moved: ${f} -> backups/`);
|
|
111
|
+
}
|
|
112
|
+
for (const k of plan.kept) io.log(`kept: ${k}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { planUninstall, applyUninstall };
|
package/bin/wizard.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The installer's wizard, as data (spec ui-first-approvals R15-R17). Pure: no terminal, clock or
|
|
3
|
+
// filesystem. bin/riskrails.js draws the steps with bin/menu.js and prints what these return.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const CLAUDE_PLANS = [['pro', 'Pro'], ['team-pro', 'Team Standard seat'], ['team-max', 'Team Premium seat'],
|
|
7
|
+
['max', 'Max'], ['max20', "Max 20x: max's settings, larger budgets"],
|
|
8
|
+
['balanced-max', 'Max account, only the session runs Opus 5.5 (200k window)']];
|
|
9
|
+
const CODEX_PLANS = [['plus', 'ChatGPT Plus'], ['pro', 'ChatGPT Pro'], ['balanced-max', 'only the session runs the strong model']];
|
|
10
|
+
const FABLE_PLANS = ['max', 'max20', 'team-max'];
|
|
11
|
+
|
|
12
|
+
const items = (rows, checked) => rows.map(([value, note]) => ({ value, label: value, note,
|
|
13
|
+
...(checked ? { checked: checked.includes(value) } : {}) }));
|
|
14
|
+
const initial = (rows, value) => Math.max(0, rows.findIndex(([v]) => v === value));
|
|
15
|
+
|
|
16
|
+
// The steps for what is detected: { claude, codex (booleans: the runtimes selected), plan, codexPlan }.
|
|
17
|
+
// Each is a menu spec { id, title, items:[{value,label,note}], initial, multi? } plus step/of/trail
|
|
18
|
+
// for the stepper line. After the runtimes step the caller asks again with the picked runtimes.
|
|
19
|
+
function steps(detected) {
|
|
20
|
+
const { claude, codex, plan, codexPlan } = detected;
|
|
21
|
+
const list = [{ id: 'runtimes', title: 'runtimes', multi: true, initial: 0,
|
|
22
|
+
items: items([['claude', 'Claude Code: agents, hooks, skills, settings'], ['codex', 'Codex: agents, hooks, config.toml']],
|
|
23
|
+
[claude && 'claude', codex && 'codex'].filter(Boolean)) }];
|
|
24
|
+
if (claude) list.push({ id: 'plan', title: 'plan', initial: initial(CLAUDE_PLANS, plan), items: items(CLAUDE_PLANS) });
|
|
25
|
+
if (claude && FABLE_PLANS.includes(plan)) {
|
|
26
|
+
list.push({ id: 'fable', title: 'fable', initial: 0,
|
|
27
|
+
items: items([['yes', 'Fable 5.1 pinned on architect, the runtime gate on'], ['no', 'no Fable anywhere in this install']]) });
|
|
28
|
+
}
|
|
29
|
+
list.push({ id: 'context', title: 'context', initial: 0, items: items([['default', "each runtime's own context window and compaction"],
|
|
30
|
+
['economy', "the plan's autoCompactWindow; a prompt past 120% is held back"]]) });
|
|
31
|
+
if (claude) {
|
|
32
|
+
list.push({ id: 'statusline', title: 'statusline', initial: 0, items: items([
|
|
33
|
+
['riskrails', "the plugin's statusline over your own command (kept, --statusline own brings it back)"],
|
|
34
|
+
['own', 'your own statusline command, behind the quota check']]) });
|
|
35
|
+
}
|
|
36
|
+
if (codex) list.push({ id: 'codex plan', title: 'codex plan', initial: initial(CODEX_PLANS, codexPlan), items: items(CODEX_PLANS) });
|
|
37
|
+
list.push({ id: 'review', title: 'review', initial: 0, items: items([['install', 'write the files now'],
|
|
38
|
+
['back', 'change an answer'], ['cancel', 'exit; nothing is written']]) });
|
|
39
|
+
const trail = list.map((s) => s.id);
|
|
40
|
+
return list.map((s, i) => ({ ...s, step: i + 1, of: list.length, trail }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The flags that repeat the choices: { target, plan, fable, statusline, codexPlan, context, version }.
|
|
44
|
+
// A choice left undefined is not passed. Returns { argv, text }; text is argv as one line.
|
|
45
|
+
function flags(choices) {
|
|
46
|
+
const { target, plan, fable, statusline, codexPlan, context, version } = choices;
|
|
47
|
+
const claude = target !== 'codex', codex = target !== 'claude';
|
|
48
|
+
const argv = ['npx', `riskrails@${version}`, '--target', target];
|
|
49
|
+
if (claude && plan) argv.push('--plan', plan);
|
|
50
|
+
if (claude && fable) argv.push('--fable', fable);
|
|
51
|
+
if (claude && statusline) argv.push('--statusline', statusline);
|
|
52
|
+
if (codex && codexPlan) argv.push('--codex-plan', codexPlan);
|
|
53
|
+
argv.push('--context', context || 'default', '--yes');
|
|
54
|
+
return { argv, text: argv.join(' ') };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The review screen's lines, above the Install / Back / Cancel menu.
|
|
58
|
+
function reviewLines(dryRunText, command) {
|
|
59
|
+
return ['Review — nothing is written until you pick Install', '', ...dryRunText.replace(/\n+$/, '').split('\n'), '',
|
|
60
|
+
`same as: ${command}`];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The final box: result = { command, next }; next defaults to the setup of a project.
|
|
64
|
+
function finalBox(result) {
|
|
65
|
+
const body = ['riskrails is installed', `next: ${result.next || '/rr-setup in a project, to survey it and build .ai/'}`,
|
|
66
|
+
`same as: ${result.command}`];
|
|
67
|
+
const width = Math.max(...body.map((l) => [...l].length));
|
|
68
|
+
const bar = '─'.repeat(width + 2);
|
|
69
|
+
return [`┌${bar}┐`, ...body.map((l) => `│ ${l}${' '.repeat(width - [...l].length)} │`), `└${bar}┘`];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { steps, flags, reviewLines, finalBox };
|
package/hooks/context-guard.py
CHANGED
|
@@ -740,6 +740,15 @@ def take_snapshot(session, transcript):
|
|
|
740
740
|
return text
|
|
741
741
|
|
|
742
742
|
|
|
743
|
+
def dialog_is_on(runtime, payload):
|
|
744
|
+
"""The dialog's effective switch (hooks/guard/dialog.py); False when the guard package is missing."""
|
|
745
|
+
try:
|
|
746
|
+
from guard import dialog # pylint: disable=import-outside-toplevel
|
|
747
|
+
return dialog.effective_switch(ai_root(payload.get("cwd")), runtime, payload)
|
|
748
|
+
except Exception: # pylint: disable=broad-except
|
|
749
|
+
return False
|
|
750
|
+
|
|
751
|
+
|
|
743
752
|
def update_notice_for(payload):
|
|
744
753
|
"""The npm update notice for a startup, as (systemMessage, additionalContext); fails open."""
|
|
745
754
|
if payload.get("source") not in (None, "", "startup"):
|
|
@@ -752,7 +761,8 @@ def update_notice_for(payload):
|
|
|
752
761
|
if runtime == "codex":
|
|
753
762
|
home = os.environ.get("CODEX_HOME") or os.path.expanduser("~/.codex")
|
|
754
763
|
plugin_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
755
|
-
return update_notice.notice(runtime, home, plugin_root, time.time()
|
|
764
|
+
return update_notice.notice(runtime, home, plugin_root, time.time(),
|
|
765
|
+
dialog=dialog_is_on(runtime, payload))
|
|
756
766
|
except Exception: # pylint: disable=broad-except
|
|
757
767
|
return "", ""
|
|
758
768
|
|
package/hooks/dialog-gate.py
CHANGED
|
@@ -61,8 +61,9 @@ def on_stop(dialog, root, runtime, payload):
|
|
|
61
61
|
blocks = sum(1 for e in dialog.read_ledger(root, types={dialog.STOP_BLOCK})
|
|
62
62
|
if e.get("session") == session and e.get("turn") == turn and e.get("decisions") == ids)
|
|
63
63
|
if blocks >= policy["stop_blocks_max"]:
|
|
64
|
-
return {"systemMessage": "DECISION_PENDING %s is still open.
|
|
65
|
-
|
|
64
|
+
return {"systemMessage": "DECISION_PENDING %s is still open. Reply in this chat (for example \"ask me "
|
|
65
|
+
"again\") and the session sends you the dialog; or settle it in your "
|
|
66
|
+
"terminal: %s" % (" ".join(ids), escape)}
|
|
66
67
|
with dialog.ledger_lock(root):
|
|
67
68
|
dialog.ledger_append(root, {"type": dialog.STOP_BLOCK, "session": session, "turn": turn,
|
|
68
69
|
"decisions": ids})
|
package/hooks/guard/decision.py
CHANGED
|
@@ -7,7 +7,7 @@ pending, every tool is denied (DECISION_PENDING and the JSON to send) except:
|
|
|
7
7
|
|
|
8
8
|
1. the dialog tool, when its questions equal what state.py prepared (R36.5);
|
|
9
9
|
the call is appended as an `offer`;
|
|
10
|
-
2. a Bash command equal to the next live pick (R36.7); a git pick's `use` is
|
|
10
|
+
2. a Bash command equal to the next live pick (R36.7); a git or npx pick's `use` is
|
|
11
11
|
appended here and handed to the git check on ctx.consent, a state.py pick
|
|
12
12
|
is spent by state.py itself after its argv check (R36.10);
|
|
13
13
|
3. `state.py decision show|settle|fallback`.
|
|
@@ -82,13 +82,14 @@ def gate(ctx, root): # pylint: disable=too-many-locals
|
|
|
82
82
|
if ctx.payload.tool == "Bash":
|
|
83
83
|
pick = dialog.next_live_pick(root, session, datetime.now(timezone.utc))
|
|
84
84
|
if pick and pick.get("decision") in ids and pick.get("label") == command:
|
|
85
|
-
if dialog.is_git_label(command):
|
|
85
|
+
if dialog.is_git_label(command) or dialog.is_npx_label(command):
|
|
86
86
|
with dialog.ledger_lock(root):
|
|
87
87
|
# Read again under the lock: two calls must not spend one pick.
|
|
88
88
|
again = dialog.next_live_pick(root, session, datetime.now(timezone.utc))
|
|
89
89
|
if not again or again.get("id") != pick["id"]:
|
|
90
90
|
raise Deny(text_to_b(pending_text))
|
|
91
|
-
dialog.ledger_append(root, {"type": "use", "id": pick["id"],
|
|
91
|
+
dialog.ledger_append(root, {"type": "use", "id": pick["id"],
|
|
92
|
+
"by": command.split()[0]})
|
|
92
93
|
ctx.consent = pick
|
|
93
94
|
raise Allow()
|
|
94
95
|
if dialog.guarded(command):
|