hypomnema 1.3.1 → 1.3.3

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 (65) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.ko.md +11 -10
  4. package/README.md +11 -10
  5. package/commands/audit.md +1 -1
  6. package/commands/crystallize.md +11 -11
  7. package/commands/doctor.md +1 -1
  8. package/commands/feedback.md +2 -2
  9. package/commands/graph.md +1 -1
  10. package/commands/ingest.md +1 -1
  11. package/commands/init.md +1 -1
  12. package/commands/lint.md +1 -1
  13. package/commands/query.md +1 -1
  14. package/commands/rename.md +79 -0
  15. package/commands/resume.md +2 -2
  16. package/commands/stats.md +1 -1
  17. package/commands/uninstall.md +1 -1
  18. package/commands/upgrade.md +1 -1
  19. package/commands/verify.md +1 -1
  20. package/docs/ARCHITECTURE.md +3 -3
  21. package/docs/CONTRIBUTING.md +2 -2
  22. package/hooks/hypo-auto-commit.mjs +8 -24
  23. package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
  24. package/hooks/hypo-compact-guard.mjs +6 -3
  25. package/hooks/hypo-cwd-change.mjs +1 -1
  26. package/hooks/hypo-first-prompt.mjs +2 -2
  27. package/hooks/hypo-hot-rebuild.mjs +14 -1
  28. package/hooks/hypo-personal-check.mjs +91 -179
  29. package/hooks/hypo-pre-commit.mjs +1 -1
  30. package/hooks/hypo-session-end.mjs +1 -1
  31. package/hooks/hypo-session-start.mjs +7 -7
  32. package/hooks/hypo-shared.mjs +1082 -89
  33. package/hooks/hypo-web-fetch-ingest.mjs +2 -2
  34. package/hooks/version-check-fetch.mjs +2 -8
  35. package/hooks/version-check.mjs +18 -0
  36. package/package.json +3 -2
  37. package/scripts/check-tracker-ids.mjs +329 -0
  38. package/scripts/crystallize.mjs +322 -110
  39. package/scripts/doctor.mjs +6 -9
  40. package/scripts/feedback-sync.mjs +1 -1
  41. package/scripts/init.mjs +1 -1
  42. package/scripts/install-git-hooks.mjs +75 -40
  43. package/scripts/lib/check-tracker-ids.mjs +140 -0
  44. package/scripts/lib/design-history-stale.mjs +59 -14
  45. package/scripts/lib/extensions.mjs +4 -4
  46. package/scripts/lib/fix-manifest.mjs +2 -2
  47. package/scripts/lib/fix-status-verify.mjs +5 -4
  48. package/scripts/lib/plugin-detect.mjs +15 -6
  49. package/scripts/lib/project-create.mjs +1 -1
  50. package/scripts/lint.mjs +63 -8
  51. package/scripts/rename.mjs +836 -0
  52. package/scripts/resume.mjs +75 -19
  53. package/scripts/uninstall.mjs +1 -1
  54. package/scripts/upgrade.mjs +26 -25
  55. package/skills/crystallize/SKILL.md +12 -9
  56. package/skills/graph/SKILL.md +1 -1
  57. package/skills/ingest/SKILL.md +1 -1
  58. package/skills/lint/SKILL.md +1 -1
  59. package/skills/query/SKILL.md +1 -1
  60. package/skills/verify/SKILL.md +1 -1
  61. package/templates/SCHEMA.md +2 -2
  62. package/templates/hypo-config.md +2 -2
  63. package/templates/hypo-guide.md +22 -1
  64. package/templates/hypo-help.md +1 -0
  65. package/templates/projects/_template/index.md +1 -1
@@ -222,7 +222,7 @@ function checkDirectories(hypoDir) {
222
222
  'projects',
223
223
  'sources',
224
224
  // Extensions baseline (ADR 0024). Existence only — SHA / settings /
225
- // manifest integrity is E5 (fix #33).
225
+ // manifest integrity is E5.
226
226
  'extensions/hooks',
227
227
  'extensions/commands',
228
228
  'extensions/skills',
@@ -310,7 +310,7 @@ function checkSettingsJson() {
310
310
  // stale hypo-* entries (uninstall remnants).
311
311
  // hypo-ext-* commands are user-extension entries (ADR 0024) — not core hooks,
312
312
  // so they are intentionally absent from HOOK_MAP. Excluded here; their
313
- // integrity (SHA + manifest + entry match) is checked separately in E5 (fix #33).
313
+ // integrity (SHA + manifest + entry match) is checked separately in E5.
314
314
  const isExtCommand = (cmd) => /(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(cmd);
315
315
  const expectedCmds = new Set(
316
316
  Object.entries(HOOK_MAP).flatMap(([, files]) =>
@@ -352,7 +352,7 @@ function checkSettingsJson() {
352
352
  if (!g || typeof g !== 'object') continue;
353
353
  for (const h of g.hooks || []) {
354
354
  if (typeof h.command !== 'string' || !/hypo-[^/]+\.mjs/.test(h.command)) continue;
355
- if (isExtCommand(h.command)) continue; // ext duplicates are E5's concern (fix #33)
355
+ if (isExtCommand(h.command)) continue; // ext duplicates are E5's concern
356
356
  if (seen.has(h.command)) dupes.push(`${event}:${h.command}`);
357
357
  else seen.add(h.command);
358
358
  }
@@ -399,10 +399,7 @@ function checkGit(hypoDir) {
399
399
  );
400
400
  }
401
401
  } else {
402
- warn(
403
- '.git/hooks/pre-commit',
404
- 'Not installed — run /hypo:init to install .hypoignore guard (fix #24)',
405
- );
402
+ warn('.git/hooks/pre-commit', 'Not installed — run /hypo:init to install .hypoignore guard');
406
403
  }
407
404
  }
408
405
 
@@ -679,7 +676,7 @@ function checkCodexPaths() {
679
676
  //
680
677
  // E5 is doctor SURFACE for extensions integrity. The mixed-group surgical
681
678
  // *write* (preserve sibling-plugin hooks, swap only ours) used to be deferred
682
- // here; fix #47 (ADR 0024 amendment 2026-05-23) lifted that deferral —
679
+ // here; ADR 0024 amendment 2026-05-23 lifted that deferral —
683
680
  // registerSettings (extensions.mjs:478 docstring) now does occurrence-first +
684
681
  // 8-rank canonical write, and the (b) loop below mirrors that read-path via
685
682
  // collectOurOccurrences so a valid mixed-group occurrence is no longer warned
@@ -773,7 +770,7 @@ function checkExtensions(hypoDir, claudeHome, target = 'claude') {
773
770
  const expected = buildExpectedSettingsEntries(discovered.hooks, hooksDir);
774
771
 
775
772
  // (b) for each registrable hook: locate every occurrence of our command
776
- // (single-hook OR mixed group, fix #47) and pick the canonical via the
773
+ // (single-hook OR mixed group) and pick the canonical via the
777
774
  // SAME 8-rank logic registerSettings uses.
778
775
  // Without this mirror, doctor picked the first traversal-order occurrence
779
776
  // under the target event and warned "differs" even when a later
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * feedback-sync.mjs — project wiki feedback as SoT, external memory as projection (ADR 0031, fix #37)
3
+ * feedback-sync.mjs — project wiki feedback as SoT, external memory as projection (ADR 0031)
4
4
  *
5
5
  * Wiki `pages/feedback/<slug>.md` is the single source of truth for
6
6
  * learning/correction knowledge. Two Claude Code memory surfaces are derived
package/scripts/init.mjs CHANGED
@@ -928,7 +928,7 @@ if (args.hooks) {
928
928
  }
929
929
  for (const r of extResult.registered) log('merged', `extension ${r}`);
930
930
  for (const w of extResult.warnings) log('skipped', `extension: ${w}`);
931
- // E3 (fix #31): a hard conflict (unowned/symlinked target) blocks install — surface
931
+ // E3: a hard conflict (unowned/symlinked target) blocks install — surface
932
932
  // the recovery and force a non-zero exit. Drift is advisory (resolvable, no block).
933
933
  if (extResult.conflicts.length > 0) {
934
934
  log('errors', '[WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
@@ -79,10 +79,39 @@ function shellSingleQuote(s) {
79
79
  return `'` + s.replace(/'/g, `'\\''`) + `'`;
80
80
  }
81
81
 
82
- function shimBody(root, gitDir) {
82
+ // Hook-specific gate body. Runs AFTER the shared identity guards, so everything
83
+ // here is already proven to be our trusted checkout. `set +e` is active, so the
84
+ // gate must explicitly `|| exit 1` to BLOCK a commit; a missing script is a
85
+ // fail-open skip (old checkout that predates the script).
86
+ function gateLines(kind) {
87
+ // CHECK_TRACKER_ROOT is a test-only seam in check-tracker-ids.mjs. Clear any
88
+ // inherited value so the real hook always gates THIS checkout's index, never a
89
+ // redirected one.
90
+ const unsetSeam = 'unset CHECK_TRACKER_ROOT';
91
+ if (kind === 'pre-commit') {
92
+ // 1) Auto-format staged files (its own exit 1 = a true git-add failure block).
93
+ // The sentinel tells pre-commit-format.mjs it runs under the trusted shim,
94
+ // so it preserves an inherited GIT_INDEX_FILE (index-whitelist defence).
95
+ // 2) Tracker-id gate on the staged blobs — blocks the commit on a leak.
96
+ return `${unsetSeam}
97
+ FMT="$HYPOMNEMA_ROOT/scripts/pre-commit-format.mjs"
98
+ [ -f "$FMT" ] && { HYPOMNEMA_HOOK_INVOCATION=1 node "$FMT" || exit 1; }
99
+ TRK="$HYPOMNEMA_ROOT/scripts/check-tracker-ids.mjs"
100
+ [ -f "$TRK" ] && { node "$TRK" --staged || exit 1; }
101
+ exit 0`;
102
+ }
103
+ // commit-msg: scan the message file (passed as $1) for wiki tracker ids.
104
+ return `${unsetSeam}
105
+ TRK="$HYPOMNEMA_ROOT/scripts/check-tracker-ids.mjs"
106
+ [ -f "$TRK" ] || exit 0
107
+ node "$TRK" --commit-msg "$1" || exit 1
108
+ exit 0`;
109
+ }
110
+
111
+ function shimBody(kind, root, gitDir) {
83
112
  return `#!/bin/sh
84
- # hypomnema-pre-commit-marker v1
85
- # Fail-open at every guard. Only the .mjs (after identity checks) can exit nonzero.
113
+ # hypomnema-${kind}-marker v2
114
+ # Fail-open at every identity/setup guard. Only the gate below can exit nonzero.
86
115
  set +e
87
116
  HYPOMNEMA_ROOT=${shellSingleQuote(root)}
88
117
  HYPOMNEMA_GIT_DIR=${shellSingleQuote(gitDir)}
@@ -91,28 +120,51 @@ TOPLEVEL="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
91
120
  [ "$TOPLEVEL" = "$HYPOMNEMA_ROOT" ] || exit 0
92
121
  ABSGITDIR="$(git rev-parse --absolute-git-dir 2>/dev/null)" || exit 0
93
122
  [ "$ABSGITDIR" = "$HYPOMNEMA_GIT_DIR" ] || exit 0
94
- SCRIPT="$HYPOMNEMA_ROOT/scripts/pre-commit-format.mjs"
95
- [ -f "$SCRIPT" ] || exit 0
96
123
  command -v node >/dev/null 2>&1 || exit 0
97
- # Sentinel: tells the .mjs it is running under our trusted shim (not direct
98
- # attacker invocation). The .mjs preserves inherited GIT_INDEX_FILE only when
99
- # this sentinel is present — direct invocation drops it and falls back to the
100
- # default \`.git/index\`. This closes prefix-matching attacks on the index
101
- # whitelist (e.g. attacker-crafted .git/next-index-attack.lock).
102
- HYPOMNEMA_HOOK_INVOCATION=1 exec node "$SCRIPT"
124
+ ${gateLines(kind)}
103
125
  `;
104
126
  }
105
127
 
106
- function writeShim(target, root, gitDir) {
128
+ // Write a single hook shim. Returns true on success, false on write failure.
129
+ // Does NOT exit — the caller installs multiple hooks and reports once.
130
+ function writeShim(target, kind, root, gitDir) {
107
131
  try {
108
- fs.writeFileSync(target, shimBody(root, gitDir), { mode: 0o755 });
132
+ fs.writeFileSync(target, shimBody(kind, root, gitDir), { mode: 0o755 });
109
133
  try {
110
134
  fs.chmodSync(target, 0o755);
111
135
  } catch {}
112
- return exitSilent(`installed pre-commit hook for ${root}`);
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ // Install/refresh one hook of the given kind under absHooksDir. Returns a short
143
+ // status string (never throws, never exits). Refreshes our own marker (any
144
+ // version) so a checkout move or a shim-body change re-propagates; never
145
+ // clobbers a user's own non-marker hook or a symlink.
146
+ function installHook(kind, absHooksDir, root, gitDir) {
147
+ const target = path.join(absHooksDir, kind);
148
+ let existing;
149
+ try {
150
+ existing = fs.lstatSync(target);
113
151
  } catch (e) {
114
- return exitSilent(`write hook failed: ${e.code || e.message}; skipping`);
152
+ if (e.code === 'ENOENT') {
153
+ return writeShim(target, kind, root, gitDir) ? `installed ${kind}` : `write ${kind} failed`;
154
+ }
155
+ return `stat ${kind} failed: ${e.code}`;
156
+ }
157
+ if (existing.isSymbolicLink()) return `${kind} is symlink; not overwriting`;
158
+ let head;
159
+ try {
160
+ head = fs.readFileSync(target, 'utf-8').split('\n').slice(0, 3).join('\n');
161
+ } catch {
162
+ return `read ${kind} failed; skipping`;
115
163
  }
164
+ if (head.includes(`hypomnema-${kind}-marker`)) {
165
+ return writeShim(target, kind, root, gitDir) ? `refreshed ${kind}` : `refresh ${kind} failed`;
166
+ }
167
+ return `existing non-marker ${kind}; not overwriting`;
116
168
  }
117
169
 
118
170
  async function main() {
@@ -225,31 +277,14 @@ async function main() {
225
277
  return exitSilent('hooks dir outside .git/; skipping');
226
278
  }
227
279
 
228
- // (7) Existing pre-commit logic.
229
- const target = path.join(absHooksDir, 'pre-commit');
230
- let existing;
231
- try {
232
- existing = fs.lstatSync(target);
233
- } catch (e) {
234
- if (e.code === 'ENOENT') {
235
- return writeShim(target, expectedRoot, absGitDir);
236
- }
237
- return exitSilent(`stat target failed: ${e.code}; skipping`);
238
- }
239
- if (existing.isSymbolicLink()) {
240
- return exitSilent('pre-commit is symlink; not overwriting');
241
- }
242
- let head;
243
- try {
244
- head = fs.readFileSync(target, 'utf-8').split('\n').slice(0, 3).join('\n');
245
- } catch {
246
- return exitSilent('read existing pre-commit failed; skipping');
247
- }
248
- if (head.includes('hypomnema-pre-commit-marker v1')) {
249
- // Same marker — regenerate (refreshes embedded root if checkout moved).
250
- return writeShim(target, expectedRoot, absGitDir);
251
- }
252
- return exitSilent('existing non-marker pre-commit; not overwriting');
280
+ // (7) Install both hooks: pre-commit (format + tracker-id gate on staged
281
+ // blobs) and commit-msg (tracker-id gate on the message). Each is
282
+ // independently marker-detected so a user's own hook is never clobbered.
283
+ const results = [
284
+ installHook('pre-commit', absHooksDir, expectedRoot, absGitDir),
285
+ installHook('commit-msg', absHooksDir, expectedRoot, absGitDir),
286
+ ];
287
+ return exitSilent(results.join('; '));
253
288
  } catch (e) {
254
289
  return exitSilent(`unexpected: ${e.code || e.message}; skipping`);
255
290
  }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * lib/check-tracker-ids.mjs — pure scanner for wiki-internal tracker IDs.
3
+ *
4
+ * The OSS repo must not ship references to the maintainer's PRIVATE wiki issue
5
+ * tracker (`ISSUE-N`) or fix tracker (`fix #N`) in public artifacts: an OSS user
6
+ * who opens a shipped file or reads the README/CHANGELOG just sees a dangling
7
+ * pointer into a tracker they cannot access. GitHub references (`PR #N`, `(#N)`,
8
+ * bare `#N`, issue URLs) are legitimate and must NOT be flagged.
9
+ *
10
+ * This module is PURE (no fs, no git, no process) so it is unit-testable; the
11
+ * CLI wrapper (scripts/check-tracker-ids.mjs) does the file/git/commit-msg I/O.
12
+ *
13
+ * Blocked (examples use an `N` placeholder, NOT a real digit, so this file
14
+ * itself scans clean and is NOT exempted from --all):
15
+ * - /\bISSUE-\d+\b/i → the wiki issue tracker, any case (e.g. ISSUE-N)
16
+ * - /\bfix[ \t ]+#\d+\b/i → the wiki fix tracker, any case (e.g. fix #N)
17
+ *
18
+ * User-facing docs ONLY (README.md, README.ko.md, docs/) additionally block
19
+ * `ADR NNNN` / `decisions/NNNN` (USER_FACING_PATTERNS) — dangling pointers into the
20
+ * maintainer's private wiki ADR set. The CLI scope-gates these so shipped code
21
+ * comments and CHANGELOG history keep their ADR anchors.
22
+ *
23
+ * Accepted edge cases (documented, not bugs):
24
+ * - FALSE POSITIVE: `FOO-ISSUE-N` matches (the `-` gives a word boundary
25
+ * before ISSUE). Such a string in this repo would still be a tracker ref.
26
+ * - The `fix #N` matcher requires the literal word `fix` immediately before the
27
+ * `#`, so `prefix #N`, `suffix #N`, `PR #N`, `(#N)`, and bare `#N` are all
28
+ * safe — none start the word `fix` right before the `#`.
29
+ */
30
+
31
+ // Applied by the CLI to user-facing docs ONLY (README.md / README.ko.md / docs/),
32
+ // never to shipped code or CHANGELOG. A `ADR NNNN` / `decisions/NNNN` reference in
33
+ // prose a user reads points into the maintainer's private wiki ADR set, which the
34
+ // user does not have — a dangling pointer. The same anchor inside a code comment
35
+ // is maintainer rationale (kept), so this set is scope-gated rather than global.
36
+ // Examples below use `NNNN`, not real digits, so this file scans clean.
37
+ export const USER_FACING_PATTERNS = [
38
+ {
39
+ name: 'ADR NNNN',
40
+ label: 'wiki ADR pointer (user-facing docs)',
41
+ re: /\bADR[ \t]+\d{3,4}\b/gi,
42
+ },
43
+ {
44
+ name: 'decisions/NNNN',
45
+ label: 'wiki decisions path (user-facing docs)',
46
+ re: /\bdecisions\/\d{3,4}\b/gi,
47
+ },
48
+ ];
49
+
50
+ // Each entry: a named, /g/i regex over a single line of text.
51
+ export const BLOCKED_PATTERNS = [
52
+ {
53
+ name: 'ISSUE-N',
54
+ label: 'wiki issue-tracker id',
55
+ re: /\bISSUE-\d+\b/gi,
56
+ },
57
+ {
58
+ name: 'fix #N',
59
+ label: 'wiki fix-tracker id',
60
+ re: /\bfix[ \t ]+#\d+\b/gi,
61
+ },
62
+ ];
63
+
64
+ /**
65
+ * Scan a blob of text against `patterns` (default BLOCKED_PATTERNS). Returns hits:
66
+ * { pattern, label, match, line, col, lineText }
67
+ * `line`/`col` are 1-based. Empty array => clean. The CLI passes the broader
68
+ * [...BLOCKED_PATTERNS, ...USER_FACING_PATTERNS] set only for user-facing docs.
69
+ */
70
+ export function scanText(text, patterns = BLOCKED_PATTERNS) {
71
+ if (typeof text !== 'string' || text.length === 0) return [];
72
+ const hits = [];
73
+ const lines = text.split(/\r?\n/);
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const lineText = lines[i];
76
+ for (const { name, label, re } of patterns) {
77
+ re.lastIndex = 0; // reset shared /g regex between lines
78
+ let m;
79
+ while ((m = re.exec(lineText)) !== null) {
80
+ hits.push({
81
+ pattern: name,
82
+ label,
83
+ match: m[0],
84
+ line: i + 1,
85
+ col: m.index + 1,
86
+ lineText,
87
+ });
88
+ if (m.index === re.lastIndex) re.lastIndex++; // never-zero-width guard
89
+ }
90
+ }
91
+ }
92
+ return hits;
93
+ }
94
+
95
+ /**
96
+ * Strip a `--verbose` commit diff: everything from the scissors line onward is
97
+ * dropped by git before the commit is recorded, so it must not be scanned. The
98
+ * scissors art is comment-char-prefixed but the `>8` token is constant across
99
+ * comment chars, so match on that. Returns the text up to (excluding) the
100
+ * scissors line. If no scissors line is present, returns the input unchanged.
101
+ */
102
+ // git's real scissors line is COMMENT-prefixed (`# ----- >8 -----`); a bare
103
+ // `--- >8 ---` line is NOT a scissors marker (git keeps it as body), so it must
104
+ // not trigger truncation. Require the leading comment char (default `#`, or `;`).
105
+ const SCISSORS_RE = /^[ \t]*[#;][ \t]*-{2,}[ \t]*>8[ \t]*-{2,}/;
106
+
107
+ export function stripScissors(text) {
108
+ if (typeof text !== 'string') return '';
109
+ const lines = text.split(/\r?\n/);
110
+ const idx = lines.findIndex((l) => SCISSORS_RE.test(l));
111
+ if (idx === -1) return text;
112
+ return lines.slice(0, idx).join('\n');
113
+ }
114
+
115
+ /**
116
+ * Does a commit-message file carry git's auto-generated template? git ONLY adds
117
+ * the instructional / status comment block (and the --verbose scissors) when the
118
+ * effective cleanup mode will STRIP comment lines (the editor default). In the
119
+ * comment-PRESERVING modes (`git commit -m` → whitespace, `--cleanup=verbatim`)
120
+ * git adds NO template, so a `#` line there is real committed content.
121
+ *
122
+ * The commit-msg hook therefore decides comment handling by template presence,
123
+ * not by config alone: template present ⇒ scan the strip-comments view (matches
124
+ * git, no false-positive on the template's branch/file names); template absent ⇒
125
+ * scan the raw view so a `#`-prefixed tracker id git WILL keep is still caught.
126
+ *
127
+ * Markers cover the default English git template + the scissors line. Other
128
+ * locales may miss the template and fall through to the raw (comment-keeping)
129
+ * scan — which is the SAFE direction for a gate (catches more, never less).
130
+ */
131
+ export function messageHasGitTemplate(text) {
132
+ if (typeof text !== 'string') return false;
133
+ if (SCISSORS_RE_M.test(text)) return true; // --verbose scissors (comment-prefixed)
134
+ return /^[#;] *(Please enter the commit message|On branch |Changes to be committed:|Changes not staged for commit:|Untracked files:|Your branch (is|and))/m.test(
135
+ text,
136
+ );
137
+ }
138
+
139
+ // Multiline form of SCISSORS_RE for whole-message detection.
140
+ const SCISSORS_RE_M = /^[ \t]*[#;][ \t]*-{2,}[ \t]*>8[ \t]*-{2,}/m;
@@ -1,21 +1,65 @@
1
1
  import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
2
2
  import { join } from 'path';
3
3
 
4
- const SESSION_LOG_DATE_RE = /^## \[(\d{4}-\d{2}-\d{2})\]/gm;
4
+ // session-log headings appear in two shapes in the wild: bracketed
5
+ // `## [YYYY-MM-DD]` (spec convention) and bare `## YYYY-MM-DD` (some entries,
6
+ // e.g. the 2026-06-08 SHIP entry). Two explicit branches instead of an
7
+ // `\[?...\]?` optional — the optional form silently accepts malformed partial
8
+ // brackets like `## [2026-06-08` and `## 2026-06-08]`, which we don't want to
9
+ // treat as valid dated headings. The bare branch carries a `(?!\])` guard so a
10
+ // trailing-only bracket (`## 2026-06-08]`) is rejected too — without it the
11
+ // bare branch would match the date and ignore the stray `]` (codex review).
12
+ const SESSION_LOG_HEADING_RE = /^## (?:\[(\d{4}-\d{2}-\d{2})\]|(\d{4}-\d{2}-\d{2})(?!\]))/gm;
5
13
  const DESIGN_HISTORY_DATE_RE = /^## (\d{4}-\d{2}-\d{2})/gm;
6
14
 
15
+ // W8 false-positive fix (issue①): a session-log entry that explicitly declares
16
+ // "no design change" (the crystallize #41 `ADR 없음` marker) must not count
17
+ // toward design-history staleness — otherwise a no-design session pushes the
18
+ // session-log date past design-history forever (treadmill), or a real design
19
+ // session that forgot to append blocks correctly. We exclude an entry ONLY when
20
+ // it carries the `ADR 없음` marker AND no ADR reference in the same block. If
21
+ // both coexist (an ambiguous/contradictory entry), we include it — excluding it
22
+ // would re-introduce the exact false-negative W8 exists to catch (codex review).
23
+ const NO_ADR_MARKER_RE = /ADR\s*없음/;
24
+ const ADR_REF_RE = /ADR\s+\d{4}|decisions\/\d{4}/;
25
+
26
+ function isValidDate(literal) {
27
+ // The regex matches digit-shaped YYYY-MM-DD literals but cannot reject
28
+ // semantically invalid ones like 2026-13-01 or 2026-02-30. JavaScript's Date
29
+ // constructor returns an Invalid Date for those, which would later crash
30
+ // `toISOString()` with RangeError and poison `>` comparisons inside maxDate.
31
+ // Filter at the parse boundary so callers never see one.
32
+ return !Number.isNaN(new Date(literal).getTime());
33
+ }
34
+
7
35
  function parseDates(text, pattern) {
8
36
  const dates = [];
9
37
  pattern.lastIndex = 0;
10
38
  let m;
11
39
  while ((m = pattern.exec(text)) !== null) {
12
- // The regex matches digit-shaped YYYY-MM-DD literals but cannot reject
13
- // semantically invalid ones like 2026-13-01 or 2026-02-30. JavaScript's
14
- // Date constructor returns an Invalid Date for those, which would later
15
- // crash `toISOString()` with RangeError and poison `>` comparisons inside
16
- // maxDate. Filter at the parse boundary so callers never see one.
17
- const d = new Date(m[1]);
18
- if (!Number.isNaN(d.getTime())) dates.push(d);
40
+ if (isValidDate(m[1])) dates.push(new Date(m[1]));
41
+ }
42
+ return dates;
43
+ }
44
+
45
+ // Parse session-log dates entry-by-entry, skipping no-design-change entries.
46
+ // Entries are sliced by heading start-index (not a single `$`-anchored block
47
+ // regex — multiline `$` terminates at line ends, not true EOF, so the last
48
+ // entry would be truncated). The last entry runs to EOF.
49
+ function parseSessionDates(text) {
50
+ const headings = [];
51
+ SESSION_LOG_HEADING_RE.lastIndex = 0;
52
+ let m;
53
+ while ((m = SESSION_LOG_HEADING_RE.exec(text)) !== null) {
54
+ headings.push({ literal: m[1] ?? m[2], start: m.index });
55
+ }
56
+ const dates = [];
57
+ for (let i = 0; i < headings.length; i++) {
58
+ const body = text.slice(headings[i].start, headings[i + 1]?.start ?? text.length);
59
+ // Exclude only an explicit no-design-change entry. An entry carrying both
60
+ // the marker and an ADR reference is treated as a design entry (included).
61
+ if (NO_ADR_MARKER_RE.test(body) && !ADR_REF_RE.test(body)) continue;
62
+ if (isValidDate(headings[i].literal)) dates.push(new Date(headings[i].literal));
19
63
  }
20
64
  return dates;
21
65
  }
@@ -43,21 +87,22 @@ export function findDesignHistoryStale(hypoDir) {
43
87
  const dhPath = join(projectDir, 'design-history.md');
44
88
  if (!existsSync(dhPath)) continue;
45
89
 
46
- // session-log can live as a flat `session-log.md` (legacy) or a directory
47
- // `session-log/YYYY-MM.md` (spec §5.2.7 canonical). Aggregate dates from
48
- // whichever shape is present — both forms appear in the wild and the
49
- // staleness check needs to see all of them.
90
+ // session-log can live as a flat `session-log.md` (legacy) or a directory of
91
+ // daily shards `session-log/YYYY-MM-DD.md` (ADR 0050 canonical; legacy
92
+ // monthly `YYYY-MM.md` files still appear pre-cutover). This globs every
93
+ // `.md` in the directory, so daily and monthly shapes are both aggregated —
94
+ // the staleness check needs to see all of them.
50
95
  const sessionDates = [];
51
96
  const flatSlPath = join(projectDir, 'session-log.md');
52
97
  if (existsSync(flatSlPath)) {
53
- sessionDates.push(...parseDates(readFileSync(flatSlPath, 'utf-8'), SESSION_LOG_DATE_RE));
98
+ sessionDates.push(...parseSessionDates(readFileSync(flatSlPath, 'utf-8')));
54
99
  }
55
100
  const dirSlPath = join(projectDir, 'session-log');
56
101
  if (existsSync(dirSlPath) && statSync(dirSlPath).isDirectory()) {
57
102
  for (const entry of readdirSync(dirSlPath)) {
58
103
  if (!entry.endsWith('.md')) continue;
59
104
  const text = readFileSync(join(dirSlPath, entry), 'utf-8');
60
- sessionDates.push(...parseDates(text, SESSION_LOG_DATE_RE));
105
+ sessionDates.push(...parseSessionDates(text));
61
106
  }
62
107
  }
63
108
  if (sessionDates.length === 0) continue;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * scripts/lib/extensions.mjs — User extensions companion sync (ADR 0024, fix #29 + #30).
2
+ * scripts/lib/extensions.mjs — User extensions companion sync (ADR 0024).
3
3
  *
4
4
  * `~/hypomnema/extensions/{hooks,commands,skills,agents}/` holds user-authored
5
5
  * extensions, git-tracked alongside the wiki. init/upgrade hard-copy them into
@@ -80,7 +80,7 @@ function pkgRootDir(target) {
80
80
 
81
81
  /**
82
82
  * Discover sync-eligible extensions under `extDir`. Returns a per-type map plus a
83
- * `warnings` array. Applies the `.hypoignore` filter (fix #30), the basename
83
+ * `warnings` array. Applies the `.hypoignore` filter, the basename
84
84
  * whitelist (plan §5 #9), and pairs each file with its optional `<name>.manifest.json`.
85
85
  * No-ops gracefully when extDir is absent (e.g. --from-remote clones, plan §5 #8).
86
86
  */
@@ -364,7 +364,7 @@ export function syncExtensions({
364
364
  const discovered = discoverExtensions(extDir, patterns, hypoDir);
365
365
  result.warnings.push(...discovered.warnings);
366
366
 
367
- // E4 (fix #32): Codex supports hooks + commands only. If the user authored
367
+ // E4: Codex supports hooks + commands only. If the user authored
368
368
  // skills/agents extensions, surface a one-time notice that they are skipped
369
369
  // for this target rather than silently dropping them (plan §2 E4).
370
370
  if (target === 'codex') {
@@ -505,7 +505,7 @@ export function syncExtensions({
505
505
  * 7. non-target event · mixed (extract + append new)
506
506
  * 8. no occurrence (append new)
507
507
  *
508
- * Mixed-group invariant (fix #47, ADR 0024 amendment 2026-05-23): foreign hooks
508
+ * Mixed-group invariant (ADR 0024 amendment 2026-05-23): foreign hooks
509
509
  * sharing the matcher group are NEVER read, modified, or reordered. The hosting
510
510
  * group's matcher is also left exactly as-is once we extract — even when our
511
511
  * extraction is the reason the group becomes single-foreign. Foreign handler-
@@ -76,10 +76,10 @@ export const FIX_MANIFEST = [
76
76
  // deliberate removal-marker comment + the negative-control test.
77
77
  fixId: 26,
78
78
  testNames: [
79
- 'replay-personal-check-bypass-order: wiki-context-critical.json does NOT bypass (fix #26 negative control)',
79
+ 'replay-personal-check-bypass-order: wiki-context-critical.json does NOT bypass (negative control)',
80
80
  ],
81
81
  adrPath: 'decisions/0022-session-close-ux-automation.md',
82
- adrKeyLine: 'Capacity bypass (≥90%) REMOVED — fix #26',
82
+ adrKeyLine: 'Capacity bypass (≥90%) REMOVED',
83
83
  },
84
84
  {
85
85
  fixId: 27,
@@ -27,8 +27,8 @@ const NEGATIVE_STATUS_TOKENS = ['STALE_MERGED', 'partial', 'retired'];
27
27
  /**
28
28
  * Parse anchor comments out of runner.mjs source text.
29
29
  *
30
- * // @fix #15: all type-conditional fields present → green
31
- * // @fix #15: another test name
30
+ * // @fix #N: all type-conditional fields present → green
31
+ * // @fix #N: another test name
32
32
  *
33
33
  * The `@fix` prefix is mandatory — distinguishes anchors from prose comments
34
34
  * that mention "fix #N" in passing. Each anchor line maps ONE fix # to ONE
@@ -79,8 +79,9 @@ export function parseStatus(specText) {
79
79
  // For each line, find every fix # mention and check whether a positive
80
80
  // status token sits within a small proximity window AFTER the mention. This
81
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).
82
+ // tokens that only apply to some of them (e.g. a line where the first
83
+ // mention is "(resolved)" and a later one is "(advisory)" — only the first
84
+ // should be picked up).
84
85
  const lines = specText.split('\n');
85
86
  const PROXIMITY = 120; // chars after fix # to scan for status
86
87
  for (const line of lines) {
@@ -1,6 +1,6 @@
1
1
  // Detect whether the Hypomnema Claude Code plugin is enabled in a settings.json.
2
2
  //
3
- // ISSUE-8 (dual-install guard): the manual/npm `upgrade.mjs` must know when the
3
+ // Dual-install guard: the manual/npm `upgrade.mjs` must know when the
4
4
  // plugin is ALSO enabled, because the plugin loader already provides the core
5
5
  // hooks/commands/settings — copying+registering them from a manual/npm `--apply`
6
6
  // would double-register every hook.
@@ -9,14 +9,19 @@
9
9
  // positive blocks/alters a legitimate npm-only user's upgrade, which is worse
10
10
  // than the rare dual-install double-register it guards against. So it fails open
11
11
  // (returns false) on every uncertainty and only fires on an exact, well-formed
12
- // `enabledPlugins` entry whose plugin name is precisely `hypomnema`.
12
+ // `enabledPlugins` entry whose plugin name is precisely `hypo` (the current
13
+ // plugin name) or `hypomnema` (the legacy name, pre-rename). Both are matched so
14
+ // the guard survives the rename's migration window: an existing user keeps the
15
+ // legacy `hypomnema@<marketplace>` key in `enabledPlugins` until they reinstall
16
+ // as `hypo@<marketplace>`, and the guard must hold across that gap.
13
17
 
14
18
  import { readFileSync } from 'node:fs';
15
19
 
16
20
  /**
17
21
  * @param {string} settingsPath path to a Claude Code settings.json (e.g. ~/.claude/settings.json)
18
22
  * @returns {boolean} true iff `enabledPlugins` contains a key shaped
19
- * `hypomnema@<marketplace>` whose value is strictly `true`.
23
+ * `hypo@<marketplace>` (or the legacy `hypomnema@<marketplace>`) whose value
24
+ * is strictly `true`.
20
25
  */
21
26
  export function isHypomnemaPluginEnabled(settingsPath) {
22
27
  let raw;
@@ -41,11 +46,15 @@ export function isHypomnemaPluginEnabled(settingsPath) {
41
46
  for (const [key, value] of Object.entries(enabled)) {
42
47
  if (value !== true) continue; // strictly true only — no truthy coercion
43
48
  // 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.
49
+ // nor the last char. A bare `"hypo": true` / `"hypomnema": true` (no
50
+ // marketplace) must NOT trigger — that is not a valid enabledPlugins
51
+ // identifier.
46
52
  const at = key.indexOf('@');
47
53
  if (at <= 0 || at === key.length - 1) continue;
48
- if (key.slice(0, at) === 'hypomnema') return true; // exact, case-sensitive
54
+ const name = key.slice(0, at);
55
+ // Match the current plugin name and the legacy one (pre-rename) so the
56
+ // guard holds across the migration window. Exact, case-sensitive.
57
+ if (name === 'hypo' || name === 'hypomnema') return true;
49
58
  }
50
59
  return false;
51
60
  }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * project-create.mjs — atomic auto-project scaffold (fix #23, ADR 0023)
3
+ * project-create.mjs — atomic auto-project scaffold (ADR 0023)
4
4
  *
5
5
  * Invoked by the LLM (NOT a user-facing subcommand — ADR 0023 deprecated
6
6
  * `hypomnema project new`) after the user answers "Y" to a SessionStart /