@jhizzard/termdeck 1.1.0 → 1.1.1

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": "1.1.0",
3
+ "version": "1.1.1",
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"
@@ -129,6 +129,12 @@
129
129
  // Sprint 37 T1: orchestrator Guide right-rail. Lazy — fetches the doc
130
130
  // on first expand to keep page load light.
131
131
  setupGuideRail();
132
+
133
+ // 2026-05-08 hotfix: document-level capture-phase image-paste handler.
134
+ // Intercepts Cmd+V image data before xterm-helper-textarea consumes it
135
+ // (xterm reads only text/plain, drops images silently). See comment on
136
+ // setupGlobalImagePaste() near uploadFilesAndType() for details.
137
+ setupGlobalImagePaste();
132
138
  }
133
139
 
134
140
  // ===== Drag/drop reorder of PTY panels (Sprint 42 T4) =====
@@ -275,6 +281,57 @@
275
281
  }
276
282
  }
277
283
 
284
+ // Document-level capture-phase image paste handler.
285
+ //
286
+ // The Sprint 59 per-panel `paste` listener at line 218 is bubble-phase, but
287
+ // xterm.js@5.5.0's hidden helper-textarea has its own `paste` handler that
288
+ // reads only `clipboardData.getData('text/plain')`. Image data lives in
289
+ // `clipboardData.items` with `kind: 'file'` and never reaches xterm's
290
+ // text path — and the panel-level bubble-phase handler runs after xterm's,
291
+ // by which point xterm has already returned (silently dropping the image).
292
+ // Net: pre-fix, Cmd+V'ing a screenshot into a focused TermDeck panel did
293
+ // nothing. Joshua reported this on 2026-05-08 (post-v1.1.0 upgrade).
294
+ //
295
+ // Fix: document-level listener with `{capture: true}` runs in capture
296
+ // phase BEFORE the event reaches xterm-helper-textarea. If the event
297
+ // target is inside a `.term-panel` AND the clipboard contains image
298
+ // files, we preventDefault + stopPropagation (so xterm + the bubble-phase
299
+ // panel handler don't see it) and route through `uploadFilesAndType`.
300
+ // For text paste (no image files) we let the event continue normally.
301
+ //
302
+ // Idempotent: setupGlobalImagePaste() is called once from init().
303
+ let _globalImagePasteSetup = false;
304
+ function setupGlobalImagePaste() {
305
+ if (_globalImagePasteSetup) return;
306
+ _globalImagePasteSetup = true;
307
+ document.addEventListener('paste', (e) => {
308
+ const target = e.target;
309
+ if (!(target instanceof Element)) return;
310
+ const panel = target.closest('.term-panel');
311
+ if (!panel) return;
312
+ const items = (e.clipboardData && e.clipboardData.items) || [];
313
+ const blobs = [];
314
+ for (const item of items) {
315
+ if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
316
+ const blob = item.getAsFile();
317
+ if (blob) blobs.push(blob);
318
+ }
319
+ }
320
+ if (blobs.length === 0) return;
321
+ e.preventDefault();
322
+ e.stopPropagation();
323
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
324
+ const named = blobs.map((b, i) => {
325
+ const ext = (b.type.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '');
326
+ const name = b.name && b.name.length > 0
327
+ ? b.name
328
+ : `pasted-${ts}${blobs.length > 1 ? '-' + i : ''}.${ext}`;
329
+ return new File([b], name, { type: b.type });
330
+ });
331
+ uploadFilesAndType(panel, named);
332
+ }, { capture: true });
333
+ }
334
+
278
335
  // ===== Create Terminal Panel =====
279
336
  function createTerminalPanel(sessionData) {
280
337
  const id = sessionData.id;
@@ -16,7 +16,25 @@ 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
40
  // Module-level singleton Postgres pool for rumen_insights (petvetbid DB).
@@ -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.
@@ -0,0 +1,182 @@
1
+ -- 022_source_agent_backfill.sql
2
+ -- Sprint 62 T3 (TermDeck) — backfill source_agent for pre-Sprint-50 NULL rows
3
+ -- where the writer can be inferred from row shape, NOT from content content-marker
4
+ -- inspection. Mnestra 0.4.9 (release-pending; orchestrator bumps at sprint close).
5
+ --
6
+ -- Why this exists:
7
+ -- Sprint 50 introduced source_agent (migration 015). Pre-Sprint-50 rows
8
+ -- have source_agent IS NULL and are silently excluded from filtered
9
+ -- memory_recall queries (per the recall tool's docstring: "NULL-source-
10
+ -- agent rows ... are excluded when this filter is set" — see
11
+ -- src/recall.ts:165-169).
12
+ --
13
+ -- 2026-05-08 production probe: 6,381 of 6,483 active memory_items rows
14
+ -- (~98%) have source_agent IS NULL — far above the SOURCE-BRIEF estimate
15
+ -- of "3,000+". Filtered recall has been blind to most of the corpus for
16
+ -- roughly the entire post-Sprint-50 window.
17
+ --
18
+ -- Migration 015 already backfilled session_summary NULL rows -> 'claude'
19
+ -- (015 lines 48-51), so the NULL universe today is exclusively non-
20
+ -- session_summary types. This migration closes the slice where the
21
+ -- writer can be inferred from row shape (architectural / schema /
22
+ -- structural evidence), and deliberately leaves the remaining slice
23
+ -- NULL — to be reached via the additive include_null_source recall
24
+ -- flag rather than by speculative attribution.
25
+ --
26
+ -- Design principle: row-shape attribution, not content-marker attribution.
27
+ -- The original SOURCE-BRIEF proposed content-marker predicates (ILIKE
28
+ -- '%[T-CODEX]%' etc). Sampling proved this unsafe: 100% of NULL rows
29
+ -- matching codex/gemini/grok markers are Claude *describing* those
30
+ -- agents, never authored by them. Marker == "row mentions agent",
31
+ -- not "row authored by agent".
32
+ --
33
+ -- Instead, this migration attributes by the (source_type, has_path,
34
+ -- has_session) tuple — schema-level fingerprints that map 1:1 to the
35
+ -- writer architecture, and that 50+ randomly-sampled rows confirm.
36
+ --
37
+ -- Predicate plan (each with explicit evidence chain):
38
+ --
39
+ -- A. NULL + source_type IN (decision, bug_fix, architecture, preference,
40
+ -- code_context) -> 'claude'.
41
+ -- Architectural evidence: pre-Sprint-50, only Claude shipped a
42
+ -- memory_remember client. The mcp__memory__memory_remember and
43
+ -- mcp__mnestra__memory_remember surfaces both ran exclusively in
44
+ -- Claude sessions. Codex/Gemini/Grok memory_remember capabilities
45
+ -- did not exist until the Sprint 51 per-agent MCP wiring (see
46
+ -- memory: "MCP server wiring patterns for Codex, Gemini, and Grok
47
+ -- CLIs (verified 2026-05-04 ... follow-up to Sprint 51.6's "Codex
48
+ -- MCP not wired" gap)"). All NULL rows of these source_types are
49
+ -- pre-Sprint-50 and therefore architecturally Claude.
50
+ -- Schema fingerprint: 100% of these rows have source_file_path IS NULL
51
+ -- AND source_session_id IS NULL — bare memory_remember shape.
52
+ -- Sample confirmation: 28-row sample showed 100% Claude-summary writing
53
+ -- pattern (project context, dated entries, file:line evidence — the
54
+ -- recognizable Claude memory_remember signature).
55
+ -- Expected count: 560.
56
+ --
57
+ -- B. NULL + source_type='fact' + source_session_id IS NOT NULL -> 'claude'.
58
+ -- Schema evidence: source_session_id is a Claude session UUID format
59
+ -- (matches the existing claude/session_summary tagged rows; same
60
+ -- shape: has_path=false, has_session=true). The Claude SessionEnd
61
+ -- hook is the only writer that populates source_session_id with a
62
+ -- Claude UUID. Other writers either set source_file_path (rag-extractor)
63
+ -- or leave both NULL (bare memory_remember).
64
+ -- Expected count: 4,587.
65
+ --
66
+ -- D. NULL + source_type='document_chunk' -> 'orchestrator'.
67
+ -- Structural evidence: 951/951 rows have source_file_path set + JSONB
68
+ -- metadata containing chunkIndex + heading keys — unmistakable
69
+ -- rag-system batch-chunker output. The chunker is not an LLM session;
70
+ -- 'orchestrator' is the appropriate non-LLM tag per the source_agent
71
+ -- enum (claude|codex|gemini|grok|orchestrator).
72
+ -- Path buckets:
73
+ -- 513 rows ~/.gemini/antigravity/scratch/* (Gemini scratch docs the
74
+ -- rag-extractor ingested — Gemini wrote the source MD,
75
+ -- but the rag-extractor wrote the row.)
76
+ -- 429 rows ~/Documents/* (project docs ingested directly).
77
+ -- 9 rows ~/.claude/projects/*/memory/MEMORY.md (auto-memory MD
78
+ -- ingested by the rag-extractor).
79
+ -- All four buckets are extractor-written, not LLM-written. The
80
+ -- original document author is preserved in source_file_path; the
81
+ -- row writer is the extractor.
82
+ -- Expected count: 951.
83
+ --
84
+ -- Predicate deliberately NOT applied (response to T4-CODEX 20:43 ET concern):
85
+ -- C. NULL + source_type='fact' + source_session_id IS NULL +
86
+ -- source_file_path IS NULL.
87
+ -- These 283 rows are bare memory_remember calls without session
88
+ -- attribution. Sampling (10 rows) showed 100% Claude content pattern,
89
+ -- but they lack the schema fingerprint that makes A/B/D structurally
90
+ -- definitive — there is no architectural lock that PREVENTS a
91
+ -- non-Claude writer from producing this shape (e.g., a manual psql
92
+ -- insert, a non-MCP REST call, or an early rag-extractor variant
93
+ -- that omitted source_file_path).
94
+ -- Migration 015 lines 24-30 explicitly preserved provenance
95
+ -- uncertainty for non-session_summary historical rows; broad
96
+ -- attribution here would erase that bright line. Per T4-CODEX
97
+ -- AUDIT-CONCERN (Sprint 62, 20:43 ET), these rows stay NULL and
98
+ -- are reached via the additive include_null_source recall path
99
+ -- added in src/recall.ts under this same sprint.
100
+ -- Residual NULL after this migration: 283 rows = 4.4% of corpus.
101
+ -- Acceptance target: <5%. Met.
102
+ --
103
+ -- Total backfill: 6,098 rows (A + B + D). Acceptance: residual NULL < 5%
104
+ -- of corpus (4.4% expected; well under threshold).
105
+ --
106
+ -- What this migration deliberately does NOT do:
107
+ -- * Touch session_summary rows (015 already attributed those).
108
+ -- * Touch already-tagged rows (every UPDATE is gated by source_agent IS NULL).
109
+ -- * Use content-marker predicates (sampling proved unreliable; markers
110
+ -- describe agents, not authors).
111
+ -- * Backfill the inferential-only slice (Predicate C, see above).
112
+ --
113
+ -- Idempotent: every UPDATE has WHERE source_agent IS NULL, so re-running
114
+ -- is a no-op on already-tagged rows. Safe to re-apply.
115
+ --
116
+ -- Reversibility: this migration tags rows but does not modify content,
117
+ -- type, or any other column. To revert (in a future migration), run:
118
+ -- UPDATE public.memory_items
119
+ -- SET source_agent = NULL
120
+ -- WHERE source_agent IN ('claude', 'orchestrator')
121
+ -- AND created_at < '2026-05-09'
122
+ -- AND source_type != 'session_summary'; -- preserve 015's backfill
123
+ --
124
+ -- RLS posture (per global CLAUDE.md RLS hygiene gates 1-5): this is a
125
+ -- DO block, not a CREATE FUNCTION. Runs as the migration runner's role
126
+ -- (service_role, which bypasses RLS). search_path is set explicitly to
127
+ -- defend against schema-shadow attacks during execution. No new policies,
128
+ -- no new function executable surface.
129
+
130
+ set search_path = public, pg_catalog;
131
+
132
+ do $$
133
+ declare
134
+ pred_a integer := 0;
135
+ pred_b integer := 0;
136
+ pred_d integer := 0;
137
+ remaining integer;
138
+ total_rows integer;
139
+ begin
140
+ -- Predicate A: structural attribution by source_type for non-fact, non-document_chunk
141
+ -- types. Architectural lock: pre-Sprint-50 only Claude shipped a memory_remember
142
+ -- client. NULL rows of these types are therefore unambiguously Claude.
143
+ update public.memory_items
144
+ set source_agent = 'claude'
145
+ where source_agent is null
146
+ and source_type in ('decision', 'bug_fix', 'architecture', 'preference', 'code_context');
147
+ get diagnostics pred_a = row_count;
148
+
149
+ -- Predicate B: fact rows with Claude-session attribution. source_session_id
150
+ -- is the Claude SessionEnd hook's UUID; same shape as the existing tagged
151
+ -- claude/session_summary rows.
152
+ update public.memory_items
153
+ set source_agent = 'claude'
154
+ where source_agent is null
155
+ and source_type = 'fact'
156
+ and source_session_id is not null;
157
+ get diagnostics pred_b = row_count;
158
+
159
+ -- Predicate D: rag-system document chunks -> 'orchestrator' (non-LLM batch writer).
160
+ -- All 951 rows carry source_file_path + chunkIndex/heading metadata — the
161
+ -- rag-extractor's deterministic fingerprint.
162
+ update public.memory_items
163
+ set source_agent = 'orchestrator'
164
+ where source_agent is null
165
+ and source_type = 'document_chunk';
166
+ get diagnostics pred_d = row_count;
167
+
168
+ select count(*) into remaining
169
+ from public.memory_items
170
+ where source_agent is null;
171
+
172
+ select count(*) into total_rows from public.memory_items;
173
+
174
+ raise notice '[022] backfill complete: A(claude/typed)=% B(claude/fact+session)=% D(orchestrator/doc_chunk)=% remaining_null=% / % total (acceptance: <5%%)',
175
+ pred_a, pred_b, pred_d, remaining, total_rows;
176
+ raise notice '[022] residual NULL = bare memory_remember fact rows (no session, no path); reach via include_null_source recall flag';
177
+ end$$;
178
+
179
+ -- Refresh the column comment to reflect 015 + 022 together as the partial-
180
+ -- backfill story, and document the residual + the recall flag escape hatch.
181
+ comment on column public.memory_items.source_agent is
182
+ 'Agent that produced this memory: claude|codex|gemini|grok|orchestrator|NULL. Populated at write time by per-agent SessionEnd writers from Sprint 50 onward. Pre-Sprint-50 NULL rows backfilled by migration 015 (session_summary -> claude) and migration 022 (decision/bug_fix/architecture/preference/code_context -> claude; fact w/ source_session_id -> claude; document_chunk -> orchestrator). Residual NULL = bare-call fact rows without session or path attribution; intentionally preserved per migration 015''s provenance bright line. Reach those via memory_recall include_null_source=true.';