claude-spotter 1.4.18 → 1.4.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.ja.md +12 -11
- package/README.md +12 -11
- package/package.json +1 -1
- package/src/cli/codex-hook-cmd.mjs +54 -96
- package/src/core/hook-event-log.mjs +3 -3
- package/src/hooks/lib.mjs +3 -65
- package/src/hooks/parent-output-projector.mjs +80 -0
- package/src/hooks/pending-context.mjs +11 -49
- package/src/hooks/stop.mjs +15 -75
- package/src/hooks/user-prompt.mjs +40 -44
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.19
|
|
4
|
+
|
|
5
|
+
親セッションの暴走を誘発できたHook出力の信頼境界を修正する。監査用AIは内部で構造化判定を返すだけとし、
|
|
6
|
+
その自由文やprovider出力を親モデルへ渡さない。親向け出力はSpotterプログラムが検証済みtool IDから
|
|
7
|
+
決定論的に生成する、採否を親が独立判断できる非命令形の助言へ限定する。
|
|
8
|
+
|
|
9
|
+
### 変更点
|
|
10
|
+
|
|
11
|
+
- **共通parent-output projector**: Claude / Codexの両Hookが同じprojectorを使う。tool IDは
|
|
12
|
+
ASCII grammar、160文字、5件、重複排除、安定sort、助言全体2,000文字の上限を持ち、改行・制御文字・
|
|
13
|
+
backtick・Markdown・超長大IDを拒否する。監査用AIの`reason` / `raw`はAPI入力に持たない。
|
|
14
|
+
- **非命令形のUserPrompt助言**: `additionalContext`はcatalog照合済みtool IDと固定テンプレートだけから
|
|
15
|
+
作る。「使え」「補正せよ」「ユーザーへ伝えよ」といった命令や監査用AIの説明文を含めない。
|
|
16
|
+
- **failure分離**: backend codeをallow-list済みの固定状態へ写像し、固定`systemMessage`・固定stderr・
|
|
17
|
+
構造Hook eventへ出す。backend message、provider stdout / stderr、未知code本文を反射しない。この保証は
|
|
18
|
+
Hook出力生成までで、全Codex App/background面でのUI可視性は未保証。
|
|
19
|
+
- **Stop持ち越し廃止**: finding / failureを`.spotter/pending/`へ新規保存せず、次の無関係な
|
|
20
|
+
UserPromptSubmitへ配送しない。findingは構造Hook event、failureは固定診断として当該Stopで完結する。
|
|
21
|
+
旧same-session pendingは内容を読まずにunlinkし、`ENOENT`以外の失敗も固定診断だけにする。
|
|
22
|
+
- **安全性維持**: `decision:"block"`、Stop継続、UserPromptSubmit exit 2の範囲拡大は行わず、既存の
|
|
23
|
+
再帰Hook / daemon proliferation防止、marker、short prompt、non-blocking failure契約を維持する。
|
|
24
|
+
|
|
25
|
+
### 検証
|
|
26
|
+
|
|
27
|
+
projector / Claude / Codexのtargeted testとfull suiteを実行し、447件中445 pass・0 fail・既存2 skip。
|
|
28
|
+
AI/backend/provider sentinel、unsafe ID、legacy pending非読取unlink、Stop次turn非配送、catalog/transcript
|
|
29
|
+
読込例外の固定degradationを確認した。敵対的再監査は初回BLOCKER 2件(Codex読込例外の境界外、
|
|
30
|
+
UI可視性の過剰主張)を検出・修正し、再監査BLOCKER 0。`npm pack --dry-run`は62 files、global
|
|
31
|
+
`spotter 1.4.19`へlocal installし、projector smoke・Hook diagnostics・global UserPromptSubmit smokeを
|
|
32
|
+
確認した。公開前release gateとしてfull test、pack、秘密混入、CI、registry tarballを再検証し、
|
|
33
|
+
`v1.4.19` tag・npm `latest`・GitHub Release・global installを同一versionへ揃える。
|
|
34
|
+
|
|
3
35
|
## 1.4.18
|
|
4
36
|
|
|
5
37
|
auditor model の更新を model 名の場当たり的な置換から切り離し、versioned policy と再現可能な比較 eval を
|
package/README.ja.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
Claude には「使えるツールがあるのに、使うべきタイミングで使わない」という構造的な弱点があります。記録すべき決定を memory / caveat MCP に残さない、docs lookup MCP を呼ばずに古い知識で応答する、ブラウザ自動化 MCP で確認せず UI 状態を推測する — **「分からないと自覚できない」から、ツールを取りに行けない**。
|
|
17
17
|
|
|
18
|
-
Spotter はツールカタログを完全に把握した別の監査エージェントで、ユーザー入力と主役 AI の応答を並走監査します。自動選択では Claude host は Codex CLI があればそれを、なければ session-scoped Haiku を選び、Codex host は Codex CLI を既定にします。明示 backend override は host より優先しますが、runtime failure で別 backend
|
|
18
|
+
Spotter はツールカタログを完全に把握した別の監査エージェントで、ユーザー入力と主役 AI の応答を並走監査します。自動選択では Claude host は Codex CLI があればそれを、なければ session-scoped Haiku を選び、Codex host は Codex CLI を既定にします。明示 backend override は host より優先しますが、runtime failure で別 backend へ黙って切り替えません。応答前は検証済みtool IDだけを固定・非命令形の助言へ変換でき、応答後のfindingは構造eventに留めて後続turnへ注入しません。監査用AIの自由文が親セッションへ入ることはありません。**主役 AI が自覚して自己監査する**設計は本プロダクトの存在意義を破壊するため、hook 経由でその意思と独立に検出します。
|
|
19
19
|
|
|
20
20
|
<p align="center">
|
|
21
21
|
<img src=".github/concept.svg" alt="Claude が答え、Spotter が見ている" width="80%">
|
|
@@ -62,6 +62,7 @@ Codex 側では現行の `[features].hooks = true` を有効化し、互換の
|
|
|
62
62
|
Spotter が所有する Codex handler は現行の同期 command schema で生成します。install / upgrade 後は `/hooks` で review して新しい Codex session を開いてください。`spotter codex-hook diagnostics` は登録と readiness を診断しますが、trust を内部状態から推測しません。
|
|
63
63
|
|
|
64
64
|
Spotter を upgrade した後、release note で hook 設定変更が案内されている場合は、各 install 済みプロジェクトで `spotter install` を再実行してください。global package update でコード経路は変わりますが、既存 `.claude/settings.json` の timeout 値は自動では書き換わりません。
|
|
65
|
+
`v1.4.19`はruntimeの出力変換だけを変更するため、install済みprojectで`spotter install`をやり直す必要はありません。global packageを更新し、新しいClaude/Codexセッションを開いてください。
|
|
65
66
|
|
|
66
67
|
```bash
|
|
67
68
|
spotter uninstall # このプロジェクトの hook 登録を解除
|
|
@@ -88,20 +89,19 @@ spotter codex-hook install
|
|
|
88
89
|
|
|
89
90
|
### 1 ターンの監査フロー
|
|
90
91
|
|
|
91
|
-
Claude Code と Codex
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
Claude Code と Codex は同じ安全なparent-output projectorを使います。監査用AIの自由文は内部に留め、
|
|
93
|
+
`UserPromptSubmit`では検証済みtool IDだけを固定・非命令形の助言へ変換します。`Stop` findingは
|
|
94
|
+
構造eventに記録し、後のturnへ注入しません。
|
|
94
95
|
|
|
95
96
|
```mermaid
|
|
96
97
|
flowchart TD
|
|
97
98
|
U([User 発話]) --> UPH[UserPromptSubmit hook<br/>Spotter が発話とカタログから一次判定]
|
|
98
|
-
UPH --> BT[
|
|
99
|
+
UPH --> BT[Host model<br/>検証済みtool IDの<br/>固定助言を受け取る場合がある]
|
|
99
100
|
BT --> BA([Claude の最初の応答])
|
|
100
101
|
BA --> SH[Stop hook<br/>応答と使用済みツールから最終チェック]
|
|
101
102
|
SH --> DEC{見落とし<br/>あり?}
|
|
102
103
|
DEC -->|なし| DONE([完了])
|
|
103
|
-
DEC -->|あり|
|
|
104
|
-
SB --> NEXT([次の UserPromptSubmit で<br/>additionalContext として配信])
|
|
104
|
+
DEC -->|あり| EVT[構造Hook eventへ記録<br/>次turnへは注入しない]
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
### カタログの収集経路
|
|
@@ -209,15 +209,16 @@ profile から production へ自動昇格しません。`latest` alias や
|
|
|
209
209
|
|
|
210
210
|
## 既知の制約
|
|
211
211
|
|
|
212
|
-
-
|
|
213
|
-
-
|
|
214
|
-
- **Haiku の JSON スキーマ違反は v0.5.0 以降「想定済み異常」として session renew + `role_collapse_reset` で回復**します。**v1.4.
|
|
212
|
+
- `Stop` hookは最初の応答がstream済みになった後で発火します。v1.4.19以降は応答の書換え・継続強制・次turnへの監査文配送をせず、findingを構造Hook eventへ記録します。pre-responseの`UserPromptSubmit`精度が引き続き主軸です
|
|
213
|
+
- `UserPromptSubmit.additionalContext`は受動的metadataではなくモデル可視contextです。v1.4.19以降はcatalog一致・grammar検証済みtool IDだけから決定論的に生成し、監査用AIのreason、backend message、provider stdout/stderrを反射しません
|
|
214
|
+
- **Haiku の JSON スキーマ違反は v0.5.0 以降「想定済み異常」として session renew + `role_collapse_reset` で回復**します。**v1.4.19以降、auditor/daemonの失敗はモデルcontextにせずnon-blockingを維持**します。allow-list済み固定`systemMessage`・固定stderr・構造Hook eventだけを出してexit 0にします
|
|
215
215
|
|
|
216
216
|
<details>
|
|
217
217
|
<summary><strong>📋 最近のハイライト</strong></summary>
|
|
218
218
|
|
|
219
|
+
- **親出力をルールベース化** (v1.4.19) — 監査用AIの自由文は親セッションへ入りません。検証済みtool IDだけがUserPromptSubmitの任意助言になり、Stop findingは無関係な次turnへ持ち越されません
|
|
219
220
|
- **daemon は異常死しても復活する** (v1.4.16) — daemon が graceful shutdown を経ず死んでも (マシンスリープ / 強制終了 / `SessionEnd` 前の crash)、残った Unix socket が以後の起動を塞がなくなった。`startDaemon` が bind 前に orphan socket を除去するので、次の `UserPromptSubmit` の auto-resurrect が `EADDRINUSE` で crash-loop せずに成功し、「そのセッションが永久に未監査」になる事態を防ぐ
|
|
220
|
-
- **失敗は声に出して縮退、host
|
|
221
|
+
- **失敗は声に出して縮退、hostを固めない** (v1.4.15) — この版でbackend failureによるpromptのsilent消去を止めた。v1.4.19以降もnon-blocking挙動は維持し、旧model可視警告文は固定`systemMessage`・stderr・構造event診断へ置換した
|
|
221
222
|
- **プラグイン形式の MCP サーバー対応** — `plugin:everything-claude-code:context7` のように名前に内部コロンを含むサーバーを正しくパースし、配下のツールをカタログに取り込めるようになった (旧版はこの形式のサーバーをすべて単一の `"plugin"` に潰して、Claude の監査から silent に脱落させていた)
|
|
222
223
|
- **プロジェクト単位の監査隔離** — daemon が監査に使うのはローカル DB のみ。グローバル DB は description 再利用キャッシュに役割限定。**他プロジェクト**でインストールしたツールが現プロジェクトの監査に混入することはない
|
|
223
224
|
- **手放しでカタログ維持** — `spotter install` が Claude DB を自動 seed、Claude / Codex それぞれの SessionStart が host-local DB を bg refresh する。手書き管理は一切不要
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
Claude has a structural blind spot: **it can't reach for a tool it doesn't realize it needs**. It may skip a project memory MCP when a decision should be recorded, answer from stale memory instead of a docs-lookup MCP, or reason about UI state without a browser-automation MCP. The model can't always tell when it doesn't know — so the tool stays unused.
|
|
17
17
|
|
|
18
|
-
Spotter runs a separate auditor with the full tool catalog and checks both the user's prompt and the primary agent's reply. Automatic selection uses Codex CLI on a Claude host when available, otherwise the session-scoped Haiku path; on a Codex host it defaults to Codex CLI. An explicit backend override takes precedence, but a runtime failure never silently switches backend.
|
|
18
|
+
Spotter runs a separate auditor with the full tool catalog and checks both the user's prompt and the primary agent's reply. Automatic selection uses Codex CLI on a Claude host when available, otherwise the session-scoped Haiku path; on a Codex host it defaults to Codex CLI. An explicit backend override takes precedence, but a runtime failure never silently switches backend. Before the primary reply, validated tool IDs may become fixed, non-directive advice; after the reply, findings remain structured events and are not injected into a later turn. Auditor prose never enters the parent session. **The primary agent is never asked to self-audit** — that would defeat the premise. Detection happens through hooks, independent of the primary agent's intent.
|
|
19
19
|
|
|
20
20
|
<p align="center">
|
|
21
21
|
<img src=".github/concept.svg" alt="Claude answers · Spotter watches" width="80%">
|
|
@@ -62,6 +62,7 @@ For Codex, install enables the current `[features].hooks = true` flag and still
|
|
|
62
62
|
Installer-owned Codex handlers use the current synchronous command schema. After install or upgrade, review them with `/hooks`, then open a fresh Codex session; `spotter codex-hook diagnostics` reports registration/readiness but does not guess hook trust.
|
|
63
63
|
|
|
64
64
|
After upgrading Spotter, re-run `spotter install` in each installed project when release notes mention hook setting changes. The global package update changes the code path, but existing `.claude/settings.json` timeout values are not rewritten automatically.
|
|
65
|
+
`v1.4.19` changes runtime output projection only, so already installed projects do not need another `spotter install`; update the global package and open a fresh Claude/Codex session.
|
|
65
66
|
|
|
66
67
|
```bash
|
|
67
68
|
spotter uninstall # remove hooks from this project
|
|
@@ -88,20 +89,19 @@ spotter codex-hook install
|
|
|
88
89
|
|
|
89
90
|
### Audit flow per turn
|
|
90
91
|
|
|
91
|
-
Claude Code and Codex
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
Claude Code and Codex share the same safe parent-output projector. Auditor prose stays
|
|
93
|
+
internal; only validated tool IDs can become fixed, non-imperative advice on
|
|
94
|
+
`UserPromptSubmit`. `Stop` records structured findings without injecting them into a later turn.
|
|
94
95
|
|
|
95
96
|
```mermaid
|
|
96
97
|
flowchart TD
|
|
97
98
|
U([User prompt]) --> UPH[UserPromptSubmit hook<br/>Spotter audits prompt against catalog]
|
|
98
|
-
UPH --> BT[
|
|
99
|
+
UPH --> BT[Host model<br/>may receive fixed advisory<br/>with validated tool IDs]
|
|
99
100
|
BT --> BA([Claude's first answer])
|
|
100
101
|
BA --> SH[Stop hook<br/>Spotter re-audits answer + tools used]
|
|
101
102
|
SH --> DEC{Missed<br/>tool?}
|
|
102
103
|
DEC -->|No| DONE([Done])
|
|
103
|
-
DEC -->|Yes|
|
|
104
|
-
SB --> NEXT([Surfaces as additionalContext<br/>on next UserPromptSubmit])
|
|
104
|
+
DEC -->|Yes| EVT[Record structured Hook event<br/>no next-turn injection]
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
### Catalog discovery
|
|
@@ -211,15 +211,16 @@ the production values for controlled experiments; diagnostics mark overrides as
|
|
|
211
211
|
|
|
212
212
|
## Known limitations
|
|
213
213
|
|
|
214
|
-
- The `Stop` hook fires **after** the first answer has already been streamed. Spotter
|
|
215
|
-
- `
|
|
216
|
-
- **Since v0.5.0, JSON schema violations from Haiku are treated as expected anomalies** (session renew + `role_collapse_reset`). **Since v1.4.
|
|
214
|
+
- The `Stop` hook fires **after** the first answer has already been streamed. Spotter records a structured finding but does not rewrite the answer, force a continuation, or inject auditor text into the next prompt. Detection accuracy in `UserPromptSubmit` (the *pre-response* stage) remains the primary quality axis
|
|
215
|
+
- `UserPromptSubmit.additionalContext` is model-visible context, not passive metadata. Since v1.4.19 it is generated only by a deterministic projector from catalog-matched, grammar-checked tool IDs. Auditor reasons, backend messages, and provider stdout/stderr are never reflected into it
|
|
216
|
+
- **Since v0.5.0, JSON schema violations from Haiku are treated as expected anomalies** (session renew + `role_collapse_reset`). **Since v1.4.19, an auditor/daemon failure remains non-blocking without becoming model context**: Claude and Codex emit only an allow-listed fixed `systemMessage`, fixed stderr, and a structured Hook event, then exit 0
|
|
217
217
|
|
|
218
218
|
<details>
|
|
219
219
|
<summary><strong>📋 Recent highlights</strong></summary>
|
|
220
220
|
|
|
221
|
+
- **Rule-based parent output boundary** (v1.4.19) — auditor AI prose cannot enter parent-session Hook output. Validated tool IDs become optional fixed advice on `UserPromptSubmit`; `Stop` findings stay structured and are never carried into an unrelated next turn
|
|
221
222
|
- **Daemon recovers after an ungraceful death** (v1.4.16) — if the daemon dies without graceful shutdown (machine sleep, force-quit, crash before `SessionEnd`), the Unix socket it leaves behind no longer bricks every restart. `startDaemon` removes the orphaned socket before binding, so the next `UserPromptSubmit` auto-resurrect succeeds instead of crash-looping on `EADDRINUSE` and leaving the session permanently unaudited
|
|
222
|
-
- **Failures degrade loudly, never freeze the host** (v1.4.15) —
|
|
223
|
+
- **Failures degrade loudly, never freeze the host** (v1.4.15) — this release stopped backend failure from silently erasing a prompt. Since v1.4.19, the non-blocking behavior remains but the old model-visible warning text is replaced by fixed `systemMessage`, stderr, and structured event diagnostics
|
|
223
224
|
- **Plugin-scoped MCP servers** — names like `plugin:everything-claude-code:context7` (with internal colons) are now parsed correctly and their tools enter the catalog. Earlier versions silently collapsed all plugin MCP servers into a single literal `"plugin"`, dropping their tools from Claude's audit
|
|
224
225
|
- **Per-project / per-host audit isolation** — the daemon audits against the local DB only; global DBs are host-specific description caches. Tools discovered in *other* projects or another host can never bleed into this project's audit set
|
|
225
226
|
- **Zero-touch catalog** — `spotter install` seeds the Claude DB automatically; Claude and Codex SessionStart hooks keep their host-local DBs fresh in the background. You never have to maintain the tool list by hand
|
package/package.json
CHANGED
|
@@ -7,22 +7,22 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { createAuditorBackend, selectAuditorBackend } from '../core/auditor-backend.mjs';
|
|
8
8
|
import { resolveCodexAuditorModelSelection } from '../core/codex-auditor-model-policy.mjs';
|
|
9
9
|
import { codexLastAssistantMessage, readCodexToolUsage } from '../core/codex-transcript.mjs';
|
|
10
|
-
import { legacyResultFromJudgment } from '../core/judgment.mjs';
|
|
11
10
|
import { readLocal } from '../tool-db/refresh.mjs';
|
|
12
11
|
import { spawnRefreshDetached } from '../hooks/spawn-daemon.mjs';
|
|
13
12
|
import {
|
|
14
13
|
die,
|
|
15
14
|
findSpotterMarker,
|
|
16
|
-
formatTransparentBlockReason,
|
|
17
|
-
formatTransparentContext,
|
|
18
15
|
isChildCall,
|
|
19
16
|
readStdinJson,
|
|
20
17
|
requireString,
|
|
21
18
|
} from '../hooks/lib.mjs';
|
|
19
|
+
import { discardLegacyPending } from '../hooks/pending-context.mjs';
|
|
22
20
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
STOP_FINDING_SYSTEM_MESSAGE,
|
|
22
|
+
projectBackendFailure,
|
|
23
|
+
projectParentAdvice,
|
|
24
|
+
projectToolIds,
|
|
25
|
+
} from '../hooks/parent-output-projector.mjs';
|
|
26
26
|
import {
|
|
27
27
|
appendHookEvent,
|
|
28
28
|
hookEventsPath,
|
|
@@ -113,6 +113,7 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
113
113
|
readLocalFn = readLocal,
|
|
114
114
|
createAuditorBackendFn = createAuditorBackend,
|
|
115
115
|
recordHookEventFn = appendCodexHookEvent,
|
|
116
|
+
discardLegacyPendingFn = discardLegacyPending,
|
|
116
117
|
writeOutput = (text) => process.stdout.write(text),
|
|
117
118
|
writeError = (text) => process.stderr.write(text),
|
|
118
119
|
} = {}) {
|
|
@@ -123,7 +124,7 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
123
124
|
const startedAt = Date.now();
|
|
124
125
|
|
|
125
126
|
const prompt = requireString(input, 'prompt');
|
|
126
|
-
const
|
|
127
|
+
const legacyPending = await discardLegacyPendingFn({ projectRoot, sessionId: codexSessionId(input) });
|
|
127
128
|
if ([...prompt.trim()].length <= SHORT_PROMPT_MAX_CHARS) {
|
|
128
129
|
await recordCodexHookEventSafe(recordHookEventFn, {
|
|
129
130
|
projectRoot,
|
|
@@ -131,35 +132,36 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
131
132
|
hook: 'UserPromptSubmit',
|
|
132
133
|
status: 'skipped',
|
|
133
134
|
reason: 'short_prompt',
|
|
134
|
-
|
|
135
|
+
legacyPendingDiagnostic: legacyPending.diagnostic,
|
|
135
136
|
durationMs: Date.now() - startedAt,
|
|
136
137
|
},
|
|
137
138
|
}, writeError);
|
|
138
|
-
writeCodexUserPromptContexts({ contexts, writeOutput });
|
|
139
139
|
return;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
let catalog;
|
|
143
143
|
let backend;
|
|
144
144
|
let judgment;
|
|
145
145
|
try {
|
|
146
|
+
catalog = await readLocalFn({ projectRoot, hostAgent: 'codex' });
|
|
146
147
|
backend = createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBackendFn });
|
|
147
148
|
judgment = await backend.judge({ stage: 'user_input', userInput: prompt });
|
|
148
149
|
} catch (err) {
|
|
149
|
-
|
|
150
|
+
const failure = projectBackendFailure(err?.code);
|
|
151
|
+
safeWriteError(writeError, failure.stderr);
|
|
150
152
|
await recordCodexHookEventSafe(recordHookEventFn, {
|
|
151
153
|
projectRoot,
|
|
152
154
|
event: {
|
|
153
155
|
hook: 'UserPromptSubmit',
|
|
154
156
|
status: 'error',
|
|
155
157
|
backend: err?.backend ?? null,
|
|
156
|
-
code:
|
|
158
|
+
code: failure.code,
|
|
157
159
|
...compactCodexModelSelectionForEvent(err?.diagnostics?.modelSelection),
|
|
158
|
-
|
|
160
|
+
legacyPendingDiagnostic: legacyPending.diagnostic,
|
|
159
161
|
durationMs: Date.now() - startedAt,
|
|
160
162
|
},
|
|
161
163
|
}, writeError);
|
|
162
|
-
|
|
164
|
+
writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
|
|
163
165
|
return;
|
|
164
166
|
}
|
|
165
167
|
await recordCodexHookEventSafe(recordHookEventFn, {
|
|
@@ -169,20 +171,20 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
169
171
|
status: 'success',
|
|
170
172
|
backend: judgment.meta?.backend ?? backend.name ?? 'unknown',
|
|
171
173
|
pass: judgment.pass,
|
|
172
|
-
missingTools: judgment.findings.map((finding) => finding.toolName),
|
|
174
|
+
missingTools: projectToolIds(judgment.findings.map((finding) => finding.toolName)),
|
|
173
175
|
...compactCodexModelSelectionForEvent(judgment.meta?.modelSelection),
|
|
174
|
-
|
|
176
|
+
legacyPendingDiagnostic: legacyPending.diagnostic,
|
|
175
177
|
backendDurationMs: judgment.meta?.durationMs ?? null,
|
|
176
178
|
durationMs: Date.now() - startedAt,
|
|
177
179
|
},
|
|
178
180
|
}, writeError);
|
|
179
181
|
if (judgment.pass === true) {
|
|
180
|
-
writeCodexUserPromptContexts({ contexts, writeOutput });
|
|
181
182
|
return;
|
|
182
183
|
}
|
|
183
184
|
|
|
184
|
-
|
|
185
|
-
|
|
185
|
+
const toolIds = projectToolIds(judgment.findings.map((finding) => finding.toolName));
|
|
186
|
+
const advice = projectParentAdvice(toolIds);
|
|
187
|
+
if (advice) writeCodexUserPromptContexts({ contexts: [advice], writeOutput });
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
export async function runCodexStopHook({
|
|
@@ -190,7 +192,6 @@ export async function runCodexStopHook({
|
|
|
190
192
|
readLocalFn = readLocal,
|
|
191
193
|
createAuditorBackendFn = createAuditorBackend,
|
|
192
194
|
readCodexToolUsageFn = readCodexToolUsage,
|
|
193
|
-
appendPendingContextFn = appendPendingContext,
|
|
194
195
|
recordHookEventFn = appendCodexHookEvent,
|
|
195
196
|
writeOutput = (text) => process.stdout.write(text),
|
|
196
197
|
writeError = (text) => process.stderr.write(text),
|
|
@@ -202,33 +203,28 @@ export async function runCodexStopHook({
|
|
|
202
203
|
if (!projectRoot) return;
|
|
203
204
|
const startedAt = Date.now();
|
|
204
205
|
const reportError = (text) => safeWriteError(writeError, text);
|
|
205
|
-
const persistPending = async ({ text, kind }) => {
|
|
206
|
-
let queued = false;
|
|
207
|
-
let pendingWriteError = null;
|
|
208
|
-
const currentProjectRoot = findSpotterMarker(input.cwd);
|
|
209
|
-
if (currentProjectRoot !== projectRoot) {
|
|
210
|
-
pendingWriteError = 'Spotter project marker no longer identifies the original project';
|
|
211
|
-
} else {
|
|
212
|
-
try {
|
|
213
|
-
queued = await appendPendingContextFn({
|
|
214
|
-
projectRoot,
|
|
215
|
-
sessionId: codexSessionId(input),
|
|
216
|
-
text,
|
|
217
|
-
}) === true;
|
|
218
|
-
if (!queued) pendingWriteError = 'appendPendingContext returned false';
|
|
219
|
-
} catch (err) {
|
|
220
|
-
pendingWriteError = String(err?.message ?? err) || 'unknown pending context persistence error';
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
if (!queued) {
|
|
224
|
-
reportError(`Spotter Codex Stop ${kind} persistence failed: ${pendingWriteError}\n${text}\n`);
|
|
225
|
-
}
|
|
226
|
-
return { queued, pendingWriteError };
|
|
227
|
-
};
|
|
228
|
-
|
|
229
206
|
const transcriptPath = requireString(input, 'transcript_path');
|
|
230
207
|
const finalResponse = codexLastAssistantMessage(input) ?? '(no final response available)';
|
|
231
|
-
|
|
208
|
+
let toolUsage;
|
|
209
|
+
try {
|
|
210
|
+
toolUsage = await readCodexToolUsageFn(transcriptPath);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const failure = projectBackendFailure(err?.code);
|
|
213
|
+
reportError(failure.stderr);
|
|
214
|
+
writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
|
|
215
|
+
await recordCodexHookEventSafe(recordHookEventFn, {
|
|
216
|
+
projectRoot,
|
|
217
|
+
event: {
|
|
218
|
+
hook: 'Stop',
|
|
219
|
+
status: 'error',
|
|
220
|
+
code: failure.code,
|
|
221
|
+
reason: 'tool_usage_observation',
|
|
222
|
+
usedToolCount: 0,
|
|
223
|
+
durationMs: Date.now() - startedAt,
|
|
224
|
+
},
|
|
225
|
+
}, reportError);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
232
228
|
const usedTools = Array.isArray(toolUsage?.usedTools) ? toolUsage.usedTools : [];
|
|
233
229
|
const toolUsageEvent = compactCodexToolUsageForEvent(toolUsage);
|
|
234
230
|
if (toolUsageEvent.toolUsageAnomalyCount === 0
|
|
@@ -246,29 +242,25 @@ export async function runCodexStopHook({
|
|
|
246
242
|
}, reportError);
|
|
247
243
|
return;
|
|
248
244
|
}
|
|
249
|
-
|
|
245
|
+
let catalog;
|
|
250
246
|
let backend;
|
|
251
247
|
let judgment;
|
|
252
248
|
try {
|
|
249
|
+
catalog = await readLocalFn({ projectRoot, hostAgent: 'codex' });
|
|
253
250
|
backend = createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBackendFn });
|
|
254
251
|
judgment = await backend.judge({ stage: 'turn_end', finalResponse, usedTools });
|
|
255
252
|
} catch (err) {
|
|
256
|
-
const
|
|
257
|
-
reportError(
|
|
258
|
-
|
|
259
|
-
text: errorText,
|
|
260
|
-
kind: 'warning',
|
|
261
|
-
});
|
|
253
|
+
const failure = projectBackendFailure(err?.code);
|
|
254
|
+
reportError(failure.stderr);
|
|
255
|
+
writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
|
|
262
256
|
await recordCodexHookEventSafe(recordHookEventFn, {
|
|
263
257
|
projectRoot,
|
|
264
258
|
event: {
|
|
265
259
|
hook: 'Stop',
|
|
266
260
|
status: 'error',
|
|
267
261
|
backend: err?.backend ?? null,
|
|
268
|
-
code:
|
|
262
|
+
code: failure.code,
|
|
269
263
|
...compactCodexModelSelectionForEvent(err?.diagnostics?.modelSelection),
|
|
270
|
-
warningQueued,
|
|
271
|
-
...(!warningQueued ? { pendingWriteError } : {}),
|
|
272
264
|
usedToolCount: usedTools.length,
|
|
273
265
|
...toolUsageEvent,
|
|
274
266
|
durationMs: Date.now() - startedAt,
|
|
@@ -295,22 +287,16 @@ export async function runCodexStopHook({
|
|
|
295
287
|
return;
|
|
296
288
|
}
|
|
297
289
|
|
|
298
|
-
|
|
299
|
-
const { queued: findingQueued, pendingWriteError } = await persistPending({
|
|
300
|
-
text: findingText,
|
|
301
|
-
kind: 'finding',
|
|
302
|
-
});
|
|
290
|
+
writeCodexSystemMessage({ systemMessage: STOP_FINDING_SYSTEM_MESSAGE, writeOutput });
|
|
303
291
|
await recordCodexHookEventSafe(recordHookEventFn, {
|
|
304
292
|
projectRoot,
|
|
305
293
|
event: {
|
|
306
294
|
hook: 'Stop',
|
|
307
|
-
status:
|
|
295
|
+
status: 'finding',
|
|
308
296
|
backend: judgment.meta?.backend ?? backend.name ?? 'unknown',
|
|
309
297
|
pass: false,
|
|
310
|
-
missingTools: judgment.findings.map((finding) => finding.toolName),
|
|
298
|
+
missingTools: projectToolIds(judgment.findings.map((finding) => finding.toolName)),
|
|
311
299
|
...compactCodexModelSelectionForEvent(judgment.meta?.modelSelection),
|
|
312
|
-
findingQueued,
|
|
313
|
-
...(!findingQueued ? { pendingWriteError } : {}),
|
|
314
300
|
usedToolCount: usedTools.length,
|
|
315
301
|
...toolUsageEvent,
|
|
316
302
|
backendDurationMs: judgment.meta?.durationMs ?? null,
|
|
@@ -614,34 +600,8 @@ function writeCodexUserPromptContexts({ contexts, writeOutput }) {
|
|
|
614
600
|
}));
|
|
615
601
|
}
|
|
616
602
|
|
|
617
|
-
function
|
|
618
|
-
|
|
619
|
-
const backend = typeof err?.backend === 'string' && err.backend ? ` ${err.backend}` : '';
|
|
620
|
-
const message = err?.message ? String(err.message) : String(err);
|
|
621
|
-
const diagnostics = formatBackendDiagnostics(err?.diagnostics);
|
|
622
|
-
return [
|
|
623
|
-
`Spotter auditor backend error${code}${backend}: ${message}`,
|
|
624
|
-
'No fallback auditor was used.',
|
|
625
|
-
diagnostics,
|
|
626
|
-
].filter(Boolean).join('\n');
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
function formatBackendDiagnostics(diagnostics) {
|
|
630
|
-
if (!diagnostics || typeof diagnostics !== 'object') return '';
|
|
631
|
-
const stderr = typeof diagnostics.stderr === 'string' ? diagnostics.stderr.trim() : '';
|
|
632
|
-
const stdout = typeof diagnostics.stdout === 'string' ? diagnostics.stdout.trim() : '';
|
|
633
|
-
const sections = [];
|
|
634
|
-
const model = diagnostics.modelSelection;
|
|
635
|
-
if (model && typeof model === 'object') {
|
|
636
|
-
sections.push(
|
|
637
|
-
`auditor model: model=${model.effectiveModel ?? 'unknown'} effort=${model.effectiveReasoningEffort ?? 'unknown'} source=${model.modelSource ?? 'unknown'}/${model.effortSource ?? 'unknown'} availability=${model.availability ?? 'unknown'}`,
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
const output = [];
|
|
641
|
-
if (stdout) output.push(`stdout:\n${stdout.split('\n').slice(-4).join('\n')}`);
|
|
642
|
-
if (stderr) output.push(`stderr:\n${stderr.split('\n').slice(-4).join('\n')}`);
|
|
643
|
-
if (output.length > 0) sections.push(`backend output:\n${output.join('\n')}`);
|
|
644
|
-
return sections.join('\n');
|
|
603
|
+
function writeCodexSystemMessage({ systemMessage, writeOutput }) {
|
|
604
|
+
writeOutput(JSON.stringify({ systemMessage }));
|
|
645
605
|
}
|
|
646
606
|
|
|
647
607
|
function compactCodexModelSelectionForEvent(selection) {
|
|
@@ -661,10 +621,8 @@ function codexSessionId(payload) {
|
|
|
661
621
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
662
622
|
}
|
|
663
623
|
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
// `.spotter/codex-pending/` to host-neutral `.spotter/pending/`. The Claude Stop hook
|
|
667
|
-
// now writes to the same queue.
|
|
624
|
+
// v1.4.19: legacy pending migration is handled only by `discardLegacyPending` on
|
|
625
|
+
// UserPromptSubmit. Stop findings are never persisted for a future parent prompt.
|
|
668
626
|
|
|
669
627
|
// Phase D (hook parity, 2026-05-08): Codex hook events now go through the host-neutral
|
|
670
628
|
// `appendHookEvent` so Claude / Codex events live in the same `.spotter/hook-events.jsonl`.
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// Codex side previously wrote `.spotter/codex-hook-events.jsonl` with schema
|
|
4
4
|
// `spotter.codex_hook_event.v1`. v1.4.8 unifies that into `.spotter/hook-events.jsonl`
|
|
5
5
|
// with schema `spotter.hook_event.v1` + a `host` field, and Claude hooks now write to
|
|
6
|
-
// the same file. Records every hook firing with skip / success / error /
|
|
6
|
+
// the same file. Records every hook firing with skip / success / error / finding status
|
|
7
7
|
// so `spotter diagnostics logs` can surface hook-side observations the daemon log alone
|
|
8
|
-
// never sees (short-prompt skip,
|
|
8
|
+
// never sees (short-prompt skip, legacy pending discard diagnostics, hook-level transport errors).
|
|
9
9
|
//
|
|
10
10
|
// File path: `<projectRoot>/.spotter/hook-events.jsonl` (append-only, no rotation yet).
|
|
11
11
|
// Record schema (v1):
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// durationMs?: number,
|
|
24
24
|
// backendDurationMs?: number | null,
|
|
25
25
|
// usedToolCount?: number,
|
|
26
|
-
//
|
|
26
|
+
// legacyPendingDiagnostic?: string | null,
|
|
27
27
|
// toolName?: string | null
|
|
28
28
|
// }
|
|
29
29
|
|
package/src/hooks/lib.mjs
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Exit code contract (§14.3 / §14.4):
|
|
4
4
|
// 0 = success (normal flow), OR a LOUD degradation: the audit could not run but the failure
|
|
5
|
-
// is surfaced
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// is surfaced through a fixed systemMessage and fixed stderr, so the host stays responsive
|
|
6
|
+
// and the user's prompt is never erased. UserPromptSubmit uses this for any daemon /
|
|
7
|
+
// auditor-backend failure — see user-prompt.mjs.
|
|
8
8
|
// 1 = expected abnormal, non-blocking (Claude Code proceeds; first stderr line shown to user).
|
|
9
9
|
// 2 = unexpected protocol/contract violation (malformed Claude Code envelope, missing required
|
|
10
10
|
// field). Reserved for cases where there is no real user prompt worth preserving — a
|
|
@@ -126,16 +126,6 @@ export function die(message, exitCode = 2) {
|
|
|
126
126
|
process.exit(exitCode);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
export function formatTransparentContext(missingTools) {
|
|
130
|
-
// §12.2: transparent phrasing — header `[Spotter からの推奨ツール]` 自体が出典明示を担う。
|
|
131
|
-
const lines = missingTools.map((m) => `- \`${m.name}\`: ${m.reason}`);
|
|
132
|
-
return [
|
|
133
|
-
'[Spotter からの推奨ツール]',
|
|
134
|
-
'このプロンプトに応答する前に、以下のツールを使うべきか検討してください。',
|
|
135
|
-
...lines,
|
|
136
|
-
].join('\n');
|
|
137
|
-
}
|
|
138
|
-
|
|
139
129
|
// Phase D (hook parity, 2026-05-08): hook-event JSONL helper for Claude-side hooks.
|
|
140
130
|
// Each Claude hook calls this once with its observation; failures are silenced (a
|
|
141
131
|
// missing diagnostics file is acceptable, but the hook itself must not break).
|
|
@@ -150,55 +140,3 @@ export async function recordClaudeHookEvent({ projectRoot, event, writeError } =
|
|
|
150
140
|
writeError: writeError ?? ((text) => process.stderr.write(text)),
|
|
151
141
|
});
|
|
152
142
|
}
|
|
153
|
-
|
|
154
|
-
export function formatTransparentBlockReason(missingTools) {
|
|
155
|
-
// §12.3: transparent phrasing — header `[Spotter からの指摘]` 自体が出典明示を担う。
|
|
156
|
-
const lines = missingTools.map((m) => `- \`${m.name}\`: ${m.reason}`);
|
|
157
|
-
return [
|
|
158
|
-
'[Spotter からの指摘]',
|
|
159
|
-
'上記応答ではツールが不足している可能性があります。以下を検討し、必要なら呼び出した上で応答を補正してください。',
|
|
160
|
-
...lines,
|
|
161
|
-
].join('\n');
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Loud degradation notice (§0 / §14.1): the auditor could not run this turn. Surfaced via the
|
|
165
|
-
// same additionalContext channel as findings so the host stays responsive AND the user is told
|
|
166
|
-
// they are temporarily unprotected (and, for an expired codex login, exactly how to recover).
|
|
167
|
-
// This is the opposite of a silent fallback.
|
|
168
|
-
export function formatSpotterWarning({ code, message, stage = 'user_input' } = {}) {
|
|
169
|
-
const header = '[Spotter からの警告]';
|
|
170
|
-
const stopFailure = stage === 'stop';
|
|
171
|
-
if (code === 'E_CODEX_CLI_AUTH') {
|
|
172
|
-
return [
|
|
173
|
-
header,
|
|
174
|
-
stopFailure
|
|
175
|
-
? 'Spotter の監査エンジン (codex) のログインが失効しているため、直前の応答を Stop 時に監査できませんでした。'
|
|
176
|
-
: 'Spotter の監査エンジン (codex) のログインが失効しているため、今回の入力は監査できませんでした。',
|
|
177
|
-
stopFailure
|
|
178
|
-
? 'ユーザーに、直前の応答が未監査だったことと「端末で `codex login` を実行して再ログインすれば Spotter の監査が復旧する」ことを伝えてください。'
|
|
179
|
-
: 'この応答を続ける前に、ユーザーに「端末で `codex login` を実行して再ログインすれば Spotter の監査が復旧する」ことを伝えてください。',
|
|
180
|
-
].join('\n');
|
|
181
|
-
}
|
|
182
|
-
if (code === 'E_CODEX_CLI_USAGE_LIMIT') {
|
|
183
|
-
return [
|
|
184
|
-
header,
|
|
185
|
-
stopFailure
|
|
186
|
-
? 'Spotter の監査エンジン (codex) が利用上限に達したため、直前の応答を Stop 時に監査できませんでした。'
|
|
187
|
-
: 'Spotter の監査エンジン (codex) が利用上限に達したため、今回の入力は監査できませんでした。',
|
|
188
|
-
stopFailure
|
|
189
|
-
? 'ユーザーに、直前の応答が未監査だったことと、表示されたリセット時刻まで待つか Codex プランを確認すれば監査を再開できることを伝えてください。'
|
|
190
|
-
: 'この応答を続ける前に、ユーザーに、表示されたリセット時刻まで待つか Codex プランを確認すれば監査を再開できることを伝えてください。',
|
|
191
|
-
].join('\n');
|
|
192
|
-
}
|
|
193
|
-
const lines = [
|
|
194
|
-
header,
|
|
195
|
-
stopFailure
|
|
196
|
-
? `Spotter は直前の応答を Stop 時に監査できませんでした (理由コード: ${code ?? 'unknown'})。直前の応答は未監査です。`
|
|
197
|
-
: `Spotter は今回の入力を監査できませんでした (理由コード: ${code ?? 'unknown'})。この応答は監査されていません。`,
|
|
198
|
-
stopFailure
|
|
199
|
-
? 'ユーザーに、直前の応答が未監査だったことと Spotter の監査が一時的に無効だったことを伝えてください。'
|
|
200
|
-
: 'この応答を続ける前に、ユーザーに Spotter の監査が一時的に無効になっていることを伝えてください。',
|
|
201
|
-
];
|
|
202
|
-
if (typeof message === 'string' && message.length > 0) lines.push(`詳細: ${message}`);
|
|
203
|
-
return lines.join('\n');
|
|
204
|
-
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const TOOL_ID_PATTERN = /^[A-Za-z0-9_.:/-]+$/;
|
|
2
|
+
const MAX_TOOL_ID_LENGTH = 160;
|
|
3
|
+
const MAX_TOOL_IDS = 5;
|
|
4
|
+
const MAX_ADVICE_LENGTH = 2000;
|
|
5
|
+
|
|
6
|
+
const FAILURE_KINDS = new Map([
|
|
7
|
+
['E_CODEX_CLI_AUTH', 'auth'],
|
|
8
|
+
['E_CODEX_CLI_USAGE_LIMIT', 'usage_limit'],
|
|
9
|
+
['E_CODEX_CLI_MODEL_UNAVAILABLE', 'model_unavailable'],
|
|
10
|
+
['E_CODEX_CLI_TIMEOUT', 'timeout'],
|
|
11
|
+
['E_TIMEOUT', 'timeout'],
|
|
12
|
+
['E_HAIKU_TIMEOUT', 'timeout'],
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const FAILURE_OUTPUTS = Object.freeze({
|
|
16
|
+
auth: Object.freeze({
|
|
17
|
+
code: 'E_SPOTTER_AUDIT_AUTH',
|
|
18
|
+
systemMessage: 'Spotter の監査は認証状態を確認できないため、このターンでは利用できませんでした。',
|
|
19
|
+
}),
|
|
20
|
+
usage_limit: Object.freeze({
|
|
21
|
+
code: 'E_SPOTTER_AUDIT_USAGE_LIMIT',
|
|
22
|
+
systemMessage: 'Spotter の監査は利用上限のため、このターンでは利用できませんでした。',
|
|
23
|
+
}),
|
|
24
|
+
model_unavailable: Object.freeze({
|
|
25
|
+
code: 'E_SPOTTER_AUDIT_MODEL_UNAVAILABLE',
|
|
26
|
+
systemMessage: 'Spotter の監査は選択中のモデルを利用できないため、このターンでは利用できませんでした。',
|
|
27
|
+
}),
|
|
28
|
+
timeout: Object.freeze({
|
|
29
|
+
code: 'E_SPOTTER_AUDIT_TIMEOUT',
|
|
30
|
+
systemMessage: 'Spotter の監査は時間内に完了しなかったため、このターンでは利用できませんでした。',
|
|
31
|
+
}),
|
|
32
|
+
generic: Object.freeze({
|
|
33
|
+
code: 'E_SPOTTER_AUDIT_GENERIC',
|
|
34
|
+
systemMessage: 'Spotter の監査は一時的な問題のため、このターンでは利用できませんでした。',
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const STOP_FINDING_SYSTEM_MESSAGE = 'Spotter は直前の応答について利用可能ツールの確認候補を記録しました。';
|
|
39
|
+
|
|
40
|
+
export function projectParentAdvice(toolIds) {
|
|
41
|
+
const accepted = projectToolIds(toolIds);
|
|
42
|
+
if (accepted.length === 0) return '';
|
|
43
|
+
const lines = [
|
|
44
|
+
'[Spotter からの参考情報]',
|
|
45
|
+
'関連する可能性がある利用可能ツール:',
|
|
46
|
+
];
|
|
47
|
+
for (const toolId of accepted) {
|
|
48
|
+
const next = [...lines, `- \`${toolId}\``, '', '適用可否は、現在の依頼と利用条件に基づいて独立に判断できます。'].join('\n');
|
|
49
|
+
if (next.length > MAX_ADVICE_LENGTH) break;
|
|
50
|
+
lines.push(`- \`${toolId}\``);
|
|
51
|
+
}
|
|
52
|
+
if (lines.length === 2) return '';
|
|
53
|
+
lines.push('', '適用可否は、現在の依頼と利用条件に基づいて独立に判断できます。');
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function projectToolIds(toolIds) {
|
|
58
|
+
return normalizeToolIds(toolIds);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function projectBackendFailure(code) {
|
|
62
|
+
const kind = FAILURE_KINDS.get(code) ?? 'generic';
|
|
63
|
+
const output = FAILURE_OUTPUTS[kind];
|
|
64
|
+
return Object.freeze({
|
|
65
|
+
code: output.code,
|
|
66
|
+
systemMessage: output.systemMessage,
|
|
67
|
+
stderr: `spotter-hook: ${output.systemMessage}\n`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeToolIds(toolIds) {
|
|
72
|
+
if (!Array.isArray(toolIds)) return [];
|
|
73
|
+
const unique = new Set();
|
|
74
|
+
for (const toolId of toolIds) {
|
|
75
|
+
if (typeof toolId !== 'string' || toolId.length === 0 || toolId.length > MAX_TOOL_ID_LENGTH) continue;
|
|
76
|
+
if (!TOOL_ID_PATTERN.test(toolId)) continue;
|
|
77
|
+
unique.add(toolId);
|
|
78
|
+
}
|
|
79
|
+
return [...unique].sort().slice(0, MAX_TOOL_IDS);
|
|
80
|
+
}
|
|
@@ -1,18 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Stop hook が pass:false を出した時、当ターンで block するのではなく次の UserPromptSubmit
|
|
4
|
-
// で `additionalContext` として配信するための queue ファイル。Claude / Codex 両 host から
|
|
5
|
-
// 同じヘルパを通すことで、ファイル形式・パス命名を統一する。
|
|
6
|
-
//
|
|
7
|
-
// File path: `<projectRoot>/.spotter/pending/<sanitizedSessionId>.json`
|
|
8
|
-
// File format: JSON 配列 `[<text>, <text>, ...]` (Codex 側 `codex-pending/` 既存形式と互換)
|
|
9
|
-
//
|
|
10
|
-
// 同 session 内のみ参照される。drain (= UserPromptSubmit が読み出す) で file は削除される。
|
|
11
|
-
// session が事故等で残った pending file は次 session の sanitizedSessionId と一致しない
|
|
12
|
-
// 限り読まれない (= 副作用なし) ので、cleanup は best-effort。
|
|
1
|
+
// Legacy pending migration only. Historical files are never read or parsed because they may
|
|
2
|
+
// contain untrusted auditor text; the matching same-session file is best-effort unlinked.
|
|
13
3
|
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
4
|
+
import { unlink } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
16
6
|
|
|
17
7
|
const PENDING_DIR = 'pending';
|
|
18
8
|
|
|
@@ -24,42 +14,14 @@ export function pendingPath({ projectRoot, sessionId } = {}) {
|
|
|
24
14
|
return join(projectRoot, '.spotter', PENDING_DIR, `${clean}.json`);
|
|
25
15
|
}
|
|
26
16
|
|
|
27
|
-
export async function
|
|
28
|
-
|
|
17
|
+
export async function discardLegacyPending({ projectRoot, sessionId, unlinkFn = unlink } = {}) {
|
|
18
|
+
const path = pendingPath({ projectRoot, sessionId });
|
|
19
|
+
if (!path) return { discarded: false, diagnostic: 'legacy_pending_invalid_path' };
|
|
29
20
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return Array.isArray(parsed)
|
|
33
|
-
? parsed.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
|
|
34
|
-
: [];
|
|
21
|
+
await unlinkFn(path);
|
|
22
|
+
return { discarded: true, diagnostic: null };
|
|
35
23
|
} catch (err) {
|
|
36
|
-
if (err
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export async function appendPendingContext({ projectRoot, sessionId, text } = {}) {
|
|
42
|
-
const path = pendingPath({ projectRoot, sessionId });
|
|
43
|
-
const value = String(text ?? '').trim();
|
|
44
|
-
if (!path || !value) return false;
|
|
45
|
-
const contexts = await readPendingContexts(path);
|
|
46
|
-
// De-dupe so repeated identical findings within the same session don't pile up.
|
|
47
|
-
if (!contexts.includes(value)) contexts.push(value);
|
|
48
|
-
await mkdir(dirname(path), { recursive: true });
|
|
49
|
-
await writeFile(path, JSON.stringify(contexts, null, 2) + '\n', 'utf8');
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function drainPendingContexts({ projectRoot, sessionId } = {}) {
|
|
54
|
-
const path = pendingPath({ projectRoot, sessionId });
|
|
55
|
-
if (!path) return [];
|
|
56
|
-
const contexts = await readPendingContexts(path);
|
|
57
|
-
if (contexts.length > 0) {
|
|
58
|
-
try {
|
|
59
|
-
await unlink(path);
|
|
60
|
-
} catch (err) {
|
|
61
|
-
if (err.code !== 'ENOENT') throw err;
|
|
62
|
-
}
|
|
24
|
+
if (err?.code === 'ENOENT') return { discarded: true, diagnostic: null };
|
|
25
|
+
return { discarded: false, diagnostic: 'legacy_pending_discard_failed' };
|
|
63
26
|
}
|
|
64
|
-
return contexts;
|
|
65
27
|
}
|
package/src/hooks/stop.mjs
CHANGED
|
@@ -1,48 +1,27 @@
|
|
|
1
|
-
// Stop hook — send turn_end to daemon
|
|
2
|
-
//
|
|
3
|
-
// Prior behavior (pre-v1.4.8): on `pass:false` returned `{decision:"block", reason:<text>}` so
|
|
4
|
-
// Claude Code re-asks Bell to regenerate the response. This worked but caused a UX defect —
|
|
5
|
-
// the regenerated reply (= the corrective answer) became the transcript's final message and
|
|
6
|
-
// the original "A" topic got lost in transcript review.
|
|
7
|
-
//
|
|
8
|
-
// Phase B behavior: on `pass:false` we instead append the same transparent block-reason text
|
|
9
|
-
// to `<projectRoot>/.spotter/pending/<sessionId>.json`. The next UserPromptSubmit drains the
|
|
10
|
-
// queue and folds the entries into `additionalContext`, so Bell receives the audit finding
|
|
11
|
-
// alongside the next user input. The original A reply stays as the turn's final message.
|
|
12
|
-
//
|
|
13
|
-
// `decision:"block"` is no longer emitted from this hook. Backend / transport errors do NOT
|
|
14
|
-
// force a continuation (a Stop exit 2 would block stopping = harmful noise on a Spotter-side
|
|
15
|
-
// failure); they are recorded as `degraded` and exit 0. Their loud `[Spotter からの警告]` is
|
|
16
|
-
// persisted in the same pending queue as findings, then delivered by the next
|
|
17
|
-
// UserPromptSubmit. A persistence failure is written to stderr but never turns Stop into exit 2.
|
|
18
|
-
// `stop_hook_active:true` is still observed: the daemon early-passes on it, so we just
|
|
19
|
-
// receive `pass:true` and return without writing pending context.
|
|
20
|
-
//
|
|
21
|
-
// v0.2 gates: see src/hooks/session-start.mjs comment.
|
|
1
|
+
// Stop hook — send turn_end to daemon and emit only fixed system messages. It never blocks the
|
|
2
|
+
// host and never carries auditor findings or failures into a future UserPromptSubmit.
|
|
22
3
|
|
|
23
4
|
import {
|
|
24
5
|
readStdinJson,
|
|
25
6
|
requireString,
|
|
26
7
|
die,
|
|
27
8
|
findSpotterMarker,
|
|
28
|
-
formatTransparentBlockReason,
|
|
29
|
-
formatSpotterWarning,
|
|
30
9
|
isChildCall,
|
|
31
10
|
isSubagentCall,
|
|
32
11
|
recordClaudeHookEvent,
|
|
33
12
|
} from './lib.mjs';
|
|
34
13
|
import { getLastAssistantText } from './transcript-reader.mjs';
|
|
35
14
|
import { sendRequest } from '../daemon/transport.mjs';
|
|
36
|
-
import {
|
|
15
|
+
import { STOP_FINDING_SYSTEM_MESSAGE, projectBackendFailure, projectToolIds } from './parent-output-projector.mjs';
|
|
37
16
|
|
|
38
17
|
const TIMEOUT_MS = 50_000;
|
|
39
18
|
|
|
40
19
|
export async function runStop({
|
|
41
20
|
readInput = readStdinJson,
|
|
42
21
|
sendRequestFn = sendRequest,
|
|
43
|
-
appendPendingContextFn = appendPendingContext,
|
|
44
22
|
getLastAssistantTextFn = getLastAssistantText,
|
|
45
23
|
recordHookEventFn = recordClaudeHookEvent,
|
|
24
|
+
writeOutput = (text) => process.stdout.write(text),
|
|
46
25
|
writeError = (text) => process.stderr.write(text),
|
|
47
26
|
} = {}) {
|
|
48
27
|
if (isChildCall()) return;
|
|
@@ -66,48 +45,19 @@ export async function runStop({
|
|
|
66
45
|
}
|
|
67
46
|
};
|
|
68
47
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// disappears while the daemon request is in flight.
|
|
74
|
-
const currentProjectRoot = findSpotterMarker(input.cwd);
|
|
75
|
-
if (currentProjectRoot !== projectRoot) {
|
|
76
|
-
pendingWriteError = 'Spotter project marker no longer identifies the original project';
|
|
77
|
-
} else {
|
|
78
|
-
try {
|
|
79
|
-
queued = await appendPendingContextFn({
|
|
80
|
-
projectRoot,
|
|
81
|
-
sessionId,
|
|
82
|
-
text,
|
|
83
|
-
}) === true;
|
|
84
|
-
if (!queued) pendingWriteError = 'appendPendingContext returned false';
|
|
85
|
-
} catch (err) {
|
|
86
|
-
pendingWriteError = String(err?.message ?? err) || 'unknown pending context persistence error';
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
if (!queued) {
|
|
90
|
-
// Include the original warning/finding as well as the persistence failure. If the durable
|
|
91
|
-
// channel is unavailable, stderr is the only remaining loud surface for this Stop event.
|
|
92
|
-
reportError(`Spotter Stop ${kind} persistence failed: ${pendingWriteError}\n${text}\n`);
|
|
93
|
-
}
|
|
94
|
-
return { queued, pendingWriteError };
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const recordFailure = async ({ code, message, reason }) => {
|
|
98
|
-
const text = formatSpotterWarning({ code, message, stage: 'stop' });
|
|
99
|
-
const { queued: warningQueued, pendingWriteError } = await persistPending({ text, kind: 'warning' });
|
|
48
|
+
const recordFailure = async ({ code, reason }) => {
|
|
49
|
+
const failure = projectBackendFailure(code);
|
|
50
|
+
reportError(failure.stderr);
|
|
51
|
+
writeOutput(JSON.stringify({ systemMessage: failure.systemMessage }));
|
|
100
52
|
await recordHookEventFn({
|
|
101
53
|
projectRoot,
|
|
102
54
|
writeError: reportError,
|
|
103
55
|
event: {
|
|
104
56
|
hook: 'Stop',
|
|
105
57
|
status: 'degraded',
|
|
106
|
-
code: code
|
|
58
|
+
code: failure.code,
|
|
107
59
|
reason,
|
|
108
60
|
durationMs: Date.now() - startedAt,
|
|
109
|
-
warningQueued,
|
|
110
|
-
...(!warningQueued ? { pendingWriteError } : {}),
|
|
111
61
|
},
|
|
112
62
|
});
|
|
113
63
|
};
|
|
@@ -129,22 +79,18 @@ export async function runStop({
|
|
|
129
79
|
timeoutMs: TIMEOUT_MS,
|
|
130
80
|
});
|
|
131
81
|
} catch (err) {
|
|
132
|
-
// Spotter-side failure
|
|
133
|
-
// force the model to continue: a Stop exit 2 blocks stopping.
|
|
82
|
+
// Spotter-side failure is surfaced only through fixed diagnostics; a Stop exit 2 would block stopping.
|
|
134
83
|
await recordFailure({
|
|
135
84
|
code: err?.code ?? 'E_INTERNAL',
|
|
136
|
-
message: err?.message ?? '',
|
|
137
85
|
reason: 'transport',
|
|
138
86
|
});
|
|
139
87
|
return;
|
|
140
88
|
}
|
|
141
89
|
|
|
142
90
|
if (response.ok !== true) {
|
|
143
|
-
// Auditor backend
|
|
144
|
-
// Forcing a continuation (exit 2) on a Spotter-side failure is harmful.
|
|
91
|
+
// Auditor backend failure is fixed-diagnostic only; forcing a continuation is harmful.
|
|
145
92
|
await recordFailure({
|
|
146
93
|
code: response.error?.code ?? 'E_INTERNAL',
|
|
147
|
-
message: response.error?.message ?? '',
|
|
148
94
|
reason: 'daemon_error',
|
|
149
95
|
});
|
|
150
96
|
return;
|
|
@@ -166,25 +112,19 @@ export async function runStop({
|
|
|
166
112
|
return; // nothing to defer
|
|
167
113
|
}
|
|
168
114
|
|
|
169
|
-
|
|
170
|
-
// decision:"block". Using the same transparent block-reason wording keeps the user-facing
|
|
171
|
-
// text identical to the prior block flow.
|
|
172
|
-
const text = formatTransparentBlockReason(result.missing_tools);
|
|
173
|
-
const { queued: findingQueued, pendingWriteError } = await persistPending({ text, kind: 'finding' });
|
|
115
|
+
writeOutput(JSON.stringify({ systemMessage: STOP_FINDING_SYSTEM_MESSAGE }));
|
|
174
116
|
await recordHookEventFn({
|
|
175
117
|
projectRoot,
|
|
176
118
|
writeError: reportError,
|
|
177
119
|
event: {
|
|
178
120
|
hook: 'Stop',
|
|
179
|
-
status:
|
|
121
|
+
status: 'finding',
|
|
180
122
|
pass: false,
|
|
181
|
-
missingTools: result.missing_tools.map((
|
|
123
|
+
missingTools: projectToolIds(Array.isArray(result.missing_tools) ? result.missing_tools.map((entry) => entry?.name) : []),
|
|
182
124
|
durationMs: Date.now() - startedAt,
|
|
183
|
-
findingQueued,
|
|
184
|
-
...(!findingQueued ? { pendingWriteError } : {}),
|
|
185
125
|
},
|
|
186
126
|
});
|
|
187
|
-
// No
|
|
127
|
+
// No continuation or pending delivery is requested.
|
|
188
128
|
}
|
|
189
129
|
|
|
190
130
|
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// UserPromptSubmit hook — send user_input to daemon
|
|
1
|
+
// UserPromptSubmit hook — send user_input to daemon and project only validated tool IDs into
|
|
2
|
+
// fixed non-imperative additionalContext text.
|
|
2
3
|
// v0.2 gates: see src/hooks/session-start.mjs comment.
|
|
3
4
|
//
|
|
4
5
|
// v0.12.0: auto-resurrect. If sendRequest fails with E_UNREACHABLE (daemon was
|
|
@@ -7,18 +8,13 @@
|
|
|
7
8
|
// natural recovery point — the start of a new turn — so the user's prompt is still
|
|
8
9
|
// audited even after long pauses or daemon failures.
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// hook and merge those entries into the same additionalContext. Drain runs even on the
|
|
13
|
-
// short-prompt early-return path so pending context never gets stuck behind a "ok" / "thanks"
|
|
14
|
-
// reply.
|
|
11
|
+
// Legacy pending files are never read or delivered. The same-session path is best-effort
|
|
12
|
+
// removed before both short and normal prompt paths.
|
|
15
13
|
|
|
16
14
|
import {
|
|
17
15
|
readStdinJson,
|
|
18
16
|
requireString,
|
|
19
17
|
die,
|
|
20
|
-
formatTransparentContext,
|
|
21
|
-
formatSpotterWarning,
|
|
22
18
|
isChildCall,
|
|
23
19
|
isSubagentCall,
|
|
24
20
|
isOutsideSpotterProject,
|
|
@@ -27,7 +23,8 @@ import {
|
|
|
27
23
|
} from './lib.mjs';
|
|
28
24
|
import { sendRequest, TransportError } from '../daemon/transport.mjs';
|
|
29
25
|
import { spawnDaemonAndWaitReady } from './spawn-daemon.mjs';
|
|
30
|
-
import {
|
|
26
|
+
import { discardLegacyPending } from './pending-context.mjs';
|
|
27
|
+
import { projectBackendFailure, projectParentAdvice, projectToolIds } from './parent-output-projector.mjs';
|
|
31
28
|
|
|
32
29
|
const TIMEOUT_MS = 50_000;
|
|
33
30
|
const SHORT_PROMPT_MAX_CHARS = 10;
|
|
@@ -36,9 +33,10 @@ export async function runUserPrompt({
|
|
|
36
33
|
readInput = readStdinJson,
|
|
37
34
|
sendRequestFn = sendRequest,
|
|
38
35
|
spawnDaemonAndWaitReadyFn = spawnDaemonAndWaitReady,
|
|
39
|
-
|
|
36
|
+
discardLegacyPendingFn = discardLegacyPending,
|
|
40
37
|
recordHookEventFn = recordClaudeHookEvent,
|
|
41
38
|
writeOutput = (text) => process.stdout.write(text),
|
|
39
|
+
writeError = (text) => process.stderr.write(text),
|
|
42
40
|
} = {}) {
|
|
43
41
|
if (isChildCall()) return;
|
|
44
42
|
const input = await readInput();
|
|
@@ -50,44 +48,35 @@ export async function runUserPrompt({
|
|
|
50
48
|
const projectRoot = findSpotterMarker(input.cwd);
|
|
51
49
|
const startedAt = Date.now();
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// Surface a visible [Spotter からの警告] (merged with any drained pending context) and exit 0
|
|
62
|
-
// so the prompt reaches the host — never erase it with a blocking exit 2.
|
|
63
|
-
const degrade = async ({ code, message, reason }) => {
|
|
64
|
-
const contexts = pendingContexts.slice();
|
|
65
|
-
contexts.push(formatSpotterWarning({ code, message }));
|
|
66
|
-
emitAdditionalContext(writeOutput, contexts);
|
|
51
|
+
const legacyPending = projectRoot
|
|
52
|
+
? await discardLegacyPendingFn({ projectRoot, sessionId })
|
|
53
|
+
: { discarded: false, diagnostic: 'legacy_pending_invalid_path' };
|
|
54
|
+
|
|
55
|
+
const degrade = async ({ code, reason }) => {
|
|
56
|
+
const failure = projectBackendFailure(code);
|
|
57
|
+
emitSystemMessage(writeOutput, failure.systemMessage);
|
|
58
|
+
safeWriteError(writeError, failure.stderr);
|
|
67
59
|
await recordHookEventFn({
|
|
68
60
|
projectRoot,
|
|
69
61
|
event: {
|
|
70
62
|
hook: 'UserPromptSubmit',
|
|
71
63
|
status: 'degraded',
|
|
72
|
-
code: code
|
|
64
|
+
code: failure.code,
|
|
73
65
|
reason,
|
|
74
|
-
|
|
66
|
+
legacyPendingDiagnostic: legacyPending.diagnostic,
|
|
75
67
|
durationMs: Date.now() - startedAt,
|
|
76
68
|
},
|
|
77
69
|
});
|
|
78
70
|
};
|
|
79
71
|
|
|
80
72
|
if ([...prompt.trim()].length <= SHORT_PROMPT_MAX_CHARS) {
|
|
81
|
-
if (pendingContexts.length > 0) {
|
|
82
|
-
emitAdditionalContext(writeOutput, pendingContexts);
|
|
83
|
-
}
|
|
84
73
|
await recordHookEventFn({
|
|
85
74
|
projectRoot,
|
|
86
75
|
event: {
|
|
87
76
|
hook: 'UserPromptSubmit',
|
|
88
77
|
status: 'skipped',
|
|
89
78
|
reason: 'short_prompt',
|
|
90
|
-
|
|
79
|
+
legacyPendingDiagnostic: legacyPending.diagnostic,
|
|
91
80
|
durationMs: Date.now() - startedAt,
|
|
92
81
|
},
|
|
93
82
|
});
|
|
@@ -115,13 +104,12 @@ export async function runUserPrompt({
|
|
|
115
104
|
} catch (recoverErr) {
|
|
116
105
|
await degrade({
|
|
117
106
|
code: recoverErr?.code ?? 'E_RESURRECT_FAILED',
|
|
118
|
-
message: recoverErr?.message ?? '',
|
|
119
107
|
reason: 'resurrect_failed',
|
|
120
108
|
});
|
|
121
109
|
return;
|
|
122
110
|
}
|
|
123
111
|
} else {
|
|
124
|
-
await degrade({ code: err?.code
|
|
112
|
+
await degrade({ code: err?.code, reason: 'transport' });
|
|
125
113
|
return;
|
|
126
114
|
}
|
|
127
115
|
}
|
|
@@ -129,20 +117,17 @@ export async function runUserPrompt({
|
|
|
129
117
|
if (response.ok !== true) {
|
|
130
118
|
await degrade({
|
|
131
119
|
code: response.error?.code ?? 'E_INTERNAL',
|
|
132
|
-
message: response.error?.message ?? '',
|
|
133
120
|
reason: 'daemon_error',
|
|
134
121
|
});
|
|
135
122
|
return;
|
|
136
123
|
}
|
|
137
124
|
|
|
138
125
|
const result = response.result;
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
emitAdditionalContext(writeOutput, contexts);
|
|
145
|
-
}
|
|
126
|
+
const toolIds = projectToolIds(Array.isArray(result.missing_tools) ? result.missing_tools.map((entry) => entry?.name) : []);
|
|
127
|
+
const advice = result.pass !== true
|
|
128
|
+
? projectParentAdvice(toolIds)
|
|
129
|
+
: '';
|
|
130
|
+
if (advice) emitAdditionalContext(writeOutput, advice);
|
|
146
131
|
|
|
147
132
|
await recordHookEventFn({
|
|
148
133
|
projectRoot,
|
|
@@ -150,15 +135,14 @@ export async function runUserPrompt({
|
|
|
150
135
|
hook: 'UserPromptSubmit',
|
|
151
136
|
status: 'success',
|
|
152
137
|
pass: result.pass === true,
|
|
153
|
-
missingTools:
|
|
154
|
-
|
|
138
|
+
missingTools: toolIds,
|
|
139
|
+
legacyPendingDiagnostic: legacyPending.diagnostic,
|
|
155
140
|
durationMs: Date.now() - startedAt,
|
|
156
141
|
},
|
|
157
142
|
});
|
|
158
143
|
}
|
|
159
144
|
|
|
160
|
-
function emitAdditionalContext(writeOutput,
|
|
161
|
-
const text = contexts.map((c) => String(c).trim()).filter(Boolean).join('\n\n');
|
|
145
|
+
function emitAdditionalContext(writeOutput, text) {
|
|
162
146
|
if (!text) return;
|
|
163
147
|
const output = {
|
|
164
148
|
hookSpecificOutput: {
|
|
@@ -169,6 +153,18 @@ function emitAdditionalContext(writeOutput, contexts) {
|
|
|
169
153
|
writeOutput(JSON.stringify(output));
|
|
170
154
|
}
|
|
171
155
|
|
|
156
|
+
function emitSystemMessage(writeOutput, systemMessage) {
|
|
157
|
+
writeOutput(JSON.stringify({ systemMessage }));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function safeWriteError(writeError, text) {
|
|
161
|
+
try {
|
|
162
|
+
writeError(text);
|
|
163
|
+
} catch {
|
|
164
|
+
// A warning writer must not turn a valid user prompt into a blocking hook failure.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
172
168
|
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`) {
|
|
173
169
|
runUserPrompt().catch((err) => die(err.message, err.exitCode ?? 2));
|
|
174
170
|
}
|