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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/README.md +246 -812
- package/agents/ai-reviewer.md.tmpl +6 -4
- package/ai/policies/review-economy.md +7 -2
- package/ai/using-riskrails.md +15 -0
- package/bin/claude-1m +15 -14
- package/bin/riskrails.js +108 -32
- package/codex/hooks.json +25 -2
- package/hooks/AGENTS.md +2 -2
- package/hooks/CLAUDE.md +2 -2
- package/hooks/ai-guard.py +29 -8
- package/hooks/ai-path-guard-defaults.json +3 -1
- package/hooks/codex-hooks.json +25 -2
- package/hooks/context-guard.py +251 -35
- package/hooks/dialog-gate.py +105 -0
- package/hooks/guard/common.py +5 -0
- package/hooks/guard/decision.py +123 -0
- package/hooks/guard/dialog.py +598 -0
- package/hooks/guard/git.py +49 -3
- package/hooks/guard/paths.py +62 -3
- package/hooks/hooks.json +37 -2
- package/hooks/memory-pre-push.py +28 -0
- package/hooks/runtime-gate.py +59 -12
- package/hooks/update_notice.py +135 -0
- package/instructions/routing.md +4 -4
- package/instructions/stub.md +2 -2
- package/package.json +1 -1
- package/profiles/max.json +3 -1
- package/profiles/pro.json +3 -1
- package/scripts/merge-codex-config.py +134 -0
- package/scripts/resolve-profile.py +15 -3
- package/settings.common.json +39 -2
- package/skills/AGENTS.md +2 -2
- package/skills/CLAUDE.md +2 -2
- package/skills/ai-init/templates/.ai/AGENTS.md +2 -2
- package/skills/ai-init/templates/.ai/VERSION +1 -1
- package/skills/ai-init/templates/.ai/policies/dialog.json +1 -0
- package/skills/ai-init/templates/.ai/policies/safety.md +22 -2
- package/skills/ai-init/templates/.ai/policies/weight.json +1 -0
- package/skills/ai-init/templates/.ai/policies/weight.md +2 -2
- package/skills/ai-init/templates/.ai/state/README.md +9 -6
- package/skills/ai-init/templates/AGENTS.block.md +1 -1
- package/skills/ai-init/templates/CLAUDE.block.md +1 -1
- package/skills/ai-init/templates/GEMINI.block.md +1 -1
- package/skills/ai-init/templates/junie-guidelines.block.md +1 -1
- package/skills/ai-task/memory_compact.py +968 -0
- package/skills/ai-task/memory_format.py +775 -0
- package/skills/ai-task/memory_store.py +528 -0
- package/skills/ai-task/memory_sync.py +1571 -0
- package/skills/ai-task/sensors.py +2 -2
- package/skills/ai-task/state.py +967 -70
- package/skills/ai-task/weight.py +3 -3
- package/skills/project-init/templates/constitution.md +1 -1
- package/skills/project-update/history/blobs/164cd34c182de88c33de7df35beb29200d792419e1c13a5f0b2104f944e57be7 +1 -0
- package/skills/project-update/history/blobs/1d1d907449ec74594d0ee923ac142ae746b9aedf0a1eda9469f075a44d23e3d7 +13 -0
- package/skills/project-update/history/blobs/23a42febbacaa7b52a802d428033a99c0694391c81d51fe3b6e1dff608a641d5 +67 -0
- package/skills/project-update/history/blobs/5353ca529c46f2443e12947ec205a6887fb594a4de1f87f0eb1c0e6c0f826133 +49 -0
- package/skills/project-update/history/blobs/5f65bc7ab3fc3c4e75ae05d49eaf4331fafc398c661abe33c69f7380a720344c +32 -0
- package/skills/project-update/history/blobs/7be527cd648b27cca6e267de30f8e33a2ce17d5631a82694f23ae5c0623efe1f +15 -0
- package/skills/project-update/history/blobs/7ee99edafe7cec45ff577376c0d0178fdb2690134d4656488f46e129aba3cc5a +67 -0
- package/skills/project-update/history/blobs/8b3ef9586491a426767c01da5d01bdd6b0655c576c68c4cc6dfb093367817dc0 +67 -0
- package/skills/project-update/history/blobs/917df3320d778ddbaa5c5c7742bc4046bf803c36ed2b050f30844ed206783469 +1 -0
- package/skills/project-update/history/blobs/935af1019931058ec3a0b834fe230e127ec914f75edcc4a6c589cfe733b0b979 +13 -0
- package/skills/project-update/history/blobs/ae5ce131d6bae401199ff32d8c3dc4d8aec188010a9111624ac64506e2116ff4 +84 -0
- package/skills/project-update/history/blobs/ca4499d829268c8766fb3b825a515e88dc55c237e9d202816b50959b4dac031f +13 -0
- package/skills/project-update/history/blobs/e9adb7a9905eb719ac78ab96671f055da53e5001810f5ad6d6c94306b0323fe7 +74 -0
- package/skills/project-update/history/blobs/fae3064e648cdc55be4aeb1177525b302a5e34ebd752e4a904f47dc0f4557b96 +71 -0
- package/skills/project-update/history/index.json +32 -13
- package/skills/project-update/migrations/0009_state_file.py +25 -0
- package/skills/project-update/migrations/0010_dialog_switch.py +30 -0
- package/skills/project-update/migrations/README.md +2 -1
- package/skills/project-update/update.py +12 -3
- package/skills/rr-build/SKILL.md +14 -5
- package/skills/rr-build-all/SKILL.md +190 -0
- package/skills/rr-plan/SKILL.md +13 -3
- package/skills/rr-review/SKILL.md +18 -8
- package/skills/rr-ship/SKILL.md +16 -8
- package/skills/rr-status/SKILL.md +2 -2
- 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
|
-
|
|
51
|
-
|
|
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.
|
|
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
|
|
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.
|
package/ai/using-riskrails.md
CHANGED
|
@@ -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
|
|
9
|
+
# CLAUDE_1M_COMPACT_WINDOW=500000 claude-1m # compact earlier than the runtime would
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# `
|
|
14
|
-
# the model
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
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
|
|
413
|
-
const
|
|
414
|
-
const ONE_M_AT =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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`,
|
|
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)
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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 = `${
|
|
551
|
-
action =
|
|
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 =
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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": "
|
|
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
|
|
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
|
-
<!--
|
|
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
|
-
<!--
|
|
6
|
+
<!-- riskrails:rule:hooks:end -->
|
package/hooks/CLAUDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
<!--
|
|
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
|
-
<!--
|
|
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
|
|
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
|
|
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
|
|
60
|
-
|
|
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-[^/]*$",
|