Saturday, September 26, 2026

Prompt Gatekeeper and Model Recommender - For Switching Models in ClaudeCode


**Location:** `<USER>\.claude\prompt_gatekeeper_model_switcher.md`

**Created:** 2026-09-26

**Revised:** 2026-09-26 (v2 -- integrity review and rebuild; v3 -- state moved to Stop hook, Option D; v4 -- [check] soft-check mode; v5 -- second integrity review, hooks aligned, doc swept)

**Status:** Implemented and ENABLED (both hooks)


Note: Section 5 examples show the v2 layout in which the state block was printed on

prompt submit. Section 13 supersedes that: state is now shown at end of turn by

`state_monitor.py`, and the submit-time block carries only the model recommendation.

Sections 0 to 4 and 6 to 10 were brought up to date in v5 (Section 15).


---


## Table of Contents


0. [PRIME DIRECTIVE](#s0)

1. [Intent](#s1)

2. [Design](#s2)

3. [Files and Components](#s3)

4. [Implementation](#s4)

5. [Usage and Examples](#s5)

6. [Enable / Disable](#s6)

7. [Extending the Rules](#s7)

8. [Limitations](#s8)

9. [Convention Scope -- Global](#s9)

10. [Verification -- the state-file design](#s10)

11. [Revision history -- v1 integrity review](#s11)

12. [Future direction -- LLM-based assessment (planning)](#s12)

13. [v3 -- State split to end of turn, Option D implemented](#s13)

14. [v4 -- [check] soft-check mode](#s14)

15. [v5 -- second integrity review](#s15)

A. [Appendix A -- Full source of the live files](#appA)


---


<a id="s0"></a>

## 0. PRIME DIRECTIVE


[Back to TOC](#toc)


**The gatekeeper hooks (`gatekeeper.py` and `state_monitor.py`) NEVER run git -- not

directly, not indirectly.**


Concretely:


- They spawn no processes of any kind. No `subprocess`, no `os.system`, no `os.popen`,

  no `pty`, no `multiprocessing`, no shelling out.

- Their only inputs are the JSON that Claude Code pipes to stdin and one file,

  `task_state.json`.

- Their only output is one JSON object on stdout.

- A runtime self-check (`check_prime_directive()`) confirms that no process-spawning

  module has been loaded. If one ever is, the violation is printed in the user-visible

  message and in Claude's context so it cannot go unnoticed.

- Any future extension (including the LLM-based assessment in Section 12) must honour

  this. An HTTP call to a model API is permitted; spawning a CLI is not.


Rationale: a hook fires on every prompt in every project. Running git from it means an

uncontrolled process touching every repository the user opens, with no audit trail and no

way for the user to see it happen. Clean-state assessment is a semantic judgement and is

made from the state file alone (see Section 10).


Scope: the directive binds the two hooks built here. The Siemens-mandated code-scan

`PreToolUse` hook in the same `settings.json` fetches its tool with `uvx --from git+https`;

that is outside this design and is not governed by it.


---


<a id="s1"></a>

## 1. Intent


[Back to TOC](#toc)


When working in Claude Code Terminal across long sessions, two decisions recur constantly:


1. **Is it safe to switch model or effort level?** Switching mid-task risks losing context

   coherence or leaving work in an inconsistent state. The user needs to know whether the

   current task is genuinely complete before changing model or effort.


2. **Which model should I use for this prompt?** Claude Code supports four models with

   very different cost and capability profiles. Choosing the wrong one either wastes money

   (using Fable for a simple edit) or produces poor results (using Haiku for complex

   ontology work). The user should not have to remember the tradeoffs for every prompt.


The Prompt Gatekeeper is a lightweight hook that fires on every prompt submission and

answers both questions automatically:


- It reports whether the session is in a clean state (task complete) or mid-task.

- It analyses the prompt text and recommends the most appropriate model.

- It optionally suggests an effort level.

- It warns explicitly if it is NOT safe to switch, or if the recommended model is

  expensive (Fable).


The gatekeeper is ADVISORY only. It never switches the model or effort automatically.

The user reads the recommendation and acts on it (or ignores it).


Note on timing: the hook fires after the prompt is submitted, so the current prompt is

already being handled by the current model. Every recommendation therefore applies to the

NEXT prompt, not this one.


---


<a id="s2"></a>

## 2. Design


[Back to TOC](#toc)


### 2.1 Hook mechanism


Claude Code supports a `UserPromptSubmit` hook: a shell command that fires every time

the user submits a prompt, before Claude processes it. The hook receives a JSON object on

stdin (fields include `user_prompt`, `cwd`, `session_id`, `transcript_path`) and returns

a JSON object on stdout with two channels:


| JSON field | Who sees it |

|------------|-------------|

| `systemMessage` | The USER, in the terminal |

| `hookSpecificOutput.additionalContext` | CLAUDE, injected into its context |


Stderr from a hook that exits 0 goes to the debug log only. It is never shown to the

user, so the gatekeeper does not write to stderr at all.


The hook cannot automatically switch the model or effort. It could block the prompt

(exit code 2) but the gatekeeper never does.


### 2.2 Clean state assessment -- single signal, state file only


The sole clean-state signal is `task_state.json` at `<USER>\.claude\hooks\`. Claude

writes this file at the start and end of every substantial task, per the global

CLAUDE.md convention. Fields:


| Field | Purpose |

|-------|---------|

| `status` | `"in_progress"` or `"clean"` |

| `task` | Short name of the current task |

| `detail` | Optional extra description |

| `project` | Short project label (e.g. "BFSI") -- disambiguates across projects |

| `cwd` | Working directory at time of writing -- for context |

| `updated` | ISO timestamp in LOCAL time -- informational; age is taken from file mtime (Section 13) |


If `status` is `"in_progress"` and the file was last written more than 24 hours ago,

both hooks raise a stale warning: the file may have been left dirty and forgotten. The

threshold is `STALE_HOURS` in each script. Age is measured from the file's modification

time, so it works even when `updated` is empty; the `updated` field is a fallback only.


If the file is missing or unreadable, the state is reported as clean with an explicit

note saying so. If `status` holds any value other than `clean` or `in_progress` (a typo

such as `in-progress`), both hooks report the value and assume clean. There is no

fallback signal (see PRIME DIRECTIVE).


"Task complete" is a semantic judgement. Uncommitted files in a repository are the

normal resting state of a working directory and say nothing about whether a task is

finished, which is why v1's git corroboration was removed (Section 11).


### 2.3 Model recommendation -- word-boundary keyword matching


The gatekeeper scans the lowercased prompt for keywords associated with each model.

Rules are evaluated top-to-bottom; the first match wins. If nothing matches, Sonnet is

the default.


Keywords match on word boundaries only: `ttl` matches "ttl" and "ttl-file" but not

"settle"; `no` does not match "know"; `ok` does not match "look".


The Haiku rule additionally applies only to prompts of eight words or fewer. A long

prompt that happens to contain "ok" or "yes" is not a confirmation.


| Model | Trigger keywords (examples) | Use when |

|-------|-----------------------------|----------|

| Opus 5.5 | ttl, sparql, ontology, implement, review all, graphmart | Complex reasoning, code generation, deep review |

| Sonnet 5 | explain, update, edit, what is, clarify | Edits, explanations, medium tasks (default) |

| Haiku 4.5 | yes, ok, go ahead, proceed, thanks (short prompts only) | Confirmations, trivial follow-ups |

| Fable 5.1 | full build, end to end, autonomous, run everything | Long-horizon multi-step autonomous tasks ONLY |


Fable is placed last and carries an explicit cost warning. Anthropic's guidance: use

Fable for demanding reasoning and long-horizon agentic work, or when Opus at higher

effort still falls short. Price figures in the script header are as recorded in Sep 2026

and unverified; check the current price list before relying on them.


### 2.4 Effort recommendation


Separately from the model, the gatekeeper scans for effort-signal keywords (also on

word boundaries):


- High effort: "review all", "self-review", "full review", "check everything", "audit",

  "validate all", "integrity check", "deep review"

- Low effort: "quick", "briefly", "one line", "just check", "in short"

- Otherwise: no effort recommendation (leave current setting)


The lists above are the live `EFFORT_HIGH_KEYWORDS` and `EFFORT_LOW_KEYWORDS` as of v5.


### 2.5 Output


On every prompt the gatekeeper returns one JSON object containing a plain-ASCII summary

block for the user (`systemMessage`) and a one-line note for Claude

(`additionalContext`). No colour codes are used because `systemMessage` is rendered by

Claude Code, not by a raw terminal.


---


<a id="s3"></a>

## 3. Files and Components


[Back to TOC](#toc)


`<USER>` = `C:\Users\<username>` on this machine. Substitute your own Windows user path

when replicating on another machine.


### Files created (full paths)


| Full path | Purpose |

|-----------|---------|

| `<USER>\.claude\hooks\gatekeeper.py` | UserPromptSubmit hook -- model recommendation, Option D instruction, `[check]` mode (Section 14) |

| `<USER>\.claude\hooks\state_monitor.py` | Stop hook -- shows state at end of each turn (Section 13) |

| `<USER>\.claude\hooks\task_state.json` | Live clean-state signal (read by hook, written by Claude) |

| `<USER>\.claude\prompt_gatekeeper_model_switcher.md` | This document |


### Files modified (full paths)


| Full path | Change |

|-----------|--------|

| `<USER>\.claude\settings.json` | Added `UserPromptSubmit` hook block (gatekeeper.py) and `Stop` hook block (state_monitor.py) |

| `<USER>\.claude\CLAUDE.md` | Added "Task State Convention" section -- the global instruction mechanism, including the `[check]` rule |


### Note on global vs. project-local instructions


| Mechanism | Scope | Used for |

|-----------|-------|----------|

| `<USER>\.claude\CLAUDE.md` | Global (all projects) | Standing behavioural rules, conventions |

| `<USER>\.claude\settings.json` | Global (all projects) | Hook registration, model defaults, env vars |

| `<project>\memory\*.md` | Project-local | Project-specific context and decisions |


The task state convention and the PRIME DIRECTIVE both live in the global `CLAUDE.md`

because the hooks are global. No project memory notes are used: auto-memory is keyed to

the directory a session starts from, so anything stored there would be invisible from

other projects. Two BFSI-scoped notes that existed briefly on 2026-09-26 were removed for

that reason; their content is in `CLAUDE.md` and Sections 12 to 15 here.


---


<a id="s4"></a>

## 4. Implementation


[Back to TOC](#toc)


The scripts are not reproduced in this section. v1 embedded a copy here and the copy

drifted from the live file within a day (Section 11). The live files are the source of

truth:


```

<USER>\.claude\hooks\gatekeeper.py

<USER>\.claude\hooks\state_monitor.py

```


Since v5, Appendix A holds a full copy of both scripts, `task_state.json` and the two

`settings.json` hook blocks for copy-and-paste. Rule: whenever either script is edited,

re-paste it into Appendix A in the same session, then `py_compile` the live file.


To replicate on another machine:


### Step 1 -- Create the hooks directory


```

mkdir <USER>\.claude\hooks

```


### Step 2 -- Create `task_state.json`


```json

{

  "status": "clean",

  "task": "",

  "detail": "",

  "project": "",

  "cwd": "",

  "updated": "2026-09-26T00:00:00"

}

```


### Step 3 -- Copy `gatekeeper.py` and `state_monitor.py`


Copy both live files to `<USER>\.claude\hooks\`. Then confirm they compile and honour

the PRIME DIRECTIVE:


```

C:\miniforge3\python -m py_compile gatekeeper.py state_monitor.py

```


and confirm that a source search for `subprocess`, `os.system`, `os.popen` or `git`

finds only the docstrings and the `FORBIDDEN_MODULES` tuples.


### Step 4 -- Register both hooks in `settings.json`


Add the `UserPromptSubmit` and `Stop` blocks inside the existing `"hooks"` object:


```json

"UserPromptSubmit": [

  {

    "hooks": [

      {

        "type": "command",

        "command": "/c/miniforge3/python /c/Users/<username>/.claude/hooks/gatekeeper.py",

        "statusMessage": "Gatekeeper assessing prompt..."

      }

    ]

  }

],

"Stop": [

  {

    "hooks": [

      {

        "type": "command",

        "command": "/c/miniforge3/python /c/Users/<username>/.claude/hooks/state_monitor.py",

        "timeout": 5

      }

    ]

  }

],

```


The Python path uses POSIX style (`/c/miniforge3/python`) because Claude Code runs hook

commands through bash on Windows.


### Step 5 -- Add the Task State Convention to the global `CLAUDE.md`


Paste the "Task State Convention (Gatekeeper Hooks)" section, reproduced in full in

Appendix A.5, into the target machine's `<USER>\.claude\CLAUDE.md`. Without it, Claude

will not maintain `task_state.json` and the state signal is dead.


### Step 6 -- Smoke test


```

printf '{"user_prompt":"review all the ttl files"}' | /c/miniforge3/python /c/Users/<username>/.claude/hooks/gatekeeper.py

```


Expected: one JSON line with `systemMessage` and `hookSpecificOutput`, recommending

Opus 5.5 with effort high, exit code 0.


```

printf '{}' | /c/miniforge3/python /c/Users/<username>/.claude/hooks/state_monitor.py

```


Expected: one JSON line with a `systemMessage` starting `STATE  [CLEAN]`, exit code 0.


---


<a id="s5"></a>

## 5. Usage and Examples


[Back to TOC](#toc)


### What you see on every prompt


Claude Code renders the `systemMessage` block in the terminal. Glance at it and act or

ignore it.


**Example 1 -- Clean state, Opus recommended:**


```

GATEKEEPER

  State  : [CLEAN]  task_state.json: clean

  Model  : /model Opus 5.5  (trigger: 'sparql')

  Use for: complex reasoning, TTL/SPARQL authoring, ontology work, deep reviews

```


Your prompt was: *"Write a SPARQL query to find all AML alerts above risk 0.7"*


Action: type `/model Opus 5.5` before your next prompt if you are not already on Opus.


**Example 2 -- Mid-task, Sonnet recommended:**


```

GATEKEEPER

  State  : [DIRTY]  task in progress: 'Writing bfsi-case.ttl' [BFSI] -- Phase P1

  Model  : /model Sonnet 5  (trigger: 'explain')

  Use for: edits, explanations, updates, medium-complexity tasks

  ! [DIRTY] task in progress -- hold off switching model/effort

```


Action: do NOT switch until the TTL task is complete.


**Example 3 -- Clean state, effort recommended:**


```

GATEKEEPER

  State  : [CLEAN]  task_state.json: clean

  Model  : /model Opus 5.5  (trigger: 'ttl')

  Use for: complex reasoning, TTL/SPARQL authoring, ontology work, deep reviews

  Effort : consider /effort high

```


**Example 4 -- Fable recommended (with cost warning):**


```

GATEKEEPER

  State  : [CLEAN]  task_state.json: clean

  Model  : /model Fable 5.1  (trigger: 'end to end')

  Use for: long-horizon multi-step autonomous tasks only -- most expensive tier

  ! NOTE: Fable 5.1 is the most expensive tier. Only use it if this is

    truly a long-horizon autonomous task that Opus at high effort cannot handle.

```


**Example 5 -- Stale state:**


```

GATEKEEPER

  State  : [DIRTY]  task in progress: 'Writing bfsi-case.ttl' [BFSI]

  Model  : /model Sonnet 5  (trigger: 'default')

  Use for: edits, explanations, updates, medium-complexity tasks

  ! Hold off switching model/effort -- finish current task first

  ! State flagged in_progress 31h ago in [BFSI] -- may be stale. Clear task_state.json if task is done.

```


Action: if the task really is done, ask Claude to reset `task_state.json` to clean.


Since v3 the `State` and stale lines above are printed by the Stop hook as a `STATE`

block at the end of the previous turn (Section 13), and the submit-time block shows

only a one-line `! [DIRTY] ... hold off switching` reminder when the state is dirty.


### How Claude uses the injected context


Claude receives a short note such as:


```

[Gatekeeper] State: DIRTY (task in progress: 'Writing bfsi-case.ttl' [BFSI]) --

hold off switching model or effort until current task is complete.

Suggested model: Sonnet 5 (matched keyword 'explain'; edits, explanations,

updates, medium-complexity tasks).

```


Claude can therefore remind you if you try to switch mid-task. Since v3 the note also

carries the Option D state-maintenance instruction, and in `[check]` mode the CHECK MODE

instruction (Sections 13 and 14).


### How to update task_state.json


Claude writes this file using the Write tool at task start and end. You can also write

it manually.


**Task starting:**


```json

{

  "status": "in_progress",

  "task": "Writing bfsi-case.ttl",

  "detail": "Phase P1 -- new shared case module",

  "project": "BFSI",

  "cwd": "C:\\d_drive\\workKW\\WorkClaude\\BFSI",

  "updated": "2026-09-26T14:32:00"

}

```


**Task complete:**


```json

{

  "status": "clean",

  "task": "",

  "detail": "",

  "project": "",

  "cwd": "",

  "updated": "2026-09-26T15:10:00"

}

```


---


<a id="s6"></a>

## 6. Enable / Disable


[Back to TOC](#toc)


Each hook is controlled by a single line near the top of its script, independently:


```python

ENABLED = True    # change to False to deactivate

```


- `gatekeeper.py` -- turns off the model recommendation, the Option D instruction and

  `[check]` mode.

- `state_monitor.py` -- turns off the end-of-turn STATE line.


When `ENABLED = False` the script calls `sys.exit(0)` immediately after the switch is

evaluated. There is no output and no context injection. The hook is still registered in

`settings.json` and Python is still launched on every event, so the cost is one

interpreter start-up (tens of milliseconds), not zero.


Important: a syntax error anywhere in the file defeats the switch, because Python

compiles the whole file before executing the first line. v1 was disabled and still

failed on every prompt for exactly this reason. After ANY edit, run:


```

C:\miniforge3\python -m py_compile C:\Users\<username>\.claude\hooks\gatekeeper.py C:\Users\<username>\.claude\hooks\state_monitor.py

```


You do NOT need to touch `settings.json` to toggle either hook on or off.


---


<a id="s7"></a>

## 7. Extending the Rules


[Back to TOC](#toc)


### Adding keywords to an existing model


Find the model's tuple in `MODEL_RULES` and append to its keyword list. Multi-word

phrases are fine; matching is on word boundaries at both ends of the phrase.


### Adding a new model


Add a new tuple to `MODEL_RULES` at the appropriate priority position:


```python

(

    "Label", "model-id",

    "description of when to use it",

    ["keyword1", "keyword2"],

),

```


### Changing the default model


Edit `DEFAULT_MODEL_LABEL`, `DEFAULT_MODEL_ID` and `DEFAULT_MODEL_PURPOSE`.


### Changing effort keywords


Edit `EFFORT_HIGH_KEYWORDS` and `EFFORT_LOW_KEYWORDS`.


### Changing the Haiku short-prompt limit


Edit `HAIKU_MAX_WORDS`.


After any change: `py_compile`, then the smoke test in Section 4 Step 6.


---


<a id="s8"></a>

## 8. Limitations


[Back to TOC](#toc)


| Limitation | Detail |

|------------|--------|

| Advisory only | Cannot auto-switch model or effort. User must act on the recommendation. |

| Recommendation is one prompt late | The hook fires after submission. The advice applies to the next prompt. Mitigated by `[check]` mode (Section 14), which assesses without executing. |

| Sonnet label vs. settings alias | `settings.json` maps the `sonnet` alias (`ANTHROPIC_DEFAULT_SONNET_MODEL`) to `claude-sonnet-4-6@default`, while the hook recommends `/model Sonnet 5`. Untested whether `/model Sonnet 5` resolves through the Siemens proxy. If it does not, align the env var or the rule label. |

| Unexpected status is fail-open | A `status` value that is neither `clean` nor `in_progress` is reported and treated as clean by both hooks. A typo therefore unlocks switching, but visibly. |

| Keyword matching is blunt | Word boundaries remove the worst false positives, but "what does TTL stand for?" still recommends Opus. See Section 12. |

| task_state.json requires discipline | If Claude forgets to update the file, the signal is wrong. There is deliberately no fallback (PRIME DIRECTIVE). The 24h stale warning is the only safety net. |

| One global state file | Two concurrent sessions in different projects overwrite each other's state. The `project` field makes this visible but does not prevent it. |

| No task list integration | The Claude Code in-memory task list is not accessible to an external hook process. |

| Model IDs and prices may drift | Update `MODEL_RULES` and the header manually when models change. |

| POSIX path dependency | The hook command uses `/c/miniforge3/python`. If Python moves, update `settings.json`. |


---


<a id="s9"></a>

## 9. Convention Scope -- Global


[Back to TOC](#toc)


Both gatekeeper hooks are GLOBAL -- they fire in every Claude Code session regardless

of project. The task_state.json convention is also implemented globally to match.


The instruction for Claude to update `task_state.json` is in the **global CLAUDE.md**:


```

<USER>\.claude\CLAUDE.md  (section: "Task State Convention (Gatekeeper Hooks)")

```


This file is loaded at the start of every Claude Code session in every project.


| Component | Scope | Location |

|-----------|-------|----------|

| `gatekeeper.py` hook (UserPromptSubmit) | Global (all projects) | `<USER>\.claude\hooks\gatekeeper.py` |

| `state_monitor.py` hook (Stop) | Global (all projects) | `<USER>\.claude\hooks\state_monitor.py` |

| `task_state.json` | Global (shared across all projects) | `<USER>\.claude\hooks\task_state.json` |

| Hook registration | Global (all projects) | `<USER>\.claude\settings.json` |

| Task state convention instruction | Global (all projects) | `<USER>\.claude\CLAUDE.md` |

| This documentation | Global | `<USER>\.claude\prompt_gatekeeper_model_switcher.md` |


---


<a id="s10"></a>

## 10. Verification -- the state-file design


[Back to TOC](#toc)


Question asked at the v2 review: is the design genuinely based on looking at a recorded

state in a JSON file that keeps getting updated, rather than on inspecting the workspace?


Answer: yes, and after v2 it is exclusively so.


**How the state is produced.** The global CLAUDE.md instructs Claude to write

`task_state.json` with `status: in_progress` at the start of any substantial task and

`status: clean` at the end. The hook only ever reads this file. It never inspects the

working directory, the repository, or the transcript.


**Evidence the convention is honoured in practice.** A search of the BFSI session

transcripts on 2026-09-26 found 32 writes of an `in_progress` state across three separate

sessions, so Claude has been maintaining the file, not merely being told to.


**What the hook does with it.**


| task_state.json says | Hooks report |

|----------------------|--------------|

| `status: in_progress`, file written < 24h ago | DIRTY, with task name, project and detail |

| `status: in_progress`, file written > 24h ago | DIRTY plus a stale warning |

| `status: clean` | CLEAN |

| file missing or malformed | CLEAN (gatekeeper) / UNKNOWN (Stop hook), both with an explicit "missing or unreadable, assuming clean" note |

| file parses but has no `status` field | CLEAN (gatekeeper) / UNKNOWN (Stop hook), both saying "no status field, assuming clean" |

| any other `status` value | CLEAN (gatekeeper) / UNKNOWN (Stop hook), both naming the value and saying "assuming clean" |


Age in the first two rows is the file's modification time (Section 13).


**Known weaknesses of a single state file.** It depends on Claude's discipline; a

forgotten reset leaves a false DIRTY until the stale warning fires. It is one file

shared by all sessions, so concurrent sessions in different projects clobber each other.

A future refinement is to key the state by `session_id` (which the hook already

receives on stdin) so each session has its own record and the hook reports the state for

the session that asked.


---


<a id="s11"></a>

## 11. Revision history -- v1 integrity review


[Back to TOC](#toc)


An integrity review on 2026-09-26 compared the v1 document against the live files and

tested the hook. Findings and the v2 response:


| # | v1 finding | v2 response |

|---|------------|-------------|

| 1 | `gatekeeper.py` did not compile (nested f-string with backslash escapes, line 276). Because compilation precedes the `ENABLED` check, the hook exited 1 on every prompt even while "disabled". | Stale message built in plain variables before formatting. Compile check added to Sections 4 and 6. |

| 2 | Design assumed the coloured stderr block was shown to the user. Per the hook docs, stderr on exit 0 goes to the debug log only. | User-facing summary moved to the `systemMessage` JSON field. Stderr and ANSI colours removed. |

| 3 | Git corroboration overrode the state file: any modified tracked file produced DIRTY, which was the resting state of the BFSI repo. | Git removed entirely and made a PRIME DIRECTIVE (Section 0). |

| 4 | Substring keyword matching: "settle" triggered Opus via "ttl"; "know" triggered Haiku via "no". | Word-boundary regex matching. Haiku restricted to prompts of 8 words or fewer. |

| 5 | Naive timestamps were treated as UTC, under-counting age by the local UTC offset. | Naive timestamps treated as local time. |

| 6 | `cwd` in `task_state.json` read but unused. | Still informational only; documented as such. |

| 7 | Section 4 embedded an older copy of the script that lacked project, cwd and stale detection. | Embedded copy removed; the live file is the single source of truth. |

| 8 | Section 9 referenced a BFSI memory note that does not exist. | Reference removed. |

| 9 | Sonnet 4.6 hard-coded as default while Sonnet 5 is current. | Default is now Sonnet 5. Price figures marked unverified. |

| 10 | CLAUDE.md demanded state-file bookkeeping while the hook was disabled and broken. | Hook enabled. |

| 11 | Hook read the `prompt` stdin field; current docs name it `user_prompt`. | Reads `user_prompt`, falls back to `prompt`. |


Verification after the rebuild: `py_compile` passes; the source contains no process

spawning; eight sample prompts produce the expected recommendations; the four

false-positive cases from v1 no longer misfire; a 2-day-old naive timestamp reports

roughly 53 hours of age.


---


<a id="s12"></a>

## 12. Future direction -- LLM-based assessment (planning)


[Back to TOC](#toc)


Status: the options below were the planning record. Outcome, per the decisions at the

end of this section: D's state half is implemented (Section 13), `[check]` mode is

implemented (Section 14), A and C are closed, B is parked, D's continuation hint is

deferred.


### The problem with keywords


Keyword matching cannot tell "what does TTL stand for?" (a Haiku question) from

"rewrite the TTL" (an Opus task). It cannot see that a short prompt like "continue"

is continuing a long-horizon build. And it cannot judge the state at all; it can only

read what Claude last wrote. The proposal is to let a model read the prompt (and

optionally the recent transcript), summarise what is being asked, and base both the

model recommendation and the state judgement on that understanding.


### Constraints any design must meet


1. PRIME DIRECTIVE holds. An HTTPS call to a model API is acceptable. Spawning the

   `claude` CLI or any other process is not.

2. Latency is paid on every prompt. Anything above roughly two seconds will be felt.

3. Cost is paid on every prompt. Only a fast, cheap model is defensible.

4. It must fail soft. If the network, proxy or Zscaler state breaks the call, the hook

   must fall back to the keyword path and say so, never error.

5. No credentials in code. API key from an environment variable only.


### Options


**A. Claude Code native `type: "prompt"` hook.** Claude Code can run a hook as a

single-turn model evaluation instead of a command. The prompt text is supplied via

`$ARGUMENTS` and the model returns a JSON decision. This is the least code and needs no

API key handling because it rides the session's own authentication. Open questions to

settle with a live test: whether `UserPromptSubmit` supports prompt hooks (the docs do

not list supported events explicitly), and whether the returned decision schema can

carry a free-form recommendation or is limited to allow/block plus reason. There is also

an experimental `type: "agent"` hook that can read files with tools, which could read the

transcript tail to judge task completion, but it is marked experimental and may change.


**B. Direct API call from `gatekeeper.py`.** The Python hook posts the prompt to

Haiku 4.5 through the Siemens proxy with a structured instruction and receives JSON:

`{summary, task_type, horizon, recommended_model, recommended_effort, confidence}`.

Full control over the schema and the fallback. Costs one network round trip per prompt

and depends on the proxy and Zscaler being reachable.


**C. Hybrid (recommended starting point).** Keep the keyword path as the fast lane for

unambiguous cases: very short confirmations, and prompts that contain an explicit Fable

trigger. Call the model (via A or B) only for prompts above a word threshold or with no

keyword hit. Most prompts then cost nothing extra, and the model is consulted exactly

where keywords are weakest.


**D. Ask the main model itself.** Inject an instruction via `additionalContext` telling

Claude to end each reply with a one-line recommendation for the next prompt and to keep

`task_state.json` updated with a short summary. Zero latency and zero extra cost, but it

is self-assessment by the model already chosen, and it only informs the next prompt.

Worth doing regardless, as a complement.


### The bigger prize: LLM-assessed state


The more valuable use of a model is not picking between Sonnet and Opus. It is judging

whether the previous task actually finished. The hook receives `transcript_path` on

stdin. A fast model given the last few turns of the transcript can answer "was the last

piece of work completed, handed off, or left mid-flight?" far more reliably than a file

Claude has to remember to reset. That judgement can then be written into

`task_state.json` (or a per-session sibling) so the state file becomes model-maintained

rather than discipline-maintained.


### Decisions taken


- 2026-09-26: **Option D is approved as an ADDITION to whichever of A/B/C is chosen,

  not a replacement.** D cannot see the next prompt, so it cannot judge that prompt's

  depth; its job is to keep `task_state.json` honest and to offer a continuation hint

  from work already in flight. Where D and the prompt-based recommendation disagree on

  the model, the prompt-based one wins. Nothing in D (or any option) switches the model;

  only the user typing `/model` does.

- 2026-09-26 (later): **D's state-maintenance half is IMPLEMENTED** (Section 13).

  D's continuation-hint half and all of A/B/C remain planning only.

- 2026-09-26: **Option A cannot be gated by keywords.** A prompt hook is a separate

  settings entry that Claude Code runs on every prompt; the Python hook cannot switch it

  on per prompt and the two cannot communicate. A real keyword-first hybrid (Option C)

  therefore requires B, where the model call sits inside `gatekeeper.py`.

- 2026-09-26: **B runs in a separate context.** It is a standalone API call that sees

  only what the hook sends it, by default the prompt text. To judge bare prompts like

  "continue" it would need `task_state.json` contents or a transcript tail passed in.

- 2026-09-26 (later): **[check] soft-check mode implemented (Section 14). A and C are

  CLOSED. B is PARKED**, to be revived only if the user finds they are routinely

  skipping `[check]` on heavy prompts. The soft check gives LLM understanding using the

  model already in the session, with full conversation context, no second hook, no API

  key and no separate context, which removes the reason A, B and C existed.


### Suggested next step


(Superseded by the decisions above.) The earlier plan was to prototype C with B as the

model path behind an `LLM_MODE` switch. That is no longer planned. The only open item

is D's continuation hint, and B stays parked unless `[check]` proves to be skipped on

heavy prompts in practice.


---


<a id="s13"></a>

## 13. v3 -- State split to end of turn, Option D implemented


[Back to TOC](#toc)


Implemented 2026-09-26, after the discussion recorded in Section 12.


### The problem with showing state on prompt submit


`UserPromptSubmit` fires after the user has typed. Showing the state there tells the

user whether the deck was clear for the prompt they have already sent. The user needs

that information BEFORE typing, at the moment Claude hands control back.


### The split


| Concern | Hook event | Script | Shown when |

|---------|-----------|--------|------------|

| State (CLEAN / DIRTY, task, age, stale) | `Stop` | `state_monitor.py` | Right after Claude finishes a turn, before the user types |

| Model and effort recommendation | `UserPromptSubmit` | `gatekeeper.py` | After the prompt is sent (applies to the next prompt) |


`gatekeeper.py` still reads the state, because Claude needs it in `additionalContext`

and because the user-visible block keeps a one-line "task in progress -- hold off

switching" warning at the moment they might reach for `/model`. It no longer prints the

full state block.


### state_monitor.py (Stop hook)


- Same PRIME DIRECTIVE as the gatekeeper: reads `task_state.json` and nothing else,

  spawns nothing, never blocks, always exits 0.

- Output is one `systemMessage`, for example:


```

STATE  [DIRTY]  task in progress: 'Writing bfsi-case.ttl' [BFSI]  (updated 12m ago)

       Phase P1 -- new shared case module

       ! Hold off switching model/effort until this task is complete

```


```

STATE  [CLEAN]  safe to switch model or effort  (updated 3m ago)

```


- Stale warning when in_progress for more than `STALE_HOURS` (24).

- Missing or malformed file is reported as `[UNKNOWN] ... assuming clean`.

- Registered in `settings.json` under `"Stop"` with a 5-second timeout.


### Age is measured from file mtime, not the `updated` field


Claude does not know the wall clock. The `updated` timestamps it writes are estimates

and were observed to be hours off. Both scripts therefore take the age from the file's

modification time, which is the true moment of the last write, and use the `updated`

field only as a fallback if the file cannot be stat'ed.


### Option D, state-maintenance half


`gatekeeper.py` now appends an instruction to `additionalContext` on every prompt:


- If state is clean: before ending the turn, write `task_state.json` as `in_progress`

  if substantial work is being started; do not write it for trivial Q&A.

- If state is in_progress: before ending the turn, write it `clean` if the task is now

  complete or handed off; leave it if still mid-flight; overwrite it if a different

  substantial task has begun.


Because Claude's Write happens during the turn and the Stop hook fires after it, the

state the user sees is never more than one turn behind reality.


### Not yet implemented


- D's continuation hint ("the next turn probably wants Opus") -- deliberately deferred

  with the rest of the model-recommendation work.

- A watchdog that flags "state not updated this turn" -- the Stop hook has no reliable

  turn-start time to compare against. mtime-based age is the interim answer.

- Options A, B, C -- since v4: A and C closed, B parked (Section 12, decisions).


### Verification


Confirmed live on 2026-09-26: the `Stop` hook's `systemMessage` renders in the terminal

and the user sees the STATE line after each turn. No `statusLine` fallback needed.


---


<a id="s14"></a>

## 14. v4 -- [check] soft-check mode


[Back to TOC](#toc)


Implemented 2026-09-26.


### Why


`UserPromptSubmit` fires after the prompt is sent, so the model recommendation always

arrives one turn late: the prompt has already executed on the current model. The user

wanted a way to have a prompt assessed WITHOUT executing it, decide on the model, and

then run it.


### How it works


Prefix the prompt with `[check]` (case-insensitive, spaces inside the brackets allowed,

must be at the very start):


```

[check] read and summarise the site www.wiki.com

```


Sequence:


1. `gatekeeper.py` detects the prefix, strips it, and runs the keyword heuristic on the

   rest as usual.

2. The user-visible block is headed `GATEKEEPER -- CHECK MODE: assessment only, nothing

   will be executed`.

3. Into Claude's context it injects a CHECK MODE instruction: do not execute, do not call

   tools, do not start the task, do not write `task_state.json`. Reply in under 120 words

   with (1) what the request would involve, (2) size and depth, (3) recommended model and

   effort with a one-sentence reason, (4) whether the current task state allows

   switching. End with exactly: "Switch with /model if needed, then type go." The keyword

   guess is passed in so the model can override it.

4. The Option D state instruction is suppressed in check mode, since nothing starts or

   finishes.

5. The current model answers with the assessment and stops.

6. The user optionally types `/model <name>`, then `go`. The original prompt is still

   in the conversation, so the chosen model executes it from context. No retyping.


### Why this beats a hard block


A hook can block a prompt with exit code 2, but Claude Code erases the prompt, forcing

a retype. The soft check keeps the prompt, costs one short reply, and the assessment

comes from a model that actually understood the request and can see `task_state.json`

and the conversation.


### Caveat


The soft check relies on the model obeying "do not execute". Sonnet and above do.

Haiku may occasionally charge ahead; if that is observed, a hard block (exit 2) for

check mode on Haiku is the safeguard.


### Tests at implementation


| Prompt | Check mode | Keyword guess | Option D injected |

|--------|-----------|---------------|-------------------|

| `[check] read and summarise the site ...` | yes | Sonnet 5 | no |

| `[CHECK]  implement the ontology` | yes | Opus 5.5 | no |

| `  [ check ] ok` | yes | Haiku 4.5 | no |

| `please check the ttl` | no | Opus 5.5 | yes |

| `the [check] tag in the middle` | no | Sonnet 5 (default) | yes |


---


<a id="s15"></a>

## 15. v5 -- second integrity review


[Back to TOC](#toc)


A second review on 2026-09-26 compared both live scripts, `settings.json` and this

document after v3 and v4 had landed.


### Code findings and fixes


| # | Finding | Fix |

|---|---------|-----|

| 1 | `gatekeeper.py` treated any `status` other than `in_progress` or `unknown` as CLEAN with the reason "task_state.json: clean", while `state_monitor.py` showed UNKNOWN. A typo such as `in-progress` silently unlocked switching. | Both hooks now name the unexpected value and say "assuming clean". Gatekeeper's injected context shows the reason. |

| 2 | Both hooks skipped age calculation entirely when the `updated` field was empty, even though mtime was available. Contradicted Section 13. | Age is always computed; `updated` is only the fallback inside the age function. |

| 3 | `settings.json` maps the `sonnet` alias to `claude-sonnet-4-6@default` while the hook recommends `/model Sonnet 5`. | Not changed (proxy setting). Recorded in Section 8 and the script header; to be checked live with `/model`. |

| 4 | PRIME DIRECTIVE scope was ambiguous: the Siemens code-scan hook in the same file fetches via `git+https`. | Section 0 now states the directive binds the two gatekeeper hooks only. |

| 5 | `state_age_hours` docstring described the pre-mtime behaviour; `project` and `cwd` unpacked in `main()` and unused. | Docstring corrected; unused values named `_project`, `_task_cwd` with a comment. |


Verification: both scripts compile; a harness with temporary state files confirmed

identical CLEAN / DIRTY / UNKNOWN verdicts from both hooks for: clean, fresh

in_progress, in_progress with no `updated` field and a 30h-old mtime (stale warning

fires in both), `status: paused`, `status: in-progress`, and file missing.


### Document findings and fixes


| Section | Was | Now |

|---------|-----|-----|

| Header | Revised line stopped at v3. | Lists v4 and v5. |

| 0 | Named only the gatekeeper hook. | Names both hooks; scope note for the code-scan hook. |

| 2.2 | Stale detection from `updated`. | From file mtime; unexpected status documented. |

| 3 | settings.json change listed only UserPromptSubmit; claimed no memory notes existed. | Stop block listed; two memory pointers acknowledged. |

| 4 | Replication copied and registered one script. | Both scripts copied, compiled, registered and smoke-tested. |

| 5 | Examples showed v2 state lines with no pointer. | Notes added pointing to the STATE block and the Option D / check-mode context. |

| 6 | One switch documented. | Both `ENABLED` flags documented. |

| 8 | No mention of `[check]`, Sonnet alias, or fail-open status. | Three rows added. |

| 9 | Scope table omitted `state_monitor.py`. | Added. |

| 10 | Behaviour table had no unexpected-status row. | Added; mtime stated. |

| 12 | "PLANNING ONLY. Nothing implemented" and a next step recommending C via B. | Status reflects decisions; next step marked superseded. |

| 13 | "Not yet implemented: A, B, C". | Cross-referenced to the A/C closed, B parked decision. |


### Second pass (same day)


A re-read of the patched files found one remaining seam: a file that parsed but had no

`status` field was described as "missing or unreadable" by the gatekeeper and as

`status='unknown'` by the Stop hook. Both now say "has no status field -- assuming

clean". Doc touch-ups: Section 3 names `[check]` in the gatekeeper row, Section 2.4

lists the full live effort keyword sets, Section 5 Example 2 shows the actual DIRTY

reminder line, Section 10 has the no-status row. Re-verified: both scripts compile;

six state-file scenarios (clean, fresh in_progress, 30h in_progress without `updated`,

no status field, `paused`, malformed JSON) give matching verdicts from both hooks; both

Section 4 smoke tests produce the documented output; `[check]` on a dirty state injects

the CHECK MODE instruction and suppresses Option D.


### Appendix added (same day)


At the user's request the full source of both hooks, `task_state.json` and the two

`settings.json` hook blocks are reproduced in Appendix A for copy-and-paste. Neither

script contains a user path or credential; the hook commands use `<username>`. The

`settings.json` `env` block (proxy credentials, telemetry identity) is deliberately

excluded. The appendix is a plain copy, so it must be re-pasted after every script edit

(rule stated in Section 4). A.5 adds the full Task State Convention section of the global

`CLAUDE.md`, previously only referenced by name, with its two user paths as `<USER>`.


### CLAUDE.md section trimmed (same day)


The section had grown to 65 lines, over half of the global `CLAUDE.md`. Long instruction

files dilute compliance with every rule in them, so it was cut to about 22 lines keeping

only what changes Claude's behaviour: when to write the state file and its six fields,

the `[check]` rule, the PRIME DIRECTIVE in two sentences, and the pointer here. The

PRIME DIRECTIVE rationale and scope note stay in Section 0. A.5 shows the trimmed form.


---


<a id="appA"></a>

## Appendix A -- Full source of the live files


[Back to TOC](#toc)


Copied verbatim from the live files on 2026-09-26 (v5, second pass). `<USER>` stands for

`C:\Users\<username>`; in hook commands the POSIX form `/c/Users/<username>` is used.

Neither script contains a user path or credential. `settings.json` is shown as the two

hook blocks only: its `env` block holds proxy credentials and telemetry identity and must

never be copied into documentation or between machines.


**Maintenance rule:** whenever `gatekeeper.py`, `state_monitor.py` or the Task State

Convention section of `CLAUDE.md` is edited, re-paste the whole file or section here in

the same session, then run `py_compile` on any edited script.


### A.1 `<USER>\.claude\hooks\gatekeeper.py`


```python

"""

Gatekeeper -- UserPromptSubmit hook for Claude Code.


=====================================================================

PRIME DIRECTIVE

  This hook NEVER runs git -- not directly, not indirectly.

  It spawns no processes of any kind: no subprocess, no os.system,

  no os.popen, no shelling out. Its only inputs are the JSON on stdin

  and one file, task_state.json. Its only output is JSON on stdout.

  A runtime self-check (check_prime_directive) confirms that no

  process-spawning module has been imported, and reports a violation

  in the user-visible message if it ever finds one.

=====================================================================


Fires on every prompt submission. Assesses clean state from

task_state.json and recommends a model and effort level based on the

prompt text.


To ENABLE  : set ENABLED = True below.

To DISABLE : set ENABLED = False below.


When disabled the script exits silently as soon as it is imported past

the switch -- no output, no context injection.


Output channels (per Claude Code hook docs):

  - systemMessage         -> shown to the USER in the terminal

  - additionalContext     -> injected into CLAUDE's context

  stderr is NOT shown to the user on exit 0, so it is not used here.


Model cost reference (as recorded Sep 2026, unverified -- check the

current price list before relying on the figures):

  Fable 5.1  : most expensive tier  -- long-horizon agentic tasks only

  Opus 5.5   : $4  / $20 per MTok   -- complex reasoning, coding, TTL work

  Sonnet 5   : $2  / $10 per MTok   -- edits, explanations, medium tasks

  Haiku 4.5  : $1  / $5  per MTok   -- quick confirmations, one-liners


Note on the Sonnet label: settings.json maps the "sonnet" alias

(ANTHROPIC_DEFAULT_SONNET_MODEL) to claude-sonnet-4-6@default. The

recommendation below says "Sonnet 5". If /model Sonnet 5 does not

resolve through the proxy, either update that env var or change

DEFAULT_MODEL_LABEL / the Sonnet rule label here.

"""


import sys

import json

import os

import re

from datetime import datetime


TASK_STATE_FILE = os.path.join(

    os.path.expanduser("~"), ".claude", "hooks", "task_state.json"

)


# -------------------------------------------------------

# MASTER SWITCH -- flip to False to deactivate

# -------------------------------------------------------

ENABLED = True

# -------------------------------------------------------


if not ENABLED:

    sys.exit(0)



# -------------------------------------------------------

# MODEL SELECTION RULES

# Evaluated top-to-bottom; first match wins.

# Keywords match on WORD BOUNDARIES: "ttl" matches "ttl" and

# "ttl-file" but not "settle"; "no" does not match "know".

# Fable is intentionally last and has a very high bar --

# it is the most expensive tier and is only justified for

# truly long-horizon, multi-step autonomous tasks.

# -------------------------------------------------------


MODEL_RULES = [

    (

        "Opus 5.5", "claude-opus-5-5",

        "complex reasoning, TTL/SPARQL authoring, ontology work, deep reviews",

        [

            "ttl", "turtle", "sparql", "owl", "ontology", "rdf",

            "implement", "build the", "write the", "generate",

            "create a script", "review all", "self-review", "full review",

            "architecture", "master plan", "masterplan",

            "phase p1", "phase p2", "phase p3",

            "validate", "anzo", "graphmart",

        ],

    ),

    (

        "Sonnet 5", "claude-sonnet-5",

        "edits, explanations, updates, medium-complexity tasks",

        [

            "explain", "summarise", "summarize", "update", "edit",

            "add a line", "what is", "how does", "can you check",

            "what does", "describe", "clarify", "in short",

            "quick", "brief",

        ],

    ),

    (

        "Haiku 4.5", "claude-haiku-4-5-20251001",

        "one-liners, confirmations, trivial follow-ups",

        [

            "yes", "no", "ok", "okay", "thanks", "great",

            "go ahead", "proceed", "continue", "sounds good",

            "make it so", "done",

        ],

    ),

    (

        "Fable 5.1", "claude-fable-5-1",

        "long-horizon multi-step autonomous tasks only -- most expensive tier",

        [

            "full build", "end to end", "end-to-end",

            "all phases", "entire p1", "entire build",

            "autonomous", "multi-step workflow",

            "run the whole", "build and validate and upload",

            "run everything", "do the whole",

        ],

    ),

]


DEFAULT_MODEL_LABEL = "Sonnet 5"

DEFAULT_MODEL_ID    = "claude-sonnet-5"

DEFAULT_MODEL_PURPOSE = "edits, explanations, updates, medium-complexity tasks"


# Haiku is only recommended for genuinely short prompts. A long prompt

# that happens to contain "ok" or "yes" is not a confirmation.

HAIKU_MAX_WORDS = 8


EFFORT_HIGH_KEYWORDS = [

    "review all", "self-review", "full review", "check everything",

    "audit", "validate all", "integrity check", "deep review",

]

EFFORT_LOW_KEYWORDS = [

    "quick", "briefly", "one line", "just check", "in short",

]


FABLE_WARNING = (

    "NOTE: Fable 5.1 is the most expensive tier. "

    "Only use it if this is truly a long-horizon autonomous task "

    "that Opus at high effort cannot handle."

)


STALE_HOURS = 24  # warn if in_progress for longer than this


# -------------------------------------------------------

# CHECK MODE

# If the prompt begins with [check], Claude is instructed to ASSESS the

# request and stop, without executing it or calling tools. The current

# model does the assessing, so it uses real understanding and the full

# conversation context, not keywords. The prompt stays in the

# conversation, so after an optional /model switch the user types "go".

# -------------------------------------------------------

CHECK_PREFIX_RE = re.compile(r"^\s*\[\s*check\s*\]\s*", re.IGNORECASE)


CHECK_MODE_INSTRUCTION = (

    "CHECK MODE. The user prefixed this prompt with [check]. Do NOT execute the "

    "request, do NOT call any tools, do NOT start the task, and do NOT write "

    "task_state.json. Instead reply with a short assessment, under 120 words: "

    "(1) one line restating what the request would involve; "

    "(2) size and depth: trivial / medium / complex / long-horizon; "

    "(3) recommended model (Haiku 4.5, Sonnet 5, Opus 5.5 or Fable 5.1) and effort "

    "(low / medium / high), with a one-sentence reason; "

    "(4) whether the current task state allows switching. "

    "End with exactly: 'Switch with /model if needed, then type go.' "

    "The keyword heuristic guessed {label}; override it if your reading differs."

)


FORBIDDEN_MODULES = ("subprocess", "pty", "multiprocessing")



# -------------------------------------------------------

# HELPERS

# -------------------------------------------------------


def check_prime_directive():

    """

    Return a violation string if any process-spawning module is loaded,

    else an empty string. This hook must never shell out.

    """

    loaded = [m for m in FORBIDDEN_MODULES if m in sys.modules]

    if loaded:

        return (

            "PRIME DIRECTIVE VIOLATION: process-spawning module(s) loaded: "

            + ", ".join(loaded)

        )

    return ""



def read_task_state():

    """

    Sole clean-state signal.

    Returns (status, task, detail, project, cwd, updated) from task_state.json.

    Falls back to ("unknown", "", "", "", "", "") if file missing or unreadable.

    Status is "missing" if the file parses but has no status field.

    """

    try:

        with open(TASK_STATE_FILE, "r", encoding="utf-8") as f:

            data = json.load(f)

        return (

            data.get("status", "missing"),

            data.get("task", ""),

            data.get("detail", ""),

            data.get("project", ""),

            data.get("cwd", ""),

            data.get("updated", ""),

        )

    except Exception:

        return ("unknown", "", "", "", "", "")



def state_age_hours(updated_str):

    """

    Return hours since the last write of task_state.json, or None.

    Primary source is the file's mtime. The 'updated' field is only a

    fallback if the file cannot be stat'ed; naive values are treated as

    LOCAL time, because that is how the convention writes them.

    """

    try:

        try:

            # File mtime is the true time of the last write. Claude does not

            # know the wall clock, so the 'updated' field is only a fallback.

            updated = datetime.fromtimestamp(os.path.getmtime(TASK_STATE_FILE)).astimezone()

        except OSError:

            updated = datetime.fromisoformat(updated_str)

            if updated.tzinfo is None:

                updated = updated.astimezone()

        delta = datetime.now().astimezone() - updated

        return delta.total_seconds() / 3600

    except Exception:

        return None



def assess_clean_state():

    """

    Returns (is_clean, reason, is_stale, age_hours, project, task_cwd).

    """

    task_status, task_name, task_detail, project, task_cwd, updated = read_task_state()

    # Age comes from file mtime, so it is computed even if 'updated' is empty.

    age_hours = state_age_hours(updated)

    is_stale = (

        task_status == "in_progress"

        and age_hours is not None

        and age_hours > STALE_HOURS

    )

    project_tag = f" [{project}]" if project else ""


    if task_status == "in_progress":

        reason = f"task in progress: '{task_name}'{project_tag}"

        if task_detail:

            reason += f" -- {task_detail}"

        return False, reason, is_stale, age_hours, project, task_cwd


    if task_status == "clean":

        return True, "task_state.json: clean", False, None, project, task_cwd


    if task_status == "unknown":

        return True, "task_state.json missing or unreadable -- assuming clean", False, None, "", ""


    if task_status == "missing":

        return True, "task_state.json has no status field -- assuming clean", False, None, project, task_cwd


    # Any other value is a convention breach (typo or unknown state).

    # Same handling as state_monitor.py: report it, assume clean.

    return (

        True,

        f"task_state.json status='{task_status}' is not clean/in_progress -- assuming clean",

        False, None, project, task_cwd,

    )



def kw_match(kw, prompt_lower):

    """Word-boundary match: keyword must not be embedded in a longer token."""

    pattern = r"(?<![a-z0-9])" + re.escape(kw) + r"(?![a-z0-9])"

    return re.search(pattern, prompt_lower) is not None



def recommend_model(prompt_lower):

    word_count = len(prompt_lower.split())

    for label, model_id, purpose, keywords in MODEL_RULES:

        if label.startswith("Haiku") and word_count > HAIKU_MAX_WORDS:

            continue

        for kw in keywords:

            if kw_match(kw, prompt_lower):

                return label, model_id, kw, purpose

    return DEFAULT_MODEL_LABEL, DEFAULT_MODEL_ID, "default", DEFAULT_MODEL_PURPOSE



def recommend_effort(prompt_lower):

    if any(kw_match(kw, prompt_lower) for kw in EFFORT_HIGH_KEYWORDS):

        return "high"

    if any(kw_match(kw, prompt_lower) for kw in EFFORT_LOW_KEYWORDS):

        return "low"

    return None



# -------------------------------------------------------

# MAIN

# -------------------------------------------------------


def main():

    raw = sys.stdin.read()

    try:

        data = json.loads(raw)

    except Exception:

        sys.exit(0)


    # Current docs name the field "user_prompt"; older builds used "prompt".

    prompt = data.get("user_prompt") or data.get("prompt", "")

    check_mode = CHECK_PREFIX_RE.match(prompt) is not None

    if check_mode:

        prompt = CHECK_PREFIX_RE.sub("", prompt, count=1)

    pl     = prompt.lower()


    violation = check_prime_directive()

    # project and cwd are informational only (kept in the file for the user).

    is_clean, state_reason, is_stale, age_hours, _project, _task_cwd = assess_clean_state()

    model_label, _, trigger, purpose = recommend_model(pl)

    effort   = recommend_effort(pl)

    is_fable = "Fable" in model_label


    state_tag = "[CLEAN]" if is_clean else "[DIRTY]"


    # ---- user-visible message (systemMessage) ----

    # Model recommendation only. The STATE is shown by state_monitor.py

    # (Stop hook) at the end of each turn, before the user types.

    lines = [

        "GATEKEEPER" + ("  -- CHECK MODE: assessment only, nothing will be executed" if check_mode else ""),

        f"  Model  : /model {model_label}  (trigger: '{trigger}')",

        f"  Use for: {purpose}",

    ]

    if effort:

        lines.append(f"  Effort : consider /effort {effort}")

    if not is_clean:

        lines.append(f"  ! {state_tag} task in progress -- hold off switching model/effort")

    if is_fable:

        lines.append(f"  ! {FABLE_WARNING}")

    if violation:

        lines.append(f"  !!! {violation}")

    system_message = "\n".join(lines)


    # ---- context injected for Claude (additionalContext) ----

    stale_note = ""

    if is_stale and age_hours is not None:

        stale_note = f" WARNING: state has been in_progress for {int(age_hours)}h -- may be stale."


    if is_clean and state_reason == "task_state.json: clean":

        state_text = "clean -- safe to switch model or effort"

    elif is_clean:

        state_text = "clean (" + state_reason + ") -- safe to switch model or effort"

    else:

        state_text = (

            "DIRTY (" + state_reason + ") -- hold off switching model or effort "

            "until current task is complete"

        )


    injected = (

        f"[Gatekeeper] State: {state_text}.{stale_note} "

        f"Suggested model: {model_label} (matched keyword '{trigger}'; {purpose})."

    )

    if effort:

        injected += f" Suggested effort: {effort}."

    if is_fable:

        injected += f" {FABLE_WARNING}"

    if violation:

        injected += f" {violation}"


    # ---- Check mode: assess only, no execution, no state writes ----

    if check_mode:

        injected += " " + CHECK_MODE_INSTRUCTION.format(label=model_label)


    # ---- Option D: state maintenance instruction for Claude ----

    # Claude keeps task_state.json honest at the END of its turn, because

    # a Stop hook then displays it to the user before the next prompt.

    # Skipped in check mode: nothing is started or finished.

    if check_mode:

        pass

    elif is_clean:

        injected += (

            " [Option D] Before ending this turn: if you START substantial work "

            "(code, TTL, SPARQL, multi-section docs, a build phase), write "

            "task_state.json with status in_progress. Do NOT write it for "

            "trivial Q&A."

        )

    else:

        injected += (

            " [Option D] Before ending this turn: if the in-progress task is now "

            "COMPLETE or handed off to the user, write task_state.json with status "

            "clean. If it is still mid-flight, leave it. If you have moved on to a "

            "different substantial task, overwrite it with the new task."

        )


    out = {

        "systemMessage": system_message,

        "hookSpecificOutput": {

            "hookEventName": "UserPromptSubmit",

            "additionalContext": injected,

        },

    }

    print(json.dumps(out))



if __name__ == "__main__":

    main()

```


### A.2 `<USER>\.claude\hooks\state_monitor.py`


```python

"""

State Monitor -- Stop hook for Claude Code.


=====================================================================

PRIME DIRECTIVE (shared with gatekeeper.py)

  This hook NEVER runs git -- not directly, not indirectly.

  It spawns no processes. Its only inputs are the JSON on stdin and

  one file, task_state.json. Its only output is JSON on stdout.

=====================================================================


Fires every time Claude finishes a turn and hands control back to the

user. Reads task_state.json and shows the CURRENT state so the user

knows, before typing the next prompt, whether the deck is clear.


This is the "state" half of the gatekeeper, split out from the

UserPromptSubmit hook so the information arrives BEFORE the next

prompt rather than after it.


Output: one JSON object with a systemMessage (shown to the user).

Never blocks. Always exits 0.


To DISABLE : set ENABLED = False below.

"""


import sys

import json

import os

from datetime import datetime


TASK_STATE_FILE = os.path.join(

    os.path.expanduser("~"), ".claude", "hooks", "task_state.json"

)


ENABLED = True


if not ENABLED:

    sys.exit(0)


STALE_HOURS = 24


FORBIDDEN_MODULES = ("subprocess", "pty", "multiprocessing")



def check_prime_directive():

    loaded = [m for m in FORBIDDEN_MODULES if m in sys.modules]

    if loaded:

        return "PRIME DIRECTIVE VIOLATION: process-spawning module(s) loaded: " + ", ".join(loaded)

    return ""



def read_task_state():

    try:

        with open(TASK_STATE_FILE, "r", encoding="utf-8") as f:

            data = json.load(f)

        return data, ""

    except FileNotFoundError:

        return {}, "task_state.json missing"

    except Exception as exc:

        return {}, f"task_state.json unreadable ({type(exc).__name__})"



def age_text(updated_str):

    """

    Return (hours_float or None, human text).

    Age is taken from the file's modification time, which is the true moment

    of the last write. The 'updated' field is written by Claude, which does

    not know the wall clock, so it is only used as a fallback.

    """

    try:

        try:

            updated = datetime.fromtimestamp(os.path.getmtime(TASK_STATE_FILE)).astimezone()

        except OSError:

            updated = datetime.fromisoformat(updated_str)

            if updated.tzinfo is None:

                updated = updated.astimezone()

        secs = (datetime.now().astimezone() - updated).total_seconds()

        hours = secs / 3600

        if secs < 0:

            return hours, "timestamp in the future"

        if secs < 90:

            return hours, "just now"

        if secs < 3600:

            return hours, f"{int(secs // 60)}m ago"

        if hours < 48:

            return hours, f"{int(hours)}h ago"

        return hours, f"{int(hours // 24)}d ago"

    except Exception:

        return None, "no valid timestamp"



def main():

    # Drain stdin so the caller never sees a broken pipe; content unused.

    try:

        sys.stdin.read()

    except Exception:

        pass


    data, problem = read_task_state()

    status  = data.get("status", "")

    task    = data.get("task", "")

    detail  = data.get("detail", "")

    project = data.get("project", "")

    updated = data.get("updated", "")

    # Age comes from file mtime, so it is computed even if 'updated' is empty.

    hours, when = age_text(updated)


    lines = []

    if problem:

        lines.append(f"STATE  [UNKNOWN]  {problem} -- assuming clean")

    elif status == "in_progress":

        tag = f" [{project}]" if project else ""

        head = f"STATE  [DIRTY]  task in progress: '{task}'{tag}  (updated {when})"

        lines.append(head)

        if detail:

            lines.append(f"       {detail}")

        if hours is not None and hours > STALE_HOURS:

            lines.append(

                f"       ! flagged in_progress for {int(hours)}h -- may be stale. "

                "If the task is done, ask Claude to reset task_state.json."

            )

        lines.append("       ! Hold off switching model/effort until this task is complete")

    elif status == "clean":

        lines.append(f"STATE  [CLEAN]  safe to switch model or effort  (updated {when})")

    elif status == "":

        lines.append("STATE  [UNKNOWN]  task_state.json has no status field -- assuming clean")

    else:

        # Same handling as gatekeeper.py: report the breach, assume clean.

        lines.append(

            f"STATE  [UNKNOWN]  status='{status}' -- expected clean or in_progress -- assuming clean"

        )


    violation = check_prime_directive()

    if violation:

        lines.append(f"       !!! {violation}")


    out = {"systemMessage": "\n".join(lines)}

    print(json.dumps(out))



if __name__ == "__main__":

    main()

```


### A.3 `<USER>\.claude\hooks\task_state.json`


Contents change constantly; these are the two shapes Claude writes. Install with the

clean form.


Clean (install this):


```json

{

  "status": "clean",

  "task": "",

  "detail": "",

  "project": "",

  "cwd": "",

  "updated": "2026-01-01T00:00:00"

}

```


In progress (example):


```json

{

  "status": "in_progress",

  "task": "Writing bfsi-case.ttl",

  "detail": "Phase P1 -- new shared case module",

  "project": "BFSI",

  "cwd": "<USER>\\Projects\\BFSI",

  "updated": "2026-09-26T14:32:00"

}

```


### A.4 `<USER>\.claude\settings.json` -- hook blocks only


Merge these two entries into the existing `"hooks"` object. Do not copy the rest of

`settings.json` between machines; its `env` block contains credentials.


```json

{

  "hooks": {

    "UserPromptSubmit": [

      {

        "hooks": [

          {

            "type": "command",

            "command": "/c/miniforge3/python /c/Users/<username>/.claude/hooks/gatekeeper.py",

            "statusMessage": "Gatekeeper assessing prompt..."

          }

        ]

      }

    ],

    "Stop": [

      {

        "hooks": [

          {

            "type": "command",

            "command": "/c/miniforge3/python /c/Users/<username>/.claude/hooks/state_monitor.py",

            "timeout": 5

          }

        ]

      }

    ]

  }

}

```


### A.5 `<USER>\.claude\CLAUDE.md` -- the "Task State Convention" section


Paste this section into the global `CLAUDE.md` on the target machine. Without it Claude

never writes `task_state.json` and the state signal is dead. Two paths inside it use

`<USER>`; substitute the real user directory. This is the trimmed v5 form (about 22

lines): it keeps every rule that changes Claude's behaviour and leaves rationale, scope

and examples to this document (Sections 0, 5, 13, 14).


````markdown

## Task State Convention (Gatekeeper Hooks)


Two global hooks (`gatekeeper.py` on UserPromptSubmit, `state_monitor.py` on Stop) read

`<USER>\.claude\hooks\task_state.json` and nothing else. Keep it accurate

BEFORE ending a turn; the user sees it immediately after.


- **START of a substantial task** (code, TTL, SPARQL, multi-section docs, scripts, build

  phases): write `{"status": "in_progress", "task": "<short name>", "detail": "<optional>",

  "project": "<e.g. BFSI>", "cwd": "<working dir>", "updated": "<ISO local time>"}`.

- **END of a task** (complete, handed off, or paused): write `"status": "clean"` with all

  other fields empty strings.

- Trivial follow-ups (a question, a one-line edit) need no update.


**[check] mode:** if a prompt begins with `[check]`, do NOT execute it or call tools.

Reply with a short assessment (what it involves, size, recommended model and effort,

whether the state allows switching) ending "Switch with /model if needed, then type go."

On "go", execute the checked prompt.


**PRIME DIRECTIVE (all projects):** these hooks, and any hook written for this user, must

never run git or spawn processes (no subprocess, os.system, os.popen, pty,

multiprocessing). After any hook edit run `C:\miniforge3\python -m py_compile` on it.


Rationale, scope, decisions and full source:

`<USER>\.claude\prompt_gatekeeper_model_switcher.md`

````


---


*End of Prompt Gatekeeper and Model Switcher documentation.*