hypomnema 1.7.4 → 1.8.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 (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +1202 -0
  4. package/README.ko.md +4 -4
  5. package/README.md +5 -5
  6. package/commands/audit.md +1 -1
  7. package/commands/capture.md +1 -1
  8. package/commands/crystallize.md +3 -3
  9. package/commands/doctor.md +15 -1
  10. package/commands/feedback.md +1 -1
  11. package/commands/graph.md +1 -1
  12. package/commands/ingest.md +1 -1
  13. package/commands/init.md +2 -2
  14. package/commands/lint.md +1 -1
  15. package/commands/query.md +1 -1
  16. package/commands/rename.md +1 -1
  17. package/commands/resume.md +1 -1
  18. package/commands/stats.md +1 -1
  19. package/commands/uninstall.md +1 -1
  20. package/commands/upgrade.md +2 -2
  21. package/commands/verify.md +1 -1
  22. package/docs/ARCHITECTURE.md +8 -3
  23. package/docs/CONTRIBUTING.md +2 -1
  24. package/hooks/base-store.mjs +198 -0
  25. package/hooks/close-gate-store.mjs +3 -2
  26. package/hooks/hypo-auto-minimal-crystallize.mjs +10 -0
  27. package/hooks/hypo-compact-guard.mjs +5 -3
  28. package/hooks/hypo-cwd-change.mjs +8 -0
  29. package/hooks/hypo-personal-check.mjs +127 -67
  30. package/hooks/hypo-session-start.mjs +91 -10
  31. package/hooks/hypo-shared.mjs +241 -19
  32. package/hooks/version-check.mjs +44 -6
  33. package/package.json +9 -3
  34. package/scripts/capture.mjs +49 -17
  35. package/scripts/crystallize.mjs +20 -2092
  36. package/scripts/doctor.mjs +334 -14
  37. package/scripts/init.mjs +106 -81
  38. package/scripts/lib/crystallize-args.mjs +50 -0
  39. package/scripts/lib/crystallize-close-apply.mjs +1830 -0
  40. package/scripts/lib/crystallize-close-check.mjs +238 -0
  41. package/scripts/lib/crystallize-close-gate.mjs +58 -0
  42. package/scripts/lib/crystallize-helpers.mjs +55 -0
  43. package/scripts/lib/extensions.mjs +175 -49
  44. package/scripts/lib/git-hooks-dir.mjs +88 -3
  45. package/scripts/lib/plugin-detect.mjs +176 -21
  46. package/scripts/lint.mjs +12 -6
  47. package/scripts/proposal.mjs +3 -3
  48. package/scripts/upgrade.mjs +243 -20
  49. package/skills/crystallize/SKILL.md +15 -13
  50. package/skills/graph/SKILL.md +3 -3
  51. package/skills/ingest/SKILL.md +2 -2
  52. package/skills/lint/SKILL.md +3 -3
  53. package/skills/query/SKILL.md +3 -3
  54. package/skills/verify/SKILL.md +3 -3
  55. package/templates/hypo-automation.md +59 -17
  56. package/templates/hypo-config.md +1 -1
  57. package/templates/hypo-guide.md +12 -9
  58. package/templates/hypo-help.md +1 -1
@@ -1063,6 +1063,63 @@ export function collectProjectWorkingDirs(hypoDir) {
1063
1063
  return out;
1064
1064
  }
1065
1065
 
1066
+ // One place that answers "is this string a slug naming a real project directory?".
1067
+ // Both resolveGateProjectOverride (a caller-supplied --project) and the gate's
1068
+ // mustEvaluate filter (slugs arriving from a transcript or a marker) need it, and
1069
+ // having the grammar in two inline copies is the duplication this release spent a
1070
+ // codex round removing elsewhere. Not exported: scripts/ has its own validator,
1071
+ // and importing that here would invert the one-way hooks/ <- scripts/ dependency.
1072
+ function isProjectSlugDirectory(hypoDir, slug) {
1073
+ if (typeof slug !== 'string' || !/^[A-Za-z0-9._-]+$/.test(slug)) return false;
1074
+ if (/^\.+$/.test(slug) || !/[A-Za-z0-9]/.test(slug)) return false;
1075
+ try {
1076
+ return statSync(join(hypoDir, 'projects', slug)).isDirectory();
1077
+ } catch {
1078
+ return false;
1079
+ }
1080
+ }
1081
+
1082
+ // session-close-scope-boundary spec §2: the one place that turns a caller-
1083
+ // supplied project name into precompactGateStatus's opts.projectOverride. Four
1084
+ // call sites share it (--mark-session-closed, apply's auto-marker, PreCompact,
1085
+ // Stop) so a foreign project's dangling close files demote to a notice instead
1086
+ // of blocking a session that never touched them, everywhere the gate runs.
1087
+ //
1088
+ // `project` is trusted nowhere upstream of this function: crystallize.mjs's
1089
+ // own --project only gets slug-syntax + directory-existence checked when the
1090
+ // CLI itself parses it (parseArgs + requireProjectDir); a value that arrives
1091
+ // via a hook payload or apply's JSON body never passes through that. So this
1092
+ // re-validates from scratch — same charset rule as project-create.mjs's
1093
+ // isValidProjectName, duplicated rather than imported because hooks/ cannot
1094
+ // import scripts/ (dependency direction is one-way) — plus a directory
1095
+ // existence check. Either failure returns null, the same as "no project was
1096
+ // given": a garbage slug must never narrow the gate's scope, only a real one
1097
+ // may (spec success criterion: "opts.projectOverride 가 없는 slug 이면 게이트가
1098
+ // 그것을 거절한다").
1099
+ //
1100
+ // With no `project`, the override is inferred from `sessionCwd` via
1101
+ // pickProjectByCwd's rejectAmbiguous mode — the same mode the gate's own
1102
+ // close-cwd check already trusts. Without rejectAmbiguous, two projects
1103
+ // sharing one working_dir (a monorepo with no uniqueness invariant) would
1104
+ // silently resolve to the first slug and could narrow the gate to a project
1105
+ // whose OWN close is still incomplete while the real cwd project goes
1106
+ // unchecked. A decline here (null) is not a failure: precompactGateStatus
1107
+ // falls back to the global, unscoped judgment, which is exactly the STOP-hook
1108
+ // call site's actual point (session-close-scope-boundary spec §2: "해석 실패면
1109
+ // 기존 fail-closed 가 받아 준다는 틀렸다 ... cwd 미해결을 마커 차단으로 만들지 않는다").
1110
+ //
1111
+ // @param {string} hypoDir
1112
+ // @param {{project?: string|null, sessionCwd?: string|null}} [opts]
1113
+ // @returns {string|null}
1114
+ export function resolveGateProjectOverride(hypoDir, { project = null, sessionCwd = null } = {}) {
1115
+ if (project) {
1116
+ return isProjectSlugDirectory(hypoDir, project) ? project : null;
1117
+ }
1118
+ return pickProjectByCwd(collectProjectWorkingDirs(hypoDir), sessionCwd, {
1119
+ rejectAmbiguous: true,
1120
+ });
1121
+ }
1122
+
1066
1123
  // A project without a working_dir anchor (no index.md, or an index.md missing
1067
1124
  // the field) never enters collectProjectWorkingDirs' universe, so cwd-first
1068
1125
  // resume always MISSes for it — the SessionStart/CwdChanged MISS branch would
@@ -1451,7 +1508,7 @@ function closeCandidateSlugs(hypoDir, dates) {
1451
1508
  // slug yields a token that no longer maps to a `projects/<slug>/`
1452
1509
  // directory — gating on disk keeps it out of the dangling-close set.
1453
1510
  // Directory (not bare-exists) mirrors the apply-path project check
1454
- // (crystallize.mjs:193).
1511
+ // (crystallize-args.mjs).
1455
1512
  const dir = join(projectsDir, m[1]);
1456
1513
  if (existsSync(dir) && statSync(dir).isDirectory()) slugs.add(m[1]);
1457
1514
  }
@@ -1526,10 +1583,17 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
1526
1583
  // projectOverride (check-only): the caller (`crystallize --check-session-close
1527
1584
  // --project=<slug>`) wants THIS project's close status, not the recency pick —
1528
1585
  // bypass discovery and report the single project, preserving the global return
1529
- // shape. NEVER threaded from a marker-writing path (--mark / apply auto-marker /
1530
- // PreCompact): those stay global so the marker == compact-ready invariant holds
1531
- // (codex design review). A green status here is a project-scoped
1532
- // diagnostic, not the global compact-readiness verdict.
1586
+ // shape. This is the ONLY caller of projectOverride now: the four marker-writing
1587
+ // paths (--mark / apply auto-marker / PreCompact / Stop) attribute through
1588
+ // `opts.attributionScope` instead (resolveCloseScope, below this function), which
1589
+ // only widens the mine/foreign partition's scope Set and leaves this function
1590
+ // evaluating every project, same as an unscoped call. A green status here is a
1591
+ // project-scoped diagnostic, not the global compact-readiness verdict. The
1592
+ // invariant that once justified keeping the writer paths fully unscoped
1593
+ // ("marker == compact-ready", codex design review) is superseded by that
1594
+ // partition, not restored by it — a marker's own `verified_scope`, attesting
1595
+ // exactly what it checked, is still missing (session-close-scope-boundary
1596
+ // spec §3, next wave).
1533
1597
  if (opts.projectOverride) {
1534
1598
  const s = sessionCloseFileStatus(hypoDir, { projectOverride: opts.projectOverride });
1535
1599
  return {
@@ -1547,11 +1611,30 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
1547
1611
  }
1548
1612
  const dates = freshDates();
1549
1613
  const recency = resolveActiveProject(hypoDir); // no cwd — close never picks by cwd
1550
- const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
1614
+ // opts.mustEvaluate: slugs this session ATTRIBUTED its close to (explicit
1615
+ // --project, transcript close-file edits, a prior marker). They are evaluated
1616
+ // even when they are not today-active candidates, because the marker writer
1617
+ // stamps the attribution scope: a slug that reaches the marker but never
1618
+ // reached this evaluation would be attested as closed without ever being
1619
+ // checked (codex pre-commit BLOCKER, reproduced with an empty projects/orphan/).
1620
+ // `projects/<slug>/` must exist on disk; a slug naming a directory that is gone
1621
+ // (a stale marker inside its 7-day TTL) is dropped rather than turned into a
1622
+ // permanent blocker for a project nobody can close any more.
1623
+ // existsSync alone would admit a file, or a traversal like '..', from a
1624
+ // malformed marker or transcript slug and turn it into a surprise blocker.
1625
+ // Same slug grammar the CLI and resolveGateProjectOverride validate against.
1626
+ const mustEvaluate = [...(opts.mustEvaluate || [])].filter((p) =>
1627
+ isProjectSlugDirectory(hypoDir, p),
1628
+ );
1629
+ // The fallback decision reads REAL today activity only. Letting mustEvaluate
1630
+ // populate it would mean an attributed slug suppresses the "this session closed
1631
+ // nothing at all" branch, and that branch is a fail-closed guard the partition
1632
+ // below depends on (`!close.fallback`).
1633
+ const activeCandidates = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
1551
1634
  hasTodayCloseActivity(hypoDir, p, dates),
1552
1635
  );
1553
1636
 
1554
- if (todayActive.length === 0) {
1637
+ if (activeCandidates.length === 0) {
1555
1638
  const legacy = sessionCloseFileStatus(hypoDir);
1556
1639
  return {
1557
1640
  ok: legacy.ok,
@@ -1567,6 +1650,8 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
1567
1650
  };
1568
1651
  }
1569
1652
 
1653
+ const todayActive = [...new Set([...activeCandidates, ...mustEvaluate])];
1654
+
1570
1655
  // primary = the recency project when it is itself today-active, else the first
1571
1656
  // today-active slug (stable order from the candidate set). Used only as the
1572
1657
  // single-slug alias for the message header and the flat `close.project` field.
@@ -3504,6 +3589,12 @@ export function partitionLintScope(findings, scope) {
3504
3589
  * every close. Same path policy as the lint-scope matcher above: separators
3505
3590
  * normalized to POSIX, exact prefix at a segment boundary.
3506
3591
  */
3592
+ // This function keeps the posixPath() normalization deliberately, unlike
3593
+ // isForeign (§2b), which reads the raw porcelain path so a literal-backslash
3594
+ // filename cannot be misread as a nested projects/<slug>/ path. Every caller
3595
+ // of isUnderProjectDirs would need to be checked before applying the same
3596
+ // raw-path treatment here, and that audit has not been done, so the existing
3597
+ // behavior stays until it has.
3507
3598
  export function isUnderProjectDirs(file, slugs) {
3508
3599
  const f = posixPath(file);
3509
3600
  return (slugs || []).some((s) => s && (f === `projects/${s}` || f.startsWith(`projects/${s}/`)));
@@ -3550,6 +3641,15 @@ function projectsFromTouchedCloseFiles(transcriptPath, hypoDir) {
3550
3641
  * the `project` that same writer recorded, so the two can never disagree.
3551
3642
  */
3552
3643
  export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
3644
+ // `direct` is the subset attributable to THIS close attempt: explicit
3645
+ // --project/closeScope, this session's transcript close-file edits, and the
3646
+ // resolved attributionScope. A marker's `projects` is PAST evidence of a close
3647
+ // that already happened, so it belongs in the partition scope (it must not be
3648
+ // demoted as foreign debt) but NOT in the mandatory-evaluation set: a 7-day-TTL
3649
+ // marker naming a project that is no longer today-active would otherwise make
3650
+ // that project's stale close block an unrelated close today (codex round 2,
3651
+ // reproduced with a 24h-old marker). The returned Set carries `direct` as a
3652
+ // property so the caller does not re-derive it, and the transcript is parsed once.
3553
3653
  const scope = new Set(opts.closeScope || []);
3554
3654
  for (const p of projectsFromTouchedCloseFiles(opts.transcriptPath, hypoDir)) scope.add(p);
3555
3655
  // Marker attribution (session-close attribution). A v4 marker carries `projects` — the
@@ -3561,6 +3661,20 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
3561
3661
  // above (explicit close scope or transcript close-file edits) already corroborate
3562
3662
  // the SAME slug; otherwise it stays a display hint. This narrow gap self-heals as
3563
3663
  // pre-v4 markers expire (7-day TTL).
3664
+ // opts.attributionScope (session-close-scope-boundary spec §2/§3): a fourth
3665
+ // signal, one slug, from resolveGateProjectOverride. A cwd that unambiguously
3666
+ // owns one project, or an explicit --project the caller already validated, is
3667
+ // evidence this session is closing THAT project — the same standing the three
3668
+ // signals above already have. It is the opt key the four marker-writing paths
3669
+ // (PreCompact, Stop, --mark-session-closed, apply's auto-marker) pass instead
3670
+ // of `opts.projectOverride`: that key stays the check-only diagnostic's (it
3671
+ // also narrows sessionCloseGlobalStatus's own evaluation and turns off the
3672
+ // partition below), while this one only widens the set the partition demotes
3673
+ // everything outside of.
3674
+ if (opts.attributionScope) scope.add(opts.attributionScope);
3675
+ // Snapshot the direct signals BEFORE the marker union below widens the set.
3676
+ scope.direct = new Set(scope);
3677
+
3564
3678
  if (Array.isArray(marker?.projects)) {
3565
3679
  for (const p of marker.projects) if (p) scope.add(p);
3566
3680
  }
@@ -3590,12 +3704,22 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
3590
3704
  * opts.projectOverride (CHECK-ONLY) narrows BOTH the close status and the lint
3591
3705
  * scope to a single project, for `--check-session-close --project=<slug>`. A
3592
3706
  * green result is then a project-scoped diagnostic, NOT the global compact-ready
3593
- * verdict: the caller must surface the scope. It is NEVER passed from
3594
- * a marker-writing path (--mark / apply auto-marker / PreCompact); those stay
3595
- * global so a marker can't attest compact-ready while PreCompact re-checks red
3596
- * (the marker == compact-ready invariant, codex design review). When a
3597
- * log-only marker governs the session, log-only mode wins and projectOverride is
3598
- * ignored.
3707
+ * verdict: the caller must surface the scope. The four marker-writing paths
3708
+ * (--mark / apply auto-marker / PreCompact / Stop) never set this — they pass
3709
+ * `opts.attributionScope` instead, which only feeds resolveCloseScope's set and
3710
+ * leaves this status evaluating every project, so the mine/foreign partition
3711
+ * below can tell their own incomplete close from someone else's debt. The
3712
+ * invariant that once justified keeping those paths fully unscoped ("marker ==
3713
+ * compact-ready", codex design review) is replaced by that partition, not
3714
+ * restored by it — a marker's own `verified_scope`, attesting exactly what it
3715
+ * checked, is still missing (session-close-scope-boundary spec §3, next wave).
3716
+ * Either key ALSO feeds the git-dirty partition (spec §2b): with an untrusted
3717
+ * transcript (missing/corrupt), a dirty file structurally under a DIFFERENT
3718
+ * eligible project's own directory is demoted to a notice, because the path
3719
+ * alone proves it is not this session's; everything else keeps blocking.
3720
+ * When a log-only marker governs the session, log-only mode wins for the
3721
+ * close-status and lint scope (scope selection above); the git-dirty
3722
+ * partition in spec §2b still applies regardless of projectOverride.
3599
3723
  *
3600
3724
  * @param {string} hypoDir
3601
3725
  * opts.sessionCwd (session-cwd close check): the authoritative cwd of the session being
@@ -3606,7 +3730,7 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
3606
3730
  * cwd yields a best-effort notice, not a block. apply never passes it (its launch
3607
3731
  * cwd may differ from the authoritative payload.project).
3608
3732
  *
3609
- * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null, sessionCwd?: string|null, sessionId?: string|null, logOnly?: boolean, closeScope?: string[]}} [opts]
3733
+ * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null, attributionScope?: string|null, sessionCwd?: string|null, sessionId?: string|null, logOnly?: boolean, closeScope?: string[]}} [opts]
3610
3734
  * @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string,file?:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
3611
3735
  */
3612
3736
  export function precompactGateStatus(hypoDir, opts = {}) {
@@ -3656,10 +3780,25 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3656
3780
  // would make almost every real transcript untrusted and defeat the scoping
3657
3781
  // this fix exists to add.
3658
3782
  let sessionTouchTrusted = false;
3783
+ // Transcript-derived files ONLY (not closeAccountableScope's mandatory-files
3784
+ // base, which comes from closeFileTargetsGlobal and would fold every close
3785
+ // file of every eligible project into "this session touched it"). §2b's
3786
+ // isForeign below needs exactly this narrower set: a file this session's own
3787
+ // Edit/Write proves it touched, even on a PARTIALLY-parsed transcript (the
3788
+ // trailing line of an append-in-progress JSONL truncates in the ordinary
3789
+ // course of a live session, not as an exceptional failure), must never be
3790
+ // classified foreign by path prefix alone. Widening this to
3791
+ // closeAccountableScope would re-open the exact foreign-close-file leak
3792
+ // (b) fixed above (a genuinely different project's close file would count
3793
+ // as "touched" just because it shares the mandatory-files base).
3794
+ let transcriptTouched = new Set();
3659
3795
  if (opts.transcriptPath) {
3660
3796
  const widened = extractTouchedWikiFilesWithTrust(opts.transcriptPath, hypoDir);
3661
3797
  sessionTouchTrusted = widened.trusted;
3662
- for (const f of widened.files) closeAccountableScope.add(f);
3798
+ for (const f of widened.files) {
3799
+ closeAccountableScope.add(f);
3800
+ transcriptTouched.add(f);
3801
+ }
3663
3802
  }
3664
3803
 
3665
3804
  // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but only
@@ -3686,7 +3825,72 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3686
3825
  const git = hypoIsClean(hypoDir);
3687
3826
  if (git.uncommitted) {
3688
3827
  const dirty = gitDirtyFiles(hypoDir);
3689
- if (dirty.length === 0 || !sessionTouchTrusted) {
3828
+ if (dirty.length === 0) {
3829
+ // Enumeration itself failed (or nothing is actually dirty despite the
3830
+ // porcelain status): "cannot attribute" is not "clean", block exactly
3831
+ // as before, override or not.
3832
+ blockers.push({ type: 'git', reason: git.reason });
3833
+ } else if (!sessionTouchTrusted && (opts.projectOverride || opts.attributionScope)) {
3834
+ // session-close-scope-boundary spec §2b: a broken/missing transcript
3835
+ // normally means "cannot attribute, fail closed" (the branch below).
3836
+ // But when the CALLER told us which project is ours (an explicit
3837
+ // --project, or the cwd-derived attributionScope), a dirty file that
3838
+ // lives structurally under a DIFFERENT eligible project's own directory
3839
+ // needs no attribution inference at all: the path alone proves it is
3840
+ // not this session's file. Everything else (this session's own
3841
+ // project, pages/, an unregistered or _template project dir) keeps the
3842
+ // pre-existing fail-closed behavior; only a provably-foreign path is
3843
+ // demoted.
3844
+ const effectiveOverride = opts.projectOverride || opts.attributionScope;
3845
+ // Computed once per gate call, never per file (collectProjectWorkingDirs
3846
+ // walks the projects/ dir and reads every index.md). A throw here (a
3847
+ // readdirSync failure it does not catch itself) must not escape as an
3848
+ // exception, the PreCompact hook's outermost catch would turn that
3849
+ // into a silent, fully-suppressed gate result, the opposite of "fail
3850
+ // closed". Treat any failure as "no eligible projects known", which
3851
+ // makes isForeign() below always false and every dirty file falls
3852
+ // through to the unconditional blocker.
3853
+ let eligibleSlugs = null;
3854
+ try {
3855
+ eligibleSlugs = new Set(collectProjectWorkingDirs(hypoDir).map((p) => p.slug));
3856
+ } catch {
3857
+ eligibleSlugs = null;
3858
+ }
3859
+ // Lexical, on the RAW git-porcelain path, BEFORE posixPath()'s
3860
+ // unconditional `\` -> `/` conversion. A file whose actual NAME
3861
+ // contains a literal backslash (`projects\other\x.md`, one path
3862
+ // segment, no real subdirectory) must not be reinterpreted as living
3863
+ // under `projects/other/` just because posixPath() would rewrite it
3864
+ // that way; testing the raw string here means it never matches this
3865
+ // regex and falls through to fail-closed instead.
3866
+ const isForeign = (f) => {
3867
+ // The transcript already PROVES this session edited f, whatever its
3868
+ // path prefix says: transcript evidence outranks the path-prefix
3869
+ // heuristic below, which only exists to cover files the (untrusted)
3870
+ // transcript could not vouch for either way.
3871
+ if (transcriptTouched.has(posixPath(f))) return false;
3872
+ if (!eligibleSlugs) return false;
3873
+ const m = /^projects\/([^/]+)\/.+$/.exec(f);
3874
+ if (!m) return false;
3875
+ const slug = m[1];
3876
+ return slug !== effectiveOverride && eligibleSlugs.has(slug);
3877
+ };
3878
+ const foreign = dirty.filter(isForeign);
3879
+ const rest = dirty.filter((f) => !isForeign(f));
3880
+ if (rest.length > 0) {
3881
+ blockers.push({
3882
+ type: 'git',
3883
+ reason: `uncommitted changes in ${hypoDir}: ${rest.join(', ')}`,
3884
+ });
3885
+ }
3886
+ for (const f of foreign) {
3887
+ notices.push({
3888
+ type: 'git',
3889
+ file: f,
3890
+ reason: `uncommitted changes outside this session's scope: ${f}`,
3891
+ });
3892
+ }
3893
+ } else if (!sessionTouchTrusted) {
3690
3894
  blockers.push({ type: 'git', reason: git.reason });
3691
3895
  } else {
3692
3896
  const mine = dirty.filter((f) => closeAccountableScope.has(posixPath(f)));
@@ -3739,8 +3943,21 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3739
3943
  }
3740
3944
  } else {
3741
3945
  // projectOverride narrows the close status to one project (check-only); a
3742
- // marker-writing caller never sets it, so the marker path stays global.
3743
- close = sessionCloseGlobalStatus(hypoDir, { projectOverride: opts.projectOverride });
3946
+ // marker-writing caller never sets it. It attributes instead through
3947
+ // opts.attributionScope, consumed a few lines down by resolveCloseScope, so
3948
+ // the status computed here stays global and only the partition below scopes it.
3949
+ // scopeSet is computed BEFORE the status so the attributed slugs become
3950
+ // mandatory evaluation targets (sessionCloseGlobalStatus's mustEvaluate).
3951
+ // Computing it after would let a slug reach the marker that the status never
3952
+ // looked at, which is the false-green this ordering exists to stop.
3953
+ const attributedScope = resolveCloseScope(hypoDir, opts, marker);
3954
+ close = sessionCloseGlobalStatus(hypoDir, {
3955
+ projectOverride: opts.projectOverride,
3956
+ // direct only: a past marker's projects must not become mandatory
3957
+ // evaluation targets (codex round 2). They stay in attributedScope so the
3958
+ // partition still treats them as this session's, not foreign debt.
3959
+ mustEvaluate: attributedScope.direct,
3960
+ });
3744
3961
 
3745
3962
  // Attribute the close debt before blocking on it. An incomplete close belongs
3746
3963
  // to whichever session performed it; charging it to an unrelated session is the
@@ -3771,7 +3988,7 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3771
3988
  // pre-existing tradeoff documented there). And the marker is never written, so the
3772
3989
  // Stop hook still refuses to end the session. Closing this properly needs a
3773
3990
  // durable close-attempt record; it is not worth a new state-file lifecycle here.
3774
- const scopeSet = resolveCloseScope(hypoDir, opts, marker);
3991
+ const scopeSet = attributedScope;
3775
3992
  const failed = (close.projects || []).filter((p) => !p.ok);
3776
3993
  const partition =
3777
3994
  !close.fallback && !opts.projectOverride && scopeSet.size > 0 && failed.length > 0;
@@ -3878,6 +4095,11 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3878
4095
  // monorepo tie pickProjectByCwd declined): we cannot attribute a close
3879
4096
  // responsibility, so we do NOT hard-block (nothing proves there is anything
3880
4097
  // to close). Surface a best-effort notice so the coverage gap is visible.
4098
+ // This reason names --project / --log-only, the crystallize CLI flags:
4099
+ // correct for a --check-session-close caller, but not a lever a
4100
+ // PreCompact hook reader has. The hook renderer (hypo-personal-check.mjs)
4101
+ // builds its own sentence for this notice type instead of echoing this
4102
+ // one verbatim; keep both in sync if the underlying condition changes.
3881
4103
  notices.push({
3882
4104
  type: 'close-cwd-unresolved',
3883
4105
  reason:
@@ -156,16 +156,54 @@ export function selectPluginVersion(plugins) {
156
156
  return typeof v === 'string' ? v : null;
157
157
  }
158
158
 
159
- /** Channel-specific one-line update instruction. */
159
+ /** The command that runs an upgrade apply, in the form the caller's channel can
160
+ * actually run. A plugin-only install has no `hypomnema` on PATH: that binary is
161
+ * the npm package's bin, while the plugin manifest ships commands only. And the
162
+ * slash command takes no `--apply` argument, so naming the flag there describes
163
+ * a call it does not make: it runs the check and appends --apply only after the
164
+ * operator confirms.
165
+ *
166
+ * Lives here because both hooks and scripts need it and the dependency only
167
+ * runs one way (scripts import hooks, never the reverse). This file is already
168
+ * in hooks.json's `shared` list, so it reaches every standalone install.
169
+ *
170
+ * Use UPGRADE_APPLY_EITHER when the target channel is genuinely unknown, e.g.
171
+ * an instruction about a DIFFERENT machine. */
172
+ export function upgradeApplyHint(isPluginChannel) {
173
+ return isPluginChannel
174
+ ? '`/hypo:upgrade` (confirm the apply step)'
175
+ : '`hypomnema upgrade --apply`';
176
+ }
177
+
178
+ export const UPGRADE_APPLY_EITHER =
179
+ '`hypomnema upgrade --apply` (or `/hypo:upgrade` on a plugin install)';
180
+
181
+ /** Channel-specific one-line update instruction.
182
+ *
183
+ * Every channel ends with the same second step. Installing a release does NOT
184
+ * repoint the vault's git pre-commit hook: that hook holds an absolute path
185
+ * baked in at init time, and only `upgrade --apply` rewrites it. No hook can
186
+ * do it either (a hook must never invoke an apply path). Without this line the
187
+ * user updates, sees the new version string, and keeps committing through the
188
+ * install they had when they first ran init. */
160
189
  export function buildUpdateLine(channel, current, latest) {
161
190
  const head = `[Hypomnema] Update available! ${current} → ${latest}`;
191
+ // The repoint step has to be RUNNABLE on the channel it is printed for. A
192
+ // plugin-only install has no `hypomnema` on PATH (that binary is the npm
193
+ // package's bin; the plugin manifest ships commands only), so its form is the
194
+ // slash command. And the slash command takes no --apply argument: that
195
+ // command runs the check first and only appends --apply after the operator
196
+ // confirms, so naming the flag there would describe a call the command does
197
+ // not make. Both mistakes are the same class as naming a flag no script parses.
198
+ const repoint = (cmd) => `\n → then: ${cmd} (repoints the vault's git hook at the new install)`;
162
199
  if (channel === 'plugin') {
163
- return `${head}\n → run: /plugin marketplace update hypomnema then /reload-plugins`;
164
- }
165
- if (channel === 'npm') {
166
- return `${head}\n → run: npm install -g hypomnema`;
200
+ return `${head}\n → run: /plugin marketplace update hypomnema then /reload-plugins${repoint(upgradeApplyHint(true))}`;
167
201
  }
168
- return `${head}\n → npm i -g hypomnema (or /plugin marketplace update hypomnema && /reload-plugins)`;
202
+ // The only remaining displayed channel is npm: computeNotice drops 'unknown'
203
+ // before anything renders, and a dual install reports the channel of the hook
204
+ // root actually running, never a third "mixed" value. A branch for one would
205
+ // be code no user reaches.
206
+ return `${head}\n → run: npm install -g hypomnema${repoint(upgradeApplyHint(false))}`;
169
207
  }
170
208
 
171
209
  // ── cache freshness + notice decision (pure) ─────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.7.4",
3
+ "version": "1.8.0",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,11 @@
28
28
  "scripts/verify.mjs",
29
29
  "scripts/weekly-report.mjs",
30
30
  "scripts/lib/core-hooks.mjs",
31
+ "scripts/lib/crystallize-args.mjs",
32
+ "scripts/lib/crystallize-close-apply.mjs",
33
+ "scripts/lib/crystallize-close-check.mjs",
34
+ "scripts/lib/crystallize-close-gate.mjs",
35
+ "scripts/lib/crystallize-helpers.mjs",
31
36
  "scripts/lib/design-history-stale.mjs",
32
37
  "scripts/lib/extensions.mjs",
33
38
  "scripts/lib/failure-type.mjs",
@@ -53,7 +58,8 @@
53
58
  "templates/",
54
59
  "docs/",
55
60
  ".claude-plugin/",
56
- "README.md"
61
+ "README.md",
62
+ "CHANGELOG.md"
57
63
  ],
58
64
  "keywords": [
59
65
  "wiki",
@@ -92,6 +98,6 @@
92
98
  "prepublishOnly": "npm test && npm run lint && npm run check:versions && npm run smoke:plugin && node scripts/check-pack-surface.mjs && npm run smoke-pack && npm run check:bilingual && npm run check:tracker-ids"
93
99
  },
94
100
  "devDependencies": {
95
- "prettier": "^3.8.3"
101
+ "prettier": "3.8.3"
96
102
  }
97
103
  }
@@ -32,6 +32,7 @@
32
32
  * acquired through the normal sync path, never by injecting a SHA directly.
33
33
  */
34
34
 
35
+ import { UPGRADE_APPLY_EITHER } from '../hooks/version-check.mjs';
35
36
  import {
36
37
  existsSync,
37
38
  readFileSync,
@@ -76,6 +77,8 @@ import {
76
77
  scanSettingsHooks,
77
78
  parseCapturableHookCommand,
78
79
  HOOK_EVENT_ALLOWLIST,
80
+ HOOK_INTERPRETER_EXT,
81
+ DEFAULT_HOOK_INTERPRETER,
79
82
  SKILL_ROOT_FILE,
80
83
  EXT_PREFIX,
81
84
  withSrcExecBits,
@@ -90,8 +93,9 @@ const HOME = homedir();
90
93
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
91
94
 
92
95
  // Captured types and their singular manifest `type` value. commands/agents install
93
- // as top-level `.md` files; hooks install as `.mjs` and additionally register a
94
- // settings.json entry (reverse of forward-sync); skills install as a DIRECTORY.
96
+ // as top-level `.md` files; hooks install as an interpreter-specific executable
97
+ // (.mjs/.py/.sh) and additionally register a settings.json entry
98
+ // (reverse of forward-sync); skills install as a DIRECTORY.
95
99
  const CAPTURE_TYPES = ['commands', 'agents', 'hooks', 'skills'];
96
100
  // The flat, single-file types. `TYPE_EXT` has no `skills` entry on purpose: a skill
97
101
  // has no install extension, and letting it through a flat helper would silently
@@ -101,7 +105,20 @@ const FLAT_TYPES = ['commands', 'agents', 'hooks'];
101
105
  // registration instead, and skills have their own directory scanner.
102
106
  const READDIR_TYPES = ['commands', 'agents'];
103
107
  const TYPE_SINGULAR = { commands: 'command', agents: 'agent', hooks: 'hook', skills: 'skill' };
104
- const TYPE_EXT = { commands: '.md', agents: '.md', hooks: '.mjs' };
108
+ // `TYPE_EXT` has no `hooks` entry either, for the same reason as skills: a hook's
109
+ // extension depends on its interpreter (HOOK_INTERPRETER_EXT), not its type.
110
+ // captureFileExt() is the one place that resolves it; every hook-touching call
111
+ // site below goes through that, not this map.
112
+ const TYPE_EXT = { commands: '.md', agents: '.md' };
113
+
114
+ /** The install/wiki-storage extension for one capture candidate. hooks vary by
115
+ * interpreter (mirrors buildHookCommand/parseCapturableHookCommand in
116
+ * extensions.mjs); the other flat types have one fixed extension. */
117
+ function captureFileExt(c) {
118
+ return c.type === 'hooks'
119
+ ? HOOK_INTERPRETER_EXT[c.interpreter ?? DEFAULT_HOOK_INTERPRETER]
120
+ : TYPE_EXT[c.type];
121
+ }
105
122
 
106
123
  // Ceilings for a captured skill subtree (skills-capture design §3). A hand-authored
107
124
  // skill is a handful of files; a vendored one (gstack: 14,311 files / 1.1GB, mostly
@@ -124,9 +141,14 @@ function pkgJsonPath() {
124
141
  * hooks/commands + already-synced hypo-ext-* copies), anything already owned
125
142
  * (a recorded SHA for its install path means the wiki already manages it), and
126
143
  * non-.md files. Symlink/non-regular is checked by the caller (needs an fs stat).
144
+ * `type` must be a flat readdir type with a single fixed extension (TYPE_EXT);
145
+ * hooks have no entry there (extension depends on interpreter) and are never
146
+ * routed through this function — scanCandidates only calls it for READDIR_TYPES.
127
147
  */
128
148
  export function isCaptureCandidate(type, file, recorded) {
129
- if (!file.endsWith(TYPE_EXT[type])) return { ok: false, reason: `not a ${TYPE_EXT[type]} file` };
149
+ const ext = TYPE_EXT[type];
150
+ if (!ext) return { ok: false, reason: `capture-by-readdir does not cover ${type}` };
151
+ if (!file.endsWith(ext)) return { ok: false, reason: `not a ${ext} file` };
130
152
  const lower = file.toLowerCase();
131
153
  if (lower === 'hypo' || lower.startsWith('hypo-')) {
132
154
  return { ok: false, reason: 'reserved hypo namespace' };
@@ -296,10 +318,14 @@ export function planSkillCapture({
296
318
  /**
297
319
  * Reverse-generate the sidecar manifest for a capture candidate. commands/agents
298
320
  * carry only `{ type, installName }`; hooks additionally carry the settings
299
- * registration fields `{ event, matcher?, timeout? }`. matcher/timeout are omitted
300
- * when absent (an empty-string matcher is already normalized to undefined by the
301
- * scanner) via conditional spread, so the in-memory object never carries an
302
- * `undefined`-valued key that would break the planCapture deep-equality check.
321
+ * registration fields `{ event, matcher?, timeout?, interpreter? }`. Every
322
+ * optional field is omitted when it would equal its default (an empty-string
323
+ * matcher is already normalized to undefined by the scanner; `node` is the
324
+ * default interpreter) via conditional spread, so the in-memory object never
325
+ * carries an `undefined`-valued key or a redundant explicit default, either of
326
+ * which would break the planCapture deep-equality check against a manifest
327
+ * captured before interpreter support landed (a `.mjs` hook's manifest must stay byte-for-byte
328
+ * identical, or every prior capture becomes a false conflict).
303
329
  */
304
330
  function buildWantManifest(c) {
305
331
  const manifest = { type: TYPE_SINGULAR[c.type], installName: c.stem };
@@ -307,6 +333,9 @@ function buildWantManifest(c) {
307
333
  manifest.event = c.event;
308
334
  if (c.matcher !== undefined && c.matcher !== '') manifest.matcher = c.matcher;
309
335
  if (c.timeout !== undefined) manifest.timeout = c.timeout;
336
+ if (c.interpreter !== undefined && c.interpreter !== DEFAULT_HOOK_INTERPRETER) {
337
+ manifest.interpreter = c.interpreter;
338
+ }
310
339
  }
311
340
  return manifest;
312
341
  }
@@ -438,8 +467,9 @@ function subsetOf(keys, allowed) {
438
467
  * canonical.
439
468
  *
440
469
  * A candidate must satisfy ALL of: strict command grammar
441
- * (`parseCapturableHookCommand`) resolving under `~/.claude/hooks/`; the resolved
442
- * `.mjs` is a real regular file (no symlink / non-regular); `type === 'command'`;
470
+ * (`parseCapturableHookCommand`, which also recovers the interpreter — node,
471
+ * python3, or bash) resolving under `~/.claude/hooks/`; the resolved
472
+ * file is a real regular file (no symlink / non-regular); `type === 'command'`;
443
473
  * the event is in `HOOK_EVENT_ALLOWLIST`; a preservable shape (F3); the case-folded
444
474
  * basename appears exactly once across all settings hooks (F6); and the stem is not
445
475
  * a core hook, not the reserved hypo namespace, and not already wiki-owned.
@@ -479,7 +509,7 @@ export function scanHookCandidates(claudeHome, settingsPath, recorded, coreBasen
479
509
  skipped.push({ type: 'hooks', command: rec.command, reason: parsed.reason });
480
510
  continue;
481
511
  }
482
- const { stem, basename } = parsed;
512
+ const { stem, basename, interpreter } = parsed;
483
513
  const label = `hooks/${basename}`;
484
514
 
485
515
  // Duplicate first: a basename registered 2+ times is uniformly skipped so both
@@ -547,6 +577,7 @@ export function scanHookCandidates(claudeHome, settingsPath, recorded, coreBasen
547
577
  event: rec.event,
548
578
  matcher: rec.matcher,
549
579
  timeout: rec.timeout,
580
+ interpreter,
550
581
  label,
551
582
  });
552
583
  }
@@ -993,7 +1024,7 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
993
1024
  captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped, created });
994
1025
  continue;
995
1026
  }
996
- const fileExt = TYPE_EXT[c.type];
1027
+ const fileExt = captureFileExt(c);
997
1028
  const wikiStem = `${EXT_PREFIX}${c.stem}`;
998
1029
  const typeDir = join(extDir, c.type);
999
1030
  const filePath = join(typeDir, `${wikiStem}${fileExt}`);
@@ -1024,11 +1055,12 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
1024
1055
  const plan = planCapture({ wantManifest, srcSha, existingFileSha, existingManifestRaw });
1025
1056
 
1026
1057
  // installFile is the ORIGINAL name the far machine installs under (installName
1027
- // adoption); for hooks that is `<stem>.mjs`, for commands/agents `<stem>.md`.
1058
+ // adoption); for hooks that is `<stem>` plus its interpreter's extension
1059
+ // (`.mjs`/`.py`/`.sh`), for commands/agents `<stem>.md`.
1028
1060
  const installFile = `${c.stem}${fileExt}`;
1029
1061
  // Ownership keys sync must record for the adoption to count. Hooks track BOTH
1030
- // the `.mjs` and its `.manifest.json` sidecar under the install path (success
1031
- // criterion d); commands/agents track only the single install file.
1062
+ // the main file and its `.manifest.json` sidecar under the install path
1063
+ // (success criterion d); commands/agents track only the single install file.
1032
1064
  const requiredKeys =
1033
1065
  c.type === 'hooks'
1034
1066
  ? [`hooks/${installFile}`, `hooks/${c.stem}.manifest.json`]
@@ -1173,7 +1205,7 @@ function report(captured, skipped, failed, dryRun) {
1173
1205
  const dest =
1174
1206
  c.type === 'skills'
1175
1207
  ? `extensions/skills/${EXT_PREFIX}${c.stem}/`
1176
- : `extensions/${c.type}/${EXT_PREFIX}${c.stem}${TYPE_EXT[c.type]}`;
1208
+ : `extensions/${c.type}/${EXT_PREFIX}${c.stem}${captureFileExt(c)}`;
1177
1209
  log(` ${c.type}/${c.file} → ${dest}`);
1178
1210
  }
1179
1211
  }
@@ -1182,7 +1214,7 @@ function report(captured, skipped, failed, dryRun) {
1182
1214
  log(`Failed to adopt ${failed.length} (see sync warnings; wiki files rolled back).`);
1183
1215
  if (!dryRun && captured.some((c) => c.status === 'captured')) {
1184
1216
  log('');
1185
- log('Next: commit + push the wiki, then run `hypomnema upgrade --apply` on another machine.');
1217
+ log(`Next: commit + push the wiki, then run ${UPGRADE_APPLY_EITHER} on another machine.`);
1186
1218
  }
1187
1219
  }
1188
1220