hypomnema 1.2.1 → 1.3.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 (38) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/commands/crystallize.md +23 -6
  4. package/commands/feedback.md +1 -1
  5. package/docs/CONTRIBUTING.md +96 -11
  6. package/hooks/hypo-auto-commit.mjs +3 -3
  7. package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
  8. package/hooks/hypo-cwd-change.mjs +2 -2
  9. package/hooks/hypo-first-prompt.mjs +1 -1
  10. package/hooks/hypo-personal-check.mjs +57 -7
  11. package/hooks/hypo-session-start.mjs +51 -4
  12. package/hooks/hypo-shared.mjs +137 -12
  13. package/hooks/version-check.mjs +204 -6
  14. package/package.json +5 -2
  15. package/scripts/bump-version.mjs +9 -3
  16. package/scripts/check-bilingual.mjs +115 -0
  17. package/scripts/crystallize.mjs +124 -15
  18. package/scripts/doctor.mjs +45 -9
  19. package/scripts/feedback-sync.mjs +44 -15
  20. package/scripts/feedback.mjs +5 -5
  21. package/scripts/fix-status-verify.mjs +256 -0
  22. package/scripts/init.mjs +45 -4
  23. package/scripts/install-git-hooks.mjs +258 -0
  24. package/scripts/lib/adr-corpus.mjs +79 -0
  25. package/scripts/lib/check-bilingual.mjs +141 -0
  26. package/scripts/lib/extensions.mjs +3 -3
  27. package/scripts/lib/feedback-scope.mjs +21 -0
  28. package/scripts/lib/fix-manifest.mjs +109 -0
  29. package/scripts/lib/fix-status-verify.mjs +438 -0
  30. package/scripts/lib/pre-commit-format.mjs +251 -0
  31. package/scripts/lib/project-create.mjs +2 -2
  32. package/scripts/lint.mjs +48 -8
  33. package/scripts/pre-commit-format.mjs +198 -0
  34. package/scripts/smoke-pack.mjs +16 -0
  35. package/scripts/upgrade.mjs +55 -23
  36. package/skills/crystallize/SKILL.md +13 -2
  37. package/templates/hypo-config.md +1 -1
  38. package/templates/hypo-guide.md +4 -0
package/scripts/lint.mjs CHANGED
@@ -11,6 +11,9 @@
11
11
  * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
12
12
  * --json Output results as JSON
13
13
  * --fix Auto-add missing `updated` field (safe repairs only)
14
+ * --strict Promote selected warnings (STRICT_PROMOTE_IDS) to errors
15
+ * so they exit 1. Opt-in gate for release-checklist /
16
+ * pre-commit; default mode stays byte-identical.
14
17
  */
15
18
 
16
19
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
@@ -20,15 +23,17 @@ import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
20
23
  import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
21
24
  import { parseSchemaVocab, checkForbidden, parseSchemaPageDirs } from './lib/schema-vocab.mjs';
22
25
  import { findDesignHistoryStale } from './lib/design-history-stale.mjs';
26
+ import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
23
27
 
24
28
  // ── arg parsing ──────────────────────────────────────────────────────────────
25
29
 
26
30
  function parseArgs(argv) {
27
- const args = { hypoDir: null, json: false, fix: false };
31
+ const args = { hypoDir: null, json: false, fix: false, strict: false };
28
32
  for (const arg of argv.slice(2)) {
29
33
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
30
34
  else if (arg === '--json') args.json = true;
31
35
  else if (arg === '--fix') args.fix = true;
36
+ else if (arg === '--strict') args.strict = true;
32
37
  }
33
38
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
34
39
  return args;
@@ -198,6 +203,20 @@ const VALID_TYPES = [
198
203
 
199
204
  const issues = [];
200
205
 
206
+ // Stable warning class IDs (W1..Wn). `--strict` promotes a frozen subset to
207
+ // errors by ID — never by brittle message-text matching. W8 (design-history
208
+ // stale) predates this scheme; hooks/hypo-personal-check.mjs filters `w.id ===
209
+ // 'W8'`, so it must keep that number — the W5..W7 gap is honest history, not a
210
+ // bug. (spec-v1.3.0 Track E)
211
+ //
212
+ // STRICT_PROMOTE_IDS (OQ-E1, frozen as a code constant): confirmed content
213
+ // defects only.
214
+ // W1 no-frontmatter / W2 unknown-type / W4 broken-wikilink → promote.
215
+ // W3 missing-updated → excluded (auto-repaired by --fix).
216
+ // W8 design-history-stale → excluded (hypo-personal-check handles it; would
217
+ // double-gate).
218
+ const STRICT_PROMOTE_IDS = new Set(['W1', 'W2', 'W4']);
219
+
201
220
  function issue(severity, rel, msg, fullPath = null, id = null) {
202
221
  issues.push({ severity, file: rel, message: msg, path: fullPath, id });
203
222
  }
@@ -249,7 +268,7 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
249
268
  }
250
269
 
251
270
  if (!content.match(/^---\r?\n/)) {
252
- issue('warn', rel, 'No frontmatter found');
271
+ issue('warn', rel, 'No frontmatter found', null, 'W1');
253
272
  return;
254
273
  }
255
274
 
@@ -264,11 +283,11 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
264
283
  }
265
284
 
266
285
  if (fm.type && !VALID_TYPES.includes(fm.type)) {
267
- issue('warn', rel, `Unknown type: "${fm.type}"`);
286
+ issue('warn', rel, `Unknown type: "${fm.type}"`, null, 'W2');
268
287
  }
269
288
 
270
289
  if (!fm.updated) {
271
- issue('warn', rel, 'Missing frontmatter field: updated', path);
290
+ issue('warn', rel, 'Missing frontmatter field: updated', path, 'W3');
272
291
  }
273
292
 
274
293
  // type-conditional required fields
@@ -296,8 +315,12 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
296
315
  // feedback: scope vocabulary + conditional claude-learned fields (ADR 0031)
297
316
  if (fm.type === 'feedback') {
298
317
  const scope = fm.scope || '';
299
- if (scope && scope !== 'global' && !/^project:[a-z0-9][a-z0-9-]*$/.test(scope)) {
300
- issue('error', rel, `Invalid feedback scope: "${scope}" (allowed: global | project:<slug>)`);
318
+ if (scope && !FEEDBACK_SCOPE_RE.test(scope)) {
319
+ issue(
320
+ 'error',
321
+ rel,
322
+ `Invalid feedback scope: "${scope}" (allowed: global | project:<project-id>)`,
323
+ );
301
324
  }
302
325
  const fbTargets = parseTagsField(fm.targets) || [];
303
326
  if (fbTargets.includes('claude-learned')) {
@@ -332,7 +355,7 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
332
355
 
333
356
  for (const link of extractWikilinks(content)) {
334
357
  if (!slugMap.has(link)) {
335
- issue('warn', rel, `Broken wikilink: [[${link}]]`);
358
+ issue('warn', rel, `Broken wikilink: [[${link}]]`, null, 'W4');
336
359
  }
337
360
  }
338
361
  }
@@ -404,12 +427,29 @@ if (args.fix) {
404
427
  }
405
428
  }
406
429
 
430
+ // --strict: promote selected warnings (by stable ID) to errors *before* the
431
+ // errors/warns split, so exit code, counts, plain-text icons, and --json `ok`
432
+ // all derive from the post-promotion severities through the existing paths.
433
+ if (args.strict) {
434
+ for (const iss of issues) {
435
+ if (iss.severity === 'warn' && iss.id && STRICT_PROMOTE_IDS.has(iss.id)) {
436
+ iss.severity = 'error';
437
+ }
438
+ }
439
+ }
440
+
407
441
  const errors = issues.filter((i) => i.severity === 'error');
408
442
  const warns = issues.filter((i) => i.severity === 'warn');
409
443
 
410
444
  if (args.json) {
445
+ // Default mode is byte-identical: only W8 carries an `id` in the JSON payload
446
+ // (hooks/hypo-personal-check.mjs filters on it). All other IDs stay internal
447
+ // unless `--strict` is set, where the full ID set is exposed so promoted
448
+ // findings are traceable to their warning class.
411
449
  const toOut = ({ severity, file, message, id }) =>
412
- id ? { severity, file, message, id } : { severity, file, message };
450
+ id && (id === 'W8' || args.strict)
451
+ ? { severity, file, message, id }
452
+ : { severity, file, message };
413
453
  console.log(
414
454
  JSON.stringify(
415
455
  {
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/pre-commit-format.mjs — Node-side entry for the pre-commit hook.
4
+ *
5
+ * Invoked by the shell shim installed at <git-dir>/hooks/pre-commit. The shim
6
+ * already verifies HYPOMNEMA_ROOT + HYPOMNEMA_GIT_DIR; this script is the
7
+ * second layer of identity defence and the only place that may exit nonzero
8
+ * (only when `git add` fails on restage).
9
+ *
10
+ * Env discipline:
11
+ * - probes git twice with ambient env to learn what Git thinks the repo is
12
+ * - builds `cleanEnv` by stripping every name from `git rev-parse --local-env-vars`
13
+ * plus GIT_NAMESPACE / GIT_CEILING_DIRECTORIES / GIT_CONFIG_* (belt-and-suspenders)
14
+ * - validates inherited GIT_INDEX_FILE: preserve if inside HYPOMNEMA_GIT_DIR
15
+ * (Git exports this for commit -am / commit -- path / commit --amend);
16
+ * otherwise refuse — that's a foreign-index attack vector
17
+ * - lib spawns get `cleanEnv` only; lib never touches process.env directly
18
+ *
19
+ * Why dynamic import: keeping the lib import behind every guard makes the
20
+ * fail-open story water-tight. A static import at file head would throw at
21
+ * module load if the lib were missing or syntactically broken, before any
22
+ * identity check could exit 0.
23
+ *
24
+ * Why pathToFileURL for the import: absolute filesystem paths fed to
25
+ * `import()` break when the checkout path contains URL-significant characters
26
+ * (#, %). pathToFileURL is the canonical Node way to bridge.
27
+ */
28
+
29
+ try {
30
+ const { execFileSync } = await import('node:child_process');
31
+ const fs = await import('node:fs');
32
+ const path = await import('node:path');
33
+ const { pathToFileURL, fileURLToPath } = await import('node:url');
34
+
35
+ const probe = (args, env) => execFileSync('git', args, { encoding: 'utf8', env }).trim();
36
+
37
+ // (0) Derive expectedRoot from THIS script's filesystem location. The shell
38
+ // shim already verifies HYPOMNEMA_ROOT/HYPOMNEMA_GIT_DIR before exec'ing
39
+ // us, but a direct `node scripts/pre-commit-format.mjs` invocation with
40
+ // hostile GIT_DIR/GIT_WORK_TREE pointing at another repo that ALSO calls
41
+ // itself "hypomnema" would bypass the package.json identity check unless
42
+ // we anchor on the script's own location. import.meta.url cannot be
43
+ // redirected by ambient env.
44
+ let expectedRoot;
45
+ try {
46
+ const here = path.dirname(fileURLToPath(import.meta.url));
47
+ expectedRoot = fs.realpathSync(path.resolve(here, '..'));
48
+ } catch {
49
+ process.exit(0);
50
+ }
51
+
52
+ // (1) Probe with ambient env to learn what Git thinks the repo is.
53
+ let toplevel, absGitDir, commonDir;
54
+ try {
55
+ toplevel = probe(['rev-parse', '--show-toplevel'], process.env);
56
+ absGitDir = probe(['rev-parse', '--absolute-git-dir'], process.env);
57
+ commonDir = probe(['rev-parse', '--git-common-dir'], process.env);
58
+ } catch {
59
+ process.exit(0);
60
+ }
61
+
62
+ // (2) Realpath-resolve for stable comparison.
63
+ let absGitDirR, commonDirR, toplevelR;
64
+ try {
65
+ absGitDirR = fs.realpathSync(absGitDir);
66
+ const cdAbs = path.isAbsolute(commonDir) ? commonDir : path.join(absGitDir, '..', commonDir);
67
+ commonDirR = fs.realpathSync(cdAbs);
68
+ toplevelR = fs.realpathSync(toplevel);
69
+ } catch {
70
+ process.exit(0);
71
+ }
72
+
73
+ // (3) Trust anchor — refuse to run against any toplevel other than this
74
+ // script's own checkout. Closes the GIT_DIR/GIT_WORK_TREE attack where a
75
+ // foreign hypomnema-named repo would otherwise pass the package.json check.
76
+ if (toplevelR !== expectedRoot) process.exit(0);
77
+
78
+ // (3a) Anchor the git dir to the expected location too. Without this, a
79
+ // mixed-env attack — GIT_DIR=/foreign/.git + GIT_WORK_TREE=expectedRoot
80
+ // + GIT_INDEX_FILE=/foreign/.git/index — would let `absGitDirR` point at
81
+ // the foreign repo while `--show-toplevel` reports expectedRoot. The
82
+ // subsequent GIT_INDEX_FILE check would then pass relative to the
83
+ // foreign git dir, and the lib would operate on a foreign index while
84
+ // mutating real files. (Live-verified by codex round 7.)
85
+ let expectedGitDirR;
86
+ try {
87
+ expectedGitDirR = fs.realpathSync(path.join(expectedRoot, '.git'));
88
+ } catch {
89
+ process.exit(0);
90
+ }
91
+ if (absGitDirR !== expectedGitDirR) process.exit(0);
92
+
93
+ // (4) Linked worktree → main-worktree only (documented limitation).
94
+ if (absGitDirR !== commonDirR) process.exit(0);
95
+
96
+ // (5) Repo identity check — package.json name must be "hypomnema" (defence in
97
+ // depth alongside the expectedRoot anchor above).
98
+ const pkgPath = path.join(toplevelR, 'package.json');
99
+ if (!fs.existsSync(pkgPath)) process.exit(0);
100
+ let pkg;
101
+ try {
102
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
103
+ } catch {
104
+ process.exit(0);
105
+ }
106
+ if (pkg?.name !== 'hypomnema') process.exit(0);
107
+
108
+ // (5) Build trusted env from --local-env-vars + GIT_CONFIG_* belt.
109
+ let localEnvList;
110
+ try {
111
+ localEnvList = probe(['rev-parse', '--local-env-vars'], process.env)
112
+ .split(/\r?\n/)
113
+ .filter(Boolean);
114
+ } catch {
115
+ // Static fallback (Git versions where --local-env-vars is unavailable).
116
+ localEnvList = [
117
+ 'GIT_DIR',
118
+ 'GIT_WORK_TREE',
119
+ 'GIT_INDEX_FILE',
120
+ 'GIT_OBJECT_DIRECTORY',
121
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
122
+ 'GIT_COMMON_DIR',
123
+ 'GIT_CONFIG',
124
+ 'GIT_CONFIG_PARAMETERS',
125
+ 'GIT_PREFIX',
126
+ 'GIT_IMPLICIT_WORK_TREE',
127
+ 'GIT_GRAFT_FILE',
128
+ 'GIT_NO_REPLACE_OBJECTS',
129
+ 'GIT_REPLACE_REF_BASE',
130
+ 'GIT_SHALLOW_FILE',
131
+ ];
132
+ }
133
+ const scrub = new Set([
134
+ ...localEnvList,
135
+ 'GIT_NAMESPACE',
136
+ 'GIT_CEILING_DIRECTORIES',
137
+ ...Object.keys(process.env).filter((k) => /^GIT_CONFIG_/.test(k)),
138
+ ]);
139
+ const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !scrub.has(k)));
140
+
141
+ // (6) GIT_INDEX_FILE preservation gated on shim invocation. Git legitimately
142
+ // exports this for these commit shapes (verified live by codex round 5/9
143
+ // on Git 2.50.1):
144
+ // - .git/index normal commit, commit --amend, merge commit
145
+ // - .git/index.lock commit -am, commit -p, commit --interactive
146
+ // - .git/next-index-*.lock commit -- <pathspec>, rebase partial commit
147
+ // The basename whitelist used in v8/v9 had a residual gap: a crafted
148
+ // .git/next-index-attack.lock matches the `next-index-*` prefix even
149
+ // though Git itself uses `next-index-<pid>.lock` (codex round 9 live
150
+ // replay). Closing that prefix gap by tightening pattern just invites
151
+ // more attacker iteration.
152
+ //
153
+ // The cleaner defence: only honour inherited GIT_INDEX_FILE when we
154
+ // were invoked from our own trusted shell shim (HYPOMNEMA_HOOK_INVOCATION
155
+ // sentinel set there). Direct invocation — which is the only path an
156
+ // attacker can use to plant a crafted index — drops the inherited value
157
+ // and lets git fall back to the default `.git/index`, i.e. the real
158
+ // staged set. The hook then either has nothing to do (no real stage) or
159
+ // formats the real stage (correct behaviour). An attacker that can also
160
+ // set HYPOMNEMA_HOOK_INVOCATION already has full env control and can
161
+ // mutate files directly without going through the hook gadget.
162
+ const fromShim = process.env.HYPOMNEMA_HOOK_INVOCATION === '1';
163
+ if (fromShim && process.env.GIT_INDEX_FILE) {
164
+ // Even when trusted, sanity-check that the path lives inside our git dir.
165
+ // Belt-and-suspenders against shim invocation with an inherited but
166
+ // misdirected GIT_INDEX_FILE (e.g. by a wrapper that exec'd our shim).
167
+ const inherited = process.env.GIT_INDEX_FILE;
168
+ const lexical = path.isAbsolute(inherited) ? inherited : path.join(toplevelR, inherited);
169
+ let absIdx;
170
+ try {
171
+ absIdx = fs.realpathSync(lexical);
172
+ } catch {
173
+ absIdx = path.resolve(lexical);
174
+ }
175
+ if (path.dirname(absIdx) === absGitDirR) {
176
+ cleanEnv.GIT_INDEX_FILE = inherited;
177
+ }
178
+ }
179
+ // If !fromShim, GIT_INDEX_FILE stays scrubbed → lib uses default .git/index.
180
+
181
+ // (7) Dynamic-import the lib through a file:// URL so checkout paths with
182
+ // URL-significant chars (#, %) don't break ESM resolution. We import
183
+ // from expectedRoot, not toplevel — the script's own location is the
184
+ // anchor of trust.
185
+ const libPath = path.join(expectedRoot, 'scripts/lib/pre-commit-format.mjs');
186
+ let lib;
187
+ try {
188
+ lib = await import(pathToFileURL(libPath).href);
189
+ } catch {
190
+ process.exit(0);
191
+ }
192
+
193
+ const result = await lib.runPreCommitFormat({ cwd: toplevelR, env: cleanEnv });
194
+ if (result.summary) process.stderr.write(`[pre-commit-format] ${result.summary}\n`);
195
+ process.exit(result.gitAddFailed ? 1 : 0);
196
+ } catch {
197
+ process.exit(0);
198
+ }
@@ -56,6 +56,13 @@ const wikiDir = join(work, 'wiki');
56
56
  let cleanupOk = false;
57
57
 
58
58
  try {
59
+ // Capture pre-commit hook contents (if any) BEFORE pack so we can prove
60
+ // `npm pack` didn't mutate it. The `prepare` lifecycle script runs during
61
+ // `npm pack` and could theoretically touch .git/hooks/pre-commit; the
62
+ // installer's CI/lifecycle guards must prevent that.
63
+ const preCommitPath = join(REPO, '.git', 'hooks', 'pre-commit');
64
+ const preCommitBefore = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : null;
65
+
59
66
  step('npm pack');
60
67
  const pack = run('npm', ['pack', '--json'], { cwd: REPO });
61
68
  const meta = JSON.parse(pack.stdout)[0];
@@ -203,6 +210,15 @@ try {
203
210
  );
204
211
  }
205
212
 
213
+ step('verify pre-commit hook was not mutated by npm pack');
214
+ const preCommitAfter = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : null;
215
+ if (preCommitBefore !== preCommitAfter) {
216
+ throw new Error(
217
+ 'npm pack mutated .git/hooks/pre-commit — the prepare lifecycle ' +
218
+ 'guard (npm_command=pack) is not firing.',
219
+ );
220
+ }
221
+
206
222
  console.log('\n✓ smoke-pack passed');
207
223
  cleanupOk = true;
208
224
  } catch (err) {
@@ -19,6 +19,8 @@
19
19
  * wiki-*.mjs → hypo-*.mjs rename migration (fix #48), and
20
20
  * the user-extensions companion sync (E4 / fix #32).
21
21
  * --json Output results as JSON
22
+ * --allow-downgrade Override the guard that refuses to overwrite a NEWER
23
+ * active install with an older package (ADR 0038)
22
24
  */
23
25
 
24
26
  import {
@@ -45,6 +47,7 @@ import {
45
47
  readFileIfRegular,
46
48
  } from './lib/pkg-json.mjs';
47
49
  import { syncExtensions } from './lib/extensions.mjs';
50
+ import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
48
51
 
49
52
  const HOME = homedir();
50
53
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -76,6 +79,7 @@ function parseArgs(argv) {
76
79
  forceCommands: false,
77
80
  forceExtensions: false,
78
81
  codex: false,
82
+ allowDowngrade: false,
79
83
  };
80
84
  for (const arg of argv.slice(2)) {
81
85
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
@@ -84,6 +88,7 @@ function parseArgs(argv) {
84
88
  else if (arg === '--force-extensions') args.forceExtensions = true;
85
89
  else if (arg === '--codex') args.codex = true;
86
90
  else if (arg === '--json') args.json = true;
91
+ else if (arg === '--allow-downgrade') args.allowDowngrade = true;
87
92
  }
88
93
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
89
94
  return args;
@@ -312,7 +317,7 @@ function applySettingsJson(settingsResults, settingsPath) {
312
317
  for (const s of settingsResults) {
313
318
  if (s.status !== 'missing') continue;
314
319
  if (!Array.isArray(settings.hooks[s.event])) settings.hooks[s.event] = [];
315
- // fix #48 BLOCKER: re-check the current parsed settings before appending.
320
+ // re-check the current parsed settings before appending.
316
321
  // applyHookNameMigration may have rewritten a legacy wiki-*.mjs command to
317
322
  // exactly `s.cmd` between checkSettingsJson and now — appending without
318
323
  // this guard creates a duplicate registration (codex 2-worker review
@@ -521,15 +526,16 @@ checklist below is manual:
521
526
  - [ ] **Re-run \`hypomnema lint\` after backfilling — confirm 0 feedback errors
522
527
  remain (including the conditional \`claude-learned\` fields above)**
523
528
 
524
- ## Caveat — \`scope: project:<project-id>\` and slug regex
529
+ ## Note — \`scope: project:<project-id>\` and the scope regex
525
530
 
526
- The lint regex \`^project:[a-z0-9][a-z0-9-]*$\` accepts only short slugs, but
527
- \`feedback-sync\`'s default resolved project-id is cwd-derived (e.g.
528
- \`-Users-you-Workspace-Project\`), which the regex rejects. To use a
529
- \`scope: project:*\` page in v1.2.0 you must override with
530
- \`--project-id=<slug>\` so the resolved id is slug-safe. The full resolved-id
531
- wiki-slug reconciliation is deferred to v1.3.0; \`commands/feedback.md\`
532
- documents the interim pattern.
531
+ As of v1.3.0 the feedback scope regex \`^(global|project:[A-Za-z0-9_-]+)\$\`
532
+ accepts cwd-derived project-ids directly (e.g.
533
+ \`-Users-you-Workspace-Project\`), so a \`scope: project:*\` page no longer needs
534
+ a \`--project-id=<slug>\` override just to pass \`lint\`. The resolved id must
535
+ still exact-match \`feedback-sync\`'s project-id for projection (default: cwd
536
+ with \`/\` and \`.\` replaced by \`-\`). Known limit: a cwd containing spaces or
537
+ other characters outside \`[A-Za-z0-9_-]\` still derives an id the regex
538
+ rejects — pass \`--project-id=<id>\` for those.
533
539
  `
534
540
  : `## What changed
535
541
 
@@ -762,7 +768,7 @@ const commands = checkCommands();
762
768
  const oldHookRefs = checkOldHookNames(claudeSettingsPath);
763
769
  const hypoignore = checkHypoignore(args.hypoDir);
764
770
 
765
- // fix #48: when --codex is set, mirror the same core-hook checks against ~/.codex/
771
+ // when --codex is set, mirror the same core-hook checks against ~/.codex/
766
772
  // so `hypomnema upgrade --codex` reports drift symmetrically and `--apply --codex`
767
773
  // updates both targets in one pass (matching init.mjs behaviour).
768
774
  const hooksCodex = args.codex ? checkHookFiles(codexHooksDir) : null;
@@ -784,7 +790,7 @@ const extCheck = syncExtensions({
784
790
  force: args.forceExtensions,
785
791
  });
786
792
 
787
- // E4 (#32): --codex mirrors the extensions sync into ~/.codex (hooks + commands
793
+ // E4 (fix #32): --codex mirrors the extensions sync into ~/.codex (hooks + commands
788
794
  // only; skills/agents skipped with a notice). The per-target SHA map lives in the
789
795
  // same ~/.claude/hypo-pkg.json under extensions.codex, so pkgPath is unchanged.
790
796
  const extCodexSettingsPath = codexSettingsPath;
@@ -839,6 +845,32 @@ let appliedExtensions = null;
839
845
  let appliedExtensionsCodex = null;
840
846
 
841
847
  if (args.apply) {
848
+ // Downgrade guard (ADR 0038, P): an `--apply` from an OLDER package than the
849
+ // active install would overwrite newer hooks (upgrade.mjs:287 copyFileSync) and
850
+ // rewrite hypo-pkg.json to the older version. Refuse before the first mutation.
851
+ // A dev workspace re-running its own --apply (incl. the post-commit sync hook)
852
+ // is exempt via realpath'd pkgRoot equality. Exit 2 = refused downgrade.
853
+ if (!args.allowDowngrade) {
854
+ const _active = readPkgJsonSafe(pkgJsonPath());
855
+ let _incomingVersion = null;
856
+ try {
857
+ _incomingVersion = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
858
+ } catch {
859
+ /* unreadable own package.json — cannot prove a downgrade, allow */
860
+ }
861
+ if (
862
+ _active &&
863
+ _active.pkgVersion &&
864
+ _incomingVersion &&
865
+ classifyInstall(
866
+ { pkgRoot: PKG_ROOT, version: _incomingVersion },
867
+ { pkgRoot: _active.pkgRoot, version: _active.pkgVersion },
868
+ ) === 'downgrade'
869
+ ) {
870
+ console.error(downgradeGuardMessage(_incomingVersion, _active.pkgVersion, 'upgrade --apply'));
871
+ process.exit(2);
872
+ }
873
+ }
842
874
  if (oldHookRefs.length > 0) {
843
875
  appliedHookNameRenames = applyHookNameMigration(
844
876
  oldHookRefs,
@@ -855,7 +887,7 @@ if (args.apply) {
855
887
  appliedCommands = applyCommands(commands, args.forceCommands);
856
888
  appliedPkgJson = true;
857
889
  appliedHypoignore = applyHypoignoreMigration(hypoignore);
858
- // fix #48: codex core hooks + settings + wiki-*→hypo-* rename mirror. Same order
890
+ // codex core hooks + settings + wiki-*→hypo-* rename mirror. Same order
859
891
  // as the claude side (rename first so subsequent hook copy can find renamed targets).
860
892
  if (args.codex) {
861
893
  if (oldHookRefsCodex.length > 0) {
@@ -878,7 +910,7 @@ if (args.apply) {
878
910
  apply: true,
879
911
  force: args.forceExtensions,
880
912
  });
881
- // E4 (#32): codex apply runs AFTER the claude apply so it reads the freshly
913
+ // E4 (fix #32): codex apply runs AFTER the claude apply so it reads the freshly
882
914
  // written hypo-pkg.json and merges extensions.codex alongside extensions.claude
883
915
  // (the per-target spread in syncExtensions preserves the other target's map).
884
916
  if (args.codex) {
@@ -898,7 +930,7 @@ if (args.apply) {
898
930
 
899
931
  const extDrift = extCheck.needsWork || (extCheckCodex?.needsWork ?? false);
900
932
 
901
- // fix #48: codex drift only counts when --codex is set — without the flag the codex
933
+ // codex drift only counts when --codex is set — without the flag the codex
902
934
  // target is intentionally unobserved (parity with the existing extensions pattern).
903
935
  const codexCoreDrift =
904
936
  args.codex &&
@@ -935,7 +967,7 @@ if (args.json) {
935
967
  hypoignore,
936
968
  extensions: extCheck,
937
969
  extensionsCodex: extCheckCodex,
938
- // fix #48: codex core mirror (null when --codex absent).
970
+ // codex core mirror (null when --codex absent).
939
971
  hooksCodex,
940
972
  settingsCodex,
941
973
  oldHookRefsCodex,
@@ -1090,7 +1122,7 @@ if (commands.length === 0) {
1090
1122
  }
1091
1123
 
1092
1124
  // Old hook names (wiki-*.mjs → hypo-*.mjs rename migration). Target-aware so
1093
- // fix #48 surfaces codex settings.json that still references the v1.0/v1.1 names.
1125
+ // codex settings.json entries that still reference the v1.0/v1.1 names are surfaced.
1094
1126
  function pushHookNameSummary(refs, label) {
1095
1127
  if (refs.length > 0) {
1096
1128
  lines.push(
@@ -1142,7 +1174,7 @@ function pushExtSummary(check, label) {
1142
1174
  for (const c of check.conflicts) lines.push(` ✗ ${c.file} [${c.action} — left untouched]`);
1143
1175
  for (const d of check.drifts) lines.push(` ⚠ ${d.file} [drift — left untouched]`);
1144
1176
  }
1145
- // E3 (#31): a hard conflict blocks install (exit 1, even under --apply); drift is
1177
+ // E3 (fix #31): a hard conflict blocks install (exit 1, even under --apply); drift is
1146
1178
  // resolvable advisory. Emit the spec'd WIKI messages so the user knows the recovery.
1147
1179
  if (nConflicts > 0) {
1148
1180
  lines.push(' [WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
@@ -1200,7 +1232,7 @@ if (
1200
1232
  lines.push(`✓ Appended .hypoignore entries (${appliedHypoignore.length}):`);
1201
1233
  for (const e of appliedHypoignore) lines.push(` → ${e}`);
1202
1234
  }
1203
- // fix #48: codex-target applied actions (mirrors claude blocks above).
1235
+ // codex-target applied actions (mirrors claude blocks above).
1204
1236
  if (appliedHookNameRenamesCodex.length > 0) {
1205
1237
  lines.push(`✓ Renamed legacy hook references (codex) (${appliedHookNameRenamesCodex.length}):`);
1206
1238
  for (const r of appliedHookNameRenamesCodex) lines.push(` → ${r}`);
@@ -1252,11 +1284,11 @@ const totalDrift =
1252
1284
  extCheck.actions.filter(
1253
1285
  (a) => a.action === 'create' || a.action === 'update' || a.action === 'force-update',
1254
1286
  ).length +
1255
- // E3 (#31): unresolved drift/conflict is pending work too — without these the
1256
- // summary printed "up to date" while the exit code was 1 (codex review).
1287
+ // E3 (fix #31): unresolved drift/conflict is pending work too — without these the
1288
+ // summary printed "up to date" while the exit code was 1.
1257
1289
  extCheck.conflicts.length +
1258
1290
  extCheck.drifts.length +
1259
- // E4 (#32): codex-target pending work counts identically (same message/exit
1291
+ // E4 (fix #32): codex-target pending work counts identically (same message/exit
1260
1292
  // consistency the E3 review caught — a codex conflict must not read "up to date").
1261
1293
  (extCheckCodex
1262
1294
  ? extCheckCodex.actions.filter(
@@ -1265,7 +1297,7 @@ const totalDrift =
1265
1297
  extCheckCodex.conflicts.length +
1266
1298
  extCheckCodex.drifts.length
1267
1299
  : 0) +
1268
- // fix #48: codex core mirror counts the same way as the claude side.
1300
+ // codex core mirror counts the same way as the claude side.
1269
1301
  staleHooksCodex.length +
1270
1302
  missingSettingsCodex.length +
1271
1303
  (invalidSettingsCodex ? 1 : 0) +
@@ -1297,7 +1329,7 @@ if (totalDrift === 0) {
1297
1329
 
1298
1330
  console.log(lines.join('\n'));
1299
1331
 
1300
- // E3 (#31): a hard extension conflict blocks even under --apply (unlike ordinary
1332
+ // E3 (fix #31): a hard extension conflict blocks even under --apply (unlike ordinary
1301
1333
  // drift, which only fails check mode). --force-extensions clears the resolvable
1302
1334
  // cases; an unfollowable symlink/non-regular dest still counts and stays exit 1.
1303
1335
  const extBlocked = extCheck.conflicts.length > 0 || (extCheckCodex?.conflicts.length ?? 0) > 0;
@@ -11,7 +11,7 @@ When invoked at the end of a session (or with phrases like "세션 종료", "wra
11
11
 
12
12
  ## What this does
13
13
 
14
- - **Close mode**: walks the 6-step checklist (session-state, project hot.md, root hot.md, session-log, open-questions(변경 시), log.md) and verifies via `crystallize.mjs --check-session-close`same gate the PreCompact hook runs.
14
+ - **Close mode**: walks the checklist (session-state, project hot.md, root hot.md, session-log, open-questions(변경 시), log.md) plus a lint step, then writes via `crystallize.mjs --apply-session-close --payload=<path>` which runs the lint gate automatically, **scoped to the files it writes** (debt elsewhere is a non-blocking notice). `--check-session-close` remains a read-only probe (freshness only). The PreCompact gate runs the same scoped lint, judging the session on the files it touched.
15
15
  - **Synthesis mode**: finds tag clusters (≥ N pages), orphan pages (no outbound `[[wikilinks]]`), and draft / stub pages, then guides consolidation into `pages/syntheses/<topic>.md` with back-links and `index.md` updates.
16
16
 
17
17
  ---
@@ -43,7 +43,18 @@ Show the output verbatim.
43
43
 
44
44
  ## Step 3 — Session-close checklist (if triggered at session end)
45
45
 
46
- If `/hypo:crystallize` was invoked as a session-close action, run through this checklist before synthesizing. Proceed automatically without confirmation unless the user has not said "auto".
46
+ If `/hypo:crystallize` was invoked as a session-close action, run through this checklist before synthesizing. The mechanical checklist items (1–6 below) proceed automatically without confirmation unless the user has not said "auto"; the advisory reflections that precede them (#41~#44) always surface to the user for confirmation — they are recommendations, never auto-actions.
47
+
48
+ ### Advisory reflections (run before the checklist below — advisory only)
49
+
50
+ Surface each of these four to the user first. Every one is **advisory** (ADR 0029 identity guard): the user confirms or declines, and none performs an automatic action, writes a file on its own, or bypasses the mandatory gate.
51
+
52
+ - **Trivial-session check (#44)** — Was this session trivial (a single bug fix, a single-file edit, or Q&A with no durable artifact)? If so, recommend skipping session-close: *"이 세션은 trivial해 보입니다 — session-close를 건너뛸까요?"* A trivial skip is a recommendation, **not** a bypass: it must not mark the session closed, must not run `--mark-session-closed`, and must not claim `/compact` can pass. Any real close still requires all 5 mandatory files.
53
+ - **ADR-candidate check (#41)** — Did this session make an architectural or design decision (a new pattern, a tradeoff, a convention)? If yes, ask whether it warrants an ADR and capture that intent in the session-log entry. If nothing rose to ADR level, record `ADR 없음 — <one-line reason>` in that same session-log entry. **Never auto-write an ADR file** — the session-log note is the only action here.
54
+ - **design-history staleness check (#42)** — If `projects/<name>/design-history.md` exists and this session changed design decisions it does not yet reflect, recommend updating it (W8 lint flags this mechanically; an active-project W8 can also block at PreCompact). If the file does not exist, skip silently — do **not** create it just for this check. Never auto-update it.
55
+ - **Ingest check (#43)** — Did this session consume trustworthy external knowledge (a fetched URL, official docs, or code you verified directly)? If so, recommend running `/hypo:ingest` to capture it under `sources/`. Proceed only on the user's confirmation.
56
+
57
+ When uncertain, surface the question rather than skip it. None of the four blocks the close or writes on its own.
47
58
 
48
59
  1. **session-state.md** — update `projects/<name>/session-state.md` with the next tasks list (what to tackle first next time).
49
60
  2. **hot.md (project)** — update `projects/<name>/hot.md` with a session snapshot: what changed and decisions made. Keep under 500 words. Do not put next-step tasks here; those belong in session-state.md.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.2.1"
4
+ version: "1.3.0"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7
 
@@ -80,6 +80,10 @@ Ask: *"이 작업이 마무리되었나요? 세션을 정리(crystallize)할까
80
80
  2. Update `projects/<name>/hot.md` (what was done, ≤500 words, overwrite)
81
81
  3. Append to `projects/<name>/session-log/YYYY-MM.md` (narrative entry, append-only)
82
82
  4. Update root `hot.md` pointer table + date
83
+ 5. Run `scripts/lint.mjs` and fix errors in files **you** touched — debt in other
84
+ projects / shared pages you did not author is reported as a non-blocking
85
+ notice, not a gate. (The documented `crystallize.mjs --apply-session-close`
86
+ path runs this lint automatically, scoped to the files it writes.)
83
87
 
84
88
  Skip session close for: single bug fix, single-file edit, Q&A only.
85
89