oh-my-adhd 0.2.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/LICENSE +21 -0
- package/README.md +182 -0
- package/bin/oh-my-adhd.mjs +382 -0
- package/dist/mcp/lib/brain.js +396 -0
- package/dist/mcp/lib/consolidate.js +190 -0
- package/dist/mcp/lib/linker.js +98 -0
- package/dist/mcp/lib/search.js +99 -0
- package/dist/mcp/mcp/server.js +36 -0
- package/dist/mcp/mcp/tools/wiki-delete.js +22 -0
- package/dist/mcp/mcp/tools/wiki-dump.js +85 -0
- package/dist/mcp/mcp/tools/wiki-graph.js +19 -0
- package/dist/mcp/mcp/tools/wiki-link.js +33 -0
- package/dist/mcp/mcp/tools/wiki-pages.js +20 -0
- package/dist/mcp/mcp/tools/wiki-query.js +67 -0
- package/dist/mcp/mcp/tools/wiki-recall.js +205 -0
- package/dist/mcp/mcp/tools/wiki-save.js +22 -0
- package/dist/mcp/mcp/tools/wiki-setup.js +25 -0
- package/dist/mcp/mcp/tools/wiki-structure.js +28 -0
- package/dist/mcp/mcp/tools/wiki-unstick.js +110 -0
- package/dist/mcp/mcp/utils.js +31 -0
- package/package.json +54 -0
- package/scripts/capture.sh +31 -0
- package/scripts/com.oh-my-adhd.server.plist +28 -0
- package/scripts/demo.sh +43 -0
- package/scripts/install-launchagent.sh +35 -0
- package/scripts/stop-hook.mjs +42 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getPage } from "../../lib/brain.js";
|
|
3
|
+
export function registerWikiStructure(server) {
|
|
4
|
+
server.tool("wiki_structure", "슬러그로 페이지의 날것 캡처 내용을 가져온다. Claude가 이 내용을 보고 ## 섹션으로 구조화한 뒤 wiki_save로 저장해야 한다.", {
|
|
5
|
+
slug: z.string().max(100).regex(/^[a-z0-9가-힣-]+$/).describe("구조화할 페이지 슬러그"),
|
|
6
|
+
}, async ({ slug }) => {
|
|
7
|
+
const page = await getPage(slug.toLowerCase());
|
|
8
|
+
if (!page) {
|
|
9
|
+
return { content: [{ type: "text", text: `페이지 없음: ${slug}` }], isError: true };
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
content: [
|
|
13
|
+
{
|
|
14
|
+
type: "text",
|
|
15
|
+
text: [
|
|
16
|
+
`=== 페이지: [[${page.title}]] (slug: ${page.slug}) ===`,
|
|
17
|
+
"",
|
|
18
|
+
"아래 날것 내용을 ## 섹션으로 구조화해서 wiki_save 툴로 저장하세요.",
|
|
19
|
+
"형식: # 제목\\n\\n## 섹션1\\n내용\\n\\n## 섹션2\\n내용",
|
|
20
|
+
"",
|
|
21
|
+
"=== 원본 내용 ===",
|
|
22
|
+
page.content,
|
|
23
|
+
].join("\n"),
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getThreads, getThread, OPEN_SIGNAL, extractFieldBrain } from "../../lib/brain.js";
|
|
3
|
+
export function registerWikiUnstick(server) {
|
|
4
|
+
server.tool("wiki_unstick", "막혀서 무력감 느낄 때 호출. 이미 시도해서 안 된 것은 제외하고, 에너지 레벨에 맞는 가장 작은 한 발자국만 알려준다. low=2분, medium=5분, high=15분.", {
|
|
5
|
+
task: z.string().max(2000).optional().describe("막힌 태스크 직접 설명 (없으면 최근 미완료 스레드 자동 감지)"),
|
|
6
|
+
energy: z.enum(["low", "medium", "high"]).optional().default("medium").describe("현재 집중력/에너지 수준 (low=2분짜리, medium=5분짜리, high=15분짜리)"),
|
|
7
|
+
}, async ({ task, energy }) => {
|
|
8
|
+
try {
|
|
9
|
+
let targetTitle = "";
|
|
10
|
+
let targetContext = task ?? "";
|
|
11
|
+
const deadEnds = [];
|
|
12
|
+
let nextStep = "";
|
|
13
|
+
const blockers = [];
|
|
14
|
+
const crossThreadBlockers = [];
|
|
15
|
+
if (!targetContext) {
|
|
16
|
+
const threads = await getThreads();
|
|
17
|
+
const candidates = threads.slice(0, 5);
|
|
18
|
+
const contents = await Promise.all(candidates.map((t) => getThread(t.id)));
|
|
19
|
+
// open thread 우선 선택, 없으면 첫 번째 — 한 스레드에서만 필드 수집
|
|
20
|
+
let chosenIdx = candidates.findIndex((_, i) => {
|
|
21
|
+
const c = contents[i];
|
|
22
|
+
if (!c)
|
|
23
|
+
return false;
|
|
24
|
+
const lastCapture = c.split(/\n---\n/).slice(1).at(-1) ?? "";
|
|
25
|
+
return OPEN_SIGNAL.test(lastCapture);
|
|
26
|
+
});
|
|
27
|
+
if (chosenIdx < 0)
|
|
28
|
+
chosenIdx = 0;
|
|
29
|
+
const content = contents[chosenIdx];
|
|
30
|
+
const chosenThread = candidates[chosenIdx];
|
|
31
|
+
if (content) {
|
|
32
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
33
|
+
targetTitle = (titleMatch?.[1] ?? chosenThread.id).trim();
|
|
34
|
+
const captures = content.split(/\n---\n/).slice(1).filter((p) => p.trim());
|
|
35
|
+
const recentCaptures = captures.slice(-3);
|
|
36
|
+
for (const cap of recentCaptures) {
|
|
37
|
+
const de = extractFieldBrain(cap, "막힌것");
|
|
38
|
+
if (de && !deadEnds.includes(de))
|
|
39
|
+
deadEnds.push(de);
|
|
40
|
+
const ns = extractFieldBrain(cap, "다음할것");
|
|
41
|
+
if (ns)
|
|
42
|
+
nextStep = ns;
|
|
43
|
+
const bl = extractFieldBrain(cap, "블로커");
|
|
44
|
+
if (bl && !blockers.includes(bl))
|
|
45
|
+
blockers.push(bl);
|
|
46
|
+
}
|
|
47
|
+
targetContext = recentCaptures
|
|
48
|
+
.map((c) => c.replace(/^(?:_[^_\n]+_|\*\*[^*\n]+\*\*)\s*/m, "").replace(/\n+/g, " ").trim())
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.join("\n");
|
|
51
|
+
}
|
|
52
|
+
// Collect dead-ends from all open threads (not just the chosen one)
|
|
53
|
+
const allOpenThreads = threads.filter(t => t.is_open && t.id !== chosenThread.id);
|
|
54
|
+
for (const ot of allOpenThreads.slice(0, 10)) {
|
|
55
|
+
const otContent = await getThread(ot.id);
|
|
56
|
+
if (!otContent)
|
|
57
|
+
continue;
|
|
58
|
+
const otCaptures = otContent.split(/\n---\n/).slice(1).filter(p => p.trim());
|
|
59
|
+
const otLast = otCaptures.at(-1) ?? "";
|
|
60
|
+
const otText = otLast.replace(/^(?:_[^_\n]+_|\*\*[^*\n]+\*\*)\s*/m, "").trim();
|
|
61
|
+
const otBlocker = extractFieldBrain(otText, "막힌것");
|
|
62
|
+
const otBlockerKey = otBlocker.toLowerCase().replace(/\s+/g, " ").slice(0, 60);
|
|
63
|
+
if (otBlocker && !deadEnds.some(d => d.toLowerCase().replace(/\s+/g, " ").includes(otBlockerKey))) {
|
|
64
|
+
crossThreadBlockers.push(`[${ot.title?.slice(0, 20) ?? "다른 스레드"}] ${otBlocker}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!task && !targetTitle && !targetContext) {
|
|
69
|
+
return { content: [{ type: "text", text: "막힌 대상을 찾지 못했어. 둘 중 하나로 다시 호출해줘:\n" +
|
|
70
|
+
"1. `task` 인자에 막힌 상황 한 줄로 직접 적기\n" +
|
|
71
|
+
"2. 먼저 `wiki_dump`로 지금 머릿속 상태를 던진 뒤 다시 `wiki_unstick` 호출\n" +
|
|
72
|
+
"(저장된 스레드가 없거나 모두 완료 상태)"
|
|
73
|
+
}] };
|
|
74
|
+
}
|
|
75
|
+
const taskSize = energy === "low" ? "2분" : energy === "high" ? "15분" : "5분";
|
|
76
|
+
const taskDetail = energy === "low"
|
|
77
|
+
? "뇌를 거의 쓰지 않아도 되는 것 (파일 열기, 탭 찾기, 줄 읽기 수준)"
|
|
78
|
+
: energy === "high"
|
|
79
|
+
? "집중이 필요하지만 명확한 완결이 있는 것"
|
|
80
|
+
: "결정 없이 바로 시작할 수 있는 것";
|
|
81
|
+
const lines = [
|
|
82
|
+
targetTitle ? `## 현재 작업: ${targetTitle}` : "## 현재 작업",
|
|
83
|
+
`> 에너지 레벨: ${energy} → ${taskSize}짜리 스텝`,
|
|
84
|
+
"",
|
|
85
|
+
"### 컨텍스트",
|
|
86
|
+
targetContext || task || "(컨텍스트 없음)",
|
|
87
|
+
];
|
|
88
|
+
if (nextStep) {
|
|
89
|
+
lines.push("", `### 마지막으로 계획한 다음 스텝`, nextStep);
|
|
90
|
+
}
|
|
91
|
+
if (deadEnds.length > 0 || crossThreadBlockers.length > 0) {
|
|
92
|
+
lines.push("", "### ⛔ 이미 시도해서 안 된 것 (제안하지 말 것)");
|
|
93
|
+
deadEnds.forEach((d) => lines.push(`- ${d}`));
|
|
94
|
+
if (crossThreadBlockers.length > 0) {
|
|
95
|
+
lines.push("", "⛔ 다른 스레드에서도 막혔던 것:");
|
|
96
|
+
crossThreadBlockers.forEach((b) => lines.push(` - ${b}`));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (blockers.length > 0) {
|
|
100
|
+
lines.push("", "### 블로커");
|
|
101
|
+
blockers.forEach((b) => lines.push(`- ${b}`));
|
|
102
|
+
}
|
|
103
|
+
lines.push("", "---", "", `위 내용을 보고 **지금 당장 할 수 있는 ${taskSize}짜리 스텝 하나**만 알려줘.`, `조건: 행동 동사로 시작 / ${taskDetail} / ⛔ 목록에 있는 것 절대 제안 금지 / 한 줄`);
|
|
104
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
return { content: [{ type: "text", text: `오류: ${e.message ?? String(e)}` }], isError: true };
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execFile as execFileCb } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
const execFile = promisify(execFileCb);
|
|
4
|
+
const GIT_OPTS = { maxBuffer: 1024 * 1024, timeout: 3000 };
|
|
5
|
+
export async function git(...args) {
|
|
6
|
+
try {
|
|
7
|
+
const { stdout } = await execFile("git", args, GIT_OPTS);
|
|
8
|
+
return stdout.trim();
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function captureGitContext() {
|
|
15
|
+
try {
|
|
16
|
+
const [branch, head, statusOut] = await Promise.all([
|
|
17
|
+
git("branch", "--show-current"),
|
|
18
|
+
git("rev-parse", "--short", "HEAD"),
|
|
19
|
+
git("status", "--porcelain=v1"),
|
|
20
|
+
]);
|
|
21
|
+
const dirty = statusOut
|
|
22
|
+
? statusOut.split("\n").filter(Boolean).map(l => l.slice(3).trim())
|
|
23
|
+
: [];
|
|
24
|
+
if (!branch && !head)
|
|
25
|
+
return "";
|
|
26
|
+
return `\n[git: ${branch}@${head}${dirty.length ? ` | dirty: ${dirty.slice(0, 3).join(", ")}` : ""}]`;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oh-my-adhd",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "ADHD second brain — zero-friction capture, auto context restore, unstick. MCP-native Claude Code plugin.",
|
|
5
|
+
"author": "Yeachan Heo",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Yeachan-Heo/oh-my-adhd.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/Yeachan-Heo/oh-my-adhd#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Yeachan-Heo/oh-my-adhd/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"claude",
|
|
16
|
+
"mcp",
|
|
17
|
+
"adhd",
|
|
18
|
+
"second-brain",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"productivity"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"oh-my-adhd": "bin/oh-my-adhd.mjs"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"dist",
|
|
33
|
+
"scripts"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"mcp": "tsx src/mcp/server.ts",
|
|
37
|
+
"build": "tsc -p tsconfig.mcp.json",
|
|
38
|
+
"mcp:build": "tsc -p tsconfig.mcp.json",
|
|
39
|
+
"prepublishOnly": "npm run build",
|
|
40
|
+
"start": "tsx src/mcp/server.ts",
|
|
41
|
+
"test": "vitest run"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
45
|
+
"minisearch": "^7.2.0",
|
|
46
|
+
"zod": "^4.4.3"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^20",
|
|
50
|
+
"tsx": "^4.22.3",
|
|
51
|
+
"typescript": "^5",
|
|
52
|
+
"vitest": "^2.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# oh-my-adhd capture popup
|
|
3
|
+
# Usage: bash capture.sh [threadId]
|
|
4
|
+
# Bind ⌥+ADH to this script in your hotkey manager (Raycast, Hammerspoon, etc.)
|
|
5
|
+
|
|
6
|
+
PORT=${OH_MY_ADHD_PORT:-3000}
|
|
7
|
+
THREAD_ARG=""
|
|
8
|
+
if [ -n "$1" ]; then
|
|
9
|
+
THREAD_ARG="?threadId=$1"
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
URL="http://localhost:${PORT}/capture${THREAD_ARG}"
|
|
13
|
+
|
|
14
|
+
# Open as a small popup window in the default browser
|
|
15
|
+
osascript <<EOF
|
|
16
|
+
tell application "Google Chrome"
|
|
17
|
+
activate
|
|
18
|
+
set w to make new window with properties {bounds:{200, 100, 760, 480}}
|
|
19
|
+
set URL of active tab of w to "${URL}"
|
|
20
|
+
end tell
|
|
21
|
+
EOF
|
|
22
|
+
|
|
23
|
+
# Fallback: Safari
|
|
24
|
+
if [ $? -ne 0 ]; then
|
|
25
|
+
osascript <<EOF2
|
|
26
|
+
tell application "Safari"
|
|
27
|
+
activate
|
|
28
|
+
make new document with properties {URL:"${URL}"}
|
|
29
|
+
end tell
|
|
30
|
+
EOF2
|
|
31
|
+
fi
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>Label</key>
|
|
6
|
+
<string>com.oh-my-adhd.server</string>
|
|
7
|
+
<key>ProgramArguments</key>
|
|
8
|
+
<array>
|
|
9
|
+
<string>/usr/local/bin/npm</string>
|
|
10
|
+
<string>start</string>
|
|
11
|
+
</array>
|
|
12
|
+
<key>WorkingDirectory</key>
|
|
13
|
+
<string>REPLACE_WITH_PROJECT_PATH</string>
|
|
14
|
+
<key>RunAtLoad</key>
|
|
15
|
+
<true/>
|
|
16
|
+
<key>KeepAlive</key>
|
|
17
|
+
<true/>
|
|
18
|
+
<key>StandardOutPath</key>
|
|
19
|
+
<string>/tmp/oh-my-adhd.log</string>
|
|
20
|
+
<key>StandardErrorPath</key>
|
|
21
|
+
<string>/tmp/oh-my-adhd.err</string>
|
|
22
|
+
<key>EnvironmentVariables</key>
|
|
23
|
+
<dict>
|
|
24
|
+
<key>PATH</key>
|
|
25
|
+
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
|
|
26
|
+
</dict>
|
|
27
|
+
</dict>
|
|
28
|
+
</plist>
|
package/scripts/demo.sh
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# oh-my-adhd demo script
|
|
3
|
+
# Record with: asciinema rec demo.cast -c "bash scripts/demo.sh"
|
|
4
|
+
set -e
|
|
5
|
+
|
|
6
|
+
echo "# oh-my-adhd demo"
|
|
7
|
+
echo ""
|
|
8
|
+
sleep 1
|
|
9
|
+
|
|
10
|
+
echo "$ npx oh-my-adhd init"
|
|
11
|
+
sleep 0.5
|
|
12
|
+
echo " ✓ Brain ~/.oh-my-adhd"
|
|
13
|
+
echo " ✓ MCP oh-my-adhd 등록됨"
|
|
14
|
+
echo " ✓ Hooks SessionStart + Stop"
|
|
15
|
+
echo ""
|
|
16
|
+
echo "지금 작업 중인 게 뭐야? (한 줄, 엔터로 건너뛰기)"
|
|
17
|
+
echo "> React 폼 validation 리팩터링"
|
|
18
|
+
sleep 0.5
|
|
19
|
+
echo ""
|
|
20
|
+
echo "✓ 첫 기억 심었어."
|
|
21
|
+
echo " Claude Code 재시작하면, Claude가 먼저"
|
|
22
|
+
echo " \"React 폼 validation 리팩터링\" 기억하고 이어서 갈지 물어볼 거야."
|
|
23
|
+
echo ""
|
|
24
|
+
echo " 까먹어도 괜찮아. 그게 이 도구의 일이야."
|
|
25
|
+
sleep 2
|
|
26
|
+
|
|
27
|
+
echo ""
|
|
28
|
+
echo "# --- 다음 세션 (Claude Code 재시작 후) ---"
|
|
29
|
+
sleep 1
|
|
30
|
+
echo ""
|
|
31
|
+
echo "[oh-my-adhd] 어제 어디까지 했더라..."
|
|
32
|
+
sleep 1
|
|
33
|
+
echo ""
|
|
34
|
+
echo "## 어제 멈춘 곳"
|
|
35
|
+
echo ""
|
|
36
|
+
echo "> 🔴 React 폼 validation 리팩터링 — 15시간 전"
|
|
37
|
+
echo "> → 다음: useFormState 마이그레이션 + 에러 메시지 i18n"
|
|
38
|
+
echo "> ⛔ 막힌것: zod refine() 비동기 검증이 submit 중복 발생"
|
|
39
|
+
echo ">"
|
|
40
|
+
echo "> 이어서 갈까? (thread: abc123...)"
|
|
41
|
+
sleep 2
|
|
42
|
+
echo ""
|
|
43
|
+
echo "# 이게 매 세션 자동으로 일어나."
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Installs oh-my-adhd as a macOS LaunchAgent (auto-start on login)
|
|
3
|
+
set -e
|
|
4
|
+
|
|
5
|
+
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
6
|
+
PLIST_SRC="$PROJECT_DIR/scripts/com.oh-my-adhd.server.plist"
|
|
7
|
+
PLIST_DEST="$HOME/Library/LaunchAgents/com.oh-my-adhd.server.plist"
|
|
8
|
+
|
|
9
|
+
NPM_BIN="$(which npm)"
|
|
10
|
+
if [ -z "$NPM_BIN" ]; then
|
|
11
|
+
echo "npm not found. Please install Node.js first."
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
# Build production bundle
|
|
16
|
+
echo "Building oh-my-adhd..."
|
|
17
|
+
cd "$PROJECT_DIR"
|
|
18
|
+
npm run build
|
|
19
|
+
|
|
20
|
+
# Write plist with actual paths
|
|
21
|
+
sed \
|
|
22
|
+
-e "s|REPLACE_WITH_PROJECT_PATH|$PROJECT_DIR|g" \
|
|
23
|
+
-e "s|/usr/local/bin/npm|$NPM_BIN|g" \
|
|
24
|
+
"$PLIST_SRC" > "$PLIST_DEST"
|
|
25
|
+
|
|
26
|
+
# Load the agent
|
|
27
|
+
launchctl unload "$PLIST_DEST" 2>/dev/null || true
|
|
28
|
+
launchctl load "$PLIST_DEST"
|
|
29
|
+
|
|
30
|
+
echo ""
|
|
31
|
+
echo "✓ oh-my-adhd will start automatically on login."
|
|
32
|
+
echo " Logs: /tmp/oh-my-adhd.log"
|
|
33
|
+
echo ""
|
|
34
|
+
echo "Next: bind ⌥+ADH to: bash $PROJECT_DIR/scripts/capture.sh"
|
|
35
|
+
echo " (use Raycast, Hammerspoon, or Karabiner)"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// oh-my-adhd Stop hook — blocks session end if no wiki_dump happened this session
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
|
|
7
|
+
const BRAIN_DIR = process.env.OH_MY_ADHD_DIR ?? join(homedir(), ".oh-my-adhd");
|
|
8
|
+
const MANIFEST = join(BRAIN_DIR, "threads", ".manifest.json");
|
|
9
|
+
const SESSION_START_FILE = join(BRAIN_DIR, ".session-start");
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const sessionStartMs = parseInt(readFileSync(SESSION_START_FILE, "utf-8").trim(), 10);
|
|
13
|
+
if (isNaN(sessionStartMs)) process.exit(0); // no marker — don't block
|
|
14
|
+
|
|
15
|
+
let manifest;
|
|
16
|
+
try {
|
|
17
|
+
manifest = JSON.parse(readFileSync(MANIFEST, "utf-8"));
|
|
18
|
+
} catch {
|
|
19
|
+
process.exit(0); // no manifest — nothing to protect
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Allow stop if any dump happened after session start
|
|
23
|
+
const latestDump = manifest.reduce((max, t) => {
|
|
24
|
+
const ts = new Date(t.updatedAt).getTime();
|
|
25
|
+
return ts > max ? ts : max;
|
|
26
|
+
}, 0);
|
|
27
|
+
if (latestDump > sessionStartMs) process.exit(0);
|
|
28
|
+
|
|
29
|
+
// Block only if there are open threads worth saving
|
|
30
|
+
const openThreads = manifest.filter(t => t.is_open);
|
|
31
|
+
if (openThreads.length > 0) {
|
|
32
|
+
const top = openThreads[0];
|
|
33
|
+
const title = (top.title ?? "진행중인 작업").slice(0, 40);
|
|
34
|
+
const nextHint = top.next_action ? `\n→ 다음할것: ${top.next_action.slice(0, 60)}` : "";
|
|
35
|
+
process.stdout.write(JSON.stringify({
|
|
36
|
+
decision: "block",
|
|
37
|
+
reason: `저장 없이 끝내려고? "${title}" 스레드가 열려있어.${nextHint}\nwiki_dump로 결정/막힌것/다음할것 저장하고 끝내자.`,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Graceful degradation — never block due to script error
|
|
42
|
+
}
|