@jhizzard/termdeck 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,10 +16,28 @@ const { createCachedLookup, createFailureLogger } = require('./rumen-pool-resili
16
16
  // Conditional imports (graceful fallback if not installed yet)
17
17
  let pty, Database, pg;
18
18
  try { pty = require('@homebridge/node-pty-prebuilt-multiarch'); } catch { pty = null; }
19
- try { Database = require('better-sqlite3'); } catch { Database = null; }
19
+ try {
20
+ Database = require('better-sqlite3');
21
+ } catch (err) {
22
+ // Brad Heath 2026-05-11: distinguish a native-ABI mismatch (Node upgraded
23
+ // after install) from "package not installed yet." ABI mismatch leaves
24
+ // Database=null and cascades into a null-handle storm downstream that
25
+ // masquerades as "Mnestra unreachable / DB timeout" in health probes.
26
+ // Fail fast with the actionable rebuild hint instead.
27
+ const msg = err && err.message ? String(err.message) : '';
28
+ if (err && err.code === 'ERR_DLOPEN_FAILED' && /NODE_MODULE_VERSION/.test(msg)) {
29
+ console.error('[db] better-sqlite3 native ABI mismatch (Node was upgraded after install).');
30
+ console.error('[db] TermDeck cannot serve memory features without a working SQLite.');
31
+ console.error('[db] Fix:');
32
+ console.error(' cd "$(npm root -g)/@jhizzard/termdeck" && npm rebuild better-sqlite3');
33
+ console.error('[db] Then restart TermDeck. Aborting.');
34
+ process.exit(1);
35
+ }
36
+ Database = null;
37
+ }
20
38
  try { pg = require('pg'); } catch { pg = null; }
21
39
 
22
- // Module-level singleton Postgres pool for rumen_insights (petvetbid DB).
40
+ // Module-level singleton Postgres pool for rumen_insights (the daily-driver DB).
23
41
  // Lazy-initialized on first rumen endpoint hit so startup stays fast and
24
42
  // servers without DATABASE_URL never pay the connection cost.
25
43
  //
@@ -274,31 +292,42 @@ function _termdeckVersion() {
274
292
  // `pty.resize()` ioctls a stale fd. The error is race-expected, not a bug,
275
293
  // but the noisy console.error trace pollutes diagnostics and obscures real
276
294
  // errors. This helper guards against the race and downgrades the known
277
- // race-class errors (EBADF, ENOTTY, generic "ioctl failed" message shape) to
278
- // a silent return. Set TERMDECK_DEBUG_PTY_RACES=1 to log to console.debug
279
- // for diagnostics.
295
+ // race-class errors (EBADF, ENOTTY) to a silent return. Set
296
+ // TERMDECK_DEBUG_PTY_RACES=1 to log to console.debug for diagnostics.
297
+ //
298
+ // Sprint 63 T1 — `isPtyRaceError(err)` extracted so the WS message-handler
299
+ // outer catch can also downgrade race-class errors that escape the helper's
300
+ // own catch (e.g. if `pty.write` ever races the close, future code paths).
301
+ // `session.pty._destroyed` short-circuit added as belt-and-suspenders for the
302
+ // `term.kill()` → before-`term.onExit`-fires window: the DELETE handler now
303
+ // stamps `_destroyed = true` immediately after kill(), so resize attempts in
304
+ // that interval short-circuit without an ioctl call.
305
+ function isPtyRaceError(err) {
306
+ if (!err) return false;
307
+ const msg = (err.message) || '';
308
+ const code = err.code;
309
+ return code === 'EBADF' ||
310
+ code === 'ENOTTY' ||
311
+ /\b(?:EBADF|ENOTTY)\b/.test(msg);
312
+ }
313
+
280
314
  function safelyResizePty(session, cols, rows) {
281
315
  if (!session || !session.pty) return false;
316
+ if (session.pty._destroyed) return false;
282
317
  if (session.meta && session.meta.status === 'exited') return false;
283
318
  try {
284
319
  session.pty.resize(cols || 120, rows || 30);
285
320
  return true;
286
321
  } catch (err) {
287
- const msg = (err && err.message) || '';
288
- const code = err && err.code;
289
322
  // Sprint 60 v1.0.14 + T4-CODEX AUDIT-CONCERN narrowing: race classifier
290
323
  // requires explicit EBADF or ENOTTY (in code OR message). The earlier
291
324
  // shape — any "ioctl(N) failed" message — was too broad: it would have
292
325
  // silently dropped a non-race ioctl failure (e.g. EINTR, EFAULT) that
293
326
  // might indicate a real bug. Now: only the specific race-class signals
294
327
  // get suppressed; everything else rethrows so it surfaces in logs.
295
- const isRace =
296
- code === 'EBADF' ||
297
- code === 'ENOTTY' ||
298
- /\b(?:EBADF|ENOTTY)\b/.test(msg);
299
- if (isRace) {
328
+ if (isPtyRaceError(err)) {
300
329
  if (process.env.TERMDECK_DEBUG_PTY_RACES) {
301
- console.debug(`[ws] resize-after-pty-exit (race-expected): session=${session.id} ${code || msg}`);
330
+ console.debug(`[ws] resize-after-pty-exit (race-expected): session=${session.id} ${err.code || err.message}`);
302
331
  }
303
332
  return false;
304
333
  }
@@ -306,6 +335,35 @@ function safelyResizePty(session, cols, rows) {
306
335
  }
307
336
  }
308
337
 
338
+ // Sprint 63 T1 (Item 1.3) — body-parser hardening. The pre-existing
339
+ // `entity.verify.failed` / `entity.parse.failed` handler logged the error
340
+ // message but not WHICH bytes triggered the parse failure. Operators on
341
+ // Brad's r730 saw 9× SyntaxError flood over 13h with no fingerprint to
342
+ // identify the offending caller. `hexEscapePrefix` renders a 32-byte
343
+ // prefix of the raw body in a single-line, log-safe form: printable ASCII
344
+ // kept verbatim, non-printables rendered as `\xNN`, backslash escaped as
345
+ // `\\`. PII-conservative because we cap at 32 bytes (truncation marker `…`
346
+ // appended if more). The error middleware injects this into the existing
347
+ // `console.warn` line so the log signature is identifiable without
348
+ // dumping the full body.
349
+ function hexEscapePrefix(buf, maxBytes = 32) {
350
+ if (!buf || buf.length === 0) return '<no-body>';
351
+ const len = Math.min(buf.length, maxBytes);
352
+ let out = '';
353
+ for (let i = 0; i < len; i++) {
354
+ const b = buf[i];
355
+ if (b === 0x5c) {
356
+ out += '\\\\';
357
+ } else if (b >= 0x20 && b < 0x7f) {
358
+ out += String.fromCharCode(b);
359
+ } else {
360
+ out += '\\x' + b.toString(16).padStart(2, '0');
361
+ }
362
+ }
363
+ if (buf.length > maxBytes) out += '…';
364
+ return out;
365
+ }
366
+
309
367
  function createServer(config) {
310
368
  const app = express();
311
369
  const server = http.createServer(app);
@@ -328,6 +386,13 @@ function createServer(config) {
328
386
  // logs so real errors aren't drowned in noise.
329
387
  app.use(express.json({
330
388
  verify: (req, res, buf) => {
389
+ // Sprint 63 T1 (Item 1.3) — capture a stable copy of the raw body so
390
+ // the error middleware below can render a 32-byte hex-escaped prefix.
391
+ // `Buffer.from(buf)` copies because express may pool the underlying
392
+ // accumulator across requests; without the copy the error handler
393
+ // could see bytes from a later request.
394
+ req.rawBody = Buffer.from(buf);
395
+
331
396
  // O(N) single-pass scan. Only checks bytes inside double-quoted string
332
397
  // regions so structural whitespace doesn't trigger false positives.
333
398
  let inString = false;
@@ -372,7 +437,13 @@ function createServer(config) {
372
437
  err.type === 'entity.verify.failed' ||
373
438
  err instanceof SyntaxError
374
439
  )) {
375
- console.warn(`[body-parser] ${err.code || err.type || 'parse-error'}: ${err.message} (${req.method} ${req.path})`);
440
+ // Sprint 63 T1 (Item 1.3) append a 32-byte hex-escaped prefix of the
441
+ // raw body so the operator can identify which caller is sending bad
442
+ // JSON without exposing the full payload. Falls through to `<no-body>`
443
+ // if the verify callback never ran (parse error before verify, or no
444
+ // body at all).
445
+ const prefix = hexEscapePrefix(req.rawBody);
446
+ console.warn(`[body-parser] ${err.code || err.type || 'parse-error'}: ${err.message} (${req.method} ${req.path}) prefix="${prefix}"`);
376
447
  return res.status(400).json({
377
448
  error: 'Malformed JSON body',
378
449
  detail: err.message,
@@ -1171,6 +1242,18 @@ function createServer(config) {
1171
1242
  const sessUploadDir = path.join(os.tmpdir(), 'termdeck-uploads', session.id);
1172
1243
  fs.rmSync(sessUploadDir, { recursive: true, force: true });
1173
1244
  } catch (_err) { /* non-blocking */ }
1245
+
1246
+ // Sprint 63 T1 (Item 1.1) — null `session.pty` so the wrapper is
1247
+ // eligible for GC and downstream `if (session.pty)` guards correctly
1248
+ // identify the exited state. Root cause of Joshua's 2026-05-08/09
1249
+ // overnight `kern.tty.ptmx_max=511` exhaustion (516 fds for 4 panels):
1250
+ // without this nulling, node-pty's wrapper stayed pinned by onData /
1251
+ // onExit closures even after the child exited, holding the master
1252
+ // fd until next GC pass. Set AFTER `onPanelClose` fires (fire-and-
1253
+ // forget; reads `session.meta` + `session.id`, not `session.pty`) and
1254
+ // AFTER the upload-dir cleanup so any sync reader above this line
1255
+ // sees the original wrapper.
1256
+ session.pty = null;
1174
1257
  });
1175
1258
 
1176
1259
  // Wire command logging to SQLite + RAG
@@ -1328,7 +1411,7 @@ function createServer(config) {
1328
1411
  });
1329
1412
 
1330
1413
  // Graph endpoints (Sprint 38 T4) — knowledge-graph view backing graph.html.
1331
- // Reuses the petvetbid pg pool (same DATABASE_URL serves memory_items +
1414
+ // Reuses the daily-driver pg pool (same DATABASE_URL serves memory_items +
1332
1415
  // memory_relationships alongside rumen_*). Graceful-degrades when the pool
1333
1416
  // is absent.
1334
1417
  createGraphRoutes({
@@ -1358,6 +1441,14 @@ function createServer(config) {
1358
1441
  // Kill PTY process
1359
1442
  if (session.pty) {
1360
1443
  try { session.pty.kill(); } catch (err) { console.error('[pty] kill failed for session', req.params.id + ':', err); }
1444
+ // Sprint 63 T1 (Item 1.2) — stamp `_destroyed = true` on the pty wrapper
1445
+ // so `safelyResizePty` can short-circuit any resize attempts that arrive
1446
+ // in the kill()→onExit window. node-pty's `kill()` only signals the
1447
+ // child; onExit fires asynchronously once the child reaps. Without this
1448
+ // marker, a WS resize message in that window would ioctl a fd whose
1449
+ // child has just SIGHUP'd, surfacing as EBADF/ENOTTY. node-pty doesn't
1450
+ // set this property itself; the convention is owned by TermDeck.
1451
+ session.pty._destroyed = true;
1361
1452
  }
1362
1453
 
1363
1454
  sessions.remove(req.params.id);
@@ -1577,15 +1668,23 @@ function createServer(config) {
1577
1668
  });
1578
1669
 
1579
1670
  // POST /api/sessions/:id/resize - resize terminal
1671
+ // Sprint 63 T1 (Item 1.2) — distinguish "session never existed" (404) from
1672
+ // "session exists but PTY has exited" (410 Gone). Pre-Sprint-63 both paths
1673
+ // collapsed to 404 (when session.pty was null after the PTY-leak fix) or
1674
+ // 409 (when safelyResizePty returned false). 410 is the semantically
1675
+ // correct response: the resource was here, the resource is now gone.
1580
1676
  app.post('/api/sessions/:id/resize', (req, res) => {
1581
1677
  const session = sessions.get(req.params.id);
1582
- if (!session?.pty) return res.status(404).json({ error: 'Session not found' });
1678
+ if (!session) return res.status(404).json({ error: 'Session not found' });
1679
+ if (!session.pty || (session.meta && session.meta.status === 'exited')) {
1680
+ return res.status(410).json({ error: 'PTY is gone (session exited)' });
1681
+ }
1583
1682
 
1584
1683
  const { cols, rows } = req.body;
1585
1684
  try {
1586
1685
  const resized = safelyResizePty(session, cols, rows);
1587
1686
  if (!resized) {
1588
- return res.status(409).json({ error: 'Session is exited or its PTY is no longer alive' });
1687
+ return res.status(410).json({ error: 'PTY is gone (session exited)' });
1589
1688
  }
1590
1689
  res.json({ ok: true, cols, rows });
1591
1690
  } catch (err) {
@@ -2009,7 +2108,7 @@ function createServer(config) {
2009
2108
  });
2010
2109
 
2011
2110
  // ==================== Rumen insights (Sprint 4 T2) ====================
2012
- // Read-only access to rumen_insights + rumen_jobs in the petvetbid Postgres
2111
+ // Read-only access to rumen_insights + rumen_jobs in the daily-driver Postgres
2013
2112
  // instance. Contract frozen in docs/sprint-4-rumen-integration/API-CONTRACT.md.
2014
2113
 
2015
2114
  function rumenUnreachable(res) {
@@ -2250,7 +2349,7 @@ function createServer(config) {
2250
2349
 
2251
2350
  switch (parsed.type) {
2252
2351
  case 'input':
2253
- if (session.pty) {
2352
+ if (session.pty && !session.pty._destroyed) {
2254
2353
  session.pty.write(parsed.data);
2255
2354
  session.trackInput(parsed.data);
2256
2355
  }
@@ -2271,7 +2370,21 @@ function createServer(config) {
2271
2370
  }));
2272
2371
  break;
2273
2372
  }
2274
- } catch (err) { console.error('[ws] message handler error:', err); }
2373
+ } catch (err) {
2374
+ // Sprint 63 T1 (Item 1.2) — belt-and-suspenders: if a race-class
2375
+ // ioctl error somehow escapes safelyResizePty's own catch (or comes
2376
+ // from a future write/ioctl path), downgrade to console.debug
2377
+ // instead of polluting stderr with the noisy ws-message-handler
2378
+ // error log. safelyResizePty itself already catches the resize
2379
+ // path; this catches any other race-class shape that bubbles here.
2380
+ if (isPtyRaceError(err)) {
2381
+ if (process.env.TERMDECK_DEBUG_PTY_RACES) {
2382
+ console.debug(`[ws] message handler race-class (suppressed): ${err.code || err.message}`);
2383
+ }
2384
+ } else {
2385
+ console.error('[ws] message handler error:', err);
2386
+ }
2387
+ }
2275
2388
  });
2276
2389
 
2277
2390
  ws.on('close', () => {
@@ -2581,6 +2694,11 @@ module.exports = {
2581
2694
  // helper instead of re-implementing it. T4-CODEX AUDIT-CONCERN flagged that
2582
2695
  // the prior re-implementation pattern in the test could drift silently.
2583
2696
  safelyResizePty,
2697
+ // Sprint 63 T1 (Item 1.2 + 1.3) — race-class classifier + raw-body hex
2698
+ // prefix renderer exported so fence tests can import the production
2699
+ // helpers instead of re-implementing them.
2700
+ isPtyRaceError,
2701
+ hexEscapePrefix,
2584
2702
  // Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
2585
2703
  readTermdeckSecretsForPty,
2586
2704
  _resetTermdeckSecretsCache,
@@ -261,7 +261,13 @@ async function checkShellSanity() {
261
261
  let output = '';
262
262
  let resolved = false;
263
263
 
264
- const proc = ptyMod.spawn(shell, ['-l', '-c', 'echo TERMDECK_OK'], {
264
+ // Sprint 63 T3 §3.3 — drop `-l` (login mode). `-l` sources ~/.bash_profile
265
+ // / ~/.zshrc and friends, which on heavy profiles (nvm, conda, plugin
266
+ // managers — Brad's r730 has conda) routinely exceeds the 3s timeout
267
+ // budget below. A PTY-spawn health check answers "can $SHELL spawn a
268
+ // PTY and emit output?" — not "does the user's interactive profile
269
+ // complete fast?" Login-mode startup time is unrelated to PTY health.
270
+ const proc = ptyMod.spawn(shell, ['-c', 'echo TERMDECK_OK'], {
265
271
  name: 'xterm-256color',
266
272
  cols: 80,
267
273
  rows: 24,
@@ -110,7 +110,33 @@ const MIGRATION_PROBES = Object.freeze({
110
110
  '018_rumen_processed_at.sql':
111
111
  "select 1 from information_schema.columns where table_schema='public' and table_name='memory_sessions' and column_name='rumen_processed_at'",
112
112
  '019_security_hardening.sql':
113
- "select 1 from pg_proc p, unnest(coalesce(p.proconfig,'{}'::text[])) c where p.proname='memory_hybrid_search' and c like 'search_path=%' and c like '%extensions%'"
113
+ "select 1 from pg_proc p, unnest(coalesce(p.proconfig,'{}'::text[])) c where p.proname='memory_hybrid_search' and c like 'search_path=%' and c like '%extensions%'",
114
+ // 021 canonicalizes legacy gorgias / gorgias-ticket-monitor project tags to
115
+ // claimguard. Probe is NOT-EXISTS-shaped: returns 1 row when both legacy
116
+ // tags carry zero rows (021's effects are in place OR the install never had
117
+ // legacy data). Returns 0 rows when at least one legacy tag still has rows
118
+ // (021 has not yet run). False-positive backfill costs nothing because the
119
+ // migration's UPDATE is gated on `project IN ('gorgias', 'gorgias-ticket-monitor')`
120
+ // so a re-apply against an already-canonicalized corpus is a 0-row no-op.
121
+ // Sprint 62 T2 added this; 020 is bootstrap-special-cased and intentionally
122
+ // absent from MIGRATION_PROBES.
123
+ '021_project_tag_canonicalize_claimguard.sql':
124
+ "select 1 where not exists (select 1 from memory_items where project in ('gorgias', 'gorgias-ticket-monitor'))",
125
+ // 022 backfills source_agent for the rows where the writer is inferable from
126
+ // row shape (Predicate A: decision/bug_fix/architecture/preference/code_context
127
+ // → 'claude'; Predicate B: fact rows with source_session_id → 'claude';
128
+ // Predicate D: document_chunk → 'orchestrator'). Predicate C (fact rows
129
+ // with no session and no path) is intentionally NOT backfilled — see the
130
+ // migration body for the provenance-preservation rationale. Probe is
131
+ // NOT-EXISTS-shaped over the A/B/D row-set: returns 1 when those targets
132
+ // all have source_agent set (022's effects in place), 0 when any A/B/D
133
+ // target still has NULL (022 has not yet run). Excludes Predicate C from
134
+ // the probe predicate so the residual NULL slice doesn't keep the probe
135
+ // false forever. False-positive backfill costs nothing because the
136
+ // migration body is gated on `source_agent IS NULL` and a re-apply against
137
+ // an already-tagged corpus is a 0-row no-op. Sprint 62 T3.
138
+ '022_source_agent_backfill.sql':
139
+ "select 1 where not exists (select 1 from memory_items where source_agent is null and (source_type in ('decision','bug_fix','architecture','preference','code_context') or (source_type='fact' and source_session_id is not null) or source_type='document_chunk'))"
114
140
  });
115
141
 
116
142
  // Sprint 61 T2 — self-transactional detection.
@@ -0,0 +1,175 @@
1
+ -- 021_project_tag_canonicalize_claimguard.sql
2
+ -- Sprint 62 T2 — finishes the gorgias / gorgias-ticket-monitor → claimguard
3
+ -- rename that migration 012 (Sprint 41 T2) explicitly scoped out.
4
+ --
5
+ -- Why this exists:
6
+ -- Same project (the ClaimGuard repo at ~/Documents/Unagi/gorgias-ticket-monitor)
7
+ -- was tagged three ways across history. As of 2026-05-08:
8
+ -- - 'claimguard' ~29 rows (newest tag, written by the
9
+ -- post-Sprint-41 PROJECT_MAP)
10
+ -- - 'gorgias-ticket-monitor' ~245 rows (mid tag, the on-disk dir name)
11
+ -- - 'gorgias' ~541 rows (oldest tag, pre-Sprint-41)
12
+ --
13
+ -- Migration 012's §"What this migration does NOT do" called out the merge
14
+ -- as a separate cleanup pass:
15
+ --
16
+ -- - Does NOT consolidate duplicate tags like 'gorgias' vs
17
+ -- 'gorgias-ticket-monitor', 'pvb' vs 'PVB', or 'mnestra' vs 'engram'.
18
+ -- Visible in `SELECT project, count(*) FROM memory_items GROUP BY
19
+ -- project` but a separate cleanup pass.
20
+ --
21
+ -- That separate pass is 021. Sprint 21 T2's earlier rename plan never
22
+ -- landed; Sprint 35's harness-hook fix addressed the upstream PROJECT_MAP
23
+ -- so new rows tag correctly, and Sprint 62 T2 (this migration) closes the
24
+ -- historical-corpus gap so memory_recall(project="claimguard") returns the
25
+ -- full ~815-row history rather than just the post-Sprint-41 tail.
26
+ --
27
+ -- The companion T2 invariant test at
28
+ -- termdeck/tests/project-tag-invariant.test.js currently skips the claimguard
29
+ -- invariant via `deferredToSprint35`; with 021 applied that invariant would
30
+ -- pass cleanly if un-deferred. Un-deferring is out of T2's lane (test edits
31
+ -- are owned by orchestrator close-out).
32
+ --
33
+ -- Why the *project*-column merge and not a content-keyword rebucket: rows
34
+ -- already-tagged 'gorgias' or 'gorgias-ticket-monitor' carry definitive
35
+ -- project provenance — the row is from the ClaimGuard project by virtue of
36
+ -- the writer's prior tag, regardless of content keywords. We are not
37
+ -- inferring; we are renaming an exact-match tag set that the SOURCE-BRIEF
38
+ -- and 012's prologue both confirm refer to the same on-disk codebase.
39
+ --
40
+ -- Idempotence:
41
+ -- The UPDATE is gated by `WHERE project IN ('gorgias','gorgias-ticket-monitor')`.
42
+ -- After the first apply those rows carry project='claimguard', so a re-run
43
+ -- matches zero rows — RAISE NOTICE prints 0 and the migration succeeds. The
44
+ -- bundled migration runner (packages/server/src/setup/migration-runner.js)
45
+ -- also checksums applied migrations into mnestra_migrations (table from
46
+ -- 020) and skips re-application by filename, so the in-runner path is
47
+ -- idempotent at two layers.
48
+ --
49
+ -- RLS posture:
50
+ -- memory_items has RLS enabled (per migration 019 security hardening), but
51
+ -- service_role bypasses RLS. The migration runner authenticates as
52
+ -- service_role via DATABASE_URL, so the UPDATE lands without policy
53
+ -- changes. This migration does NOT touch policies or roles.
54
+ --
55
+ -- Reversibility:
56
+ -- Down-migration is documented at the bottom (commented). Splitting the
57
+ -- merged set back into three is destructive — once project='claimguard'
58
+ -- replaces the prior values, the row provenance for which tag it ORIGINALLY
59
+ -- carried is gone (no audit column tracks pre-image). Reversal requires
60
+ -- restore from a pg_dump snapshot taken before the migration was applied.
61
+ -- Do NOT attempt heuristic reversal.
62
+ --
63
+ -- Application:
64
+ -- Applied via the bundled migration runner using node-postgres
65
+ -- client.query(). DO blocks + GET DIAGNOSTICS ROW_COUNT (no psql
66
+ -- metacommands — \gset / \echo / etc are not supported in client.query).
67
+ -- Manual fallback: `psql "$DATABASE_URL" -f 021_project_tag_canonicalize_claimguard.sql`.
68
+
69
+ BEGIN;
70
+
71
+ -- ============================================================
72
+ -- AUDIT BEFORE
73
+ -- ============================================================
74
+ DO $$
75
+ DECLARE
76
+ before_claimguard int;
77
+ before_gorgias int;
78
+ before_gorgias_ticket_monitor int;
79
+ before_total_three int;
80
+ BEGIN
81
+ SELECT count(*) INTO before_claimguard
82
+ FROM public.memory_items WHERE project = 'claimguard';
83
+ SELECT count(*) INTO before_gorgias
84
+ FROM public.memory_items WHERE project = 'gorgias';
85
+ SELECT count(*) INTO before_gorgias_ticket_monitor
86
+ FROM public.memory_items WHERE project = 'gorgias-ticket-monitor';
87
+ before_total_three := before_claimguard + before_gorgias + before_gorgias_ticket_monitor;
88
+ RAISE NOTICE '[021-canonicalize] BEFORE claimguard=% gorgias=% gorgias-ticket-monitor=% (sum=%)',
89
+ before_claimguard, before_gorgias, before_gorgias_ticket_monitor, before_total_three;
90
+ END $$;
91
+
92
+ -- ============================================================
93
+ -- CANONICALIZE — gorgias + gorgias-ticket-monitor → claimguard
94
+ --
95
+ -- Single-statement UPDATE on the project column. No content scoping required:
96
+ -- the source tags refer unambiguously to the ClaimGuard project per Sprint 41
97
+ -- T2's analysis (012's prologue) and the SOURCE-BRIEF for Sprint 62 §1.
98
+ -- ============================================================
99
+ DO $$
100
+ DECLARE
101
+ affected_count integer;
102
+ BEGIN
103
+ UPDATE public.memory_items
104
+ SET project = 'claimguard'
105
+ WHERE project IN ('gorgias', 'gorgias-ticket-monitor');
106
+ GET DIAGNOSTICS affected_count = ROW_COUNT;
107
+ RAISE NOTICE '[021-canonicalize] canonicalized % memory_items rows (gorgias + gorgias-ticket-monitor) -> claimguard',
108
+ affected_count;
109
+ END $$;
110
+
111
+ -- ============================================================
112
+ -- AUDIT AFTER + CONSERVATION CHECK
113
+ -- ============================================================
114
+ DO $$
115
+ DECLARE
116
+ after_claimguard int;
117
+ after_gorgias int;
118
+ after_gorgias_ticket_monitor int;
119
+ BEGIN
120
+ SELECT count(*) INTO after_claimguard
121
+ FROM public.memory_items WHERE project = 'claimguard';
122
+ SELECT count(*) INTO after_gorgias
123
+ FROM public.memory_items WHERE project = 'gorgias';
124
+ SELECT count(*) INTO after_gorgias_ticket_monitor
125
+ FROM public.memory_items WHERE project = 'gorgias-ticket-monitor';
126
+ RAISE NOTICE '[021-canonicalize] AFTER claimguard=% gorgias=% gorgias-ticket-monitor=%',
127
+ after_claimguard, after_gorgias, after_gorgias_ticket_monitor;
128
+ IF after_gorgias <> 0 OR after_gorgias_ticket_monitor <> 0 THEN
129
+ RAISE EXCEPTION
130
+ '[021-canonicalize] post-apply invariant violated: expected zero rows in gorgias / gorgias-ticket-monitor, got gorgias=% gorgias-ticket-monitor=%',
131
+ after_gorgias, after_gorgias_ticket_monitor;
132
+ END IF;
133
+ END $$;
134
+
135
+ COMMIT;
136
+
137
+ -- ============================================================
138
+ -- POST-APPLY: verification queries (NOT part of the migration; run separately
139
+ -- to confirm the merge took, the invariant tests stay green, and the recall
140
+ -- path returns the full history). Each query is safe to run repeatedly.
141
+ -- ============================================================
142
+ --
143
+ -- 1. Tag distribution after migration — claimguard should be the only
144
+ -- bucket among the three; gorgias / gorgias-ticket-monitor should be 0:
145
+ -- SELECT project, count(*) FROM public.memory_items
146
+ -- WHERE project IN ('claimguard', 'gorgias', 'gorgias-ticket-monitor')
147
+ -- GROUP BY project ORDER BY project;
148
+ --
149
+ -- 2. Confirm no orphan rows remain under either legacy tag (these should
150
+ -- return 0):
151
+ -- SELECT count(*) FROM public.memory_items
152
+ -- WHERE project IN ('gorgias', 'gorgias-ticket-monitor');
153
+ --
154
+ -- 3. Spot-check that the merged set carries content from all three
155
+ -- historical eras (look for varied dates, varied source_types):
156
+ -- SELECT date_trunc('week', created_at) AS week, count(*)
157
+ -- FROM public.memory_items
158
+ -- WHERE project = 'claimguard'
159
+ -- GROUP BY 1 ORDER BY 1;
160
+ --
161
+ -- 4. Confirm the project-tag invariant test for claimguard would now pass
162
+ -- if un-deferred (rows whose content matches gorgias-ticket-monitor or
163
+ -- Unagi/ identifiers should be top-tagged claimguard):
164
+ -- SELECT project, count(*) FROM public.memory_items
165
+ -- WHERE content ILIKE '%gorgias-ticket-monitor%'
166
+ -- OR content ILIKE '%Unagi/%'
167
+ -- GROUP BY project ORDER BY count(*) DESC LIMIT 5;
168
+ --
169
+ -- DOWN-MIGRATION (manual, NOT auto-applied):
170
+ -- Splitting the merged set back into three is non-trivial (no source-of-
171
+ -- truth on which rows were originally which tag — provenance is lost when
172
+ -- the UPDATE replaces the project string). If a roll-back is needed,
173
+ -- restore from a pg_dump taken before this migration was applied. Do NOT
174
+ -- attempt to reverse via heuristic — the row provenance is destroyed by
175
+ -- the merge.