hypomnema 1.6.2 → 1.7.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 +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +18 -1
  11. package/hooks/hypo-auto-commit.mjs +92 -15
  12. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  13. package/hooks/hypo-auto-stage.mjs +41 -1
  14. package/hooks/hypo-close-guard.mjs +246 -0
  15. package/hooks/hypo-cwd-change.mjs +31 -2
  16. package/hooks/hypo-file-watch.mjs +21 -2
  17. package/hooks/hypo-first-prompt.mjs +19 -3
  18. package/hooks/hypo-hot-rebuild.mjs +43 -5
  19. package/hooks/hypo-lookup.mjs +86 -29
  20. package/hooks/hypo-personal-check.mjs +24 -3
  21. package/hooks/hypo-session-record.mjs +2 -3
  22. package/hooks/hypo-session-start.mjs +199 -20
  23. package/hooks/hypo-shared.mjs +2080 -176
  24. package/hooks/proposal-store.mjs +513 -0
  25. package/hooks/version-check.mjs +45 -0
  26. package/package.json +42 -14
  27. package/scripts/capture.mjs +556 -37
  28. package/scripts/crystallize.mjs +751 -110
  29. package/scripts/doctor.mjs +787 -26
  30. package/scripts/feedback-sync.mjs +515 -44
  31. package/scripts/graph.mjs +35 -13
  32. package/scripts/init.mjs +287 -41
  33. package/scripts/lib/extensions.mjs +656 -1
  34. package/scripts/lib/git-hooks-dir.mjs +229 -0
  35. package/scripts/lib/hypo-ignore.mjs +54 -6
  36. package/scripts/lib/hypo-root.mjs +56 -6
  37. package/scripts/lib/page-usage.mjs +15 -2
  38. package/scripts/lib/pkg-json.mjs +40 -0
  39. package/scripts/lib/plugin-detect.mjs +96 -6
  40. package/scripts/lib/project-create.mjs +5 -1
  41. package/scripts/lib/rename-marker.mjs +39 -0
  42. package/scripts/lib/wd-match.mjs +23 -5
  43. package/scripts/lib/wikilink.mjs +32 -6
  44. package/scripts/lint.mjs +103 -5
  45. package/scripts/proposal.mjs +1032 -0
  46. package/scripts/query.mjs +25 -4
  47. package/scripts/rename.mjs +223 -18
  48. package/scripts/resume.mjs +34 -12
  49. package/scripts/stats.mjs +41 -9
  50. package/scripts/uninstall.mjs +141 -6
  51. package/scripts/upgrade.mjs +197 -15
  52. package/skills/crystallize/SKILL.md +44 -7
  53. package/skills/debate/SKILL.md +88 -0
  54. package/skills/debate/references/orchestration-patterns.md +83 -0
  55. package/templates/.hyposcanignore +10 -0
  56. package/templates/SCHEMA.md +12 -0
  57. package/templates/gitignore +9 -0
  58. package/templates/hypo-config.md +1 -1
  59. package/templates/hypo-guide.md +6 -0
  60. package/scripts/.gitkeep +0 -0
  61. package/scripts/check-bilingual.mjs +0 -153
  62. package/scripts/check-readme-version.mjs +0 -126
  63. package/scripts/check-tracker-ids.mjs +0 -426
  64. package/scripts/check-versions.mjs +0 -171
  65. package/scripts/install-git-hooks.mjs +0 -293
  66. package/scripts/lib/changelog-classify.mjs +0 -216
  67. package/scripts/lib/check-bilingual.mjs +0 -244
  68. package/scripts/lib/check-tracker-ids.mjs +0 -217
  69. package/scripts/lib/pre-commit-format.mjs +0 -251
  70. package/scripts/pre-commit-format.mjs +0 -198
@@ -39,6 +39,12 @@ import {
39
39
  CODEX_TYPES,
40
40
  readExtensionPkgStateNoMutate,
41
41
  parseExtKey,
42
+ parseSkillKey,
43
+ parseSkillShaValue,
44
+ isFlatShaValue,
45
+ emptyShaMap,
46
+ isContainedUnder,
47
+ hasSymlinkAncestor,
42
48
  buildHookCommand,
43
49
  } from './lib/extensions.mjs';
44
50
 
@@ -105,8 +111,8 @@ function removeCommands(apply, force) {
105
111
  // map empties out, drop the target key; when the whole `extensions` object empties,
106
112
  // drop it too. Other targets' records (e.g. codex map during a Claude-only uninstall)
107
113
  // are never touched.
108
- function stripExtensionsFromPkg(pkgPath, target, removedKeys, apply) {
109
- if (!existsSync(pkgPath) || removedKeys.length === 0) return false;
114
+ function stripExtensionsFromPkg(pkgPath, target, removedKeys, apply, rewrittenKeys = new Map()) {
115
+ if (!existsSync(pkgPath) || (removedKeys.length === 0 && rewrittenKeys.size === 0)) return false;
110
116
  let pkg;
111
117
  try {
112
118
  pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
@@ -126,6 +132,15 @@ function stripExtensionsFromPkg(pkgPath, target, removedKeys, apply) {
126
132
  changed = true;
127
133
  }
128
134
  }
135
+ // A partially-uninstalled skill keeps only the files it still owns. Leaving the
136
+ // removed paths in the map would let a later --force run delete whatever the user
137
+ // has since put back at those paths.
138
+ for (const [key, nested] of rewrittenKeys) {
139
+ if (key in perTarget) {
140
+ perTarget[key] = nested;
141
+ changed = true;
142
+ }
143
+ }
129
144
  if (Object.keys(perTarget).length === 0) {
130
145
  delete extensions[target];
131
146
  changed = true;
@@ -157,6 +172,46 @@ function stripExtensionsFromPkg(pkgPath, target, removedKeys, apply) {
157
172
  // file whose on-disk SHA differs from the recorded one is user-modified and
158
173
  // preserved unless --force-extensions; a symlink/non-regular target is always
159
174
  // preserved (force does not follow them, matching install/upgrade E3's guard).
175
+ // Remove the directories a skill removal emptied, deepest first, and finally the
176
+ // skill root itself. Anything still holding files (user-added, or copies we
177
+ // refused to remove) is left standing — rmdir on a non-empty directory throws and
178
+ // we swallow it, which is exactly the "never destroy what we don't own" behavior.
179
+ //
180
+ // Every rmdir is guarded by the same containment + symlink-ancestor walk the file
181
+ // removals use. Without it, a corrupt `"skills/x": {}` record plus a symlinked
182
+ // skill dir was enough to rmdir empty directories anywhere on disk: the record
183
+ // granted no file ownership, yet the prune still ran (codex pre-commit BLOCKER).
184
+ function pruneSkillDirs(skillsRoot, skillRoot) {
185
+ if (hasSymlinkAncestor(skillsRoot, skillRoot)) return;
186
+ const dirs = [];
187
+ const collect = (dir, depth) => {
188
+ if (depth > 16) return;
189
+ let entries;
190
+ try {
191
+ entries = readdirSync(dir, { withFileTypes: true });
192
+ } catch {
193
+ return;
194
+ }
195
+ for (const e of entries) {
196
+ if (!e.isDirectory()) continue; // isDirectory() is false for a symlink here
197
+ const full = join(dir, e.name);
198
+ collect(full, depth + 1);
199
+ dirs.push(full);
200
+ }
201
+ };
202
+ collect(skillRoot, 1);
203
+ dirs.push(skillRoot);
204
+ for (const dir of dirs) {
205
+ if (!isContainedUnder(skillsRoot, dir)) continue;
206
+ if (hasSymlinkAncestor(skillsRoot, dir)) continue;
207
+ try {
208
+ rmdirSync(dir);
209
+ } catch {
210
+ // not empty (or gone) — leave it
211
+ }
212
+ }
213
+ }
214
+
160
215
  function removeExtensions(target, apply, force) {
161
216
  const targetRoot = target === 'codex' ? join(HOME, '.codex') : join(HOME, '.claude');
162
217
  const types = target === 'codex' ? CODEX_TYPES : EXT_TYPES;
@@ -167,11 +222,77 @@ function removeExtensions(target, apply, force) {
167
222
 
168
223
  const removed = [];
169
224
  const removedKeys = [];
225
+ // Skill keys that survive in reduced form: key → the nested map of files we still
226
+ // own after this run.
227
+ const rewrittenKeys = new Map();
170
228
  const skippedUserModified = [];
171
229
  const skippedNonRegular = [];
172
230
 
173
231
  for (const [key, recordedSHA] of Object.entries(recorded)) {
174
232
  if (!recordedSHA) continue; // no ownership baseline → nothing to remove
233
+
234
+ // A directory skill records one key (`skills/<name>`) whose value is a map of
235
+ // per-file SHAs. parseExtKey rejects that key by design (it demands the type's
236
+ // file extension), so without this branch every installed skill file would be
237
+ // silently stranded: owned on paper, unreachable by uninstall.
238
+ const skillKey = types.includes('skills') ? parseSkillKey(key) : null;
239
+ if (skillKey) {
240
+ const nested = parseSkillShaValue(recordedSHA);
241
+ // A corrupt value grants no ownership: remove nothing, and do NOT prune —
242
+ // pruning on an empty/invalid record is destructive power the record never
243
+ // earned.
244
+ if (!nested || Object.keys(nested).length === 0) continue;
245
+ const skillsRoot = join(targetRoot, 'skills');
246
+ const skillRoot = join(skillsRoot, skillKey.installDir);
247
+ // What the record must still claim after this run. A file we removed drops
248
+ // out; a file we preserved stays. Keeping a removed path in the record is not
249
+ // cosmetic: a later --force run deletes whatever now sits at that path,
250
+ // including a brand-new file the user created there (codex fix-verify BLOCKER).
251
+ const remaining = emptyShaMap();
252
+ let removedAny = false;
253
+ const preserve = (rel, sha, path, bucket) => {
254
+ remaining[rel] = sha;
255
+ bucket.push(path);
256
+ };
257
+ for (const [rel, sha] of Object.entries(nested)) {
258
+ const fullPath = join(skillRoot, ...rel.split('/'));
259
+ // Same containment + symlink-ancestor guard the sync path uses: a lexical
260
+ // check alone cannot see a symlinked directory in the middle of the path.
261
+ if (!isContainedUnder(skillRoot, fullPath) || hasSymlinkAncestor(skillsRoot, fullPath)) {
262
+ preserve(rel, sha, fullPath, skippedNonRegular);
263
+ continue;
264
+ }
265
+ if (!existsSync(fullPath)) continue; // already gone: the claim goes too
266
+ if (!isRegularFile(fullPath)) {
267
+ preserve(rel, sha, fullPath, skippedNonRegular);
268
+ continue;
269
+ }
270
+ const buf = readFileIfRegular(fullPath);
271
+ const fileSha = buf ? sha256(buf) : null;
272
+ if (fileSha === sha || force) {
273
+ if (apply) rmSync(fullPath);
274
+ removed.push(fullPath);
275
+ removedAny = true;
276
+ } else {
277
+ preserve(rel, sha, fullPath, skippedUserModified);
278
+ }
279
+ }
280
+ // Prune the directories we emptied, deepest first. A skill dir still holding
281
+ // user files (or files we refused to remove) is left in place.
282
+ if (apply && removedAny) pruneSkillDirs(skillsRoot, skillRoot);
283
+ // Retire the key outright only when nothing is left to claim. Otherwise rewrite
284
+ // it down to just the preserved files: dropping the whole key would disown them
285
+ // forever, and keeping the whole key would leave stale claims on paths we no
286
+ // longer own.
287
+ if (Object.keys(remaining).length === 0) removedKeys.push(key);
288
+ else rewrittenKeys.set(key, remaining);
289
+ continue;
290
+ }
291
+
292
+ // A flat key's value must be a plain hex SHA. Anything else (an object parked
293
+ // there by a corrupt pkg-json) grants no ownership — otherwise --force would
294
+ // remove the file on the strength of a value we never wrote.
295
+ if (!isFlatShaValue(recordedSHA)) continue;
175
296
  const parsed = parseExtKey(key, types);
176
297
  if (!parsed) continue; // untrusted / cross-target key → never join+rm
177
298
  const fullPath = join(targetRoot, parsed.type, parsed.installFile);
@@ -193,7 +314,7 @@ function removeExtensions(target, apply, force) {
193
314
  skippedUserModified.push(fullPath);
194
315
  }
195
316
  }
196
- return { target, removed, removedKeys, skippedUserModified, skippedNonRegular };
317
+ return { target, removed, removedKeys, rewrittenKeys, skippedUserModified, skippedNonRegular };
197
318
  }
198
319
 
199
320
  // True iff `pkg.json` still holds extensions state for a target the current
@@ -478,6 +599,7 @@ let codexExtResult = {
478
599
  target: 'codex',
479
600
  removed: [],
480
601
  removedKeys: [],
602
+ rewrittenKeys: new Map(),
481
603
  skippedUserModified: [],
482
604
  skippedNonRegular: [],
483
605
  };
@@ -497,10 +619,23 @@ if (args.codex) {
497
619
  );
498
620
  }
499
621
 
500
- // Surgical per-target ext SHA strip — only for files we actually removed.
501
- stripExtensionsFromPkg(pkgJsonPath, 'claude', claudeExtResult.removedKeys, args.apply);
622
+ // Surgical per-target ext SHA strip — only for files we actually removed. A skill
623
+ // we uninstalled only partway is rewritten down to the files we still own.
624
+ stripExtensionsFromPkg(
625
+ pkgJsonPath,
626
+ 'claude',
627
+ claudeExtResult.removedKeys,
628
+ args.apply,
629
+ claudeExtResult.rewrittenKeys,
630
+ );
502
631
  if (args.codex) {
503
- stripExtensionsFromPkg(pkgJsonPath, 'codex', codexExtResult.removedKeys, args.apply);
632
+ stripExtensionsFromPkg(
633
+ pkgJsonPath,
634
+ 'codex',
635
+ codexExtResult.removedKeys,
636
+ args.apply,
637
+ codexExtResult.rewrittenKeys,
638
+ );
504
639
  }
505
640
 
506
641
  // pkg.json metadata file removal — only when no user-tracked state remains.
@@ -49,9 +49,11 @@ import {
49
49
  sha256 as sha256Buf,
50
50
  isRegularFile,
51
51
  readFileIfRegular,
52
+ readVersionAtRoot,
53
+ writeDualSkipProvenance,
52
54
  } from './lib/pkg-json.mjs';
53
55
  import { syncExtensions } from './lib/extensions.mjs';
54
- import { isHypomnemaPluginEnabled } from './lib/plugin-detect.mjs';
56
+ import { isHypomnemaPluginEnabled, resolveEnabledPluginRoot } from './lib/plugin-detect.mjs';
55
57
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
56
58
 
57
59
  const HOME = homedir();
@@ -213,26 +215,58 @@ const SHARED_FILES = _hookConfig.shared ?? [];
213
215
 
214
216
  // ── checks ───────────────────────────────────────────────────────────────────
215
217
 
216
- function checkSchemaVersion(hypoDir) {
217
- const pkgPath = join(TEMPLATES, 'SCHEMA.md');
218
- const hypoPath = join(hypoDir, 'SCHEMA.md');
218
+ // Shared by SCHEMA.md and hypo-guide.md: both templates carry a top-level
219
+ // `version:` frontmatter stamp, and both are compared the same way — installed
220
+ // copy vs. the package's own template — without ever writing to hypoPath. This
221
+ // function is read-only; nothing downstream may use its return value to
222
+ // overwrite the installed file (hand-authored wiki files are never
223
+ // silently clobbered).
224
+ function checkTemplateVersion(hypoDir, filename) {
225
+ const pkgPath = join(TEMPLATES, filename);
226
+ const hypoPath = join(hypoDir, filename);
219
227
 
220
228
  const pkgVersion = existsSync(pkgPath)
221
229
  ? parseVersion((parseFrontmatter(readFileSync(pkgPath, 'utf-8')) ?? {}).version)
222
230
  : null;
223
- const hypoVersion = existsSync(hypoPath)
231
+
232
+ const hypoExists = existsSync(hypoPath);
233
+ const hypoVersion = hypoExists
224
234
  ? parseVersion((parseFrontmatter(readFileSync(hypoPath, 'utf-8')) ?? {}).version)
225
235
  : null;
226
236
 
237
+ // File present but carries no `version:` frontmatter field at all — a
238
+ // pre-versioning install (the file predates this stamp existing). This is
239
+ // deliberately distinguished from "file missing entirely" (still routed to
240
+ // bumpType's 'unknown' below): a bare `bumpType(null, pkgVersion)` cannot
241
+ // tell the two apart, and folding this case into plain 'unknown' is exactly
242
+ // what let an already-installed, unstamped hypo-guide.md report "up to
243
+ // date" — the file this check exists to catch. The caller decides whether
244
+ // 'unstamped' counts as drift; this helper only classifies.
245
+ const unstamped = hypoExists && !hypoVersion && !!pkgVersion;
246
+
227
247
  return {
228
248
  installed: hypoVersion?.raw ?? null,
229
249
  current: pkgVersion?.raw ?? null,
230
- bump: bumpType(hypoVersion, pkgVersion),
250
+ bump: unstamped ? 'unstamped' : bumpType(hypoVersion, pkgVersion),
231
251
  hypoPath,
232
252
  pkgPath,
233
253
  };
234
254
  }
235
255
 
256
+ function checkSchemaVersion(hypoDir) {
257
+ return checkTemplateVersion(hypoDir, 'SCHEMA.md');
258
+ }
259
+
260
+ // hypo-guide.md had no update channel at all — upgrade.mjs never
261
+ // looked at it, and init.mjs only ever writes it once (skip if present), so an
262
+ // installed vault's copy went permanently stale the moment the shipped template
263
+ // changed, with no signal to the user. This check restores visibility (a warning
264
+ // on drift) without adding a write path: --apply still never touches an
265
+ // installed hypo-guide.md, exactly like SCHEMA.md's existing Option C contract.
266
+ function checkGuideVersion(hypoDir) {
267
+ return checkTemplateVersion(hypoDir, 'hypo-guide.md');
268
+ }
269
+
236
270
  // Target-aware: the same core-hook integrity check runs against ~/.claude/hooks/
237
271
  // for the default claude target, and against ~/.codex/hooks/ when --codex is set
238
272
  // The function reads only (apply happens in applyHookFiles).
@@ -479,6 +513,11 @@ const GITIGNORE_REQUIRED_ENTRIES = [
479
513
  pattern: '.cache/',
480
514
  comment: '# Hypomnema runtime cache (page-usage log, session growth metrics)',
481
515
  },
516
+ {
517
+ pattern: '*.lock',
518
+ comment:
519
+ '# Append-lock files (transient <target>.lock; a crashed holder can leave a stale one)',
520
+ },
482
521
  ];
483
522
 
484
523
  function checkGitignore(hypoDir) {
@@ -836,6 +875,15 @@ function writePluginModeMetadata() {
836
875
  return true;
837
876
  }
838
877
 
878
+ // readVersionAtRoot and writeDualSkipProvenance now live in ./lib/pkg-json.mjs
879
+ // (imported above) — writeDualSkipProvenance is deliberately not
880
+ // writePluginModeMetadata(): that function hardcodes THIS npm run's
881
+ // PKG_ROOT/PKG_VERSION and always drops `commands` — both correct for true
882
+ // plugin mode, both wrong for a dualSkip correction, where the identity being
883
+ // written is the OTHER install's. Moving both into lib/pkg-json.mjs (alongside
884
+ // the other pkgJsonPath-taking helpers already there) also makes the writer's
885
+ // TOCTOU refusal directly unit-testable without spawning the whole script.
886
+
839
887
  // ── main ─────────────────────────────────────────────────────────────────────
840
888
 
841
889
  const args = parseArgs(process.argv);
@@ -886,9 +934,41 @@ const managesClaudeCore = !pluginMode && (!dualInstallCoreConflict || args.allow
886
934
  const dualSkip = dualInstallCoreConflict && !args.allowDualInstall;
887
935
 
888
936
  const schema = checkSchemaVersion(args.hypoDir);
937
+ const guide = checkGuideVersion(args.hypoDir);
889
938
  const hooks = checkHookFiles(claudeHooksDir);
890
939
  const settings = checkSettingsJson(claudeSettingsPath, claudeHooksDir);
891
940
  const pkgJson = checkPkgJson();
941
+ // dualSkip provenance self-heal: the registry root the enabled plugin key
942
+ // POSITIVELY resolves to (same lookup init.mjs's resolveDurableRoot uses), or
943
+ // null when the registry is absent/unreadable/corrupt or names no usable entry
944
+ // for the enabled key. Computed unconditionally (fail-open on any uncertainty —
945
+ // see lib/plugin-detect.mjs) so both the --apply write below and the read-only
946
+ // report can agree on the same positively-resolved identity without a second
947
+ // registry read racing the first.
948
+ const dualSkipRegistryRoot = dualSkip
949
+ ? resolveEnabledPluginRoot(
950
+ claudeSettingsPath,
951
+ join(HOME, '.claude', 'plugins', 'installed_plugins.json'),
952
+ )
953
+ : null;
954
+ // The recorded pkgRoot currently on disk, read non-mutatingly (readPkgJsonSafe
955
+ // would rename aside a corrupt file — a write — before this script has decided
956
+ // whether to touch anything).
957
+ const dualSkipRecordedRoot = (() => {
958
+ try {
959
+ const v = JSON.parse(readFileSync(pkgJsonPath(), 'utf-8')).pkgRoot;
960
+ return typeof v === 'string' ? v : null;
961
+ } catch {
962
+ return null;
963
+ }
964
+ })();
965
+ // A CONDITIONAL self-heal, never an unconditional rewrite: correct only when the
966
+ // registry positively resolves a usable root that DIFFERS from what's already
967
+ // recorded. An unresolvable registry (null) must PRESERVE the existing pointer,
968
+ // not blank it or fall back to this npm PKG_ROOT — a null here is "cannot prove
969
+ // a correction is needed", not "there is nothing to preserve".
970
+ const dualSkipWouldCorrect =
971
+ dualSkip && dualSkipRegistryRoot !== null && dualSkipRegistryRoot !== dualSkipRecordedRoot;
892
972
  const commands = checkCommands();
893
973
  const oldHookRefs = checkOldHookNames(claudeSettingsPath);
894
974
  const hypoignore = checkHypoignore(args.hypoDir);
@@ -948,15 +1028,41 @@ const missingSettingsCodex = settingsCodex
948
1028
  const invalidSettingsCodex = settingsCodex
949
1029
  ? settingsCodex.some((s) => s.status === 'invalid-json')
950
1030
  : false;
951
- const schemaDrift = schema.bump !== 'none' && schema.bump !== 'unknown' && schema.bump !== 'ahead';
952
- // Dual-install: when core is skipped, hypo-pkg.json is deliberately left
953
- // pointing at the PLUGIN's package root (preserved identity), so checkPkgJson()
954
- // reports it 'stale' relative to this npm/manual PKG_ROOT. That mismatch is
955
- // INTENTIONAL — `--apply` will not (and must not) rewrite it — so it must not
956
- // count as actionable drift, or the user would be nagged to run --apply forever.
957
- // A genuinely missing/corrupt file (status 'missing') is still surfaced (warning
1031
+ // SCHEMA.md keeps its pre-existing behavior: 'unstamped' is deliberately NOT
1032
+ // counted as drift here (SCHEMA.md is user-owned vocabulary, Option C — an
1033
+ // unstamped SCHEMA.md was already the "cannot compare" case before this
1034
+ // classification existed, and no SCHEMA test exercises it as actionable).
1035
+ const schemaDrift =
1036
+ schema.bump !== 'none' &&
1037
+ schema.bump !== 'unknown' &&
1038
+ schema.bump !== 'ahead' &&
1039
+ schema.bump !== 'unstamped';
1040
+ // hypo-guide.md: 'unstamped' DOES count as drift — this is the exact shape of
1041
+ // an already-installed pre-versioning vault, the population this change targets.
1042
+ // Excluding it (as the old unknown-only check did) is the bug this fixes.
1043
+ const guideDrift = guide.bump !== 'none' && guide.bump !== 'unknown' && guide.bump !== 'ahead';
1044
+ // Dual-install: when core is skipped, hypo-pkg.json is normally left pointing
1045
+ // at the PLUGIN's package root (preserved identity), so checkPkgJson() reports
1046
+ // it 'stale' relative to this npm/manual PKG_ROOT. That mismatch is USUALLY
1047
+ // intentional — `--apply` will not rewrite an already-correct plugin pointer
1048
+ // — so it must not count as actionable drift by default, or the user would be
1049
+ // nagged to run --apply forever. The one exception is dualSkipWouldCorrect: an
1050
+ // npm-first divergence where the recorded pointer differs from what the plugin
1051
+ // registry POSITIVELY resolves (e.g. `npm init`, enable the plugin, `npm init`
1052
+ // again — the npm root stays recorded forever otherwise). THAT case IS
1053
+ // actionable — `--apply` corrects it via writeDualSkipProvenance — so it must
1054
+ // count as drift, or the user has no signal to ever run --apply. dualSkipWouldCorrect
1055
+ // is checked FIRST and unconditionally (not gated behind `pkgJson.status !==
1056
+ // 'up-to-date'`): checkPkgJson() compares the recorded pointer only against
1057
+ // THIS run's own PKG_ROOT, so an npm-first divergence where the recorded
1058
+ // pointer happens to equal PKG_ROOT (but the registry resolves a DIFFERENT
1059
+ // plugin root) would read 'up-to-date' and slip past that status check
1060
+ // entirely — leaving a correctable divergence permanently unreported. A
1061
+ // genuinely missing/corrupt file (status 'missing') is still surfaced (warning
958
1062
  // below), because the runtime then cannot resolve its package root at all.
959
- const pkgJsonDrift = pkgJson.status !== 'up-to-date' && !(dualSkip && pkgJson.status === 'stale');
1063
+ const pkgJsonDrift =
1064
+ dualSkipWouldCorrect ||
1065
+ (pkgJson.status !== 'up-to-date' && !(dualSkip && pkgJson.status === 'stale'));
960
1066
  const staleCommands = commands.filter((c) => c.status === 'stale' || c.status === 'missing');
961
1067
  const userModifiedCommands = commands.filter((c) => c.status === 'user-modified');
962
1068
  const orphanedCommands = commands.filter((c) => c.status === 'orphaned');
@@ -968,6 +1074,11 @@ let migrationPath = null;
968
1074
  let appliedHooks = [];
969
1075
  let appliedSettings = [];
970
1076
  let appliedPkgJson = false;
1077
+ // True only once the dualSkip provenance self-heal actually WROTE a correction
1078
+ // this run (a positively-resolved registry root that differed from the
1079
+ // recorded one). Distinct from dualSkipWouldCorrect (a pre-apply preview
1080
+ // computed above), which stays true even in a dry run.
1081
+ let dualSkipCorrected = false;
971
1082
  let appliedHookNameRenames = [];
972
1083
  let appliedHooksCodex = [];
973
1084
  let appliedSettingsCodex = [];
@@ -1054,6 +1165,20 @@ if (args.apply) {
1054
1165
  // user to resolve the dual install.
1055
1166
  if (pkgJson.status === 'missing') {
1056
1167
  appliedPkgJson = writePluginModeMetadata();
1168
+ } else if (dualSkipWouldCorrect) {
1169
+ // npm-first correction: a usable pkgRoot IS already recorded (status
1170
+ // 'stale' or 'up-to-date'), but the registry positively resolves a
1171
+ // DIFFERENT usable root — e.g. `npm init`, then enable the plugin, then
1172
+ // `npm init` again, leaving the npm root recorded forever. Correct the
1173
+ // provenance to the registry's own root/version. When the registry
1174
+ // cannot positively resolve one (dualSkipWouldCorrect is false — null
1175
+ // registry lookup, or it already matches what's recorded), fall through
1176
+ // to preserving the existing pointer untouched; never blank it.
1177
+ // writeDualSkipProvenance can still refuse here (TOCTOU: the registry
1178
+ // root's own package.json became unreadable between resolution and this
1179
+ // write) — dualSkipCorrected only follows an ACTUAL write, never assume it.
1180
+ appliedPkgJson = writeDualSkipProvenance(pkgJsonPath(), dualSkipRegistryRoot);
1181
+ dualSkipCorrected = appliedPkgJson;
1057
1182
  } else {
1058
1183
  appliedPkgJson = false;
1059
1184
  }
@@ -1134,6 +1259,7 @@ const hasDrift =
1134
1259
  (managesClaudeCore && claudeCoreDrift) ||
1135
1260
  pkgJsonDrift ||
1136
1261
  schemaDrift ||
1262
+ guideDrift ||
1137
1263
  hypoignore.status === 'needs-migration' ||
1138
1264
  gitignore.status === 'needs-migration' ||
1139
1265
  extDrift ||
@@ -1150,6 +1276,7 @@ if (args.json) {
1150
1276
  coreManagedBy: managesClaudeCore ? 'self' : pluginMode ? 'plugin' : 'plugin-enabled',
1151
1277
  dualInstallOverride: args.allowDualInstall,
1152
1278
  schema,
1279
+ guide,
1153
1280
  hooks,
1154
1281
  settings,
1155
1282
  pkgJson,
@@ -1249,6 +1376,12 @@ if (schema.bump === 'none') {
1249
1376
  lines.push(
1250
1377
  `⚠ SCHEMA version ${schema.installed} (installed is ahead of package ${schema.current})`,
1251
1378
  );
1379
+ } else if (schema.bump === 'unstamped') {
1380
+ // Not counted as drift for SCHEMA.md (Option C, unchanged) — still surfaced
1381
+ // as advisory-only visibility, same spirit as the pre-existing 'unknown' text.
1382
+ lines.push(
1383
+ `⚠ SCHEMA version installed has no version stamp (pre-versioning copy), package=${schema.current} (cannot compare automatically)`,
1384
+ );
1252
1385
  } else if (schema.bump === 'major') {
1253
1386
  lines.push(
1254
1387
  `✗ SCHEMA version ${schema.installed} → ${schema.current} [MAJOR — --apply writes MIGRATION report; SCHEMA must be merged manually]`,
@@ -1259,6 +1392,33 @@ if (schema.bump === 'none') {
1259
1392
  );
1260
1393
  }
1261
1394
 
1395
+ // hypo-guide.md version stamp. Visibility only — a drift warning,
1396
+ // never a write: --apply must not overwrite an installed hypo-guide.md, since a
1397
+ // user may have customized it (same Option C contract as SCHEMA.md above).
1398
+ if (guide.bump === 'none') {
1399
+ lines.push(`✓ hypo-guide.md v${guide.installed} (up to date)`);
1400
+ } else if (guide.bump === 'unknown') {
1401
+ lines.push(
1402
+ `⚠ hypo-guide.md installed=${guide.installed ?? 'not found (no version stamp)'}, package=${guide.current ?? 'not found'} (cannot compare)`,
1403
+ );
1404
+ } else if (guide.bump === 'ahead') {
1405
+ lines.push(
1406
+ `⚠ hypo-guide.md v${guide.installed} (installed is ahead of package v${guide.current})`,
1407
+ );
1408
+ } else if (guide.bump === 'unstamped') {
1409
+ // The target case: the file exists (this is not a fresh/missing
1410
+ // install) but predates the version stamp entirely. Counted as drift above
1411
+ // (guideDrift) — the message must be actionable, not "cannot compare",
1412
+ // since a pre-versioning copy silently reporting "up to date" was the bug.
1413
+ lines.push(
1414
+ `⚠ hypo-guide.md installed copy has no version stamp (pre-versioning stale copy; package=v${guide.current}) — review templates/hypo-guide.md and update your copy manually; --apply does not overwrite it`,
1415
+ );
1416
+ } else {
1417
+ lines.push(
1418
+ `⚠ hypo-guide.md v${guide.installed} → v${guide.current} [package template changed — review templates/hypo-guide.md and update your copy manually; --apply does not overwrite it]`,
1419
+ );
1420
+ }
1421
+
1262
1422
  // Hook files (target-aware so --codex can mirror the same block).
1263
1423
  function pushHookSummary(hookList, label, targetPath) {
1264
1424
  const colHook = `Hook files${label}`.padEnd(20);
@@ -1318,7 +1478,28 @@ if (managesClaudeCore) {
1318
1478
  if (settingsCodex) pushSettingsSummary(settingsCodex, ' (codex)', invalidSettingsCodex);
1319
1479
 
1320
1480
  // Package metadata
1321
- if (dualSkip && pkgJson.status === 'stale') {
1481
+ if (dualSkip && dualSkipCorrected) {
1482
+ // npm-first correction actually WROTE this run: the pre-apply pkgJson
1483
+ // classification above was computed against the running npm PKG_ROOT, so
1484
+ // without this branch a corrected pointer would still be reported as
1485
+ // "preserved — not rewritten" (or "up to date"/"stale") below, contradicting
1486
+ // what --apply just did. Report the explicit outcome instead, with the
1487
+ // registry package's own version — the identity now on disk.
1488
+ const correctedVersion = readVersionAtRoot(dualSkipRegistryRoot);
1489
+ lines.push(
1490
+ `✓ Package metadata hypo-pkg.json corrected to enabled plugin registry root` +
1491
+ (correctedVersion ? ` (v${correctedVersion})` : ''),
1492
+ );
1493
+ } else if (dualSkip && dualSkipWouldCorrect) {
1494
+ // Same divergence, but this was a dry run (no --apply): preview what
1495
+ // --apply would do rather than claiming the identity is already preserved.
1496
+ const previewVersion = readVersionAtRoot(dualSkipRegistryRoot);
1497
+ lines.push(
1498
+ `⚠ Package metadata hypo-pkg.json points at a stale npm/manual root — \`--apply\` will` +
1499
+ ` correct it to the enabled plugin registry root` +
1500
+ (previewVersion ? ` (v${previewVersion})` : ''),
1501
+ );
1502
+ } else if (dualSkip && pkgJson.status === 'stale') {
1322
1503
  // Dual-install: the 'stale' here is the preserved plugin identity (pkgRoot points at
1323
1504
  // the plugin, not this npm/manual copy). That is intentional — not actionable.
1324
1505
  lines.push(
@@ -1572,6 +1753,7 @@ const totalDrift =
1572
1753
  claudeCoreCount +
1573
1754
  (pkgJsonDrift ? 1 : 0) +
1574
1755
  (schemaDrift ? 1 : 0) +
1756
+ (guideDrift ? 1 : 0) +
1575
1757
  (hypoignore.status === 'needs-migration' ? hypoignore.missing.length : 0) +
1576
1758
  (gitignore.status === 'needs-migration' ? gitignore.missing.length : 0) +
1577
1759
  extCheck.actions.filter(
@@ -11,7 +11,7 @@ When invoked to close a session — via an explicit close signal ("세션 종료
11
11
 
12
12
  ## What this does
13
13
 
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 `/hypo:crystallize` in `--apply-session-close --payload=<path>` mode — which runs the lint gate automatically, **scoped to the files it writes** (debt elsewhere is a non-blocking notice). `--check-session-close` is a read-only dry-run of the **full** PreCompact gate: close files + scoped lint + design-history + feedback projection, sharing one function (`precompactGateStatus`) with the gate. A green check means no gate blocker needs a human fix, so it is the signal to declare the session closed (pass `--transcript-path` to widen the lint scope to this session's edited files exactly as the interactive hook does). It is not a hard guarantee: the live `/compact` can still differ on a context-≥70% prompt, `HYPO_SKIP_GATE`, or a transcript-scoped lint error the check did not see.
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 `/hypo:crystallize` in `--apply-session-close --payload=<path> --session-id=<id>` mode, which runs the lint gate automatically, **scoped to the files it writes** (debt elsewhere is a non-blocking notice). **`--session-id` is required.** Before writing a byte, the apply resolves that session's transcript and refuses the whole close, exit 1 and nothing on disk, unless the **user** asked for it (`session-id-required` / `transcript-unresolved` / `no-user-close-signal`). A refusal is the answer, not an obstacle: ask the user, and re-run only if they say yes. `--check-session-close` is a read-only dry-run of the **full** PreCompact gate: close files + scoped lint + design-history + feedback projection, sharing one function (`precompactGateStatus`) with the gate. A green check means no gate blocker needs a human fix, so it is the signal to declare the session closed (pass `--transcript-path` to widen the lint scope to this session's edited files exactly as the interactive hook does). It is not a hard guarantee: the live `/compact` can still differ on a context-≥70% prompt, `HYPO_SKIP_GATE`, or a transcript-scoped lint error the check did not see.
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
  ---
@@ -102,15 +102,52 @@ Once it passes, report each item with ✓:
102
102
  - ✓ session-log entry
103
103
  - ✓ open-questions (or skipped if unchanged)
104
104
  - ✓ log.md entry
105
- - **marker written?** (required, if `--apply-session-close --session-id` was used): check `markerWritten` in the JSON output. If `true`, report "session-close marker written." If `false`, do NOT declare the session closed or complete; recover per the `markerSkipReason` branch below.
105
+ - **marker written?** (required): check `markerWritten` in the JSON output. If `true`, report "session-close marker written." If `false`, do NOT declare the session closed or complete; recover per the branches below.
106
106
 
107
- If `markerWritten: true` (or `--session-id` was not passed), ask: "Session closed. Would you like to also run knowledge synthesis now, or stop here?"
107
+ If `markerWritten: true`, ask: "Session closed. Would you like to also run knowledge synthesis now, or stop here?"
108
108
 
109
- If `markerWritten: false`, do NOT say "session closed." Branch on `markerSkipReason`:
109
+ **If `ok: false` with an authority `reason`, the close did not happen at all.** No file was written, nothing was committed. Do not report a partial close, and do not write the files by hand to get around it.
110
110
 
111
- - `no-user-close-signal`: files applied cleanly and the transcript resolved, but it carries no close phrase the gate recognizes (the user's close wording fell outside the close-signal set, e.g. "세션 마무리까지 진행해줘" / "세션 마무리 진행"). Re-running the same id will not help. Confirm intent once with `AskUserQuestion` (header "세션", one option **세션 마무리**); if the user picks it, that answer is a recognized close signal, so re-run the same `--apply-session-close … --session-id` command (idempotent no-op writes; the marker lands). If the user declines, leave the session unmarked. Do NOT change the close-signal matcher.
112
- - `transcript-unresolved` (wrong / background id): report "Files applied and verified (ok: true), but the marker was not written (reason: `<markerSkipReason>`). The Stop-chain is still active. Re-run with the correct main-conversation `--session-id`."
113
- - any other reason (`compact-gate-not-ok`, `commit-failed: …`, `marker-did-not-land`): surface it verbatim and resolve the underlying blocker before re-running.
111
+ - `session-id-required`: `--session-id` was omitted. Pass the main conversation's id and re-run.
112
+ - `transcript-unresolved`: the id resolved no transcript, so it is most likely a background-task or Agent-thread uuid rather than the main conversation's. Get the right one and re-run.
113
+ - `no-user-close-signal`: the transcript is this session's, and the user never asked to close in wording the gate recognizes (e.g. "세션 마무리까지 진행해줘" falls outside the close-signal set). Re-running the same id changes nothing. Confirm intent once with `AskUserQuestion` (header "세션", one option **세션 마무리**); if the user picks it, that answer becomes a recognized close signal, so the same command now applies everything: writes, commit, and marker. If the user declines, the session stays open and nothing is written. Do NOT change the close-signal matcher.
114
+
115
+ **If `ok: false` with `stage: "session-id-mismatch"`,** the payload carries a `sessionId` that is not the `--session-id` you passed, so this file was authored for a different session (a reused or overwritten temp path). Nothing was written. Do not force it: rebuild the payload for THIS session (write it to a session-scoped path, e.g. `/tmp/hypo-session-close-<session-id>.json`) and re-run.
116
+
117
+ If the apply succeeded but `markerWritten: false`, branch on `markerSkipReason` (`compact-gate-not-ok`, `commit-failed: …`, `marker-did-not-land`): surface it verbatim and resolve the underlying blocker before re-running.
118
+
119
+ ### If the close came back `ok: false, stage: "proposal-pending"`
120
+
121
+ A concurrent session wrote one of the pages this close wants to replace, so the close
122
+ withheld the bytes rather than clobber that session's work, and parked them. Nothing is
123
+ lost and nothing is broken. The close is waiting on a decision only the user makes:
124
+ whether to replace the other session's bytes.
125
+
126
+ Do NOT write the pages with the Write tool and mark the session closed. That path skips
127
+ the store entirely and silently destroys the other session's edits. Drive it out properly:
128
+
129
+ 1. **Look at `proposals`.** If the array is EMPTY, no page was parked. The only conflicts
130
+ were append-lock timeouts, which the next close re-reads and re-appends by itself. Just
131
+ re-run the same close command.
132
+ 2. **`hypomnema proposal challenge --session-id <id> --ids <id,...>`** with the ids from
133
+ `proposals`. It re-reads each page from disk and prints the diff between what is there
134
+ now and what this close wants to write.
135
+ 3. **Show the user those diffs** and tell them plainly what would be overwritten. Then give
136
+ them the approval line the command printed, and ask them to type it:
137
+
138
+ apply-proposals <nonce>
139
+
140
+ They have to TYPE it. A click on `AskUserQuestion` does not approve an overwrite (you
141
+ author the option labels, so a click cannot prove they approved this specific write),
142
+ and the command refuses it. If they would rather keep the other session's page, run
143
+ `hypomnema proposal discard <id>` instead and re-run the close.
144
+ 4. **`hypomnema proposal resolve --session-id <id>`** once they have typed it. It verifies
145
+ the approval in the transcript, checks the pages have not moved since they saw the diff,
146
+ and writes them.
147
+ 5. **Re-run the close.** `resolve` writes the pages; it does NOT close the session. The
148
+ re-run skips what already landed and finishes the close.
149
+ 6. **Check `markerWritten` again** before you say the session is closed. It is never
150
+ automatic.
114
151
 
115
152
  ---
116
153