@jhizzard/termdeck 1.0.3 → 1.0.4

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.4",
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;