ask-later 0.1.0
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/.claude-plugin/plugin.json +9 -0
- package/LICENSE +21 -0
- package/README.en.md +125 -0
- package/README.md +311 -0
- package/bin/ask-later.mjs +12 -0
- package/docs/field-log.md +182 -0
- package/docs/manual-free/01-why-it-stops.html +151 -0
- package/docs/manual-free/01-why-it-stops.md +192 -0
- package/docs/manual-free/README.md +26 -0
- package/docs/manual-free/index.html +53 -0
- package/docs/manual-free/templates/CLAUDE.snippet.md +20 -0
- package/docs/manual-free/templates/INBOX.md +22 -0
- package/docs/manual-free/templates/PENDING.md +59 -0
- package/docs/manual-free/templates/settings.hooks.json +45 -0
- package/hooks/hooks.json +19 -0
- package/lib/cli.mjs +213 -0
- package/lib/config.mjs +75 -0
- package/lib/hook-entry.mjs +20 -0
- package/lib/hook.mjs +220 -0
- package/lib/inbox.mjs +46 -0
- package/lib/install.mjs +282 -0
- package/lib/messages.mjs +125 -0
- package/lib/notify.mjs +51 -0
- package/lib/pending.mjs +115 -0
- package/lib/queue.mjs +147 -0
- package/lib/sentinel.mjs +65 -0
- package/lib/text.mjs +151 -0
- package/package.json +53 -0
- package/templates/CLAUDE.snippet.en.md +21 -0
- package/templates/CLAUDE.snippet.ja.md +20 -0
- package/templates/INBOX.en.md +22 -0
- package/templates/INBOX.ja.md +22 -0
- package/templates/PENDING.en.md +59 -0
- package/templates/PENDING.ja.md +59 -0
package/lib/hook.mjs
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// フック本体。Claude Code から stdin に JSON を受け取り、stdout に JSON を返す。
|
|
2
|
+
//
|
|
3
|
+
// SessionStart 集計(期限切れの取り下げ・番兵)→ 未処理件数と INBOX 未読を additionalContext に注入
|
|
4
|
+
// UserPromptSubmit 未読があれば毎回注入
|
|
5
|
+
// PostToolUse 未読の集合が前回の注入から変わったときだけ注入(同じ内容は繰り返さない。署名で管理。
|
|
6
|
+
// 署名はセッション(session_id)とサブエージェント(agent_id)ごとに持つ——サブエージェントの
|
|
7
|
+
// ツール呼び出しが先に消費して本体に届かない、を防ぐ)
|
|
8
|
+
// Stop 未読があれば decision:block で停止を差し止め。同じ未読の集合・同じセッションにつき
|
|
9
|
+
// config.stopBlockMax 回(既定 2・上限 7)まで。回数はセッションごとに持つ(state.stopBlocksBySession。
|
|
10
|
+
// 別のセッションの Stop が割り込んでも数え直さない)。人が次の入力をしたら(UserPromptSubmit)
|
|
11
|
+
// そのセッションの回数は 0 に戻る——本体の「連続 8 回」も人の入力で切れるので、人の 1 入力につき最大 N 回。
|
|
12
|
+
// Claude Code 本体は連続 8 回で打ち切るので、stop_hook_active を見なくても無限ループにはならない。集計・通知
|
|
13
|
+
// SessionEnd 番兵を更新する(出力は Claude Code に読まれない。通知は出さない——SessionEnd の予算は既定 1.5 秒で、
|
|
14
|
+
// プラグイン形では timeout で延ばせない。通知は Stop が同じ条件で出している)
|
|
15
|
+
//
|
|
16
|
+
// 【原則】このモジュールは何があっても exit 0 で終わる。stdin が JSON でない・ファイルが無い・壊れている・
|
|
17
|
+
// 書き込めない——どれでも本体の作業を止めない。読める 1 行を hook.log に残すだけ。
|
|
18
|
+
import { appendFileSync, statSync, mkdirSync, renameSync } from "node:fs";
|
|
19
|
+
import { dirname } from "node:path";
|
|
20
|
+
import { readStream, formatLocal } from "./text.mjs";
|
|
21
|
+
import { loadConfig, loadState, saveState, logPath, DEFAULTS } from "./config.mjs";
|
|
22
|
+
import { summarize } from "./queue.mjs";
|
|
23
|
+
import { msg } from "./messages.mjs";
|
|
24
|
+
|
|
25
|
+
export const EVENTS = ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", "SessionEnd"];
|
|
26
|
+
export const MAX_LISTED = 30; // additionalContext は 10,000 字で切られる(公式)。一覧は 30 件で「ほか N 件」
|
|
27
|
+
const SIGNATURE_KEYS = 20; // 署名を覚えておくセッション数(古い順に落とす)
|
|
28
|
+
|
|
29
|
+
// 署名の鍵。session_id と(サブエージェントの中なら)agent_id。無ければ "-"
|
|
30
|
+
export function signatureKey(input) {
|
|
31
|
+
const sid = typeof input?.session_id === "string" && input.session_id ? input.session_id : "-";
|
|
32
|
+
const aid = typeof input?.agent_id === "string" && input.agent_id ? input.agent_id : "main";
|
|
33
|
+
return `${sid}/${aid}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function lastSignature(state, key) {
|
|
37
|
+
const by = state?.inboxSignatureBySession;
|
|
38
|
+
const v = by && typeof by === "object" ? by[key] : null;
|
|
39
|
+
return v && typeof v.sig === "string" ? v.sig : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// そのセッション(鍵)の差し止め回数。署名(未読の集合)が違えば 0
|
|
43
|
+
function stopCount(state, key, sig) {
|
|
44
|
+
const by = state?.stopBlocksBySession;
|
|
45
|
+
const v = by && typeof by === "object" ? by[key] : null;
|
|
46
|
+
return v && v.signature === sig && Number.isInteger(v.count) ? v.count : 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 鍵ごとの記録を上限件数に保つ(古い順に落とす)。by を書き換えて返す
|
|
50
|
+
function keepRecent(by, key, value) {
|
|
51
|
+
delete by[key];
|
|
52
|
+
by[key] = value;
|
|
53
|
+
for (const k of Object.keys(by).slice(0, Math.max(0, Object.keys(by).length - SIGNATURE_KEYS))) delete by[k];
|
|
54
|
+
return by;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatInbox(lines, m, config, garbled = 0) {
|
|
58
|
+
const shown = lines.slice(0, MAX_LISTED);
|
|
59
|
+
const parts = [m.ctxInboxHead(lines.length, config.inbox), ...shown.map((t) => ` > ${t}`)];
|
|
60
|
+
if (lines.length > shown.length) parts.push(m.ctxOpenMore(lines.length - shown.length));
|
|
61
|
+
parts.push(...m.ctxInboxTail(config.pending));
|
|
62
|
+
if (garbled > 0) parts.push(m.ctxGarbled(config.inbox));
|
|
63
|
+
return parts.join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 純関数: イベント・入力・集計結果・状態から、出力と「保存すべき署名」「差し止め回数」「回数の数え直し」を決める
|
|
67
|
+
export function decide(event, input, snap, state, config) {
|
|
68
|
+
const m = msg(config.lang);
|
|
69
|
+
const unread = snap.inboxLines ?? [];
|
|
70
|
+
const sig = snap.signature ?? "";
|
|
71
|
+
const key = signatureKey(input);
|
|
72
|
+
const last = lastSignature(state, key);
|
|
73
|
+
const garbled = snap.inboxGarbled ?? 0;
|
|
74
|
+
let output = null;
|
|
75
|
+
let saveSignature = null;
|
|
76
|
+
let stopBlocks = null;
|
|
77
|
+
let resetStopBlocks = false;
|
|
78
|
+
|
|
79
|
+
switch (event) {
|
|
80
|
+
case "SessionStart": {
|
|
81
|
+
const ctx = [];
|
|
82
|
+
if (snap.openCount > 0) {
|
|
83
|
+
ctx.push(m.ctxOpen(snap.openCount, config.pending));
|
|
84
|
+
const shown = snap.openItems.slice(0, MAX_LISTED);
|
|
85
|
+
for (const it of shown) ctx.push(` - ${it.id} ${it.title}`);
|
|
86
|
+
if (snap.openItems.length > shown.length) ctx.push(m.ctxOpenMore(snap.openItems.length - shown.length));
|
|
87
|
+
ctx.push(m.ctxOpenTail);
|
|
88
|
+
}
|
|
89
|
+
if (snap.withdrawn?.length) ctx.push(m.ctxWithdrawn(snap.withdrawn));
|
|
90
|
+
if (unread.length) ctx.push(formatInbox(unread, m, config, garbled));
|
|
91
|
+
if (snap.malformed?.length) ctx.push(m.ctxMalformed(snap.malformed, config.pending));
|
|
92
|
+
if (!snap.pendingExists) ctx.push(m.ctxNoPending(config.pending));
|
|
93
|
+
if (ctx.length) output = { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: ctx.join("\n") } };
|
|
94
|
+
saveSignature = sig;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case "UserPromptSubmit": {
|
|
98
|
+
// 人の入力で、このセッションの差し止め回数は数え直す(本体の「連続 8 回」も人の入力で切れる)
|
|
99
|
+
resetStopBlocks = Boolean(state?.stopBlocksBySession?.[key]);
|
|
100
|
+
if (unread.length) {
|
|
101
|
+
output = { hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: formatInbox(unread, m, config, garbled) } };
|
|
102
|
+
saveSignature = sig;
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "PostToolUse": {
|
|
107
|
+
if (unread.length && sig !== last) {
|
|
108
|
+
output = { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: formatInbox(unread, m, config, garbled) } };
|
|
109
|
+
saveSignature = sig;
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case "Stop": {
|
|
114
|
+
if (!unread.length) break;
|
|
115
|
+
const max = Number.isInteger(config.stopBlockMax) ? config.stopBlockMax : DEFAULTS.stopBlockMax;
|
|
116
|
+
const count = stopCount(state, key, sig);
|
|
117
|
+
if (count < max) {
|
|
118
|
+
const k = count + 1;
|
|
119
|
+
const head = `${m.stopReason(unread.length, config.inbox)}${k > 1 ? m.stopReasonAgain(k, max) : ""}`;
|
|
120
|
+
output = { decision: "block", reason: `${head}\n${formatInbox(unread, m, config, garbled)}` };
|
|
121
|
+
stopBlocks = { key, signature: sig, count: k };
|
|
122
|
+
saveSignature = sig;
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
default:
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
return { output, saveSignature, stopBlocks, resetStopBlocks };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function appendLog(root, line) {
|
|
133
|
+
try {
|
|
134
|
+
const p = logPath(root);
|
|
135
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
136
|
+
try {
|
|
137
|
+
if (statSync(p).size > 1_000_000) renameSync(p, `${p}.1`); // 1 MB で世代を 1 つ回す
|
|
138
|
+
} catch {
|
|
139
|
+
/* 無い */
|
|
140
|
+
}
|
|
141
|
+
appendFileSync(p, line + "\n", "utf8");
|
|
142
|
+
} catch {
|
|
143
|
+
/* ログすら書けなくても止めない */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// I/O を含む本体。返り値 { exitCode: 0, output: string|null }。呼び出し側が output を stdout に書く
|
|
148
|
+
// version hook.log に残す版(init の写しなら VERSION ファイル、プラグイン形なら package.json)。無ければ書かない
|
|
149
|
+
export async function main(event, { root, stdin = process.stdin, stdinText = null, now = new Date(), version = null } = {}) {
|
|
150
|
+
const res = { exitCode: 0, output: null };
|
|
151
|
+
const stamp = () => formatLocal(now, { seconds: true });
|
|
152
|
+
const ver = version ? ` v${version}` : "";
|
|
153
|
+
let m = msg(DEFAULTS.lang);
|
|
154
|
+
try {
|
|
155
|
+
if (!root) root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
156
|
+
const raw = stdinText != null ? String(stdinText) : await readStream(stdin);
|
|
157
|
+
let input = {};
|
|
158
|
+
if (raw && raw.trim().startsWith("{")) {
|
|
159
|
+
try {
|
|
160
|
+
input = JSON.parse(raw);
|
|
161
|
+
} catch {
|
|
162
|
+
input = {};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const config = loadConfig(root);
|
|
166
|
+
m = msg(config.lang);
|
|
167
|
+
if (!EVENTS.includes(event)) {
|
|
168
|
+
appendLog(root, `${stamp()} ${String(event).padEnd(16)} ${m.logUnknown}${ver}`);
|
|
169
|
+
return res;
|
|
170
|
+
}
|
|
171
|
+
const heavy = event === "SessionStart" || event === "Stop" || event === "SessionEnd";
|
|
172
|
+
const snap = await summarize(root, config, { now, expire: heavy, notify: event === "Stop", write: heavy });
|
|
173
|
+
const state = loadState(root);
|
|
174
|
+
const { output, saveSignature, stopBlocks, resetStopBlocks } = decide(event, input, snap, state, config);
|
|
175
|
+
if (saveSignature != null || stopBlocks || resetStopBlocks) {
|
|
176
|
+
const key = signatureKey(input);
|
|
177
|
+
if (saveSignature != null) {
|
|
178
|
+
const by = state.inboxSignatureBySession && typeof state.inboxSignatureBySession === "object" ? state.inboxSignatureBySession : {};
|
|
179
|
+
state.inboxSignatureBySession = keepRecent(by, key, { sig: saveSignature, at: now.toISOString(), event });
|
|
180
|
+
delete state.inboxSignature; // 旧形式(プロジェクト単位)は使わない
|
|
181
|
+
delete state.inboxSignatureAt;
|
|
182
|
+
delete state.inboxSignatureEvent;
|
|
183
|
+
}
|
|
184
|
+
const blocks = state.stopBlocksBySession && typeof state.stopBlocksBySession === "object" ? state.stopBlocksBySession : {};
|
|
185
|
+
if (stopBlocks) state.stopBlocksBySession = keepRecent(blocks, key, { signature: stopBlocks.signature, count: stopBlocks.count, at: now.toISOString() });
|
|
186
|
+
if (resetStopBlocks) {
|
|
187
|
+
delete blocks[key];
|
|
188
|
+
state.stopBlocksBySession = blocks;
|
|
189
|
+
}
|
|
190
|
+
delete state.stopBlocks; // 旧形式(プロジェクトで 1 組)は使わない(設計監査 2 巡目 #2)
|
|
191
|
+
try {
|
|
192
|
+
saveState(root, state);
|
|
193
|
+
} catch {
|
|
194
|
+
/* 次回は同じ内容をもう一度注入するだけ */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (output) res.output = JSON.stringify(output) + "\n";
|
|
198
|
+
// ログの鍵は [session_id の先頭 8 字] か、サブエージェントの中なら [sid/agent_id の先頭 8 字](本体と区別できるように)
|
|
199
|
+
const sid = typeof input.session_id === "string" ? input.session_id.slice(0, 8) : "--------";
|
|
200
|
+
const aid = typeof input.agent_id === "string" && input.agent_id ? `/${input.agent_id.slice(0, 8)}` : "";
|
|
201
|
+
const what = output
|
|
202
|
+
? output.decision === "block"
|
|
203
|
+
? m.logBlock(snap.inboxUnread, stopBlocks?.count ?? 1, config.stopBlockMax)
|
|
204
|
+
: m.logInject(snap.openCount, snap.inboxUnread)
|
|
205
|
+
: m.logNone(snap.openCount, snap.inboxUnread);
|
|
206
|
+
const errs = [
|
|
207
|
+
snap.pendingError && `${config.pending}: ${snap.pendingError}`,
|
|
208
|
+
snap.inboxError && `${config.inbox}: ${snap.inboxError}`,
|
|
209
|
+
...(snap.writeErrors ?? []),
|
|
210
|
+
snap.sentinelError && `sentinel: ${snap.sentinelError}`,
|
|
211
|
+
snap.inboxGarbled > 0 && m.logGarbled(snap.inboxGarbled),
|
|
212
|
+
config.error,
|
|
213
|
+
].filter(Boolean);
|
|
214
|
+
const tail = [snap.withdrawn?.length ? m.logWithdrawn(snap.withdrawn) : "", snap.notified ? m.logNotified : ""].filter(Boolean).map((s) => ` ${s}`).join("");
|
|
215
|
+
appendLog(root, `${stamp()} ${event.padEnd(16)} [${sid}${aid}] ${what}${tail}${errs.length ? ` ! ${errs.join(" / ")}` : ""}${ver}`);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
appendLog(root ?? process.cwd(), `${stamp()} ${String(event).padEnd(16)} ${m.logFail(e?.message ?? e)}${ver}`);
|
|
218
|
+
}
|
|
219
|
+
return res;
|
|
220
|
+
}
|
package/lib/inbox.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// INBOX.md の未読行。
|
|
2
|
+
// 区切り線 "---" より後ろの、空行・見出し・引用・既読マーク・水平線でない行を「記載」とみなす。
|
|
3
|
+
// 加えて、最初の見出しより前に書かれた行も「記載」とみなす(ファイルの先頭に書いてしまう実例があった。
|
|
4
|
+
// テンプレートの位置に縛らない)。見出しと区切り線の間はテンプレート(書き方の例)で、読まない。
|
|
5
|
+
// 行頭が ✔(✓ ✅ も可)のものはエージェントが処理済みにしたもの。
|
|
6
|
+
// 見出しは Markdown と同じく「# の後に空白」があるときだけ("#1 done" のような行は記載として読む)。
|
|
7
|
+
import { splitLines, sha256 } from "./text.mjs";
|
|
8
|
+
|
|
9
|
+
export const DONE_MARKS = ["✔", "✓", "✅"];
|
|
10
|
+
const HR = /^(-{3,}|\*{3,}|_{3,})\s*$/;
|
|
11
|
+
const HEADING = /^#+(\s|$)/;
|
|
12
|
+
|
|
13
|
+
export function isDone(trimmedLine) {
|
|
14
|
+
return DONE_MARKS.some((m) => trimmedLine.startsWith(m));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function unreadLines(text) {
|
|
18
|
+
if (text == null) return [];
|
|
19
|
+
const out = [];
|
|
20
|
+
let afterSep = false;
|
|
21
|
+
let seenHeading = false;
|
|
22
|
+
for (const raw of splitLines(text)) {
|
|
23
|
+
const t = raw.trim();
|
|
24
|
+
if (!afterSep) {
|
|
25
|
+
if (HR.test(t)) {
|
|
26
|
+
afterSep = true;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (HEADING.test(t)) {
|
|
30
|
+
seenHeading = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (seenHeading) continue;
|
|
34
|
+
}
|
|
35
|
+
if (t === "") continue;
|
|
36
|
+
if (HEADING.test(t) || t.startsWith(">") || HR.test(t)) continue;
|
|
37
|
+
if (isDone(t)) out.push({ done: true, text: t });
|
|
38
|
+
else out.push({ done: false, text: t });
|
|
39
|
+
}
|
|
40
|
+
return out.filter((l) => !l.done).map((l) => l.text);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 未読集合の署名(同じ集合には繰り返し注入しない)。空なら ""
|
|
44
|
+
export function signatureOf(lines) {
|
|
45
|
+
return lines.length ? sha256(lines.join("\n")) : "";
|
|
46
|
+
}
|
package/lib/install.mjs
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// init / uninit。.claude/settings.json の hooks にマージし(既存を壊さない)、uninit で元に戻す。
|
|
2
|
+
//
|
|
3
|
+
// フックのエントリは既定で exec 形(args 形式):
|
|
4
|
+
// { "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/ask-later/hook.mjs", "<Event>"] }
|
|
5
|
+
// 公式仕様は「パスのプレースホルダを参照するフックは exec 形にせよ」で、${CLAUDE_PROJECT_DIR} は args の各要素の中でも
|
|
6
|
+
// 置換される。シェルを通さないので、Git Bash の無い Windows(PowerShell がシェルになる)や旧版でも引用に依存しない。
|
|
7
|
+
// --shell で旧来の shell 形 `node "${CLAUDE_PROJECT_DIR}/.claude/ask-later/hook.mjs" <Event>` も書ける。
|
|
8
|
+
// 本体はプロジェクトの .claude/ask-later/ に写すので、npx のキャッシュや node_modules が消えても動き、
|
|
9
|
+
// settings.json をそのままリポジトリに入れて共有できる。
|
|
10
|
+
// 自分のエントリは command / args に "ask-later/hook.mjs" を含むことで識別する(settings に余計なキーを足さない)。
|
|
11
|
+
import { existsSync, mkdirSync, copyFileSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join, dirname, basename } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { readText, writeTextAtomic, isDirectory, detectJsonFormat, formatJson, detectEol } from "./text.mjs";
|
|
15
|
+
import { STATE_DIR, DEFAULTS, configPath } from "./config.mjs";
|
|
16
|
+
import { removeSentinels } from "./sentinel.mjs";
|
|
17
|
+
import { LANGS } from "./messages.mjs";
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
export const TEMPLATE_DIR = join(here, "..", "templates");
|
|
21
|
+
export const HOOK_DIR = join(".claude", "ask-later");
|
|
22
|
+
export const HOOK_MARK = "ask-later/hook.mjs";
|
|
23
|
+
export const HOOK_PATH_PLACEHOLDER = "${CLAUDE_PROJECT_DIR}/.claude/ask-later/hook.mjs";
|
|
24
|
+
// タイムアウト(秒)。公式の既定は command フックが 600 秒・UserPromptSubmit が 30 秒で、集計には十分だが、
|
|
25
|
+
// SessionEnd だけは既定 1.5 秒の共有予算(settings.json の per-hook timeout で最大 60 秒まで延びる。プラグイン形では延びない)。
|
|
26
|
+
// 既定に依存しないよう全部に明示する。
|
|
27
|
+
export const HOOK_EVENTS = [
|
|
28
|
+
["SessionStart", 20],
|
|
29
|
+
["UserPromptSubmit", 15],
|
|
30
|
+
["PostToolUse", 15],
|
|
31
|
+
["Stop", 20],
|
|
32
|
+
["SessionEnd", 20],
|
|
33
|
+
];
|
|
34
|
+
// フックに要るファイルだけ写す(cli / install は要らない)
|
|
35
|
+
export const HOOK_LIB_FILES = ["text.mjs", "messages.mjs", "config.mjs", "pending.mjs", "inbox.mjs", "sentinel.mjs", "notify.mjs", "queue.mjs", "hook.mjs"];
|
|
36
|
+
export const GITIGNORE_LINES = [".ask-later/", "\\!pending_*.txt"];
|
|
37
|
+
export const CLAUDE_MD_MARK = { ja: "## 非同期キュー(ask-later)", en: "## Async queue (ask-later)" };
|
|
38
|
+
|
|
39
|
+
export class UserError extends Error {}
|
|
40
|
+
|
|
41
|
+
export function packageVersion() {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version ?? null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 写しの版(.claude/ask-later/VERSION)。無ければ null(VERSION を書く前の写し)
|
|
50
|
+
export function hookCopyVersion(root) {
|
|
51
|
+
try {
|
|
52
|
+
return readFileSync(join(root, HOOK_DIR, "VERSION"), "utf8").trim() || null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function hookEntry(event, timeout, { form = "exec" } = {}) {
|
|
59
|
+
if (form === "shell") {
|
|
60
|
+
return { type: "command", command: `node "${HOOK_PATH_PLACEHOLDER}" ${event}`, timeout };
|
|
61
|
+
}
|
|
62
|
+
return { type: "command", command: "node", args: [HOOK_PATH_PLACEHOLDER, event], timeout };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isOurs(def) {
|
|
66
|
+
if (!def || typeof def !== "object") return false;
|
|
67
|
+
const norm = (s) => String(s).replace(/\\/g, "/");
|
|
68
|
+
if (typeof def.command === "string" && norm(def.command).includes(HOOK_MARK)) return true;
|
|
69
|
+
if (Array.isArray(def.args) && def.args.some((a) => norm(a).includes(HOOK_MARK))) return true;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 純関数(引数のオブジェクトを書き換えて返す)。自分のエントリを取り除いてから足す(再 init は更新になる)
|
|
74
|
+
export function mergeHooks(settings, opts = {}) {
|
|
75
|
+
const s = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {};
|
|
76
|
+
if (!s.hooks || typeof s.hooks !== "object" || Array.isArray(s.hooks)) s.hooks = {};
|
|
77
|
+
for (const [event, timeout] of HOOK_EVENTS) {
|
|
78
|
+
const groups = Array.isArray(s.hooks[event]) ? s.hooks[event] : [];
|
|
79
|
+
const kept = stripOurs(groups);
|
|
80
|
+
kept.push({ hooks: [hookEntry(event, timeout, opts)] });
|
|
81
|
+
s.hooks[event] = kept;
|
|
82
|
+
}
|
|
83
|
+
return s;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function stripOurs(groups) {
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const g of groups) {
|
|
89
|
+
if (!g || typeof g !== "object" || !Array.isArray(g.hooks)) {
|
|
90
|
+
out.push(g);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const rest = g.hooks.filter((h) => !isOurs(h));
|
|
94
|
+
if (rest.length === g.hooks.length) out.push(g);
|
|
95
|
+
else if (rest.length) out.push({ ...g, hooks: rest });
|
|
96
|
+
// 自分のものだけだったグループは落とす
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function removeHooks(settings) {
|
|
102
|
+
const s = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {};
|
|
103
|
+
if (!s.hooks || typeof s.hooks !== "object") return s;
|
|
104
|
+
for (const event of Object.keys(s.hooks)) {
|
|
105
|
+
if (!Array.isArray(s.hooks[event])) continue;
|
|
106
|
+
const kept = stripOurs(s.hooks[event]);
|
|
107
|
+
if (kept.length) s.hooks[event] = kept;
|
|
108
|
+
else delete s.hooks[event];
|
|
109
|
+
}
|
|
110
|
+
if (!Object.keys(s.hooks).length) delete s.hooks;
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readSettings(path) {
|
|
115
|
+
const raw = readText(path);
|
|
116
|
+
if (raw == null) return { settings: null, raw: null };
|
|
117
|
+
let settings;
|
|
118
|
+
try {
|
|
119
|
+
settings = JSON.parse(raw);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
throw new UserError(`${path} が JSON として読めません(${e.message})。直してから再実行してください。何も書き換えていません`);
|
|
122
|
+
}
|
|
123
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
124
|
+
throw new UserError(`${path} の中身がオブジェクトではありません。何も書き換えていません`);
|
|
125
|
+
}
|
|
126
|
+
return { settings, raw };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 元ファイルの体裁(字下げ幅・改行コード・末尾改行)を写して書く。元が無ければ 2 スペース・LF・末尾改行あり
|
|
130
|
+
function writeSettings(path, settings, raw) {
|
|
131
|
+
writeTextAtomic(path, formatJson(settings, detectJsonFormat(raw)));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function templatePath(kind, lang) {
|
|
135
|
+
return join(TEMPLATE_DIR, `${kind}.${lang}.md`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function snippet(lang = "ja") {
|
|
139
|
+
return readFileSync(templatePath("CLAUDE.snippet", LANGS.includes(lang) ? lang : "ja"), "utf8");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 本体をプロジェクトに写す。VERSION に写したときのパッケージの版を残す(status が古い写しを言えるように)
|
|
143
|
+
export function copyHookFiles(root) {
|
|
144
|
+
const dst = join(root, HOOK_DIR);
|
|
145
|
+
mkdirSync(join(dst, "lib"), { recursive: true });
|
|
146
|
+
copyFileSync(join(here, "hook-entry.mjs"), join(dst, "hook.mjs"));
|
|
147
|
+
for (const f of HOOK_LIB_FILES) copyFileSync(join(here, f), join(dst, "lib", f));
|
|
148
|
+
writeFileSync(join(dst, "VERSION"), `${packageVersion() ?? "?"}\n`, "utf8");
|
|
149
|
+
return dst;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// CLAUDE.md に規律の断片を足す(見出しが既にあれば触らない)。返り値: "created" | "appended" | "kept"
|
|
153
|
+
export function appendClaudeMd(root, lang) {
|
|
154
|
+
const p = join(root, "CLAUDE.md");
|
|
155
|
+
const body = snippet(lang);
|
|
156
|
+
const cur = readText(p);
|
|
157
|
+
if (cur == null) {
|
|
158
|
+
writeTextAtomic(p, body);
|
|
159
|
+
return "created";
|
|
160
|
+
}
|
|
161
|
+
if (Object.values(CLAUDE_MD_MARK).some((mark) => cur.includes(mark))) return "kept";
|
|
162
|
+
const eol = detectEol(cur);
|
|
163
|
+
const sep = cur === "" ? "" : cur.endsWith("\n") ? eol : eol + eol;
|
|
164
|
+
writeTextAtomic(p, cur + sep + body.replace(/\r?\n/g, eol));
|
|
165
|
+
return "appended";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// .gitignore に 2 行を足す(無い行だけ)。返り値: 足した行
|
|
169
|
+
export function appendGitignore(root) {
|
|
170
|
+
const p = join(root, ".gitignore");
|
|
171
|
+
const cur = readText(p) ?? "";
|
|
172
|
+
const have = new Set(cur.split(/\r?\n/).map((l) => l.trim()));
|
|
173
|
+
const add = GITIGNORE_LINES.filter((l) => !have.has(l));
|
|
174
|
+
if (!add.length) return [];
|
|
175
|
+
const eol = detectEol(cur);
|
|
176
|
+
const sep = cur === "" ? "" : cur.endsWith("\n") ? "" : eol;
|
|
177
|
+
writeTextAtomic(p, cur + sep + add.join(eol) + eol);
|
|
178
|
+
return add;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function init(root, { lang = "ja", force = false, local = false, hooks = true, files = true, form = "exec", claudeMd = true, gitignore = true, log = () => {} } = {}) {
|
|
182
|
+
if (!isDirectory(root)) throw new UserError(`ディレクトリがありません: ${root}`);
|
|
183
|
+
if (!LANGS.includes(lang)) throw new UserError(`--lang は ${LANGS.join(" | ")} のどれかです: ${lang}`);
|
|
184
|
+
const r = { created: [], updated: [], skipped: [], settingsPath: null, hookDir: null, claudeMd: null, gitignore: [] };
|
|
185
|
+
|
|
186
|
+
if (files) {
|
|
187
|
+
for (const [kind, name] of [
|
|
188
|
+
["PENDING", DEFAULTS.pending],
|
|
189
|
+
["INBOX", DEFAULTS.inbox],
|
|
190
|
+
]) {
|
|
191
|
+
const dst = join(root, name);
|
|
192
|
+
if (existsSync(dst) && !force) {
|
|
193
|
+
r.skipped.push(name);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const existed = existsSync(dst);
|
|
197
|
+
writeTextAtomic(dst, readFileSync(templatePath(kind, lang), "utf8"));
|
|
198
|
+
(existed ? r.updated : r.created).push(name);
|
|
199
|
+
log(`${name} を書きました`);
|
|
200
|
+
}
|
|
201
|
+
const cp = configPath(root);
|
|
202
|
+
if (!existsSync(cp)) {
|
|
203
|
+
writeTextAtomic(cp, JSON.stringify({ ...DEFAULTS, lang }, null, 2) + "\n");
|
|
204
|
+
r.created.push(`${STATE_DIR}/config.json`);
|
|
205
|
+
} else r.skipped.push(`${STATE_DIR}/config.json`);
|
|
206
|
+
if (claudeMd) {
|
|
207
|
+
r.claudeMd = appendClaudeMd(root, lang);
|
|
208
|
+
if (r.claudeMd === "created") r.created.push("CLAUDE.md");
|
|
209
|
+
else if (r.claudeMd === "appended") r.updated.push("CLAUDE.md");
|
|
210
|
+
else r.skipped.push("CLAUDE.md");
|
|
211
|
+
}
|
|
212
|
+
if (gitignore) {
|
|
213
|
+
const existed = existsSync(join(root, ".gitignore"));
|
|
214
|
+
r.gitignore = appendGitignore(root);
|
|
215
|
+
if (r.gitignore.length) (existed ? r.updated : r.created).push(".gitignore");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (hooks) {
|
|
220
|
+
r.hookDir = copyHookFiles(root);
|
|
221
|
+
const settingsPath = join(root, ".claude", local ? "settings.local.json" : "settings.json");
|
|
222
|
+
const { settings, raw } = readSettings(settingsPath);
|
|
223
|
+
const merged = mergeHooks(settings ?? {}, { form });
|
|
224
|
+
writeSettings(settingsPath, merged, raw);
|
|
225
|
+
// init が作った settings ファイルは、uninit で「{} だけの空ファイル」を残さず消す(設計監査 2 巡目 #9)。
|
|
226
|
+
// 印は写しの中(uninit が消す前に読む。再 init では残る)。init 前からあったファイルには印を付けない
|
|
227
|
+
if (!settings) writeFileSync(join(r.hookDir, "CREATED_SETTINGS"), `${basename(settingsPath)}\n`, "utf8");
|
|
228
|
+
r.settingsPath = settingsPath;
|
|
229
|
+
(settings ? r.updated : r.created).push(settingsPath.slice(root.length + 1).replace(/\\/g, "/"));
|
|
230
|
+
log(`${settingsPath} に hooks を${settings ? "マージ" : "作成"}しました`);
|
|
231
|
+
}
|
|
232
|
+
return r;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function uninit(root, { purge = false, log = () => {} } = {}) {
|
|
236
|
+
if (!isDirectory(root)) throw new UserError(`ディレクトリがありません: ${root}`);
|
|
237
|
+
const rel = (p) => p.slice(root.length + 1).replace(/\\/g, "/");
|
|
238
|
+
const r = { settings: [], removed: [] };
|
|
239
|
+
const createdByInit = (readText(join(root, HOOK_DIR, "CREATED_SETTINGS")) ?? "").trim();
|
|
240
|
+
for (const name of ["settings.json", "settings.local.json"]) {
|
|
241
|
+
const p = join(root, ".claude", name);
|
|
242
|
+
const { settings, raw } = readSettings(p);
|
|
243
|
+
if (!settings) continue;
|
|
244
|
+
const before = JSON.stringify(settings);
|
|
245
|
+
removeHooks(settings);
|
|
246
|
+
if (JSON.stringify(settings) !== before) {
|
|
247
|
+
if (createdByInit === name && Object.keys(settings).length === 0) {
|
|
248
|
+
// init が作り、他に何も書かれていないファイル → 元どおり「無い」に戻す
|
|
249
|
+
rmSync(p, { force: true });
|
|
250
|
+
r.removed.push(rel(p));
|
|
251
|
+
log(`${rel(p)} は init が作ったもので他に中身が無いので消しました / removed (created by init, nothing else in it)`);
|
|
252
|
+
} else {
|
|
253
|
+
writeSettings(p, settings, raw);
|
|
254
|
+
r.settings.push(rel(p));
|
|
255
|
+
log(`${rel(p)} から hooks を外しました / removed hooks from ${rel(p)}`);
|
|
256
|
+
}
|
|
257
|
+
} else if (raw != null) {
|
|
258
|
+
log(`${rel(p)}: ask-later の hooks はありません / no ask-later hooks`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const hookDir = join(root, HOOK_DIR);
|
|
262
|
+
if (existsSync(hookDir)) {
|
|
263
|
+
rmSync(hookDir, { recursive: true, force: true });
|
|
264
|
+
r.removed.push(HOOK_DIR.replace(/\\/g, "/"));
|
|
265
|
+
}
|
|
266
|
+
r.removed.push(...removeSentinels(root, DEFAULTS.sentinelPrefix));
|
|
267
|
+
const stateDir = join(root, STATE_DIR);
|
|
268
|
+
if (existsSync(stateDir)) {
|
|
269
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
270
|
+
r.removed.push(STATE_DIR);
|
|
271
|
+
}
|
|
272
|
+
if (purge) {
|
|
273
|
+
for (const name of [DEFAULTS.pending, DEFAULTS.inbox]) {
|
|
274
|
+
const p = join(root, name);
|
|
275
|
+
if (existsSync(p)) {
|
|
276
|
+
rmSync(p, { force: true });
|
|
277
|
+
r.removed.push(name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return r;
|
|
282
|
+
}
|