metame-cli 1.5.8 → 1.5.10

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.
@@ -0,0 +1,421 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const { execSync } = require('child_process');
7
+
8
+ /**
9
+ * daemon-reactive-lifecycle.js — Reactive Loop Lifecycle Module
10
+ *
11
+ * Extracts reactive dispatch logic from daemon.js into a testable,
12
+ * self-contained module with four hard gates:
13
+ * 1. Budget gate — pauses loop when daily budget exhausted
14
+ * 2. Depth gate — pauses loop when depth counter hits max
15
+ * 3. Fresh session — every reactive dispatch uses new_session: true
16
+ * 4. RESEARCH_COMPLETE — resets depth, marks completed, notifies user
17
+ * 5. Verifier hook — runs project verifier before waking parent
18
+ */
19
+
20
+ // ── Signal parsing ──────────────────────────────────────────────
21
+
22
+ // Signal patterns (created per-call to avoid global regex lastIndex state)
23
+ // Supports two formats (both common in LLM output):
24
+ // NEXT_DISPATCH: target "prompt here" (quoted, single-line)
25
+ // NEXT_DISPATCH: target: prompt here (colon-separated, until next directive or end)
26
+ const QUOTED_PATTERN = /NEXT_DISPATCH:\s*(\S+)\s+"([^"]+)"/g;
27
+ const COLON_PATTERN = /NEXT_DISPATCH\s*:\s*(\S+)\s*:\s*(.+?)(?=\nNEXT_DISPATCH\s*:|$)/gs;
28
+ const RESEARCH_COMPLETE_RE = /RESEARCH_COMPLETE/;
29
+
30
+ /**
31
+ * Parse reactive signals from agent output.
32
+ * Pure function — no side effects.
33
+ *
34
+ * @param {string} output - Raw agent output text
35
+ * @returns {{ directives: Array<{target: string, prompt: string}>, complete: boolean }}
36
+ */
37
+ function parseReactiveSignals(output) {
38
+ const directives = [];
39
+ let match;
40
+ // Try quoted format first (preferred, documented in CLAUDE.md)
41
+ // Create fresh regex each call to avoid global lastIndex state pollution
42
+ const quotedRe = new RegExp(QUOTED_PATTERN.source, QUOTED_PATTERN.flags);
43
+ while ((match = quotedRe.exec(output)) !== null) {
44
+ const target = match[1].trim();
45
+ const prompt = match[2].trim();
46
+ if (target && prompt) directives.push({ target, prompt });
47
+ }
48
+ // Fallback: colon-separated format (tolerant of LLM variation)
49
+ if (directives.length === 0) {
50
+ const colonRe = new RegExp(COLON_PATTERN.source, COLON_PATTERN.flags);
51
+ while ((match = colonRe.exec(output)) !== null) {
52
+ const target = match[1].trim();
53
+ const prompt = match[2].trim();
54
+ if (target && prompt) directives.push({ target, prompt });
55
+ }
56
+ }
57
+ const complete = RESEARCH_COMPLETE_RE.test(output);
58
+ return { directives, complete };
59
+ }
60
+
61
+ // ── Internal state helpers (not exported) ───────────────────────
62
+
63
+ function getReactiveState(state, projectKey) {
64
+ if (!state.reactive) state.reactive = {};
65
+ if (!state.reactive[projectKey]) {
66
+ state.reactive[projectKey] = {
67
+ depth: 0,
68
+ max_depth: 50,
69
+ status: 'idle',
70
+ pause_reason: '',
71
+ last_signal: '',
72
+ updated_at: new Date().toISOString(),
73
+ };
74
+ }
75
+ return state.reactive[projectKey];
76
+ }
77
+
78
+ function setReactiveStatus(state, projectKey, status, reason) {
79
+ const rs = getReactiveState(state, projectKey);
80
+ rs.status = status;
81
+ rs.pause_reason = reason || '';
82
+ rs.updated_at = new Date().toISOString();
83
+ }
84
+
85
+ /**
86
+ * Find the reactive parent project key for a given team member.
87
+ * Returns the parent key string, or null if not found.
88
+ */
89
+ function findReactiveParent(targetProject, config) {
90
+ if (!config || !config.projects) return null;
91
+ for (const [parentKey, proj] of Object.entries(config.projects)) {
92
+ if (!proj || !Array.isArray(proj.team)) continue;
93
+ if (proj.team.some(m => m.key === targetProject)) {
94
+ return parentKey;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Check if a project is configured as a reactive parent.
102
+ * A reactive parent has a `reactive` truthy flag in its project config.
103
+ */
104
+ function isReactiveParent(projectKey, config) {
105
+ if (!config || !config.projects) return false;
106
+ const proj = config.projects[projectKey];
107
+ return !!(proj && proj.reactive);
108
+ }
109
+
110
+ // ── Verifier helpers ────────────────────────────────────────────
111
+
112
+ function resolveProjectCwd(projectKey, config) {
113
+ const proj = config.projects?.[projectKey];
114
+ if (!proj || !proj.cwd) return null;
115
+ return proj.cwd.replace(/^~/, os.homedir());
116
+ }
117
+
118
+ function readPhaseFromState(statePath) {
119
+ try {
120
+ const content = fs.readFileSync(statePath, 'utf8');
121
+ const match = content.match(/^phase:\s*(\S+)/m);
122
+ return match ? match[1] : '';
123
+ } catch { return ''; }
124
+ }
125
+
126
+ /**
127
+ * Run project-level verifier script if it exists.
128
+ * Returns parsed JSON result or null if no verifier / error.
129
+ */
130
+ function runProjectVerifier(projectKey, config, deps) {
131
+ const projectCwd = resolveProjectCwd(projectKey, config);
132
+ if (!projectCwd) return null;
133
+
134
+ const verifierPath = path.join(projectCwd, 'scripts', 'research-verifier.js');
135
+ if (!fs.existsSync(verifierPath)) return null;
136
+
137
+ const statePath = path.join(
138
+ deps.metameDir || path.join(os.homedir(), '.metame'),
139
+ 'memory', 'now', `${projectKey}.md`
140
+ );
141
+ const phase = readPhaseFromState(statePath);
142
+
143
+ try {
144
+ const output = execSync('node scripts/research-verifier.js', {
145
+ cwd: projectCwd,
146
+ encoding: 'utf8',
147
+ timeout: 15000,
148
+ env: {
149
+ ...process.env,
150
+ VERIFIER_CWD: projectCwd,
151
+ VERIFIER_PHASE: phase || '',
152
+ VERIFIER_STATE_PATH: statePath,
153
+ },
154
+ }).trim();
155
+ return JSON.parse(output);
156
+ } catch (e) {
157
+ deps.log('WARN', `Verifier failed for ${projectKey}: ${e.message}`);
158
+ return { passed: false, phase: phase || 'unknown', details: `verifier_error: ${e.message.slice(0, 200)}`, artifacts: [], hints: ['验证器执行失败,请检查 verifier 脚本'] };
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Run project-level completion hooks (archive + topic pool).
164
+ * Platform only calls scripts if they exist — no business logic here.
165
+ * @returns {{ archived: boolean, nextTopic: string|null, nextTopicPrompt: string|null }}
166
+ */
167
+ function runCompletionHooks(projectKey, projectCwd, deps) {
168
+ const result = { archived: false, nextTopic: null, nextTopicPrompt: null };
169
+
170
+ // 1. Archive
171
+ const archiveScript = path.join(projectCwd, 'scripts', 'research-archive.js');
172
+ if (fs.existsSync(archiveScript)) {
173
+ try {
174
+ // Read project name from state file
175
+ const statePath = path.join(
176
+ deps.metameDir || path.join(os.homedir(), '.metame'),
177
+ 'memory', 'now', `${projectKey}.md`
178
+ );
179
+ let projectName = projectKey;
180
+ try {
181
+ const stateContent = fs.readFileSync(statePath, 'utf8');
182
+ const m = stateContent.match(/^project:\s*"?(.+?)"?\s*$/m);
183
+ if (m) projectName = m[1];
184
+ } catch { /* use projectKey */ }
185
+
186
+ const archiveOut = execSync('node scripts/research-archive.js', {
187
+ cwd: projectCwd, encoding: 'utf8', timeout: 30000,
188
+ env: { ...process.env, ARCHIVE_CWD: projectCwd, ARCHIVE_PROJECT_NAME: projectName, ARCHIVE_STATE_PATH: statePath },
189
+ }).trim();
190
+ const archiveResult = JSON.parse(archiveOut);
191
+ result.archived = archiveResult.success === true;
192
+ deps.log('INFO', `Reactive: archive result for ${projectKey}: ${archiveOut.slice(0, 200)}`);
193
+ } catch (e) {
194
+ deps.log('WARN', `Reactive: archive failed for ${projectKey}: ${e.message}`);
195
+ }
196
+ }
197
+
198
+ // 2. Topic pool — only proceed if archive succeeded
199
+ const topicScript = path.join(projectCwd, 'scripts', 'topic-pool.js');
200
+ if (result.archived && fs.existsSync(topicScript)) {
201
+ const topicEnv = { ...process.env, TOPICS_CWD: projectCwd };
202
+ // 2a. Complete current active topic first
203
+ try {
204
+ const listOut = execSync('node scripts/topic-pool.js list', {
205
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
206
+ }).trim();
207
+ const listResult = JSON.parse(listOut);
208
+ if (listResult.success && Array.isArray(listResult.topics)) {
209
+ const activeTopic = listResult.topics.find(t => t.status === 'active');
210
+ if (activeTopic) {
211
+ execSync(`node scripts/topic-pool.js complete ${activeTopic.id}`, {
212
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
213
+ });
214
+ deps.log('INFO', `Reactive: completed active topic ${activeTopic.id}: ${activeTopic.title}`);
215
+ }
216
+ }
217
+ } catch (e) {
218
+ deps.log('WARN', `Reactive: topic complete failed: ${e.message}`);
219
+ }
220
+ // 2b. Get next pending topic
221
+ try {
222
+ const nextOut = execSync('node scripts/topic-pool.js next', {
223
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
224
+ }).trim();
225
+ const nextResult = JSON.parse(nextOut);
226
+ if (nextResult.success && nextResult.topic) {
227
+ // Activate the next topic
228
+ try {
229
+ execSync(`node scripts/topic-pool.js activate ${nextResult.topic.id}`, {
230
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
231
+ });
232
+ } catch (e) {
233
+ deps.log('WARN', `Reactive: topic activate failed: ${e.message}`);
234
+ }
235
+ result.nextTopic = nextResult.topic.title;
236
+ result.nextTopicPrompt = `新课题启动: "${nextResult.topic.title}"\n\n请开始研究这个课题。第一步:更新 now/${projectKey}.md 的 project 和 phase 字段,然后 NEXT_DISPATCH scout 进行文献调研。`;
237
+ deps.log('INFO', `Reactive: next topic for ${projectKey}: ${nextResult.topic.title}`);
238
+ }
239
+ } catch (e) {
240
+ deps.log('WARN', `Reactive: topic pool query failed for ${projectKey}: ${e.message}`);
241
+ }
242
+ } else if (!result.archived && fs.existsSync(topicScript)) {
243
+ deps.log('WARN', `Reactive: skipping topic pool for ${projectKey} — archive did not succeed`);
244
+ }
245
+
246
+ return result;
247
+ }
248
+
249
+ // ── Main handler ────────────────────────────────────────────────
250
+
251
+ /**
252
+ * Handle reactive agent output. Called from outputHandler in dispatchTask.
253
+ * Responsible for: signal parsing, budget gate, depth gate, completion,
254
+ * constructing follow-up dispatches.
255
+ *
256
+ * @param {string} targetProject - The project key that produced the output
257
+ * @param {string} output - Raw output text
258
+ * @param {object} config - Full daemon config
259
+ * @param {object} deps - Injected dependencies
260
+ * @param {Function} deps.log - (level, msg) => void
261
+ * @param {Function} deps.loadState - () => state
262
+ * @param {Function} deps.saveState - (state) => void
263
+ * @param {Function} deps.checkBudget - (config, state) => boolean
264
+ * @param {Function} deps.handleDispatchItem - (item, config) => result
265
+ * @param {Function} [deps.notifyUser] - (msg) => void (Feishu notification)
266
+ * @param {Function} [deps.runVerifier] - (projectKey, config) => {passed,phase,details,artifacts,hints} | null
267
+ * @param {string} [deps.metameDir] - Override ~/.metame path (for testing)
268
+ */
269
+ function handleReactiveOutput(targetProject, output, config, deps) {
270
+ if (!config || !config.projects) return;
271
+
272
+ const signals = parseReactiveSignals(output);
273
+ const hasSignals = signals.directives.length > 0 || signals.complete;
274
+
275
+ // ── Case 1: targetProject is a reactive parent ──
276
+ if (isReactiveParent(targetProject, config)) {
277
+ if (!hasSignals) return;
278
+
279
+ const projectKey = targetProject;
280
+ const st = deps.loadState();
281
+ const rs = getReactiveState(st, projectKey);
282
+
283
+ // RESEARCH_COMPLETE takes priority
284
+ if (signals.complete) {
285
+ deps.log('INFO', `Reactive: ${projectKey} research completed`);
286
+ setReactiveStatus(st, projectKey, 'completed', '');
287
+ st.reactive[projectKey].depth = 0;
288
+ rs.last_signal = 'RESEARCH_COMPLETE';
289
+ deps.saveState(st);
290
+
291
+ // Run completion hooks (archive + next topic) if project has scripts/
292
+ const projectCwd = resolveProjectCwd(projectKey, config);
293
+ if (projectCwd) {
294
+ const completionResult = runCompletionHooks(projectKey, projectCwd, deps);
295
+ const notifyMsg = completionResult.nextTopic
296
+ ? `\u2705 科研课题已完成并归档。下一课题: ${completionResult.nextTopic}`
297
+ : '\u2705 科研课题已完成并归档。无待处理课题,系统进入等待。';
298
+ if (deps.notifyUser) deps.notifyUser(notifyMsg);
299
+
300
+ // Auto-start next topic if available — requires budget to be OK
301
+ if (completionResult.nextTopic && completionResult.nextTopicPrompt) {
302
+ if (!deps.checkBudget(config, st)) {
303
+ deps.log('WARN', `Reactive: budget exceeded, skipping auto-start of next topic for ${projectKey}`);
304
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f 下一课题 "${completionResult.nextTopic}" 已就绪但预算不足,暂不启动`);
305
+ } else {
306
+ deps.log('INFO', `Reactive: auto-starting next topic for ${projectKey}: ${completionResult.nextTopic}`);
307
+ setReactiveStatus(st, projectKey, 'running', '');
308
+ st.reactive[projectKey].depth = 0;
309
+ deps.saveState(st);
310
+ deps.handleDispatchItem({
311
+ target: projectKey,
312
+ prompt: completionResult.nextTopicPrompt,
313
+ from: '_system',
314
+ _reactive: true,
315
+ new_session: true,
316
+ }, config);
317
+ }
318
+ }
319
+ } else {
320
+ if (deps.notifyUser) deps.notifyUser('\u2705 科研课题已完成');
321
+ }
322
+ return;
323
+ }
324
+
325
+ // NEXT_DISPATCH processing
326
+ rs.last_signal = 'NEXT_DISPATCH';
327
+
328
+ // Budget gate
329
+ if (!deps.checkBudget(config, st)) {
330
+ deps.log('WARN', `Reactive: budget exceeded, pausing ${projectKey}`);
331
+ setReactiveStatus(st, projectKey, 'paused', 'budget_exceeded');
332
+ deps.saveState(st);
333
+ if (deps.notifyUser) deps.notifyUser('\u26a0\ufe0f \u79d1\u7814\u5faa\u73af\u5df2\u6682\u505c\uff1a\u4eca\u65e5\u9884\u7b97\u5df2\u8017\u5c3d');
334
+ return;
335
+ }
336
+
337
+ // Depth gate
338
+ const maxDepth = rs.max_depth || 50;
339
+ if (rs.depth >= maxDepth) {
340
+ deps.log('WARN', `Reactive: depth ${rs.depth} >= ${maxDepth}, pausing ${projectKey}`);
341
+ setReactiveStatus(st, projectKey, 'paused', 'depth_exceeded');
342
+ deps.saveState(st);
343
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f \u79d1\u7814\u5faa\u73af\u5df2\u6682\u505c\uff1a\u5faa\u73af\u6df1\u5ea6\u8fbe\u5230\u4e0a\u9650 ${maxDepth}`);
344
+ return;
345
+ }
346
+
347
+ rs.depth += 1;
348
+ rs.status = 'running';
349
+ rs.updated_at = new Date().toISOString();
350
+ deps.saveState(st);
351
+
352
+ // Dispatch each directive with fresh session
353
+ for (const d of signals.directives) {
354
+ deps.handleDispatchItem({
355
+ target: d.target,
356
+ prompt: d.prompt,
357
+ from: projectKey,
358
+ _reactive: true,
359
+ new_session: true,
360
+ }, config);
361
+ }
362
+ return;
363
+ }
364
+
365
+ // ── Case 2: targetProject is a team member of a reactive parent ──
366
+ const parentKey = findReactiveParent(targetProject, config);
367
+ if (!parentKey || !isReactiveParent(parentKey, config)) return;
368
+
369
+ const st = deps.loadState();
370
+
371
+ // Budget gate
372
+ if (!deps.checkBudget(config, st)) {
373
+ deps.log('WARN', `Reactive: budget exceeded, pausing ${parentKey} (via member ${targetProject})`);
374
+ setReactiveStatus(st, parentKey, 'paused', 'budget_exceeded');
375
+ deps.saveState(st);
376
+ if (deps.notifyUser) deps.notifyUser('\u26a0\ufe0f \u79d1\u7814\u5faa\u73af\u5df2\u6682\u505c\uff1a\u4eca\u65e5\u9884\u7b97\u5df2\u8017\u5c3d');
377
+ return;
378
+ }
379
+
380
+ // Depth gate
381
+ const rs = getReactiveState(st, parentKey);
382
+ const maxDepth = rs.max_depth || 50;
383
+ if (rs.depth >= maxDepth) {
384
+ deps.log('WARN', `Reactive: depth ${rs.depth} >= ${maxDepth}, pausing ${parentKey} (via member ${targetProject})`);
385
+ setReactiveStatus(st, parentKey, 'paused', 'depth_exceeded');
386
+ deps.saveState(st);
387
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f \u79d1\u7814\u5faa\u73af\u5df2\u6682\u505c\uff1a\u5faa\u73af\u6df1\u5ea6\u8fbe\u5230\u4e0a\u9650 ${maxDepth}`);
388
+ return;
389
+ }
390
+
391
+ rs.depth += 1;
392
+ rs.status = 'running';
393
+ rs.last_signal = 'MEMBER_COMPLETE';
394
+ rs.updated_at = new Date().toISOString();
395
+ deps.saveState(st);
396
+
397
+ // Run verifier if available
398
+ const verifyResult = deps.runVerifier
399
+ ? deps.runVerifier(parentKey, config)
400
+ : runProjectVerifier(parentKey, config, deps);
401
+
402
+ const verifierBlock = verifyResult
403
+ ? `\n\n[验证门结果] phase=${verifyResult.phase} passed=${verifyResult.passed}\n${verifyResult.details}${verifyResult.hints?.length ? '\n建议: ' + verifyResult.hints.join('; ') : ''}`
404
+ : '\n\n[验证门结果] passed=false\n验证器未配置或不可用,请谨慎推进阶段';
405
+
406
+ // Trigger parent with member's output summary
407
+ const summary = output.slice(0, 1200);
408
+ deps.handleDispatchItem({
409
+ target: parentKey,
410
+ prompt: `[团队成员交付] ${targetProject} 完成任务。\n\n产出摘要:\n${summary}${verifierBlock}\n\n请阅读产出,评估质量,更新 now/${parentKey}.md,然后决定下一步。\n如需派发新任务,在回复末尾使用 NEXT_DISPATCH 指令。如研究全部完成,输出 RESEARCH_COMPLETE。`,
411
+ from: targetProject,
412
+ _reactive: true,
413
+ new_session: true,
414
+ }, config);
415
+ }
416
+
417
+ module.exports = {
418
+ handleReactiveOutput,
419
+ parseReactiveSignals,
420
+ __test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd },
421
+ };
@@ -841,6 +841,8 @@ function createSessionStore(deps) {
841
841
  function writeSessionName(sessionId, cwd, name) {
842
842
  void cwd;
843
843
  try {
844
+ // Clear stale cache — JSONL may have just been created by spawnClaudeStreaming
845
+ clearSessionFileCache(sessionId);
844
846
  const sessionFile = findSessionFile(sessionId);
845
847
  if (!sessionFile) {
846
848
  log('WARN', `writeSessionName: session file not found for ${sessionId.slice(0, 8)}`);
@@ -969,13 +971,26 @@ function createSessionStore(deps) {
969
971
  function _isClaudeSessionValid(sessionId, normCwd) {
970
972
  try {
971
973
  const sessionFile = findSessionFile(sessionId);
972
- if (!sessionFile) return false;
974
+ if (!sessionFile) {
975
+ log('WARN', `[SessionValid] ${sessionId.slice(0, 8)}: JSONL file not found`);
976
+ return false;
977
+ }
973
978
 
974
979
  // Try to read cwd/model from session JSONL file content (most reliable)
975
980
  const metadata = _readClaudeSessionMetadata(sessionFile);
976
- if (metadata.model && !metadata.model.startsWith('claude-')) return false;
981
+ if (metadata.model && !metadata.model.startsWith('claude-')) {
982
+ log('WARN', `[SessionValid] ${sessionId.slice(0, 8)}: non-claude model "${metadata.model}"`);
983
+ return false;
984
+ }
977
985
  if (metadata.cwd && path.resolve(metadata.cwd) === normCwd) return true;
978
- if (metadata.cwd) return false;
986
+ if (metadata.cwd) {
987
+ // CWD mismatch: the session was created for a different directory.
988
+ // However, if the JSONL file exists and has Claude content, the session is still
989
+ // usable — the cwd might have changed due to worktree cleanup, config reload, etc.
990
+ // Log the mismatch but allow resuming (Claude CLI handles cwd internally).
991
+ log('INFO', `[SessionValid] ${sessionId.slice(0, 8)}: cwd mismatch (jsonl="${metadata.cwd}" vs expected="${normCwd}") — allowing resume`);
992
+ return true;
993
+ }
979
994
  for (const line of metadata.lines.slice(0, 20)) { // preserve tolerant parsing for malformed heads
980
995
  try {
981
996
  const entry = JSON.parse(line);
@@ -984,27 +999,12 @@ function createSessionStore(deps) {
984
999
  } catch { /* skip non-JSON lines */ }
985
1000
  }
986
1001
 
987
- // Fallback: check sessions-index.json if exists
988
- const projectDir = path.dirname(sessionFile);
989
- const indexFile = path.join(projectDir, 'sessions-index.json');
990
- if (fs.existsSync(indexFile)) {
991
- const data = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
992
- const entries = Array.isArray(data && data.entries) ? data.entries : [];
993
- const entry = entries.find(e => e && e.sessionId === sessionId);
994
- if (entry && entry.projectPath) return path.resolve(entry.projectPath) === normCwd;
995
- const anyPath = (entries.find(e => e && e.projectPath) || {}).projectPath;
996
- if (anyPath) return path.resolve(anyPath) === normCwd;
997
- }
998
-
999
- // Last resort fallback: dir name match (less reliable, skip for worktree paths)
1000
- // Skip this for paths containing .worktree to avoid edge cases
1001
- if (normCwd.includes('.worktree')) return true; // trust the session exists
1002
- const actualDir = path.basename(projectDir).toLowerCase();
1003
- const expectedDir = process.platform === 'win32'
1004
- ? normCwd.replace(/[:\\\/_ ]/g, '-').toLowerCase()
1005
- : ('-' + normCwd.replace(/^\//, '').replace(/[\/_. ]/g, '-')).toLowerCase();
1006
- return actualDir === expectedDir;
1007
- } catch {
1002
+ // JSONL exists but has no cwd metadata — trust it (e.g., very short session,
1003
+ // or JSONL format changed). Better to attempt resume than force a new session.
1004
+ log('INFO', `[SessionValid] ${sessionId.slice(0, 8)}: no cwd in JSONL, trusting file existence`);
1005
+ return true;
1006
+ } catch (e) {
1007
+ log('WARN', `[SessionValid] ${sessionId.slice(0, 8)}: infra error "${e.message}" trusting session`);
1008
1008
  return true; // conservative: infra failure ≠ invalid session
1009
1009
  }
1010
1010
  }