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.
- package/CHANGELOG.md +665 -0
- package/LICENSE +191 -0
- package/README.md +853 -0
- package/hooks/claude-code/post-plan.mjs +391 -0
- package/hooks/claude-code/post-remember.mjs +239 -0
- package/hooks/claude-code/pre-compact.mjs +241 -0
- package/hooks/claude-code/pre-websearch.mjs +90 -0
- package/hooks/claude-code/prompt-submit.mjs +861 -0
- package/hooks/claude-code/session-start.mjs +494 -0
- package/hooks/claude-code/shared.mjs +368 -0
- package/hooks/claude-code/stop.mjs +646 -0
- package/hooks/claude-code/test-user-learning.mjs +531 -0
- package/mcp-server/package.json +38 -0
- package/mcp-server/run.mjs +10 -0
- package/mcp-server/src/config.ts +70 -0
- package/mcp-server/src/http.ts +93 -0
- package/mcp-server/src/index.ts +203 -0
- package/mcp-server/src/preload-http.ts +25 -0
- package/mcp-server/src/preload.ts +29 -0
- package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
- package/mcp-server/src/services/categories.ts +429 -0
- package/mcp-server/src/services/discord.ts +702 -0
- package/mcp-server/src/services/file-checksums.ts +33 -0
- package/mcp-server/src/services/local-embeddings.ts +77 -0
- package/mcp-server/src/services/neural-interface.ts +344 -0
- package/mcp-server/src/services/sqlite.ts +823 -0
- package/mcp-server/src/tools/browser-advanced.ts +123 -0
- package/mcp-server/src/tools/browser-interact.ts +255 -0
- package/mcp-server/src/tools/browser-navigate.ts +77 -0
- package/mcp-server/src/tools/browser-observe.ts +1330 -0
- package/mcp-server/src/tools/browser.ts +107 -0
- package/mcp-server/src/tools/card-tools.ts +193 -0
- package/mcp-server/src/tools/card.ts +22 -0
- package/mcp-server/src/tools/category.ts +379 -0
- package/mcp-server/src/tools/discord-channel.ts +274 -0
- package/mcp-server/src/tools/discord-guild.ts +188 -0
- package/mcp-server/src/tools/discord-member.ts +158 -0
- package/mcp-server/src/tools/discord-message.ts +231 -0
- package/mcp-server/src/tools/discord-onboarding.ts +228 -0
- package/mcp-server/src/tools/discord-role.ts +209 -0
- package/mcp-server/src/tools/discord-thread.ts +110 -0
- package/mcp-server/src/tools/discord-webhook.ts +176 -0
- package/mcp-server/src/tools/discord.ts +40 -0
- package/mcp-server/src/tools/forget.ts +38 -0
- package/mcp-server/src/tools/git-tools.ts +141 -0
- package/mcp-server/src/tools/git.ts +13 -0
- package/mcp-server/src/tools/image-tools.ts +98 -0
- package/mcp-server/src/tools/image.ts +17 -0
- package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
- package/mcp-server/src/tools/leonardo.ts +28 -0
- package/mcp-server/src/tools/loop.ts +348 -0
- package/mcp-server/src/tools/memories.ts +118 -0
- package/mcp-server/src/tools/recall.ts +323 -0
- package/mcp-server/src/tools/reflect.ts +145 -0
- package/mcp-server/src/tools/remember.ts +115 -0
- package/mcp-server/src/tools/response.ts +26 -0
- package/mcp-server/src/tools/restore.ts +34 -0
- package/mcp-server/src/tools/sync.ts +114 -0
- package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
- package/mcp-server/src/tools/tictactoe.ts +13 -0
- package/mcp-server/src/tools/utils.ts +25 -0
- package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
- package/mcp-server/src/tools/whiteboard.ts +22 -0
- package/mcp-server/src/tui.ts +940 -0
- package/mcp-server/src/types.ts +85 -0
- package/mcp-server/tsconfig.json +19 -0
- package/neural-interface/package.json +27 -0
- package/neural-interface/public/apple-touch-icon.png +0 -0
- package/neural-interface/public/category-logos/claude.svg +1 -0
- package/neural-interface/public/category-logos/gemini-color.svg +1 -0
- package/neural-interface/public/category-logos/openai.svg +1 -0
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude-chat.css +878 -0
- package/neural-interface/public/claude-chat.html +92 -0
- package/neural-interface/public/claude-chat.js +1168 -0
- package/neural-interface/public/favicon-16x16.png +0 -0
- package/neural-interface/public/favicon-32x32.png +0 -0
- package/neural-interface/public/gallery.svg +6 -0
- package/neural-interface/public/icon-192.png +0 -0
- package/neural-interface/public/icon-512.png +0 -0
- package/neural-interface/public/index-legacy.html +14677 -0
- package/neural-interface/public/index.html +212 -0
- package/neural-interface/public/index2d-legacy.html +8193 -0
- package/neural-interface/public/index2d.html +127 -0
- package/neural-interface/public/invite.html +351 -0
- package/neural-interface/public/leonardoai.svg +1 -0
- package/neural-interface/public/logo-synabun.png +0 -0
- package/neural-interface/public/logoHD.png +0 -0
- package/neural-interface/public/manifest.json +14 -0
- package/neural-interface/public/offline.html +299 -0
- package/neural-interface/public/onboarding.html +2335 -0
- package/neural-interface/public/openclaw-logo-text.png +0 -0
- package/neural-interface/public/pin-svgrepo-com.svg +25 -0
- package/neural-interface/public/sessionicon.svg +8 -0
- package/neural-interface/public/shared/ansi-parser.js +305 -0
- package/neural-interface/public/shared/api.js +1043 -0
- package/neural-interface/public/shared/colors.js +153 -0
- package/neural-interface/public/shared/constants.js +141 -0
- package/neural-interface/public/shared/html-shell.js +777 -0
- package/neural-interface/public/shared/html-term-renderer.js +1022 -0
- package/neural-interface/public/shared/i18n.js +147 -0
- package/neural-interface/public/shared/registry.js +82 -0
- package/neural-interface/public/shared/state.js +86 -0
- package/neural-interface/public/shared/storage.js +150 -0
- package/neural-interface/public/shared/styles.css +21212 -0
- package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
- package/neural-interface/public/shared/ui-bookmarks.js +231 -0
- package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
- package/neural-interface/public/shared/ui-command-runner.js +534 -0
- package/neural-interface/public/shared/ui-cost-widget.js +602 -0
- package/neural-interface/public/shared/ui-detail.js +1454 -0
- package/neural-interface/public/shared/ui-explorer.js +1356 -0
- package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
- package/neural-interface/public/shared/ui-games.js +411 -0
- package/neural-interface/public/shared/ui-help.js +107 -0
- package/neural-interface/public/shared/ui-image-gallery.js +461 -0
- package/neural-interface/public/shared/ui-invite.js +519 -0
- package/neural-interface/public/shared/ui-keybinds.js +452 -0
- package/neural-interface/public/shared/ui-layouts.js +297 -0
- package/neural-interface/public/shared/ui-link.js +731 -0
- package/neural-interface/public/shared/ui-loading.js +255 -0
- package/neural-interface/public/shared/ui-menubar.js +499 -0
- package/neural-interface/public/shared/ui-multiselect.js +93 -0
- package/neural-interface/public/shared/ui-navbar.js +222 -0
- package/neural-interface/public/shared/ui-notifications.js +287 -0
- package/neural-interface/public/shared/ui-panels.js +309 -0
- package/neural-interface/public/shared/ui-resume.js +653 -0
- package/neural-interface/public/shared/ui-search.js +145 -0
- package/neural-interface/public/shared/ui-sessions.js +531 -0
- package/neural-interface/public/shared/ui-settings.js +5987 -0
- package/neural-interface/public/shared/ui-sidebar.js +1905 -0
- package/neural-interface/public/shared/ui-skills.js +2805 -0
- package/neural-interface/public/shared/ui-stats.js +38 -0
- package/neural-interface/public/shared/ui-sync.js +201 -0
- package/neural-interface/public/shared/ui-terminal.js +6385 -0
- package/neural-interface/public/shared/ui-tooltip.js +123 -0
- package/neural-interface/public/shared/ui-trash.js +439 -0
- package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
- package/neural-interface/public/shared/ui-tutorial.js +1117 -0
- package/neural-interface/public/shared/ui-update.js +244 -0
- package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
- package/neural-interface/public/shared/ui-workspaces.js +722 -0
- package/neural-interface/public/shared/utils.js +360 -0
- package/neural-interface/public/shared/vterm-buffer.js +1102 -0
- package/neural-interface/public/sw.js +52 -0
- package/neural-interface/public/synabun.png +0 -0
- package/neural-interface/public/toolbar-album.svg +8 -0
- package/neural-interface/public/toolbar-question.svg +6 -0
- package/neural-interface/public/toolbar-sessions.svg +6 -0
- package/neural-interface/public/variant/2d/background.js +113 -0
- package/neural-interface/public/variant/2d/context-menu.js +133 -0
- package/neural-interface/public/variant/2d/gfx.js +45 -0
- package/neural-interface/public/variant/2d/graph.js +1635 -0
- package/neural-interface/public/variant/2d/hulls.js +207 -0
- package/neural-interface/public/variant/2d/lasso.js +135 -0
- package/neural-interface/public/variant/2d/layout.js +594 -0
- package/neural-interface/public/variant/2d/main.js +521 -0
- package/neural-interface/public/variant/2d/minimap.js +312 -0
- package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
- package/neural-interface/public/variant/3d/background.js +884 -0
- package/neural-interface/public/variant/3d/camera.js +661 -0
- package/neural-interface/public/variant/3d/gfx.js +116 -0
- package/neural-interface/public/variant/3d/graph.js +2399 -0
- package/neural-interface/public/variant/3d/main.js +500 -0
- package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
- package/neural-interface/scripts/rebuild-pty.js +100 -0
- package/neural-interface/server.js +15000 -0
- package/neural-interface/templates/CLAUDE-template.md +211 -0
- package/package.json +53 -0
- package/postinstall.js +135 -0
- package/setup.js +260 -0
- package/skills/leonardo/SKILL.md +159 -0
- package/skills/leonardo/icon.svg +1 -0
- package/skills/leonardo/modules/browser-guide.md +378 -0
- package/skills/leonardo/modules/image-prompter.md +277 -0
- package/skills/leonardo/modules/model-advisor.md +100 -0
- package/skills/leonardo/modules/prompt-library.md +110 -0
- package/skills/leonardo/modules/style-guide.md +186 -0
- package/skills/leonardo/modules/video-prompter.md +311 -0
- package/skills/synabun/SKILL.md +185 -0
- package/skills/synabun/icon.png +0 -0
- package/skills/synabun/modules/audit.md +374 -0
- package/skills/synabun/modules/changelog.md +194 -0
- package/skills/synabun/modules/idea.md +144 -0
- package/skills/synabun/modules/memorize.md +188 -0
- 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
|
+
});
|