synabun 1.3.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.
Files changed (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,646 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * SynaBun Stop Hook for Claude Code
5
+ *
6
+ * Fires when Claude finishes responding. Enforces requirements:
7
+ *
8
+ * 1. COMPACTION AUTO-STORE — If a pending-compact flag exists (set by
9
+ * PreCompact hook), blocks Claude until it stores the session via
10
+ * `remember` with category "conversations".
11
+ *
12
+ * 1.5. ACTIVE LOOP — If a loop state file exists (set by `loop start`
13
+ * MCP tool), blocks Claude to continue the next iteration until
14
+ * iteration cap or time cap is reached.
15
+ *
16
+ * 2. TASK MEMORY — If 3+ file edits have occurred without a `remember`
17
+ * call, blocks Claude to store the work. Also catches unstored plans.
18
+ *
19
+ * Safety: Max 3 retries per flag to prevent infinite loops.
20
+ * Loop: Iteration cap is authoritative. Inactivity (45 min) catches stuck loops.
21
+ *
22
+ * Input (stdin JSON):
23
+ * { session_id, transcript_path, cwd, hook_event_name: "Stop",
24
+ * stop_hook_active, last_assistant_message }
25
+ *
26
+ * Output (stdout JSON):
27
+ * { "decision": "block", "reason": "..." } to force Claude to continue
28
+ * {} to allow Claude to stop normally
29
+ */
30
+
31
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, appendFileSync, readdirSync, renameSync, statSync } from 'node:fs';
32
+ import { dirname, join } from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { cleanupStaleLoops, detectProject } from './shared.mjs';
35
+
36
+ const __dirname = dirname(fileURLToPath(import.meta.url));
37
+ const DATA_DIR = join(__dirname, '..', '..', 'data');
38
+ const PENDING_COMPACT_DIR = join(DATA_DIR, 'pending-compact');
39
+ const LOOP_DIR = join(DATA_DIR, 'loop');
40
+ const PENDING_REMEMBER_DIR = join(DATA_DIR, 'pending-remember');
41
+ const STORED_PLANS_PATH = join(DATA_DIR, 'stored-plans.json');
42
+ const PLANS_DIR = join(DATA_DIR, 'plans');
43
+ const PLANS_DIR_FALLBACK = join(process.env.USERPROFILE || process.env.HOME || '', '.claude', 'plans');
44
+ const PLAN_MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes
45
+ const MAX_RETRIES = 3;
46
+ const EDIT_THRESHOLD = 1;
47
+
48
+ /**
49
+ * Soft-cleanup a pending-remember flag file.
50
+ * Resets enforcement fields (editCount, retries, files) while preserving
51
+ * session-wide tracking (messageCount, greetingDelivered, etc.) to prevent
52
+ * the greeting from re-firing mid-session.
53
+ */
54
+ function softCleanupFlag(flagPath) {
55
+ try {
56
+ if (!existsSync(flagPath)) return;
57
+ const flag = JSON.parse(readFileSync(flagPath, 'utf-8'));
58
+
59
+ const cleaned = {
60
+ editCount: 0,
61
+ retries: 0,
62
+ files: [],
63
+ messageCount: flag.messageCount || 0,
64
+ totalSessionMessages: flag.totalSessionMessages || 0,
65
+ totalEdits: flag.totalEdits || 0,
66
+ greetingDelivered: flag.greetingDelivered || false,
67
+ rememberCount: flag.rememberCount || 0,
68
+ autoStoreTriggered: flag.autoStoreTriggered || false,
69
+ firstMessageAt: flag.firstMessageAt,
70
+ lastMessageAt: flag.lastMessageAt,
71
+ userLearningNudgeCount: flag.userLearningNudgeCount || 0,
72
+ userLearningPending: false,
73
+ userLearningRetries: 0,
74
+ userLearningObserved: flag.userLearningObserved || false,
75
+ };
76
+
77
+ writeFileSync(flagPath, JSON.stringify(cleaned));
78
+ } catch {
79
+ // If we can't clean up gracefully, leave the file as-is
80
+ // (better than deleting and resetting greeting tracking)
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Find unstored plan files modified within PLAN_MAX_AGE_MS.
86
+ * Returns the most recent unstored plan or null.
87
+ */
88
+ function findUnstoredRecentPlan() {
89
+ try {
90
+ let stored = {};
91
+ try {
92
+ if (existsSync(STORED_PLANS_PATH)) {
93
+ stored = JSON.parse(readFileSync(STORED_PLANS_PATH, 'utf-8'));
94
+ }
95
+ } catch { /* ok */ }
96
+
97
+ const now = Date.now();
98
+ const allFiles = [];
99
+ for (const dir of [PLANS_DIR, PLANS_DIR_FALLBACK]) {
100
+ if (!existsSync(dir)) continue;
101
+ for (const f of readdirSync(dir).filter(f => f.endsWith('.md'))) {
102
+ if (allFiles.some(r => r.name === f)) continue; // skip duplicates
103
+ const fullPath = join(dir, f);
104
+ const stat = statSync(fullPath);
105
+ allFiles.push({ name: f, path: fullPath, mtimeMs: stat.mtimeMs });
106
+ }
107
+ }
108
+ allFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
109
+
110
+ for (const file of allFiles) {
111
+ if (stored[file.name]) continue;
112
+ if (now - file.mtimeMs > PLAN_MAX_AGE_MS) break;
113
+ return file;
114
+ }
115
+ } catch { /* ok */ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Mark a plan file as stored in the dedup tracker so it's not re-triggered.
121
+ */
122
+ function markPlanStored(fileName) {
123
+ try {
124
+ let stored = {};
125
+ if (existsSync(STORED_PLANS_PATH)) {
126
+ stored = JSON.parse(readFileSync(STORED_PLANS_PATH, 'utf-8'));
127
+ }
128
+ stored[fileName] = { memoryId: 'pending-remember', storedAt: new Date().toISOString() };
129
+ writeFileSync(STORED_PLANS_PATH, JSON.stringify(stored, null, 2), 'utf-8');
130
+ } catch { /* best-effort */ }
131
+ }
132
+
133
+ /**
134
+ * Detect if the agent's last message indicates it's waiting for human action
135
+ * in the browser (login, CAPTCHA, 2FA, etc.). Used to pause the loop instead
136
+ * of advancing to the next iteration.
137
+ */
138
+ function isHumanBlocker(msg) {
139
+ if (!msg) return false;
140
+ const blockerPhrases = [
141
+ 'login', 'log in', 'sign in', 'signin',
142
+ 'captcha', 'recaptcha',
143
+ 'authentication', 'authenticate',
144
+ '2fa', 'two-factor', 'two factor', 'verification code',
145
+ 'waiting for your', 'waiting for you',
146
+ 'browser panel',
147
+ 'human action', 'manual action',
148
+ 'please log', 'need to log', 'need to sign',
149
+ 'requires login', 'requires authentication',
150
+ 'login wall', 'login page',
151
+ 'blocked by', 'access denied',
152
+ ];
153
+ // Must match at least one blocker phrase AND a "waiting/stop" signal
154
+ const hasBlocker = blockerPhrases.some(p => msg.includes(p));
155
+ const hasWaitSignal = ['wait', 'stop', 'pause', 'cannot', 'can\'t', 'unable', 'need to', 'requires', 'please', 'blocked'].some(w => msg.includes(w));
156
+ return hasBlocker && hasWaitSignal;
157
+ }
158
+
159
+ /**
160
+ * Detect if the agent's last message indicates it's waiting for ANY user action
161
+ * (file upload, selection, login, CAPTCHA, etc.). Used to suppress soft obligations
162
+ * (user learning, conversation turns) that would interrupt interactive flows.
163
+ */
164
+ function isWaitingForUser(msg) {
165
+ if (!msg) return false;
166
+ const phrases = [
167
+ // Interactive flow pauses
168
+ 'attach', 'upload', 'provide', 'select continue',
169
+ 'when ready', 'when you\'re ready', 'once you',
170
+ 'let me know', 'your turn',
171
+ 'waiting for you', 'waiting for your',
172
+ // Human blockers (login, CAPTCHA, 2FA)
173
+ 'login', 'log in', 'sign in', 'signin',
174
+ 'captcha', 'recaptcha',
175
+ 'authentication', 'authenticate',
176
+ '2fa', 'two-factor', 'two factor', 'verification code',
177
+ 'browser panel',
178
+ 'human action', 'manual action',
179
+ 'please log', 'need to log', 'need to sign',
180
+ 'requires login', 'requires authentication',
181
+ 'login wall', 'login page',
182
+ 'blocked by', 'access denied',
183
+ ];
184
+ return phrases.some(p => msg.includes(p));
185
+ }
186
+
187
+ function readStdin() {
188
+ return new Promise((resolve) => {
189
+ if (process.stdin.isTTY) return resolve('{}');
190
+ let data = '';
191
+ process.stdin.setEncoding('utf8');
192
+ process.stdin.on('data', (chunk) => { data += chunk; });
193
+ process.stdin.on('end', () => resolve(data));
194
+ setTimeout(() => resolve(data || '{}'), 2000);
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Check a pending flag and return a block decision if needed.
200
+ * Returns { shouldBlock, reason } or null if no action needed.
201
+ */
202
+ function checkFlag(flagPath, buildReason) {
203
+ if (!existsSync(flagPath)) return null;
204
+
205
+ let flag;
206
+ try {
207
+ flag = JSON.parse(readFileSync(flagPath, 'utf-8'));
208
+ } catch {
209
+ // Corrupt flag → delete and skip
210
+ try { unlinkSync(flagPath); } catch { /* ok */ }
211
+ return null;
212
+ }
213
+
214
+ const retries = flag.retries || 0;
215
+
216
+ // Safety valve: max retries reached → give up
217
+ if (retries >= MAX_RETRIES) {
218
+ try { unlinkSync(flagPath); } catch { /* ok */ }
219
+ return null;
220
+ }
221
+
222
+ // Increment retry counter
223
+ flag.retries = retries + 1;
224
+ try {
225
+ writeFileSync(flagPath, JSON.stringify(flag));
226
+ } catch { /* ok */ }
227
+
228
+ return {
229
+ shouldBlock: true,
230
+ reason: buildReason(flag, retries + 1),
231
+ };
232
+ }
233
+
234
+ async function main() {
235
+ let sessionId = '';
236
+ let lastMessage = '';
237
+ let cwd = '';
238
+ try {
239
+ const raw = await readStdin();
240
+ const input = JSON.parse(raw);
241
+ sessionId = input.session_id || '';
242
+ lastMessage = (input.last_assistant_message || '').toLowerCase();
243
+ cwd = input.cwd || '';
244
+ } catch { /* proceed */ }
245
+
246
+ if (!sessionId) {
247
+ process.stdout.write(JSON.stringify({}));
248
+ return;
249
+ }
250
+
251
+ // ─── PRE-CHECK: Quick loop scan ───
252
+ // Determine if a loop is active BEFORE checking compaction. Loops must NEVER
253
+ // be blocked by compaction — it stalls iteration transitions and kills the loop.
254
+ // We do a fast scan here; the full loop logic is below in CHECK 1.5.
255
+ // When SYNABUN_TERMINAL_SESSION is set (server-launched loop), only count
256
+ // loops owned by THIS terminal — prevents other automations from suppressing
257
+ // compaction in unrelated sessions.
258
+ const terminalSessionEnv = process.env.SYNABUN_TERMINAL_SESSION || '';
259
+ let hasActiveLoop = false;
260
+ try {
261
+ if (existsSync(LOOP_DIR)) {
262
+ const loopFiles = readdirSync(LOOP_DIR).filter(f => f.endsWith('.json'));
263
+ for (const f of loopFiles) {
264
+ try {
265
+ const candidate = JSON.parse(readFileSync(join(LOOP_DIR, f), 'utf-8'));
266
+ if (candidate.active && (candidate.currentIteration || 0) > 0) {
267
+ // When running inside a loop terminal, only match OUR loop
268
+ if (terminalSessionEnv && candidate.terminalSessionId && candidate.terminalSessionId !== terminalSessionEnv) continue;
269
+ hasActiveLoop = true;
270
+ break;
271
+ }
272
+ } catch { continue; }
273
+ }
274
+ }
275
+ } catch { /* ok */ }
276
+
277
+ // ─── CHECK 1: Pending compact ───
278
+ // SKIP compaction when a loop is active — compaction blocks iteration transitions
279
+ // and causes the loop to stall. The compact flag will be handled after the loop ends.
280
+ const DEBUG_LOG = join(DATA_DIR, 'compact-debug.log');
281
+ let compactFlagPath = null;
282
+
283
+ if (!hasActiveLoop) {
284
+ try {
285
+ if (existsSync(PENDING_COMPACT_DIR)) {
286
+ const files = readdirSync(PENDING_COMPACT_DIR)
287
+ .filter(f => f.endsWith('.json') && !f.startsWith('test-'));
288
+ if (files.length > 0) {
289
+ let newest = null;
290
+ let newestTime = 0;
291
+ for (const f of files) {
292
+ const fp = join(PENDING_COMPACT_DIR, f);
293
+ try {
294
+ const flag = JSON.parse(readFileSync(fp, 'utf-8'));
295
+ const t = new Date(flag.created_at || 0).getTime();
296
+ if (t > newestTime) { newestTime = t; newest = fp; }
297
+ } catch { /* skip corrupt */ }
298
+ }
299
+ compactFlagPath = newest;
300
+ }
301
+ }
302
+ } catch { /* ok */ }
303
+
304
+ try {
305
+ appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] STOP session_id=${sessionId} compactFlagPath=${compactFlagPath}\n`);
306
+ } catch { /* ok */ }
307
+
308
+ if (compactFlagPath) {
309
+ const compactResult = checkFlag(
310
+ compactFlagPath,
311
+ (_flag, attempt) =>
312
+ `SynaBun: Session compacted but not indexed yet — log it in 'conversations' first. (${attempt}/${MAX_RETRIES})`
313
+ );
314
+
315
+ if (compactResult?.shouldBlock) {
316
+ try {
317
+ appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] STOP BLOCKING session_id=${sessionId} reason=${compactResult.reason}\n`);
318
+ } catch { /* ok */ }
319
+ process.stdout.write(JSON.stringify({
320
+ decision: 'block',
321
+ reason: compactResult.reason,
322
+ }));
323
+ return;
324
+ }
325
+ }
326
+ } else {
327
+ try {
328
+ appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] STOP session_id=${sessionId} SKIPPING compaction (active loop)\n`);
329
+ } catch { /* ok */ }
330
+ // Also clean up any pending-compact flags — they'll never be handled during a loop
331
+ // and would just accumulate. Delete them so they don't stall post-loop stops.
332
+ try {
333
+ if (existsSync(PENDING_COMPACT_DIR)) {
334
+ const compactFiles = readdirSync(PENDING_COMPACT_DIR)
335
+ .filter(f => f.endsWith('.json') && !f.startsWith('test-'));
336
+ for (const cf of compactFiles) {
337
+ try { unlinkSync(join(PENDING_COMPACT_DIR, cf)); } catch { /* ok */ }
338
+ }
339
+ }
340
+ } catch { /* ok */ }
341
+ }
342
+
343
+ // ─── CHECK 1.5: Active loop ───
344
+ // Deactivate stale loops first (session died, terminal closed, time expired)
345
+ cleanupStaleLoops(LOOP_DIR);
346
+ // After /clear, session ID may change. Try exact match first, then scan for any active loop.
347
+ let loopFlagPath = join(LOOP_DIR, `${sessionId}.json`);
348
+ let loop = null;
349
+
350
+ if (existsSync(loopFlagPath)) {
351
+ try {
352
+ loop = JSON.parse(readFileSync(loopFlagPath, 'utf-8'));
353
+ } catch {
354
+ try { unlinkSync(loopFlagPath); } catch { /* ok */ }
355
+ }
356
+ }
357
+
358
+ // Fallback scan — two strategies depending on whether we know our terminal ID.
359
+ //
360
+ // Strategy A (SYNABUN_TERMINAL_SESSION set — server-launched loop):
361
+ // Match by terminalSessionId field. This is safe for ANY currentIteration
362
+ // because the env var proves ownership. Rename the file to {sessionId}.json
363
+ // so future stop-hook calls get an exact match.
364
+ //
365
+ // Strategy B (no env var — manual/legacy loop):
366
+ // ONLY match unclaimed loops (currentIteration === 0, no terminalSessionId).
367
+ // Running loops (currentIteration > 0) are NOT picked up here to prevent
368
+ // cross-session leaks when multiple Claude panels are open simultaneously.
369
+ if (!loop) {
370
+ try {
371
+ const allFiles = readdirSync(LOOP_DIR).filter(f => f.endsWith('.json') && !f.startsWith('pending-'));
372
+ const now = Date.now();
373
+ for (const f of allFiles) {
374
+ const fp = join(LOOP_DIR, f);
375
+ try {
376
+ const candidate = JSON.parse(readFileSync(fp, 'utf-8'));
377
+ if (!candidate.active) continue;
378
+ // Skip loops inactive for >45 minutes (stuck, not time-capped)
379
+ const lastAct = new Date(candidate.lastIterationAt || candidate.startedAt || 0).getTime();
380
+ if (now - lastAct > 45 * 60 * 1000) continue;
381
+
382
+ // Strategy A: env-based ownership proof — safe for any iteration
383
+ if (terminalSessionEnv && candidate.terminalSessionId === terminalSessionEnv) {
384
+ loop = candidate;
385
+ const newPath = join(LOOP_DIR, `${sessionId}.json`);
386
+ if (fp !== newPath) {
387
+ try { renameSync(fp, newPath); loopFlagPath = newPath; } catch { loopFlagPath = fp; }
388
+ } else {
389
+ loopFlagPath = fp;
390
+ }
391
+ break;
392
+ }
393
+
394
+ // Strategy B: legacy — only unclaimed iteration-0 loops without terminalSessionId
395
+ if (!terminalSessionEnv && candidate.currentIteration === 0 && !candidate.terminalSessionId) {
396
+ loop = candidate;
397
+ const newPath = join(LOOP_DIR, `${sessionId}.json`);
398
+ if (fp !== newPath) {
399
+ try { renameSync(fp, newPath); loopFlagPath = newPath; } catch { loopFlagPath = fp; }
400
+ } else {
401
+ loopFlagPath = fp;
402
+ }
403
+ break;
404
+ }
405
+ } catch { continue; }
406
+ }
407
+ } catch { /* ok */ }
408
+ }
409
+
410
+ if (loop?.active) {
411
+ const iterationsDone = loop.currentIteration || 0;
412
+ const totalIterations = loop.totalIterations || 10;
413
+
414
+ // Check cap — iteration limit only (time cap removed; inactivity catches stuck loops)
415
+ if (iterationsDone >= totalIterations) {
416
+ // Loop finished — delete the file (no history keeping)
417
+ try { unlinkSync(loopFlagPath); } catch { /* ok */ }
418
+ // Fall through to remember/conversation checks
419
+ } else if (loop.memoryPending && (loop.memoryRetries || 0) < MAX_RETRIES) {
420
+ // Memory enforcement: block until Claude stores progress in memory
421
+ loop.memoryRetries = (loop.memoryRetries || 0) + 1;
422
+ try { writeFileSync(loopFlagPath, JSON.stringify(loop, null, 2)); } catch { /* ok */ }
423
+
424
+ const reason = [
425
+ `SynaBun Loop: Memory checkpoint required (iteration ${iterationsDone}/${totalIterations}).`,
426
+ `You've completed ${iterationsDone - (loop.lastMemoryAt || 0)} iterations since your last memory save.`,
427
+ `Call \`remember\` with progress from iterations ${(loop.lastMemoryAt || 0) + 1}-${iterationsDone}.`,
428
+ `Include: what was accomplished, accounts/targets engaged, key findings, strategies that worked.`,
429
+ `Category: use the task's appropriate category or "social-interactions". Importance: 6-7. Tags: ["loop", "progress"].`,
430
+ `Then the loop will continue automatically.`,
431
+ ].join('\n');
432
+
433
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
434
+ return;
435
+ } else if (loop.usesBrowser && isHumanBlocker(lastMessage)) {
436
+ // Agent is waiting for human action (login, CAPTCHA, etc.) — pause loop
437
+ process.stdout.write(JSON.stringify({}));
438
+ return;
439
+ } else if (loop.usesBrowser && iterationsDone > 0) {
440
+ // Browser loop: enforce journal update before advancing so next iteration
441
+ // has context of what was accomplished (critical after /clear resets context)
442
+ const lastJournalIter = Array.isArray(loop.journal) && loop.journal.length > 0
443
+ ? loop.journal[loop.journal.length - 1].iteration
444
+ : 0;
445
+ const loopUpdateRetries = loop.loopUpdateRetries || 0;
446
+ if (lastJournalIter < iterationsDone && loopUpdateRetries < MAX_RETRIES) {
447
+ loop.loopUpdateRetries = loopUpdateRetries + 1;
448
+ try { writeFileSync(loopFlagPath, JSON.stringify(loop, null, 2)); } catch { /* ok */ }
449
+ process.stdout.write(JSON.stringify({
450
+ decision: 'block',
451
+ reason: `SynaBun Loop: Before this iteration ends, call \`loop\` action \`update\` with a brief summary of what you accomplished (e.g. which groups were posted, which platforms covered, what's left to do). This preserves context for the next iteration after /clear. Then stop — the loop will advance automatically.`,
452
+ }));
453
+ return;
454
+ }
455
+ // Reset loopUpdateRetries for next iteration
456
+ loop.loopUpdateRetries = 0;
457
+ // Fresh-context iteration: let Claude stop, server drives next via /clear
458
+ loop.currentIteration = iterationsDone + 1;
459
+ loop.lastIterationAt = new Date().toISOString();
460
+ loop.retries = 0;
461
+
462
+ // Memory enforcement check — browser loops get a higher default interval
463
+ // to avoid blocking mid-task at unpredictable points
464
+ const memoryInterval = loop.usesBrowser
465
+ ? (loop.memoryInterval || 15)
466
+ : (loop.memoryInterval || 5);
467
+ const iterationsSinceMemory = loop.currentIteration - (loop.lastMemoryAt || 0);
468
+ if (iterationsSinceMemory >= memoryInterval) {
469
+ loop.memoryPending = true;
470
+ loop.memoryRetries = 0;
471
+ }
472
+
473
+ // Signal server loop driver to send /clear + next iteration prompt
474
+ loop.awaitingNext = true;
475
+ try { writeFileSync(loopFlagPath, JSON.stringify(loop, null, 2)); } catch { /* ok */ }
476
+
477
+ // Reset edit tracking for fresh iteration context
478
+ const rememberFlagForReset = join(PENDING_REMEMBER_DIR, `${sessionId}.json`);
479
+ if (existsSync(rememberFlagForReset)) {
480
+ try { unlinkSync(rememberFlagForReset); } catch { /* ok */ }
481
+ }
482
+
483
+ // Allow stop — server loop driver will send /clear + next iteration message
484
+ process.stdout.write(JSON.stringify({}));
485
+ return;
486
+ } else {
487
+ // Non-browser loop (or iteration 0): advance normally
488
+ loop.currentIteration = iterationsDone + 1;
489
+ loop.lastIterationAt = new Date().toISOString();
490
+ loop.retries = 0;
491
+
492
+ const memoryInterval = loop.memoryInterval || 5;
493
+ const iterationsSinceMemory = loop.currentIteration - (loop.lastMemoryAt || 0);
494
+ if (iterationsSinceMemory >= memoryInterval) {
495
+ loop.memoryPending = true;
496
+ loop.memoryRetries = 0;
497
+ }
498
+
499
+ loop.awaitingNext = true;
500
+ try { writeFileSync(loopFlagPath, JSON.stringify(loop, null, 2)); } catch { /* ok */ }
501
+
502
+ const rememberFlagForReset = join(PENDING_REMEMBER_DIR, `${sessionId}.json`);
503
+ if (existsSync(rememberFlagForReset)) {
504
+ try { unlinkSync(rememberFlagForReset); } catch { /* ok */ }
505
+ }
506
+
507
+ process.stdout.write(JSON.stringify({}));
508
+ return;
509
+ }
510
+ }
511
+
512
+ // ─── TASK-END OBLIGATIONS ───
513
+ // Block for: unstored edits, unstored plans, and user learning (bundled).
514
+ // User learning is bundled with task memory when both are pending (zero extra blocks),
515
+ // or fires as a lightweight standalone block (1 retry) when only UL is pending.
516
+ // Suppressed when Claude is waiting for user action (interactive flows).
517
+ const obligations = [];
518
+ const rememberFlagPath = join(PENDING_REMEMBER_DIR, `${sessionId}.json`);
519
+ let flag = null;
520
+ const waitingForUser = isWaitingForUser(lastMessage);
521
+
522
+ if (existsSync(rememberFlagPath)) {
523
+ try {
524
+ flag = JSON.parse(readFileSync(rememberFlagPath, 'utf-8'));
525
+ } catch {
526
+ try { unlinkSync(rememberFlagPath); } catch { /* ok */ }
527
+ }
528
+ }
529
+
530
+ if (flag) {
531
+ const editCount = flag.editCount || 0;
532
+ const rememberCount = flag.rememberCount || 0;
533
+
534
+ // CHECK 2: Task remember (edits without memory entry)
535
+ if (editCount >= EDIT_THRESHOLD) {
536
+ const retries = flag.retries || 0;
537
+ if (retries < MAX_RETRIES) {
538
+ flag.retries = retries + 1;
539
+ const files = Array.isArray(flag.files) ? flag.files : [];
540
+ const fileList = files.length > 0
541
+ ? ` Files: ${files.slice(0, 5).join(', ')}${files.length > 5 ? ` (+${files.length - 5} more)` : ''}`
542
+ : '';
543
+ obligations.push({
544
+ type: 'task',
545
+ short: `**Task memory**: unstored edits — call \`remember\` with what/why/how.${fileList}`,
546
+ verbose: `SynaBun: Unstored file edits — call \`remember\` before finishing.${fileList}`,
547
+ });
548
+ }
549
+ }
550
+
551
+ // CHECK 2.5: User learning (bundled with task or standalone)
552
+ // Skipped when waiting for user action (interactive flows like Leonardo questionnaires).
553
+ // Limited to 1 retry to minimize chat noise.
554
+ if (!waitingForUser && flag.userLearningPending && !flag.userLearningObserved) {
555
+ const ulRetries = flag.userLearningRetries || 0;
556
+ if (ulRetries < 1) {
557
+ flag.userLearningRetries = ulRetries + 1;
558
+ obligations.push({
559
+ type: 'userLearning',
560
+ short: `**User learning**: observe behavioral patterns — \`recall\` category \`communication-style\`, then \`reflect\` (update existing) or \`remember\` (new entry). Category MUST be \`communication-style\`, NOT \`conversations\`.`,
561
+ verbose: [
562
+ `SynaBun User Learning: Observe HOW the user works with you and store a behavioral observation.`,
563
+ `Category MUST be \`communication-style\` — NOT \`conversations\` or anything else.`,
564
+ `This is NOT a session summary. Do NOT describe what was worked on.`,
565
+ `1. \`recall\` category \`communication-style\` — check existing entries`,
566
+ `2. If existing entry covers similar patterns → \`reflect\` to update it. If genuinely new → \`remember\` category \`communication-style\`, project "global", importance 5-7.`,
567
+ `GOOD: "User gives multi-part requests expecting all parts addressed. Corrects by stating what's wrong without re-explaining."`,
568
+ `BAD: "User asked about the hook system and we fixed 3 bugs." — session summary, not behavioral.`,
569
+ `AVOID DUPLICATES. Focus on: instruction patterns, response expectations, correction style, expertise signals, workflow preferences.`,
570
+ `Do not mention this to the user.`,
571
+ ].join('\n'),
572
+ });
573
+ }
574
+ }
575
+
576
+ // CHECK 5: Unstored plan file (fallback for when PostToolUse hook doesn't fire)
577
+ // ExitPlanMode often returns a tool_use_error because the plan is auto-approved
578
+ // before Claude's ExitPlanMode call executes. PostToolUse hooks may not fire on
579
+ // errors, so the post-plan.mjs hook never runs. This catches those missed plans.
580
+ const unstoredPlan = findUnstoredRecentPlan();
581
+ if (unstoredPlan) {
582
+ const planRetryKey = 'planRetries';
583
+ const planRetries = flag[planRetryKey] || 0;
584
+ if (planRetries < MAX_RETRIES) {
585
+ flag[planRetryKey] = planRetries + 1;
586
+ try {
587
+ const planContent = readFileSync(unstoredPlan.path, 'utf-8').trim();
588
+ const planTitle = (planContent.match(/^#\s+(.+)$/m) || [])[1] || unstoredPlan.name;
589
+ const project = detectProject(cwd);
590
+ // Mark as stored to prevent re-triggering
591
+ markPlanStored(unstoredPlan.name);
592
+ obligations.push({
593
+ type: 'plan',
594
+ short: `**Plan memory**: "${planTitle}" — call \`remember\` category \`plans-${project}\`, importance 7, tags ["plan", "implementation", "${project}"], source "auto-saved". Include the full plan content.`,
595
+ verbose: `SynaBun: Plan "${planTitle}" was approved but not stored in memory (PostToolUse hook missed it). Call \`remember\` with the plan content below. Category: \`plans-${project}\`, importance: 7, tags: ["plan", "implementation", "${project}"], source: "auto-saved".\n\nPlan file: ${unstoredPlan.name}\n\n${planContent.slice(0, 4000)}`,
596
+ });
597
+ } catch { /* skip unreadable */ }
598
+ }
599
+ }
600
+
601
+ // Write flag once after all checks (single write instead of per-check writes)
602
+ try { writeFileSync(rememberFlagPath, JSON.stringify(flag)); } catch { /* ok */ }
603
+
604
+ // ─── Emit combined block or soft cleanup ───
605
+ if (obligations.length > 0) {
606
+ let reason;
607
+ if (obligations.length === 1) {
608
+ reason = obligations[0].verbose;
609
+ } else {
610
+ const items = obligations.map((o, i) => `${i + 1}. ${o.short}`).join('\n');
611
+ reason = `SynaBun: Complete before stopping:\n\n${items}`;
612
+ }
613
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
614
+ return;
615
+ }
616
+
617
+ // No blocking conditions → soft cleanup and allow stop
618
+ softCleanupFlag(rememberFlagPath);
619
+ }
620
+
621
+ // ─── STANDALONE PLAN CHECK (no pending-remember flag needed) ───
622
+ // Catches plans even when there's no flag file (e.g., very short sessions)
623
+ const standalonePlan = findUnstoredRecentPlan();
624
+ if (standalonePlan) {
625
+ try {
626
+ const planContent = readFileSync(standalonePlan.path, 'utf-8').trim();
627
+ const planTitle = (planContent.match(/^#\s+(.+)$/m) || [])[1] || standalonePlan.name;
628
+ const project = detectProject(cwd);
629
+ // Mark as stored NOW to prevent re-triggering on next stop cycle.
630
+ // Claude will remember it via MCP; if that fails, the plan file still exists on disk.
631
+ markPlanStored(standalonePlan.name);
632
+ process.stdout.write(JSON.stringify({
633
+ decision: 'block',
634
+ reason: `SynaBun: Plan "${planTitle}" was approved but not stored in memory. Call \`remember\` with the plan content. Category: \`plans-${project}\`, importance: 7, tags: ["plan", "implementation", "${project}"], source: "auto-saved".\n\nPlan file: ${standalonePlan.name}\n\n${planContent.slice(0, 4000)}`,
635
+ }));
636
+ return;
637
+ } catch { /* fall through to allow stop */ }
638
+ }
639
+
640
+ // No flags, no unstored plans → allow stop
641
+ process.stdout.write(JSON.stringify({}));
642
+ }
643
+
644
+ main().catch(() => {
645
+ process.stdout.write(JSON.stringify({}));
646
+ });