@jhizzard/termdeck 0.10.0 → 0.10.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -237,6 +237,9 @@
237
237
  case 'meta':
238
238
  updatePanelMeta(id, msg.session.meta);
239
239
  break;
240
+ case 'proactive_memory':
241
+ showProactiveToast(id, msg.hit);
242
+ break;
240
243
  case 'exit':
241
244
  updatePanelMeta(id, {
242
245
  status: 'exited',
@@ -1245,6 +1248,9 @@
1245
1248
  case 'meta':
1246
1249
  updatePanelMeta(id, msg.session.meta);
1247
1250
  break;
1251
+ case 'proactive_memory':
1252
+ showProactiveToast(id, msg.hit);
1253
+ break;
1248
1254
  case 'exit':
1249
1255
  updatePanelMeta(id, { status: 'exited', statusDetail: `Exited (${msg.exitCode})` });
1250
1256
  const p = document.getElementById(`panel-${id}`);
@@ -1253,6 +1259,17 @@
1253
1259
  case 'status_broadcast':
1254
1260
  updateGlobalStats(msg.sessions);
1255
1261
  break;
1262
+ case 'config_changed':
1263
+ // Sprint 40 T1: parity with the main panel WS handler. The
1264
+ // server broadcasts config_changed to ALL ws clients, including
1265
+ // reconnected sessions; previously the reconnect path silently
1266
+ // dropped these. Idempotent — safe to re-receive.
1267
+ if (msg.config) {
1268
+ state.config = { ...state.config, ...msg.config };
1269
+ if (typeof renderSettingsPanel === 'function') renderSettingsPanel();
1270
+ if (typeof updateRagIndicator === 'function') updateRagIndicator();
1271
+ }
1272
+ break;
1256
1273
  }
1257
1274
  } catch (err) { console.error('[client] reconnect ws message failed:', err); }
1258
1275
  };
@@ -2509,12 +2526,29 @@
2509
2526
  {
2510
2527
  targets: ['#btn-status', '#btn-config'],
2511
2528
  title: 'Status and config',
2512
- body: `<strong>status</strong> opens a global-metrics modal (session counts by state, RAG mode, memory bridge). <strong>config</strong> shows your loaded project list and theme defaults. Both are in the polish queue for Sprint 3 buttons are visible but unwired right now.`,
2529
+ body: `<strong>status</strong> opens a global-metrics modal (session counts by state, RAG mode, memory bridge). <strong>config</strong> shows your loaded project list and theme defaults plus a live RAG-mode toggle (Sprint 36) that flips Flashback on/off without a server restart.`,
2530
+ },
2531
+ {
2532
+ targets: ['#btn-sprint', '#btn-graph'],
2533
+ title: 'Sprint runner and knowledge graph',
2534
+ body: `<strong>sprint</strong> opens the in-dashboard 4+1 sprint runner (Sprint 37): name the sprint, define T1–T4 lane goals, click kick off — TermDeck spawns four panels and injects boot prompts via the two-stage submit pattern automatically. Optional <strong>--isolation=worktree</strong> creates a git worktree per lane so concurrent edits can't stomp. <strong>graph</strong> opens the D3.js force-directed knowledge graph (Sprint 38) of your memory_items + memory_relationships in a new tab — click any node to open its memory in a drawer, filter by relationship type, search, zoom/pan.`,
2513
2535
  },
2514
2536
  {
2515
2537
  targets: ['#btn-how', '#btn-help'],
2516
2538
  title: 'How this works and help',
2517
- body: `Click <strong>how this works</strong> any time to replay this tour. <strong>help</strong> opens the full TermDeck documentation in a new tab.`,
2539
+ body: `Click <strong>how this works</strong> any time to replay this tour. <strong>help</strong> opens the full TermDeck documentation in a new tab. The <strong>📖 Guide</strong> tab on the right edge of the screen — also opens with the <kbd>g</kbd> keyboard shortcut — is the always-on Orchestrator Guide (Sprint 37): nine sections covering the 4+1 sprint pattern, inject mandate, CLAUDE.md hierarchy, memory-first discipline, sprint discipline, restart-prompt rituals, scaffolding files, channel inject patterns. Search built in.`,
2540
+ },
2541
+ {
2542
+ target: '#guideRail',
2543
+ title: 'Right-rail Orchestrator Guide',
2544
+ body: `The <strong>📖 Guide</strong> rail is your orchestration cheat-sheet — collapsed by default, one click (or <kbd>g</kbd>) to expand. It auto-scrolls to the relevant section based on what you're focused on: clicking a terminal panel jumps the Guide to the 4+1 pattern; opening the project drawer jumps to CLAUDE.md hierarchy. Useful when you forget exactly how the two-stage submit pattern works at 2 AM in the middle of a sprint inject.`,
2545
+ fallback: '#btn-how',
2546
+ },
2547
+ {
2548
+ target: '#btnPreviewProject',
2549
+ title: 'Orchestration preview',
2550
+ body: `The <strong>preview</strong> button next to the project + button (Sprint 37) shows you exactly what <code>termdeck init --project &lt;name&gt;</code> would create for the selected project — file tree, contents per file, expand-on-click. Read-only by default; optional generate button writes the scaffolding (CLAUDE.md, CONTRADICTIONS.md, project_facts.md, .claude/settings.json, docs/orchestration/, RESTART-PROMPT.md template). Lets you see-before-commit instead of running the CLI blind.`,
2551
+ fallback: '#btn-how',
2518
2552
  },
2519
2553
  {
2520
2554
  target: '.panel-header',
@@ -2553,10 +2587,15 @@
2553
2587
  title: 'Prompt bar',
2554
2588
  body: `Type any command here to launch it as a new terminal — <kbd>claude code ~/myproject</kbd>, <kbd>python3 manage.py runserver</kbd>, <kbd>npm run dev</kbd>. Pick a project from the dropdown to auto-cd into its path and apply its default theme. <kbd>Ctrl+Shift+N</kbd> focuses this bar from anywhere.`,
2555
2589
  },
2590
+ {
2591
+ target: null,
2592
+ title: 'Knowledge graph + memory inference',
2593
+ body: `Sprint 38 brought your <strong>memory_relationships</strong> table to life. The <strong>graph</strong> button (top toolbar) renders your memories as a force-directed network — supersedes / relates_to / contradicts / elaborates / caused_by / blocks / inspired_by / cross_project_link edges, color-coded, filterable. The Mnestra MCP server now exposes four new tools: <code>memory_link</code>, <code>memory_unlink</code>, <code>memory_related</code>, and <code>memory_recall_graph</code> — Claude Code can connect related memories explicitly, traverse N-hop neighborhoods, and recall via graph-aware re-ranking (vector_score × edge_weight × recency). Edges populate automatically from Joshua's private rag-system classifier; a nightly cron in Sprint 39+ will surface cross-project connections.`,
2594
+ },
2556
2595
  {
2557
2596
  target: null,
2558
2597
  title: 'You are ready.',
2559
- body: `That's every major surface. Click <strong>how this works</strong> in the top toolbar to replay this walkthrough. <strong>help</strong> opens the full docs. Questions, bugs, feedback: <a href="https://github.com/jhizzard/termdeck/issues" target="_blank" style="color:var(--tg-accent)">github.com/jhizzard/termdeck/issues</a>. Now launch something.`,
2598
+ body: `That's every major surface. Click <strong>how this works</strong> in the top toolbar to replay this walkthrough. <strong>help</strong> opens the full docs. Press <kbd>g</kbd> any time to crack open the Orchestrator Guide. Questions, bugs, feedback: <a href="https://github.com/jhizzard/termdeck/issues" target="_blank" style="color:var(--tg-accent)">github.com/jhizzard/termdeck/issues</a>. Now launch something.`,
2560
2599
  },
2561
2600
  ];
2562
2601
 
@@ -0,0 +1,51 @@
1
+ // Flashback diagnostic ring buffer (Sprint 39 T1).
2
+ //
3
+ // Six decision points along the Flashback pipeline write structured events
4
+ // here so production-flow regressions surface as a readable timeline instead
5
+ // of a silent gate failure. The ring is in-memory and lost on restart by
6
+ // design — persistence is a Sprint-40+ concern. Public surface:
7
+ //
8
+ // log({ sessionId, event, ...fields }) — append one event
9
+ // snapshot({ sessionId?, eventType?, limit? }) — read back filtered tail
10
+ // _resetForTest() — test-only ring clear
11
+ //
12
+ // Event shape (all events): { ts, sessionId, event, ...event-specific fields }.
13
+ //
14
+ // Event types and their producers:
15
+ // pattern_match — session.js _detectErrors (PATTERNS.error /
16
+ // errorLineStart / shellError matched)
17
+ // error_detected — session.js _detectErrors at onErrorDetected
18
+ // entry, before rate-limit check
19
+ // rate_limit_blocked — session.js _detectErrors when 30s limiter rejects
20
+ // bridge_query — mnestra-bridge queryMnestra at call return
21
+ // bridge_result — mnestra-bridge queryMnestra at call return
22
+ // proactive_memory_emit — index.js onErrorDetected WS send block
23
+ //
24
+ // The route GET /api/flashback/diag (registered in index.js) returns
25
+ // snapshot() output as JSON for ad-hoc inspection by Joshua and consumption
26
+ // by T4's production-flow e2e test.
27
+
28
+ const RING_SIZE = 200;
29
+
30
+ let ring = [];
31
+
32
+ function log(event) {
33
+ ring.push({ ts: new Date().toISOString(), ...event });
34
+ if (ring.length > RING_SIZE) {
35
+ ring = ring.slice(-RING_SIZE);
36
+ }
37
+ }
38
+
39
+ function snapshot({ sessionId, eventType, limit = RING_SIZE } = {}) {
40
+ let out = ring;
41
+ if (sessionId) out = out.filter((e) => e.sessionId === sessionId);
42
+ if (eventType) out = out.filter((e) => e.event === eventType);
43
+ const cap = Math.max(1, Math.min(RING_SIZE, Number(limit) || RING_SIZE));
44
+ return out.slice(-cap);
45
+ }
46
+
47
+ function _resetForTest() {
48
+ ring = [];
49
+ }
50
+
51
+ module.exports = { log, snapshot, _resetForTest, RING_SIZE };
@@ -56,6 +56,7 @@ const { SessionManager } = require('./session');
56
56
  const { initDatabase, logCommand, getSessionHistory, getProjectSessions } = require('./database');
57
57
  const { RAGIntegration } = require('./rag');
58
58
  const { createBridge } = require('./mnestra-bridge');
59
+ const flashbackDiag = require('./flashback-diag');
59
60
  const { writeSessionLog } = require('./session-logger');
60
61
  const { TranscriptWriter } = require('./transcripts');
61
62
  const { createHealthHandler, runPreflight } = require('./preflight');
@@ -853,30 +854,69 @@ function createServer(config) {
853
854
  question,
854
855
  project: sess.meta.project,
855
856
  searchAll: false,
857
+ cwd: sess.meta.cwd,
858
+ sessionId: sess.id,
856
859
  sessionContext: {
857
860
  type: sess.meta.type,
858
861
  project: sess.meta.project,
862
+ cwd: sess.meta.cwd,
859
863
  lastCommands: sess.meta.lastCommands.slice(-5),
860
864
  status: 'errored'
861
865
  }
862
866
  }).then((result) => {
863
- const count = (result.memories || []).length;
867
+ const memories = (result && result.memories) || [];
868
+ const count = memories.length;
864
869
  console.log(`[flashback] query returned ${count} matches for session ${sess.id}`);
865
- const hit = (result.memories || [])[0];
870
+ const hit = memories[0];
871
+ const wsReadyState = sess.ws ? sess.ws.readyState : null;
866
872
  if (!hit) {
867
873
  console.log(`[flashback] no matches — skipping proactive_memory send for session ${sess.id}`);
874
+ flashbackDiag.log({
875
+ sessionId: sess.id,
876
+ event: 'proactive_memory_emit',
877
+ ws_ready_state: wsReadyState,
878
+ frame_size_bytes: 0,
879
+ result_count_in_frame: 0,
880
+ outcome: 'dropped_empty',
881
+ });
868
882
  return;
869
883
  }
870
884
  if (sess.ws && sess.ws.readyState === 1) {
885
+ const frame = JSON.stringify({ type: 'proactive_memory', hit });
871
886
  try {
872
- sess.ws.send(JSON.stringify({ type: 'proactive_memory', hit }));
887
+ sess.ws.send(frame);
873
888
  console.log(`[flashback] proactive_memory sent to session ${sess.id} (source_type=${hit.source_type}, project=${hit.project})`);
889
+ flashbackDiag.log({
890
+ sessionId: sess.id,
891
+ event: 'proactive_memory_emit',
892
+ ws_ready_state: 1,
893
+ frame_size_bytes: Buffer.byteLength(frame, 'utf8'),
894
+ result_count_in_frame: 1,
895
+ outcome: 'emitted',
896
+ });
874
897
  } catch (err) {
875
898
  console.error('[flashback] proactive_memory send failed:', err);
876
899
  console.error('[ws] proactive_memory send failed:', err);
900
+ flashbackDiag.log({
901
+ sessionId: sess.id,
902
+ event: 'proactive_memory_emit',
903
+ ws_ready_state: 1,
904
+ frame_size_bytes: Buffer.byteLength(frame, 'utf8'),
905
+ result_count_in_frame: 1,
906
+ outcome: 'error',
907
+ error_message: err && err.message ? err.message : String(err),
908
+ });
877
909
  }
878
910
  } else {
879
911
  console.log(`[flashback] ws not open for session ${sess.id} (readyState=${sess.ws ? sess.ws.readyState : 'null'}) — dropped hit`);
912
+ flashbackDiag.log({
913
+ sessionId: sess.id,
914
+ event: 'proactive_memory_emit',
915
+ ws_ready_state: wsReadyState,
916
+ frame_size_bytes: 0,
917
+ result_count_in_frame: count,
918
+ outcome: 'dropped_no_ws',
919
+ });
880
920
  }
881
921
  }).catch((err) => {
882
922
  console.error(`[flashback] query failed for session ${sess.id}: ${err.message}`);
@@ -1347,6 +1387,23 @@ function createServer(config) {
1347
1387
  });
1348
1388
  });
1349
1389
 
1390
+ // GET /api/flashback/diag - Sprint 39 T1 diagnostic ring buffer.
1391
+ // Returns the last N Flashback decision-point events so Joshua can trigger
1392
+ // a real-shell error and read the timeline of which gate dropped the toast.
1393
+ // Optional filters: ?sessionId=<uuid>, ?eventType=pattern_match, ?limit=N
1394
+ // (capped at 200, the ring size).
1395
+ app.get('/api/flashback/diag', (req, res) => {
1396
+ const { sessionId, eventType } = req.query || {};
1397
+ const rawLimit = req.query && req.query.limit;
1398
+ const limit = rawLimit != null ? parseInt(rawLimit, 10) : undefined;
1399
+ const events = flashbackDiag.snapshot({
1400
+ sessionId: typeof sessionId === 'string' && sessionId.length ? sessionId : undefined,
1401
+ eventType: typeof eventType === 'string' && eventType.length ? eventType : undefined,
1402
+ limit: Number.isFinite(limit) && limit > 0 ? Math.min(limit, flashbackDiag.RING_SIZE) : undefined,
1403
+ });
1404
+ res.json({ count: events.length, events });
1405
+ });
1406
+
1350
1407
  // ==================== Transcript endpoints (Sprint 6 T3) ====================
1351
1408
 
1352
1409
  // GET /api/transcripts/search - FTS across all sessions
@@ -1568,6 +1625,7 @@ function createServer(config) {
1568
1625
  const sessionContext = session ? {
1569
1626
  type: session.meta.type,
1570
1627
  project: session.meta.project,
1628
+ cwd: session.meta.cwd,
1571
1629
  lastCommands: session.meta.lastCommands.slice(-5),
1572
1630
  status: session.meta.status
1573
1631
  } : null;
@@ -1577,6 +1635,7 @@ function createServer(config) {
1577
1635
  question,
1578
1636
  project,
1579
1637
  searchAll,
1638
+ cwd: session ? session.meta.cwd : undefined,
1580
1639
  sessionContext
1581
1640
  });
1582
1641
 
@@ -10,6 +10,7 @@
10
10
 
11
11
  const { spawn } = require('child_process');
12
12
  const { resolveProjectName } = require('../rag');
13
+ const flashbackDiag = require('../flashback-diag');
13
14
 
14
15
  function createBridge(config) {
15
16
  const mode = config.rag?.mnestraMode || 'direct';
@@ -225,7 +226,7 @@ function createBridge(config) {
225
226
  }
226
227
  }
227
228
 
228
- async function queryMnestra({ question, project, searchAll, sessionContext, cwd }) {
229
+ async function queryMnestra({ question, project, searchAll, sessionContext, cwd, sessionId }) {
229
230
  // Flashback callers pass the session's project (from config.yaml). If that
230
231
  // slot is empty — e.g. a session created without an explicit project — fall
231
232
  // back to resolving the session's cwd against config.projects so queries
@@ -246,15 +247,68 @@ function createBridge(config) {
246
247
  // out-of-repo session-end hook), the mismatch surfaces here at query time.
247
248
  console.log(`[mnestra-bridge] query project=${effectiveProject ?? 'ALL'} source=${searchAll ? 'searchAll' : projectSource} mode=${mode}`);
248
249
 
249
- switch (mode) {
250
- case 'webhook':
251
- return queryWebhook({ question, project: effectiveProject, searchAll });
252
- case 'mcp':
253
- return queryMcp({ question, project: effectiveProject, searchAll });
254
- case 'direct':
255
- default:
256
- return queryDirect({ question, project: effectiveProject, searchAll });
250
+ const projectTagInFilter = searchAll ? null : (effectiveProject || null);
251
+ const t0 = Date.now();
252
+ let result;
253
+ let callError;
254
+ try {
255
+ switch (mode) {
256
+ case 'webhook':
257
+ result = await queryWebhook({ question, project: effectiveProject, searchAll });
258
+ break;
259
+ case 'mcp':
260
+ result = await queryMcp({ question, project: effectiveProject, searchAll });
261
+ break;
262
+ case 'direct':
263
+ default:
264
+ result = await queryDirect({ question, project: effectiveProject, searchAll });
265
+ break;
266
+ }
267
+ } catch (err) {
268
+ callError = err;
257
269
  }
270
+ const durationMs = Date.now() - t0;
271
+
272
+ // Sprint 39 T1 — bridge_query / bridge_result diag events. Emitted at
273
+ // queryMnestra's outer boundary so all three backends (direct, webhook,
274
+ // mcp) flow through one observability point. T3 reads project_tag_in_filter
275
+ // (the tag the bridge SENT to the RPC) and top_3_project_tags (the tags
276
+ // it GOT BACK) to confirm or refute the project-mismatch hypothesis.
277
+ flashbackDiag.log({
278
+ sessionId,
279
+ event: 'bridge_query',
280
+ project_tag_in_filter: projectTagInFilter,
281
+ query_text: typeof question === 'string' ? question.slice(0, 200) : '',
282
+ mode,
283
+ rpc_args: {
284
+ project: projectTagInFilter,
285
+ searchAll: !!searchAll,
286
+ project_source: searchAll ? 'searchAll' : projectSource,
287
+ },
288
+ duration_ms: durationMs,
289
+ });
290
+
291
+ const memories = (result && Array.isArray(result.memories)) ? result.memories : [];
292
+ const tagCounts = {};
293
+ for (const m of memories) {
294
+ const tag = m && m.project != null ? String(m.project) : '(null)';
295
+ tagCounts[tag] = (tagCounts[tag] || 0) + 1;
296
+ }
297
+ const top3 = Object.entries(tagCounts)
298
+ .sort((a, b) => b[1] - a[1])
299
+ .slice(0, 3)
300
+ .map(([tag, count]) => ({ tag, count }));
301
+
302
+ flashbackDiag.log({
303
+ sessionId,
304
+ event: 'bridge_result',
305
+ result_count: memories.length,
306
+ error_message: callError ? (callError.message || String(callError)) : null,
307
+ top_3_project_tags: top3,
308
+ });
309
+
310
+ if (callError) throw callError;
311
+ return result;
258
312
  }
259
313
 
260
314
  return { mode, queryMnestra };
@@ -14,6 +14,7 @@ const { v4: uuidv4 } = require('uuid');
14
14
  const os = require('os');
15
15
  const path = require('path');
16
16
  const { resolveTheme } = require('./theme-resolver');
17
+ const flashbackDiag = require('./flashback-diag');
17
18
 
18
19
  // Strip ANSI escape codes for pattern matching
19
20
  function stripAnsi(str) {
@@ -43,6 +44,13 @@ const PATTERNS = {
43
44
  django: /Starting development server/,
44
45
  httpServer: /Serving HTTP on/,
45
46
  request: /(?:^|\s|")(GET|POST|PUT|DELETE|PATCH)\s+\S+.*?\s(\d{3})/m,
47
+ // Sprint 40 T2: HTTP 5xx response in a web-server log line is a real
48
+ // error condition for the application. Used as a python-server-typed
49
+ // fallback in _detectErrors when the prose-shape analyzers miss because
50
+ // the line carries no `Error:` keyword — just `"GET /foo HTTP/1.1" 503`.
51
+ // 5xx only (not 4xx, which are typically client-caused). The leading
52
+ // `(?:^|\s|")` mirrors `request` so colon-quoted log shapes still match.
53
+ serverError: /(?:^|\s|")(?:GET|POST|PUT|DELETE|PATCH)\s+\S+.*?\sHTTP\/\d(?:\.\d)?"?\s+5\d{2}\b/m,
46
54
  // Port detection — matches any of:
47
55
  // • "port NNNN" phrase (capture group 1)
48
56
  // • URL with http/https scheme, optionally prefixed with "on " or "at "
@@ -65,11 +73,20 @@ const PATTERNS = {
65
73
  // tools (cat, ls, cd, rm, etc.) report filesystem misses in plain English
66
74
  // without ever emitting the ENOENT errno code. Flagged as a gap by Rumen's
67
75
  // first production kickstart insight on 2026-04-15.
68
- error: /(?:^|\n)\s*(?:Error:\s+\S|error:\s+\S|Traceback \(most recent call last\):|npm ERR!|error\[E\d+\]:|Uncaught Exception|Fatal:)/m,
76
+ // Sprint 40 T2: added uppercase `ERROR:` (mirrors `Error:` / `error:` for
77
+ // case-symmetry — closes the stripAnsi-ERROR test fixture from Sprint 33)
78
+ // and Node errno-style colon-prefix shapes (`ENOENT:`, `EACCES:`,
79
+ // `ECONNREFUSED:`) so `ENOENT: no such file or directory` shapes from
80
+ // child-process error reporting fire without depending on the line ALSO
81
+ // containing the `No such file or directory` prose phrase.
82
+ error: /(?:^|\n)\s*(?:Error:\s+\S|error:\s+\S|ERROR:\s+\S|Traceback \(most recent call last\):|npm ERR!|error\[E\d+\]:|Uncaught Exception|Fatal:|ENOENT:\s+\S|EACCES:\s+\S|ECONNREFUSED:\s+\S)/m,
69
83
  // Stricter line-anchored variant for Claude Code, whose tool output (grep
70
84
  // results, test logs, file contents) routinely mentions "Error" mid-line
71
85
  // without representing an actual failure of the agent itself.
72
- errorLineStart: /^\s*(error|Error|ERROR|exception|Exception|Traceback|fatal|FATAL|segmentation fault|panic|EACCES|ECONNREFUSED|ENOENT|command not found|undefined reference|cannot find module|failed with exit code|No such file or directory|Permission denied)\b/m,
86
+ // Sprint 40 T2: added mixed-case `Fatal` (mirrors `fatal` / `FATAL`) and
87
+ // the `npm ERR!` shape (special-cased outside the alternation because
88
+ // `!` is not a word character so `\b` after `npm ERR!` doesn't match).
89
+ errorLineStart: /^\s*(?:(?:error|Error|ERROR|exception|Exception|Traceback|fatal|Fatal|FATAL|segmentation fault|panic|EACCES|ECONNREFUSED|ENOENT|command not found|undefined reference|cannot find module|failed with exit code|No such file or directory|Permission denied)\b|npm ERR!)/m,
73
90
  // Sprint 33: PATTERNS.error misses the most common Unix shell errors —
74
91
  // `cat: /foo: No such file or directory`, `bash: foo: command not found`,
75
92
  // `rm: cannot remove ...: Permission denied`. These have a colon-prefix
@@ -77,7 +94,27 @@ const PATTERNS = {
77
94
  // mentioning the same words. Each branch requires either the colon-prefix
78
95
  // structure or a stand-alone anchored keyword. Validated against an
79
96
  // adversarial prose suite (see tests/analyzer-error-fixtures.test.js).
80
- shellError: /(?:^|\n)(?:[^\n]*:\s+(?:.*?:\s+)?(?:No such file or directory|Permission denied|Is a directory|Not a directory|command not found)\b|[^\n]*?\(\d+\)\s+Could not resolve host\b|\s*ModuleNotFoundError:\s+\S|\s*Segmentation fault\b|\s*fatal:\s+\S)/m
97
+ //
98
+ // Sprint 39 T2: separated `command not found` from the other phrases. The
99
+ // unified branch was matching rcfile-noise lines emitted by version
100
+ // managers during shell startup — most notably:
101
+ // `pyenv: pyenv-virtualenv-init: command not found in path`
102
+ // …which has the colon-prefix-with-`command not found` shape but with a
103
+ // descriptive suffix (` in path`) rather than ending the line. The pyenv
104
+ // case confirms the strong rcfile-noise hypothesis for pyenv users: their
105
+ // shell startup burns the 30s onErrorDetected rate limit before the user
106
+ // can type their first command. The dedicated `command not found` branch
107
+ // below requires the keyword to be either:
108
+ // • followed by `:` (the zsh `command not found: <cmd>` form), or
109
+ // • at end-of-line (the bash `<sh>: <cmd>: command not found` form).
110
+ // Suffixes like ` in path`, ` in $PATH`, ` (compinit)` are silenced as
111
+ // rcfile noise.
112
+ // Trade-off: custom command_not_found_handler output that adds a comma-
113
+ // separated "did you mean X" suggestion is silenced — those are cosmetic
114
+ // suggestions, not the error itself, which the user already saw fire.
115
+ // See tests/rcfile-noise.test.js and tests/analyzer-error-fixtures.test.js
116
+ // for the locked corpus.
117
+ shellError: /(?:^|\n)(?:[^\n]*:\s+(?:.*?:\s+)?(?:No such file or directory|Permission denied|Is a directory|Not a directory)\b|[^\n]*:\s+(?:.*?:\s+)?command not found(?::|\s*(?:[\r\n]|$))|[^\n]*?\(\d+\)\s+Could not resolve host\b|\s*ModuleNotFoundError:\s+\S|\s*Segmentation fault\b|\s*fatal:\s+\S)/m
81
118
  };
82
119
 
83
120
  class Session {
@@ -350,14 +387,44 @@ class Session {
350
387
  // Claude Code's tool output frequently contains "error"/"Error" mid-line
351
388
  // (grep matches, test results, log dumps). Use a line-anchored pattern
352
389
  // for that session type so we don't flag content as failure.
353
- const pattern = this.meta.type === 'claude-code'
390
+ const primaryPattern = this.meta.type === 'claude-code'
354
391
  ? PATTERNS.errorLineStart
355
392
  : PATTERNS.error;
393
+ const primaryName = this.meta.type === 'claude-code' ? 'errorLineStart' : 'error';
356
394
  // Sprint 33 fix: the structured patterns above miss `cat: /foo: No such
357
395
  // file or directory` and friends — the most common Unix shell error
358
396
  // shapes Josh hits day-to-day. Fall through to PATTERNS.shellError so
359
397
  // the analyzer flips status='errored' and Flashback can fire.
360
- if (!pattern.test(clean) && !PATTERNS.shellError.test(clean)) return;
398
+ const primaryMatch = clean.match(primaryPattern);
399
+ const shellMatch = !primaryMatch ? clean.match(PATTERNS.shellError) : null;
400
+ // Sprint 40 T2: HTTP 5xx fallback for python-server sessions. The prose
401
+ // analyzers miss `"GET /foo HTTP/1.1" 503 -` because it carries no
402
+ // `Error:` keyword — but the response IS the error signal for an
403
+ // HTTP-server session. Gated on session type to avoid flagging 5xx
404
+ // status codes that legitimately appear in unrelated content (e.g. a
405
+ // shell that just printed a copy of an HTTP log).
406
+ const serverMatch = (!primaryMatch && !shellMatch && this.meta.type === 'python-server')
407
+ ? clean.match(PATTERNS.pythonServer.serverError)
408
+ : null;
409
+ if (!primaryMatch && !shellMatch && !serverMatch) return;
410
+
411
+ // Sprint 39 T1 — pattern_match diag event. Emitted on every PATTERNS hit,
412
+ // including ones that get rate-limited downstream. T2 reads these to
413
+ // measure the rcfile-noise false-positive rate against real shell output.
414
+ const matchedSrc = primaryMatch || shellMatch || serverMatch;
415
+ const matchedLine = (matchedSrc && typeof matchedSrc[0] === 'string')
416
+ ? matchedSrc[0].replace(/^\n+/, '').slice(0, 200)
417
+ : '';
418
+ const matchedPattern = primaryMatch
419
+ ? primaryName
420
+ : (shellMatch ? 'shellError' : 'serverError');
421
+ flashbackDiag.log({
422
+ sessionId: this.id,
423
+ event: 'pattern_match',
424
+ pattern: matchedPattern,
425
+ matched_line: matchedLine,
426
+ output_chunk_size: clean.length,
427
+ });
361
428
 
362
429
  const oldStatus = this.meta.status;
363
430
  this.meta.status = 'errored';
@@ -371,7 +438,30 @@ class Session {
371
438
 
372
439
  // Server-side rate limit: at most one error_detected event every 30s per session
373
440
  const now = Date.now();
441
+ const remainingMs = this._lastErrorFireAt
442
+ ? Math.max(0, 30000 - (now - this._lastErrorFireAt))
443
+ : 0;
444
+
445
+ // Sprint 39 T1 — error_detected diag event, before the rate-limit gate.
446
+ // The (error_detected count − rate_limit_blocked count) is the number of
447
+ // errors that actually got dispatched to onErrorDetected. T2/T3 use this
448
+ // to spot rcfile noise burning the rate-limit window before real errors.
449
+ flashbackDiag.log({
450
+ sessionId: this.id,
451
+ event: 'error_detected',
452
+ error_text: matchedLine,
453
+ rate_limit_remaining_ms: remainingMs,
454
+ last_emit_at: this._lastErrorFireAt
455
+ ? new Date(this._lastErrorFireAt).toISOString()
456
+ : null,
457
+ });
458
+
374
459
  if (now - this._lastErrorFireAt < 30000) {
460
+ flashbackDiag.log({
461
+ sessionId: this.id,
462
+ event: 'rate_limit_blocked',
463
+ rate_limit_remaining_ms: remainingMs,
464
+ });
375
465
  console.log(`[flashback] error detected in session ${this.id} but rate-limited (${Math.round((30000 - (now - this._lastErrorFireAt)) / 1000)}s left)`);
376
466
  return;
377
467
  }
@@ -0,0 +1,237 @@
1
+ -- Sprint 39 T3 — chopin-nashville project-tag backfill.
2
+ --
3
+ -- Why this exists:
4
+ -- memory_items rows tagged project='chopin-nashville' are ~96% polluted
5
+ -- with content from other projects (termdeck, mnestra, rumen, podium, pvb,
6
+ -- dor). Root cause is the harness session-end hook
7
+ -- (~/.claude/hooks/memory-session-end.js, OUT OF THIS REPO): its
8
+ -- PROJECT_MAP iteration tests /ChopinNashville/i first and there are no
9
+ -- entries for termdeck/mnestra/rumen/podium/dor — so any session whose
10
+ -- cwd lives under ~/Documents/Graciella/ChopinNashville/... falls into
11
+ -- chopin-nashville, including the entire TermDeck checkout (which lives at
12
+ -- ChopinNashville/SideHustles/TermDeck/termdeck) and Podium (which lives at
13
+ -- ChopinNashville/2026/ChopinInBohemia/podium).
14
+ --
15
+ -- This migration heals the historical rows. The forward-fix to the harness
16
+ -- hook is Joshua's responsibility (out-of-repo file) and is NOT covered
17
+ -- here — without it, new mis-tagged rows will continue to be written until
18
+ -- he extends PROJECT_MAP with the missing project entries.
19
+ --
20
+ -- What this migration does NOT do:
21
+ -- - Does NOT touch mnestra_session_memory / mnestra_project_memory / etc.
22
+ -- (legacy rag-events tables; different write path; separate cleanup).
23
+ -- - Does NOT consolidate duplicate project tags like 'gorgias' vs
24
+ -- 'gorgias-ticket-monitor', 'pvb' vs 'PVB', or 'mnestra' vs 'engram'.
25
+ -- Those are visible in `SELECT project, count(*) FROM memory_items GROUP
26
+ -- BY project` but they're a separate cleanup pass.
27
+ -- - Does NOT touch the ~898 "other/uncertain" chopin-nashville rows that
28
+ -- don't carry an unambiguous project keyword. A future sprint can run an
29
+ -- LLM-classification pass; for this migration, conservative wins.
30
+ --
31
+ -- Heuristic — content keyword bucketing:
32
+ -- The migration runs UPDATEs sequentially. Earlier buckets claim ambiguous
33
+ -- multi-project rows first; later buckets only see rows that no earlier
34
+ -- bucket has already re-tagged. Order is by bucket size (largest first):
35
+ --
36
+ -- 1. termdeck / mnestra — keywords: termdeck, mnestra, "4+1 sprint"
37
+ -- 2. rumen — keyword: rumen
38
+ -- 3. podium — keyword: podium
39
+ -- 4. pvb — keywords: PVB, petvetbid, pet vet bid
40
+ -- 5. dor / openclaw — TIGHTENED:
41
+ -- word-boundary uppercase DOR (rules out
42
+ -- "dormant", "vendored", "indoor", etc.),
43
+ -- plus path/identifier markers and
44
+ -- openclaw substring.
45
+ --
46
+ -- Spot-check baseline (T3 audit, 2026-04-27):
47
+ -- termdeck/mnestra: 130 rows, all 6 sampled were true positives (TermDeck
48
+ -- server code, Mnestra wizard, sprint orchestration).
49
+ -- rumen: 92 rows, all 6 sampled were true positives.
50
+ -- podium: 58 rows, all 6 sampled were true positives.
51
+ -- pvb: 7 rows, 1 of those overlaps with mnestra ("Mnestra
52
+ -- repo … petvetbid project") and gets claimed by bucket 1.
53
+ -- dor (tightened): 3 rows after tightening from 6 — the original
54
+ -- `%dor%` ILIKE pattern caught false positives like
55
+ -- "dormant", "vendored". Final 3 rows are all true
56
+ -- DOR/OpenClaw mentions.
57
+ -- chopin-nashville total: 1,169 rows. Legitimate-signal baseline (rows
58
+ -- matching Acceptd / NICPC / Bohemia / laureate /
59
+ -- applicant / competition / repertoire keywords): 71.
60
+ --
61
+ -- Idempotence:
62
+ -- Every UPDATE is gated by `WHERE project = 'chopin-nashville'`. After the
63
+ -- first run, those rows have a different project tag, so re-running this
64
+ -- migration is a no-op (zero rows updated per bucket). RAISE NOTICE on a
65
+ -- re-run will print zeros, which is the expected idempotent signal.
66
+ --
67
+ -- Application:
68
+ -- THIS MIGRATION IS NOT EXECUTED BY THE LANE THAT WROTE IT. Orchestrator
69
+ -- reviews the RAISE NOTICE counts after applying. Apply via the bundled
70
+ -- migration runner at packages/server/src/setup/migration-runner.js (which
71
+ -- uses node-postgres client.query — psql metacommands like \gset are NOT
72
+ -- available, so the count probes use GET DIAGNOSTICS ROW_COUNT inside DO
73
+ -- blocks). Manual fallback: `psql "$DATABASE_URL" -f 011_project_tag_backfill.sql`.
74
+
75
+ BEGIN;
76
+
77
+ -- ============================================================
78
+ -- AUDIT BEFORE
79
+ -- ============================================================
80
+ DO $$
81
+ DECLARE
82
+ before_chopin int;
83
+ before_termdeck int;
84
+ before_rumen int;
85
+ before_podium int;
86
+ before_pvb int;
87
+ before_dor int;
88
+ BEGIN
89
+ SELECT count(*) INTO before_chopin FROM memory_items WHERE project = 'chopin-nashville';
90
+ SELECT count(*) INTO before_termdeck FROM memory_items WHERE project = 'termdeck';
91
+ SELECT count(*) INTO before_rumen FROM memory_items WHERE project = 'rumen';
92
+ SELECT count(*) INTO before_podium FROM memory_items WHERE project = 'podium';
93
+ SELECT count(*) INTO before_pvb FROM memory_items WHERE project = 'pvb';
94
+ SELECT count(*) INTO before_dor FROM memory_items WHERE project = 'dor';
95
+ RAISE NOTICE '[011-backfill] BEFORE chopin-nashville=% termdeck=% rumen=% podium=% pvb=% dor=%',
96
+ before_chopin, before_termdeck, before_rumen, before_podium, before_pvb, before_dor;
97
+ END $$;
98
+
99
+ -- ============================================================
100
+ -- BUCKET 1 — TermDeck / Mnestra (claims multi-project mentions first)
101
+ -- ============================================================
102
+ DO $$
103
+ DECLARE
104
+ rows_updated int;
105
+ BEGIN
106
+ UPDATE memory_items SET project = 'termdeck'
107
+ WHERE project = 'chopin-nashville'
108
+ AND (
109
+ content ILIKE '%termdeck%'
110
+ OR content ILIKE '%mnestra%'
111
+ OR content ILIKE '%4+1 sprint%'
112
+ );
113
+ GET DIAGNOSTICS rows_updated = ROW_COUNT;
114
+ RAISE NOTICE '[011-backfill] bucket 1 (termdeck/mnestra): % rows re-tagged', rows_updated;
115
+ END $$;
116
+
117
+ -- ============================================================
118
+ -- BUCKET 2 — Rumen
119
+ -- ============================================================
120
+ DO $$
121
+ DECLARE
122
+ rows_updated int;
123
+ BEGIN
124
+ UPDATE memory_items SET project = 'rumen'
125
+ WHERE project = 'chopin-nashville'
126
+ AND content ILIKE '%rumen%';
127
+ GET DIAGNOSTICS rows_updated = ROW_COUNT;
128
+ RAISE NOTICE '[011-backfill] bucket 2 (rumen): % rows re-tagged', rows_updated;
129
+ END $$;
130
+
131
+ -- ============================================================
132
+ -- BUCKET 3 — Podium
133
+ -- ============================================================
134
+ DO $$
135
+ DECLARE
136
+ rows_updated int;
137
+ BEGIN
138
+ UPDATE memory_items SET project = 'podium'
139
+ WHERE project = 'chopin-nashville'
140
+ AND content ILIKE '%podium%';
141
+ GET DIAGNOSTICS rows_updated = ROW_COUNT;
142
+ RAISE NOTICE '[011-backfill] bucket 3 (podium): % rows re-tagged', rows_updated;
143
+ END $$;
144
+
145
+ -- ============================================================
146
+ -- BUCKET 4 — PVB (case-insensitive PVB / petvetbid markers)
147
+ -- ============================================================
148
+ DO $$
149
+ DECLARE
150
+ rows_updated int;
151
+ BEGIN
152
+ UPDATE memory_items SET project = 'pvb'
153
+ WHERE project = 'chopin-nashville'
154
+ AND (
155
+ content ILIKE '%PVB%'
156
+ OR content ILIKE '%petvetbid%'
157
+ OR content ILIKE '%pet vet bid%'
158
+ );
159
+ GET DIAGNOSTICS rows_updated = ROW_COUNT;
160
+ RAISE NOTICE '[011-backfill] bucket 4 (pvb): % rows re-tagged', rows_updated;
161
+ END $$;
162
+
163
+ -- ============================================================
164
+ -- BUCKET 5 — DOR / OpenClaw (TIGHTENED — word boundary + identifiers)
165
+ --
166
+ -- Original briefing heuristic was `content ILIKE '%dor%'`, which produced a
167
+ -- ~33% false-positive rate (matched "dormant", "vendored", "indoor", etc.).
168
+ -- T3 audit tightened to:
169
+ -- • POSIX word boundary `\mDOR\M` — case-sensitive uppercase only, so
170
+ -- "dormant" / "DormHall" / "vendor" / "indoor" no longer match.
171
+ -- • path/identifier markers: /DOR/, ~/Documents/DOR, dor.config,
172
+ -- "Rust LLM gateway" (DOR's tagline).
173
+ -- • openclaw substring (OpenClaw is the slack-channel automation product
174
+ -- that lives next to DOR in Joshua's stack).
175
+ -- ============================================================
176
+ DO $$
177
+ DECLARE
178
+ rows_updated int;
179
+ BEGIN
180
+ UPDATE memory_items SET project = 'dor'
181
+ WHERE project = 'chopin-nashville'
182
+ AND (
183
+ content ~ '\mDOR\M'
184
+ OR content ILIKE '%/DOR/%'
185
+ OR content ILIKE '%~/Documents/DOR%'
186
+ OR content ILIKE '%dor.config%'
187
+ OR content ILIKE '%Rust LLM gateway%'
188
+ OR content ILIKE '%openclaw%'
189
+ );
190
+ GET DIAGNOSTICS rows_updated = ROW_COUNT;
191
+ RAISE NOTICE '[011-backfill] bucket 5 (dor): % rows re-tagged', rows_updated;
192
+ END $$;
193
+
194
+ -- ============================================================
195
+ -- AUDIT AFTER
196
+ -- ============================================================
197
+ DO $$
198
+ DECLARE
199
+ after_chopin int;
200
+ after_termdeck int;
201
+ after_rumen int;
202
+ after_podium int;
203
+ after_pvb int;
204
+ after_dor int;
205
+ BEGIN
206
+ SELECT count(*) INTO after_chopin FROM memory_items WHERE project = 'chopin-nashville';
207
+ SELECT count(*) INTO after_termdeck FROM memory_items WHERE project = 'termdeck';
208
+ SELECT count(*) INTO after_rumen FROM memory_items WHERE project = 'rumen';
209
+ SELECT count(*) INTO after_podium FROM memory_items WHERE project = 'podium';
210
+ SELECT count(*) INTO after_pvb FROM memory_items WHERE project = 'pvb';
211
+ SELECT count(*) INTO after_dor FROM memory_items WHERE project = 'dor';
212
+ RAISE NOTICE '[011-backfill] AFTER chopin-nashville=% termdeck=% rumen=% podium=% pvb=% dor=%',
213
+ after_chopin, after_termdeck, after_rumen, after_podium, after_pvb, after_dor;
214
+ RAISE NOTICE '[011-backfill] If apply succeeds and chopin-nashville count is around the legitimate baseline (~71 rows match competition/laureate/applicant/Acceptd/NICPC/Bohemia/repertoire keywords as of T3 audit), the migration succeeded. The ~898 rows that remain under chopin-nashville without a clear keyword signal are deliberate — a future LLM-classification pass can address them if needed.';
215
+ END $$;
216
+
217
+ COMMIT;
218
+
219
+ -- ============================================================
220
+ -- POST-APPLY: optional verification queries (NOT part of the migration).
221
+ -- Run separately to confirm Flashback against project='termdeck' now hits
222
+ -- the re-tagged rows.
223
+ -- ============================================================
224
+ --
225
+ -- 1. Tag distribution after migration:
226
+ -- SELECT project, count(*) FROM memory_items GROUP BY project ORDER BY count(*) DESC LIMIT 20;
227
+ --
228
+ -- 2. Confirm no chopin-nashville rows match obvious termdeck/rumen keywords:
229
+ -- SELECT count(*) FROM memory_items
230
+ -- WHERE project='chopin-nashville'
231
+ -- AND (content ILIKE '%termdeck%' OR content ILIKE '%rumen%' OR content ILIKE '%podium%');
232
+ -- -- Expected: 0
233
+ --
234
+ -- 3. Confirm Flashback project-bound test corpus (>= 5 termdeck-tagged rows
235
+ -- matching the canonical probe question):
236
+ -- SELECT count(*) FROM memory_items
237
+ -- WHERE project='termdeck' AND content ILIKE '%shell error%';