riskrails 4.1.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.
Files changed (92) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +25 -15
  5. package/agents/ai-reviewer.md.tmpl +14 -1
  6. package/ai/agents/implementer.md +6 -0
  7. package/ai/agents/reviewer.md +8 -1
  8. package/bin/ansi.js +12 -0
  9. package/bin/menu.js +71 -12
  10. package/bin/riskrails.js +240 -14
  11. package/bin/uninstall.js +115 -0
  12. package/bin/wizard.js +72 -0
  13. package/hooks/AGENTS.md +0 -6
  14. package/hooks/CLAUDE.md +0 -6
  15. package/hooks/context-guard.py +11 -1
  16. package/hooks/dialog-gate.py +3 -2
  17. package/hooks/guard/decision.py +4 -3
  18. package/hooks/guard/dialog.py +12 -2
  19. package/hooks/guard/ere.py +29 -10
  20. package/hooks/guard/git.py +2 -1
  21. package/hooks/guard/paths.py +48 -1406
  22. package/hooks/guard/paths_base.py +66 -0
  23. package/hooks/guard/paths_bash.py +176 -0
  24. package/hooks/guard/paths_classify.py +205 -0
  25. package/hooks/guard/paths_fs.py +137 -0
  26. package/hooks/guard/paths_human.py +105 -0
  27. package/hooks/guard/paths_interp.py +162 -0
  28. package/hooks/guard/paths_movers.py +393 -0
  29. package/hooks/guard/paths_shell.py +288 -0
  30. package/hooks/runtime-gate.py +87 -0
  31. package/hooks/update_notice.py +23 -8
  32. package/package.json +1 -1
  33. package/skills/AGENTS.md +0 -6
  34. package/skills/CLAUDE.md +0 -6
  35. package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
  36. package/skills/ai-init/templates/gitignore.snippet +2 -0
  37. package/skills/ai-task/memory_cleanup.py +213 -0
  38. package/skills/ai-task/memory_compact.py +25 -0
  39. package/skills/ai-task/memory_config.py +120 -0
  40. package/skills/ai-task/memory_export.py +246 -0
  41. package/skills/ai-task/memory_format.py +7 -3
  42. package/skills/ai-task/memory_hook.py +172 -0
  43. package/skills/ai-task/memory_io.py +122 -0
  44. package/skills/ai-task/memory_migrate.py +187 -0
  45. package/skills/ai-task/memory_packages.py +83 -0
  46. package/skills/ai-task/memory_risks.py +84 -0
  47. package/skills/ai-task/memory_status.py +76 -0
  48. package/skills/ai-task/memory_sync.py +20 -1481
  49. package/skills/ai-task/memory_verify.py +316 -0
  50. package/skills/ai-task/rr_state/cli.py +1 -0
  51. package/skills/ai-task/rr_state/close.py +2 -2
  52. package/skills/ai-task/rr_state/dialog.py +32 -7
  53. package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
  54. package/skills/ai-task/rr_state/gate.py +10 -4
  55. package/skills/ai-task/rr_state/labels.py +33 -4
  56. package/skills/ai-task/rr_state/memory_cmd.py +84 -64
  57. package/skills/ai-task/rr_state/questions_cmd.py +5 -4
  58. package/skills/ai-task/rr_state/report.py +73 -10
  59. package/skills/ai-task/rr_state/report_cmd.py +2 -0
  60. package/skills/ai-task/rr_state/review.py +18 -14
  61. package/skills/ai-task/rr_state/start.py +12 -9
  62. package/skills/ai-task/rr_state/steps.py +16 -7
  63. package/skills/ai-task/rr_state/verify.py +17 -12
  64. package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
  65. package/skills/project-update/history/blobs/fa0cb370b024e1a30e1355912032657b08f609e47773a603c193b1e609b0f997 +8 -0
  66. package/skills/project-update/history/index.json +8 -4
  67. package/skills/project-update/rr_update/__init__.py +1 -0
  68. package/skills/project-update/rr_update/apply.py +188 -0
  69. package/skills/project-update/rr_update/blocks.py +38 -0
  70. package/skills/project-update/rr_update/build.py +96 -0
  71. package/skills/project-update/rr_update/cleanup.py +194 -0
  72. package/skills/project-update/rr_update/cli.py +102 -0
  73. package/skills/project-update/rr_update/files.py +106 -0
  74. package/skills/project-update/rr_update/gate.py +60 -0
  75. package/skills/project-update/rr_update/hints.py +128 -0
  76. package/skills/project-update/rr_update/history.py +117 -0
  77. package/skills/project-update/rr_update/kept.py +120 -0
  78. package/skills/project-update/rr_update/paths.py +108 -0
  79. package/skills/project-update/rr_update/plan.py +123 -0
  80. package/skills/project-update/rr_update/report.py +51 -0
  81. package/skills/project-update/rr_update/rules.py +178 -0
  82. package/skills/project-update/rr_update/shared.py +74 -0
  83. package/skills/project-update/rr_update/siblings.py +37 -0
  84. package/skills/project-update/rr_update/weights.py +171 -0
  85. package/skills/project-update/update.py +8 -1682
  86. package/skills/rr-build/SKILL.md +5 -2
  87. package/skills/rr-setup/SKILL.md +4 -2
  88. package/skills/rr-setup/setup.py +13 -12
  89. package/skills/rr-ship/SKILL.md +2 -2
  90. package/skills/rr-spec/SKILL.md +6 -0
  91. package/skills/rr-status/SKILL.md +2 -0
  92. 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
- const now = new Date();
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
- // On a terminal, with nothing naming the runtime, the detected set is the menu's first proposal.
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 (TARGET === 'auto' && !TARGET_GIVEN && !CLAUDE_DIR_GIVEN && !CODEX_DIR_GIVEN) {
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. A second run on the same day replaces that copy.
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', BACKUP_DATE, rel);
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/${BACKUP_DATE}/${rel}`);
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', BACKUP_DATE, rel);
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/${BACKUP_DATE}/${rel}`);
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', BACKUP_DATE, rel);
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/${BACKUP_DATE}/${rel}`);
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', BACKUP_DATE, 'skills');
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/${BACKUP_DATE}/skills/ (it was loading as a duplicate skill)`);
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/${BACKUP_DATE}/config.toml holds the old one.`,
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);
@@ -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/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 -->
@@ -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
 
@@ -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. Settle it in your terminal: %s"
65
- % (" ".join(ids), escape)}
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})
@@ -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"], "by": "git"})
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):