hypomnema 1.2.1 → 1.3.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.
Files changed (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +4 -2
  4. package/README.md +4 -2
  5. package/commands/crystallize.md +23 -6
  6. package/commands/feedback.md +1 -1
  7. package/commands/upgrade.md +2 -0
  8. package/docs/CONTRIBUTING.md +96 -11
  9. package/hooks/hypo-auto-commit.mjs +3 -3
  10. package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
  11. package/hooks/hypo-cwd-change.mjs +2 -2
  12. package/hooks/hypo-first-prompt.mjs +1 -1
  13. package/hooks/hypo-personal-check.mjs +57 -7
  14. package/hooks/hypo-session-start.mjs +73 -19
  15. package/hooks/hypo-shared.mjs +206 -16
  16. package/hooks/version-check.mjs +204 -6
  17. package/package.json +5 -2
  18. package/scripts/bump-version.mjs +9 -3
  19. package/scripts/check-bilingual.mjs +115 -0
  20. package/scripts/crystallize.mjs +130 -16
  21. package/scripts/doctor.mjs +45 -9
  22. package/scripts/feedback-sync.mjs +44 -15
  23. package/scripts/feedback.mjs +5 -5
  24. package/scripts/fix-status-verify.mjs +256 -0
  25. package/scripts/init.mjs +45 -4
  26. package/scripts/install-git-hooks.mjs +258 -0
  27. package/scripts/lib/adr-corpus.mjs +79 -0
  28. package/scripts/lib/check-bilingual.mjs +141 -0
  29. package/scripts/lib/extensions.mjs +3 -3
  30. package/scripts/lib/feedback-scope.mjs +21 -0
  31. package/scripts/lib/fix-manifest.mjs +109 -0
  32. package/scripts/lib/fix-status-verify.mjs +438 -0
  33. package/scripts/lib/plugin-detect.mjs +51 -0
  34. package/scripts/lib/pre-commit-format.mjs +251 -0
  35. package/scripts/lib/project-create.mjs +2 -2
  36. package/scripts/lint.mjs +48 -8
  37. package/scripts/pre-commit-format.mjs +198 -0
  38. package/scripts/resume.mjs +61 -3
  39. package/scripts/smoke-pack.mjs +39 -2
  40. package/scripts/upgrade.mjs +308 -58
  41. package/skills/crystallize/SKILL.md +13 -2
  42. package/templates/hypo-config.md +1 -1
  43. package/templates/hypo-guide.md +4 -0
@@ -0,0 +1,438 @@
1
+ /**
2
+ * fix-status-verify (lib) — pure functions for verifying fix→test linkage.
3
+ *
4
+ * Phase 1 of CLAUDE.md learned_behavior #6 (2026-05-16): "merged 표기 전
5
+ * (1) ADR 핵심 결정 라인 grep + (2) replay/integration test green 양쪽 충족".
6
+ * This module automates the *test green* half. ADR core decision grep is out
7
+ * of scope (Phase 2 / v1.3.0 manifest PR).
8
+ *
9
+ * SoT split (after codex 3-worker review 2026-05-27):
10
+ * - fix→test mapping SoT: `// @fix #N: <full test name>` anchor comments in
11
+ * tests/runner.mjs (sits next to the assertion that verifies the fix).
12
+ * - fix status SoT: wiki spec-v1.2.md word-boundary grep
13
+ * (\bfix\s*#N\b ... \b(TRUE_MERGED|merged|resolved)\b).
14
+ *
15
+ * Word-boundary on status terms is required so STALE_MERGED / partial /
16
+ * retired are NOT matched as positive claims.
17
+ */
18
+
19
+ import { parseFrontmatter } from './frontmatter.mjs';
20
+
21
+ const POSITIVE_STATUSES = new Set(['merged', 'TRUE_MERGED', 'resolved']);
22
+
23
+ // Words that disqualify a line as a positive claim (case-sensitive).
24
+ // STALE_MERGED contains "MERGED" as a substring but is the opposite signal.
25
+ const NEGATIVE_STATUS_TOKENS = ['STALE_MERGED', 'partial', 'retired'];
26
+
27
+ /**
28
+ * Parse anchor comments out of runner.mjs source text.
29
+ *
30
+ * // @fix #15: all type-conditional fields present → green
31
+ * // @fix #15: another test name
32
+ *
33
+ * The `@fix` prefix is mandatory — distinguishes anchors from prose comments
34
+ * that mention "fix #N" in passing. Each anchor line maps ONE fix # to ONE
35
+ * test name (whole captured group is the name, no comma-splitting). Multiple
36
+ * anchors for the same fix # accumulate (union, order-preserving, dedupe).
37
+ *
38
+ * Sentinel: NAME = "NO_AUTO_TEST" declares the fix has no automated test by
39
+ * design (behavioral / prompt-driven). Verified upstream in verifyMatrix.
40
+ */
41
+ export function parseAnchors(runnerText) {
42
+ const out = new Map();
43
+ const re = /^\s*\/\/\s*@fix\s*#(\d+)\s*:\s*(.+?)\s*$/gim;
44
+ let m;
45
+ while ((m = re.exec(runnerText)) !== null) {
46
+ const fixNum = Number(m[1]);
47
+ const name = m[2].trim();
48
+ if (!name) continue;
49
+ if (!out.has(fixNum)) out.set(fixNum, []);
50
+ const list = out.get(fixNum);
51
+ if (!list.includes(name)) list.push(name);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * Detect a redirect-stub spec: a page whose frontmatter declares
58
+ * `type: reference`. These are placeholders left behind after an archive move
59
+ * (e.g. spec-v1.2.md → archive/spec-v1.2.md) and carry zero fix-status claims.
60
+ * Pointing the verifier at one yields a vacuous green, so verifyMatrix rejects
61
+ * it up front.
62
+ */
63
+ export function isReferenceStub(specText) {
64
+ const fm = parseFrontmatter(specText);
65
+ return fm?.type === 'reference';
66
+ }
67
+
68
+ /**
69
+ * Parse fix status claims out of wiki spec-v1.2.md.
70
+ *
71
+ * Returns Map<fixNum:number, status:string>. status is the most recent
72
+ * positive status token in the file (last mention wins so `merged → resolved`
73
+ * narrative normalises to `resolved`). Fixes whose only mentions are negative
74
+ * (STALE_MERGED / partial / retired) are NOT added — they're considered
75
+ * incomplete claims and skipped by verifyMatrix.
76
+ */
77
+ export function parseStatus(specText) {
78
+ const out = new Map();
79
+ // For each line, find every fix # mention and check whether a positive
80
+ // status token sits within a small proximity window AFTER the mention. This
81
+ // avoids false positives when a line mentions multiple fix #s with status
82
+ // tokens that only apply to some of them (e.g. "fix #38 (resolved); fix #41
83
+ // (v1.3.0 advisory)" — only #38 should be picked up).
84
+ const lines = specText.split('\n');
85
+ const PROXIMITY = 120; // chars after fix # to scan for status
86
+ for (const line of lines) {
87
+ // Quick reject: line must mention a positive status token at all.
88
+ const hasPositive =
89
+ /\bTRUE_MERGED\b/.test(line) ||
90
+ /\bresolved\b/.test(line) ||
91
+ /(?<![A-Z_])merged(?![A-Z_])/.test(line);
92
+ if (!hasPositive) continue;
93
+ // Two accepted fix # forms:
94
+ // (a) inline prose: "fix #N" (word-boundary)
95
+ // (b) table cell start: "| #N |" (§9.1.0 status correction table)
96
+ const matches = [];
97
+ let m2;
98
+ const inlineRe = /\bfix\s*#(\d+)\b/gi;
99
+ while ((m2 = inlineRe.exec(line)) !== null) {
100
+ matches.push({ fixNum: Number(m2[1]), end: m2.index + m2[0].length });
101
+ }
102
+ const tableRe = /\|\s*#(\d+)\s*\|/g;
103
+ while ((m2 = tableRe.exec(line)) !== null) {
104
+ matches.push({ fixNum: Number(m2[1]), end: m2.index + m2[0].length });
105
+ }
106
+ for (const { fixNum, end } of matches) {
107
+ const window = line.slice(end, end + PROXIMITY);
108
+ // Determine the strongest status in the proximity window. Priority:
109
+ // TRUE_MERGED > resolved > merged.
110
+ let status = null;
111
+ if (/\bTRUE_MERGED\b/.test(window)) status = 'TRUE_MERGED';
112
+ else if (/\bresolved\b/.test(window)) status = 'resolved';
113
+ else if (/(?<![A-Z_])merged(?![A-Z_])/.test(window)) status = 'merged';
114
+ if (status) out.set(fixNum, status); // last positive mention wins
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Parse runner.mjs stdout to map test names → "pass" | "fail".
122
+ *
123
+ * The harness prints ` ✓ <name>` on pass and ` ✗ <name>` on fail.
124
+ */
125
+ export function parseRunnerOutput(stdout) {
126
+ const out = new Map();
127
+ const lines = stdout.split('\n');
128
+ for (const line of lines) {
129
+ const passM = line.match(/^\s*✓\s+(.+?)\s*$/);
130
+ if (passM) {
131
+ // Sticky pass — only set if no prior result. A later fail must NOT be
132
+ // overridden, and a prior fail must not be flipped back to pass.
133
+ if (!out.has(passM[1])) out.set(passM[1], 'pass');
134
+ continue;
135
+ }
136
+ const failM = line.match(/^\s*✗\s+(.+?)\s*$/);
137
+ if (failM) {
138
+ // Fail is sticky: once a name has any failure, the verdict stays fail
139
+ // even if a duplicate test() with the same name passed elsewhere.
140
+ out.set(failM[1], 'fail');
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * Cross-check anchors × status × test results.
148
+ *
149
+ * Finding classes:
150
+ * NO_ANCHOR — fix claimed positive in spec, no anchor in runner.
151
+ * MISSING_TEST — anchor names a test, runner output does not contain it.
152
+ * FAILING_TEST — anchor names a test, runner output marks it failed.
153
+ * ORPHAN_ANCHOR — anchor exists, no positive status claim in spec (warn).
154
+ * STUB_SPEC — spec is unusable: a `type: reference` redirect stub, or
155
+ * it parses zero positive status claims while anchors exist
156
+ * (the vacuous-gate the tool exists to prevent). Error.
157
+ *
158
+ * Returns { ok, findings: [...] }. ok=false if any ERROR-level finding.
159
+ * ORPHAN_ANCHOR is WARN-only.
160
+ *
161
+ * STUB_SPEC is a precondition failure, so it short-circuits: when the spec is
162
+ * unusable there is nothing meaningful to cross-check, and the per-anchor
163
+ * ORPHAN noise would only bury the one decisive error.
164
+ */
165
+ export function verifyMatrix({ anchors, status, testResults, specIsStub = false }) {
166
+ if (specIsStub) {
167
+ return {
168
+ ok: false,
169
+ findings: [
170
+ {
171
+ level: 'error',
172
+ class: 'STUB_SPEC',
173
+ detail:
174
+ 'spec is a `type: reference` redirect stub (0 fix-status claims by design) — pass --spec pointing at the real spec',
175
+ },
176
+ ],
177
+ };
178
+ }
179
+ // Vacuous-gate invariant: anchors exist in the runner but the spec yields no
180
+ // positive status claim to verify them against. Greening here would defeat
181
+ // the tool's purpose. (No anchors + no claims is an empty/custom matrix, not
182
+ // a vacuous gate, so it is left to the normal path.)
183
+ if (status.size === 0 && anchors.size > 0) {
184
+ return {
185
+ ok: false,
186
+ findings: [
187
+ {
188
+ level: 'error',
189
+ class: 'STUB_SPEC',
190
+ detail: `${anchors.size} anchor(s) in runner but 0 positive status claims parsed from spec — gate would be vacuous`,
191
+ },
192
+ ],
193
+ };
194
+ }
195
+
196
+ const findings = [];
197
+
198
+ for (const [fixNum, statusValue] of status.entries()) {
199
+ const anchored = anchors.get(fixNum);
200
+ if (!anchored || anchored.length === 0) {
201
+ findings.push({
202
+ level: 'error',
203
+ class: 'NO_ANCHOR',
204
+ fixNum,
205
+ status: statusValue,
206
+ detail: `claimed ${statusValue} in spec but no // fix #${fixNum}: anchor in runner.mjs`,
207
+ });
208
+ continue;
209
+ }
210
+ // Sentinel: explicit "no automated test by design" (behavioral /
211
+ // prompt-driven fixes). The fix is still claimed-merged but verifying it
212
+ // is out of scope for an integration runner.
213
+ if (anchored.length === 1 && anchored[0] === 'NO_AUTO_TEST') {
214
+ findings.push({
215
+ level: 'info',
216
+ class: 'NO_AUTO_TEST',
217
+ fixNum,
218
+ status: statusValue,
219
+ detail: `fix #${fixNum} declares NO_AUTO_TEST (behavioral / prompt-driven)`,
220
+ });
221
+ continue;
222
+ }
223
+ for (const testName of anchored) {
224
+ const result = testResults.get(testName);
225
+ if (result === undefined) {
226
+ findings.push({
227
+ level: 'error',
228
+ class: 'MISSING_TEST',
229
+ fixNum,
230
+ status: statusValue,
231
+ testName,
232
+ detail: `anchor names "${testName}" but no such test ran`,
233
+ });
234
+ } else if (result === 'fail') {
235
+ findings.push({
236
+ level: 'error',
237
+ class: 'FAILING_TEST',
238
+ fixNum,
239
+ status: statusValue,
240
+ testName,
241
+ detail: `test "${testName}" failed`,
242
+ });
243
+ }
244
+ }
245
+ }
246
+
247
+ for (const [fixNum, names] of anchors.entries()) {
248
+ if (!status.has(fixNum)) {
249
+ findings.push({
250
+ level: 'warn',
251
+ class: 'ORPHAN_ANCHOR',
252
+ fixNum,
253
+ tests: names,
254
+ detail: `anchor exists for fix #${fixNum} but no positive status claim in spec`,
255
+ });
256
+ }
257
+ }
258
+
259
+ const ok = !findings.some((f) => f.level === 'error');
260
+ return { ok, findings };
261
+ }
262
+
263
+ // ── Phase 2 (A-sot) — manifest validation, coverage/drift, ADR-line grep ─────
264
+ // ADR 0036: manifest is the evidence SoT (fix → test + ADR-line). status SoT
265
+ // stays in the spec. These are pure functions; the CLI injects fs-backed
266
+ // searchFn / adrExistsFn so the corpus walk stays out of the pure layer.
267
+
268
+ import { FIX_MANIFEST, NO_ADR, NO_AUTO_TEST } from './fix-manifest.mjs';
269
+
270
+ /** Order-insensitive set equality over string arrays (deduped). */
271
+ function sameStringSet(a, b) {
272
+ const sa = new Set(a);
273
+ const sb = new Set(b);
274
+ if (sa.size !== sb.size) return false;
275
+ for (const x of sa) if (!sb.has(x)) return false;
276
+ return true;
277
+ }
278
+
279
+ /**
280
+ * Structural validation of the manifest shape (ADR 0036).
281
+ *
282
+ * Findings (all error):
283
+ * MANIFEST_DUP_FIXID — two rows share a fixId.
284
+ * MANIFEST_EMPTY_TESTS — testNames is empty.
285
+ * MANIFEST_SENTINEL_MIX — NO_AUTO_TEST mixed with real test names.
286
+ * MANIFEST_EMPTY_KEYLINE — adrKeyLine missing/blank.
287
+ * MANIFEST_NO_ADR_SHAPE — NO_ADR row with non-null adrPath, or a non-NO_ADR
288
+ * row with null adrPath.
289
+ */
290
+ export function validateManifest(manifest = FIX_MANIFEST) {
291
+ const findings = [];
292
+ const seen = new Set();
293
+ for (const row of manifest) {
294
+ const fixNum = row.fixId;
295
+ if (seen.has(fixNum)) {
296
+ findings.push({
297
+ level: 'error',
298
+ class: 'MANIFEST_DUP_FIXID',
299
+ fixNum,
300
+ detail: `duplicate manifest row for fix #${fixNum}`,
301
+ });
302
+ }
303
+ seen.add(fixNum);
304
+
305
+ const names = Array.isArray(row.testNames) ? row.testNames : [];
306
+ if (names.length === 0) {
307
+ findings.push({
308
+ level: 'error',
309
+ class: 'MANIFEST_EMPTY_TESTS',
310
+ fixNum,
311
+ detail: `fix #${fixNum} manifest row has empty testNames`,
312
+ });
313
+ }
314
+ if (names.includes(NO_AUTO_TEST) && names.length > 1) {
315
+ findings.push({
316
+ level: 'error',
317
+ class: 'MANIFEST_SENTINEL_MIX',
318
+ fixNum,
319
+ detail: `fix #${fixNum} mixes NO_AUTO_TEST sentinel with real test names`,
320
+ });
321
+ }
322
+
323
+ const keyLine = typeof row.adrKeyLine === 'string' ? row.adrKeyLine.trim() : '';
324
+ if (!keyLine) {
325
+ findings.push({
326
+ level: 'error',
327
+ class: 'MANIFEST_EMPTY_KEYLINE',
328
+ fixNum,
329
+ detail: `fix #${fixNum} manifest row has empty adrKeyLine`,
330
+ });
331
+ continue;
332
+ }
333
+ const isNoAdr = row.adrKeyLine === NO_ADR;
334
+ if (isNoAdr && row.adrPath != null) {
335
+ findings.push({
336
+ level: 'error',
337
+ class: 'MANIFEST_NO_ADR_SHAPE',
338
+ fixNum,
339
+ detail: `fix #${fixNum} is NO_ADR but adrPath is not null`,
340
+ });
341
+ }
342
+ if (!isNoAdr && row.adrPath == null) {
343
+ findings.push({
344
+ level: 'error',
345
+ class: 'MANIFEST_NO_ADR_SHAPE',
346
+ fixNum,
347
+ detail: `fix #${fixNum} has a real adrKeyLine but null adrPath`,
348
+ });
349
+ }
350
+ }
351
+ return findings;
352
+ }
353
+
354
+ /**
355
+ * Coverage + drift between manifest, runner anchors, and spec status claims.
356
+ *
357
+ * MANIFEST_MISSING_ROW — a fix claimed-merged AND anchored has no manifest
358
+ * row (its ADR-line check would be silently skipped).
359
+ * Error: a missing row bypasses the whole gate.
360
+ * MANIFEST_TEST_DRIFT — a manifest row's testNames do not set-equal the
361
+ * runner anchors for that fix (stale evidence).
362
+ *
363
+ * Both error-level. The claimed∩anchored requirement mirrors the manifest
364
+ * scope (ADR 0036): rows exist to prove claims; anchors-without-claims are
365
+ * ORPHAN_ANCHOR (handled in verifyMatrix), not manifest gaps.
366
+ */
367
+ export function checkManifestCoverage({ manifest = FIX_MANIFEST, anchors, status }) {
368
+ const findings = [];
369
+ const byFix = new Map(manifest.map((r) => [r.fixId, r]));
370
+
371
+ for (const fixNum of status.keys()) {
372
+ if (!anchors.has(fixNum)) continue; // claimed but unanchored → NO_ANCHOR (verifyMatrix)
373
+ if (!byFix.has(fixNum)) {
374
+ findings.push({
375
+ level: 'error',
376
+ class: 'MANIFEST_MISSING_ROW',
377
+ fixNum,
378
+ detail: `fix #${fixNum} is claimed-merged and anchored but has no manifest row`,
379
+ });
380
+ }
381
+ }
382
+
383
+ for (const row of manifest) {
384
+ const fixNum = row.fixId;
385
+ const anchored = anchors.get(fixNum) || [];
386
+ const names = Array.isArray(row.testNames) ? row.testNames : [];
387
+ if (!sameStringSet(names, anchored)) {
388
+ findings.push({
389
+ level: 'error',
390
+ class: 'MANIFEST_TEST_DRIFT',
391
+ fixNum,
392
+ detail:
393
+ `fix #${fixNum} manifest testNames ${JSON.stringify(names)} ` +
394
+ `≠ runner anchors ${JSON.stringify(anchored)}`,
395
+ });
396
+ }
397
+ }
398
+ return findings;
399
+ }
400
+
401
+ /**
402
+ * ADR-line grep: each non-NO_ADR manifest row must point at an existing ADR
403
+ * file and its adrKeyLine must exist verbatim in the production-code corpus.
404
+ *
405
+ * ADR_PATH_MISSING — adrPath does not resolve to a file.
406
+ * ADR_LINE_MISSING — adrKeyLine not found in the corpus (fixed-string).
407
+ *
408
+ * searchFn(literal) → boolean: true iff the literal appears in the corpus
409
+ * (the corpus MUST exclude scripts/lib/fix-manifest.mjs, else every line
410
+ * self-matches and the gate is vacuous — see the CLI corpus builder).
411
+ * adrExistsFn(adrPath) → boolean. NO_ADR rows are skipped (test-green only).
412
+ */
413
+ export function checkAdrLines({ manifest = FIX_MANIFEST, searchFn, adrExistsFn }) {
414
+ const findings = [];
415
+ for (const row of manifest) {
416
+ if (row.adrKeyLine === NO_ADR) continue;
417
+ const fixNum = row.fixId;
418
+ if (row.adrPath != null && !adrExistsFn(row.adrPath)) {
419
+ findings.push({
420
+ level: 'error',
421
+ class: 'ADR_PATH_MISSING',
422
+ fixNum,
423
+ detail: `fix #${fixNum} adrPath does not resolve: ${row.adrPath}`,
424
+ });
425
+ }
426
+ if (!searchFn(row.adrKeyLine)) {
427
+ findings.push({
428
+ level: 'error',
429
+ class: 'ADR_LINE_MISSING',
430
+ fixNum,
431
+ detail: `fix #${fixNum} adrKeyLine not found in production corpus: "${row.adrKeyLine}"`,
432
+ });
433
+ }
434
+ }
435
+ return findings;
436
+ }
437
+
438
+ export { POSITIVE_STATUSES, NEGATIVE_STATUS_TOKENS, FIX_MANIFEST, NO_ADR, NO_AUTO_TEST };
@@ -0,0 +1,51 @@
1
+ // Detect whether the Hypomnema Claude Code plugin is enabled in a settings.json.
2
+ //
3
+ // ISSUE-8 (dual-install guard): the manual/npm `upgrade.mjs` must know when the
4
+ // plugin is ALSO enabled, because the plugin loader already provides the core
5
+ // hooks/commands/settings — copying+registering them from a manual/npm `--apply`
6
+ // would double-register every hook.
7
+ //
8
+ // This parser is INTENTIONALLY conservative. The asymmetric cost is: a false
9
+ // positive blocks/alters a legitimate npm-only user's upgrade, which is worse
10
+ // than the rare dual-install double-register it guards against. So it fails open
11
+ // (returns false) on every uncertainty and only fires on an exact, well-formed
12
+ // `enabledPlugins` entry whose plugin name is precisely `hypomnema`.
13
+
14
+ import { readFileSync } from 'node:fs';
15
+
16
+ /**
17
+ * @param {string} settingsPath path to a Claude Code settings.json (e.g. ~/.claude/settings.json)
18
+ * @returns {boolean} true iff `enabledPlugins` contains a key shaped
19
+ * `hypomnema@<marketplace>` whose value is strictly `true`.
20
+ */
21
+ export function isHypomnemaPluginEnabled(settingsPath) {
22
+ let raw;
23
+ try {
24
+ raw = readFileSync(settingsPath, 'utf-8');
25
+ } catch {
26
+ return false; // missing / unreadable → cannot prove enabled → fail open
27
+ }
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(raw);
31
+ } catch {
32
+ return false; // corrupt JSON → fail open
33
+ }
34
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
35
+
36
+ const enabled = parsed.enabledPlugins;
37
+ // enabledPlugins is an object map `{ "<name>@<marketplace>": true|false }`.
38
+ // Anything else (absent, array, scalar) → not enabled.
39
+ if (!enabled || typeof enabled !== 'object' || Array.isArray(enabled)) return false;
40
+
41
+ for (const [key, value] of Object.entries(enabled)) {
42
+ if (value !== true) continue; // strictly true only — no truthy coercion
43
+ // Require a real `name@marketplace` shape: an `@` that is neither the first
44
+ // nor the last char. A bare `"hypomnema": true` (no marketplace) must NOT
45
+ // trigger — that is not a valid enabledPlugins identifier.
46
+ const at = key.indexOf('@');
47
+ if (at <= 0 || at === key.length - 1) continue;
48
+ if (key.slice(0, at) === 'hypomnema') return true; // exact, case-sensitive
49
+ }
50
+ return false;
51
+ }