@jhizzard/termdeck 1.0.3 → 1.0.5

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.0.3",
3
+ "version": "1.0.5",
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"
@@ -599,6 +599,201 @@ function refreshBundledHookIfNewer(opts = {}) {
599
599
  // production users never see noise; the gate is broadly useful for future
600
600
  // wire-up bisects (any developer can re-run the wizard with the env var
601
601
  // set and get a deterministic trace).
602
+ // Sprint 51.8 — settings.json wiring migration (Brad's v1.0.3 follow-up).
603
+ //
604
+ // Sprint 51.7 fixed the Class M wire-up bug — `runHookRefresh()` runs
605
+ // upstream of the DB phase so the v2 hook FILE always lands. But the
606
+ // settings.json wiring half of `installSessionEndHook` (in
607
+ // `packages/stack-installer/src/index.js`) was never lifted into the
608
+ // wizard. Result: anyone whose `~/.claude/settings.json` was wired by a
609
+ // pre-Sprint-48 stack-installer (= `@jhizzard/termdeck-stack@<=0.5.0`,
610
+ // matching anyone who first ran `termdeck init --mnestra` on
611
+ // v1.0.0/v1.0.1) gets the v2 hook FILE post-1.0.3, but the file is still
612
+ // wired under `Stop`. The v2 hook does not gate on event type, so it
613
+ // fires every assistant turn and writes N `session_summary` rows in
614
+ // `memory_items` per session (Brad's 2026-05-04 jizzard-brain repro).
615
+ //
616
+ // `_mergeSessionEndHookEntry` is a 1:1 hoist of the same-named function
617
+ // in `packages/stack-installer/src/index.js:451`. We can't `require()`
618
+ // across to it because the published `@jhizzard/termdeck` tarball ships
619
+ // only `packages/stack-installer/assets/hooks/**`, not `.../src/**` —
620
+ // the settings.json migration logic is unreachable at runtime from the
621
+ // wizard's own tarball. Hoisting is correct here; the function is pure
622
+ // (~50 LOC, no I/O), the upstream is exhaustively covered by
623
+ // `tests/stack-installer-hook-merge.test.js`, and a duplicate copy
624
+ // avoids cross-package version-coupling.
625
+ //
626
+ // Why we run this on every wizard pass, not just first install: the
627
+ // whole point is to self-heal old Stop wirings on upgrade. Idempotent —
628
+ // if SessionEnd is already correct, this no-ops and prints
629
+ // `already wired`. Brad's framing: "settings.json invariants the wizard
630
+ // must enforce" (INSTALLER-PITFALLS.md ledger #16). The wizard is the
631
+ // canonical place to enforce them because it's the only path users
632
+ // actually run after upgrading the package.
633
+
634
+ const SETTINGS_JSON_PATH = path.join(require('os').homedir(), '.claude', 'settings.json');
635
+ const HOOK_COMMAND = 'node ~/.claude/hooks/memory-session-end.js';
636
+ const HOOK_TIMEOUT_SECONDS = 30;
637
+
638
+ function _isSessionEndHookEntry(entry) {
639
+ return entry && typeof entry.command === 'string'
640
+ && entry.command.includes('memory-session-end.js');
641
+ }
642
+
643
+ // Pure: merges our SessionEnd entry into the given settings object.
644
+ // Idempotent. Returns { settings, status } where status is one of
645
+ // 'already-installed', 'installed', or 'migrated-from-stop'. Mutates
646
+ // the input. Mirrors `_mergeSessionEndHookEntry` in
647
+ // `packages/stack-installer/src/index.js:451` byte-for-byte (modulo
648
+ // constants pulled from this file's scope).
649
+ function _mergeSessionEndHookEntry(settings, opts = {}) {
650
+ const command = opts.command || HOOK_COMMAND;
651
+ const timeout = opts.timeout != null ? opts.timeout : HOOK_TIMEOUT_SECONDS;
652
+ const entry = { type: 'command', command, timeout };
653
+
654
+ if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
655
+
656
+ // Migrate any pre-Sprint-48 Stop registration of OUR hook to SessionEnd.
657
+ // Only entries matching `_isSessionEndHookEntry` are touched — any
658
+ // unrelated Stop hooks the user has are preserved verbatim.
659
+ let migrated = false;
660
+ if (Array.isArray(settings.hooks.Stop)) {
661
+ for (const group of settings.hooks.Stop) {
662
+ if (!group || !Array.isArray(group.hooks)) continue;
663
+ const before = group.hooks.length;
664
+ group.hooks = group.hooks.filter((e) => !_isSessionEndHookEntry(e));
665
+ if (group.hooks.length !== before) migrated = true;
666
+ }
667
+ settings.hooks.Stop = settings.hooks.Stop.filter(
668
+ (g) => g && Array.isArray(g.hooks) && g.hooks.length > 0
669
+ );
670
+ if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
671
+ }
672
+
673
+ if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
674
+
675
+ for (const group of settings.hooks.SessionEnd) {
676
+ if (!group || !Array.isArray(group.hooks)) continue;
677
+ if (group.hooks.some(_isSessionEndHookEntry)) {
678
+ return { settings, status: migrated ? 'migrated-from-stop' : 'already-installed' };
679
+ }
680
+ }
681
+
682
+ const emptyMatcher = settings.hooks.SessionEnd.find(
683
+ (g) => g && g.matcher === '' && Array.isArray(g.hooks)
684
+ );
685
+ if (emptyMatcher) {
686
+ emptyMatcher.hooks.push(entry);
687
+ } else {
688
+ settings.hooks.SessionEnd.push({ matcher: '', hooks: [entry] });
689
+ }
690
+ return { settings, status: migrated ? 'migrated-from-stop' : 'installed' };
691
+ }
692
+
693
+ function _readSettingsJson(filePath) {
694
+ if (!fs.existsSync(filePath)) {
695
+ return { settings: {}, status: 'no-file' };
696
+ }
697
+ try {
698
+ const raw = fs.readFileSync(filePath, 'utf8');
699
+ if (raw.trim() === '') return { settings: {}, status: 'empty' };
700
+ const parsed = JSON.parse(raw);
701
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
702
+ return { settings: {}, status: 'malformed', error: 'top-level must be an object' };
703
+ }
704
+ return { settings: parsed, status: 'ok' };
705
+ } catch (e) {
706
+ return { settings: {}, status: 'malformed', error: e.message };
707
+ }
708
+ }
709
+
710
+ function _writeSettingsJson(filePath, settings) {
711
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
712
+ const tmp = filePath + '.tmp';
713
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
714
+ fs.renameSync(tmp, filePath);
715
+ }
716
+
717
+ // Apply the Stop→SessionEnd migration to ~/.claude/settings.json (or the
718
+ // path passed via opts.settingsPath, used by tests). Idempotent on
719
+ // already-migrated installs. Best-effort backup before write —
720
+ // timestamped `.bak.<YYYYMMDDhhmmss>` matching the convention used by
721
+ // `refreshBundledHookIfNewer`. Returns a structured status the caller
722
+ // can pretty-print.
723
+ function migrateSettingsJsonHookEntry(opts = {}) {
724
+ const dryRun = !!opts.dryRun;
725
+ const settingsPath = opts.settingsPath || SETTINGS_JSON_PATH;
726
+
727
+ const read = _readSettingsJson(settingsPath);
728
+ if (read.status === 'malformed') {
729
+ return { status: 'malformed', error: read.error, settingsPath };
730
+ }
731
+
732
+ // Snapshot pre-merge JSON so we can detect "no actual change" even
733
+ // when status == 'installed' (e.g. a fresh user with no hook keys at
734
+ // all — we'd add one, which IS a change).
735
+ const before = JSON.stringify(read.settings);
736
+ const merge = _mergeSessionEndHookEntry(read.settings);
737
+ const after = JSON.stringify(merge.settings);
738
+ const noChange = before === after;
739
+
740
+ if (merge.status === 'already-installed' || noChange) {
741
+ return { status: 'already-installed', settingsPath };
742
+ }
743
+
744
+ if (dryRun) {
745
+ return { status: 'would-' + merge.status, settingsPath };
746
+ }
747
+
748
+ // Best-effort backup before write — only when a settings.json
749
+ // existed; brand-new install (no-file → write) doesn't need a backup.
750
+ let backup = null;
751
+ if (read.status === 'ok' || read.status === 'empty') {
752
+ const stamp = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
753
+ backup = `${settingsPath}.bak.${stamp}`;
754
+ try { fs.copyFileSync(settingsPath, backup); }
755
+ catch (_) { backup = null; /* best-effort */ }
756
+ }
757
+
758
+ _writeSettingsJson(settingsPath, merge.settings);
759
+ return { status: merge.status, settingsPath, backup };
760
+ }
761
+
762
+ function runSettingsJsonMigration({ dryRun = false } = {}) {
763
+ const debug = !!process.env.TERMDECK_DEBUG_WIREUP;
764
+ step('Reconciling ~/.claude/settings.json hook event mapping (Stop → SessionEnd)...');
765
+ if (debug) {
766
+ process.stderr.write(`[wire-up-debug] runSettingsJsonMigration entry: dryRun=${dryRun} SETTINGS_JSON_PATH=${SETTINGS_JSON_PATH} exists=${fs.existsSync(SETTINGS_JSON_PATH)}\n`);
767
+ }
768
+ try {
769
+ const r = migrateSettingsJsonHookEntry({ dryRun });
770
+ if (debug) process.stderr.write(`[wire-up-debug] runSettingsJsonMigration return: ${JSON.stringify(r)}\n`);
771
+ if (r.status === 'already-installed') {
772
+ ok('already wired (SessionEnd)');
773
+ } else if (r.status === 'installed') {
774
+ ok(r.backup ? `installed (SessionEnd; backup: ${path.basename(r.backup)})` : 'installed (SessionEnd)');
775
+ } else if (r.status === 'migrated-from-stop') {
776
+ ok(r.backup ? `migrated Stop → SessionEnd (was firing on every turn; backup: ${path.basename(r.backup)})` : 'migrated Stop → SessionEnd (was firing on every turn)');
777
+ } else if (r.status === 'would-installed') {
778
+ ok('would install (SessionEnd) (dry-run)');
779
+ } else if (r.status === 'would-migrated-from-stop') {
780
+ ok('would migrate Stop → SessionEnd (dry-run)');
781
+ } else if (r.status === 'malformed') {
782
+ ok(`(skipped: settings.json malformed: ${r.error})`);
783
+ } else {
784
+ ok(`(${r.status})`);
785
+ }
786
+ } catch (err) {
787
+ // Don't abort init for a settings-migration failure — log + continue.
788
+ // Same fail-soft posture as runHookRefresh: the user's wizard goal
789
+ // (DB setup) is independent of settings.json wiring; if we can't
790
+ // write to settings.json (e.g. permission denied), the wizard
791
+ // should still finish the DB work.
792
+ process.stdout.write(` ! settings.json migration failed: ${err.message} (continuing)\n`);
793
+ if (debug) process.stderr.write(`[wire-up-debug] runSettingsJsonMigration threw: ${err && err.stack || err}\n`);
794
+ }
795
+ }
796
+
602
797
  function runHookRefresh({ dryRun = false } = {}) {
603
798
  const debug = !!process.env.TERMDECK_DEBUG_WIREUP;
604
799
  step('Refreshing ~/.claude/hooks/memory-session-end.js (if bundled is newer)...');
@@ -715,6 +910,18 @@ async function main(argv) {
715
910
  // strands the wizard.
716
911
  runHookRefresh({ dryRun: flags.dryRun });
717
912
 
913
+ // Sprint 51.8 — reconcile ~/.claude/settings.json wiring. Sprint 51.7
914
+ // landed the v2 hook FILE on disk but never moved the event mapping
915
+ // from `Stop` to `SessionEnd` for users whose settings.json was
916
+ // written by `@jhizzard/termdeck-stack@<=0.5.0`. The v2 hook does not
917
+ // gate on event type, so a Stop wiring fires on every assistant turn
918
+ // and writes N session_summary rows in memory_items per session. This
919
+ // migration is idempotent and runs alongside the file refresh so the
920
+ // wire-up + wiring stay in lockstep on every wizard pass. Brad's
921
+ // 2026-05-04 jizzard-brain repro is the canonical fixture for this
922
+ // class of bug (INSTALLER-PITFALLS.md ledger #16).
923
+ runSettingsJsonMigration({ dryRun: flags.dryRun });
924
+
718
925
  step('Connecting to Supabase...');
719
926
  if (flags.dryRun) {
720
927
  ok('(dry-run, skipped)');
@@ -795,3 +1002,10 @@ if (require.main === module) {
795
1002
  module.exports = main;
796
1003
  // Sprint 51.6 T3 — exported for tests/init-mnestra-hook-refresh.test.js.
797
1004
  module.exports.refreshBundledHookIfNewer = refreshBundledHookIfNewer;
1005
+ // Sprint 51.8 — exported for tests/init-mnestra-settings-migration.test.js.
1006
+ module.exports.migrateSettingsJsonHookEntry = migrateSettingsJsonHookEntry;
1007
+ module.exports._mergeSessionEndHookEntry = _mergeSessionEndHookEntry;
1008
+ module.exports._isSessionEndHookEntry = _isSessionEndHookEntry;
1009
+ module.exports.SETTINGS_JSON_PATH = SETTINGS_JSON_PATH;
1010
+ module.exports.HOOK_COMMAND = HOOK_COMMAND;
1011
+ module.exports.HOOK_TIMEOUT_SECONDS = HOOK_TIMEOUT_SECONDS;
@@ -78,6 +78,45 @@ create index if not exists memory_relationships_target_idx on memory_relationshi
78
78
 
79
79
  -- ── match_memories helper RPC ─────────────────────────────────────────────
80
80
  -- Used by remember.ts (dedup) and consolidate.ts (cluster seeding).
81
+ --
82
+ -- Sprint 52.1 — signature-drift guard. On long-lived v0.6.x-era installs
83
+ -- (Joshua's petvetbid, Brad's jizzard-brain), match_memories was created by
84
+ -- a prior Mnestra version with a different RETURN-table column shape:
85
+ -- (id, content, metadata, source_type, category, project, created_at, similarity)
86
+ -- vs the canonical:
87
+ -- (id, content, source_type, category, project, metadata, similarity)
88
+ -- Postgres rejects `CREATE OR REPLACE FUNCTION` when the return-table
89
+ -- column list changes — `cannot change return type of existing function`
90
+ -- — and the migration replay throws exit 5. Sprint 51.7 fixed Class M
91
+ -- (DB failure no longer strands hook upgrade) but this drift remained
92
+ -- and was the only blocker keeping `termdeck init --mnestra` from
93
+ -- finishing cleanly on existing v0.6.x installs. Sprint 51.8 fixed
94
+ -- Class N (settings.json wiring lockstep).
95
+ --
96
+ -- The do-block below drops all `public.match_memories` overloads
97
+ -- regardless of arg list, so the subsequent CREATE OR REPLACE always
98
+ -- lands cleanly on greenfield AND existing-drift installs. Idempotent —
99
+ -- on a brand-new project the loop iterates zero times. Scoped to schema
100
+ -- `public` so we never touch a same-named function in another schema.
101
+ -- No CASCADE: dependent objects in plpgsql/SQL function bodies (e.g.
102
+ -- `memory_recall_graph` from migration 010) reference functions by
103
+ -- name and resolve at call time, so the drop-then-recreate pattern is
104
+ -- safe without CASCADE. If a true hard dependency (view, generated
105
+ -- column) ever appears, the DROP fails loud — the right behavior.
106
+
107
+ do $$
108
+ declare
109
+ r record;
110
+ begin
111
+ for r in
112
+ select p.oid::regprocedure as sig
113
+ from pg_proc p
114
+ join pg_namespace n on n.oid = p.pronamespace
115
+ where p.proname = 'match_memories' and n.nspname = 'public'
116
+ loop
117
+ execute 'drop function ' || r.sig::text;
118
+ end loop;
119
+ end $$;
81
120
 
82
121
  create or replace function match_memories (
83
122
  query_embedding vector(1536),