@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 +1 -1
- package/packages/cli/src/init-mnestra.js +214 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "1.0.
|
|
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;
|