hypomnema 1.4.2 → 1.5.0

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 (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/commands/audit.md +1 -1
  6. package/commands/crystallize.md +8 -8
  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 +2 -2
  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-personal-check.mjs +37 -22
  27. package/hooks/hypo-session-end.mjs +1 -1
  28. package/hooks/hypo-session-record.mjs +2 -2
  29. package/hooks/hypo-session-start.mjs +34 -40
  30. package/hooks/hypo-shared.mjs +271 -71
  31. package/hooks/version-check.mjs +1 -1
  32. package/package.json +5 -1
  33. package/scripts/check-tracker-ids.mjs +69 -31
  34. package/scripts/crystallize.mjs +82 -38
  35. package/scripts/doctor.mjs +7 -7
  36. package/scripts/feedback-sync.mjs +5 -5
  37. package/scripts/feedback.mjs +9 -10
  38. package/scripts/init.mjs +7 -7
  39. package/scripts/lib/check-tracker-ids.mjs +90 -32
  40. package/scripts/lib/design-history-stale.mjs +1 -1
  41. package/scripts/lib/extensions.mjs +7 -7
  42. package/scripts/lib/failure-type.mjs +1 -1
  43. package/scripts/lib/feedback-scope.mjs +1 -1
  44. package/scripts/lib/project-create.mjs +2 -2
  45. package/scripts/lib/template-schema-version.mjs +1 -1
  46. package/scripts/lib/wd-match.mjs +181 -0
  47. package/scripts/lib/wikilink.mjs +1 -1
  48. package/scripts/lint.mjs +5 -5
  49. package/scripts/rename.mjs +1 -1
  50. package/scripts/resume.mjs +20 -22
  51. package/scripts/session-audit.mjs +1 -1
  52. package/scripts/stats.mjs +3 -3
  53. package/scripts/uninstall.mjs +3 -3
  54. package/scripts/upgrade.mjs +17 -18
  55. package/skills/crystallize/SKILL.md +7 -7
  56. package/skills/graph/SKILL.md +2 -2
  57. package/skills/ingest/SKILL.md +4 -4
  58. package/skills/lint/SKILL.md +2 -2
  59. package/skills/query/SKILL.md +2 -2
  60. package/skills/verify/SKILL.md +2 -2
  61. package/templates/SCHEMA.md +1 -1
  62. package/templates/hypo-config.md +1 -1
  63. package/templates/hypo-guide.md +20 -14
  64. package/templates/projects/_template/hot.md +1 -1
  65. package/scripts/fix-status-verify.mjs +0 -256
  66. package/scripts/lib/adr-corpus.mjs +0 -79
  67. package/scripts/lib/fix-manifest.mjs +0 -109
  68. package/scripts/lib/fix-status-verify.mjs +0 -439
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
@@ -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) {
@@ -0,0 +1,181 @@
1
+ /**
2
+ * scripts/lib/wd-match.mjs — shared cwd ↔ working_dir project matcher.
3
+ *
4
+ * The four readers (resume.mjs, hooks/hypo-shared.mjs, hooks/hypo-session-start.mjs,
5
+ * hooks/hypo-cwd-change.mjs) each used to inline the same prefix-match. This
6
+ * module is the single source of truth they delegate the path logic to, so the
7
+ * cross-machine basename fallback below is implemented (and tested) once.
8
+ *
9
+ * The cross-machine problem: a git-synced vault carries one machine's absolute
10
+ * working_dir to every machine, so on a second machine cwd never prefix-matches
11
+ * and cwd-first resume silently degrades to recency. The basename tier recovers
12
+ * the match WITHOUT a synced map or a writer: when the absolute prefix fails, a
13
+ * cwd ancestor whose directory name is a GLOBALLY unique project working_dir
14
+ * basename identifies the project. Unique-only, so a shared repo dirname fails
15
+ * closed back to recency (the original prefix-only behavior).
16
+ *
17
+ * The matcher (pickProjectByCwd) is PURE — no fs. collectProjectWorkingDirs is
18
+ * the disk companion that builds the project universe the matcher reasons over.
19
+ */
20
+
21
+ import { homedir } from 'os';
22
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
23
+ import { join } from 'path';
24
+ import { parseFrontmatter } from './frontmatter.mjs';
25
+
26
+ // Expand a leading `~`/`~/`, strip trailing slashes. Mirrors the inline
27
+ // expansion the readers did, kept here so every caller normalizes identically.
28
+ // Returns null for empty/falsy input.
29
+ export function normalizeWorkingDir(p) {
30
+ if (!p) return null;
31
+ let s = String(p).trim();
32
+ if (s === '~') s = homedir();
33
+ else if (s.startsWith('~/')) s = `${homedir()}/${s.slice(2)}`;
34
+ s = s.replace(/\/+$/, '');
35
+ return s || null;
36
+ }
37
+
38
+ // macOS (APFS/HFS+ default) and Windows match paths case-insensitively, so a
39
+ // case-only difference between cwd and a recorded working_dir is the SAME dir
40
+ // there and a genuinely different dir on Linux. Fold only on those platforms:
41
+ // on Linux folding stays off, preserving the exact original comparison.
42
+ export function isCaseInsensitiveFs(platform = process.platform) {
43
+ return platform === 'darwin' || platform === 'win32';
44
+ }
45
+
46
+ function casefold(s, ci) {
47
+ return ci ? s.toLowerCase() : s;
48
+ }
49
+
50
+ // Trailing path segment (basename) without importing path — paths here are
51
+ // already normalized (no trailing slash). Returns '' for a bare root.
52
+ function lastSegment(p) {
53
+ const i = p.lastIndexOf('/');
54
+ return i < 0 ? p : p.slice(i + 1);
55
+ }
56
+
57
+ /**
58
+ * Resolve which project a cwd belongs to.
59
+ *
60
+ * @param {{slug:string, workingDir:string|null|undefined}[]} projects
61
+ * EVERY project with an index.md (the universe). The basename-uniqueness gate
62
+ * is computed over all of them, so an unrelated project sharing a dirname
63
+ * correctly disqualifies the basename tier (fail closed).
64
+ * @param {string} cwd process.cwd()
65
+ * @param {object} [opts]
66
+ * @param {string[]|null} [opts.eligible] restrict the ANSWER to these slugs
67
+ * (e.g. resume's hot.md rows); uniqueness is still judged over all `projects`.
68
+ * null = any project is an acceptable answer.
69
+ * @param {string|null} [opts.realpathCwd] realpathSync(cwd), if the caller
70
+ * resolved symlinks. Tried in addition to the raw cwd.
71
+ * @param {boolean} [opts.caseInsensitive] override FS case sensitivity (tests).
72
+ * @returns {string|null} matched slug, or null when nothing matches.
73
+ */
74
+ export function pickProjectByCwd(projects, cwd, opts = {}) {
75
+ const { eligible = null, realpathCwd = null, caseInsensitive = isCaseInsensitiveFs() } = opts;
76
+ if (!cwd && !realpathCwd) return null;
77
+
78
+ const eligibleSet = eligible ? new Set(eligible) : null;
79
+ const isEligible = (slug) => !eligibleSet || eligibleSet.has(slug);
80
+
81
+ // Universe of normalized (slug, path) entries with a real working_dir.
82
+ const entries = [];
83
+ for (const p of projects) {
84
+ const path = normalizeWorkingDir(p.workingDir);
85
+ if (path) entries.push({ slug: p.slug, path });
86
+ }
87
+ if (entries.length === 0) return null;
88
+
89
+ // cwd variants in PRIORITY order: raw first, realpath (symlink-resolved) only
90
+ // as a fallback. A raw-cwd match must not be overridden by a realpath match
91
+ // (preserves the original single-cwd behavior; realpath only rescues misses).
92
+ const cwds = [];
93
+ for (const c of [cwd, realpathCwd]) {
94
+ const n = normalizeWorkingDir(c);
95
+ if (n && !cwds.includes(n)) cwds.push(n);
96
+ }
97
+
98
+ // ── Tier 1: longest absolute prefix (the original behavior) ────────────────
99
+ // cwd === path or cwd under path/. Longest path wins so /repo/sub beats /repo.
100
+ // The FIRST cwd variant that yields any prefix match wins (raw before realpath).
101
+ for (const c of cwds) {
102
+ const cf = casefold(c, caseInsensitive);
103
+ let bestSlug = null;
104
+ let bestLen = -1;
105
+ for (const e of entries) {
106
+ if (!isEligible(e.slug)) continue;
107
+ const pf = casefold(e.path, caseInsensitive);
108
+ if ((cf === pf || cf.startsWith(`${pf}/`)) && e.path.length > bestLen) {
109
+ bestLen = e.path.length;
110
+ bestSlug = e.slug;
111
+ }
112
+ }
113
+ if (bestSlug) return bestSlug;
114
+ }
115
+
116
+ // ── Tier 2: globally-unique basename of a cwd ancestor (cross-machine) ─────
117
+ // Count basenames across the WHOLE universe. A basename is usable only when
118
+ // exactly one project carries it, so a shared dirname falls through to null.
119
+ const byBasename = new Map(); // folded basename -> { slug, count }
120
+ for (const e of entries) {
121
+ const b = casefold(lastSegment(e.path), caseInsensitive);
122
+ if (!b) continue;
123
+ const hit = byBasename.get(b);
124
+ if (hit) hit.count += 1;
125
+ else byBasename.set(b, { slug: e.slug, count: 1 });
126
+ }
127
+
128
+ // For each cwd variant (raw first), collect every ancestor whose basename is a
129
+ // globally-unique, eligible project basename. Use it ONLY when the whole chain
130
+ // points at exactly ONE project: if two different projects match along the
131
+ // path (e.g. cwd /x/monorepo/api with both `monorepo` and `api` registered),
132
+ // cwd alone can't disambiguate, so decline and let the caller fall back to
133
+ // recency (fail closed, no wrong-project guess).
134
+ for (const c of cwds) {
135
+ const matched = new Set();
136
+ let cur = c;
137
+ while (cur && cur.includes('/')) {
138
+ const b = casefold(lastSegment(cur), caseInsensitive);
139
+ const hit = b && byBasename.get(b);
140
+ if (hit && hit.count === 1 && isEligible(hit.slug)) matched.add(hit.slug);
141
+ cur = cur.slice(0, cur.lastIndexOf('/'));
142
+ }
143
+ if (matched.size === 1) return [...matched][0];
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Scan projects/<slug>/index.md and return [{slug, workingDir}] for every real
150
+ * project. Skips `_template` and dirs without an index.md. The full set is the
151
+ * uniqueness universe for tier 2, so callers should pass ALL projects here even
152
+ * when they later restrict the answer via `opts.eligible`.
153
+ *
154
+ * @param {string} hypoDir
155
+ * @returns {{slug:string, workingDir:string|null}[]}
156
+ */
157
+ export function collectProjectWorkingDirs(hypoDir) {
158
+ const projectsDir = join(hypoDir, 'projects');
159
+ if (!existsSync(projectsDir)) return [];
160
+ const out = [];
161
+ for (const slug of readdirSync(projectsDir)) {
162
+ if (slug === '_template') continue;
163
+ const dir = join(projectsDir, slug);
164
+ try {
165
+ if (!statSync(dir).isDirectory()) continue;
166
+ } catch {
167
+ continue;
168
+ }
169
+ const indexPath = join(dir, 'index.md');
170
+ if (!existsSync(indexPath)) continue;
171
+ let workingDir = null;
172
+ try {
173
+ const fm = parseFrontmatter(readFileSync(indexPath, 'utf-8'));
174
+ workingDir = fm?.working_dir ?? null;
175
+ } catch {
176
+ workingDir = null;
177
+ }
178
+ out.push({ slug, workingDir });
179
+ }
180
+ return out;
181
+ }
@@ -7,7 +7,7 @@
7
7
  // - scripts/graph.mjs (slug index, page collection, link extraction)
8
8
  // - scripts/crystallize.mjs (page collection, link extraction)
9
9
  //
10
- // IMPR-13 consolidation. Before this lib, each script carried its own copy of
10
+ // Shared-resolver consolidation. Before this lib, each script carried its own copy of
11
11
  // collectPages + a slug-form deriver, drifting in subtle ways. The four
12
12
  // collectPages variants are NOT interchangeable — they differ deliberately in
13
13
  // traversal/security/output-shape policy (codex design review, CONCERN):
package/scripts/lint.mjs CHANGED
@@ -115,7 +115,7 @@ const TYPE_CONDITIONAL_FIELDS = {
115
115
  'tool-eval': ['status'],
116
116
  postmortem: ['outcome'],
117
117
  learning: ['source'],
118
- // feedback: ADR 0031 — projection SoT requires full classification
118
+ // feedback: projection SoT requires full classification
119
119
  feedback: [
120
120
  'status',
121
121
  'scope',
@@ -136,13 +136,13 @@ const TYPE_ENUM_FIELDS = {
136
136
  prd: { status: ['draft', 'active', 'completed', 'cancelled', 'archived'] },
137
137
  adr: { status: ['proposed', 'accepted', 'deprecated', 'superseded'] },
138
138
  'tool-eval': { status: ['adopted', 'evaluating', 'rejected'] },
139
- // feedback: ADR 0031 — sensitivity:private is forbidden (wiki is a
139
+ // feedback: sensitivity:private is forbidden (wiki is a
140
140
  // git-pushed public surface)
141
141
  feedback: {
142
142
  status: ['active', 'superseded', 'archived'],
143
143
  tier: ['L1', 'L2'],
144
144
  sensitivity: ['public', 'sanitized'],
145
- // FEAT-1: optional failure taxonomy. The enum loop guards on `if (fm[field])`,
145
+ // optional failure taxonomy. The enum loop guards on `if (fm[field])`,
146
146
  // so an omitted failure_type is never validated (optional, migration-safe).
147
147
  failure_type: FAILURE_TYPE_ENUM,
148
148
  },
@@ -283,7 +283,7 @@ const issues = [];
283
283
  // double-gate).
284
284
  // NOTE: no gate currently passes --strict (npm run lint / CI / release.yml /
285
285
  // crystallize / the close-gate all run plain lint), so promotion is
286
- // forward-looking these surface as warnings today. IMPR-3's deliverable is
286
+ // forward-looking: these surface as warnings today. The deliverable is
287
287
  // "stop silently green-passing invalid YAML"; the W9 warning satisfies that.
288
288
  const STRICT_PROMOTE_IDS = new Set(['W1', 'W2', 'W4', 'W9']);
289
289
 
@@ -392,7 +392,7 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
392
392
  }
393
393
  }
394
394
 
395
- // feedback: scope vocabulary + conditional claude-learned fields (ADR 0031)
395
+ // feedback: scope vocabulary + conditional claude-learned fields
396
396
  if (fm.type === 'feedback') {
397
397
  const scope = fm.scope || '';
398
398
  if (scope && !FEEDBACK_SCOPE_RE.test(scope)) {
@@ -234,7 +234,7 @@ function fail(args, msg) {
234
234
  // • A directory move does NOT change basenames, only the path prefix. So bare
235
235
  // `[[0052]]` links keep resolving to the relocated page automatically and need
236
236
  // NO rewrite — only the FULL-slug and 1-seg DIR-RELATIVE forms encode the moved
237
- // prefix and break. (Forms that drop ≥2 segments, e.g. `[[decisions/0052]]`, do
237
+ // prefix and break. (Forms that drop ≥2 segments, e.g. `[[decisions/NNNN]]`, do
238
238
  // not encode the renamed prefix either: lint cannot resolve them today and they
239
239
  // survive a move unchanged — out of scope, matching lint's buildSlugMap.)
240
240
  // • Every moved page is BOTH a target (it relocates) and a source (its links to