hypomnema 1.7.2 → 1.7.3

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.
@@ -11,7 +11,7 @@
11
11
  "name": "hypo",
12
12
  "source": "./",
13
13
  "description": "LLM-native personal wiki — session-aware knowledge base for Claude Code",
14
- "version": "1.7.2",
14
+ "version": "1.7.3",
15
15
  "homepage": "https://github.com/sk-lim19f/Hypomnema"
16
16
  }
17
17
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypo",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "LLM-native personal wiki system — session-aware knowledge base for Claude Code",
5
5
  "author": {
6
6
  "name": "sk-lim19f",
@@ -153,13 +153,13 @@ The result JSON includes a `stage` field when `ok: false`. Branch on it:
153
153
  | `post-apply-lint` | The payload introduced an error-level lint blocker in a payload file (malformed body / bad frontmatter), or lint crashed. | Fix the offending content in the payload, then re-run. (Broken wikilinks are W4 warnings — not gated.) |
154
154
  | `post-apply-verification+lint` | Both above. | Fix both; re-run. |
155
155
 
156
- Once `ok: true`, report:
156
+ Once `ok: true`, report from the result JSON's `applied` and `skipped` arrays together, not `applied` alone. `applied` lists only the fields this run actually wrote bytes for; `skipped` lists the fields that already matched what was on disk (a re-run of an already-applied payload). An idempotent re-run legitimately reports `applied: []`, and that is success, not a failure to report on: check `skipped` for the same 4-6 entries instead. Read `committed` the same way: `true` covers both a real commit and the case where nothing needed staging (a full no-op re-run); `false` means the commit itself ran and failed (see `markerSkipReason`); `null` means apply never reached the commit step at all, because `ok` was already false (an authority refusal before any write, or a verification/lint failure, or a withheld conflict, per `stage`). `null` does NOT mean nothing was written: `applied` can be non-empty (bytes landed on disk) while `committed` stays `null`, because those bytes were never staged into git.
157
157
 
158
- - ✓ session-state.md applied
159
- - ✓ hot.md (project + root) applied
160
- - ✓ session-log entry appended
158
+ - ✓ session-state.md applied (or already current, per `skipped`)
159
+ - ✓ hot.md (project + root) applied (or already current)
160
+ - ✓ session-log entry appended (or already present)
161
161
  - ✓ open-questions applied (or skipped if unchanged)
162
- - ✓ log.md entry appended
162
+ - ✓ log.md entry appended (or already present)
163
163
  - ✓ post-apply lint clean
164
164
  - **marker written?** (required check): if `markerWritten: true`, report "session-close marker written"; if `markerWritten: false`, report "session-close marker NOT written (reason: `<markerSkipReason>`)" and do NOT declare the session "closed" or "complete". A missing marker means the Stop-chain is still open; recover per the `markerSkipReason` branch below.
165
165
 
@@ -19,6 +19,7 @@ import {
19
19
  deriveRootLogEntries,
20
20
  recordTouchedPaths,
21
21
  } from './hypo-shared.mjs';
22
+ import { advanceBase, hashContent } from './base-store.mjs';
22
23
 
23
24
  const HOT_PATH = join(HYPO_DIR, 'hot.md');
24
25
  const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
@@ -73,7 +74,7 @@ function parsePointerRows(content) {
73
74
  }
74
75
 
75
76
  /** @returns {boolean} true when hot.md was actually rewritten. */
76
- function rebuild() {
77
+ function rebuild(sessionId) {
77
78
  if (!existsSync(HOT_PATH)) return false;
78
79
 
79
80
  const current = readFileSync(HOT_PATH, 'utf-8');
@@ -116,6 +117,25 @@ ${tableRows}
116
117
 
117
118
  if (canonical !== current) {
118
119
  writeFileSync(HOT_PATH, canonical);
120
+ // This write bypasses the Write/Edit tool, so hypo-auto-stage's
121
+ // PostToolUse-based advanceBaseForWrite never sees it (see the file-header
122
+ // comment above). Without advancing the base here, this session's own
123
+ // rewrite of hot.md looks -- at close time -- exactly like a DIFFERENT
124
+ // session having edited it, and the observed-base guard in crystallize.mjs
125
+ // parks a false conflict against this session's own work. Mirrors
126
+ // crystallize.mjs's overwrite(): advanceBase right after the write that
127
+ // made it true.
128
+ //
129
+ // No session_id on stdin (empty/malformed payload): advanceBase is a
130
+ // no-op without a snapshot anyway, so this stays silent-safe like before
131
+ // this fix -- just observable on stderr instead of a guess.
132
+ if (sessionId) {
133
+ advanceBase(HYPO_DIR, sessionId, 'hot.md', hashContent(canonical));
134
+ } else {
135
+ process.stderr.write(
136
+ '[hypo-hot-rebuild] no session_id on stdin; base not advanced for hot.md\n',
137
+ );
138
+ }
119
139
  return true;
120
140
  }
121
141
  return false;
@@ -134,7 +154,7 @@ function emitGrowth() {
134
154
 
135
155
  let hotWritten = false;
136
156
  try {
137
- hotWritten = rebuild();
157
+ hotWritten = rebuild(sessionId);
138
158
  } catch (err) {
139
159
  process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
140
160
  }
@@ -445,6 +445,72 @@ export function hypoIsClean(dir = HYPO_DIR) {
445
445
  }
446
446
  }
447
447
 
448
+ /**
449
+ * Repo-relative POSIX paths with uncommitted changes (tracked or untracked) in
450
+ * `dir`. Reuses the SAME `-z` rename-aware porcelain parsing as
451
+ * commitWikiChanges (PR #222): a rename or copy emits `to\0from`, and both
452
+ * sides count as dirty (a rename's source still shows staged until the rename
453
+ * itself lands). This is the read-only half of that fix's pathspec boundary:
454
+ * commitWikiChanges narrows what gets COMMITTED to a caller-supplied scope;
455
+ * this narrows what counts as a BLOCKER the same way, for precompactGateStatus
456
+ * to attribute dirty files to the session that owns them instead of the whole
457
+ * shared working tree.
458
+ *
459
+ * Paths are normalized to be relative to `dir` itself, NOT to the git
460
+ * repository's top level: `git -C <dir> status --porcelain` prints paths
461
+ * relative to the repo TOP LEVEL even under `-C` (verified empirically: a
462
+ * vault nested under `<repo>/vault/` reports `vault/hot.md`, not `hot.md`).
463
+ * Every other path this file compares against (closeAccountableScope,
464
+ * closeFileTargetsGlobal, extractTouchedWikiFiles) is `dir`-relative, so
465
+ * without this normalization a nested vault's OWN files would never match
466
+ * its own scope and every one of them would look foreign (codex pre-commit
467
+ * review BLOCKER 2). `git rev-parse --show-prefix` gives exactly the prefix
468
+ * to strip; a dirty path that does not start with it lives outside the
469
+ * vault entirely (the rest of a bigger host repo) and is dropped, never
470
+ * "mine".
471
+ *
472
+ * @returns {string[]} dirty paths relative to `dir`, or `[]` on any git
473
+ * failure (the caller already has its own git-status result via
474
+ * hypoIsClean and treats that failure as an unconditional blocker; an
475
+ * empty return here just means "cannot attribute", not "clean").
476
+ */
477
+ function gitDirtyFiles(dir) {
478
+ const prefixRes = spawnSync('git', ['-C', dir, 'rev-parse', '--show-prefix'], {
479
+ encoding: 'utf-8',
480
+ });
481
+ if (prefixRes.status !== 0) return []; // can't resolve the repo → cannot attribute
482
+ const prefix = (prefixRes.stdout || '').trim();
483
+
484
+ const porcelain = spawnSync('git', ['-C', dir, 'status', '--porcelain', '-uall', '-z'], {
485
+ encoding: 'utf-8',
486
+ });
487
+ if (porcelain.status !== 0) return [];
488
+ const out = [];
489
+ const records = (porcelain.stdout || '').split('\0');
490
+ const toDirRelative = (f) => {
491
+ if (!f) return null;
492
+ if (!prefix) return f; // dir IS the repo top level, nothing to strip
493
+ return f.startsWith(prefix) ? f.slice(prefix.length) : null; // outside the vault
494
+ };
495
+ for (let i = 0; i < records.length; i++) {
496
+ const rec = records[i];
497
+ if (!rec) continue;
498
+ const xy = rec.slice(0, 2);
499
+ const file = rec.slice(3); // destination path for a rename/copy
500
+ const isRenameOrCopy = xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C';
501
+ let fromFile = null;
502
+ if (isRenameOrCopy) {
503
+ i++;
504
+ fromFile = records[i] || null;
505
+ }
506
+ const rel = toDirRelative(file);
507
+ if (rel) out.push(rel);
508
+ const relFrom = toDirRelative(fromFile);
509
+ if (relFrom) out.push(relFrom);
510
+ }
511
+ return out;
512
+ }
513
+
448
514
  export function hotMdIsClean(dir = HYPO_DIR) {
449
515
  const hotPath = dir === HYPO_DIR ? HOT_PATH : join(dir, 'hot.md');
450
516
  if (!existsSync(hotPath)) return { clean: true };
@@ -3281,22 +3347,32 @@ function toHypoRel(absPath, hypoDir) {
3281
3347
  }
3282
3348
 
3283
3349
  /**
3284
- * Repo-relative POSIX paths of wiki files this session edited via direct
3285
- * Edit/Write/MultiEdit/NotebookEdit tool_use. Returns a Set; empty when the
3286
- * transcript is missing/unreadable (callers decide the fallback). A per-line
3287
- * JSON parse error skips that line only (transcripts occasionally truncate).
3350
+ * Same walk as extractTouchedWikiFiles, but also reports whether the walk can
3351
+ * be TRUSTED as a complete enumeration: `trusted: false` when the transcript
3352
+ * is missing, unreadable, or contains a line that failed to parse (transcripts
3353
+ * occasionally truncate mid-write). `extractTouchedWikiFiles` collapses all of
3354
+ * that to an empty/partial Set, indistinguishable from "this session touched
3355
+ * nothing", which is fine for lint's existing debt-partition (a false notice is not a
3356
+ * false pass), but NOT fine for an attribution-sensitive caller like
3357
+ * precompactGateStatus's git-scope check: treating an untrustworthy empty Set
3358
+ * as "nothing to widen" would let a session's own dirty file, invisible only
3359
+ * because its transcript could not be read, pass as someone else's foreign
3360
+ * debt (codex pre-commit review BLOCKER 1).
3361
+ *
3362
+ * @returns {{files: Set<string>, trusted: boolean}}
3288
3363
  */
3289
- export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
3364
+ export function extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir) {
3290
3365
  const out = new Set();
3291
3366
  if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
3292
- return out;
3367
+ return { files: out, trusted: false };
3293
3368
  }
3294
3369
  let raw;
3295
3370
  try {
3296
3371
  raw = readFileSync(transcriptPath, 'utf-8');
3297
3372
  } catch {
3298
- return out;
3373
+ return { files: out, trusted: false };
3299
3374
  }
3375
+ let trusted = true;
3300
3376
  for (const line of raw.split('\n')) {
3301
3377
  const t = line.trim();
3302
3378
  if (!t) continue;
@@ -3304,6 +3380,7 @@ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
3304
3380
  try {
3305
3381
  entry = JSON.parse(t);
3306
3382
  } catch {
3383
+ trusted = false; // a truncated/corrupt line: the walk may be incomplete
3307
3384
  continue;
3308
3385
  }
3309
3386
  for (const fp of extractTranscriptToolFilePaths(entry)) {
@@ -3311,7 +3388,19 @@ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
3311
3388
  if (rel) out.add(rel);
3312
3389
  }
3313
3390
  }
3314
- return out;
3391
+ return { files: out, trusted };
3392
+ }
3393
+
3394
+ /**
3395
+ * Repo-relative POSIX paths of wiki files this session edited via direct
3396
+ * Edit/Write/MultiEdit/NotebookEdit tool_use. Returns a Set; empty when the
3397
+ * transcript is missing/unreadable (callers decide the fallback). A per-line
3398
+ * JSON parse error skips that line only (transcripts occasionally truncate).
3399
+ * A caller that needs to tell "genuinely empty" apart from "could not fully
3400
+ * enumerate" wants extractTouchedWikiFilesWithTrust instead.
3401
+ */
3402
+ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
3403
+ return extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir).files;
3315
3404
  }
3316
3405
 
3317
3406
  /**
@@ -3539,20 +3628,89 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3539
3628
  const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
3540
3629
  const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
3541
3630
 
3542
- // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK:
3543
- // they are human-fixable. Unpushed commits (ahead) DEMOTE to a notice: push is
3631
+ // Paths this session is accountable for at THIS close: the same signals the
3632
+ // lint partition below already trusts for exactly this question ("is this
3633
+ // file mine or pre-existing debt"), hoisted so the git check (step 1) can
3634
+ // partition on it too. Base = the mandatory close-target files (opts.lintScope
3635
+ // override, else the log-only shared root files, else one project's close
3636
+ // files under projectOverride, else every today-active project's); widened by
3637
+ // every file this session's transcript shows it editing directly.
3638
+ const closeAccountableScope = new Set(
3639
+ opts.lintScope ||
3640
+ (logOnly
3641
+ ? ['hot.md', 'log.md']
3642
+ : opts.projectOverride
3643
+ ? closeFileTargetsForProject(hypoDir, opts.projectOverride)
3644
+ : closeFileTargetsGlobal(hypoDir)),
3645
+ );
3646
+ // Trust signal for the git-scope check below (codex pre-commit review
3647
+ // BLOCKER 1): closeAccountableScope's mandatory-files base is always
3648
+ // reliable, but the transcript widening that catches an ad hoc page this
3649
+ // session edited is only as good as the transcript. No opts.transcriptPath
3650
+ // at all, an unreadable file, or a line that fails to parse all mean the
3651
+ // widened scope may be under-inclusive, NOT that this session touched
3652
+ // nothing extra. Only a fully-read, fully-parsed transcript earns
3653
+ // sessionTouchTrusted:true. A single tool_use with no file_path does NOT
3654
+ // lower trust: most tool_use blocks (Read, Bash, Grep, ...) never carry one
3655
+ // and that is expected, not corruption, so treating it as untrustworthy
3656
+ // would make almost every real transcript untrusted and defeat the scoping
3657
+ // this fix exists to add.
3658
+ let sessionTouchTrusted = false;
3659
+ if (opts.transcriptPath) {
3660
+ const widened = extractTouchedWikiFilesWithTrust(opts.transcriptPath, hypoDir);
3661
+ sessionTouchTrusted = widened.trusted;
3662
+ for (const f of widened.files) closeAccountableScope.add(f);
3663
+ }
3664
+
3665
+ // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but only
3666
+ // the ones inside closeAccountableScope, and only when sessionTouchTrusted
3667
+ // (above) says that scope can actually be trusted: a session's own scoped
3668
+ // auto-commit (commitWikiChanges, PR #222) can leave the working tree
3669
+ // non-empty when a DIFFERENT session sharing this vault still has its own
3670
+ // file dirty, the 2026-08-03 multi-session block. That dirty file is
3671
+ // human-fixable by whoever owns it, not by this session, so it demotes to
3672
+ // a notice (listed by path, never silently dropped) instead of refusing
3673
+ // this session's marker. A dirty file THIS session owns still blocks
3674
+ // unconditionally (fail-closed is unchanged for scope this session
3675
+ // actually touched), and so does an unattributable state: a git failure
3676
+ // gitDirtyFiles can't enumerate (dirty.length === 0 despite
3677
+ // git.uncommitted === true), OR a scope we cannot trust
3678
+ // (!sessionTouchTrusted) both fall back to the original unscoped blocker:
3679
+ // "cannot attribute" is not "clean".
3680
+ // Unpushed commits (ahead) DEMOTE to a notice regardless of scope: push is
3544
3681
  // automatic (auto-commit Stop hook) and its failures are already non-fatal, so
3545
3682
  // "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
3546
3683
  // here (the shared gate) keeps the marker == compact-ready invariant:
3547
3684
  // a committed-but-unpushed close marks AND compacts, instead of the close writer
3548
3685
  // committing its own payload and then being blocked by its own (unpushed) commit.
3549
3686
  const git = hypoIsClean(hypoDir);
3550
- if (git.uncommitted) blockers.push({ type: 'git', reason: git.reason });
3551
- else if (git.ahead)
3687
+ if (git.uncommitted) {
3688
+ const dirty = gitDirtyFiles(hypoDir);
3689
+ if (dirty.length === 0 || !sessionTouchTrusted) {
3690
+ blockers.push({ type: 'git', reason: git.reason });
3691
+ } else {
3692
+ const mine = dirty.filter((f) => closeAccountableScope.has(posixPath(f)));
3693
+ const foreign = dirty.filter((f) => !closeAccountableScope.has(posixPath(f)));
3694
+ if (mine.length > 0) {
3695
+ blockers.push({
3696
+ type: 'git',
3697
+ reason: `uncommitted changes in ${hypoDir}: ${mine.join(', ')}`,
3698
+ });
3699
+ }
3700
+ for (const f of foreign) {
3701
+ notices.push({
3702
+ type: 'git',
3703
+ file: f,
3704
+ reason: `uncommitted changes outside this session's scope: ${f}`,
3705
+ });
3706
+ }
3707
+ }
3708
+ } else if (git.ahead) {
3552
3709
  notices.push({
3553
3710
  type: 'git-sync',
3554
3711
  reason: `unpushed commits in ${hypoDir} (push deferred to Stop hook)`,
3555
3712
  });
3713
+ }
3556
3714
 
3557
3715
  // 2. root hot.md structure
3558
3716
  const hot = hotMdIsClean(hypoDir);
@@ -3762,25 +3920,13 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3762
3920
  const parsed = JSON.parse(r.stdout);
3763
3921
  const allErrors = parsed.errors || [];
3764
3922
  const allW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
3765
- // log-only base scope = the shared root files only (hot.md / log.md) — NOT
3766
- // closeFileTargetsGlobal, which would fold the active/phantom project's
3767
- // mandatory files in and re-introduce the cross-project attribution. The
3768
- // session's own transcript-touched files are still added below (a log-only
3769
- // session is accountable for the wiki files it actually edited).
3770
- // Lint scope: explicit opts.lintScope wins; else log-only uses the shared
3771
- // root files only; else projectOverride narrows to that one project's close
3772
- // files (matching the narrowed close status above); else the global set.
3773
- const scope = new Set(
3774
- opts.lintScope ||
3775
- (logOnly
3776
- ? ['hot.md', 'log.md']
3777
- : opts.projectOverride
3778
- ? closeFileTargetsForProject(hypoDir, opts.projectOverride)
3779
- : closeFileTargetsGlobal(hypoDir)),
3780
- );
3781
- if (opts.transcriptPath && existsSync(opts.transcriptPath)) {
3782
- for (const f of extractTouchedWikiFiles(opts.transcriptPath, hypoDir)) scope.add(f);
3783
- }
3923
+ // Lint scope = closeAccountableScope, hoisted above step 1 so the git
3924
+ // check can partition on the same "is this mine" answer. See that
3925
+ // hoisted comment for what feeds it (opts.lintScope override, else
3926
+ // log-only's shared root files, else one project's close files under
3927
+ // projectOverride, else the global set, widened by transcript-touched
3928
+ // files).
3929
+ const scope = closeAccountableScope;
3784
3930
  const part = partitionLintScope(allErrors, scope);
3785
3931
  if (part.blocking.length > 0) {
3786
3932
  blockers.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,9 +42,10 @@ import {
42
42
  unlinkSync,
43
43
  renameSync,
44
44
  realpathSync,
45
- lstatSync,
46
45
  openSync,
47
46
  closeSync,
47
+ statSync,
48
+ chmodSync,
48
49
  } from 'fs';
49
50
  import { randomBytes } from 'crypto';
50
51
  import { join, dirname, relative, sep } from 'path';
@@ -76,6 +77,7 @@ import {
76
77
  HOOK_EVENT_ALLOWLIST,
77
78
  SKILL_ROOT_FILE,
78
79
  EXT_PREFIX,
80
+ withSrcExecBits,
79
81
  } from './lib/extensions.mjs';
80
82
  import { readCoreHooksConfig, deriveCoreHookBasenames } from './lib/core-hooks.mjs';
81
83
 
@@ -582,7 +584,12 @@ function log(msg) {
582
584
  // `${dest}.tmp.${pid}` name was predictable, and writeFileSync on a path someone had
583
585
  // already planted a symlink at would follow it straight out of the wiki. O_EXCL fails
584
586
  // on an existing path of any kind, symlink included.
585
- function writeAtomic(dest, buf) {
587
+ // `srcMode`, when given, carries the source file's execute bit onto the wiki
588
+ // copy (openSync's own mode argument is clipped by umask same as writeFileSync,
589
+ // so this still has to be a separate chmod). Omitted for a manifest write: that
590
+ // content is JSON we generated, not a copy of something with a mode worth
591
+ // keeping.
592
+ function writeAtomic(dest, buf, srcMode) {
586
593
  const tmp = `${dest}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
587
594
  const fd = openSync(tmp, 'wx');
588
595
  try {
@@ -590,6 +597,9 @@ function writeAtomic(dest, buf) {
590
597
  } finally {
591
598
  closeSync(fd);
592
599
  }
600
+ if (srcMode != null) {
601
+ chmodSync(tmp, withSrcExecBits(statSync(tmp).mode, srcMode));
602
+ }
593
603
  try {
594
604
  renameSync(tmp, dest);
595
605
  } catch (err) {
@@ -719,7 +729,8 @@ function writeSkill({ rec, skillRoot, manifestPath, manifest, files, guard, wiki
719
729
  madeDirs.add(cur);
720
730
  }
721
731
  }
722
- writeAtomic(destPath, readFileSync(f.srcPath));
732
+ const buf = readFileSync(f.srcPath);
733
+ writeAtomic(destPath, buf, statSync(f.srcPath).mode);
723
734
  rec.createdFiles.push(destPath);
724
735
  }
725
736
  }
@@ -814,22 +825,6 @@ function captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped,
814
825
  return;
815
826
  }
816
827
 
817
- // Content round-trips; the executable bit does not (forward-sync writes with the
818
- // default mode). Say so rather than let a captured `scripts/run.sh` arrive
819
- // non-executable on the far machine without a word.
820
- const execFiles = c.files.filter((f) => {
821
- try {
822
- return (lstatSync(f.srcPath).mode & 0o111) !== 0;
823
- } catch {
824
- return false;
825
- }
826
- });
827
- if (execFiles.length > 0) {
828
- log(
829
- `! ${label}: ${execFiles.length} executable file(s) — content is captured, but the executable bit is not carried by sync`,
830
- );
831
- }
832
-
833
828
  if (!args.dryRun) {
834
829
  // The ledger is owned by the caller so a throw MID-write is still recoverable: the
835
830
  // paths created before the failure are already recorded in it.
@@ -1055,7 +1050,7 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
1055
1050
  manifestPrevBuf: existingManifestBuf,
1056
1051
  };
1057
1052
  writeAtomic(manifestPath, JSON.stringify(plan.manifest, null, 2) + '\n');
1058
- writeAtomic(filePath, srcBuf);
1053
+ writeAtomic(filePath, srcBuf, statSync(c.srcPath).mode);
1059
1054
  created.push(rec);
1060
1055
  }
1061
1056
  captured.push({ ...c, installFile, requiredKeys, status: 'ready' });
@@ -109,6 +109,7 @@ import {
109
109
  scopeVisible,
110
110
  readVisibilityScope,
111
111
  withFileLock,
112
+ extractTouchedWikiFilesWithTrust,
112
113
  } from '../hooks/hypo-shared.mjs';
113
114
  import { hashContent, readBaseEntry, advanceBase } from '../hooks/base-store.mjs';
114
115
  import { writeProposal } from '../hooks/proposal-store.mjs';
@@ -225,6 +226,35 @@ function requireProjectDir(args, slug) {
225
226
  }
226
227
  }
227
228
 
229
+ // When the global gate's own discovery (hot.md pointer table + today
230
+ // close-activity scan, both in hypo-shared.mjs) comes back with NO project at
231
+ // all, a real apply never hits that dead end: it is handed `payload.project`
232
+ // directly and never infers. --check-session-close has no payload, so its one
233
+ // remaining authoritative signal is the same session's own transcript — which
234
+ // project's files did THIS session actually touch. Reusing the exact
235
+ // evidence-resolution helper the widened-lint-scope path already trusts here
236
+ // keeps this a single inference vocabulary (touched wiki files), not a second
237
+ // one: the difference is only which project-shaped question gets asked of it.
238
+ // Never guessed: a transcript touching zero or more than one project's files
239
+ // leaves the check exactly as unresolved as it was before this fallback.
240
+ function deriveTouchedProject(hypoDir, transcriptPath) {
241
+ if (!transcriptPath) return null;
242
+ const { files, trusted } = extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir);
243
+ // `trusted:false` means the walk itself may be incomplete (a missing/unreadable
244
+ // transcript, or a line that failed to parse). A truncated line could have named
245
+ // a SECOND project the walk never saw, so treating this Set as "the whole
246
+ // truth" would resolve a single-project reading off a scope that is only
247
+ // single-project because part of it is missing, exactly the ambiguity this
248
+ // fallback exists to refuse rather than guess through.
249
+ if (!trusted) return null;
250
+ const slugs = new Set();
251
+ for (const f of files) {
252
+ const m = /^projects\/([^/]+)\//.exec(f);
253
+ if (m && existsSync(join(hypoDir, 'projects', m[1]))) slugs.add(m[1]);
254
+ }
255
+ return slugs.size === 1 ? [...slugs][0] : null;
256
+ }
257
+
228
258
  // ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
229
259
  // Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
230
260
  // flow can self-verify before /compact triggers PreCompact.
@@ -261,7 +291,7 @@ function runSessionCloseCheck(args) {
261
291
  args.transcriptPath ||
262
292
  (args.sessionId ? resolveTranscriptBySessionId(args.sessionId) : null) ||
263
293
  null;
264
- const status = precompactGateStatus(args.hypoDir, {
294
+ let status = precompactGateStatus(args.hypoDir, {
265
295
  ...(args.project
266
296
  ? { projectOverride: args.project }
267
297
  : checkTranscript
@@ -276,7 +306,30 @@ function runSessionCloseCheck(args) {
276
306
  // enforcement lives in the PreCompact/Stop hooks, which carry payload.cwd).
277
307
  ...(args.sessionCwd && !args.project ? { sessionCwd: args.sessionCwd } : {}),
278
308
  });
309
+
310
+ // check/apply divergence (2026-08-25 QA): a real apply never hits discovery
311
+ // dead-ends because payload.project is required input, not an inference. This
312
+ // check has no payload, so when discovery finds NO project at all (not even
313
+ // the recency fallback), it retries scoped to whatever single project this
314
+ // session's own transcript shows it touching. This is a diagnostic estimate,
315
+ // not a preview of what a real apply will do: a payload's `project` field is
316
+ // whatever the caller puts there and can legitimately name a project the
317
+ // transcript never mentions. Only fires on a fully unresolved global result,
318
+ // and only on a TRUSTED single-project reading (see deriveTouchedProject): an
319
+ // already-successful discovery, an ambiguous/empty transcript, or one the walk
320
+ // could not fully read is left untouched rather than guessed at.
321
+ let inferredProject = null;
322
+ if (!args.project && !status.close.project) {
323
+ inferredProject = deriveTouchedProject(args.hypoDir, checkTranscript);
324
+ if (inferredProject) {
325
+ status = precompactGateStatus(args.hypoDir, {
326
+ projectOverride: inferredProject,
327
+ ...(args.sessionId ? { sessionId: args.sessionId } : {}),
328
+ });
329
+ }
330
+ }
279
331
  const close = status.close;
332
+ const scopedProject = args.project || inferredProject;
280
333
 
281
334
  // When a --session-id is supplied, report whether THIS session's
282
335
  // per-session marker (the Stop-chain completion signal) exists. This is a
@@ -301,8 +354,8 @@ function runSessionCloseCheck(args) {
301
354
  // log-only marker governs the session, the gate runs in log-only mode and the
302
355
  // --project override is IGNORED — surface that rather than implying X was
303
356
  // checked (it was not).
304
- const logOnlyWon = args.project != null && markerObj?.scope === 'log-only';
305
- const scope = args.project ? (logOnlyWon ? 'log-only' : 'project') : 'global';
357
+ const logOnlyWon = scopedProject != null && markerObj?.scope === 'log-only';
358
+ const scope = scopedProject ? (logOnlyWon ? 'log-only' : 'project') : 'global';
306
359
 
307
360
  if (args.json) {
308
361
  console.log(
@@ -325,9 +378,13 @@ function runSessionCloseCheck(args) {
325
378
  skipped: status.skipped,
326
379
  // scope is additive; `global` keeps prior semantics for existing readers
327
380
  scope,
328
- ...(args.project
381
+ ...(scopedProject
329
382
  ? {
330
- scoped_project: args.project,
383
+ scoped_project: scopedProject,
384
+ // Distinguishes a user-typed --project from this check picking one
385
+ // for itself off the transcript — a reader should not mistake the
386
+ // latter for an explicit ask (see deriveTouchedProject above).
387
+ ...(inferredProject ? { project_inferred_from_transcript: true } : {}),
331
388
  ...(logOnlyWon ? { project_override_ignored: true } : {}),
332
389
  }
333
390
  : {}),
@@ -340,13 +397,20 @@ function runSessionCloseCheck(args) {
340
397
  process.exit(status.ok ? 0 : 1);
341
398
  }
342
399
 
400
+ // Label the scoped project by how it was chosen — an explicit --project reads
401
+ // as a flag the caller typed; an inferred one reads as this check's own guess
402
+ // off the transcript, so a reader does not credit the caller with an ask
403
+ // nobody made.
404
+ const scopedProjectLabel = args.project
405
+ ? `--project=${args.project}`
406
+ : `project=${scopedProject} (inferred from the session transcript, no --project given)`;
343
407
  if (logOnlyWon) {
344
408
  console.log(
345
- `Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and --project=${args.project} was IGNORED (no project was checked).\n`,
409
+ `Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and ${scopedProjectLabel} was IGNORED (no project was checked).\n`,
346
410
  );
347
411
  } else if (scope === 'project') {
348
412
  console.log(
349
- `Note: --project=${args.project} — this is a PROJECT-SCOPED diagnostic, not the global /compact gate. A green result means only ${args.project} is close-complete; another project can still block /compact.\n`,
413
+ `Note: ${scopedProjectLabel} — this is a PROJECT-SCOPED diagnostic, not the global /compact gate. A green result means only ${scopedProject} is close-complete; another project can still block /compact.\n`,
350
414
  );
351
415
  }
352
416
 
@@ -398,8 +462,8 @@ function runSessionCloseCheck(args) {
398
462
  // Do NOT claim global compact-readiness (the whole point of the narrow).
399
463
  console.log(
400
464
  status.ok
401
- ? `✓ ${args.project} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
402
- : `✗ ${args.project} is not close-complete — resolve the ✗ items above.`,
465
+ ? `✓ ${scopedProject} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
466
+ : `✗ ${scopedProject} is not close-complete — resolve the ✗ items above.`,
403
467
  );
404
468
  } else {
405
469
  console.log(
@@ -1043,7 +1107,10 @@ function applySessionClose(args) {
1043
1107
  stage: 'no-user-close-signal',
1044
1108
  reason: closeAuth.reason,
1045
1109
  applied: [],
1046
- committed: false,
1110
+ // `null`, not `false`: this refusal fires before the commit step is ever
1111
+ // reached (see the general result's own `committed` contract below).
1112
+ // `false` is reserved for a commit that actually ran and failed.
1113
+ committed: null,
1047
1114
  error: closeAuth.error,
1048
1115
  };
1049
1116
  console.log(
@@ -1103,7 +1170,9 @@ function applySessionClose(args) {
1103
1170
  stage: 'session-id-mismatch',
1104
1171
  error: msg,
1105
1172
  applied: [],
1106
- committed: false,
1173
+ // `null`, not `false` — refused before the commit step, same contract as
1174
+ // the `no-user-close-signal` refusal above.
1175
+ committed: null,
1107
1176
  };
1108
1177
  console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
1109
1178
  process.exit(1);
@@ -1734,6 +1803,10 @@ function applySessionClose(args) {
1734
1803
  // but silently.
1735
1804
  let markerWritten = false;
1736
1805
  let markerSkipReason = null;
1806
+ // Hoisted so the result JSON below can report it: `null` when this apply never
1807
+ // reached the commit step at all (ok:false before the writes were even
1808
+ // verified), distinct from a commit that ran and reported `committed:false`.
1809
+ let commitOutcome = null;
1737
1810
  if (ok && args.sessionId) {
1738
1811
  // IO stays lazy so this preserves the exact side-effect order (codex design
1739
1812
  // review): commit first (the only mutation), then resolve the
@@ -1749,7 +1822,6 @@ function applySessionClose(args) {
1749
1822
  // apply's stage+commit. A lock-timeout is treated exactly like any other
1750
1823
  // commit failure below (skip the marker, surface the reason) rather than
1751
1824
  // crashing the apply.
1752
- let commitOutcome;
1753
1825
  try {
1754
1826
  commitOutcome = withFileLock(vaultCommitLockTarget(args.hypoDir), () =>
1755
1827
  commitWikiChanges(args.hypoDir, appliedPaths),
@@ -1843,6 +1915,19 @@ function applySessionClose(args) {
1843
1915
  date,
1844
1916
  applied,
1845
1917
  skipped,
1918
+ // Was the general-shape sibling of the two early-refusal `committed:null`
1919
+ // fields (no-user-close-signal / session-id-mismatch), which this path never
1920
+ // carried before: a reader of `applied:[]` on a no-op re-run had no
1921
+ // `committed` value to check against and no way to tell it apart from a run
1922
+ // that never reached the commit step. `null` here means exactly that: `ok`
1923
+ // came back false before the commit ever ran (see `stage` for which check
1924
+ // failed: post-apply-verification, post-apply-lint, or proposal-pending). It
1925
+ // does NOT mean nothing was written — an overwrite/append can already be on
1926
+ // disk (see `applied` / `appliedUncommitted`) while `committed` stays `null`.
1927
+ // `true` covers both an actual commit and the legitimate "nothing to stage"
1928
+ // no-op (commitWikiChanges' own contract, see hooks/hypo-shared.mjs); `false`
1929
+ // is a real commit failure, surfaced together with markerSkipReason below.
1930
+ committed: commitOutcome ? commitOutcome.committed : null,
1846
1931
  // Targets withheld: an overwrite drifted from this session's observed base, or
1847
1932
  // an append could not take the file lock in time (`kind: 'append'`). Two
1848
1933
  // channels resolve these, and `proposals` vs `conflicts[].kind` are the sole
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'url';
20
20
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
21
  import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
22
22
  import { readRenameMarker, renameMarkerPath, RENAME_MARKER_REL } from './lib/rename-marker.mjs';
23
- import { resolveGitHooksDir } from './lib/git-hooks-dir.mjs';
23
+ import { resolveGitHooksDir, WIKI_PRE_COMMIT_MARKER_START } from './lib/git-hooks-dir.mjs';
24
24
  import { parseFrontmatter } from './lib/frontmatter.mjs';
25
25
  import {
26
26
  readSyncState,
@@ -506,7 +506,7 @@ function checkGit(hypoDir) {
506
506
  ? 'Not installed — run /hypo:init to install .hypoignore guard'
507
507
  : 'Not installed, and /hypo:init will not install into this path — point core.hooksPath back inside the repository, or install the guard yourself',
508
508
  );
509
- } else if (content.includes('# hypo-managed:pre-commit:start')) {
509
+ } else if (content.includes(WIKI_PRE_COMMIT_MARKER_START)) {
510
510
  pass(label, 'Hypomnema .hypoignore guard installed');
511
511
  } else {
512
512
  warn(label, 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore');
package/scripts/init.mjs CHANGED
@@ -38,7 +38,14 @@ import { execSync, spawnSync } from 'child_process';
38
38
  import { fileURLToPath } from 'url';
39
39
  import { createHash } from 'crypto';
40
40
  import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
41
- import { hooksDirForInstall, unsafeHookTargetReason } from './lib/git-hooks-dir.mjs';
41
+ import {
42
+ hooksDirForInstall,
43
+ unsafeHookTargetReason,
44
+ WIKI_PRE_COMMIT_MARKER_START,
45
+ WIKI_PRE_COMMIT_MARKER_END,
46
+ SHELL_MARKER_START,
47
+ SHELL_MARKER_END,
48
+ } from './lib/git-hooks-dir.mjs';
42
49
  import { readCoreHooksConfig } from './lib/core-hooks.mjs';
43
50
  import {
44
51
  readPkgJson as readPkgJsonSafe,
@@ -748,9 +755,6 @@ function installPkgGitHook(dryRun) {
748
755
 
749
756
  // ── wiki pre-commit hook ─────────────────────────────────────────────────────
750
757
 
751
- const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
752
- const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
753
-
754
758
  // Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
755
759
  function shellSingleQuote(p) {
756
760
  return `'${p.replace(/'/g, "'\\''")}'`;
@@ -859,9 +863,6 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
859
863
 
860
864
  // ── shell function setup ─────────────────────────────────────────────────────
861
865
 
862
- const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
863
- const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
864
-
865
866
  function shellFunctionBlock() {
866
867
  return `${SHELL_MARKER_START}
867
868
  function claude() {
@@ -28,6 +28,8 @@ import {
28
28
  unlinkSync,
29
29
  mkdirSync,
30
30
  lstatSync,
31
+ statSync,
32
+ chmodSync,
31
33
  rmdirSync,
32
34
  } from 'fs';
33
35
  import { join, dirname, relative, resolve, posix, sep } from 'path';
@@ -894,9 +896,26 @@ export function readExtensionPkgStateNoMutate(pkgPath, target) {
894
896
 
895
897
  // ── sync orchestration ─────────────────────────────────────────────────────────
896
898
 
897
- function writeFreshAtomic(dest, content) {
899
+ /** Carry only src's execute bits (owner/group/other) onto a mode value; every
900
+ * other permission bit (dest's own read/write, however umask shaped it) is
901
+ * left alone. This is the one place forward-sync decides "should this file be
902
+ * executable", so every writer of dest routes through it. Exported so
903
+ * capture.mjs's own atomic writer (a captured file's FIRST write into the wiki)
904
+ * applies the identical rule; the wiki copy must not lose the bit before
905
+ * forward-sync ever gets a chance to carry it further. */
906
+ export function withSrcExecBits(destMode, srcMode) {
907
+ return (destMode & ~0o111) | (srcMode & 0o111);
908
+ }
909
+
910
+ function writeFreshAtomic(dest, content, srcMode) {
898
911
  const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
899
912
  writeFileSync(tmp, content);
913
+ if (srcMode != null) {
914
+ // writeFileSync has no mode option that survives umask, so the execute bit
915
+ // has to be applied explicitly, and before the rename: otherwise dest is
916
+ // briefly visible with the wrong mode to anything racing this write.
917
+ chmodSync(tmp, withSrcExecBits(statSync(tmp).mode, srcMode));
918
+ }
900
919
  try {
901
920
  renameSync(tmp, dest);
902
921
  } catch (err) {
@@ -914,13 +933,19 @@ function writeFreshAtomic(dest, content) {
914
933
  * overwrites user-modified / unowned files; without it those are left untouched and
915
934
  * surface as drift/conflict. A symlink/non-regular dest is never followed even under
916
935
  * force (the isRegularFile guard precedes the force branch).
936
+ *
937
+ * The executable bit is not tracked anywhere (no mode column in the SHA map's
938
+ * ownership model), so src's mode is the only source of truth for it and gets
939
+ * carried onto dest on every branch that (re)writes it, including the
940
+ * content-identical `up-to-date` branch below.
917
941
  */
918
942
  function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
919
943
  const srcContent = readFileSync(srcPath);
920
944
  const srcSHA = sha256(srcContent);
945
+ const srcMode = statSync(srcPath).mode;
921
946
 
922
947
  if (!existsSync(destPath)) {
923
- if (apply) writeFreshAtomic(destPath, srcContent);
948
+ if (apply) writeFreshAtomic(destPath, srcContent, srcMode);
924
949
  return { action: 'create', sha: srcSHA };
925
950
  }
926
951
  if (!isRegularFile(destPath)) {
@@ -933,6 +958,20 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
933
958
  }
934
959
  const onDiskSHA = sha256(onDisk);
935
960
  if (onDiskSHA === srcSHA) {
961
+ // Content already matches, but the exec bit can still be stale: this used to
962
+ // be a pure no-op, which is exactly why a mismatched bit here never healed.
963
+ // Reported as 'update' (not a new action) so every existing "N to sync" /
964
+ // "N synced" count and log line already keyed on create/update/force-update
965
+ // picks it up for free.
966
+ // ponytail: this also overwrites an exec bit a user deliberately flipped on a
967
+ // file whose content happens to still match src (chmod -x'd a script they
968
+ // like read-only). Upgrade path: record mode next to sha in the pkg-json map
969
+ // so a user's own bit can be told apart from our default and left alone.
970
+ const destMode = statSync(destPath).mode;
971
+ if ((destMode & 0o111) !== (srcMode & 0o111)) {
972
+ if (apply) chmodSync(destPath, withSrcExecBits(destMode, srcMode));
973
+ return { action: 'update', sha: srcSHA };
974
+ }
936
975
  return { action: 'up-to-date', sha: srcSHA };
937
976
  }
938
977
  if (recordedSHA && onDiskSHA === recordedSHA) {
@@ -942,7 +981,7 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
942
981
  if (!verify || sha256(verify) !== recordedSHA) {
943
982
  return { action: 'skip-changed', sha: recordedSHA };
944
983
  }
945
- writeFreshAtomic(destPath, srcContent);
984
+ writeFreshAtomic(destPath, srcContent, srcMode);
946
985
  }
947
986
  return { action: 'update', sha: srcSHA };
948
987
  }
@@ -950,7 +989,7 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
950
989
  if (force) {
951
990
  if (apply) {
952
991
  writeFreshAtomic(`${destPath}.bak`, onDisk);
953
- writeFreshAtomic(destPath, srcContent);
992
+ writeFreshAtomic(destPath, srcContent, srcMode);
954
993
  }
955
994
  return { action: 'force-update', sha: srcSHA };
956
995
  }
@@ -38,6 +38,16 @@ import { execFileSync } from 'child_process';
38
38
  import { existsSync, lstatSync, realpathSync, statSync } from 'fs';
39
39
  import { basename, dirname, isAbsolute, join, resolve, sep } from 'path';
40
40
 
41
+ // ── shared install/uninstall markers ────────────────────────────────────────
42
+ // init.mjs writes these when it installs the wiki's git pre-commit hook and
43
+ // the shell rc block; uninstall.mjs reads them back to remove exactly what
44
+ // init created. Defined once here, imported by both, so the two scripts can
45
+ // never drift into recognizing different markers.
46
+ export const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
47
+ export const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
48
+ export const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
49
+ export const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
50
+
41
51
  // Fallback scrub list for git versions without `rev-parse --local-env-vars`.
42
52
  // Mirrors scripts/install-git-hooks.mjs, which established this trust model.
43
53
  const STATIC_LOCAL_ENV_VARS = [
package/scripts/lint.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
20
20
  import { join, extname, basename } from 'path';
21
21
  import { resolveHypoRootInfo, checkVaultOrExit, expandHome } from './lib/hypo-root.mjs';
22
- import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
22
+ import { SESSION_STATE_NEXT_HEADINGS, closeFileTargetsGlobal } from '../hooks/hypo-shared.mjs';
23
23
  import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
24
24
  import {
25
25
  parseSchemaVocab,
@@ -554,6 +554,50 @@ const validTypes = new Set([...VALID_TYPES, ...parseSchemaTypes(args.hypoDir)]);
554
554
 
555
555
  for (const page of pages) lintPage(page, slugMap, tagVocab, pageDirs, validTypes);
556
556
 
557
+ // W4 (broken-wikilink only, NOT the full lintPage) for close's root-level
558
+ // write targets that fall outside pages/projects/journal: hot.md and log.md
559
+ // today. closeFileTargetsGlobal is the one list of what close writes
560
+ // (hooks/hypo-shared.mjs), reused here instead of re-derived, so a future
561
+ // close target is covered automatically. Only the entries NOT already under
562
+ // a scanDir are new; closeFileTargetsGlobal's projects/<slug>/* entries are
563
+ // already linted in full above.
564
+ //
565
+ // Why link-only and not the full lintPage: measured, not assumed. On the
566
+ // packaged templates/ (hot.md type:reference, log.md type:log, both declared
567
+ // in templates/SCHEMA.md's taxonomy), full lintPage on both files comes back
568
+ // completely clean, so "these types trip W2" is not the reason to hold back.
569
+ // The real reason is that live vaults do not all match the template. A vault
570
+ // whose SCHEMA.md predates the `log` type row (or has none) gets a fresh
571
+ // "Unknown type: log" W2 the moment log.md is run through lintPage, and a
572
+ // vault whose root log.md predates the frontmatter convention entirely (no
573
+ // leading `---` block at all, confirmed against a real maintainer vault)
574
+ // gets a fresh "No frontmatter found" W1. Both W1 and W2 are in
575
+ // STRICT_PROMOTE_IDS, so under --strict a vault that lint has always passed
576
+ // would start failing on a file whose content this fix never touched. Full
577
+ // lintPage stays reserved for pages/projects/journal, where every file was
578
+ // already being linted before this change and there is no such newly-exposed
579
+ // vault. W4 itself stays warn-only outside --strict, and postApply
580
+ // (crystallize.mjs) gates only on errors, so this addition can add a new
581
+ // warning to a close's lint output but can never fail one.
582
+ const closeRootTargets = [...closeFileTargetsGlobal(args.hypoDir)].filter(
583
+ (f) => !f.startsWith('pages/') && !f.startsWith('projects/') && !f.startsWith('journal/'),
584
+ );
585
+ for (const rel of closeRootTargets) {
586
+ const full = join(args.hypoDir, rel);
587
+ if (!existsSync(full) || isScanIgnored(full, args.hypoDir, ignorePatterns)) continue;
588
+ let content;
589
+ try {
590
+ content = readFileSync(full, 'utf-8');
591
+ } catch {
592
+ continue;
593
+ }
594
+ for (const link of extractWikilinks(content)) {
595
+ if (!slugMap.has(link)) {
596
+ issue('warn', rel, `Broken wikilink: [[${link}]]`, null, 'W4');
597
+ }
598
+ }
599
+ }
600
+
557
601
  // W8: design-history.md stale relative to session-log.md. Emitted once per
558
602
  // project (not per page) — runs outside the page loop. POSIX-separated path
559
603
  // literal (not path.join) so consumers can rely on `file.split('/')` shape
@@ -14,6 +14,15 @@
14
14
  * --force-commands Remove user-modified slash commands instead of preserving them
15
15
  * --force-extensions Remove user-modified extension files (hypo-ext-*) instead of preserving them
16
16
  * --hooks-dir=<path> Override Claude hooks directory (default: ~/.claude/hooks)
17
+ * --hypo-dir=<path> Wiki vault to remove the pre-commit hook from (default: auto-resolve,
18
+ * same rules as init/lint/query)
19
+ * --shell-config=<path> Shell rc file to strip the shell block from (default: checks both
20
+ * ~/.zshrc and ~/.bashrc, since init may have run under either shell)
21
+ *
22
+ * The wiki's git pre-commit hook and the shell rc's `claude()` wrapper function are removed
23
+ * only when they still carry the marker init.mjs wrote (WIKI_PRE_COMMIT_MARKER_START /
24
+ * SHELL_MARKER_START, both from ./lib/git-hooks-dir.mjs). A user's own pre-commit hook, a
25
+ * symlinked hook target, and any rc content outside the marker block are never touched.
17
26
  *
18
27
  * Extensions: hypo-ext-* hard-copies under
19
28
  * ~/.claude/{hooks,commands,skills,agents}/ and ~/.codex/{hooks,commands}/ (with
@@ -23,7 +32,16 @@
23
32
  * does not follow them). The wiki source (~/hypomnema/extensions/) is preserved.
24
33
  */
25
34
 
26
- import { existsSync, readFileSync, writeFileSync, rmSync, rmdirSync, readdirSync } from 'fs';
35
+ import {
36
+ existsSync,
37
+ readFileSync,
38
+ writeFileSync,
39
+ rmSync,
40
+ rmdirSync,
41
+ readdirSync,
42
+ statSync,
43
+ realpathSync,
44
+ } from 'fs';
27
45
  import { join } from 'path';
28
46
  import { homedir } from 'os';
29
47
  import { fileURLToPath } from 'url';
@@ -48,6 +66,15 @@ import {
48
66
  buildHookCommand,
49
67
  } from './lib/extensions.mjs';
50
68
  import { removeProvenanceSidecar } from './lib/pkg-provenance.mjs';
69
+ import {
70
+ hooksDirForInstall,
71
+ unsafeHookTargetReason,
72
+ WIKI_PRE_COMMIT_MARKER_START,
73
+ WIKI_PRE_COMMIT_MARKER_END,
74
+ SHELL_MARKER_START,
75
+ SHELL_MARKER_END,
76
+ } from './lib/git-hooks-dir.mjs';
77
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
51
78
 
52
79
  const HOME = homedir();
53
80
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -428,6 +455,182 @@ function stripExtensionSettings(settingsPath, hooksDir, apply, ownedCommands = n
428
455
  return { stripped };
429
456
  }
430
457
 
458
+ // ── marker-span validation (shared by both removal paths below) ────────────
459
+
460
+ // Two independent indexOf() calls cannot tell "well-formed" apart from
461
+ // "duplicated" or "swapped": if a file happens to hold two full copies of the
462
+ // block, indexOf finds only the first END, so slicing [firstStart, firstEnd]
463
+ // leaves the second copy's install behind with no report of it. If END
464
+ // precedes START (a hand-edited or corrupted file), slicing [start, end) with
465
+ // start > end does not error, it silently duplicates whatever sits between
466
+ // them into the "removed" span. Neither this script nor the file it is
467
+ // touching has a way back from either outcome, so a span is only trusted when
468
+ // both markers appear EXACTLY once and START comes before END.
469
+ function countOccurrences(content, needle) {
470
+ let count = 0;
471
+ let idx = 0;
472
+ while ((idx = content.indexOf(needle, idx)) !== -1) {
473
+ count++;
474
+ idx += needle.length;
475
+ }
476
+ return count;
477
+ }
478
+
479
+ function findMarkerSpan(content, startMarker, endMarker) {
480
+ const startCount = countOccurrences(content, startMarker);
481
+ const endCount = countOccurrences(content, endMarker);
482
+ if (startCount !== 1 || endCount !== 1) {
483
+ return {
484
+ ok: false,
485
+ reason: `expected exactly one start and one end marker, found ${startCount} start / ${endCount} end`,
486
+ };
487
+ }
488
+ const startIdx = content.indexOf(startMarker);
489
+ const endIdx = content.indexOf(endMarker);
490
+ if (!(startIdx < endIdx)) {
491
+ return { ok: false, reason: 'the end marker appears before the start marker' };
492
+ }
493
+ return { ok: true, startIdx, endIdx };
494
+ }
495
+
496
+ // ── wiki pre-commit hook removal ────────────────────────────────────────────
497
+
498
+ // Mirrors init.mjs's own resolution (hooksDirForInstall) as the PRIMARY
499
+ // candidate, so this finds the hook wherever init would put it today,
500
+ // including under a core.hooksPath override. A second, best-effort candidate
501
+ // (the vault's plain .git/hooks/pre-commit) is checked too: if core.hooksPath
502
+ // changed after install, the current resolution no longer points at the file
503
+ // init actually wrote, and that file would otherwise never be found. Both
504
+ // candidates go through the same marker/ownership gate below, so widening the
505
+ // search costs nothing in safety, only in how many places we bother to look.
506
+ // A hook that still carries our marker is removed; a user's own pre-commit (no
507
+ // marker), a symlinked/non-regular target, and a hook whose marker is
508
+ // duplicated, swapped, or missing its shebang are all left standing.
509
+ // `pre-commit.bak` (init's --force-commands backup of the user's original
510
+ // hook) is never removed here, only reported so the user knows it exists.
511
+ function removeWikiPreCommitHook(hypoDir, apply) {
512
+ const result = { removed: [], skipped: [], bakPresent: [] };
513
+
514
+ if (!hypoDir || !existsSync(join(hypoDir, 'hypo-config.md'))) {
515
+ result.skipped.push(`no Hypomnema vault found${hypoDir ? ` at ${hypoDir}` : ''} — nothing to remove`);
516
+ return result;
517
+ }
518
+
519
+ const candidateDirs = [];
520
+ const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
521
+ if (hooksDir) candidateDirs.push(hooksDir);
522
+ else if (skip) result.skipped.push(skip);
523
+
524
+ // Best-effort fallback: only when `.git` is a real directory here (a plain
525
+ // checkout, not a linked worktree's gitdir-pointer FILE, which this join
526
+ // would misread entirely — resolveGitHooksDir already handles that layout
527
+ // correctly via the primary candidate above).
528
+ const legacyGitDir = join(hypoDir, '.git');
529
+ if (existsSync(legacyGitDir) && statSync(legacyGitDir).isDirectory()) {
530
+ candidateDirs.push(join(legacyGitDir, 'hooks'));
531
+ }
532
+
533
+ const seen = new Set();
534
+ for (const dir of candidateDirs) {
535
+ const hookPath = join(dir, 'pre-commit');
536
+ // Dedupe by the resolved real path, not the string: the primary candidate
537
+ // is realpath'd internally (resolveGitHooksDir's canonicalize) while the
538
+ // legacy join above is not, so the same physical file can arrive under two
539
+ // different-looking paths. Falling back to the raw path when the file does
540
+ // not exist is fine — two distinct absent candidates never collide.
541
+ let key = hookPath;
542
+ try {
543
+ key = realpathSync(hookPath);
544
+ } catch {
545
+ // leave key as hookPath
546
+ }
547
+ if (seen.has(key)) continue;
548
+ seen.add(key);
549
+
550
+ const bakPath = `${hookPath}.bak`;
551
+ if (existsSync(bakPath)) result.bakPresent.push(bakPath);
552
+
553
+ const unsafe = unsafeHookTargetReason(hookPath);
554
+ if (unsafe) {
555
+ result.skipped.push(`${hookPath} (${unsafe})`);
556
+ continue;
557
+ }
558
+ if (!existsSync(hookPath)) continue; // already absent, nothing to report
559
+
560
+ let content;
561
+ try {
562
+ content = readFileSync(hookPath, 'utf-8');
563
+ } catch (e) {
564
+ result.skipped.push(`${hookPath} (cannot read: ${e.code || e.message})`);
565
+ continue;
566
+ }
567
+ if (!content.includes(WIKI_PRE_COMMIT_MARKER_START) || !content.includes(WIKI_PRE_COMMIT_MARKER_END)) {
568
+ result.skipped.push(`${hookPath} (not managed by Hypomnema — preserving)`);
569
+ continue;
570
+ }
571
+
572
+ const span = findMarkerSpan(content, WIKI_PRE_COMMIT_MARKER_START, WIKI_PRE_COMMIT_MARKER_END);
573
+ if (!span.ok) {
574
+ result.skipped.push(`${hookPath} (${span.reason} — preserving)`);
575
+ continue;
576
+ }
577
+
578
+ // init writes the marker as the ENTIRE hook body: a bare "#!/bin/sh\n"
579
+ // right before WIKI_PRE_COMMIT_MARKER_START, nothing after
580
+ // WIKI_PRE_COMMIT_MARKER_END. A file with no shebang there was never
581
+ // written by init even if it happens to carry a well-formed marker span
582
+ // (hand-authored or copy-pasted) — deleting it on marker presence alone
583
+ // would remove code we do not own. A user who appended their own check
584
+ // after the block turned this into a file we only partly own. init's own
585
+ // --force-commands path handles the analogous case by OVERWRITING with
586
+ // equivalent content (a safe merge); uninstall has no such repair, only
587
+ // rmSync, so treating "extra content" the same as "fully ours" would
588
+ // silently delete the user's check with no way back.
589
+ const before = content.slice(0, span.startIdx);
590
+ const after = content.slice(span.endIdx + WIKI_PRE_COMMIT_MARKER_END.length);
591
+ if (!/^#![^\n]*\n$/.test(before)) {
592
+ result.skipped.push(`${hookPath} (hook carries content before the Hypomnema block — preserving)`);
593
+ continue;
594
+ }
595
+ if (after.trim() !== '') {
596
+ result.skipped.push(`${hookPath} (hook carries content after the Hypomnema block — preserving)`);
597
+ continue;
598
+ }
599
+
600
+ if (apply) rmSync(hookPath);
601
+ result.removed.push(hookPath);
602
+ }
603
+
604
+ return result;
605
+ }
606
+
607
+ // ── shell function block removal ────────────────────────────────────────────
608
+
609
+ // init picks ONE rc file at install time from $SHELL (or --shell-config), but
610
+ // $SHELL by uninstall time may point somewhere else, or init may have run in
611
+ // a different shell session altogether — so both common rc files are checked
612
+ // by default rather than guessing one. Only the marker span itself is
613
+ // stripped; every other byte in the file, including surrounding blank lines,
614
+ // is left exactly as it was. A malformed span (duplicated or swapped markers,
615
+ // see findMarkerSpan above) leaves the file completely untouched: an rc file
616
+ // is the user's own, and this script has no backup to restore it from.
617
+ function removeShellFunctionBlock(shellConfigPath, apply) {
618
+ if (!existsSync(shellConfigPath)) return null;
619
+ const content = readFileSync(shellConfigPath, 'utf-8');
620
+ if (!content.includes(SHELL_MARKER_START) && !content.includes(SHELL_MARKER_END)) {
621
+ return null; // block not present here at all
622
+ }
623
+
624
+ const span = findMarkerSpan(content, SHELL_MARKER_START, SHELL_MARKER_END);
625
+ if (!span.ok) {
626
+ return { path: shellConfigPath, removed: false, skipped: span.reason };
627
+ }
628
+
629
+ const updated = content.slice(0, span.startIdx) + content.slice(span.endIdx + SHELL_MARKER_END.length);
630
+ if (apply) writeFileSync(shellConfigPath, updated);
631
+ return { path: shellConfigPath, removed: true, skipped: null };
632
+ }
633
+
431
634
  // ── arg parsing ──────────────────────────────────────────────────────────────
432
635
 
433
636
  function parseArgs(argv) {
@@ -437,6 +640,8 @@ function parseArgs(argv) {
437
640
  hooksDir: null,
438
641
  forceCommands: false,
439
642
  forceExtensions: false,
643
+ hypoDir: null,
644
+ shellConfig: null,
440
645
  };
441
646
  for (const arg of argv.slice(2)) {
442
647
  if (arg === '--apply') args.apply = true;
@@ -444,6 +649,16 @@ function parseArgs(argv) {
444
649
  else if (arg === '--force-commands') args.forceCommands = true;
445
650
  else if (arg === '--force-extensions') args.forceExtensions = true;
446
651
  else if (arg.startsWith('--hooks-dir=')) args.hooksDir = arg.slice(12);
652
+ // expandHome mirrors init.mjs's own --hypo-dir/--shell-config parsing
653
+ // (init.mjs's parseArgs) exactly, reusing the same function from
654
+ // ./lib/hypo-root.mjs rather than re-deriving it. Uninstall must undo
655
+ // whatever path init actually wrote to disk, and init resolves a leading
656
+ // "~/" itself (the shell never does, since the value arrives already
657
+ // quoted inside "--flag=value"); skipping that step here would silently
658
+ // fail to find the vault or rc file a user installed with "~/..." to
659
+ // begin with.
660
+ else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
661
+ else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
447
662
  }
448
663
  return args;
449
664
  }
@@ -589,6 +804,20 @@ const hookResult = removeHookFiles(claudeHooksDir, hookFiles, args.apply);
589
804
  const settingsResult = stripSettingsJson(claudeSettings, claudeHooksDir, hookMap, args.apply);
590
805
  const commandResult = removeCommands(args.apply, args.forceCommands);
591
806
 
807
+ // Wiki-side cleanup: the git pre-commit hook and the shell rc block init.mjs
808
+ // installs outside ~/.claude entirely. Both are independent of --codex/--hooks-dir.
809
+ const hypoDir = args.hypoDir ?? resolveHypoRoot();
810
+ const preCommitResult = removeWikiPreCommitHook(hypoDir, args.apply);
811
+
812
+ const shellConfigCandidates = args.shellConfig
813
+ ? [args.shellConfig]
814
+ : [join(HOME, '.zshrc'), join(HOME, '.bashrc')];
815
+ const shellBlockOutcomes = shellConfigCandidates
816
+ .map((p) => removeShellFunctionBlock(p, args.apply))
817
+ .filter(Boolean);
818
+ const shellBlockResults = shellBlockOutcomes.filter((r) => r.removed).map((r) => r.path);
819
+ const shellBlockSkipped = shellBlockOutcomes.filter((r) => !r.removed);
820
+
592
821
  // Extensions. Order matters: remove files first, then strip
593
822
  // settings, then surgically clear the per-target SHA map. The SHA strip uses
594
823
  // removedKeys so a user-modified file we left in place keeps its recorded SHA
@@ -736,6 +965,27 @@ if (hookResult.missing.length)
736
965
  lines.push(
737
966
  `⊘ Already absent (${hookResult.missing.length}):\n${hookResult.missing.map((p) => ` ${p}`).join('\n')}`,
738
967
  );
968
+ if (preCommitResult.removed.length)
969
+ lines.push(
970
+ `✓ Wiki pre-commit hook ${dryRun ? 'to remove' : 'removed'} (${preCommitResult.removed.length}):\n${preCommitResult.removed.map((p) => ` ${p}`).join('\n')}`,
971
+ );
972
+ if (preCommitResult.skipped.length)
973
+ lines.push(
974
+ `⊘ Wiki pre-commit hook preserved:\n${preCommitResult.skipped.map((p) => ` ${p}`).join('\n')}`,
975
+ );
976
+ if (preCommitResult.bakPresent.length)
977
+ lines.push(
978
+ `ⓘ Pre-commit backup left in place (from --force-commands, never touched by uninstall):\n${preCommitResult.bakPresent.map((p) => ` ${p}`).join('\n')}`,
979
+ );
980
+ if (shellBlockResults.length)
981
+ lines.push(
982
+ `✓ Shell function block ${dryRun ? 'to remove' : 'removed'} (${shellBlockResults.length}):\n${shellBlockResults.map((p) => ` ${p}`).join('\n')}`,
983
+ );
984
+ if (shellBlockSkipped.length)
985
+ lines.push(
986
+ `⊘ Shell function block preserved:\n${shellBlockSkipped.map((r) => ` ${r.path} (${r.skipped})`).join('\n')}`,
987
+ );
988
+
739
989
  if (settingsResult.error) lines.push(`⚠ ${settingsResult.error}`);
740
990
  if (claudeExtSettings.error) lines.push(`⚠ ${claudeExtSettings.error}`);
741
991
  if (codexExtSettings.error) lines.push(`⚠ ${codexExtSettings.error}`);
@@ -750,7 +1000,9 @@ if (
750
1000
  !pkgJsonRemoved &&
751
1001
  !commandResult.skippedUserModified.length &&
752
1002
  !extSkippedUserModified.length &&
753
- !extSkippedNonRegular.length
1003
+ !extSkippedNonRegular.length &&
1004
+ !preCommitResult.removed.length &&
1005
+ !shellBlockResults.length
754
1006
  ) {
755
1007
  lines.push('Nothing to uninstall — Hypomnema does not appear to be installed.');
756
1008
  }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.7.2"
4
+ version: "1.7.3"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7