Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 11 additions & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hwip",
"version": "0.1.1",
"version": "0.1.2",
"description": "AI agent notifications via Hwip — notify and ask across devices",
"author": {
"name": "hwipit",
Expand Down Expand Up @@ -93,6 +93,16 @@
}
],
"PostToolUse": [
{
"matcher": "^mcp__.*__hwip_ask$",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hwip-ask-result.js\"",
"timeout": 5
}
]
},
{
"hooks": [
{
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ Hwip is three cooperating layers. Hooks fire whether or not the model cooperates
```
hwipit/plugin (Claude Code plugin)
├─ hooks/hwip-setup.js → SessionStart: inject the behavioral rules
├─ hooks/hwip-ask-result.js → PostToolUse(hwip_ask): rules for handling the answer, once per context
├─ hooks/hwip-stop.sh → Stop: auto completion push
├─ hooks/hwip-ask.sh → PreToolUse(AskUserQuestion): question push
├─ hooks/hwip-approve.sh → PreToolUse(Bash): opt-in phone approval for hard-to-undo commands
Expand Down
7 changes: 7 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ REMOTE 에 들어가고, 마커 없는 프롬프트(터미널 입력)가 오면
└─ Push Signal은 push-mode 다이얼(quiet/normal/loud)에 맞춰 선택
```

규칙 7·8(`hwip_ask` 답 다루기)은 NORMAL 분기에 넣지 않는다. 대신 그 컨텍스트에서 처음 돌아온
`hwip_ask` 결과에 PostToolUse 훅(`hwip-ask-result.js`)이 한 번 붙인다. 120초를 넘겨 백그라운드로 옮겨진
호출도 옮겨졌다는 결과에 붙어서, 답보다 먼저 모델에 닿는다. 마크 `handling-injected-<session_id>`는
SessionStart 가 매번 지우고, REMOTE 전문을 실을 때 새로 쓴다(다음 시작까지 유효). 규칙을 넘긴 ask 훅도
마크를 쓰는데, 이 마크는 30분 뒤 무효가 된다 — /rewind 는 SessionStart 없이 규칙을 실은 결과를 지울 수 있다.
subagent 의 호출(`agent_id` 있음)은 마크를 읽지도 쓰지도 않는다.

**분기하는 이유:** Claude Code는 훅의 `additionalContext`가 10,000자를 넘으면
파일로 옮기고 모델에는 2,000자 프리뷰만 넣는다 (`cli.js` `k$d=1e4`, `hFr=2000`).
무조건 주입하던 단일 블록은 15,483 바이트여서 첫 2KB 아래 규칙은 모델에
Expand Down
4 changes: 2 additions & 2 deletions docs/CORE_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Hwip lets the user drive this session from their phone. You are talking to a use
### Push Signal — steer the end-of-turn auto-push (Stop hook)

<!-- hwip-branch: pushmode-quiet -->
This project's push dial is **quiet**, so the Stop hook's heuristic never fires and the `skip`/`push` markers change nothing — `<!-- hwip: high -->` (an HTML comment, invisible in the terminal, stripped from the push body) is the whole channel. Emit it once on a completion the user is genuinely waiting on, never on routine work; it is ignored on a turn that already sent `hwip_ask`. The hook also pushes on its own once the user has been away from the terminal for a while, so never emit `high` just to reach someone who left.
This project's push dial is **quiet**: no heuristic push, so `skip`/`push` do nothing; only `<!-- hwip: high -->` (HTML comment the hook strips) counts. Emit it once on a completion the user awaits, never on routine work; a `hwip_ask` turn ignores it. The hook pushes on its own once the user is away; never emit `high` to reach them.
<!-- /hwip-branch -->

<!-- hwip-branch: pushmode-normal-loud -->
Expand Down Expand Up @@ -168,5 +168,5 @@ REMOTE begins the moment the user sends a message from their phone; from that tu

---

**Last updated**: 2026-09-13
**Last updated**: 2026-10-25
**Synced across**: plugin, CLAUDE.md, SKILL.md, cli/templates.ts (generated via cli/scripts/sync-from-plugin.mjs)
43 changes: 42 additions & 1 deletion docs/HOOKS-EXPLAINED.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The plugin installs these hooks (`.claude-plugin/plugin.json`), which fire autom
| Hook | Event | File | Purpose |
|------|-------|------|---------|
| **SessionStart** | Session begins | hwip-setup.js | Inject behavioral rules into context |
| **PostToolUse** (Ask result) | After `hwip_ask` returns | hwip-ask-result.js | Hand rules 7·8 (handling the answer) over with the first `hwip_ask` result of a session context |
| **Stop** | Response ends | hwip-stop.sh | Send completion notification if work was done |
| **PreToolUse** (Ask) | Before AskUserQuestion | hwip-ask.sh | Send notification when Claude asks user a question |
| **PreToolUse** (Approve) | Before Bash | hwip-approve.sh | Opt-in: hold a hard-to-undo command until the user approves it from the phone |
Expand Down Expand Up @@ -63,7 +64,7 @@ T=$(ls -t ~/.claude/projects/*/*.jsonl | head -1)
| `/hwip-mode mute` marker for this project | three lines: hooks are silent, don't call the tools unless asked | ~220 B |
| No hook id (`HWIP_HOOK_ID` env or `hookId` in `~/.hwip/config.json`) | one-way notify discipline (`hwip_ask` does not exist) | ~1.3 KB |
| Sticky REMOTE live (`remote-active-<hash>`) | the sticky-REMOTE contract in full — Rules 1-3, 7-11, 13 (Rule 4 is subsumed by Rule 9 there) — and no Push Signal, since markers are ignored on a turn that already sent `hwip_ask` | ~7.4 KB |
| Otherwise | the NORMAL branch: notify discipline, Push Signal, and what starts REMOTE. No ask rules — every one of them is REMOTE-scoped, so a session at the terminal never blocks on a phone answer | ~3.1–3.6 KB |
| Otherwise | the NORMAL branch: notify discipline, Push Signal, and what starts REMOTE. No ask rules — every one of them is REMOTE-scoped, so a session at the terminal never blocks on a phone answer. Rules 7·8 (handling a `hwip_ask` answer) come with the first `hwip_ask` result instead ([Ask-Result Hook](#ask-result-hook-hwip-ask-resultjs)) | ~2.5–3.0 KB |

The Push Signal section follows the project's push-mode dial: on `quiet` (the
stock default) only the `high` marker does anything, so only that one is
Expand Down Expand Up @@ -112,6 +113,46 @@ echo $HWIP_API_KEY

---

## Ask-Result Hook (hwip-ask-result.js)

**What it does:** hands rules 7·8 ("Handling the response" in `CORE_RULES.md`)
to the model with the first `hwip_ask` result of a session context. Those rules
only govern what to do with a `hwip_ask` answer, so the NORMAL SessionStart
text leaves them out, and a session that never calls `hwip_ask` never pays for them.

**When it runs:** PostToolUse, matcher `^mcp__.*__hwip_ask$` (the plugin's
`mcp__plugin_hwip_hwip__hwip_ask` and a standalone `mcp__hwip__hwip_ask`).
Claude Code attaches the hook's `additionalContext` to the tool result. When
the call outlives 120 s and moves to the background, Claude Code attaches it to
the background-move result instead, so the rules land before the answer either way
(probed against Claude Code 2.1.310, interactive and `-p`).

**Once per session context.** The mark `handling-injected-<session_id>` sits
beside `remote-injected-<session_id>`:

- Every SessionStart (startup, resume, clear, compact) removes it.
- SessionStart writes it (content `session-start`) when it ships the REMOTE
contract, which carries rules 7·8 already. That mark holds until the next start.
- This hook writes it (content `ask`) after the rules went out. That mark holds
for 30 minutes. `/rewind` or a conversation restore can drop the result that
carried the rules, and no SessionStart fires to say so. The bound keeps such a
session at most half an hour from getting them again.
- A subagent's call (hook input with `agent_id`) neither reads nor writes it. The
rules land in the subagent's context, so the main context's next `hwip_ask`
still gets them.

It fails open. With no readable `session_id`, or a mark that cannot be written,
it injects every time. It writes the mark only after the output went out. With an
unreadable `CORE_RULES.md`, or no "Handling the response" heading, it injects
nothing, since there is no text to hand over. It leaves no mark and says why on
stderr, like SessionStart does.

The hook reads its input blocking (`tty.isatty`, not `process.stdin`) and whole,
because Claude Code always pipes and closes it. `hwip-setup.js` keeps its
non-blocking read, because `scripts/hwip-mode.sh` runs it with the caller's stdin.

---

## Stop Hook (hwip-stop.sh)

**What it does:**
Expand Down
79 changes: 79 additions & 0 deletions hooks/core-rules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use strict';

// CORE_RULES.md slicing, shared by the hooks that inject from it: SessionStart
// (hwip-setup.js) and the PostToolUse hook on hwip_ask (hwip-ask-result.js).
// One reader, so both hand the model the same text for the same heading.

const fs = require('fs');
const os = require('os');
const path = require('path');
const tty = require('tty');

const CORE_RULES_PATH = path.join(__dirname, '..', 'docs', 'CORE_RULES.md');

// Throws when the file cannot be read — callers decide what a missing file
// means for them, and can report the real cause.
const readCoreRules = () => fs.readFileSync(CORE_RULES_PATH, 'utf-8');

// One `###`/`####` section, heading included, up to the next heading of the
// same or a higher level. Missing heading → empty string: a rename in
// CORE_RULES.md must drop one section, not crash the session's first turn.
const section = (coreRules, heading) => {
const level = heading.match(/^#+/)[0].length;
const lines = coreRules.split('\n');
const start = lines.findIndex((l) => l.trim() === heading);
if (start === -1) return '';
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
const m = lines[i].match(/^(#+) /);
if (m && m[1].length <= level) { end = i; break; }
}
return lines.slice(start, end).join('\n').replace(/\n+---\s*$/, '').trim();
};

// Resolve the `<!-- hwip-branch: NAME -->` blocks a section carries: keep the
// one that matches this session's state, drop the rest and every marker.
// `<!-- hwip-doc-only -->` blocks explain the branching to a human reading
// CORE_RULES.md and are never injected.
const pickBranch = (text, keep) => text
.replace(/<!-- hwip-branch: ([\w-]+) -->\n([\s\S]*?)\n<!-- \/hwip-branch -->\n?/g,
(_, name, body) => (name === keep ? `${body}\n` : ''))
.replace(/<!-- hwip-doc-only -->\n[\s\S]*?\n<!-- \/hwip-doc-only -->\n?/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();

// Rules 7·8. NORMAL's SessionStart text leaves them out; hwip-ask-result.js
// hands them over with the first hwip_ask result of a session context.
const HANDLING_HEADING = '### Handling the response';

// Per-session marks live beside the rest of the hook state. A session id is
// used in a file name only when it is a plain token.
const STATE_DIR = path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), 'hwip');
const SAFE_SESSION_ID = /^[A-Za-z0-9_-]+$/;
const HANDLING_MARK_PREFIX = 'handling-injected-';
// What a handling mark holds says who wrote it: SessionStart when it shipped
// the REMOTE contract (holds until the next start), or the ask hook (ages out).
const HANDLING_MARK_BY_SESSION_START = 'session-start';

// The hook input on stdin, read whole and blocking — for hooks only Claude Code
// runs, which always pipes the input and closes it. An exit before the writer
// finishes hands it a SIGPIPE (tests/test-stdin-drain.sh). Unreadable or not
// JSON → {}. tty.isatty, not process.stdin.isTTY: touching process.stdin turns
// a piped fd 0 non-blocking, and readFileSync(0) then fails with EAGAIN.
// hwip-setup.js keeps its own non-blocking read: scripts/hwip-mode.sh runs it
// with the caller's stdin, which may never close.
const readHookInput = () => {
try {
if (tty.isatty(0)) return {};
return JSON.parse(fs.readFileSync(0, 'utf-8')) || {};
} catch { return {}; }
};

// A session id usable in a mark file name, else ''.
const sessionIdOf = (input) =>
(typeof input.session_id === 'string' && SAFE_SESSION_ID.test(input.session_id) ? input.session_id : '');

module.exports = {
readCoreRules, section, pickBranch, HANDLING_HEADING,
STATE_DIR, SAFE_SESSION_ID, HANDLING_MARK_PREFIX, HANDLING_MARK_BY_SESSION_START, readHookInput, sessionIdOf,
};
74 changes: 74 additions & 0 deletions hooks/hwip-ask-result.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env node
'use strict';

// PostToolUse hook on hwip_ask — hands rules 7·8 ("Handling the response") to
// the model with the first hwip_ask result of a session context.
//
// The rules govern only a hwip_ask answer, so NORMAL's SessionStart text leaves
// them out (hwip-setup.js `normal()`) and this hook supplies them when they
// first matter. Claude Code attaches the hook's additionalContext to the tool
// result, and when the call outlives 120s and moves to the background, to the
// background-move result — so the rules land before the answer either way.
//
// Once per session context, by the mark `handling-injected-<sid>`:
// - hwip-setup.js drops it on every SessionStart (startup, resume, clear,
// compact), and writes it when it ships the REMOTE contract, which carries
// these rules already; that mark holds until the next start
// - this hook writes it after the rules went out; that mark holds for
// ASK_MARK_TTL_MS only, since /rewind or a restore can drop the result that
// carried the rules without any SessionStart to say so
// - a subagent's call (input carries agent_id) neither reads nor writes it:
// the rules land in the subagent's context, not the main one
// Everything else fails open — a duplicate costs a few hundred chars, a missing
// rule changes what the model does with the user's answer.

const fs = require('fs');
const path = require('path');
const {
readCoreRules, section, HANDLING_HEADING,
STATE_DIR, HANDLING_MARK_PREFIX, HANDLING_MARK_BY_SESSION_START, readHookInput, sessionIdOf,
} = require('./core-rules');

const ASK_MARK_TTL_MS = 30 * 60 * 1000;

const input = readHookInput();

let rules = '';
try {
rules = section(readCoreRules(), HANDLING_HEADING);
if (!rules) process.stderr.write(`[hwip-ask-result] no "${HANDLING_HEADING}" section in docs/CORE_RULES.md — nothing to hand over\n`);
} catch (err) {
process.stderr.write(`[hwip-ask-result] Could not read docs/CORE_RULES.md: ${err.message}\n`);
}
if (!rules) process.exit(0);

const inSubagent = typeof input.agent_id === 'string' && input.agent_id !== '';
const sessionId = inSubagent ? '' : sessionIdOf(input);
const mark = sessionId ? path.join(STATE_DIR, HANDLING_MARK_PREFIX + sessionId) : '';

// Held: SessionStart shipped the rules, or this hook did within the TTL.
const markHolds = () => {
try {
if (fs.readFileSync(mark, 'utf-8') === HANDLING_MARK_BY_SESSION_START) return true;
return Date.now() - fs.statSync(mark).mtimeMs <= ASK_MARK_TTL_MS;
} catch { return false; }
};
if (mark && markHolds()) process.exit(0);

// The mark goes down only once the rules are out: a failed write leaves none,
// and the next result hands them over again.
try {
fs.writeSync(1, JSON.stringify({
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: rules },
}));
} catch (err) {
process.stderr.write(`[hwip-ask-result] could not write the hook output: ${err.message}\n`);
process.exit(0);
}

if (mark) {
try {
fs.mkdirSync(STATE_DIR, { recursive: true });
fs.writeFileSync(mark, 'ask');
} catch { /* no mark: the next result hands the rules over again */ }
}
Loading
Loading