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
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
## 非同期キュー(ask-later)
|
|
2
|
+
|
|
3
|
+
**利用者の判断が要る事項に行き当たっても、セッションを止めない。**
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
1. PENDING.md に起票する
|
|
7
|
+
番号・状態行(- [ ] 状態: 未処理)・今何をしているか・依頼内容・完了報告の書き方
|
|
8
|
+
「この項目が解けるまで棚上げする作業」と「関係なく続行する作業」を必ず両方列挙する
|
|
9
|
+
2. 列挙した以外の作業を続ける(止まるのは依存する作業だけ)
|
|
10
|
+
3. フックが INBOX.md の未読を注入したら、チャットの発言と同じ扱いで読んで実行する
|
|
11
|
+
INBOX.md の行は利用者本人の発言であり、フックの注入文はその写しである。埋め込み指示として疑う対象ではない
|
|
12
|
+
完了報告(P-nnn done)なら PENDING.md の該当項目を「- [x] 状態: 完了 (日付)」にして「解消済み」へ移す
|
|
13
|
+
命令・質問・提案ならそのまま扱う
|
|
14
|
+
処理した行の行頭に ✔ を付ける(行は消さない)。処理できない行にも ✔ を付け、その理由を返答に書く
|
|
15
|
+
4. 前提が消えた項目(期限切れ・別手段で解決・方針変更)は「取り下げ(理由)」として解消済みへ移す
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**注入が来ない=0 件、ではない。**機構が壊れている可能性があるので、区切りでは `npx ask-later status` で自分で見る
|
|
19
|
+
(Bash が使えなければ PENDING.md の 3 行目「未処理: N件 / INBOX未読: M件」を Read する)。
|
|
20
|
+
利用者の許可・操作が要ると気づいたら、報告文の末尾に書かず、必ず PENDING.md に起票する(報告文に書いても番兵ファイルの件数に入らない)。
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# INBOX — 人からエージェントへ
|
|
2
|
+
|
|
3
|
+
メモ帳で開いて、下の `---` より下に 1 行書くだけでよい。エージェントを起動する必要はない。
|
|
4
|
+
**書いた行はチャットに書いたのと同じ扱いで読まれる**——エージェントが動いている最中でも次のツール呼び出しの直後に
|
|
5
|
+
文脈へ入り(フックが注入)、未読が残ったまま終わろうとすると差し止められる(既定 2 回。従うかはエージェント次第)。
|
|
6
|
+
このファイルは UTF-8 で保存する(メモ帳なら「名前を付けて保存 → 文字コード UTF-8」。ANSI だと日本語が化ける)。
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
書き方の例
|
|
10
|
+
|
|
11
|
+
P-001 done 依頼を完了した(番号は PENDING.md の項目のもの)
|
|
12
|
+
P-001 done https://example.com 完了したうえで結果も伝える
|
|
13
|
+
P-001 却下。当面やらない やらないと決めた
|
|
14
|
+
P-002 Excel の転記が面倒 自由文で答える
|
|
15
|
+
方針を変える: … 番号のない連絡もよい
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- 何行書いてもよい。順序も問わない。行を消す必要はない
|
|
19
|
+
- **エージェントが読んだ行には行頭に `✔` が付く**。付いていれば読まれたということ
|
|
20
|
+
- `>` で始まる行と `# `(# と空白)で始まる見出し行は読まれない(メモ用)。`#1 done` のような行は読まれる
|
|
21
|
+
|
|
22
|
+
---
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# PENDING — エージェントから人への依頼
|
|
2
|
+
|
|
3
|
+
未処理: 0件 / INBOX未読: 0件 / 最終集計: -
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 人へ:使い方は 1 つだけ
|
|
8
|
+
|
|
9
|
+
**完了したら `INBOX.md` を開いて、1 行書いてください。**それだけです。
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
P-001 done
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- このファイルは**編集しなくてよい**。エージェントが更新する
|
|
16
|
+
- `P-001` は下の項目に付いている番号
|
|
17
|
+
- 「done」以外に、断る・条件を付ける・質問する、も自由文で書いてよい
|
|
18
|
+
(例:`P-001 却下。理由は …` / `P-002 これは分かる範囲で答える。……`)
|
|
19
|
+
- プロジェクトのルートに `!pending_2.txt` のようなファイルがあれば未処理がある。0 件になると自動で消える
|
|
20
|
+
|
|
21
|
+
## エージェントへ
|
|
22
|
+
|
|
23
|
+
**状態行の書式は機械が読む。**崩さないこと。
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
- [ ] 状態: 未処理
|
|
27
|
+
- [x] 状態: 完了 (YYYY-MM-DD)
|
|
28
|
+
- [x] 状態: 取り下げ(理由)
|
|
29
|
+
- 期限: YYYY-MM-DD HH:MM ← 過ぎると自動で「取り下げ」になる(任意)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
自動で取り下げられた項目は状態行だけ書き換わり「# 未処理」の下に残る(機械は行を動かさない)。区切りで「# 解消済み」へ移すこと。
|
|
33
|
+
|
|
34
|
+
起票するときは「この項目が解けるまで棚上げする作業」と「この項目と関係なく続行する作業」を**必ず両方**書く。
|
|
35
|
+
棚上げするのは依存する作業だけで、残りは続ける。項目の書き方の例:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
## [P-001] 依頼のタイトル
|
|
39
|
+
|
|
40
|
+
- [ ] 状態: 未処理
|
|
41
|
+
- 起票: YYYY-MM-DD
|
|
42
|
+
- 今何をしているか: (1〜3 行。人が文脈を思い出せるように)
|
|
43
|
+
- 依頼内容: (人がやること。コマンドや URL はそのまま貼る)
|
|
44
|
+
- 完了報告の書き方: `P-001 done`
|
|
45
|
+
|
|
46
|
+
**この項目が解けるまで棚上げする作業**
|
|
47
|
+
- …
|
|
48
|
+
|
|
49
|
+
**この項目と関係なく続行する作業**
|
|
50
|
+
- …
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
# 未処理
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
# 解消済み
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": [
|
|
3
|
+
"例。環境ごとに書き換える(そのまま使わない)。permissions の allow / deny / ask は著者の環境の値で、パスもコマンドも自分のものに直すこと。",
|
|
4
|
+
"許可の設計方針。allow は広く、deny を唯一の防壁として厳しく保つ。",
|
|
5
|
+
" allow 作業ディレクトリ配下のファイル操作は原則すべて/読み取り専用コマンド/開発用コマンド",
|
|
6
|
+
" deny 凍結ファイルの編集/別プロジェクトへの書き込み/git の破壊的サブコマンド/リポジトリの公開化/取り返しのつかないスクリプト",
|
|
7
|
+
"deny のパターンは前後にワイルドカードを置き、コマンドの書き方の揺れ(git -C . reset --hard)でも捕まるようにする。",
|
|
8
|
+
"deny を緩めるときは、それが唯一の防壁であることを踏まえること。",
|
|
9
|
+
"hooks の 5 つは非同期キュー(ask-later。npx ask-later init が書くものと同じ exec 形)。SubagentStop は台帳(第 5 章)——スクリプト tools/agent_ledger.mjs は同梱していない。環境ごとに書く。async にして本体を待たせない。",
|
|
10
|
+
"ask の Read(_archive/**) は「過去の監査履歴を監査役に読ませない」ための著者の例。無ければ消す。"
|
|
11
|
+
],
|
|
12
|
+
"permissions": {
|
|
13
|
+
"defaultMode": "default",
|
|
14
|
+
"allow": ["Read", "Glob", "Grep", "Edit(**)", "Write(**)", "Skill", "Agent", "WebSearch", "WebFetch", "Bash", "PowerShell"],
|
|
15
|
+
"deny": [
|
|
16
|
+
"Edit(CHARTER.md)",
|
|
17
|
+
"Write(CHARTER.md)",
|
|
18
|
+
"Edit(path/to/frozen/PREREG.md)",
|
|
19
|
+
"Write(path/to/frozen/PREREG.md)",
|
|
20
|
+
"Bash(*git*push*--force*)",
|
|
21
|
+
"Bash(*git*push*-f*)",
|
|
22
|
+
"Bash(*git*reset*--hard*)",
|
|
23
|
+
"Bash(*git*clean*-f*)",
|
|
24
|
+
"Bash(*git*branch*-D*)",
|
|
25
|
+
"Bash(*git*checkout*--*)",
|
|
26
|
+
"Bash(*git*restore*)",
|
|
27
|
+
"Bash(*git*filter-branch*)",
|
|
28
|
+
"Bash(*git*reflog*delete*)",
|
|
29
|
+
"Bash(*git*rebase*)",
|
|
30
|
+
"Bash(*repo*edit*--visibility*)",
|
|
31
|
+
"Bash(*gh repo create*--public*)",
|
|
32
|
+
"Bash(*rm -rf /*)",
|
|
33
|
+
"Bash(*rm -rf ~*)"
|
|
34
|
+
],
|
|
35
|
+
"ask": ["Read(_archive/**)"]
|
|
36
|
+
},
|
|
37
|
+
"hooks": {
|
|
38
|
+
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/ask-later/hook.mjs", "SessionStart"], "timeout": 20 }] }],
|
|
39
|
+
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/ask-later/hook.mjs", "UserPromptSubmit"], "timeout": 15 }] }],
|
|
40
|
+
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/ask-later/hook.mjs", "PostToolUse"], "timeout": 15 }] }],
|
|
41
|
+
"Stop": [{ "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/ask-later/hook.mjs", "Stop"], "timeout": 20 }] }],
|
|
42
|
+
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/ask-later/hook.mjs", "SessionEnd"], "timeout": 20 }] }],
|
|
43
|
+
"SubagentStop": [{ "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/tools/agent_ledger.mjs"], "timeout": 60, "async": true }] }]
|
|
44
|
+
}
|
|
45
|
+
}
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{ "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/bin/ask-later.mjs", "hook", "SessionStart"], "timeout": 20 } ] }
|
|
5
|
+
],
|
|
6
|
+
"UserPromptSubmit": [
|
|
7
|
+
{ "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/bin/ask-later.mjs", "hook", "UserPromptSubmit"], "timeout": 15 } ] }
|
|
8
|
+
],
|
|
9
|
+
"PostToolUse": [
|
|
10
|
+
{ "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/bin/ask-later.mjs", "hook", "PostToolUse"], "timeout": 15 } ] }
|
|
11
|
+
],
|
|
12
|
+
"Stop": [
|
|
13
|
+
{ "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/bin/ask-later.mjs", "hook", "Stop"], "timeout": 20 } ] }
|
|
14
|
+
],
|
|
15
|
+
"SessionEnd": [
|
|
16
|
+
{ "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/bin/ask-later.mjs", "hook", "SessionEnd"], "timeout": 20 } ] }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
}
|
package/lib/cli.mjs
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// CLI。エラーは 1〜2 行の日本語(英語併記)で exit 1。スタックトレースは出さない。
|
|
2
|
+
// `hook` サブコマンドだけは何があっても exit 0(本体を止めない)。
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { init, uninit, snippet, hookCopyVersion, UserError } from "./install.mjs";
|
|
8
|
+
import { loadConfig } from "./config.mjs";
|
|
9
|
+
import { summarize, humanError } from "./queue.mjs";
|
|
10
|
+
import { notify } from "./notify.mjs";
|
|
11
|
+
import { main as hookMain, EVENTS } from "./hook.mjs";
|
|
12
|
+
import { msg, LANGS } from "./messages.mjs";
|
|
13
|
+
import { isDirectory } from "./text.mjs";
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
export function version() {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version;
|
|
20
|
+
} catch {
|
|
21
|
+
return "?";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const USAGE = `ask-later ${version()} — 聞いて、待たずに進む / ask now, get the answer later
|
|
26
|
+
|
|
27
|
+
使い方 / usage:
|
|
28
|
+
npx ask-later init [--lang ja|en] [--dir <project>] [--force] [--local] [--no-hooks] [--no-files] [--no-claude-md] [--no-gitignore] [--shell]
|
|
29
|
+
.claude/settings.json に hooks をマージし(--local なら settings.local.json)、PENDING.md / INBOX.md の雛形と
|
|
30
|
+
.claude/ask-later/(フック本体の写し)を置き、CLAUDE.md に規律の断片・.gitignore に 2 行を足す(無いときだけ)。
|
|
31
|
+
既存の雛形は --force が無ければ触らない。--shell はフックを shell 形式(node "\${CLAUDE_PROJECT_DIR}/…" Event)で書く
|
|
32
|
+
npx ask-later uninit [--dir <project>] [--purge]
|
|
33
|
+
hooks と .claude/ask-later/・番兵・.ask-later/ を外す。PENDING.md / INBOX.md は --purge のときだけ消す(CLAUDE.md / .gitignore は触らない)
|
|
34
|
+
npx ask-later status [--dir <project>] [--json] [--dry-run]
|
|
35
|
+
未処理の件数・INBOX の未読・番兵の状態(期限切れの自動取り下げも行う。--dry-run なら読むだけ)
|
|
36
|
+
npx ask-later notify [--dir <project>] [message]
|
|
37
|
+
OS 通知を 1 回出す(動作確認用)
|
|
38
|
+
npx ask-later snippet [--lang ja|en]
|
|
39
|
+
CLAUDE.md に貼る規律の断片を出す
|
|
40
|
+
npx ask-later hook <${EVENTS.join("|")}>
|
|
41
|
+
フック本体(Claude Code が呼ぶ。stdin に JSON)。常に exit 0
|
|
42
|
+
npx ask-later --version / --help
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
function parseArgs(argv) {
|
|
46
|
+
const flags = {};
|
|
47
|
+
const rest = [];
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
const a = argv[i];
|
|
50
|
+
if (a === "--") {
|
|
51
|
+
rest.push(...argv.slice(i + 1));
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
if (a.startsWith("--")) {
|
|
55
|
+
const eq = a.indexOf("=");
|
|
56
|
+
if (eq > 0) {
|
|
57
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const key = a.slice(2);
|
|
61
|
+
if (["lang", "dir"].includes(key)) {
|
|
62
|
+
if (i + 1 >= argv.length || argv[i + 1].startsWith("--")) throw new UserError(`--${key} には値が要ります / --${key} needs a value`);
|
|
63
|
+
flags[key] = argv[++i];
|
|
64
|
+
} else flags[key] = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
rest.push(a);
|
|
68
|
+
}
|
|
69
|
+
return { flags, rest };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveRoot(flags, cwd) {
|
|
73
|
+
const root = resolve(cwd, flags.dir ?? ".");
|
|
74
|
+
if (!isDirectory(root)) throw new UserError(`ディレクトリがありません / no such directory: ${root}`);
|
|
75
|
+
return root;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function runCli(argv, { cwd = process.cwd(), stdout = process.stdout, stderr = process.stderr, stdin = process.stdin, env = process.env } = {}) {
|
|
79
|
+
const out = (s) => stdout.write(s.endsWith("\n") ? s : s + "\n");
|
|
80
|
+
const err = (s) => stderr.write(s.endsWith("\n") ? s : s + "\n");
|
|
81
|
+
let flags, rest;
|
|
82
|
+
try {
|
|
83
|
+
({ flags, rest } = parseArgs(argv));
|
|
84
|
+
} catch (e) {
|
|
85
|
+
err(`エラー: ${e.message}`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
const command = rest[0];
|
|
89
|
+
|
|
90
|
+
if (flags.version || command === "version") {
|
|
91
|
+
out(version());
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
if (flags.help || !command || command === "help") {
|
|
95
|
+
out(USAGE);
|
|
96
|
+
return command || flags.help ? 0 : 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// フックは常に exit 0。ここでは例外も握る
|
|
100
|
+
if (command === "hook") {
|
|
101
|
+
try {
|
|
102
|
+
const root = env.CLAUDE_PROJECT_DIR || resolve(cwd, flags.dir ?? ".");
|
|
103
|
+
const r = await hookMain(rest[1], { root, stdin, version: version() });
|
|
104
|
+
if (r.output) stdout.write(r.output);
|
|
105
|
+
} catch {
|
|
106
|
+
/* 止めない */
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
switch (command) {
|
|
113
|
+
case "init": {
|
|
114
|
+
const root = resolveRoot(flags, cwd);
|
|
115
|
+
const lang = flags.lang ?? "ja";
|
|
116
|
+
if (!LANGS.includes(lang)) throw new UserError(`--lang は ${LANGS.join(" | ")} のどれかです / --lang must be one of ${LANGS.join(" | ")}: ${lang}`);
|
|
117
|
+
const r = await init(root, {
|
|
118
|
+
lang,
|
|
119
|
+
force: !!flags.force,
|
|
120
|
+
local: !!flags.local,
|
|
121
|
+
hooks: !flags["no-hooks"],
|
|
122
|
+
files: !flags["no-files"],
|
|
123
|
+
form: flags.shell ? "shell" : "exec",
|
|
124
|
+
claudeMd: !flags["no-claude-md"],
|
|
125
|
+
gitignore: !flags["no-gitignore"],
|
|
126
|
+
});
|
|
127
|
+
if (r.created.length) out(`作成 / created: ${r.created.join(", ")}`);
|
|
128
|
+
if (r.updated.length) out(`更新 / updated: ${r.updated.join(", ")}`);
|
|
129
|
+
if (r.skipped.length) out(`そのまま / kept: ${r.skipped.join(", ")}(雛形は --force で上書き / templates: overwrite with --force)`);
|
|
130
|
+
if (r.hookDir) out(`フック本体 / hook files: .claude/ask-later/ ${version()}(更新は npx ask-later@latest init / re-run to update)`);
|
|
131
|
+
out("");
|
|
132
|
+
out(lang === "en" ? "Next:" : "次にやること:");
|
|
133
|
+
const touchedClaudeMd = r.claudeMd === "created" || r.claudeMd === "appended";
|
|
134
|
+
const en = [
|
|
135
|
+
flags["no-claude-md"] ? "Paste `npx ask-later snippet --lang en` into CLAUDE.md (the rule the agent follows)" : touchedClaudeMd ? "CLAUDE.md now has the rule the agent follows (`## Async queue (ask-later)`). Read it once" : null,
|
|
136
|
+
flags["no-gitignore"] ? "Add `.ask-later/` and `\\!pending_*.txt` to .gitignore (the backslash matters: a leading ! negates)" : null,
|
|
137
|
+
"Start claude. Then write one line below the `---` in INBOX.md (e.g. `test: reply \"read\" if you can see this`) and send any prompt — the line gets a leading ✔ (that is the check; the reply usually contains it too). `npx ask-later status` shows the counts",
|
|
138
|
+
].filter(Boolean);
|
|
139
|
+
const ja = [
|
|
140
|
+
flags["no-claude-md"] ? "`npx ask-later snippet` の出力を CLAUDE.md に貼る(エージェントが守る規律)" : touchedClaudeMd ? "CLAUDE.md にエージェントが守る規律(`## 非同期キュー(ask-later)`)を足した。一度読んでおく" : null,
|
|
141
|
+
flags["no-gitignore"] ? ".gitignore に `.ask-later/` と `\\!pending_*.txt` を足す(! で始まる名前は \\ が要る)" : null,
|
|
142
|
+
"claude を起動する。INBOX.md の `---` の下に 1 行(例: `テスト: これが読めたら「読めた」と返して`)書いて何か入力すると、その行の行頭に ✔ が付く(これが確認。返事にも大抵出る)。`npx ask-later status` で件数を見る",
|
|
143
|
+
].filter(Boolean);
|
|
144
|
+
(lang === "en" ? en : ja).forEach((line, i) => out(` ${i + 1}. ${line}`));
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
case "uninit": {
|
|
148
|
+
const root = resolveRoot(flags, cwd);
|
|
149
|
+
const r = await uninit(root, { purge: !!flags.purge, log: out });
|
|
150
|
+
if (r.removed.length) out(`削除 / removed: ${r.removed.join(", ")}`);
|
|
151
|
+
if (!r.settings.length && !r.removed.length) out("外すものがありませんでした / nothing to remove");
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
case "status": {
|
|
155
|
+
const root = resolveRoot(flags, cwd);
|
|
156
|
+
const config = loadConfig(root);
|
|
157
|
+
const m = msg(config.lang);
|
|
158
|
+
const r = await summarize(root, config, { expire: true, notify: false, write: !flags["dry-run"] });
|
|
159
|
+
const copyVersion = hookCopyVersion(root);
|
|
160
|
+
if (flags.json) {
|
|
161
|
+
out(JSON.stringify({
|
|
162
|
+
root, pendingExists: r.pendingExists, inboxExists: r.inboxExists, openCount: r.openCount, openItems: r.openItems.map((it) => ({ id: it.id, title: it.title })),
|
|
163
|
+
withdrawn: r.withdrawn, malformed: r.malformed, inboxUnread: r.inboxUnread, inboxLines: r.inboxLines, inboxGarbled: r.inboxGarbled, sentinel: r.sentinel,
|
|
164
|
+
hookVersion: copyVersion, packageVersion: version(),
|
|
165
|
+
errors: [config.error, r.pendingError && `${config.pending}: ${r.pendingError}`, r.inboxError && `${config.inbox}: ${r.inboxError}`, ...r.writeErrors].filter(Boolean),
|
|
166
|
+
}, null, 2));
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
if (config.error) out(m.warnWrite(config.error));
|
|
170
|
+
if (!r.pendingExists) out(m.statusMissing(config.pending) + (r.pendingError ? `(${r.pendingError})` : ""));
|
|
171
|
+
else {
|
|
172
|
+
out(m.statusPending(r.openCount));
|
|
173
|
+
for (const it of r.openItems) out(` - ${it.id} ${it.title}${it.deadline ? `(期限 / due ${it.deadline.toISOString().slice(0, 10)})` : ""}`);
|
|
174
|
+
for (const id of r.withdrawn) out(m.statusWithdrawn(id));
|
|
175
|
+
if (r.malformed.length) out(m.statusMalformed(r.malformed));
|
|
176
|
+
}
|
|
177
|
+
if (!r.inboxExists) out(m.statusMissing(config.inbox) + (r.inboxError ? `(${r.inboxError})` : ""));
|
|
178
|
+
else {
|
|
179
|
+
out(m.statusInbox(r.inboxUnread));
|
|
180
|
+
for (const t of r.inboxLines) out(` > ${t}`);
|
|
181
|
+
if (r.inboxGarbled > 0) out(m.statusGarbled(r.inboxGarbled, config.inbox));
|
|
182
|
+
}
|
|
183
|
+
out(m.statusSentinel(r.sentinel ? r.sentinel.slice(root.length + 1) : null, r.openCount));
|
|
184
|
+
if (copyVersion !== null || isDirectory(join(root, ".claude", "ask-later"))) out(m.statusHookVersion(copyVersion, version()));
|
|
185
|
+
for (const e of r.writeErrors) out(m.warnWrite(e));
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
case "notify": {
|
|
189
|
+
const root = resolveRoot(flags, cwd);
|
|
190
|
+
const config = loadConfig(root);
|
|
191
|
+
const body = rest.slice(1).join(" ") || (config.lang === "en" ? "Test notification from ask-later" : "ask-later の通知テストです");
|
|
192
|
+
const r = await notify({ title: "ask-later", body });
|
|
193
|
+
out(r.ok ? `通知を出しました / sent(${r.method})` : `通知を出せませんでした / could not notify(${r.method ?? "-"}: ${r.error ?? "?"})。番兵ファイルは動きます / the sentinel file still works`);
|
|
194
|
+
return r.ok ? 0 : 1;
|
|
195
|
+
}
|
|
196
|
+
case "snippet": {
|
|
197
|
+
const lang = flags.lang ?? "ja";
|
|
198
|
+
if (!LANGS.includes(lang)) throw new UserError(`--lang は ${LANGS.join(" | ")} のどれかです / --lang must be one of ${LANGS.join(" | ")}: ${lang}`);
|
|
199
|
+
stdout.write(snippet(lang));
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
default:
|
|
203
|
+
err(`エラー: 知らないコマンドです / unknown command: ${command}\n`);
|
|
204
|
+
err(USAGE);
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
} catch (e) {
|
|
208
|
+
if (e instanceof UserError) err(`エラー: ${e.message}`);
|
|
209
|
+
else if (e && (e.code === "EACCES" || e.code === "EPERM" || e.code === "EROFS" || e.code === "EBUSY")) err(`エラー: ${msg("ja").errWrite(e.path ?? "")} / ${msg("en").errWrite(e.path ?? "")}`);
|
|
210
|
+
else err(`エラー: 予期しない失敗です / unexpected failure: ${e?.message ?? e}`);
|
|
211
|
+
return 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// 設定(.ask-later/config.json)と状態(.ask-later/state.json)。
|
|
2
|
+
// どちらも無くても動く。壊れていてもフックは止めない(既定値で進み、status が警告する)。
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readText, writeTextAtomic } from "./text.mjs";
|
|
5
|
+
import { LANGS } from "./messages.mjs";
|
|
6
|
+
|
|
7
|
+
export const STATE_DIR = ".ask-later";
|
|
8
|
+
|
|
9
|
+
export const DEFAULTS = Object.freeze({
|
|
10
|
+
lang: "ja",
|
|
11
|
+
pending: "PENDING.md",
|
|
12
|
+
inbox: "INBOX.md",
|
|
13
|
+
sentinelPrefix: "!pending_",
|
|
14
|
+
quietHours: { start: 21, end: 8 }, // 21:00〜08:00 は通知を鳴らさず翌朝に持ち越す
|
|
15
|
+
notify: true,
|
|
16
|
+
// Stop の差し止め回数(同じ未読の集合・同じセッションにつき)。0 で差し止めなし。上限 7——Claude Code 本体が
|
|
17
|
+
// 連続 8 回の差し止めで打ち切る(公式 hooks 仕様)ので、その手前で必ず止まる
|
|
18
|
+
stopBlockMax: 2,
|
|
19
|
+
});
|
|
20
|
+
export const STOP_BLOCK_MAX_LIMIT = 7;
|
|
21
|
+
|
|
22
|
+
export const configPath = (root) => join(root, STATE_DIR, "config.json");
|
|
23
|
+
export const statePath = (root) => join(root, STATE_DIR, "state.json");
|
|
24
|
+
export const logPath = (root) => join(root, STATE_DIR, "hook.log");
|
|
25
|
+
|
|
26
|
+
function safeJson(text) {
|
|
27
|
+
try {
|
|
28
|
+
const v = JSON.parse(text);
|
|
29
|
+
return v && typeof v === "object" && !Array.isArray(v) ? { value: v, error: null } : { value: null, error: "オブジェクトではありません / not an object" };
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return { value: null, error: e.message };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 既定値に config.json を重ねる。値の型が違えばその項目だけ既定値に戻す
|
|
36
|
+
export function loadConfig(root) {
|
|
37
|
+
const out = { ...DEFAULTS, quietHours: { ...DEFAULTS.quietHours }, error: null };
|
|
38
|
+
const text = readText(configPath(root));
|
|
39
|
+
if (text == null) return out;
|
|
40
|
+
const { value, error } = safeJson(text);
|
|
41
|
+
if (!value) {
|
|
42
|
+
out.error = `config.json が読めません(${error})。既定値で動きます`;
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
if (typeof value.lang === "string" && LANGS.includes(value.lang)) out.lang = value.lang;
|
|
46
|
+
for (const k of ["pending", "inbox", "sentinelPrefix"]) {
|
|
47
|
+
if (typeof value[k] === "string" && value[k].trim() && !/[\\/]/.test(value[k])) out[k] = value[k].trim();
|
|
48
|
+
}
|
|
49
|
+
if (typeof value.notify === "boolean") out.notify = value.notify;
|
|
50
|
+
if (Number.isInteger(value.stopBlockMax) && value.stopBlockMax >= 0) out.stopBlockMax = Math.min(value.stopBlockMax, STOP_BLOCK_MAX_LIMIT);
|
|
51
|
+
if (value.quietHours && typeof value.quietHours === "object") {
|
|
52
|
+
const { start, end } = value.quietHours;
|
|
53
|
+
if (Number.isInteger(start) && start >= 0 && start <= 24) out.quietHours.start = start;
|
|
54
|
+
if (Number.isInteger(end) && end >= 0 && end <= 24) out.quietHours.end = end;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function loadState(root) {
|
|
60
|
+
const text = readText(statePath(root));
|
|
61
|
+
if (text == null) return {};
|
|
62
|
+
const { value } = safeJson(text);
|
|
63
|
+
return value ?? {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function saveState(root, state) {
|
|
67
|
+
writeTextAtomic(statePath(root), JSON.stringify(state, null, 2) + "\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 静音時間帯か。start > end なら日をまたぐ(21→8)。start === end なら静音なし
|
|
71
|
+
export function isQuietHour(date, { start, end } = DEFAULTS.quietHours) {
|
|
72
|
+
const h = date.getHours();
|
|
73
|
+
if (start === end) return false;
|
|
74
|
+
return start > end ? h >= start || h < end : h >= start && h < end;
|
|
75
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ask-later のフック入口。`npx ask-later init` がこのファイルを <project>/.claude/ask-later/hook.mjs に写し、
|
|
3
|
+
// 同じ場所の lib/ に本体を、VERSION に写したときのパッケージの版を写す。パッケージの置き場所(npx のキャッシュ・
|
|
4
|
+
// node_modules)に依存しないための写しであり、更新は `npx ask-later@latest init` で上書きする。
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { dirname, resolve, join } from "node:path";
|
|
8
|
+
import { main } from "./lib/hook.mjs";
|
|
9
|
+
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url)); // <project>/.claude/ask-later
|
|
11
|
+
const root = resolve(here, "..", "..");
|
|
12
|
+
let version = null;
|
|
13
|
+
try {
|
|
14
|
+
version = readFileSync(join(here, "VERSION"), "utf8").trim() || null;
|
|
15
|
+
} catch {
|
|
16
|
+
/* 古い写し(VERSION 無し) */
|
|
17
|
+
}
|
|
18
|
+
const r = await main(process.argv[2], { root, version });
|
|
19
|
+
if (r.output) process.stdout.write(r.output, () => process.exit(r.exitCode));
|
|
20
|
+
else process.exit(r.exitCode);
|