Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
01a9d3f
feat(search): tell every fresh MISS it can be published back, and tra…
vraspar Aug 9, 2026
4834230
feat(hooks): ship a fail-open WebSearch hook and a local open-loop re…
vraspar Aug 9, 2026
8c2c1fc
feat(install): make a headless install produce a machine that works
vraspar Aug 9, 2026
d67f694
docs(skills,readme): one-line search gate, a delegation rule, and eve…
vraspar Aug 9, 2026
5fcc627
feat(hooks): put the WebSearch hook's searches in the CLI's own store
vraspar Aug 9, 2026
70a4c10
feat(install): create the wallet by default, and add --no-hooks
vraspar Aug 9, 2026
40e137b
docs(readme): the new defaults, the hook toggles, and the wallet policy
vraspar Aug 9, 2026
1b92138
test(hooks): guard the mirrored lock protocol against drift on either…
vraspar Aug 9, 2026
9e9a0b6
fix(test): make install's wallet tests hermetic instead of mutating p…
vraspar Aug 9, 2026
df71953
feat(install): write the CLAUDE.md search nudge by default
vraspar Aug 9, 2026
aea6d07
Merge origin/main into vraspar/adoption-loop
vraspar Aug 9, 2026
16d5ffa
chore(cli): hide the pre-default compat flags from install --help
vraspar Aug 10, 2026
8cb7332
feat(install): settle publish.mode headlessly, and treat a cancelled …
vraspar Aug 10, 2026
2574d1a
fix(hooks): frame the hint title as data, stage script writes behind …
vraspar Aug 10, 2026
8e20511
docs: drop the outcome hold-back and state the two flag families
vraspar Aug 10, 2026
f18fb1e
fix(hooks): a double quote in a title cannot step outside the hint's …
vraspar Aug 10, 2026
aabbe72
fix(hooks): validate the search response fail-closed before storing o…
vraspar Aug 10, 2026
d49b869
fix(hooks): compare settings.json adjacent to the commit, not only be…
vraspar Aug 10, 2026
fcd6377
docs: align the install and hook guarantees with what ships
vraspar Aug 10, 2026
f1476cc
fix(hooks): the response parser drops a malformed field, it never rep…
vraspar Aug 10, 2026
4fa6641
docs: finish the truth sweep the round-3 pass left half done
vraspar Aug 10, 2026
09de09e
fix(outcome): --last targets the last deliberate search, not a hook r…
vraspar Aug 10, 2026
fd74346
fix(hooks): a genuinely free candidate is advertised as free, not paid
vraspar Aug 10, 2026
1ae7ed4
fix(hooks): require searchId to be a string before the uuid regex
vraspar Aug 10, 2026
a98083b
feat(install): add native Hermes retrieval hooks
A1igator Aug 10, 2026
c37c083
Merge origin/main into agent/hermes-native-integration
A1igator Aug 10, 2026
f9ad137
fix(hermes): gate hook writes on the hooks decision, repair the re-po…
A1igator Aug 10, 2026
6936304
fix(hermes): report a withheld write as skipped, not disabled
A1igator Aug 11, 2026
f6aa89f
fix(hermes): the surviving-plugin note offers inert, not removal
A1igator Aug 11, 2026
ff17ef6
Merge origin/main into agent/hermes-native-integration
A1igator Aug 12, 2026
632a9ad
Merge origin/main into agent/hermes-native-integration
A1igator Aug 12, 2026
d36181a
Merge origin/main into agent/hermes-native-integration
A1igator Aug 12, 2026
31101dd
Merge origin/main into agent/hermes-native-integration
A1igator Aug 14, 2026
c1f9b3e
Merge origin/main into agent/hermes-native-integration
A1igator Aug 16, 2026
d721972
Merge origin/main into agent/hermes-native-integration
A1igator Aug 16, 2026
ead7458
Merge origin/main into agent/hermes-native-integration
A1igator Aug 17, 2026
ea02721
Merge remote-tracking branch 'origin/main' into agent/hermes-native-i…
A1igator Aug 18, 2026
5275360
fix(hermes): report the whole shared hook bundle, not just websearch+…
A1igator Aug 18, 2026
3df9572
fix(hermes): forward session_id and cwd into the Stop hook payload
A1igator Aug 18, 2026
8b04bb3
Merge remote-tracking branch 'origin/main' into agent/hermes-native-i…
A1igator Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/native-hermes-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'tenjin-cli': minor
---

Add a working native Hermes Agent integration. `tenjin install --harness hermes`
now installs the Tenjin skills, adds a conservative MCP entry, and enables a
stdlib-only Hermes plugin that checks Tenjin before `web_search`, attaches a hit
to that tool's result, and surfaces unresolved searches through
`transform_llm_output` for publish-back.

Hermes reuses the same generated, bounded, fail-open retrieval/store/nag scripts
as Claude Code instead of carrying a second payment-facing implementation. The
installer honors an absolute `HERMES_HOME`, embeds absolute executable paths,
preserves unsupported or user-owned YAML byte-for-byte, never overrides
`plugins.disabled`, keeps automatic detection inert until explicit activation,
and adds a warn-level doctor check. It adds no `TENJIN_HARNESS` policy selector
and does not copy or couple wallet state.

Hook consent and plugin activation are two separate decisions. `--no-hooks` and
`--search-hooks off` withhold the Hermes scripts, plugin, and activation exactly
as they withhold Claude's `settings.json` entries; the `mcp_servers.tenjin` entry
is a server registration, so it is still written. Where the stored
`hooks.searchMode` is what holds the plugin back, install and `tenjin doctor` say
`tenjin config set hooks.searchMode auto` rather than an install command that
cannot move the blocker. Withholding a write is not an uninstall, so install
reports it as `skipped` and names any enabled plugin an earlier run left behind.

Re-pointing the MCP entry (an nvm switch, a pnpm-vs-npm global) rewrites the
managed block in place, leaving one marker comment and any neighbouring comments
untouched. `tenjin doctor` tolerates a relative `HERMES_HOME` set for some other
tool, reports a baked MCP command that no longer exists as stale rather than
green, and shares the installer's classifier so its fix cannot point into a
conflict it did not predict.
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Tenjin is meant for questions that are public, durable, and annoying to reproduc
This repository ships:

- `tenjin`, the CLI published as [`tenjin-cli`](https://www.npmjs.com/package/tenjin-cli)
- Agent Skills for Claude Code, Codex, and other Agent-Skills-compatible harnesses
- Agent Skills for Claude Code, Codex, Hermes Agent, and other Agent-Skills-compatible harnesses
- A local stdio MCP server backed by the same command core

No API key or Tenjin account is required. Your wallet is the credential, and the private key stays on your machine.
Expand Down Expand Up @@ -257,6 +257,13 @@ Cursor:
}
```

Hermes Agent: `tenjin install --harness hermes` writes the entry into
`~/.hermes/config.yaml` for you, alongside a native plugin that checks Tenjin
before `web_search` and raises unresolved searches at turn end. The plugin runs
the same scripts as Claude Code's hooks, so `--no-hooks` and
`hooks.searchMode off` withhold and disarm it the same way. Auto-detection
installs it inert; naming the harness is what enables it.

There is also a keyless remote MCP server:

```text
Expand Down
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,11 @@ export function buildProgram(io: Io, setExit: (code: number) => void): Command {

addGlobalFlags(program.command('install'))
.description(
'Detect installed harnesses (Claude Code, Codex), wire the Tenjin skills, then run the doctor checks last',
'Detect installed harnesses (Claude Code, Codex, Hermes), wire Tenjin, then run doctor last',
)
.option(
'--harness <name>',
'target a specific harness: claude | codex | shared (repeatable; overrides detection)',
'target a specific harness: claude | codex | hermes | shared (repeatable; overrides detection)',
collect,
[],
)
Expand Down
7 changes: 5 additions & 2 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../lib/harness-permissions';
import { modeGatedPointer } from '../lib/permissions';
import { stopHookIsCurrent } from '../lib/harness-hooks';
import { resolveHermesHomeLenient } from '../lib/hermes';
import {
CONFIG_KEYS,
HOOKS_CONFIG_KEYS,
Expand Down Expand Up @@ -403,11 +404,13 @@ async function claudeInPlay(
const requested = await loadRawConfig(ctx.dataDir)
.then((c) => c.install?.harness ?? [])
.catch(() => [] as HarnessTarget[]);
const hermesHome = resolveHermesHomeLenient(home, env).home;
return harnessInPlay(
home,
harnessTargetDir(home, 'claude'),
detectHarnesses(home, which),
harnessTargetDir(home, 'claude', hermesHome),
detectHarnesses(home, which, hermesHome),
requested,
hermesHome,
);
}

Expand Down
93 changes: 93 additions & 0 deletions src/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { saveSessionFile } from '../lib/session-key';
import { sessionPath } from '../lib/paths';
import { testSessionKey } from '../lib/read-test-utils';
import type { WalletProvider } from '../lib/wallet';
import { wireHermesIntegration } from '../lib/hermes';

// doctor loads viem's balance read lazily; the mock keeps every test off-chain.
vi.mock('../lib/usdc', () => ({ getUsdcBalance: vi.fn() }));
Expand Down Expand Up @@ -129,6 +130,98 @@ async function writeWallet(mode: number): Promise<void> {
}

describe('runDoctor — passing outcomes', () => {
it('reports a working native Hermes integration separately from portable skills', async () => {
await wireHermesIntegration({
// A path that EXISTS: doctor now stats the baked command, because an
// `npx`/`dlx` cache path can be pruned out from under a green check.
hermesHome: join(skillHome, '.hermes'),
dataDir: dir,
tenjinCommand: process.execPath,
nodeCommand: process.execPath,
dryRun: false,
explicit: true,
hooks: { enabled: true, mode: 'auto' },
});
const res = await runDoctor(ctxFor(), {
walletPassphrase: NO_OS_STORE,
homeDir: skillHome,
skillsSourceDir: pkgSrc,
env: {},
which: () => false,
fetchImpl: healthyFetch,
});
const checks = (res.data as { checks: CheckResult[] }).checks;
expect(find(checks, 'hermes')).toMatchObject({ status: 'ok', required: false });
expect(find(checks, 'hermes').detail).toContain('retrieval and publish-back');
});

// Doctor is the command you reach for when something is already broken, so the
// STRICT resolver must never run here: a stray relative HERMES_HOME belonging to
// some other tool would return CONFIG_INVALID and run zero checks on a machine
// with no Hermes at all.
it('a relative HERMES_HOME warns and falls back instead of aborting every check', async () => {
const res = await runDoctor(ctxFor(), {
walletPassphrase: NO_OS_STORE,
homeDir: skillHome,
skillsSourceDir: pkgSrc,
env: { HERMES_HOME: 'relative/hermes' },
which: () => false,
fetchImpl: healthyFetch,
});
const checks = (res.data as { checks: CheckResult[] }).checks;
expect(checks.length).toBeGreaterThan(1);
expect(find(checks, 'node').status).toBe('ok');
});

it('a baked MCP command that no longer exists warns instead of reading green', async () => {
const hermesHome = join(skillHome, '.hermes');
await wireHermesIntegration({
hermesHome,
dataDir: dir,
tenjinCommand: join(skillHome, 'pruned-npx-cache', 'tenjin'),
nodeCommand: process.execPath,
dryRun: false,
explicit: true,
hooks: { enabled: true, mode: 'auto' },
});
const res = await runDoctor(ctxFor(), {
walletPassphrase: NO_OS_STORE,
homeDir: skillHome,
skillsSourceDir: pkgSrc,
env: {},
which: () => false,
fetchImpl: healthyFetch,
});
const hermes = find((res.data as { checks: CheckResult[] }).checks, 'hermes');
expect(hermes.status).toBe('warn');
expect(hermes.detail).toContain('MCP command missing');
// One subject per problem: prefixing the activation with `plugin` too reads as
// one subject named twice.
expect(hermes.detail).not.toContain('plugin plugin');
});

// `tenjin install --harness hermes` alone is a dead end with the mode stored off:
// it re-runs, withholds the hook code by design, and prints the same warning
// forever. The `native-harness` fix string in this same PR already names the
// config command; doctor has to as well.
it('names the config command when the stored searchMode is what blocks the plugin', async () => {
await writeFile(
join(dir, 'config.json'),
JSON.stringify({ install: { harness: ['hermes'] }, hooks: { searchMode: 'off' } }),
);
const res = await runDoctor(ctxFor(), {
walletPassphrase: NO_OS_STORE,
homeDir: skillHome,
skillsSourceDir: pkgSrc,
env: {},
which: () => false,
fetchImpl: healthyFetch,
});
const hermes = find((res.data as { checks: CheckResult[] }).checks, 'hermes');
expect(hermes.status).toBe('warn');
expect(hermes.fix).toContain('tenjin config set hooks.searchMode auto');
});

it('all required checks green, no wallet: status pass with a warn wallet check', async () => {
const res = await runDoctor(ctxFor(), {
walletPassphrase: NO_OS_STORE,
Expand Down
Loading