hypomnema 1.4.2 → 1.5.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 (70) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +35 -18
  4. package/README.md +25 -8
  5. package/commands/audit.md +2 -2
  6. package/commands/crystallize.md +22 -14
  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 +2 -2
  12. package/commands/lint.md +1 -1
  13. package/commands/query.md +1 -1
  14. package/commands/rename.md +1 -1
  15. package/commands/resume.md +1 -1
  16. package/commands/stats.md +1 -1
  17. package/commands/uninstall.md +4 -2
  18. package/commands/upgrade.md +1 -1
  19. package/commands/verify.md +1 -1
  20. package/docs/ARCHITECTURE.md +4 -4
  21. package/docs/CONTRIBUTING.md +8 -7
  22. package/hooks/hypo-auto-commit.mjs +2 -2
  23. package/hooks/hypo-auto-minimal-crystallize.mjs +7 -7
  24. package/hooks/hypo-compact-guard.mjs +2 -2
  25. package/hooks/hypo-cwd-change.mjs +27 -37
  26. package/hooks/hypo-lookup.mjs +37 -2
  27. package/hooks/hypo-personal-check.mjs +37 -22
  28. package/hooks/hypo-session-end.mjs +1 -1
  29. package/hooks/hypo-session-record.mjs +2 -2
  30. package/hooks/hypo-session-start.mjs +62 -42
  31. package/hooks/hypo-shared.mjs +430 -85
  32. package/hooks/version-check.mjs +1 -1
  33. package/package.json +5 -1
  34. package/scripts/check-tracker-ids.mjs +69 -31
  35. package/scripts/crystallize.mjs +151 -53
  36. package/scripts/doctor.mjs +7 -7
  37. package/scripts/feedback-sync.mjs +5 -5
  38. package/scripts/feedback.mjs +9 -10
  39. package/scripts/init.mjs +7 -7
  40. package/scripts/lib/check-tracker-ids.mjs +90 -32
  41. package/scripts/lib/design-history-stale.mjs +1 -1
  42. package/scripts/lib/extensions.mjs +7 -7
  43. package/scripts/lib/failure-type.mjs +1 -1
  44. package/scripts/lib/feedback-scope.mjs +1 -1
  45. package/scripts/lib/page-usage.mjs +141 -0
  46. package/scripts/lib/project-create.mjs +2 -2
  47. package/scripts/lib/template-schema-version.mjs +1 -1
  48. package/scripts/lib/wd-match.mjs +181 -0
  49. package/scripts/lib/wikilink.mjs +1 -1
  50. package/scripts/lint.mjs +5 -5
  51. package/scripts/rename.mjs +1 -1
  52. package/scripts/resume.mjs +20 -22
  53. package/scripts/session-audit.mjs +1 -1
  54. package/scripts/stats.mjs +3 -3
  55. package/scripts/uninstall.mjs +3 -3
  56. package/scripts/upgrade.mjs +85 -18
  57. package/skills/crystallize/SKILL.md +17 -11
  58. package/skills/graph/SKILL.md +3 -3
  59. package/skills/ingest/SKILL.md +5 -5
  60. package/skills/lint/SKILL.md +3 -3
  61. package/skills/query/SKILL.md +3 -3
  62. package/skills/verify/SKILL.md +3 -3
  63. package/templates/SCHEMA.md +1 -1
  64. package/templates/hypo-config.md +1 -1
  65. package/templates/hypo-guide.md +21 -15
  66. package/templates/projects/_template/hot.md +1 -1
  67. package/scripts/fix-status-verify.mjs +0 -256
  68. package/scripts/lib/adr-corpus.mjs +0 -79
  69. package/scripts/lib/fix-manifest.mjs +0 -109
  70. package/scripts/lib/fix-status-verify.mjs +0 -439
@@ -221,7 +221,7 @@ function checkDirectories(hypoDir) {
221
221
  'pages',
222
222
  'projects',
223
223
  'sources',
224
- // Extensions baseline (ADR 0024). Existence only — SHA / settings /
224
+ // Extensions baseline. Existence only — SHA / settings /
225
225
  // manifest integrity is E5.
226
226
  'extensions/hooks',
227
227
  'extensions/commands',
@@ -308,7 +308,7 @@ function checkSettingsJson() {
308
308
  }
309
309
 
310
310
  // stale hypo-* entries (uninstall remnants).
311
- // hypo-ext-* commands are user-extension entries (ADR 0024) — not core hooks,
311
+ // hypo-ext-* commands are user-extension entries — not core hooks,
312
312
  // so they are intentionally absent from HOOK_MAP. Excluded here; their
313
313
  // integrity (SHA + manifest + entry match) is checked separately in E5.
314
314
  const isExtCommand = (cmd) => /(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(cmd);
@@ -545,7 +545,7 @@ function checkSyncState(hypoDir) {
545
545
  }
546
546
 
547
547
  function checkProjectSuggestions(hypoDir) {
548
- // ADR 0023: the auto-project skip-persistence store. Absent file is
548
+ // Auto-project skip-persistence store. Absent file is
549
549
  // healthy (no offers declined yet). Validate the RAW JSON shape here rather
550
550
  // than via readProjectSuggestions(): that helper deliberately normalizes a
551
551
  // non-array `skips` to [] for fail-open hook reads, which would mask a
@@ -665,7 +665,7 @@ function checkCodexPaths() {
665
665
  }
666
666
  }
667
667
 
668
- // ── extensions integrity (ADR 0024, E5) ───────────────────────────
668
+ // ── extensions integrity (E5) ─────────────────────────────────────
669
669
 
670
670
  // Detect drift between the user's `~/hypomnema/extensions/` source, the recorded
671
671
  // per-target SHA map (`hypo-pkg.json`), and the installed copies + settings.json
@@ -685,7 +685,7 @@ function checkCodexPaths() {
685
685
  //
686
686
  // E5 is doctor SURFACE for extensions integrity. The mixed-group surgical
687
687
  // *write* (preserve sibling-plugin hooks, swap only ours) used to be deferred
688
- // here; ADR 0024 amendment 2026-05-23 lifted that deferral
688
+ // here; amendment 2026-05-23 lifted that deferral:
689
689
  // registerSettings (extensions.mjs:478 docstring) now does occurrence-first +
690
690
  // 8-rank canonical write, and the (b) loop below mirrors that read-path via
691
691
  // collectOurOccurrences so a valid mixed-group occurrence is no longer warned
@@ -926,7 +926,7 @@ function checkExtensions(hypoDir, claudeHome, target = 'claude') {
926
926
  else warn(label, `${sample}${extra}`);
927
927
  }
928
928
 
929
- // ── feedback projection (ADR 0031) ──────────────────────────────
929
+ // ── feedback projection ──────────────────────────────────────────
930
930
 
931
931
  // Spawn feedback-sync.mjs --check --json and map its drift report onto doctor's
932
932
  // pass/warn/fail. Integrity violations (exit-3 class: conflict / unpaired marker
@@ -1015,7 +1015,7 @@ function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
1015
1015
  }
1016
1016
  }
1017
1017
 
1018
- // ── stale sibling install (ADR 0038, D) ──────────────────────────────────────
1018
+ // ── stale sibling install (D) ────────────────────────────────────────────────
1019
1019
  //
1020
1020
  // Detect a SECOND, older Hypomnema that owns the `hypomnema` bin on PATH while a
1021
1021
  // newer copy owns the active hooks. That sibling is a footgun: `hypomnema init` /
@@ -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)
3
+ * feedback-sync.mjs — project wiki feedback as SoT, external memory as projection
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
@@ -506,7 +506,7 @@ async function resolveProjectId(args, { prompt = defaultPrompt, isTTY } = {}) {
506
506
  //
507
507
  // Both modes are *reverse* one-time helpers that scaffold wiki DRAFTS under
508
508
  // pages/feedback/_drafts/ — they NEVER write pages/feedback/<slug>.md directly
509
- // (the single-direction invariant, ADR 0031 §6). A human reviews each draft,
509
+ // (the single-direction invariant). A human reviews each draft,
510
510
  // fills the decision fields (scope/tier/targets/promote_to_global), and moves
511
511
  // it into pages/feedback/. _drafts/ is excluded from sync candidates
512
512
  // (loadFeedbackPages) and from lint (collectPages skips `_`-dirs), so an
@@ -856,7 +856,7 @@ function run(args, resolvedPid = null) {
856
856
  // not actionable drift — contract §5 step 4 promises this never hard-fails,
857
857
  // so it must stay OUT of strictWarnings or the PreCompact gate (#3, which
858
858
  // runs `--check --strict`) would block every first-run user. A private-
859
- // sensitivity page IS a real SoT violation (ADR 0031 §7; lint #8 blocks it
859
+ // sensitivity page IS a real SoT violation (lint #8 blocks it
860
860
  // at the source) so it stays strict-escalatable as defense-in-depth.
861
861
  const warnings = [];
862
862
  const strictWarnings = [];
@@ -875,8 +875,8 @@ function run(args, resolvedPid = null) {
875
875
 
876
876
  // pass 1: preflight every target before touching disk — validates the write
877
877
  // plan (container/anchor, markers) and computes next content. A conflict /
878
- // over-cap / build error in ANY target blocks writes to ALL (atomicity — ADR
879
- // 0031 Decision §6 "no auto-merge"; avoids a partial write where one target
878
+ // over-cap / build error in ANY target blocks writes to ALL (atomicity:
879
+ // "no auto-merge"; avoids a partial write where one target
880
880
  // lands and another refuses).
881
881
  const evals = targets.map((target) => ({ target, res: evaluateTarget(pages, target) }));
882
882
  for (const { target, res } of evals) {
@@ -3,8 +3,8 @@
3
3
  * Hypomnema feedback script
4
4
  *
5
5
  * Creates or appends to pages/feedback/<topic>.md with a behaviour-correction
6
- * entry. The wiki feedback page is the single source of truth (ADR 0031 / fix
7
- * #37); MEMORY.md and CLAUDE.md <learned_behaviors> are one-way *projections*
6
+ * entry. The wiki feedback page is the single source of truth; MEMORY.md and
7
+ * CLAUDE.md <learned_behaviors> are one-way *projections*
8
8
  * derived from it. Never hand-edit those targets — this script writes the page
9
9
  * and then runs `feedback-sync --write` to refresh the projection automatically.
10
10
  *
@@ -123,7 +123,7 @@ function listTopics(hypoDir) {
123
123
  for (const f of files) console.log(` ${f.replace(/\.md$/, '')}`);
124
124
  }
125
125
 
126
- // ── classification validation (mirrors lint #8 / ADR 0031 §6) ──────────────────
126
+ // ── classification validation (mirrors lint #8) ─────────────────────────────────
127
127
 
128
128
  const TIER_ENUM = ['L1', 'L2'];
129
129
  const SENSITIVITY_ENUM = ['public', 'sanitized']; // private is forbidden (wiki is git-public)
@@ -136,7 +136,7 @@ function parseTargets(raw) {
136
136
  .filter(Boolean);
137
137
  }
138
138
 
139
- // FEAT-1: `failure_type` is OPTIONAL an unset value is always fine. A set value
139
+ // `failure_type` is OPTIONAL: an unset value is always fine. A set value
140
140
  // must be one of the eight enum members (same vocabulary lint enforces). Returns
141
141
  // an error string or null. Shared by create (validateClassification) and append.
142
142
  function failureTypeError(value) {
@@ -168,7 +168,7 @@ function validateClassification(args, targets) {
168
168
  const ftErr = failureTypeError(args.failureType);
169
169
  if (ftErr) errs.push(ftErr);
170
170
 
171
- // CLAUDE.md projection candidates must be global + L1 (ADR 0031 §6 filter), and
171
+ // CLAUDE.md projection candidates must be global + L1 (SoT filter), and
172
172
  // carry the two conditional fields (lint #8). Enforce here so we never write a
173
173
  // claude-learned page that lint rejects or that silently never projects.
174
174
  if (targets.includes('claude-learned')) {
@@ -176,9 +176,8 @@ function validateClassification(args, targets) {
176
176
  errs.push('--global-summary is required when --targets includes claude-learned');
177
177
  if (!args.promoteToGlobal)
178
178
  errs.push('--promote-to-global is required when --targets includes claude-learned');
179
- if (args.scope !== 'global')
180
- errs.push('claude-learned projection requires --scope=global (ADR 0031 §6)');
181
- if (args.tier !== 'L1') errs.push('claude-learned projection requires --tier=L1 (ADR 0031 §6)');
179
+ if (args.scope !== 'global') errs.push('claude-learned projection requires --scope=global');
180
+ if (args.tier !== 'L1') errs.push('claude-learned projection requires --tier=L1');
182
181
  }
183
182
  return errs;
184
183
  }
@@ -279,7 +278,7 @@ function writeFeedback(args, today) {
279
278
  // Append a dated entry; preserve existing frontmatter classification.
280
279
  mode = 'append';
281
280
  const existing = readFileSync(filePath, 'utf-8');
282
- // FEAT-1: failure_type is a per-page classification property. On append,
281
+ // failure_type is a per-page classification property. On append,
283
282
  // set it if the page has none, error if it conflicts with an existing value,
284
283
  // no-op if it matches. Without the flag, append is byte-for-byte unchanged.
285
284
  const existingFt = (parseFrontmatter(existing) || {}).failure_type || null;
@@ -348,7 +347,7 @@ function writeFeedback(args, today) {
348
347
  return { wrote: true };
349
348
  }
350
349
 
351
- // ── projection post-step (ADR 0031) ────────────────────────────────────────────
350
+ // ── projection post-step ────────────────────────────────────────────────────────
352
351
 
353
352
  // Refresh the MEMORY.md / CLAUDE.md projection from the just-written page. This
354
353
  // is best-effort and NON-blocking: a projection failure (over-cap, conflict,
package/scripts/init.mjs CHANGED
@@ -123,7 +123,7 @@ Commands:
123
123
  doctor Health check: directories, files, hooks, settings.json, git
124
124
  uninstall Remove hooks and registrations (dry-run by default; pass --apply)
125
125
  feedback-sync Project feedback (SoT) → MEMORY.md / CLAUDE.md learned-behaviors
126
- projection (--check default, --write to apply; ADR 0031)
126
+ projection (--check default, --write to apply)
127
127
 
128
128
  Running \`hypomnema\` with no command is equivalent to \`hypomnema init\`.
129
129
 
@@ -200,7 +200,7 @@ const HYPO_DIRS = [
200
200
  'journal/weekly',
201
201
  'journal/monthly',
202
202
  'pages/observability',
203
- // User extensions companion (ADR 0024). init creates the baseline
203
+ // User extensions companion. init creates the baseline
204
204
  // dirs; #29 (E2) adds the hard-copy / manifest / settings sync into them.
205
205
  'extensions/hooks',
206
206
  'extensions/commands',
@@ -451,7 +451,7 @@ function pkgJsonPath() {
451
451
  }
452
452
 
453
453
  /**
454
- * ADR 0038 (P): abort when this package would DOWNGRADE a newer active install.
454
+ * Abort when this package would DOWNGRADE a newer active install.
455
455
  * Exit 2 (distinct from the generic exit-1 error class) on refusal so callers and
456
456
  * tests can tell "refused downgrade" apart from "init failed". No-ops on a fresh
457
457
  * install (no metadata), unparseable versions, the same package re-running itself,
@@ -829,7 +829,7 @@ const args = parseArgs(process.argv);
829
829
  // Validate hooks.json before any file writes so a bad package leaves no partial state
830
830
  const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
831
831
 
832
- // Downgrade guard (ADR 0038, P): refuse to overwrite a NEWER active install with
832
+ // Downgrade guard: refuse to overwrite a NEWER active install with
833
833
  // an older package. The footgun: a stale `npm i -g hypomnema` owns the `hypomnema`
834
834
  // bin on PATH, so `hypomnema init` runs the OLD package and silently downgrades
835
835
  // the newer install (dropping features like the update-notifier). Runs
@@ -854,7 +854,7 @@ if (args.fromRemote) {
854
854
  for (const d of HYPO_DIRS) ensureDir(join(args.hypoDir, d), args.dryRun);
855
855
 
856
856
  // 1b. extensions baseline: drop a .gitkeep into each so the empty dirs are
857
- // git-trackable in the user's wiki repo (ADR 0024).
857
+ // git-trackable in the user's wiki repo.
858
858
  for (const t of ['hooks', 'commands', 'skills', 'agents']) {
859
859
  copyTemplate(
860
860
  join('extensions', t, '.gitkeep'),
@@ -929,12 +929,12 @@ if (args.hooks) {
929
929
  // Always record the active install identity (pkgRoot + pkgVersion), even for a
930
930
  // --no-hooks --no-commands scaffold. That flow still installs the wiki pre-commit
931
931
  // hook (and, with --codex, ~/.codex hooks); without a recorded pkgVersion baseline
932
- // the ADR 0038 downgrade guard has nothing to compare against, so a later stale
932
+ // the downgrade guard has nothing to compare against, so a later stale
933
933
  // sibling could repoint those surfaces unguarded. writePkgJson merges (...existing),
934
934
  // so this never clobbers a commands/extensions map written elsewhere.
935
935
  writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {});
936
936
 
937
- // 4b. user extensions companion sync (ADR 0024). Runs after
937
+ // 4b. user extensions companion sync. Runs after
938
938
  // writePkgJson so the per-target SHA map is merged into the same hypo-pkg.json
939
939
  // (preserving the commands map) rather than racing it.
940
940
  if (args.hooks) {
@@ -10,15 +10,25 @@
10
10
  * This module is PURE (no fs, no git, no process) so it is unit-testable; the
11
11
  * CLI wrapper (scripts/check-tracker-ids.mjs) does the file/git/commit-msg I/O.
12
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):
13
+ * Blocked everywhere in scope (examples use an `N` placeholder, NOT a real
14
+ * digit, so this file itself scans clean and is NOT exempted from --all):
15
15
  * - /\bISSUE-\d+\b/i → the wiki issue tracker, any case (e.g. ISSUE-N)
16
16
  * - /\bfix[ \t ]+#\d+\b/i → the wiki fix tracker, any case (e.g. fix #N)
17
+ * - /\bFEAT-\d+\b/i → the wiki feature tracker, any case (e.g. FEAT-N)
18
+ * - /\bIMPR-\d+\b/i → the wiki improvement tracker, any case (e.g. IMPR-N)
19
+ * - /\bPRAC-\d+\b/i → the wiki practice tracker, any case (e.g. PRAC-N)
17
20
  *
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.
21
+ * FEAT-/IMPR-/PRAC- were once exempt inside shipped code comments; that exemption
22
+ * shipped dangling pointers into the maintainer's private wiki to OSS users, so it
23
+ * was removed. They now block everywhere in scope; the maintainer keeps them only
24
+ * in tests/, qa-runs/, and local notes. The verifier subsystem that legitimately
25
+ * carries `decisions/NNNN` runtime data is excluded from the scan instead (see the
26
+ * CLI's EXCLUDED_FILES).
27
+ *
28
+ * `ADR NNNN` / `ADR-NNNN` / `decisions/NNNN` (DECISION_PATTERNS) are dangling
29
+ * pointers into the maintainer's private wiki ADR set. They block everywhere in
30
+ * scope EXCEPT CHANGELOG.md, whose version history legitimately cites the decision
31
+ * behind a release line. The CLI applies these per-file via patternsFor().
22
32
  *
23
33
  * Accepted edge cases (documented, not bugs):
24
34
  * - FALSE POSITIVE: `FOO-ISSUE-N` matches (the `-` gives a word boundary
@@ -28,21 +38,22 @@
28
38
  * safe — none start the word `fix` right before the `#`.
29
39
  */
30
40
 
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 = [
41
+ // `ADR NNNN` / `ADR-NNNN` / `decisions/NNNN` point into the maintainer's private
42
+ // wiki ADR set, which an OSS user does not have. The CLI applies this set to every
43
+ // in-scope file EXCEPT CHANGELOG.md (version history may cite a decision); the
44
+ // verifier subsystem that carries decisions/ paths as runtime data is removed by
45
+ // EXCLUDED_FILES first. The ADR matcher tolerates a space, tab, or hyphen between
46
+ // `ADR` and the number. Examples below use `NNNN`, not real digits, so this file
47
+ // scans clean.
48
+ export const DECISION_PATTERNS = [
38
49
  {
39
50
  name: 'ADR NNNN',
40
- label: 'wiki ADR pointer (user-facing docs)',
41
- re: /\bADR[ \t]+\d{3,4}\b/gi,
51
+ label: 'wiki ADR pointer',
52
+ re: /\bADR[ \t-]+\d{3,4}\b/gi,
42
53
  },
43
54
  {
44
55
  name: 'decisions/NNNN',
45
- label: 'wiki decisions path (user-facing docs)',
56
+ label: 'wiki decisions path',
46
57
  re: /\bdecisions\/\d{3,4}\b/gi,
47
58
  },
48
59
  ];
@@ -59,32 +70,40 @@ export const BLOCKED_PATTERNS = [
59
70
  label: 'wiki fix-tracker id',
60
71
  re: /\bfix[ \t ]+#\d+\b/gi,
61
72
  },
62
- ];
63
-
64
- // The remaining wiki tracker prefixes (FEAT-/IMPR-/PRAC-). These are NOT in
65
- // BLOCKED_PATTERNS because shipped CODE COMMENTS legitimately cite them for
66
- // maintainer context (e.g. `// FEAT-17 hardening`), exactly like ADR anchors —
67
- // blocking them everywhere would flag ~20 real in-code references. They ARE
68
- // blocked on the PUBLIC RELEASE SURFACE that carries no code: the annotated tag
69
- // body (the source of the GitHub Release via --notes-from-tag). The CLI applies
70
- // [...BLOCKED_PATTERNS, ...SURFACE_TRACKER_PATTERNS] only in --tag mode. The
71
- // CHANGELOG's own surface-ID-0 is held by the section migration + a grep
72
- // regression test, not by widening this file-scan set (changelog-pr-guide §5).
73
- // Examples below use `N`, not a real digit, so this file scans clean.
74
- export const SURFACE_TRACKER_PATTERNS = [
73
+ // FEAT-/IMPR-/PRAC- were a tag-body-only set until they were promoted here so
74
+ // they block in shipped code comments too (the old exemption leaked dead wiki
75
+ // pointers to OSS users). Examples below use `N`, not a real digit, so this
76
+ // file scans clean and is NOT exempted from --all.
75
77
  { name: 'FEAT-N', label: 'wiki feature-tracker id', re: /\bFEAT-\d+\b/gi },
76
78
  { name: 'IMPR-N', label: 'wiki improvement-tracker id', re: /\bIMPR-\d+\b/gi },
77
79
  { name: 'PRAC-N', label: 'wiki practice-tracker id', re: /\bPRAC-\d+\b/gi },
78
80
  ];
79
81
 
80
- // The full tracker-ID set for a no-code public surface (the annotated tag body).
81
- export const TAG_BODY_PATTERNS = [...BLOCKED_PATTERNS, ...SURFACE_TRACKER_PATTERNS];
82
+ // The full tracker-ID set for a no-code public surface (the annotated tag body,
83
+ // republished verbatim by `gh release create --notes-from-tag`). It equals
84
+ // BLOCKED_PATTERNS now that FEAT-/IMPR-/PRAC- live there; ADR/decisions anchors
85
+ // are intentionally allowed in the tag body (release notes cite decisions the way
86
+ // CHANGELOG history does). The CHANGELOG's own surface-ID-0 is held by the section
87
+ // migration + a grep regression test (changelog-pr-guide §5).
88
+ export const TAG_BODY_PATTERNS = [...BLOCKED_PATTERNS];
89
+
90
+ // Strip a leading comment-continuation marker (`*`, `//`, `#`) so a wrapped
91
+ // comment line can be re-joined to its predecessor as flowing text.
92
+ const COMMENT_CONT_RE = /^[ \t]*(?:\*|\/\/|#+)[ \t]?/;
82
93
 
83
94
  /**
84
95
  * Scan a blob of text against `patterns` (default BLOCKED_PATTERNS). Returns hits:
85
96
  * { pattern, label, match, line, col, lineText }
86
97
  * `line`/`col` are 1-based. Empty array => clean. The CLI passes the broader
87
- * [...BLOCKED_PATTERNS, ...USER_FACING_PATTERNS] set only for user-facing docs.
98
+ * [...BLOCKED_PATTERNS, ...DECISION_PATTERNS] set for every in-scope file but the
99
+ * CHANGELOG.
100
+ *
101
+ * Tracker tokens also line-wrap inside comments (`... continuing (ADR\n * 0045)`).
102
+ * A pure per-line scan misses those, so each line is ALSO scanned joined to the
103
+ * next (with the next line's comment-continuation marker collapsed to a space);
104
+ * only matches that START on the current line and CROSS the join are kept, so a
105
+ * wrap is reported exactly once at its prefix line and non-wrapped tokens are
106
+ * never double-counted.
88
107
  */
89
108
  export function scanText(text, patterns = BLOCKED_PATTERNS) {
90
109
  if (typeof text !== 'string' || text.length === 0) return [];
@@ -107,6 +126,45 @@ export function scanText(text, patterns = BLOCKED_PATTERNS) {
107
126
  if (m.index === re.lastIndex) re.lastIndex++; // never-zero-width guard
108
127
  }
109
128
  }
129
+ // Wrapped-token guard: a token can line-wrap at two points — between its
130
+ // prefix WORD and its number (`ADR\n0045`, where the break stands in for a
131
+ // space) OR right at its separator (`ISSUE-\n9`, `decisions/\n0031`,
132
+ // `fix #\n37`, where the digit must sit flush against `-`/`/`/`#`). So scan
133
+ // this line joined to the next BOTH with a space (space-separated forms) and
134
+ // with no gap (separator-flush forms), keeping only matches that begin on this
135
+ // line and cross the join. A token reconstructed by both joins (e.g. the ADR
136
+ // hyphen form) is de-duplicated by its whitespace-collapsed text.
137
+ if (i + 1 < lines.length) {
138
+ const lt = lineText.length;
139
+ const tail = lines[i + 1].replace(COMMENT_CONT_RE, '');
140
+ const seen = new Set();
141
+ for (const [joined, tailAt] of [
142
+ [lineText + ' ' + tail, lt + 1],
143
+ [lineText + tail, lt],
144
+ ]) {
145
+ for (const { name, label, re } of patterns) {
146
+ re.lastIndex = 0;
147
+ let m;
148
+ while ((m = re.exec(joined)) !== null) {
149
+ if (m.index < lt && m.index + m[0].length > tailAt) {
150
+ const key = name + ':' + m[0].replace(/\s+/g, '');
151
+ if (!seen.has(key)) {
152
+ seen.add(key);
153
+ hits.push({
154
+ pattern: name,
155
+ label,
156
+ match: m[0],
157
+ line: i + 1,
158
+ col: m.index + 1,
159
+ lineText: joined,
160
+ });
161
+ }
162
+ }
163
+ if (m.index === re.lastIndex) re.lastIndex++;
164
+ }
165
+ }
166
+ }
167
+ }
110
168
  }
111
169
  return hits;
112
170
  }
@@ -88,7 +88,7 @@ export function findDesignHistoryStale(hypoDir) {
88
88
  if (!existsSync(dhPath)) continue;
89
89
 
90
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
91
+ // daily shards `session-log/YYYY-MM-DD.md` (canonical; legacy
92
92
  // monthly `YYYY-MM.md` files still appear pre-cutover). This globs every
93
93
  // `.md` in the directory, so daily and monthly shapes are both aggregated —
94
94
  // the staleness check needs to see all of them.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * scripts/lib/extensions.mjs — User extensions companion sync (ADR 0024).
2
+ * scripts/lib/extensions.mjs — User extensions companion sync.
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
@@ -38,12 +38,12 @@ const HOME = homedir();
38
38
  // Extension types and their on-disk target subdirectory under ~/.claude or ~/.codex.
39
39
  export const EXT_TYPES = ['hooks', 'commands', 'skills', 'agents'];
40
40
 
41
- // Required filename prefix (ADR 0024 §2 recommends; plan §5 #9 enforces). Files
41
+ // Required filename prefix (plan §5 #9 enforces). Files
42
42
  // without it are skipped so uninstall (which strips hypo-ext-*) can always reach
43
43
  // every file we install — and so a stray name cannot collide with core hooks.
44
44
  export const EXT_PREFIX = 'hypo-ext-';
45
45
 
46
- // Codex supports hooks + commands only; skills/agents are Claude-only (ADR 0024 §4).
46
+ // Codex supports hooks + commands only; skills/agents are Claude-only.
47
47
  export const CODEX_TYPES = ['hooks', 'commands'];
48
48
 
49
49
  // Per-type expected file extension. Hooks are executable .mjs; the rest are .md.
@@ -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 (ADR 0024 amendment 2026-05-23): foreign hooks
508
+ * Mixed-group invariant (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-
@@ -516,7 +516,7 @@ export function syncExtensions({
516
516
  * Our hook entry, however, is canonical-reset on any drift mutation (ranks 3
517
517
  * and 4): the entire prior hook object — including any handler-level fields a
518
518
  * user appended to our hypo-ext-* entry — is replaced by the manifest-derived
519
- * `{ type, command, timeout? }` shape. This mirrors the ADR 0024 hard-copy
519
+ * `{ type, command, timeout? }` shape. This mirrors the hard-copy
520
520
  * ownership semantic for hypo-ext-* file copies. Users who want extra handler
521
521
  * fields on a Hypomnema-managed extension must extend the manifest, not edit
522
522
  * settings.json directly (manifest-as-SoT). registerSettings is honest about
@@ -718,7 +718,7 @@ function registerSettings(settingsPath, expectedEntries, apply) {
718
718
  // target single-hook drift → replace the canonical group object with
719
719
  // the manifest-derived shape. Our hook entry is canonical-reset on this
720
720
  // path (any user-added handler fields are discarded — that mirrors the
721
- // ADR 0024 hard-copy ownership semantic for hypo-ext-*).
721
+ // hard-copy ownership semantic for hypo-ext-*).
722
722
  const loc = locateGroup(settings.hooks, canonical.group);
723
723
  if (loc) settings.hooks[loc.event][loc.groupIdx] = desiredGroup;
724
724
  } else if (canonicalRank === 4) {
@@ -726,7 +726,7 @@ function registerSettings(settingsPath, expectedEntries, apply) {
726
726
  // entry (looked up by identity) with the manifest-derived shape.
727
727
  // Foreign hooks and the group-level matcher are untouched; our own
728
728
  // hook is canonical-reset (handler-level user mods on the hypo-ext-*
729
- // hook are not preserved — by the same ADR 0024 ownership semantic).
729
+ // hook are not preserved — by the same ownership semantic).
730
730
  const hi = canonical.group.hooks.indexOf(canonical.hook);
731
731
  if (hi !== -1) canonical.group.hooks[hi] = desiredHook;
732
732
  } else {
@@ -4,7 +4,7 @@
4
4
  // - scripts/lint.mjs (lint-time enum validation of feedback frontmatter)
5
5
  // - scripts/feedback.mjs (create/append-time --failure-type validation)
6
6
  // Keep this the ONLY definition; both consumers import it so the two validators
7
- // never drift (mirrors feedback-scope.mjs / ADR 0034). stats.mjs aggregates by
7
+ // never drift (mirrors feedback-scope.mjs). stats.mjs aggregates by
8
8
  // plain string and does not validate, so it is intentionally not a consumer.
9
9
  //
10
10
  // `failure_type` is an OPTIONAL field: it classifies feedback that came from a
@@ -4,7 +4,7 @@
4
4
  // - scripts/lint.mjs (lint-time validation of feedback page frontmatter)
5
5
  // - scripts/feedback.mjs (create-time --scope validation in /hypo:feedback)
6
6
  // Keep this the ONLY definition; both consumers import it so the two validators
7
- // never drift (ADR 0034 / OQ-34). feedback-sync.mjs matches scope by plain string
7
+ // never drift. feedback-sync.mjs matches scope by plain string
8
8
  // equality (not this regex), so it is intentionally not a consumer.
9
9
  //
10
10
  // Accepts `global` or `project:<id>`. The `<id>` charset matches what
@@ -0,0 +1,141 @@
1
+ // page-usage.mjs: read-only aggregation over .cache/page-usage.jsonl (B3)
2
+ //
3
+ // The lookup hook appends one JSONL record per injected page slug (see
4
+ // hooks/hypo-shared.mjs recordLookupUsage). This lib reads that log and derives
5
+ // "lookup-cold candidates": pages that have inbound wikilinks (so the graph
6
+ // treats them as live) but have not been injected by lookup within a recency
7
+ // window. It writes nothing and is only invoked from scripts (crystallize), so
8
+ // it never touches the per-prompt hook hot path.
9
+
10
+ import { readFileSync, existsSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { collectPagesCrystallize, extractWikilinks, slugForms } from './wikilink.mjs';
13
+ import { parseFrontmatter } from './frontmatter.mjs';
14
+
15
+ const PAGE_USAGE_REL = '.cache/page-usage.jsonl';
16
+ const DAY_MS = 86400000;
17
+
18
+ // The distinct link forms a slug may appear as in a [[wikilink]] or in the log:
19
+ // its full path, its basename, and (when nested) the path minus its first
20
+ // segment. Matching by form-set intersection bridges the gap between the log's
21
+ // index-matched slug form and the reverse index's [[wikilink]] form.
22
+ function formSet(slug) {
23
+ const f = slugForms(slug);
24
+ const s = new Set([f.full, f.bare]);
25
+ if (f.dirRel) s.add(f.dirRel);
26
+ return s;
27
+ }
28
+
29
+ function intersects(a, b) {
30
+ for (const x of a) if (b.has(x)) return true;
31
+ return false;
32
+ }
33
+
34
+ // Read every record from the usage log, skipping malformed lines. Returns [] if
35
+ // the log is absent or unreadable (fail-soft: aggregation is advisory).
36
+ export function readPageUsage(hypoDir) {
37
+ const path = join(hypoDir, PAGE_USAGE_REL);
38
+ if (!existsSync(path)) return [];
39
+ let text;
40
+ try {
41
+ text = readFileSync(path, 'utf-8');
42
+ } catch {
43
+ return [];
44
+ }
45
+ const records = [];
46
+ for (const line of text.split('\n')) {
47
+ const trimmed = line.trim();
48
+ if (!trimmed) continue;
49
+ try {
50
+ const parsed = JSON.parse(trimmed);
51
+ // Only keep plain objects. A bare `null`, number, string, or array is
52
+ // valid JSON but not a record; keeping it would crash the field access in
53
+ // aggregateColdCandidates (and crystallize calls that outside a try).
54
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
55
+ records.push(parsed);
56
+ }
57
+ } catch {
58
+ // malformed line → skip (log is append-only and may be partially written)
59
+ }
60
+ }
61
+ return records;
62
+ }
63
+
64
+ // Derive lookup-cold candidates. Guards against cold-start: if there are no
65
+ // records, or the observed log span is shorter than minLogSpanDays, returns
66
+ // { status: 'insufficient-data' } so a fresh log doesn't flag the whole vault.
67
+ // Otherwise returns { status: 'ok', candidates: [{ slug, title }] } for pages
68
+ // that have >= 1 inbound wikilink from another page yet have not been logged
69
+ // within the last thresholdDays.
70
+ export function aggregateColdCandidates(
71
+ hypoDir,
72
+ { thresholdDays = 90, minLogSpanDays = 14, now = Date.now(), ignorePatterns = [] } = {},
73
+ ) {
74
+ const records = readPageUsage(hypoDir);
75
+ if (records.length === 0) return { status: 'insufficient-data', reason: 'no-records' };
76
+
77
+ const times = records.map((r) => Date.parse(r.ts)).filter((t) => Number.isFinite(t));
78
+ if (times.length === 0) return { status: 'insufficient-data', reason: 'no-timestamps' };
79
+ const span = Math.max(...times) - Math.min(...times);
80
+ if (span < minLogSpanDays * DAY_MS) {
81
+ return { status: 'insufficient-data', reason: 'span-too-short' };
82
+ }
83
+
84
+ // Forms of every slug logged within the recency window.
85
+ const recentCutoff = now - thresholdDays * DAY_MS;
86
+ const recentForms = new Set();
87
+ for (const r of records) {
88
+ const t = Date.parse(r.ts);
89
+ if (!Number.isFinite(t) || t < recentCutoff) continue;
90
+ if (typeof r.slug !== 'string' || !r.slug) continue;
91
+ for (const form of formSet(r.slug)) recentForms.add(form);
92
+ }
93
+
94
+ // Build the page list with slug, title, forms, and outbound links.
95
+ const pagesDir = join(hypoDir, 'pages');
96
+ const pageList = [];
97
+ for (const { path, rel } of collectPagesCrystallize(pagesDir, hypoDir, ignorePatterns)) {
98
+ let content;
99
+ try {
100
+ content = readFileSync(path, 'utf-8');
101
+ } catch {
102
+ continue;
103
+ }
104
+ const fm = parseFrontmatter(content);
105
+ if (!fm) continue;
106
+ const slug = rel.replace(/\.md$/, '');
107
+ const body = content.replace(/^---[\s\S]*?---/, '');
108
+ pageList.push({
109
+ slug,
110
+ title: fm.title || slug,
111
+ forms: formSet(slug),
112
+ links: extractWikilinks(body),
113
+ });
114
+ }
115
+
116
+ // Reverse index: which page slugs receive >= 1 inbound link from another page.
117
+ const formOwners = new Map(); // form → Set<page slug>
118
+ for (const p of pageList) {
119
+ for (const form of p.forms) {
120
+ if (!formOwners.has(form)) formOwners.set(form, new Set());
121
+ formOwners.get(form).add(p.slug);
122
+ }
123
+ }
124
+ const hasInbound = new Set();
125
+ for (const p of pageList) {
126
+ for (const link of p.links) {
127
+ const targets = new Set();
128
+ for (const form of formSet(link)) {
129
+ const owners = formOwners.get(form);
130
+ if (owners) for (const o of owners) targets.add(o);
131
+ }
132
+ for (const t of targets) if (t !== p.slug) hasInbound.add(t);
133
+ }
134
+ }
135
+
136
+ const candidates = pageList
137
+ .filter((p) => hasInbound.has(p.slug) && !intersects(p.forms, recentForms))
138
+ .map((p) => ({ slug: p.slug, title: p.title }));
139
+
140
+ return { status: 'ok', candidates };
141
+ }
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * project-create.mjs — atomic auto-project scaffold (ADR 0023)
3
+ * project-create.mjs — atomic auto-project scaffold
4
4
  *
5
- * Invoked by the LLM (NOT a user-facing subcommand ADR 0023 deprecated
5
+ * Invoked by the LLM (NOT a user-facing subcommand; `hypomnema project new` is deprecated
6
6
  * `hypomnema project new`) after the user answers "Y" to a SessionStart /
7
7
  * CwdChanged auto-project offer. One call materializes a whole project:
8
8
  *
@@ -6,7 +6,7 @@ import { parseFrontmatter } from './frontmatter.mjs';
6
6
  // frontmatter. init.mjs / upgrade.mjs stamp it into hypo-pkg.json metadata;
7
7
  // deriving it here (rather than hardcoding a literal at each write site) keeps
8
8
  // the stamped value from going stale on a schema bump — the failure mode that
9
- // FEAT-1's 2.0 → 2.1 bump would otherwise have introduced. Returns null only if
9
+ // the 2.0 → 2.1 schema bump would otherwise have introduced. Returns null only if
10
10
  // the template is missing/unreadable (a broken package), in which case callers
11
11
  // keep their prior literal default.
12
12
  export function templateSchemaVersion(pkgRoot) {