riskrails 3.0.0 → 3.0.1

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 (81) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/README.md +246 -812
  5. package/agents/ai-reviewer.md.tmpl +6 -4
  6. package/ai/policies/review-economy.md +7 -2
  7. package/ai/using-riskrails.md +15 -0
  8. package/bin/claude-1m +15 -14
  9. package/bin/riskrails.js +108 -32
  10. package/codex/hooks.json +25 -2
  11. package/hooks/AGENTS.md +2 -2
  12. package/hooks/CLAUDE.md +2 -2
  13. package/hooks/ai-guard.py +29 -8
  14. package/hooks/ai-path-guard-defaults.json +3 -1
  15. package/hooks/codex-hooks.json +25 -2
  16. package/hooks/context-guard.py +251 -35
  17. package/hooks/dialog-gate.py +105 -0
  18. package/hooks/guard/common.py +5 -0
  19. package/hooks/guard/decision.py +123 -0
  20. package/hooks/guard/dialog.py +598 -0
  21. package/hooks/guard/git.py +49 -3
  22. package/hooks/guard/paths.py +62 -3
  23. package/hooks/hooks.json +37 -2
  24. package/hooks/memory-pre-push.py +28 -0
  25. package/hooks/runtime-gate.py +59 -12
  26. package/hooks/update_notice.py +135 -0
  27. package/instructions/routing.md +4 -4
  28. package/instructions/stub.md +2 -2
  29. package/package.json +1 -1
  30. package/profiles/max.json +3 -1
  31. package/profiles/pro.json +3 -1
  32. package/scripts/merge-codex-config.py +134 -0
  33. package/scripts/resolve-profile.py +15 -3
  34. package/settings.common.json +39 -2
  35. package/skills/AGENTS.md +2 -2
  36. package/skills/CLAUDE.md +2 -2
  37. package/skills/ai-init/templates/.ai/AGENTS.md +2 -2
  38. package/skills/ai-init/templates/.ai/VERSION +1 -1
  39. package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
  40. package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
  41. package/skills/ai-init/templates/.ai/policies/weight.json +1 -0
  42. package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
  43. package/skills/ai-init/templates/.ai/state/README.md +9 -6
  44. package/skills/ai-init/templates/AGENTS.block.md +1 -1
  45. package/skills/ai-init/templates/CLAUDE.block.md +1 -1
  46. package/skills/ai-init/templates/GEMINI.block.md +1 -1
  47. package/skills/ai-init/templates/junie-guidelines.block.md +1 -1
  48. package/skills/ai-task/memory_compact.py +968 -0
  49. package/skills/ai-task/memory_format.py +775 -0
  50. package/skills/ai-task/memory_store.py +528 -0
  51. package/skills/ai-task/memory_sync.py +1571 -0
  52. package/skills/ai-task/sensors.py +2 -2
  53. package/skills/ai-task/state.py +967 -70
  54. package/skills/ai-task/weight.py +3 -3
  55. package/skills/project-init/templates/constitution.md +1 -1
  56. package/skills/project-update/history/blobs/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
  57. package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
  58. package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -0
  59. package/skills/project-update/history/blobs/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
  60. package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -0
  61. package/skills/project-update/history/blobs/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
  62. package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
  63. package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
  64. package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
  65. package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
  66. package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
  67. package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +13 -0
  68. package/skills/project-update/history/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
  69. package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
  70. package/skills/project-update/history/index.json +32 -13
  71. package/skills/project-update/migrations/0009_state_file.py +25 -0
  72. package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
  73. package/skills/project-update/migrations/README.md +2 -1
  74. package/skills/project-update/update.py +12 -3
  75. package/skills/rr-build/SKILL.md +14 -5
  76. package/skills/rr-build-all/SKILL.md +190 -0
  77. package/skills/rr-plan/SKILL.md +13 -3
  78. package/skills/rr-review/SKILL.md +18 -8
  79. package/skills/rr-ship/SKILL.md +16 -8
  80. package/skills/rr-status/SKILL.md +2 -2
  81. package/skills/usage-report/usage-report.py +41 -8
@@ -47,12 +47,14 @@ does each named finding close against the real code, and what did the remediatio
47
47
  introduce? A re-review that re-reads the whole change is a first review wearing
48
48
  the wrong name, and costs the same.
49
49
 
50
- Verify by execution where execution is possible. A claim you ran beats a claim you
51
- reasoned to, and it is what makes your row in the ledger worth inheriting.
50
+ Read the diff; run only the tests the step names, through `step_test_command`; no
51
+ scripts, mutation or timing loops in a scratchpad or temp dir. About 40 tool calls
52
+ in light and guarded, about 80 in full, then FINDINGS and VERDICT, with
53
+ `not reached (budget): …` under EXAMINED AND CLEAN.
52
54
 
53
55
  ## Boundary
54
56
 
55
- The brief names the task's mode; `.ai/state/current.json` has it otherwise. In
57
+ The brief names the task's mode; `.ai/state/state.json` (or the legacy `current.json`) has it otherwise. In
56
58
  light and guarded mode your inputs are the task goal, the diff, the tests the
57
59
  step names, and the project's own fixture corpus and threat model:
58
60
  `.ai/policies/safety.md`, the guards' `*-defaults.json`, the test fixtures.
@@ -61,7 +63,7 @@ it — a shape no session produces, a size no fixture reaches — is reported as
61
63
  `[INFO]` with `in_scope: no`, with the severity it would carry inside the
62
64
  boundary in `why it matters`. It goes to the risk register, never to a
63
65
  remediation, and it does not move your verdict. In full mode there is no
64
- boundary: the list below applies unbounded.
66
+ boundary: the list below applies unbounded, but the call cap holds.
65
67
 
66
68
  ## Look for
67
69
 
@@ -91,7 +91,11 @@ A task gets **two passes**: one full review and one scoped re-review, at every
91
91
  tier. That holds whoever asks — a re-review the human requests is one of the
92
92
  two, a cross-vendor review is one, and the parallel dimensions of §4 together
93
93
  are one. When both are spent, a third pass is not the next move: the human
94
- decides the verdict. The reviewer's verdict says which closing rule applies:
94
+ decides the verdict. One exception, chosen per project or per task: a **batch**
95
+ task (`/rr-build-all`, `weight.json` `batch.*`) runs a whole plan as one task,
96
+ and its passes are full ones — `batch.review_rounds_max` of them (three as
97
+ shipped), each after a fix batch — before the same stop: the pass past that
98
+ number is the human's. The reviewer's verdict says which closing rule applies:
95
99
  `blockers_open` (a BLOCKER) takes the remediation and the scoped re-review;
96
100
  `pass_with_findings` (a HIGH, no BLOCKER) takes the remediation and a green
97
101
  verification run, no re-review; `pass` closes. `state.py` has no value of its
@@ -204,6 +208,7 @@ work.
204
208
  - No reviewer is moved to a cheaper model to go faster. Narrow the surface, never
205
209
  the mind.
206
210
  - A blocker still returns to implementation, however late it arrives. Past the
207
- two passes of §3, a human — not a third pass — decides that it closed.
211
+ two passes of §3 — `batch.review_rounds_max` passes in a batch task — a human,
212
+ not another pass, decides that it closed.
208
213
  - The probe is the manager's own work and never replaces the adversarial pass. It
209
214
  exists so the pass starts further in, not so it happens less.
@@ -13,6 +13,7 @@ the steps; `policies/` holds the rules; this says which to open.
13
13
  | turn an idea into intent, requirements and design | `/rr-spec <idea>` |
14
14
  | plan a spec into tasks, or one task into a mode and a start | `/rr-plan <spec or task>` |
15
15
  | do the work, or resume it | `/rr-build` (no task in flight: it runs `/rr-plan` first) |
16
+ | run a whole plan as one task — parallel implementers and fixers, tests and the review again until it passes | `/rr-build-all <plan>` |
16
17
  | review the change | `/rr-review` — guarded and full, or when `close` asks |
17
18
  | close the task, get the human's approval | `/rr-ship` |
18
19
  | see where the task stands, or what it cost | `/rr-status`, `/rr-status --cost` |
@@ -38,6 +39,20 @@ domain at 2 is full whatever W is.
38
39
  The mode only rises during a task (`verify` and `close` re-score reversibility
39
40
  and domain from the diff); lowering it is the human's.
40
41
 
42
+ ## Batch — one task for a whole plan
43
+
44
+ `/rr-build-all docs/sdlc/plans/<slug>.md` runs every row of a programme plan as
45
+ one task: the ready rows go to parallel implementers on BALANCED (`after`
46
+ serializes the dependent ones), the suite runs once, every failure is fixed by
47
+ parallel fixers as one batch, one review at the mode's tier, its findings fixed
48
+ as one batch, then tests and the review again until it passes. Its budgets are
49
+ `weight.json` `batch.*`: three failed review passes (`batch.review_rounds_max`)
50
+ and eight suite runs stop at the human — `REVIEW_BUDGET` and `SUITE_BUDGET`, as
51
+ always. The mode, the guard, the two facts and the gate are unchanged.
52
+ `batch.default` makes it the project's default; `state.py batch off --by …
53
+ --why …` returns a task to the standard loop (anyone); `batch on` mid-task is
54
+ the human's, from their own terminal.
55
+
41
56
  ## The two facts
42
57
 
43
58
  `close` measures, never asks: (a) a green whole-suite `verify` on the tree as it
package/bin/claude-1m CHANGED
@@ -6,15 +6,15 @@
6
6
  # claude-1m Opus 5 [1m] as the main session
7
7
  # claude-1m fable Fable 5.1 [1m] as the main session
8
8
  # claude-1m fable -p '<brief>' one headless large-context run, e.g. called from another session
9
- # CLAUDE_1M_COMPACT_WINDOW=500000 claude-1m # compact earlier than 767k
9
+ # CLAUDE_1M_COMPACT_WINDOW=500000 claude-1m # compact earlier than the runtime would
10
10
  #
11
- # `autoCompactWindow` in settings.json is one value for every model, but Claude Code
12
- # caps it at the model's own window, so the 800 000 there already means near 167k on
13
- # `opus` and near 767k on `opus[1m]` and `fable[1m]`. This launcher is therefore about
14
- # the model, not the window: it pins a [1m] model for one session at launch.
15
- # CLAUDE_CODE_AUTO_COMPACT_WINDOW still outranks settings.json for this process only,
16
- # which is how CLAUDE_1M_COMPACT_WINDOW compacts *earlier* than 767k. context-guard
17
- # reads the same variable, so its warning and hold-back thresholds follow.
11
+ # This launcher is about the model, not the window: it pins a [1m] model for one
12
+ # session at launch, and by default leaves compaction to Claude Code (or to the
13
+ # `autoCompactWindow` of an install with `--context economy`, which Claude Code caps at
14
+ # the model's own window: 800 000 means near 167k on `opus` and near 767k on `opus[1m]`).
15
+ # Only CLAUDE_1M_COMPACT_WINDOW sets a window: exported as CLAUDE_CODE_AUTO_COMPACT_WINDOW,
16
+ # it outranks settings.json for this process only. context-guard reads the same
17
+ # variable, so its warning and hold-back thresholds follow.
18
18
  #
19
19
  # Subagents get the session's window, capped by Claude Code at each model's own: a 200k
20
20
  # agent compacts near 167k, and `architect` on fable[1m] gets the full window.
@@ -28,10 +28,11 @@ case "${1:-}" in
28
28
  fable) MODEL='fable[1m]'; shift;;
29
29
  esac
30
30
 
31
- WINDOW="${CLAUDE_1M_COMPACT_WINDOW:-800000}"
32
- case "$WINDOW" in
33
- ''|*[!0-9]*) echo "claude-1m: CLAUDE_1M_COMPACT_WINDOW must be a number of tokens (got '$WINDOW')" >&2; exit 2;;
34
- esac
35
-
36
- export CLAUDE_CODE_AUTO_COMPACT_WINDOW="$WINDOW"
31
+ if [ -n "${CLAUDE_1M_COMPACT_WINDOW:-}" ]; then
32
+ WINDOW="$CLAUDE_1M_COMPACT_WINDOW"
33
+ case "$WINDOW" in
34
+ ''|*[!0-9]*) echo "claude-1m: CLAUDE_1M_COMPACT_WINDOW must be a number of tokens (got '$WINDOW')" >&2; exit 2;;
35
+ esac
36
+ export CLAUDE_CODE_AUTO_COMPACT_WINDOW="$WINDOW"
37
+ fi
37
38
  exec "${CLAUDE_BIN:-claude}" --model "$MODEL" "$@"
package/bin/riskrails.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // RiskRails installer (Claude Code and/or Codex). Node 18+, stdlib only.
3
3
  // npx riskrails [--target auto|claude|codex|both] [--plan pro|team-pro|team-max|max|max20|balanced-max]
4
4
  // [--fable auto|yes|no] [--codex-plan plus|pro|balanced-max] [--dry-run]
5
+ // [--context default|economy] [--statusline-context yes|no]
5
6
  // [--home DIR] [--codex-home DIR] [--yes] [--version]
6
7
  // --target defaults to auto: each runtime is installed only if its executable is on PATH.
7
8
  // --plan Claude only: pro, team-pro (Team Standard seat), team-max (Team Premium seat), max
@@ -16,6 +17,14 @@
16
17
  // --fable Claude only. auto = yes on max, max20 and team-max, no on pro and team-pro.
17
18
  // --codex-plan Codex only: plus, pro or balanced-max. Defaults to auto-detect from the ChatGPT
18
19
  // login in ~/.codex/auth.json (chatgpt_plan_type); assumes pro when it cannot tell.
20
+ // --context default leaves each runtime its own context window and compaction, and
21
+ // context-guard only warns. economy writes the plan's autoCompactWindow (800 000 on
22
+ // Max, 300 000 on Pro) and the guard also holds a prompt back past 120% of it.
23
+ // Without economy, an autoCompactWindow equal to the plan's is removed; any other
24
+ // value is the user's and stays. Recorded in riskrails/profile.json as `context`.
25
+ // --statusline-context yes (default) shows the model and the context use in the
26
+ // statusline: a line before the user's own in Claude Code, `model-with-reasoning`
27
+ // and `context-used` in Codex's [tui] status_line. no adds nothing and removes nothing.
19
28
  // --home / --codex-home the runtime homes (default ~/.claude and ~/.codex; the environment
20
29
  // variables CLAUDE_DIR and CODEX_DIR mean the same). Naming exactly one of them under
21
30
  // --target auto installs that runtime alone.
@@ -47,6 +56,7 @@ let CODEX_DIR = env.CODEX_DIR || path.join(os.homedir(), '.codex');
47
56
  const HOME = env.HOME || os.homedir();
48
57
  let PLAN = '', FABLE = 'auto', DRY = false, TARGET = 'auto', PLAN_GIVEN = false;
49
58
  let CODEX_PLAN = '', CODEX_PLAN_GIVEN = false, YES = false;
59
+ let CONTEXT = 'default', STATUSLINE_CONTEXT = 'yes';
50
60
 
51
61
  function version() {
52
62
  try { return JSON.parse(fs.readFileSync(path.join(SRC, 'package.json'), 'utf8')).version || 'unknown'; }
@@ -76,6 +86,8 @@ function usage() {
76
86
  case '--plan': PLAN = take(); PLAN_GIVEN = true; break;
77
87
  case '--fable': FABLE = take(); PLAN_GIVEN = true; break;
78
88
  case '--codex-plan': CODEX_PLAN = take(); CODEX_PLAN_GIVEN = true; break;
89
+ case '--context': CONTEXT = take(); break;
90
+ case '--statusline-context': STATUSLINE_CONTEXT = take(); break;
79
91
  case '--home': CLAUDE_DIR = path.resolve(take()); CLAUDE_DIR_GIVEN = true; break;
80
92
  case '--codex-home': CODEX_DIR = path.resolve(take()); CODEX_DIR_GIVEN = true; break;
81
93
  case '--dry-run': DRY = true; break;
@@ -89,6 +101,10 @@ function usage() {
89
101
  if (!['auto', 'claude', 'codex', 'both'].includes(TARGET)) {
90
102
  die(`--target must be auto|claude|codex|both (got '${TARGET}')`, 2);
91
103
  }
104
+ if (!['default', 'economy'].includes(CONTEXT)) die(`--context must be default|economy (got '${CONTEXT}')`, 2);
105
+ if (!['yes', 'no'].includes(STATUSLINE_CONTEXT)) {
106
+ die(`--statusline-context must be yes|no (got '${STATUSLINE_CONTEXT}')`, 2);
107
+ }
92
108
  const major = Number(process.versions.node.split('.')[0]);
93
109
  if (major < 18) die('riskrails needs Node 18 or newer (this is ' + process.versions.node + ')', 1);
94
110
 
@@ -281,7 +297,7 @@ let PLUGIN_VERSION = 'unknown';
281
297
  try { PLUGIN_VERSION = readJSON(path.join(SRC, '.codex-plugin/plugin.json')).version || 'unknown'; } catch (e) { /* unknown */ }
282
298
  function agenticProfile(runtime, profile, plan, label, fable) {
283
299
  const p = JSON.parse(python([path.join(SRC, 'scripts/resolve-profile.py'), profile, '--src', SRC, '--plan', plan,
284
- '--label', label, '--fable', fable, '--print', 'agentic']));
300
+ '--label', label, '--fable', fable, '--context', CONTEXT, '--print', 'agentic']));
285
301
  p.runtime = runtime; p.plugin_version = PLUGIN_VERSION; p.written_at = utcStamp();
286
302
  p.dialog = { tool: runtime === 'claude' ? 'AskUserQuestion' : codexDialogTool() };
287
303
  p.set_by = { via: 'installer', at: p.written_at };
@@ -391,7 +407,9 @@ function claudeRender() {
391
407
 
392
408
  // The profile's riskrails tables are the plugin's, not Claude Code's: the resolver
393
409
  // prints the settings half alone, so they never reach settings.json.
394
- const profile = JSON.parse(python([path.join(SRC, 'scripts/resolve-profile.py'), PROFILE_NAME, '--src', SRC, '--print', 'settings']));
410
+ // --context economy puts the plan's autoCompactWindow into that half; default leaves it out.
411
+ const profile = JSON.parse(python([path.join(SRC, 'scripts/resolve-profile.py'), PROFILE_NAME, '--src', SRC,
412
+ '--context', CONTEXT, '--print', 'settings']));
395
413
  if (TIER === 'max' && FABLE === 'no') {
396
414
  profile.availableModels = (profile.availableModels || []).filter((m) => !String(m).startsWith('fable'));
397
415
  if (isObj(profile.modelSettings)) delete profile.modelSettings['claude-fable-5-1'];
@@ -404,18 +422,26 @@ function claudeRender() {
404
422
  const fallbacks = Array.isArray(snippet.fallbackModel) ? snippet.fallbackModel : [snippet.fallbackModel];
405
423
  const FALLBACK = fallbacks.join(', then ');
406
424
  const EFFORT = String(snippet.effortLevel);
407
- const COMPACT = Number(snippet.autoCompactWindow);
408
- // Auto-compaction fires about 33k under the window; context-guard.py derives its
409
- // thresholds from the same point. Claude Code caps autoCompactWindow at the model's
410
- // own window, so print the number that matches the session this install configures.
425
+ // The plan's own window (riskrails.economy), written only with --context economy; in
426
+ // default the runtime compacts against the model's window. Auto-compaction fires about
427
+ // 33k under the window; context-guard.py derives its thresholds from the same point and
428
+ // holds a prompt back only when a window is configured. Claude Code caps
429
+ // autoCompactWindow at the model's own window, so print the number that matches the
430
+ // session this install configures.
431
+ const COMPACT = CONTEXT === 'economy' ? Number(snippet.autoCompactWindow) || 0 : 0;
411
432
  const MODEL_WINDOW = SESSION_MODEL.includes('[1m]') ? 1000000 : 200000;
412
- const COMPACT_AT = Math.min(COMPACT, MODEL_WINDOW) - 33000;
413
- const oneM = read(path.join(SRC, 'bin/claude-1m')).match(/^WINDOW="\$\{CLAUDE_1M_COMPACT_WINDOW:-([0-9]*)\}"$/m);
414
- const ONE_M_AT = Number(oneM ? oneM[1] : 0) - 33000;
433
+ const fires = (window) => (COMPACT ? Math.min(COMPACT, window) : window) - 33000;
434
+ const COMPACT_AT = fires(MODEL_WINDOW);
435
+ const ONE_M_AT = fires(1000000);
436
+ const COMPACT_NOTE = COMPACT ? `\`autoCompactWindow\` ${COMPACT}` : 'the runtime default, `--context economy` sets a window';
415
437
  const ONE_M_FABLE = FABLE === 'yes' ? ' and `claude-1m fable` for Fable 5.1 [1m]' : '';
416
- let ONE_M_RULE = `One \`autoCompactWindow\` of ${COMPACT} serves every model: Claude Code caps it at the model's own window, so this session compacts near ${COMPACT_AT} and a \`[1m]\` one near ${ONE_M_AT}, whether it was started with \`claude-1m\` (\`~/.claude/bin/claude-1m\`) for \`opus[1m]\`${ONE_M_FABLE} or switched to with \`/model\`. \`claude-1m\` pins the model at launch and \`CLAUDE_1M_COMPACT_WINDOW\` lowers the window for that one process; it is no longer what grants the large window. When a task inside an ordinary session needs one large-context read, say so and, once the user agrees, run \`claude-1m -p '<brief>'\` from Bash — its own process, only the answer comes back.`;
438
+ let ONE_M_RULE = COMPACT
439
+ ? `One \`autoCompactWindow\` of ${COMPACT} serves every model: Claude Code caps it at the model's own window, so this session compacts near ${COMPACT_AT} and a \`[1m]\` one near ${ONE_M_AT}, whether it was started with \`claude-1m\` (\`~/.claude/bin/claude-1m\`) for \`opus[1m]\`${ONE_M_FABLE} or switched to with \`/model\`. \`claude-1m\` pins the model at launch and \`CLAUDE_1M_COMPACT_WINDOW\` lowers the window for that one process; it is no longer what grants the large window. When a task inside an ordinary session needs one large-context read, say so and, once the user agrees, run \`claude-1m -p '<brief>'\` from Bash — its own process, only the answer comes back.`
440
+ : `Compaction is at the runtime default: each model compacts against its own window, so this session near ${COMPACT_AT} and a \`[1m]\` one near ${ONE_M_AT}, whether it was started with \`claude-1m\` (\`~/.claude/bin/claude-1m\`) for \`opus[1m]\`${ONE_M_FABLE} or switched to with \`/model\`. \`claude-1m\` pins the model at launch and \`CLAUDE_1M_COMPACT_WINDOW\` sets an earlier window for that one process. When a task inside an ordinary session needs one large-context read, say so and, once the user agrees, run \`claude-1m -p '<brief>'\` from Bash — its own process, only the answer comes back.`;
417
441
  if (MODEL_WINDOW > 200000) {
418
- ONE_M_RULE = `\`autoCompactWindow\` ${COMPACT} applies uncapped to this 1M session, so it compacts near ${COMPACT_AT}; a 200k model picked with \`/model opus\` is capped at its own window and compacts near 167000. For a small task a 200k session is cheaper per turn — say so and let the user switch. \`claude-1m\` (\`~/.claude/bin/claude-1m\`) still pins the model at launch${ONE_M_FABLE}, and \`CLAUDE_1M_COMPACT_WINDOW\` lowers the window for that one process.`;
442
+ ONE_M_RULE = COMPACT
443
+ ? `\`autoCompactWindow\` ${COMPACT} applies uncapped to this 1M session, so it compacts near ${COMPACT_AT}; a 200k model picked with \`/model opus\` is capped at its own window and compacts near 167000. For a small task a 200k session is cheaper per turn — say so and let the user switch. \`claude-1m\` (\`~/.claude/bin/claude-1m\`) still pins the model at launch${ONE_M_FABLE}, and \`CLAUDE_1M_COMPACT_WINDOW\` lowers the window for that one process.`
444
+ : `Compaction is at the runtime default, so this 1M session compacts near ${COMPACT_AT} and context-guard only warns; a 200k model picked with \`/model opus\` compacts near 167000. For a small task a 200k session is cheaper per turn — say so and let the user switch. \`claude-1m\` (\`~/.claude/bin/claude-1m\`) still pins the model at launch${ONE_M_FABLE}, and \`CLAUDE_1M_COMPACT_WINDOW\` sets an earlier window for that one process.`;
419
445
  }
420
446
  const READ_LINES = String(snippet.env.CLAUDE_READ_MAX_LINES);
421
447
  const READ_BYTES = String(snippet.env.CLAUDE_READ_MAX_BYTES);
@@ -442,17 +468,17 @@ function claudeRender() {
442
468
  ARCHITECT_MODEL_LINE = 'model: fable[1m]';
443
469
  ARCHITECT_EFFORT = 'xhigh';
444
470
  EXPERT_ROW = '`fable[1m]` / `xhigh`, pinned on `architect`';
445
- PLAN_SPECIFIC = `- Session model is ${SESSION_HUMAN} by default, compaction near ${COMPACT_AT} tokens (\`autoCompactWindow\` ${COMPACT}). A large context is not free: every turn re-reads it, so keep \`/clear\` between tasks. ${ONE_M_RULE} Fable 5.1 [1m] is pinned on \`architect\` (\`model: fable[1m]\`, \`xhigh\`) and is the session only when the user starts one with \`claude-1m fable\` — never pick it for a reader or a reviewer. \`max\` stays off.
471
+ PLAN_SPECIFIC = `- Session model is ${SESSION_HUMAN} by default, compaction near ${COMPACT_AT} tokens (${COMPACT_NOTE}). A large context is not free: every turn re-reads it, so keep \`/clear\` between tasks. ${ONE_M_RULE} Fable 5.1 [1m] is pinned on \`architect\` (\`model: fable[1m]\`, \`xhigh\`) and is the session only when the user starts one with \`claude-1m fable\` — never pick it for a reader or a reviewer. \`max\` stays off.
446
472
  - \`runtime-gate\` checks Fable at run time. \`fallbackModel\` covers an overload; after a rate-limit or model-not-found failure, and while the weekly limit is ${WEEKLY}% or more used, the gate sends every \`model: fable\` agent to Opus until the reset, and says so in the agent's context. If a Fable agent still returns such an error, re-run the same brief once with \`model: opus\` — an availability switch, not a downgrade. \`~/.claude/hooks/runtime-gate.py status\` shows the gate; \`clear\` re-enables Fable early.`;
447
473
  } else {
448
474
  ARCHITECT_MODEL_LINE = `# model: intentionally omitted — inherits the session model (${SESSION_HUMAN}, Fable disabled in this install)`;
449
475
  ARCHITECT_EFFORT = 'high';
450
476
  EXPERT_ROW = `the session model at \`${ARCHITECT_EFFORT}\` — \`architect\` inherits it`;
451
- PLAN_SPECIFIC = `- Session model is ${SESSION_HUMAN} by default, compaction near ${COMPACT_AT} tokens (\`autoCompactWindow\` ${COMPACT}). ${ONE_M_RULE} Fable is disabled in this install (\`--fable no\`). Do not request \`model: fable\` anywhere. \`xhigh\` and \`max\` stay off.`;
477
+ PLAN_SPECIFIC = `- Session model is ${SESSION_HUMAN} by default, compaction near ${COMPACT_AT} tokens (${COMPACT_NOTE}). ${ONE_M_RULE} Fable is disabled in this install (\`--fable no\`). Do not request \`model: fable\` anywhere. \`xhigh\` and \`max\` stay off.`;
452
478
  }
453
479
  if (PLAN === 'balanced-max') {
454
480
  ARCHITECT_MODEL_LINE = '# model: intentionally omitted — inherits the session model (Opus 5.5; no Fable on BalancedMax)';
455
- PLAN_SPECIFIC = `- BalancedMax: only the session runs the strong model. Session model is Opus 5.5 with the 200k window and compaction near ${COMPACT_AT} tokens (\`autoCompactWindow\` ${COMPACT}); it plans and implements. Agents keep their cheap tiers — readers (\`Explore\`, \`log-reader\`) on \`${tier('FAST', 'model')}\` / \`${tier('FAST', 'effort')}\`, judgement (\`ai-discovery\`) on \`${tier('BALANCED', 'model')}\`, \`ai-reviewer\` on \`${tier('STRONG', 'model')}\`, and \`architect\` inherits the session. \`opus[1m]\` is a per-task choice, never the default. ${ONE_M_RULE} Fable is off on this plan; never request \`model: fable\`. \`xhigh\` and \`max\` stay off.`;
481
+ PLAN_SPECIFIC = `- BalancedMax: only the session runs the strong model. Session model is Opus 5.5 with the 200k window and compaction near ${COMPACT_AT} tokens (${COMPACT_NOTE}); it plans and implements. Agents keep their cheap tiers — readers (\`Explore\`, \`log-reader\`) on \`${tier('FAST', 'model')}\` / \`${tier('FAST', 'effort')}\`, judgement (\`ai-discovery\`) on \`${tier('BALANCED', 'model')}\`, \`ai-reviewer\` on \`${tier('STRONG', 'model')}\`, and \`architect\` inherits the session. \`opus[1m]\` is a per-task choice, never the default. ${ONE_M_RULE} Fable is off on this plan; never request \`model: fable\`. \`xhigh\` and \`max\` stay off.`;
456
482
  }
457
483
  EFFORT_RULE = 'Raise to `high` for architecture, root-cause analysis and adversarial verification, and say that you are raising it; readers stay at `low`.';
458
484
  }
@@ -467,7 +493,10 @@ function claudeRender() {
467
493
  const vars = {
468
494
  PLAN: PLAN_LABEL, PLAN_LABEL, SESSION_MODEL: SESSION_HUMAN, FALLBACK_MODEL: FALLBACK_HUMAN,
469
495
  DEFAULT_EFFORT: EFFORT, COMPACT_WINDOW: spaced(COMPACT_AT),
470
- CONTEXT_WARN: `${Math.trunc(COMPACT_AT * 80 / 100000)}k`, CONTEXT_BLOCK: `${Math.trunc(COMPACT_AT * 120 / 100000)}k`,
496
+ CONTEXT_WARN: `${Math.trunc(COMPACT_AT * 80 / 100000)}k`,
497
+ CONTEXT_BLOCK: COMPACT
498
+ ? `and from ${Math.trunc(COMPACT_AT * 120 / 100000)}k the prompt is held back once — sending it again goes through`
499
+ : 'and the prompt is never held back (only `--context economy` sets a block threshold)',
471
500
  READ_LINES, PLAN_SPECIFIC_ROUTING: PLAN_SPECIFIC, EFFORT_RULE, EXPERT_EFFORT, EXPERT_MODEL_LINE, EXPERT_ROW,
472
501
  ARCHITECT_MODEL_LINE, ARCHITECT_EFFORT,
473
502
  FAST_MODEL: tier('FAST', 'model'), FAST_EFFORT: tier('FAST', 'effort'),
@@ -524,8 +553,11 @@ function shSplit(s) { // shlex.split,
524
553
  return words;
525
554
  }
526
555
 
556
+ // The wrapper is `<PREFIX> [--context] [--then '<the user's command>']`; --context
557
+ // (--statusline-context yes) adds a line with the model and the context use.
527
558
  function statuslineGate(file, gate, mode) {
528
559
  const PREFIX = '"$HOME/.claude/hooks/runtime-gate.py" statusline';
560
+ const WANT = STATUSLINE_CONTEXT === 'yes' ? `${PREFIX} --context` : PREFIX;
529
561
  const OLD_PREFIX = '"$HOME/.claude/hooks/fable-gate.py" statusline';
530
562
  let settings = {};
531
563
  if (exists(file)) {
@@ -538,23 +570,35 @@ function statuslineGate(file, gate, mode) {
538
570
  line.command = PREFIX + cmd.slice(OLD_PREFIX.length);
539
571
  ours = true; gate = 'moved';
540
572
  }
573
+ // What follows the prefix and an optional --context: the user's --then, byte for byte.
574
+ const tail = () => line.command.slice(PREFIX.length).trim().replace(/^--context(\s+|$)/, '');
575
+ const shown = STATUSLINE_CONTEXT === 'yes' ? ' with the model and the context use' : '';
541
576
  let action = null;
542
577
  if (gate === 'moved') {
578
+ const rest = tail();
579
+ line.command = rest ? `${WANT} ${rest}` : WANT;
543
580
  action = 'moved the fable-gate statusline wrapper to runtime-gate (your command is unchanged)';
544
581
  } else if (gate === 'on') {
545
- if (ours) return ['statusline: already records the quota'];
546
- if (line === undefined || line === null) {
547
- settings.statusLine = { type: 'command', command: PREFIX };
548
- action = 'added a silent statusline that records the quota (and the Fable weekly limit)';
582
+ if (ours) {
583
+ const rest = tail();
584
+ const want = rest ? `${WANT} ${rest}` : WANT;
585
+ if (want === cmd) return ['statusline: already records the quota'];
586
+ line.command = want;
587
+ action = STATUSLINE_CONTEXT === 'yes'
588
+ ? 'added the model and the context use to the statusline wrapper (your command is unchanged)'
589
+ : 'removed the model and context line from the statusline wrapper (your command is unchanged)';
590
+ } else if (line === undefined || line === null) {
591
+ settings.statusLine = { type: 'command', command: WANT };
592
+ action = `added a statusline that records the quota (and the Fable weekly limit)${shown}`;
549
593
  } else if (isObj(line) && line.type === 'command' && typeof cmd === 'string' && cmd.trim()) {
550
- line.command = `${PREFIX} --then ${shQuote(cmd)}`;
551
- action = 'wrapped your statusline command with the quota check (its output is unchanged)';
594
+ line.command = `${WANT} --then ${shQuote(cmd)}`;
595
+ action = `wrapped your statusline command with the quota check${shown} (its output is unchanged)`;
552
596
  } else {
553
597
  return ['statusline: not a command statusline; the weekly-limit check is not wired'];
554
598
  }
555
599
  } else {
556
600
  if (!ours) return [];
557
- const rest = cmd.slice(PREFIX.length).trim();
601
+ const rest = tail();
558
602
  if (rest.startsWith('--then')) {
559
603
  const parts = shSplit(rest);
560
604
  line.command = parts.length > 1 ? parts[1] : '';
@@ -579,12 +623,26 @@ const claudeHookFiles = () => [...glob(path.join(SRC, 'hooks'), '.sh'), ...glob(
579
623
  .filter((f) => !path.basename(f).startsWith('codex-'));
580
624
  const skillsLine = (plugin) => out(` skills: ${ls(path.join(SRC, 'skills')).filter((n) => isDir(path.join(SRC, 'skills', n))).join(',')}${plugin ? ' (without SKILL.md: the plugin loads them)' : ''}`);
581
625
 
626
+ // Without --context economy, an autoCompactWindow equal to a shipped plan's economy window
627
+ // (800000 Max, 300000 Pro — the one an earlier install wrote, whichever plan it was for)
628
+ // goes; any other value is the user's own and stays. Returns the value to remove, or 0.
629
+ function ownCompactWindow(doc) {
630
+ if (CONTEXT === 'economy' || !isObj(doc)) return 0;
631
+ const shipped = glob(path.join(SRC, 'profiles'), '.json').map((f) => {
632
+ try { return Number(((readJSON(f).riskrails || {}).economy || {}).autoCompactWindow) || 0; } catch (e) { return 0; }
633
+ });
634
+ return shipped.includes(doc.autoCompactWindow) ? doc.autoCompactWindow : 0;
635
+ }
636
+
582
637
  function claudeDryRun() {
583
638
  out(`== claude: plan=${PLAN} (${C.PLAN_LABEL}, ${C.TIER} profile) fable=${FABLE} fable-gate=${C.GATE} runtime-gate=on (dry run, nothing written)`);
584
639
  out(`== claude: target directory ${CLAUDE_DIR}`);
585
640
  for (const l of statuslineGate(path.join(CLAUDE_DIR, 'settings.json'), 'on', 'dry')) out('== ' + l);
586
641
  out(`== settings snippet (merged into ${CLAUDE_DIR}/settings.json):`);
587
642
  out(pretty(C.snippet));
643
+ let current = {};
644
+ try { current = readJSON(path.join(CLAUDE_DIR, 'settings.json')); } catch (e) { /* nothing installed yet */ }
645
+ if (ownCompactWindow(current)) out(`== would remove autoCompactWindow ${current.autoCompactWindow} (economy off)`);
588
646
  out('== agents/architect.md (rendered head):');
589
647
  out(read(path.join(TMP, 'agents/architect.md')).split('\n').slice(0, 10).join('\n'));
590
648
  out('== CLAUDE.md managed block:');
@@ -1045,6 +1103,11 @@ function claudeApply() {
1045
1103
  if (dropPluginDuplicates(doc, keys)) out('removed: the hook entries the riskrails plugin now runs');
1046
1104
  if (isObj(doc.env) && doc.env.CLAUDE_CODE_SUBAGENT_MODEL === 'sonnet') delete doc.env.CLAUDE_CODE_SUBAGENT_MODEL;
1047
1105
  doc = mergeHooks(deepMerge(doc, snippet), newHooks, true);
1106
+ const own = ownCompactWindow(doc);
1107
+ if (own) {
1108
+ delete doc.autoCompactWindow;
1109
+ out(`removed: autoCompactWindow ${own} (economy off)`);
1110
+ }
1048
1111
  writeJSONFile(SETTINGS, doc);
1049
1112
  }
1050
1113
  out('merged: settings.json');
@@ -1119,11 +1182,16 @@ function claudeSummary() {
1119
1182
  ` session model ${C.SESSION_MODEL} (${C.SESSION_HUMAN}), effort ${C.EFFORT}`,
1120
1183
  ` fallback ${C.FALLBACK}`,
1121
1184
  ` EXPERT tier architect on ${expert} at effort ${C.ARCHITECT_EFFORT}`,
1122
- ` compaction near ${C.COMPACT_AT} tokens on this ${Math.trunc(C.MODEL_WINDOW / 1000)}k session (autoCompactWindow ${C.COMPACT},`,
1123
- " capped at the model's window); context-guard warns from",
1124
- ` ${Math.trunc(C.COMPACT_AT * 80 / 100)} and holds a prompt back once from ${Math.trunc(C.COMPACT_AT * 120 / 100)}`,
1185
+ ...(C.COMPACT ? [
1186
+ ` compaction near ${C.COMPACT_AT} tokens on this ${Math.trunc(C.MODEL_WINDOW / 1000)}k session (autoCompactWindow ${C.COMPACT},`,
1187
+ " capped at the model's window, --context economy); context-guard warns from",
1188
+ ` ${Math.trunc(C.COMPACT_AT * 80 / 100)} and holds a prompt back once from ${Math.trunc(C.COMPACT_AT * 120 / 100)}`,
1189
+ ] : [
1190
+ ` compaction the runtime default: near ${C.COMPACT_AT} tokens on this ${Math.trunc(C.MODEL_WINDOW / 1000)}k session;`,
1191
+ ` context-guard warns from ${Math.trunc(C.COMPACT_AT * 80 / 100)} and never holds a prompt back (economy only)`,
1192
+ ]),
1125
1193
  C.TIER === 'max'
1126
- ? ` large context claude-1m${FABLE === 'yes' ? ' [opus|fable]' : ''} starts one session on opus[1m]${FABLE === 'yes' ? ' or fable[1m] (Fable 5.1)' : ''} that compacts near ${C.ONE_M_AT}\n (the same autoCompactWindow, uncapped on a 1M model)`
1194
+ ? ` large context claude-1m${FABLE === 'yes' ? ' [opus|fable]' : ''} starts one session on opus[1m]${FABLE === 'yes' ? ' or fable[1m] (Fable 5.1)' : ''} that compacts near ${C.ONE_M_AT}\n (${C.COMPACT ? 'the same autoCompactWindow, uncapped on a 1M model' : 'the runtime default'})`
1127
1195
  : '',
1128
1196
  ` read guard an unbounded Read is refused above ${C.READ_LINES} lines / ${C.READ_BYTES} bytes`,
1129
1197
  ` agents ${agentNames(' ')}`,
@@ -1210,14 +1278,15 @@ function codexRender() {
1210
1278
  }
1211
1279
 
1212
1280
  // The guard scripts Codex can use. cap-large-read.py is absent: Codex has no hookable Read
1213
- // tool. context-guard.py serves both runtimes: it reads its own location to know which.
1214
- const codexHookFiles = () => ['ai-guard.py', 'runtime-gate.py', 'context-guard.py'].map((n) => path.join(SRC, 'hooks', n));
1281
+ // tool. context-guard.py serves both runtimes: it reads its own location to know which;
1282
+ // dialog-gate.py reads the payload's envelope (R36.19).
1283
+ const codexHookFiles = () => ['ai-guard.py', 'runtime-gate.py', 'context-guard.py', 'dialog-gate.py', 'update_notice.py'].map((n) => path.join(SRC, 'hooks', n));
1215
1284
 
1216
1285
  function codexDryRun() {
1217
1286
  out(`== codex: plan=${CODEX_PLAN} (${X.label}) session ${X.s(X.P.session.model)} at ${X.s(X.P.session.model_reasoning_effort)} (dry run, nothing written)`);
1218
1287
  out(`== codex: target directory ${CODEX_DIR}`);
1219
1288
  const merged = python([path.join(SRC, 'scripts/merge-codex-config.py'), path.join(CODEX_DIR, 'config.toml'),
1220
- '--profile', X.profileFile, '--dry-run'], { allowFail: true });
1289
+ '--profile', X.profileFile, '--status-context', STATUSLINE_CONTEXT, '--dry-run'], { allowFail: true });
1221
1290
  for (const l of merged.replace(/\n$/, '').split('\n')) if (merged) out('== ' + l);
1222
1291
  out('== AGENTS.md managed block:');
1223
1292
  fs.writeSync(1, read(path.join(TMP, 'AGENTS.block.md')));
@@ -1274,7 +1343,9 @@ function codexApply() {
1274
1343
  // codex-model-gate entries from before WP5 and runtime-gate ones from an earlier run go
1275
1344
  // first, so the merge adds exactly one gate per event. Codex runs only hooks the user has
1276
1345
  // reviewed (config.toml keeps a trusted_hash per entry): a changed entry is skipped until
1277
- // it is reviewed once in /hooks.
1346
+ // it is reviewed once in /hooks. A group whose commands are exactly a new group's is
1347
+ // replaced by it, as on Claude, so a reinstall carries a widened matcher (R36 7d2); an
1348
+ // unchanged group is written back as it was and keeps its trust.
1278
1349
  const HOOKS = path.join(CODEX_DIR, 'hooks.json');
1279
1350
  if (isFile(HOOKS)) backupFile(HOOKS, CODEX_DIR); else fs.writeFileSync(HOOKS, '{}\n');
1280
1351
  let hadShim = false, hadGuardShims = false;
@@ -1286,7 +1357,11 @@ function codexApply() {
1286
1357
  dropHooks(doc, SHIM_RE);
1287
1358
  const keys = pluginHookKeys(plugin, 'codex-hooks.json');
1288
1359
  if (dropPluginDuplicates(doc, keys)) out('removed: the hook entries the riskrails plugin now runs');
1289
- writeJSONFile(HOOKS, mergeHooks(doc, withoutPluginHooks(readJSON(path.join(SRC, 'codex/hooks.json')).hooks, keys), false));
1360
+ const fresh = withoutPluginHooks(readJSON(path.join(SRC, 'codex/hooks.json')).hooks, keys);
1361
+ const changed = Object.entries(fresh).some(([ev, entries]) => ((isObj(doc.hooks) && doc.hooks[ev]) || []).some(
1362
+ (old) => entries.some((e) => sameList(commands(e), commands(old)) && !sameList(e, old))));
1363
+ writeJSONFile(HOOKS, mergeHooks(doc, fresh, true));
1364
+ if (changed) out('changed: riskrails hook entries in hooks.json — review them once in /hooks, or Codex skips them');
1290
1365
  }
1291
1366
  out('merged: hooks.json');
1292
1367
  if (hadShim) out('replaced: codex-model-gate.py hooks with runtime-gate.py — review the changed gate entries once in /hooks, or Codex skips them');
@@ -1300,7 +1375,8 @@ function codexApply() {
1300
1375
  const config = path.join(CODEX_DIR, 'config.toml'), before = path.join(TMP, 'config.toml.before');
1301
1376
  rmrf(before);
1302
1377
  if (exists(config) && fs.statSync(config).size > 0) fs.copyFileSync(fs.realpathSync(config), before);
1303
- python([path.join(SRC, 'scripts/merge-codex-config.py'), config, '--profile', X.profileFile, '--no-backup'], { capture: false });
1378
+ python([path.join(SRC, 'scripts/merge-codex-config.py'), config, '--profile', X.profileFile,
1379
+ '--status-context', STATUSLINE_CONTEXT, '--no-backup'], { capture: false });
1304
1380
  if (exists(before) && fs.statSync(before).size > 0 && !sameFile(before, config)) backupFile(config, CODEX_DIR, before);
1305
1381
 
1306
1382
  // -------------------------------------------------------------- 6. AGENTS.md
package/codex/hooks.json CHANGED
@@ -3,13 +3,13 @@
3
3
  "hooks": {
4
4
  "PreToolUse": [
5
5
  {
6
- "matcher": "Bash|apply_patch|Edit|Write",
6
+ "matcher": ".*",
7
7
  "hooks": [
8
8
  {
9
9
  "type": "command",
10
10
  "command": "python3 -I -S \"$HOME/.codex/hooks/ai-guard.py\"",
11
11
  "timeout": 15,
12
- "statusMessage": "Checking git, paths and scope"
12
+ "statusMessage": "Checking git, paths, scope and open decisions"
13
13
  }
14
14
  ]
15
15
  },
@@ -36,6 +36,17 @@
36
36
  "statusMessage": "Recording expert-model availability"
37
37
  }
38
38
  ]
39
+ },
40
+ {
41
+ "matcher": "^request_user_input$",
42
+ "hooks": [
43
+ {
44
+ "type": "command",
45
+ "command": "python3 -I -S \"$HOME/.codex/hooks/dialog-gate.py\"",
46
+ "timeout": 5,
47
+ "statusMessage": "Recording the dialog's answer"
48
+ }
49
+ ]
39
50
  }
40
51
  ],
41
52
  "SubagentStart": [
@@ -98,6 +109,18 @@
98
109
  }
99
110
  ]
100
111
  }
112
+ ],
113
+ "Stop": [
114
+ {
115
+ "hooks": [
116
+ {
117
+ "type": "command",
118
+ "command": "python3 -I -S \"$HOME/.codex/hooks/dialog-gate.py\"",
119
+ "timeout": 5,
120
+ "statusMessage": "Checking for an open decision"
121
+ }
122
+ ]
123
+ }
101
124
  ]
102
125
  }
103
126
  }
package/hooks/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
- <!-- claude-agentic:rule:hooks:start -->
1
+ <!-- riskrails:rule:hooks:start -->
2
2
  # Hooks rules
3
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
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
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
- <!-- claude-agentic:rule:hooks:end -->
6
+ <!-- riskrails:rule:hooks:end -->
package/hooks/CLAUDE.md CHANGED
@@ -1,6 +1,6 @@
1
- <!-- claude-agentic:rule:hooks:start -->
1
+ <!-- riskrails:rule:hooks:start -->
2
2
  # Hooks rules
3
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
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
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
- <!-- claude-agentic:rule:hooks:end -->
6
+ <!-- riskrails:rule:hooks:end -->
package/hooks/ai-guard.py CHANGED
@@ -3,8 +3,12 @@
3
3
 
4
4
  Reads the hook payload from stdin and either exits 0 silently (allow) or prints
5
5
  the deny object and exits 0, the contract of every guard. The checks are the
6
- three shell guards this replaces, in the order a runtime ran them:
6
+ open-decision gate, then the three shell guards this replaces, in the order a
7
+ runtime ran them:
7
8
 
9
+ decision the gate while a dialog decision is open, on every tool
10
+ (guard/decision.py): deny all but the dialog, the picked command
11
+ and `state.py decision show|settle|fallback`
8
12
  git git and deployment safety, on Bash (guard/git.py)
9
13
  paths sensitive paths, control files, the runtime lock, dependency
10
14
  instruction files (guard/paths.py)
@@ -12,20 +16,24 @@ three shell guards this replaces, in the order a runtime ran them:
12
16
 
13
17
  `--only <check>` runs one of them; that is what each hooks/ai-*-guard.sh shim
14
18
  passes, so a registration made for the shell guards keeps its meaning. With no
15
- flag all three run and the first refusal wins.
19
+ flag all of them run and the first refusal wins.
16
20
 
17
21
  Fail-open: anything unexpected — a payload that is not JSON, a missing file,
18
22
  an exception — allows, with no output. A guard is defence-in-depth against an
19
23
  agent's mistakes, not a security boundary, and must never make a tool unusable.
24
+ Two exceptions, both with the dialog on (R36): the git check's consent rule
25
+ denies a commit or push when the consent ledger cannot be read (R36.9), and
26
+ state.py's own approve refuses without a pick or a terminal (R36.10).
20
27
  Run as `python3 -I -S`: no site packages, no environment-driven imports.
21
28
  """
22
29
  import os
23
30
  import sys
24
31
 
25
- CHECKS = ("git", "paths", "scope")
32
+ CHECKS = ("decision", "git", "paths", "scope")
26
33
  # The tools each check reads; any other tool is allowed by its first line, so
27
- # the module is not even imported for it.
34
+ # the module is not even imported for it. None: every tool.
28
35
  TOOLS = {
36
+ "decision": None,
29
37
  "git": ("Bash",),
30
38
  "paths": ("Read", "Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"),
31
39
  "scope": ("Edit", "Write", "MultiEdit", "NotebookEdit"),
@@ -52,16 +60,28 @@ def main(argv):
52
60
  return 0
53
61
  ctx = Context(payload, loc, pwd, common.bytes_b(os.fsencode(hook_dir)))
54
62
  for name in (only,) if only else CHECKS:
55
- if payload.tool not in TOOLS[name]:
63
+ if TOOLS[name] is not None and payload.tool not in TOOLS[name]:
56
64
  continue
57
- module = __import__("guard." + name, fromlist=["check"])
58
65
  # Each shell guard was its own process with its own SECONDS: the
59
- # first check's clock started with the process, a later one's now.
60
- ctx.clock = common.Clock(common.START if name == ((only,) if only else CHECKS)[0] else None)
66
+ # first shell guard's clock started with the process, a later one's
67
+ # now. The decision check, new, does not move that.
68
+ ctx.clock = common.Clock(common.START if name == (only or "git") else None)
61
69
  try:
70
+ module = __import__("guard." + name, fromlist=["check"])
62
71
  module.check(ctx)
63
72
  except common.Allow:
64
73
  continue
74
+ except Exception as exc: # pylint: disable=broad-exception-caught
75
+ # The decision gate fails open on its own (R36.12): a missing or
76
+ # broken module — a half-done install — must not end the run
77
+ # before git, paths and scope.
78
+ if name != "decision" or isinstance(exc, common.Deny):
79
+ # A git pick the gate spent on this call is gone even
80
+ # though a later rule refuses the command (review focus 1).
81
+ if isinstance(exc, common.Deny) and name != "decision" and ctx.consent:
82
+ from guard import dialog # pylint: disable=import-outside-toplevel
83
+ exc.reason += common.text_to_b(dialog.used_note(ctx.consent)) # pylint: disable=no-member # exc is a common.Deny by the isinstance above
84
+ raise
65
85
  except Exception as exc: # pylint: disable=broad-exception-caught
66
86
  common = sys.modules.get("guard.common")
67
87
  if common is not None and isinstance(exc, common.Deny):
@@ -86,6 +106,7 @@ class Context: # pylint: disable=too-few-public-met
86
106
  self.pwd = pwd
87
107
  self.hook_dir = hook_dir
88
108
  self.clock = common.Clock()
109
+ self.consent = None # the git pick the decision gate spent on this call
89
110
 
90
111
 
91
112
  if __name__ == "__main__":
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "Shipped defaults for ai-path-guard. Do not edit this file; a project overrides it in .ai/policies/path-guard.json (patterns are unioned, allow wins over every other list). Patterns are POSIX extended regular expressions matched against the absolute path. deny_patterns are secrets: refused for read and write. protected_config_patterns are always on and refuse writes: the task's state and journal, task.md, and the guards' own configuration, engine and state.py under .claude/ or .codex/, in a project or in the user's home, and the riskrails plugin's cache folder, which carries the same code on the plugin route. A project's dependency_instruction_patterns, from before 3.4, are read as deny_patterns. task_protected_patterns are on only while a task is in flight (.ai/state/state.json, or else current.json, exists and has not reached \"done\"): the runtime's settings, Claude's record of installed plugins and the pipeline's policies, so a run cannot change the rules it is being judged by. Reading anything but a secret is allowed.",
2
+ "_comment": "Shipped defaults for ai-path-guard. Do not edit this file; a project overrides it in .ai/policies/path-guard.json (patterns are unioned, allow wins over every other list). Patterns are POSIX extended regular expressions matched against the absolute path. deny_patterns are secrets: refused for read and write. protected_config_patterns are always on and refuse writes: the task's state and journal, task.md, the compact record (.ai/memory/, .ai/local/memory/), and the guards' own configuration, engine and state.py under .claude/ or .codex/, in a project or in the user's home, and the riskrails plugin's cache folder, which carries the same code on the plugin route. A project's dependency_instruction_patterns, from before 3.4, are read as deny_patterns. task_protected_patterns are on only while a task is in flight (.ai/state/state.json, or else current.json, exists and has not reached \"done\"): the runtime's settings, Claude's record of installed plugins and the pipeline's policies, so a run cannot change the rules it is being judged by. Reading anything but a secret is allowed.",
3
3
  "deny_patterns": [
4
4
  "(^|/)\\.env$",
5
5
  "(^|/)\\.env\\.(local|prod|production|staging|live)$",
@@ -29,6 +29,8 @@
29
29
  "(^|/)\\.ai/reports/[^/]+/events\\.jsonl$",
30
30
  "(^|/)\\.ai/reports/[^/]+/(adopt|migration)\\.json$",
31
31
  "(^|/)\\.ai/policies/[^/]*\\.json$",
32
+ "(^|/)\\.ai/memory/",
33
+ "(^|/)\\.ai/local/memory/",
32
34
  "(^|/)\\.claude/hooks/ai-[^/]*$",
33
35
  "/\\.claude/hooks/ai-[^/]*$",
34
36
  "(^|/)\\.codex/hooks/ai-[^/]*$",