Repository navigation
Kherep 0.2.0
Added
-
Control Plane: with
wake.replies: truethe messages of a task an idle
Claude Code session requested withmsg send <node> --newalso wake it
without a listing, so the task's plainmsg sendanswer, which carries the
task id and noinReplyTo, is not left waiting for the next user turn. The
grant needs the request to be dispatched, the message to come from the node
the task was dispatched to, the request to be at most 24 hours old, and the
requesting session's id, whichmsg send --newandtask newnow keep locally as
requestedBySessionIdand never sends to the Worker, to match (a request
without it matches byrequestedBy). It covers every message of the task,
bounded by the turn budget. Audit lines name"grant": "task", delivery
progress reports such messages like replies, and Codex requesters are out
of scope (issue #264). -
Control Plane: the optional node policy object
wake.budget
(perHour,perDay,spacingSeconds) sets the per-session budget of
autonomous turns, which stays 6 per rolling hour, 20 per rolling day and
30 s apart without it or for a missing field. Values must be integers within
hard bounds (perHour1 to 60,perDay1 to 500,spacingSeconds5 to
3600), an explicitperDayat leastperHour(without it the day allows at leastperHourturns); anything else, or an unknown key,
rejects the wholewakesection. The Claude Code wake listener, the Claude
and CodexStopcontinuations, the Codex task resume,codex queueand
closed-session delivery use the same effective budget, anddoctorshows it
aswakeBudget(issue #259). -
Control Plane: the opt-in node policy switch
wake.replies: truelets a
reply wake the idle Claude Code session that sent the original message,
without listing the session;sessionsmay then be empty. The grant needs
the reply to come from the node the original was sent to, the original to
be at most 24 hours old and not refused, expired or error, and the sending
session's id, whichmsg sendnow keeps locally asfromSessionIdand never
sends to the Worker, to match (a sent record without it matches by name).
Every other wake guard still applies; audit lines name"grant": "reply",
delivery progress reports such replies, anddoctorshows the switch. A
non-boolean value turns waking off. Codex wake paths are unchanged
(issue #253). -
Control Plane:
GET /healthalso returns the bundled productversion,
an optional deploy-time sourcecommit(--define KHEREP_BUILD_COMMIT) and
remoteMcp, still without authentication or configuration values. Existing
fields are unchanged (issue #215). -
Control Plane:
kherep-node doctorprints a JSON report on enrollment and
key, daemon liveness, Worker reachability and version, policy and wake
summary, installed runtimes and versions, hook paths against this checkout
and live wake listeners, and exits 1 when a check fails. The daemon now
writesdaemon.json(pid, start, last authenticated connection and lost
connection time) for it (issue #215). -
Control Plane:
kherep-node doctorreports each runtime'sreadyfrom the
daemon's last readiness probe instead of"not available":true, or
falsewith the fixedcause(sign-in,timeout,error), each with
probedAtandaged(older than the daemon's 10 or 2 minute revalidation
bound), or"unknown"when the daemon is not running or has not probed that
runtime. Doctor never probes. A runtime the sessions policy names whose last
probe needs a sign-in fails theruntimescheck. The daemon records each
completed probe indaemon.jsonasreadiness.<runtime>, without the
probe's output (issue #222). -
Control Plane: a runtime readiness probe (issue #197).
claude auth status
reportedloggedIn: truefor an expired login, so the node checks each
enabled runtime with a real minimal call:claude -p --safe-mode --no-session-persistence --tools ""with a one-line system prompt, and
codex -c features.hooks=false exec --ephemeral --sandbox read-onlywith the
user's config, no hooks and its MCP servers disabled as for intercom runs. It probes once at daemon start;
afterwards a verdict is used stale while it revalidates in the background
(ready after 10 minutes, not ready after 2, also from the session round; 45
second timeout), and no probe runs on the daemon's frame lane. A ready
runtime is advertised asruntime.claude.ready.v1or
runtime.codex.ready.v1. For a runtime that is not signed in, a task start
or continue fails withtarget runtime <runtime> not ready (sign-in required)and starts nothing, and a message that a closed-session delivery
or a Codex message resume would carry isrefusedwith that reason instead
ofdelivered. A probe that timed out or failed otherwise blocks no task;
messages wait withretry-pending. -
Control Plane: a task or intercom run without first turn progress 10
minutes after its start is stopped and reportedfailedwithno progress after start(issue #197). The messages a Claude intercom run carried are
refused withtarget run made no progress after start; those of a Codex run
get thewake-failedprogress and are offered again within the offer limit. -
Control Plane:
msg statusandmsg send --waitshow the sender states
runningandstopped, derived from the existing accepted progress codes
without a new wire state, and an accepted message without progress for 5
minutes gets a fixed actionable reason (issue #197). Messages for Codex task
sessions now carry a progress code while the task is busy, stopped by its
operator, held by a wake guard, failing to resume or running. -
Control Plane: the MCP
statustool also returnssenderState(running,
stoppedor the canonical state, from the samesenderStatefunction as
msg status) and, for an accepted message without progress for 5 minutes,
a fixedhint(issue #197). Thestatefield is unchanged. -
Control Plane: a Codex intercom run the node started on its own is listed
withkindcodex-intercominstead ofcodex-task, so the MCPsessions
tool tells it apart from a requested task;msg sessionsmarks both as
[background task](issue #198). -
Control Plane: an idle Claude Code session wakes after a restart without any
user input (issue #101). ASessionStartinput withoutpermission_mode
and without a stored mode now takes the mode of the last user entry in the
session transcript (transcript_path, the last 2 MiB, only the
permissionModefield, a regular file underprojects/of the Claude
config directory;node/transcript-mode.mts) and stores it. A missing,
foreign or unreadable transcript, orbypassPermissionsthere, still
refuses; the listing, settings and launch flag checks run as before. The
listener armed there also wakes once, after 8 s, for messages that arrived
while no listener ran (auditbacklog), each at most once and under the
same guards and budget; a prompt within those 8 s supersedes it. -
Control Plane: a message for a session of this node that is no longer
running is still handled when the node policy sets
messaging.resumeClosed: true(issue #102, default off). The daemon
resumes the known session in the background in its recordedautoor
defaultmode (Claude Codeclaude --resume <id> --bg, Codex
codex exec resume <thread>) with the fixed wake text, so the message
arrives as framed peer content, or otherwise starts an intercom session
with the closed session's runtime and working directory and a directive
that names the automatic fallback. The guards of--newand the wake apply
fail closed (kill switch, sessions and runtime,delegate.acceptand the
accept rules, reply depth, neverbypassPermissions,maxConcurrent,
workspace roots, the session's turn budget), a message causes one attempt
at most, and every outcome is audited inwake.jsonlasclosed-session
resumed,neworrefusedwith the reason. Every successful listing is
kept for 7 days inknown-sessions.json. -
Control Plane: a message for a closed session goes to one intercom session
per sender session instead of resuming the closed session, whose whole
conversation a resume would reload (issue #105). The newest intercom session
the node started itself for the same sender (requestedBy
<node id>/<session>) gets it: a running one through its delivery hook and
wake, the message readdressed to it (closedTokeeps the closed session)
and its task grant extended to the messages of that sender; an ended one is
resumed in the background (claude --resume,codex exec resume). Only
without such a session, or when its resume fails, a new one starts. The
closed session itself is no longer resumed. Every message now carries its
threaded reply command: a new intercom session gets the messages framed as
the delivery hook frames them, each with
<cli> msg send --reply-to <message id> -- <reply text>, and is told to
answer with it, as the hook and the Codex resume already frame it. The
guards stay fail closed; the audit outcomeresumedis replaced by
reused(with the intercom session'staskId), next tonewand
refused. -
Control Plane: the Claude Code wake listener is armed at
SessionStarttoo
(issue #97), so an idle session is wakeable again after its process
restarts (app restart,--resume,--continue,/clear, fork) without
waiting for the user to type. The installer adds the entry last to the
SessionStarthooks with the sameasyncRewakeand timeout. A following
prompt supersedes that listener as before. Aftercompactthe session
counts as busy, because auto-compaction runs inside a turn. ASessionStart
input carries nopermission_mode, so the listener arms there only when,
fail closed, the mode the session's last prompt orStopreported
(listeners/<id>.mode.json) exists and is notbypassPermissions, the
session is listed by id or name, no readable settings layer sets
defaultModebypassPermissions, and no launch flag of the owning Claude
process points to bypass (node/launch-mode.mts). Anything it cannot read
refuses with the auditpermission-mode-unknown. -
Control Plane:
kherep-node attach <node>/<session>resolves a session
through the directory likemsg sendand prints the command to open it on
its host (issue #81):claude attachandclaude logswith the 8-character
short id for a Claude Code background session, a note for an interactive
one, and the thread title withcodex resume <thread id>for Codex. An
optional operator-writtenattach.jsonin the node'scontrol-plane
directory maps a node to an SSH target, which prefixes each command with
ssh -t <target>; a session on this node gets no prefix. The command only
prints; it runs nothing, opens no connection, and no session content
travels through the Worker. -
Control Plane:
msg sessionsshows the Codex thread title the Codex app
shows next to the session name, quoted, on the listing node and on peer
nodes (issue #88). The daemon reads it fromsession_index.jsonlin the
Codex home with a bounded, fail-soft read and publishes it as the optional
session fieldtitle; the Worker stores it in a new nullable column.
Titles are display only and never an address. Titles appear in the
directory only after the Worker is deployed; older nodes and Workers
ignore the field. -
Control Plane: the daemon wakes an idle interactive Codex session (TUI or
app) for peer messages withcodex queue --thread <id> --message <pointer>,
a fixed text with the message count only; the woken turn's delivery hook
offers the messages. The Claude wake's guards apply (kill switch, wake
allowlist, nobypassPermissions, reply depth, shared budget), one queue per
message, no second queue while one is unconfirmed, and never a flag that
changes the sandbox or approvals. The Codex delivery hook records the
session's permission mode; a session without one is woken only when the
allowlist names it by its full id.codex queueruns on its own lane and is
killed with its process tree after 30 seconds. Codex session names now use
the random tail of the thread id (codex-<last 8>); the old prefix name is
accepted only where it is unambiguous, and wake authorization uses the full
id only. The Codex Stop continuation is budgeted and never runs in
bypassPermissions. -
Control Plane Codex tasks:
POST /api/taskswithrequirements.runtime
codexgoes to a node that listscodexas a CLI runtime, and that node
runscodex exec --jsondetached, with stdin from the null device, only
when itssessions.runtimeslistscodex(the default staysclaude).
Permission modes map to Codex sandboxes (autoandacceptEditsto
workspace-write,defaulttoread-only); the bypass flags are never
passed. The task's session id is thethread_id; the node reports
started, thendonewith the last message as summary orfailed,
continues withcodex exec resume, and stops the process group by pid
after checking its start time. Claude and Codex tasks share the limits.
The Worker accepts runtimecodexonce redeployed. Codex task sessions are
listed in the node's session snapshot from the task records (thread id,
task name, runtimecodex). A peer message for an ended Codex task resumes
it with the message framed as the delivery hook frames it, under the Claude
wake's guards (kill switch, allowlist or task grant, reply depth, budget),
one run per task, and is confirmed when that run completes. The process can
write the node's outbox (its only extra writable root) and gets
KHEREP_CONFIG_DIRandKHEREP_SESSION_ID, somsg sendworks from the
sandbox; the msg CLI honoursKHEREP_SESSION_IDwhen
CLAUDE_CODE_SESSION_IDis not set. The prompt goes to Codex on stdin, never as a
process argument. A Codex task whose process cannot be identified after a
daemon restart is reported failed (process identity unknown). -
Control Plane tasks: the operator creates a task with
POST /api/tasks
(behind Access), and the Worker dispatchessession.startto an online node
that advertisessessions.v1and matches the runtime, os and capabilities,
the one with the fewest active tasks (409 with the reason when none fits).
The node runsclaude --bg --name task-<id> --permission-mode <mode>in a
working directory inside its workspace roots, maps the session id from
claude agents --json --all, reports state changes withtask.report, and
stops a session after its max runtime.POST /api/tasks/{id}/stopand
/continuestop or resume it. Off unless the node'spolicy.jsonenables a
sessionssection; per node at most 3 running task sessions, 10 starts per
rolling day and 120 minutes per run, permission modeautoby default,
neverbypassPermissions, Claude first (Codex: see the entry above). Sessions report
withkherep-node task done,msg sendtags messages with the task, and the
wake listener wakes a task's session for messages of its task beyond the
allowlist, within the budget. A session may request a task withtask new
only on the operator's directive and only when both nodes opt in
(sessions.delegate.requestand.accept); a session started for a task
cannot request one. The audit records task actions, the requesting session
and the directive, never the task text. -
Control Plane: an idle Claude Code session wakes when a peer message
arrives, if the node'spolicy.jsonopts in with awakesection that
lists the session (by id or name, or an explicit"*"); without it nothing
is woken. A listener (modules/control-plane/node/wake-hook.mts) runs with
asyncRewakeafter the delivery hook onUserPromptSubmitandStop, one
per session, and takes its re-arm deadline from the--timeoutits entry
carries. Wakes andStopcontinuations share a budget of 6 per rolling
hour, 20 per rolling day and 30 s spacing per session; a session in
bypassPermissionsis neither woken nor continued. It does not wake at reply
depth 6 or deeper, wakes once for an offer left by a turn withoutStop,
ends when its Claude Code process is gone, re-arms itself before its timeout,
and can be switched off with awake.disabledfile in the node directory;
every decision is logged towake.jsonlwithout message text. A prompt typed
while a turn is still running no longer re-offers that turn's messages; they
are re-offered afterStopFailure(now wired) or after 10 minutes.msg sendaddresses sessions by id, policy rules match the id or the session's
current name, and the peer framing follows the new peer-coordination rule. -
Control Plane, Phase 1 (
modules/control-plane/). A Cloudflare Worker
kherep-controlwith two SQLite-backed Durable Objects:NodeSession
holds each node's hibernatable WebSocket, the Ed25519 challenge handshake,
a seq/ack pending-command log resent after reconnect and alarm-based
offline detection (5-minute interval, offline after 3 missed intervals);
Registrykeeps nodes, runtimes, sessions, one-time enrollment codes and
an audit trail./node/*is gated only by the signed challenge;/api/*
requires a Cloudflare Access JWT and fails closed without the team domain
and AUD. Onlynode.status,runtime.listandsession.listcan be
dispatched. Thekherep-nodeCLI and daemon (node onboard|status|unenroll,
daemon) generate and keep the node key locally, discoverclaude,
codexand loopback LM Studio/Ollama, reconnect with jittered backoff up to
60 s and enforce a local allowlist. The committedwrangler.jsoncholds
placeholders only; deployment uses an operator-local override config.
npm run test:control-planeruns the node tests; the Worker has its own
package and test suite. -
get --id <page> --body-only [--format storage|adf]on both Confluence
brokers prints only the page body to stdout, without metadata and without
an added newline, so> fileyields exactly the body. One request with
the v2body-formatparameter; an unmapped or empty--format,--format
without--body-only, and an answer without the requested representation
fail on stderr with a non-zero exit. The brokers still write no file. Plain
getis unchanged. -
A per-repository opt-out from the work-item key. The
commit-msghook skips
the key check when the repository-local Git config
kherep.workItemRequiredis a Git boolean false (false,no,off,
0). A non-emptyKHEREP_WORK_ITEM_REQUIREDat commit time still wins,
then the repository value, then the policy file. Global and system values
do not opt out; an absent, unreadable or invalid value keeps the rule. AI
attribution trailers are still rejected in an opted-out repository. -
The Claude installer wires the control-plane delivery hook
(modules/control-plane/node/deliver-hook.mts) into theUserPromptSubmit
andStophooks of the usersettings.json. It runs from the Kherep
checkout through the new__KHEREP_REPO__placeholder, rendered with
forward slashes and quoted like the other hook paths;drift-check.sh
renders the same path andcapture.shmaps it back. Without an enrolled
node the hook finds no inbox and exits 0 without output. -
Control Plane Codex sessions (issue #31, step 4). The delivery hook takes
--runtime codexand then serves CodexSessionStart,UserPromptSubmit
andStop: it records each session incodex-sessions/, tells the session
its id at start, offers messages as developer context with a--fromreply
command, and atStopcontinues the turn with a fixed text, never peer
content, when new messages wait. The daemon lists Codex sessions seen within
12 hours,msg send --from <codex id>sends as the session's name, and the
Codex installer adds the three hooks to its managed block. The hooks must be
trusted in Codex before they run.
Changed
- Control Plane: Claude delivery progress builds the task request index once
per observation instead of once per session; behaviour is unchanged
(issue #266). - Claude hooks: the six blocking guards move from JavaScript to TypeScript
(.mts, ESM, Node type stripping), with their tests and
portable-scope-hooks.test:commit-guard,deploy-guard,
dispatch-contract-guard,playwright-file-guard,privacy-boundary-guard
andsecret-output-guard. Behaviour is unchanged; every existing guard test
passes with the same count.claude/hooksholds no.jsfile any more and
legacy-javascript.txtis empty. The settings template wires the.mts
files, andretired.txtlists every old.js, so an install parks them in
hooks/_deprecatedand unwires every command that still runs one, including
the legacy groups hosts carry under their own matchers. The Codex installer
copies and wires the.mtsnames ofcommit-guard,deploy-guardand
playwright-file-guard, removes their old.jscopies (kept in the install
backup) and recognises the block an earlier installer wrote with the.js
names, so an upgrade replaces it instead of refusing. New tests run each of
the six guards through its wired Claude command, and the three shared ones
through their wired Codex command after an install, show that a 0-byte or
missing guard fails those checks, and prove that each template matcher covers
every tool a legacy matcher covered. Closes issue #237. - Claude hooks: nine hooks move from JavaScript to TypeScript (
.mts, ESM,
run by Node's type stripping like the other.mtshooks), with their tests
andlib/semver-compare:clq-accept-gate,critical-file-integrity,
drift-check-nudge,live-hook-integrity,maestro-banner-gate,
maestro-discipline,orchestra-default,runtime-capability-snapshotand
smoke-test-nudge. Behaviour is unchanged. The settings template wires the
.mtsfiles; an install over an existing installation parks the old.js
files inhooks/_deprecatedthrough the install transaction
(retired.txt), and the rendered settings keep only the.mtswiring. New
tests run the two converted hooks that block,clq-accept-gateand
maestro-banner-gate, through their wired settings command, and cover
critical-file-integrity, which had no test. Part of issue #237; the
remaining guards follow in a second batch. - Control Plane: a message that a new Claude intercom session carries in its
task text isdeliveredonce the watch round sees that session's turn
progress, no longer whenclaude --bgreturns; until then it stays
acceptedwithfallback-running(issue #197). - BREAKING Control Plane:
msg send,msg send --new,msg inbox --from
andmsg sessions --fromno longer take--fromas typed (issue #200).
--frommust name the session ofCLAUDE_CODE_SESSION_IDor
KHEREP_SESSION_IDby id or name, or be the full id of a Codex session a
Kherep hook recorded within the last 12 hours, with no conflicting
KHEREP_SESSION_ID. Refused withis not a verified sender, and nothing is
written: an arbitrary name such as--from ops, a shortcodex-<8>alias, an
id no hook recorded, and a record older than 12 hours. Call it correctly by
omitting--fromin Claude Code sessions and node-started Codex runs, or, in
Codex, by passing the full session id theSessionStarthook context names
(msg send --from <session_id>). A Claude Code variable that a Codex process
inherited does not block its hook-recorded id. Missing-session errors now
name both variables. - Control Plane:
msg sessionsand themsg send --newresult mark
node-started background tasks and name the commands for their status and
transcript (issue #198);msg statuslists threaded replies (issue #200);
newmsg stopstops one task through owner task control and reports
stoppedonly for a confirmed process-tree exit of the measured run
(issue #199). - The Claude user settings now set
attributionwith an emptycommit, an
emptyprandsessionUrl: false, so Claude Code no longer instructs a
Co-Authored-Bytrailer in commit messages or an attribution line in pull
request descriptions. The object form is used because the shorthand
attribution: falseneeds Claude Code 2.1.281 or later, and older versions
discard the whole settings file. The existing commit guards are unchanged.
Reinstall to apply. enginesinpackage.jsonis now^22.18.0 || >=23.6.0. Node 23.0 to 23.5
cannot run the TypeScript sources without a flag; unflagged type stripping
arrived in Node 23.6.0.- The retirement manifest
bootstrap/manifest/retired.txtcan now name
workspace files with theproject/prefix that the installer and
drift-check already use. Such an entry lists the SHA-256 of every version
the installer placed there, hashed with CRLF folded to LF
(project/<path> sha256:<hex>[,<hex>...]), and an entry without hashes is
rejected when the manifest is read. The installer parks the file in a
_deprecated/sibling inside the workspace only while its content matches
one of those hashes, with its previous version in the installation backup
underretired/project/, and a rollback puts it back. Other content is
kept in place and reported as
retire: KEEP <entry> (content not placed by the installer), without a
backup or journal entry, and the installation continues (#45). An entry that
is absent on the host is skipped. A_deprecated/directory the pass
creates is journalled, and a rollback removes it again while it is empty.
drift-check reports a declared retired path that is still present, in the
Claude home or in the workspace, asRETIRED-LIVE. The line is information:
it does not change a PASS or the exit code, and the session-start drift
nudge does not count it (#45). WithDRIFT_SCOPE=projectonly the workspace
entries are checked. - The Codex installer retires workspace files through the same
bootstrap/manifest/retired.txtand the same rules as the Claude installer
(#44). It reads theproject/entries, ignores the Claude-home entries,
refuses the whole manifest before anything moves when an entry is invalid,
and parks a file in a_deprecated/sibling only while its content matches
one of the listed hashes; other content is kept and reported as
retire: KEEP <entry> (content not placed by the installer). A parked file
has its previous version in the installation backup underworkspace/, an
occupied destination gets a dated suffix, and a rollback puts the file back
and removes a_deprecated/it created while that is empty. Retirement now
runs on every Codex install, also withoutKHEREP_INSTALL_ATLASSIAN_TOOLS=1.
The installer no longer deletes the oldtools/*.mjsJira brokers from its
own hardcoded list: six of them are now declared inretired.txtwith the
hashes of the versions the installer placed, so both installers park them.
tools/jira-config.mjshas no known placed version and is no longer
retired automatically. - The Claude installer writes the system-wide
core.hooksPath, which binds
every account on the host, only withKHEREP_INSTALL_SYSTEM_HOOKSPATH=1.
Without it the installer reads the value and, when it differs from Kherep's
hook directory or is unset, prints both values and changes nothing, even when
the system file is writable without elevation, as it can be with Git for
Windows. With it the installer reports the value it replaced, and a failed
write fails the install. The global and repository-local bindings are
unchanged. - Atlassian brokers: all four brokers parse arguments strictly from a per-verb
flag table in the new shared moduleatlassian-cli-args.mts, which installs
with the default Confluence set. A positional argument, an unknown flag, a
flag without a value, a repeated flag or a missing required flag is refused
before any configuration, credential or network access, and the message
names the verb's full syntax and, for a positional, the call it probably
meant (Did you mean: get --id 275907063). Before, the Claude Jira and both
Confluence brokers skipped positionals and unknown flags silently and kept
the last of a repeated flag.help,--helpand-hlist every verb with
its flags and exit 0; the Codex Jira broker returns the list as JSON.
selftestwith arguments is now an error, a caller-supplied--versionon
Confluenceupdateis refused instead of ignored, and a refusedsearch
call staysstatus: unavailablewith exit 2.stitch --dry-run --id <id>
keeps the id instead of reading--idas the value of--dry-run, and the
routing documents namestitch --space <key> --id <id>(issue #299).
Removed
- The MPAC tools (
modules/mpac-tools/, Atlassian Marketplace vendor
reporting). They are not part of Kherep's purpose. The installer no longer
places<workspace>/tools/mpac/and drift-check no longer compares it;
KHEREP_INSTALL_ATLASSIAN_TOOLSnow gates only the Jira helpers. On hosts
that installed them earlier, an upgrade parks<workspace>/tools/mpac/mpac.ps1
andREADME.mdin<workspace>/tools/mpac/_deprecated/through the
retirement manifest.
Fixed
-
Claude hooks:
live-hook-integrityand
bootstrap/wired-blocking-guards.test.mtsno longer usenode --checkfor
.mtsfiles.node --checknever type-strips a.mts: it compiles the raw
source as CommonJS with module detection, exits 0 for any file that contains
importorexport, soexport const x = ;passed as healthy, and rejects
valid type annotations in a file without them, so a healthy hook such as
const x: number = 1;was reported as broken and overwritten (measured on
Node 26.10.0; the same code is in 22.18.0 and 24.1.0 by source). The new
hooks/lib/hook-syntax.mtsruns the two parsers Node runs at load time,
in-process and without executing the hook:module.stripTypeScriptTypes,
then a V8 module parse of the stripped source through adata:import whose
link is made to fail, so top-level code never runs; about 1 ms per file
instead of a child process. A report line for such a file reads "rejected by
Node's parser". The.jspath (vm.Script, confirmed bynode --check) is
unchanged. Only the stripper's own ExperimentalWarning is filtered in the
hook's process; every other warning, Node's type-stripping warning included,
still prints. Known limits: link-time SyntaxErrors such as
import { nope } from "node:fs"are not detected, asnode --checknever
detected them; a Node build withoutmodule.stripTypeScriptTypesgives
UNGEPRUEFT, never OK; and the new library joins the integrity hook's own
imports, which it cannot heal for itself (issue #279). Tests cover the ESM
error shapes, TypeScript-only and V8-only errors, valid typed code without
imports, non-execution, BOM and CRLF, the warning filter and the
command-line entrynode hooks/lib/hook-syntax.mts <file>.
bootstrap/smoke-test.shfollows separately (issue #278). -
Claude hooks:
live-hook-integritynow also measures the files a wired hook
imports, the transitive closure of its relative static imports under
hooks/(includinghooks/lib), classifies each as missing, 0 bytes or
rejected bynode --check, and restores it from the checkout with the same
SHA-256 proof as a wired file.node --checkdoes not resolve imports, so a
hook whose library was missing or empty passed it and then failed at import
with exit 1, which Claude Code treats as non-blocking: a blocking guard
failed open without notice. A report line names the importing hooks, and the
journal entry recordskindandimportedBy. A restore now refuses to write
through a symbolic link or into a directory that resolves outside the hooks
directory. The inventory lives in the newhooks/lib/hook-inventory.mts,
which the installer manifest lists, and
bootstrap/hook-require-resolution.test.mtsnow fails when a file of that
closure is missing from the manifest. New tests cover a missing, empty and
syntax-broken library, a transitive and a sibling import, type-only imports,
comments, imports outsidehooks/, symlinks and the report-only case
without a checkout. Known limits: an ESM-shaped syntax error in a.mts
file, such asexport const x = ;, is not detected, becausenode --check
exits 0 for it on Node 26.10 (follow-up issue #278); the syntax-broken test
uses a shape thatnode --checkrejects. The integrity hook cannot heal its
own three library imports (lib/workspace-scope.mts,
lib/orchestra-checkout.mts,lib/hook-inventory.mts): if one of them is
broken the hook itself fails at import, and onlydrift-checkand the smoke
test cover them (issue #273). -
commit-guard.mtsno longer backtracks exponentially while it looks for
git ... commit(issue #271, CodeQLjs/redosalerts #16 and #17). Its
regex read-Cboth as a flag and as a flag with a value, and--longboth
as--+longand-+-long;gitfollowed by 26-C --pairs took
2.3 s, and the time about doubled with each further pair, so a hook timeout let the call
through. The newhooks/lib/git-commit-match.mtssplits the command into
tokens once and tracks the reachable tokens in one linear pass. It accepts
exactly the strings the regex accepted: a seeded differential test of
20,000 short token sequences finds no difference, and the guard's other
checks are unchanged. The installer manifest lists the new module. -
Control Plane: on Windows the wake hook's kept run mode now hits (issue
#248). Claude Code starts each hook through Git Bash, so the key
run-modes/<session_id>.<parent pid>.jsonwas new for every hook: each
hook paid the full process listing (348 to 362 ms, measured 2026-10-06) and
wrote an entry nobody read. Aclaude -ptook a median of 8.1 s with the
hooks against 5.6 s without them; this removes the listing from that
difference after the first hook. The entry is now keyed onCLAUDE_PID,
Claude Code's own pid, which survives the shell and which a nested
claude -psets anew, when it is a plain pid above 1; otherwise the parent
pid stays the key, and when that parent is a shell nothing is written or
pruned. Only a positive signal is headless, and an entry still answers only
for what a full check of the same Claude Code process found, with the same
entrypoint and within one hour. -
Control Plane: an interactive Codex TUI whose rollout starts like a Desktop
chat (Codex 0.160) is woken withcodex queueagain instead of waiting with
awaiting-user-turn(issue #268). Newcodex-daemon.mtsasks the shared
app-server daemon forthread/loaded/listover its control socket, a
WebSocket over a unix socket, with a 2 s timeout and a 10 s cache. Only a
thread the daemon lists that also has the marker
<codex home>/tui-thread-reference-capabilities/<id>takes the TUI queue
path, audited astui-reachable; any probe failure, a missing marker, a
thread missing from the list, and Windows, where the probe does not
connect, keep the Desktop behaviour.wake.codexAppskips a reachable TUI,
which needs its full id inwake.sessions. -
Codex hooks:
commit-guardanddeploy-guardnow block under Codex on
Windows (issue #258). Codex runs the Windows hook form under pwsh, which
reports the guard's exit 2 as 1, and Codex treats that as a failed,
non-blocking hook, so the tool call went ahead.codex-hook-adapter.mtsnow
answers a guard's exit 2 in the PreToolUse phases with the documented JSON
deny on stdout (hookSpecificOutput.permissionDecision: "deny", the guard's
stderr aspermissionDecisionReason, or a fixed reason naming the guard when
stderr is empty) and exits 0, on every platform. In a multi-command payload
the first block answers alone and nothing after it runs. A guard's own JSON
decision, a silent exit 0, any other exit code and the PostToolUse phase
behave as before. The Claude Code wiring, which runs the guards directly, is
unchanged. -
Bootstrap:
install.shwith aCLAUDE_HOMEother than<HOME>/.claude
no longer changes global or systemcore.hooksPathand no longer runs the
credential step, which read the file named by an inherited
KHEREP_ATL_CRED_FILE_CLAUDE(issue #256). A candidate run that forgot one
skip switch had pointed the account's Git hooks at a temporary directory. A
non-default home that is the real Claude home opts in per step with
KHEREP_INSTALL_ALLOW_GITCONFIG=1andKHEREP_INSTALL_ALLOW_ATL_CREDENTIAL=1.
The newKHEREP_INSTALL_PREVIEW=1implies the Git configuration, runtime
agent, credential and knowledge-space skips for any home, and the documented
preview uses it. Each skipped step names its reason. The default-home install
is unchanged. -
Bootstrap: an upgrade now unwires every
settings.jsonhook command that
runs a script listed inbootstrap/manifest/retired.txt(issue #252). A host
could carry a second, legacy wiring such asnode ~/.claude/hooks/<name>.js
in groups of its own; the installer parked the file but left the command,
which then failed withCannot find moduleon every event. The settings
render removes such a command from any event and group, whatever the spelling
of the Claude home (the absolute path in drive or Git Bash form, quoted or
not, and~/.claude,$HOME/.claudeor${HOME}/.claudewhile
CLAUDE_HOMEis the default one; onlynode <script>commands are
recognised), drops a group left
without hooks, and prints oneretire: unwire <event> <command>line per
removal. Commands for scripts that are not retired, and commands that only
name a retired file as an argument, stay. The new settings are written inside
the install transaction, so a rollback restores the previous file.
drift-check.shnow reportsDANGLING-HOOK <event> <command>as drift for a
wired command whose Claude-home script is missing or retired, under every
DRIFT_SCOPE. -
Claude hooks:
clq-accept-gate.mtsis wired underStopinstead of
PreToolUseBash. It reads the Stop payload, and the only Stop wiring on
existing hosts was the legacyclq-accept-gate.jsentry that the upgrade
above unwires. The settings render now also removes an entry the installer
wrote for a managed hook (the hook under the absolute Claude home) at an event
the template no longer wires that hook at, and prints
hooks: unwire <event> <command> (managed under <events>). Hand-written
~/.claudeentries and hooks the template does not manage stay. -
Control Plane:
kherep-node msg stop <messageId>for a message delivered
into an existing session no longer ends at the barenot stopped: fresh status does not identify a stoppable run (denied, source_not_found)(issue
#241). Such a delivery starts no task, so the Worker holds no grant for the
message. When the directory lists the message's target session under the
task name of exactly one task this node requested on that node,msg stop
stops that task through the existing fresh status and run-bound stop and says
so; otherwise it names the target<node>/<session>and the message's
progress and says the session matches no task the sender owns. No Worker or
protocol change. -
Control Plane: the wake hook keeps its run mode per Claude Code process
(issue #245), inrun-modes/<session_id>.<parent pid>.jsonof the node
directory for at most one hour and only for the same
CLAUDE_CODE_ENTRYPOINT, so only the first hook of a process lists
processes; read or write errors fall back to the full check. On macOS a kept
decision took 0.02 to 0.03 ms instead of a median of 38 to 43 ms. Where a
shell stays between Claude Code and the hook (Git Bash on Windows), every
hook still checks in full. On macOS and Linux aclaude "daemon foo" -p
run, or aclaude -pstarted by a session whose prompt begins with
daemonbut notdaemon run, is no longer taken for the--bgdaemon: its subcommands count
only without-por--printamong the process's own options, and
daemononly asdaemon run. Aclaude -p --resumeof an idle interactive
session stays headless; the interactive session listens again from its next
own prompt. -
Control Plane: a delegated task start the target refuses is no longer lost
silently (issue #240). A Git Bashmsg send <mac> --new codex --cwd /Users/...storedC:/Program Files/Git/Users/...; the Mac refused the
start during admission without a log line or task record, and the sender
sawtask_unknownunder adispatchedthat never changed. Now the node
logs every refused start (task <taskId> refused: <reason>) and every
failed command result with its task id and reason, never the prompt. A start
refused before a task record existed leaves a bounded record in
task-refusals/(at most 256, 7 days) that only owner task control reads,
sotask statusanswerstaskStatefailedwithprocessStateclosed.
A requesting node with owner task control advertises
sessions.own-task-control.report.v1, and the Worker adds the state and
reason of the target's lasttask.reportasreportedStateand
reportedReason; older nodes and Workers never see them.task showsays
thatdispatchedmeans queued by the Worker, not acknowledged by the target,
and shows the last status answer.msg send <node> --new ... --cwd <dir>
refuses, before writing anything, a directory rewritten by Git Bash path
conversion and, when the target's path style is known from its sessions in
the directory, a Windows drive path for a POSIX node or the reverse; the
error points to PowerShell orMSYS_NO_PATHCONV=1. The reported reason
reaches the requester only after the Worker is deployed. -
Control Plane: a headless
claude -prun no longer hangs until the wake
hook's 24-hour timeout (issue #235). On Windows with Claude Code 2.1.289 the
run waited for the wake listener, its only child process, and sent no API
request. The listener still arms as before, so the arming order of a
session's listeners is unchanged, but before its first poll it decides the
run mode from one process listing. For a headless run it removes its own
lock and scope (and the mode file this arming created), auditsheadless
and exits 0. A process of the--bgmachinery (--bg-pty-host, or the
daemon,bg-pty-hostandbg-sparesubcommands on macOS) or its child,
which includes the daemon's task and intercom sessions, is interactive; otherwise
a run is headless whenCLAUDE_CODE_ENTRYPOINTissdk-clior when the
first Claude Code process above the hook has the option-por--print.
Windows command lines are split with their quoting, so a-pin a quoted
prompt does not count. Anything undecided keeps listening. Task sessions no
longer inherit the daemon'sCLAUDE_CODE_ENTRYPOINTandCLAUDECODE. On
macOS and Linux, wherepsloses the quoting, a session outside the--bg
machinery whose prompt holds a separate-pis taken for headless. -
Control Plane:
msg statusno longer calls a policy refusal of
closed-session delivery a failed delivery session (issue #230). A message
for a closed session whose working directory lay outside the node's
workspace roots readthe local delivery session failed; delivery is not confirmed [failed/fallback-failed], because the node guessed the progress
code from the refusal text and fell back tofallback-failed. Each refusal
now names its code: the working-directory checks,delegate.acceptand an
operator sender wait withwake-not-authorized,maxConcurrentand
maxStartsPerDaybefore an attempt withretry-pending, and the start
checks a new intercom session needs (its runtime and permission mode) are
made before the attempt with their own codes.fallback-failedremains for
a start or resume that failed, including a new start the policy refuses
after a failed resume, since that message is not tried again; its reason
names the policy (new start refused: ...). The
wake-not-authorizedtext now readsthe target node's policy did not authorize automatic delivery. No new progress code: older Workers accept
every frame. -
Control Plane: a message for an ended Codex task whose working directory
the node's policy refuses (not absolute, missing, or outside the workspace
roots) no longer reads as a failed wake (issue #239). The exchange round's
resume reported that refusal asfailed/wake-failed; it now waits with
waiting/wake-not-authorized, the code closed-session delivery uses for the
same check since issue #230, and the next round tries again.wake-failed
remains for a resume that did not start. No new progress code: older
Workers accept every frame. -
Control Plane: a message for an ended Codex task no longer stays
acceptedwithout a progress code while the node's policy has sessions not
enabled orcodexmissing fromsessions.runtimes(issue #244). The
exchange round now labels itwaiting/wake-disabledand auditsdisabled
once per message, as under the kill switch; nothing starts. A refused
working directory keepswaiting/wake-not-authorized, audits the new
actioncwd-refused, and logsnot resuming task <taskId> for messages: <reason>once per message instead of on every 2-second round. No new
progress code: older Workers accept every frame. -
Control Plane: a Codex run whose
codex execprocess dies abruptly no
longer leaves its shell commands running (issue #233). On macOS a
sleep 901outlived a SIGKILLed Codex intercom run in its own process
group, re-parented to init; the run settledfailedandmsg stopfound no
running process. The watch round now records a live run's descendant
processes with their start identities in the task record (at most 32, a
start read only for a pid not recorded yet), and when the run settles
failedafter its root ended, or a stop finds the root already ended, it
ends each recorded descendant that still has its recorded start identity by
its own pid, SIGTERM and then SIGKILL, off the frame lane, and logs the pids.
A reused pid is never signalled; a run that settlesdonekeeps what it
started. -
Control Plane: a task stop no longer leaves a Codex shell command running
when it is outside the root's process group (issue #231). On macOS a
sleep 900started by Codex missed the SIGTERM to the root's group, Codex
exited, and the stop failed with "root process ended before SIGKILL while
captured descendants still run" without ever signalling the child, which
then ran on as an orphan. After each group signal, SIGTERM and then SIGKILL,
the stop now signals every captured descendant that still has its captured
start identity by its own pid (taskkill /PID <pid>,/Ffor SIGKILL, on
Windows, wheretaskkill /Tcannot reach a descendant whose root ended),
also when the root has already ended. A reused pid is never signalled, the
group is signalled only while the root is the captured process, and the
stop is still confirmed only after every captured identity ended; otherwise
it fails and names the ended or reused root and any failed SIGKILL send. A
failed SIGTERM send no longer ends the stop before SIGKILL. -
Control Plane: the daemon removes the locks of wake listeners whose process
is gone, at its start and then hourly, so doctor'sstalelistener count
falls (issue #225). It never deletes by path: it renames a lock to a
tombstone, deletes it only when its bytes still equal what it judged, and
otherwise links it back without overwriting; if a file took the place
meanwhile, the later-armed lock keeps it, by a clock-independent arming
order(one more than the lock and scope in place at least;startedAt
stays real time). A listener never yields
to an older one: it puts its lock back when it is gone and the scope file
names it or an older listener (the scope now recordsorder), and
writes it back over an older listener's lock. The scope file goes with its
lock only while it carries that lock's token; the session's mode, budget,
woken and queued files stay. Sweep failures are logged with their error codes,
and a lock or scope read that fails is never taken for a missing file. -
Control Plane: a task stop no longer leaves the process tree running when a
helper times out (issue #221). On awindows-latestrunner the Toolhelp32
process-tree query (powershell.exewith anAdd-Typecompile) hit its
10 s limit, the stop threw before any signal, and the deadline test waited
in vain for the tree to end. A query that timed out now runs once more.
When apsor PowerShell helper of the stop still times out, the stop
forces the tree of the root if the root still has its recorded start
identity (SIGKILL,taskkill /T /Fon Windows) and still fails with the
timeout, so the failure is logged and no unconfirmed stop is reported; a
later watch round settles the task as an ended run. Any other failure is
unchanged. -
Control Plane: on Windows a credential write or ACL check whose
powershell.exerun hits its 5 s limit runs once more instead of failing
withremote_mcp_credential_unreadable(issue #219). On loaded
windows-latestrunners the first start in a test exceeded the limit in
three runs on 2026-10-04, while the write, its check and one bridge check
took 0.9 to 3.3 s together in 14 passing runs. The writer removes the
timed-out attempt's temporary file first; any other failure and a second
timeout still fail closed. -
Control Plane: an outbox message the Worker did not answer is sent again on
the live connection after 30 seconds instead of waiting for a reconnect, and
inbox retention refuses a waiting message instead of deleting it unreported
(issue #195). Regression tests cover a lost answer, a repeated send and a
crash of either daemon in the middle of an exchange. -
Control Plane: operator Codex tasks (start and continue) no longer open
visible console windows on Windows and still survive a daemon restart
(issue #124). The daemon starts them detached through a small wrapper,
node/codex-windowless.mts, run with its own Node: codex runs as the
wrapper's attached child with all stdio piped, so Node starts it with
CREATE_NO_WINDOWand its shells and MCP servers share a console without
a window. The wrapper forwards the prompt, the events, stderr and codex's
exit code; the pid, start time andexit.jsonthe daemon records are the
wrapper's, andtaskkill /Ton it ends codex and its children. Intercom
runs and macOS and Linux are unchanged.conhost.exe --headlesswas
measured and rejected: codex's stdout arrives as terminal escape sequences,
the console host ends when its input closes and exits 0 whatever the exit
code of codex. -
Control Plane: finished Codex tasks settle on Windows (issue #121). The
start-time query exited 1 for a pid that had ended, so the watch took an
ended process for a failed read: the task stayedstarted, the messages
its run carried stayedoffered, andcould not stop task ...repeated in
the daemon log. The query now returns no output and exit 0 only for "no
such process" and still fails for any other error. A run whoseexit.json
the daemon recorded counts as ended without a start-time read, also past
its deadline, and is not signalled on stop; tasks already stuckstarted
with anexit.jsonsettle on the first watch round after the upgrade. -
Control Plane: daemon-started Codex intercom runs no longer open visible
console windows on Windows (part of issue #119). They start without
detachedthere and share the daemon's hidden console; operator Codex
tasks and other platforms stay detached. Such a run ends with the daemon,
so the messages of a new Codex intercom session now wait for itoffered
and count asdeliveredonly when its run completes the turn; a run a
daemon restart ended offers them again and the next exchange round resumes
the session for them. -
Control Plane: daemon-started Codex intercom runs no longer start the MCP
servers of the user's Codex config (issue #119,node/codex-mcp.mts). The
run still loads that config, so its hooks and guards keep working, but gets
one-c mcp_servers.<name>.enabled=falseper enabled server the config
defines, beforeexec(also forexec resume). The names come from
codex mcp list --json, run by the daemon without a shell; only bare key
names are used, and the overrides are checked once with
codex <overrides> mcp list --json, dropping each server codex rejects
(servers the desktop app or a plugin provides, which make codex fail at
startup when overridden). The result is cached per codex binary for
5 minutes. When the list cannot be determined the run starts as before and
the daemon log names the reason. Operator Codex tasks are unchanged.
Measured on Windows with Codex CLI 0.157.1: 10 of 11 enabled servers
disabled, local descendant processes of a probe run 22 before, 10 after,
with the app-providedcua_replthe only MCP server left. -
Adopting a resumed intercom session as a copy no longer leaves the previous
copy running (issue #111). The intercom task record notes the session it
held before the resume (retire); once the new copy is adopted, right after
the resume or in a later watch round, the node stops the previous one with
claude stop <short id>, but only whileclaude agents --json --alllists
it under that short id with the recorded session id andstatusidle. A
busyorwaitingone is tried again in each watch round, up to 30 rounds;
one that is not listed, holds another session id or has no live process is
left alone. Only sessions a node-started intercom record held are stopped,
never the closed session the messages were sent to.wake.jsonlrecords
each stop with the actionclosed-sessionand the outcomeretired-copy,
and a give-up withcopy-kept. -
A message for a closed session that its intercom session answered is no
longer refused astarget session not running(issue #111). The copy's
first turn could start before the node readdressed the message to it, so no
turn offered it; the session read it withmsg inbox, answered with
msg send --reply-to, and the record stayedaccepteduntil the copy
ended and the 60-minute sweep refused it. A reply now marks the waiting
message it answers asdelivered. The sweep also judges a message handed to
an intercom session (closedToset) by its currenttoSessionfrom the
time of the handover (closedAttempt), not from its arrival. -
A node-started Claude Code session whose wake listener armed before the node
recorded its session id is woken again (issue #109). This happens after a
resume that Claude Code continued as a copy under a new id. The node now
writes the task record beforeclaude --bgorclaude --resume --bgwith
mappingPendingSince, maps the session throughclaude agents --json
right after the run, and clears the field once the id is listed. A resumed
intercom session that became a copy is adopted: the same task record takes
the new id and its waiting messages are readdressed to it, instead of
stopping the copy and starting a new intercom session. A wake listener
without grant or allowlist entry keeps polling while an active task record
in its working directory has had a pending mapping for at most 2 minutes,
and wakes once the node records its id. The grant still comes only from
that recorded id. Otherwise the listener exits at once with
not-allowlistedas before, audited once. -
msg send <node>/<full session id>reaches a closed session (issue #107).
A full session id (a UUID, as Claude Code and Codex use) on a node the
directory lists asonlineis now sent even when the directory no longer
lists that session, with the notesession not listed on <node>; the node decides whether it can deliveron stderr. The target node decides as for
any message: its accept rules, then delivery, the closed-session fallback
(messaging.resumeClosed), or the refusal after 60 minutes. Names, labels,
codex-<8>and titles still need a listed session, an unknown node or a
node that is not online still fails, andattachis unchanged. -
Windows installation with Claude Code installed through npm (issue #99): the
plugin reconciliation no longer fails withClaude marketplace list failed
when only the npmclaude/claude.cmdshims are onPATH. Without
KHEREP_CLAUDE_BINit runs the firstclaude.exeonPATHor the native
bin/claude.exenext to an npm shim, still without a shell, and fails naming
KHEREP_CLAUDE_BINwhen neither exists. The installer no longer warns that
the globalcore.hooksPathis being replaced when the old value names the
same directory in another Windows spelling, and the repository-local binding
skips repositories under_deprecated. -
Codex sessions send and reply over the Control Plane on the first try
(issue #72): the Codex installer makes the node outbox, and only the outbox,
a writable root of theworkspace-writesandbox
(sandbox_workspace_write.writable_roots). Without a table of the operator's
the managed block carries it; an operator table or dotted keys get the
outbox merged into theirwritable_roots, never a second table and never a
removed entry; an inline table ordefault_permissionsis left alone. The
receipt reports the outcome.node onboardand the daemon create the outbox,
because a sandboxed session can write into it but not create it. -
Control Plane Codex tasks run in a workspace root that is not a Git
repository:codex execandcodex exec resumeget--skip-git-repo-check
(the sandbox is unchanged). A failed run reports codex's last stderr line
after the exit code, with API keys redacted. On Windows acodex.cmdnpm
shim is no longer refused: the node runs the package's launcher
bin/codex.jswith its own Node and without a shell, and a stop ends the
whole process tree. A continue or message resume checks the task's working
directory against the workspace roots again, a thread id must be a plain id,
and failure reasons also redact bearer tokens, JWTs, URL user info and query
strings. Codex on Windows stays unmeasured: its sandbox must be checked before
a Windows node listscodex. -
Two Codex installer failures (#55). The installer parses only the standard
output ofcodex plugin marketplace list --json; the warning Codex prints on
standard error whenCODEX_HOMElies under a temporary directory made the
joined output invalid JSON and stopped the install withUnexpected Codex marketplace list schema. And afterbrew upgrade nodeon macOS, a managed
block whose hook commands name the removed versioned Node keg is recognised
again: the known managed fragments are also matched as rendered with each
Node path the live block names, still exactly, and anotifyentry with that
path is removed as before. Any other difference is still refused. New
renders name the Homebrew link<prefix>/bin/nodeinstead of the keg under
<prefix>/Cellar/node/<version>when the link resolves to the running
executable, so the next upgrade does not invalidate the block. An explicit
nodePathstill wins. Reinstall to apply. -
The session-start drift nudge lists drift-check findings labelled
MISSING-BLOCK,BLOCK-INVALIDandNORMALIZE-FAIL. It counted only
DRIFT,MISSING-REPO,MISSING-LIVEandEXTRA-LIVE, so a report whose
only problem carried one of the other labels ended inFOUND DRIFTbut
showed no findings at session start. -
bootstrap/install-transaction.test.shcan no longer change the host's Git
configuration. Whatever the caller's environment, it redirects the system
and global scopes to throwaway files, drops every inheritedKHEREP_*
variable, and fails when the host's system or globalcore.hooksPath
differs after the run. -
The Claude hooks
cbm-code-discovery-gate,cbm-session-reminderand
cbm-subagent-reminderare executable again. They were tracked without the
executable bit since 0.1.0, and because the settings invoke them directly,
every installed host failed them withpermission deniedand the
code-discovery gate and both reminders never ran. Reinstall to apply. -
The smoke test's project-drift assertion follows the managed-block rule from
0.1.1: operator text outside the Kherep block in the workspaceCLAUDE.md
must not be reported as drift, and a change inside the block must be. -
The CLIs
bootstrap/confluence-space.mts,modules/twg/install.mts,
modules/twg/runtime/cli.mtsandmodules/control-plane/node/cli.mtsrun
again when their path contains a symlink, such as the default macOS temporary
directory under/var, which links to/private/var. They compared
import.meta.urlwith the unresolved script argument, while Node loads the
main module from its real path, so through a symlink they did nothing and
exited 0. They now compare against the real path of the script argument.
This does not rely onimport.meta.main, which the Node 23 and early Node 24
releases admitted by the engines range do not provide. The local-inference
keepalive and runner tests now resolve their fixture directories to their
real paths, and the runner test still removes its fixture afterwards. The
local-inference check that rejects a symlinked output root is unchanged. -
The remaining entry points and hooks no longer rely on
import.meta.main
to detect a direct start. Node 23.6 and later 23 releases and Node 24.0 to
24.1 run the TypeScript sources but lack it, although the engines range
admitted them, so there eight Claude hooks, ten Codex hooks, including the
privacy-boundary and dispatch-contract guards, and the bootstrap, Codex
installer, plugin-snapshot, broker, local-inference and MCP-wrapper CLIs did
nothing and exited 0. Node 23.0 to 23.5 failed loudly instead, because they
cannot load the sources without a flag. Every entry point, the four CLIs
above included, now comparesimport.meta.urlwith its script argument both
as given and resolved to its real path, so it also runs under
--preserve-symlinks-main, which keeps the symlinked path. A test fails when
a tracked source file readsimport.meta.mainagain, compares against the
script argument in one form only, or defines the check without both forms,
and CI runs the unit, Codex, module, broker and Control Plane node suites on
Node 22.18.0 and 24.1.0. -
Codex installer: a managed block that the Codex app split while storing hook
trust is installed again instead of refused (issue #274). On a Windows host
the app had written its[hooks.state]trust tables inside the block, moved
the managed tail (the Control Plane deliver hooks and
[mcp_servers.kherep_messaging]) verbatim behind the end marker, and left
one trust table'senabled = falseafter the marker. The TOML was unchanged,
but no known fragment matched, so the installer refused and a shared-guard
fix could not reach Codex. The installer now puts the tail back into the
block and the trust tables, with their keys, behind the end marker, then runs
the unchanged exact match. It does so only when the block without its trust
tables is the exact start of a known fragment and the exact rest follows the
marker with nothing but trust tables in between, and moves a key after the
marker only together with a trust table that ends the block. Anything else,
such as a changed managed line, an unknown table, unknown content in the
block or CRLF line endings, is still refused. The hooks arrays and the trust
state are kept, so no hook needs to be trusted again. -
Bootstrap: on Windows
smoke-test.shlooked hung in
install-transaction.test.shand was killed after six minutes, which also
withheld every result it had buffered (issue #276). The sub-test was not
hung: under Git Bash it takes about 12 minutes and printed nothing after
test_lock, and the Windows process list showed its bash idle without
children because MSYS fork and exec do not keep Windows parent links. The
transaction test now names each test on stderr as it starts, and
smoke-test.shruns it undertimeoutwith a 30-minute bound, set with
SMOKE_SUBTEST_TIMEOUT, so a real hang becomes a finding and the report is
still printed. Without GNUtimeoutorgtimeout, as on a stock Mac, it
runs unbounded as before. -
Observation agent: on Windows
claude-obsleft its page bodies in the
calling session's working directory as untracked files named like
C<U+F03A>Users...finding1_body.txt(issue #280). The agent had no rule for
creating its--body-fileand wrote to the backslash scratchpad path from
Bash, where the backslashes vanished and MSYS mapped the drive colon to
U+F03A, so the path became a relative file name. The agent now creates the
file withf="$(mktemp)", passes--body-file "$f", removes it after the
broker call and never uses a Windows backslash path in Bash; the contract
test pins that.codex-obswrites no files and is unchanged. The
observation Stop hook also reads the top level of the working directory and
its git root and, when names starting withCand U+F03A are there, shows
their count and one shortened example as a warning. It never moves or
deletes them. -
Bootstrap:
smoke-test.shno longer usesnode --checkfor the wired
hooks'HOOK SYNTAXandLIVE HOOK SYNTAXchecks, so a.mtshook with an
ESM syntax error such asexport const x = ;is a finding instead of a pass
(issue #278). Ahook_syntax()helper callshooks/lib/hook-syntax.mts: the
repo copy for the repo source, and for the live hook the copy installed next
to it, without falling back to the repo copy, so a missing installed library
is a finding too. Only a proven OK passes. UNGEPRUEFT, a missing checker and
any other exit are findings markedUNCHECKED, never a silent pass; such a
finding names the firstErrorline of a failing checker, and a trailing CR
on the checker's output is ignored..js
hooks keep their check through the library's dispatch.
hooks/lib/hook-syntax.mtsis now in the smoke test's list of installed
files. The comments incommit-guard,playwright-file-guardand
secret-output-guardno longer claim that theirnode:processimport marks
the file as ESM fornode --check; nothing needs that import any more. -
Claude hooks: the observation Stop hook's stray-file warning now matches any
drive letter followed by U+F03A, not onlyC. A body written from Bash to a
D:\...path, where a workspace can sit, became aD+U+F03A file in the
working directory and was not reported. A name that only contains U+F03A
further in is still ignored. -
Claude hooks:
live-hook-integrityno longer writes a restore through a link
planted after its check (issue #279). The restore checked the target with
lstatand then wrote withcopyFileSync, which follows a link at the
destination on every platform. The newhooks/lib/restore-write.mtsopens
the file withoutO_TRUNC, withO_NOFOLLOWwhere the platform has it, and
writes only when the opened file has the(dev, ino)the path had; on Windows
a volume without file ids is refused. A swapped-in file is left
byte-identical. A linked<CLAUDE_HOME>/hooksis still restored at its
target on purpose, and a test pins that; a link at the file or at a
subdirectory that leads elsewhere is still refused. The hook now loads its
own libs with dynamicimport()and checks the functions it uses, so a
missing, 0-byte or broken lib no longer makes it exit 1 unreported: it
reports one line, journals it as kindself, exits 0 and restores the lib
whenworkspace-scope,orchestra-checkoutandrestore-writeloaded.
hooks/lib/hook-inventory.mtsand the install manifest test also follow a
string-literalimport("./x.mts"), nottypeof import(), and the inventory
scan ignores a leading byte order mark.hooks/lib/restore-write.mtsis in
the install manifest, the required hooks and the smoke test's list of
installed files. -
Claude hooks:
hooks/lib/hook-syntax.mtsfollow-ups from the review of PR
#283. The stripper's ExperimentalWarning is now dropped by swapping
process.emitWarningonly for the duration of the
module.stripTypeScriptTypescall instead of re-registering the process's
warning listeners, which had made aoncelistener fire more than once and
still delivered the warning to listeners attached later; every other
warning, Node's own type-stripping warning included, still prints, and
quietStripWarning()stays exported as a no-op for its callers. For.js
and.cjs, anode --checkchild that did not run or did not finish
(ENOENT, ETIMEDOUT after 15 s, a signal, exit 9 for a badNODE_OPTIONS, a
SyntaxErrorfrom a preload rather than the checked file) is UNGEPRUEFT
"vm.Script rejected it and node --check could not confirm (...)" instead of
DEFEKT; only a child that exited non-zero on its own and printed a
SyntaxErrorfor the checked file is a rejection. The probe import that
keeps a checked module from linking now carries an import attribute that
Node's load step rejects, so a--import/--requirecustomization hook that
resolves the probe specifier is stopped by Node's load step before anything
is instantiated (UNGEPRUEFT, never OK); before, the checked file's top-level
code ran first. Residual risk: a hook that also overrides the import
attributes, short-circuitsload, or returns a format whose attributes Node
does not validate (module-typescript,commonjs-typescript) can still
bypass the probe and run the checked file; the verdict then stays
UNGEPRUEFT, never OK. Such a hook owns the process and is outside this check.
Measured on Node 26.10.0; same source paths in 22.18.0 and 24.1.0. -
Research Stop hooks: Claude and Codex now recognise the same opt-out marker.
Codex accepted only an ASCII hyphen afternoneand rejected the marker
with an en dash (U+2013) or em dash (U+2014), which models write in
practice, so such a turn was sent back once for research it had declared
irrelevant; Claude accepted anything afternone, including
[research: none]without a reason, although ROUTING.md and both hook texts
require one. Both runtimes now use one pattern: a hyphen, en dash or em dash
followed by a nonempty one-line reason without[inside the brackets,
matched anywhere in the final assistant text of the ending turn. Excluding
[keeps a line of unterminated markers linear: 8000 of them took 1.8 s
with a reason that could span them and under a millisecond now. A bare[research: none]in
Claude now gets one continuation instead of opting out. The documented form
stays[research: none - <reason>]. A shared contract test runs the same
cases against both patterns and fails when they drift (issue #293). -
Codex hooks: a hook-integrity check now runs in every Codex session
(#275). Before, only the Claudelive-hook-integrityhook checked hook
files, and only when a Claude session started, so a missing or 0-byte shared
guard under<CODEX_HOME>/hooksfailed open on a day with only Codex
sessions.codex/hooks/hook-integrity.mtsis installed as
hooks/kherep-maestro/codex-hook-integrity.mtsand appended as the last
entry of the MaestroSessionStartgroup (timeout = 30), after the
optional native hook, so every existing entry keeps its positional trust
key. It checks every.mts/.jshook file thatconfig.tomlwires under
<CODEX_HOME>/hooks, their import closure and the libs the privacy guard
loads through a computedrequire. A broken file is restored from the
checkout named by the deliver-hook command, else from the Claude-side
checkout chain, and counts as restored only when a re-read matches the
checkout's SHA-256. The two installer-rendered observation hooks are
reported only, and without a checkout nothing is written. Findings go to
<CODEX_HOME>/.cache/hook-integrity/incidents.jsonl; the hook is silent
when everything is OK and always exits 0. A failing own lib is reported as
kindself. The installer recognizes the previous managed block and
upgrades it; Codex then asks to review exactly the one new hook. -
Claude hooks:
hooks/lib/hook-syntax.mtsno longer lets aNODE_OPTIONS
preload decide thenode --checkconfirmation of a.jsor.cjsfile
(issue #292). A preload that printed one stderr line turned a real
rejection into UNGEPRUEFT, and a preload callingprocess.exit(0)made a
broken file OK, a false OK. The check child now runs without
NODE_OPTIONS(matched case-insensitively on Windows), so no preload can
print, exit early or forge a rejection; this is the policy the.mtspath
has had since #284, only Node's default loader is trusted. An operator's
--no-experimental-detect-moduleis therefore not applied either. The
rejection is read from the block Node prints (<path>:<line>, source line,
caret, blank line,SyntaxError: ...): aSyntaxErrorline counts only
when a line within the four above it names the checked file, wherever the
block sits in stderr, so lines printed before it (NODE_DEBUG, a loader
warning) no longer hide a rejection. Thenode --checktests move to
hooks/lib/hook-syntax-check.test.mts. The ExperimentalWarning control in
hook-syntax.test.mtsrecords whether this Node still warns for
module.stripTypeScriptTypes(MEASURED |) and asserts the filter only
while it does. Measured on Node 26.10.0; Node 22.18.0, 24.1.0 and the
Windows path format are read from Node's source. -
Claude hooks:
hooks/lib/hook-syntax.mtsnow reports a.jsor.cjsfile
DEFEKT whennode --checkrejected it under another name (issue #298).
Node prints the realpath of a file or directory symbolic link and honours a
//# sourceURL=comment, so a broken hook reached through a renamed link,
or one carrying such a comment, was UNGEPRUEFT instead of DEFEKT (never OK).
Since #292 the check child runs withoutNODE_OPTIONSand parses only the
checked file, so the rejection block (<location>:<line>, source line,
caret, blank line,SyntaxError: ...) now counts whatever its location
names, as long as it is not anode:internal frame and the child exited
non-zero on its own; Node's own failures (a missing file, an invalid
package.json) stay UNGEPRUEFT. When the name differs from the file's, the
reason says so. Measured on Node 26.10.0 on macOS and, for a directory
junction, on Windows 11; Windows file links are read from Node's source.