hypomnema 1.7.3 → 1.7.4

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.
@@ -45,7 +45,8 @@ import {
45
45
  openSync,
46
46
  closeSync,
47
47
  statSync,
48
- chmodSync,
48
+ fstatSync,
49
+ fchmodSync,
49
50
  } from 'fs';
50
51
  import { randomBytes } from 'crypto';
51
52
  import { join, dirname, relative, sep } from 'path';
@@ -589,17 +590,27 @@ function log(msg) {
589
590
  // so this still has to be a separate chmod). Omitted for a manifest write: that
590
591
  // content is JSON we generated, not a copy of something with a mode worth
591
592
  // keeping.
593
+ //
594
+ // The mode is set on the open FD (`fchmodSync`), before the FD is closed, the
595
+ // same ordering the forward writer (extensions.mjs's writeFreshAtomic) already
596
+ // uses. A pathname-based `chmodSync(tmp, ...)` run after `closeSync` reopens
597
+ // `tmp` by name, and a competing process racing this write could have already
598
+ // deleted `tmp` and planted a symlink at that name in the gap between close
599
+ // and chmod — `wx` only protects the file's creation, not everything after it.
600
+ // Chmod-ing the FD closes that window: it always addresses the file this
601
+ // process itself just created, never whatever a symlink at the same name
602
+ // might point to by the time the pathname is looked up again.
592
603
  function writeAtomic(dest, buf, srcMode) {
593
604
  const tmp = `${dest}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
594
605
  const fd = openSync(tmp, 'wx');
595
606
  try {
596
607
  writeFileSync(fd, buf);
608
+ if (srcMode != null) {
609
+ fchmodSync(fd, withSrcExecBits(fstatSync(fd).mode, srcMode));
610
+ }
597
611
  } finally {
598
612
  closeSync(fd);
599
613
  }
600
- if (srcMode != null) {
601
- chmodSync(tmp, withSrcExecBits(statSync(tmp).mode, srcMode));
602
- }
603
614
  try {
604
615
  renameSync(tmp, dest);
605
616
  } catch (err) {
@@ -102,7 +102,7 @@ import {
102
102
  hasSessionLogHeading,
103
103
  hasLogEntry,
104
104
  resolveTranscriptBySessionId,
105
- hasUserCloseSignal,
105
+ isCloseGateOpen,
106
106
  commitWikiChanges,
107
107
  vaultCommitLockTarget,
108
108
  currentDevice,
@@ -113,6 +113,7 @@ import {
113
113
  } from '../hooks/hypo-shared.mjs';
114
114
  import { hashContent, readBaseEntry, advanceBase } from '../hooks/base-store.mjs';
115
115
  import { writeProposal } from '../hooks/proposal-store.mjs';
116
+ import { recordGateClosed, resolutionStamp, closeGateStatus } from '../hooks/close-gate-store.mjs';
116
117
 
117
118
  // This script's own absolute path. Used to print copy-pasteable recovery
118
119
  // commands as `node <SELF_SCRIPT> ...` rather than a bare `crystallize` bin,
@@ -750,7 +751,7 @@ function runMarkSessionClosed(args) {
750
751
  // /compact, or an AskUserQuestion close answer). This is the hard backstop for
751
752
  // model over-close, where prose guidance lost to a conflicting global rule.
752
753
  // Fail-closed when the transcript can't be resolved.
753
- if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
754
+ if (!closeTranscript || !isCloseGateOpen(closeTranscript)) {
754
755
  const reason = !closeTranscript
755
756
  ? `cannot resolve a transcript for session ${args.sessionId} — the session-closed marker requires a verifiable user close signal`
756
757
  : "no user close signal in this session's transcript — marker refused (the user did not signal session close)";
@@ -938,7 +939,7 @@ const CLOSE_REFUSAL_HELP = [
938
939
  * { ok: false, reason, error } reason: session-id-required | transcript-unresolved
939
940
  * | no-user-close-signal
940
941
  */
941
- function verifyCloseAuthority(sessionId) {
942
+ function verifyCloseAuthority(sessionId, hypoDir) {
942
943
  if (!sessionId) {
943
944
  return {
944
945
  ok: false,
@@ -961,13 +962,15 @@ function verifyCloseAuthority(sessionId) {
961
962
  `authority here.`,
962
963
  };
963
964
  }
964
- if (!hasUserCloseSignal(transcript)) {
965
+ const gateStatus = closeGateStatus({ transcriptPath: transcript, hypoDir, sessionId });
966
+ if (!gateStatus.ok) {
965
967
  return {
966
968
  ok: false,
967
969
  reason: 'no-user-close-signal',
968
970
  error:
969
971
  "session-close apply refused before any wiki write or commit: this session's transcript " +
970
- 'carries no user close signal. The user did not ask to close.',
972
+ 'carries no user close signal. The user did not ask to close. ' +
973
+ `Gate detail: ${gateStatus.reason}`,
971
974
  };
972
975
  }
973
976
  return { ok: true };
@@ -1100,7 +1103,9 @@ function applySessionClose(args) {
1100
1103
  // Only a payload-bearing call can write. A payload-less one falls through to the
1101
1104
  // "payload is required" error below without touching a byte, so gating it here
1102
1105
  // would just replace one refusal with a less accurate one.
1103
- const closeAuth = args.payload ? verifyCloseAuthority(args.sessionId) : { ok: true };
1106
+ const closeAuth = args.payload
1107
+ ? verifyCloseAuthority(args.sessionId, args.hypoDir)
1108
+ : { ok: true };
1104
1109
  if (!closeAuth.ok) {
1105
1110
  const out = {
1106
1111
  ok: false,
@@ -1808,6 +1813,39 @@ function applySessionClose(args) {
1808
1813
  // verified), distinct from a commit that ran and reported `committed:false`.
1809
1814
  let commitOutcome = null;
1810
1815
  if (ok && args.sessionId) {
1816
+ // Close-gate resolution: apply succeeding (`ok`) IS the resolution, not
1817
+ // whether the per-session marker below happens to land. The marker can
1818
+ // be withheld for reasons that have nothing to do with whether this
1819
+ // apply's own writes were valid (a stale git tree, a feedback-projection
1820
+ // cap, W8 design-history staleness) — none of that should leave the
1821
+ // resolution unrecorded, because the wiki writes already happened, and
1822
+ // re-running the SAME apply with no fresh user close signal is exactly
1823
+ // what this record exists to block. So this sits OUTSIDE and ahead of
1824
+ // the marker's own commit-gated logic below, resolving its own
1825
+ // transcript rather than sharing the marker's `closeTranscript` (which
1826
+ // stays null whenever the commit fails) — a commit failure withholds
1827
+ // the marker but must not also withhold the resolution.
1828
+ //
1829
+ // Best-effort like every other write in this store: resolutionStamp
1830
+ // returns null on anything it cannot read as a Buffer, recordGateClosed
1831
+ // refuses a null stamp, and both fail silently, so a transcript that
1832
+ // vanishes mid-read (or a cache-write failure) can never turn an
1833
+ // otherwise-successful apply into a failure.
1834
+ try {
1835
+ const resolutionTranscriptPath = resolveTranscriptBySessionId(args.sessionId);
1836
+ if (resolutionTranscriptPath) {
1837
+ recordGateClosed(
1838
+ args.hypoDir,
1839
+ args.sessionId,
1840
+ resolutionStamp(readFileSync(resolutionTranscriptPath)),
1841
+ );
1842
+ }
1843
+ } catch {
1844
+ // Unreadable at the moment of a successful close is not this apply's
1845
+ // problem to surface — the resolution just stays unrecorded, same as
1846
+ // if this session had never resolved at all (NO_CONSTRAINT).
1847
+ }
1848
+
1811
1849
  // IO stays lazy so this preserves the exact side-effect order (codex design
1812
1850
  // review): commit first (the only mutation), then resolve the
1813
1851
  // transcript, then run the compact gate with that transcript, then scan the
@@ -1851,8 +1889,18 @@ function applySessionClose(args) {
1851
1889
  gateOk,
1852
1890
  transcriptResolved: !!closeTranscript,
1853
1891
  // Scan the signal only when the gate passed AND a transcript resolved —
1854
- // hasUserCloseSignal never runs earlier than the original nested `else if`.
1855
- hasUserSignal: gateOk && !!closeTranscript && hasUserCloseSignal(closeTranscript),
1892
+ // isCloseGateOpen never runs earlier than the original nested `else if`.
1893
+ // Reads the raw walkCloseGate open, not closeGateStatus: this apply's
1894
+ // OWN recordGateClosed call above already ran with this transcript's
1895
+ // full record count as closedAtIndex, and openedAtIndex can never reach
1896
+ // or pass a count taken from the very same transcript (see
1897
+ // closeGateStatus's doc comment) — so gating this diagnostic on .ok
1898
+ // would read false on every apply, unconditionally, not just a stale
1899
+ // one. This field asks a narrower question than closeGateStatus
1900
+ // answers: "did the transcript carry a close signal", not "is this
1901
+ // apply itself still authorized" (verifyCloseAuthority already settled
1902
+ // that, before any byte was written).
1903
+ hasUserSignal: gateOk && !!closeTranscript && isCloseGateOpen(closeTranscript),
1856
1904
  });
1857
1905
  markerSkipReason = decision.skipReason;
1858
1906
  if (decision.write) {
package/scripts/init.mjs CHANGED
@@ -41,10 +41,12 @@ import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
41
41
  import {
42
42
  hooksDirForInstall,
43
43
  unsafeHookTargetReason,
44
+ findMarkerSpan,
44
45
  WIKI_PRE_COMMIT_MARKER_START,
45
46
  WIKI_PRE_COMMIT_MARKER_END,
46
47
  SHELL_MARKER_START,
47
48
  SHELL_MARKER_END,
49
+ SHELL_FUNCTION_BODY,
48
50
  } from './lib/git-hooks-dir.mjs';
49
51
  import { readCoreHooksConfig } from './lib/core-hooks.mjs';
50
52
  import {
@@ -863,13 +865,13 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
863
865
 
864
866
  // ── shell function setup ─────────────────────────────────────────────────────
865
867
 
868
+ // Built FROM SHELL_FUNCTION_BODY (./lib/git-hooks-dir.mjs), not a second copy
869
+ // of the same literal: uninstall's isOwnedShellFunctionBody() compares an
870
+ // existing marker span's body against that same constant byte-for-byte, so
871
+ // this and that check can never drift apart the way two independent string
872
+ // literals could.
866
873
  function shellFunctionBlock() {
867
- return `${SHELL_MARKER_START}
868
- function claude() {
869
- echo "{\\"cwd\\":\\"$(pwd)\\"}" | node "$HOME/.claude/hooks/hypo-session-start.mjs" > /dev/null 2>&1
870
- command claude "$@"
871
- }
872
- ${SHELL_MARKER_END}`;
874
+ return `${SHELL_MARKER_START}${SHELL_FUNCTION_BODY}${SHELL_MARKER_END}`;
873
875
  }
874
876
 
875
877
  function detectShellConfig(customPath) {
@@ -892,19 +894,32 @@ function installShellFunction(shellConfigPath, dryRun) {
892
894
  }
893
895
 
894
896
  const content = readFileSync(shellConfigPath, 'utf-8');
895
- const startIdx = content.indexOf(SHELL_MARKER_START);
896
- const endIdx = content.indexOf(SHELL_MARKER_END);
897
897
 
898
- if (startIdx !== -1 && endIdx !== -1) {
898
+ if (content.includes(SHELL_MARKER_START) || content.includes(SHELL_MARKER_END)) {
899
+ // A block-shaped span is claimed here: validate it the same way uninstall
900
+ // does before touching a single byte. Two bare indexOf() calls cannot tell
901
+ // "well-formed" apart from "duplicated" (only the FIRST end is found, so a
902
+ // second full copy's body gets stranded in the untouched tail) or "swapped"
903
+ // (end before start silently duplicates whatever sits between them into the
904
+ // "replaced" span instead of raising anything). Neither corruption is
905
+ // something this script can safely repair, so a malformed span is left
906
+ // completely alone, the same contract uninstall.mjs holds for removal.
907
+ const span = findMarkerSpan(content, SHELL_MARKER_START, SHELL_MARKER_END);
908
+ if (!span.ok) {
909
+ log('skipped', `${shellConfigPath} (${span.reason}, leaving the file untouched)`);
910
+ return;
911
+ }
899
912
  // Block exists — check if already up to date
900
- const existing = content.slice(startIdx, endIdx + SHELL_MARKER_END.length);
913
+ const existing = content.slice(span.startIdx, span.endIdx + SHELL_MARKER_END.length);
901
914
  if (existing === block) {
902
915
  log('skipped', `${shellConfigPath} (shell function up to date)`);
903
916
  return;
904
917
  }
905
918
  // Replace stale block
906
919
  const updated =
907
- content.slice(0, startIdx) + block + content.slice(endIdx + SHELL_MARKER_END.length);
920
+ content.slice(0, span.startIdx) +
921
+ block +
922
+ content.slice(span.endIdx + SHELL_MARKER_END.length);
908
923
  if (!dryRun) writeFileSync(shellConfigPath, updated);
909
924
  log('merged', `${shellConfigPath} (shell function updated)`);
910
925
  return;
@@ -69,9 +69,10 @@ function maxDate(dates) {
69
69
  return dates.reduce((a, b) => (a > b ? a : b));
70
70
  }
71
71
 
72
- // Returns stale findings: { project, lastSession, lastDesignHistory, diffDays }
73
- // Only includes projects where design-history.md exists AND is stale relative
74
- // to the latest session-log entry. Date source is body section headings
72
+ // Returns findings: { project, kind, lastSession, lastDesignHistory, diffDays }.
73
+ // `kind` is 'stale' (the file exists but session-log has moved past it) or
74
+ // 'missing' (the file does not exist at all, yet session-log carries at least
75
+ // one design-relevant entry). Date source is body section headings
75
76
  // (## YYYY-MM-DD), not frontmatter `updated:` — auto-stage hooks bump the
76
77
  // frontmatter on unrelated edits, so it can't signal staleness on its own.
77
78
  export function findDesignHistoryStale(hypoDir) {
@@ -81,17 +82,19 @@ export function findDesignHistoryStale(hypoDir) {
81
82
  if (!existsSync(projectsDir)) return stale;
82
83
 
83
84
  for (const name of readdirSync(projectsDir)) {
85
+ if (name.startsWith('_')) continue; // e.g. templates/projects/_template — not a real project
84
86
  const projectDir = join(projectsDir, name);
85
87
  if (!statSync(projectDir).isDirectory()) continue;
86
88
 
87
89
  const dhPath = join(projectDir, 'design-history.md');
88
- if (!existsSync(dhPath)) continue;
89
90
 
90
91
  // session-log can live as a flat `session-log.md` (legacy) or a directory of
91
92
  // daily shards `session-log/YYYY-MM-DD.md` (canonical; legacy
92
93
  // monthly `YYYY-MM.md` files still appear pre-cutover). This globs every
93
94
  // `.md` in the directory, so daily and monthly shapes are both aggregated —
94
- // the staleness check needs to see all of them.
95
+ // the staleness check needs to see all of them. Gathered before the
96
+ // existsSync(dhPath) branch below, since a project with zero design-history
97
+ // file still needs this to decide whether it has a design-relevant entry.
95
98
  const sessionDates = [];
96
99
  const flatSlPath = join(projectDir, 'session-log.md');
97
100
  if (existsSync(flatSlPath)) {
@@ -107,16 +110,32 @@ export function findDesignHistoryStale(hypoDir) {
107
110
  }
108
111
  if (sessionDates.length === 0) continue;
109
112
 
113
+ if (!existsSync(dhPath)) {
114
+ // The file was never created, so there is nothing to compare dates
115
+ // against — but parseSessionDates already excluded pure "ADR 없음"
116
+ // entries, so a non-empty sessionDates here means at least one entry
117
+ // recorded (or implied) a design change with nowhere to land. This is a
118
+ // bootstrap gap, not a staleness gap: `lastDesignHistory`/`diffDays` stay
119
+ // null and callers must key off `kind` to avoid conflating the two.
120
+ stale.push({
121
+ project: name,
122
+ kind: 'missing',
123
+ lastSession: maxDate(sessionDates).toISOString().slice(0, 10),
124
+ lastDesignHistory: null,
125
+ diffDays: null,
126
+ });
127
+ continue;
128
+ }
129
+
110
130
  const dhText = readFileSync(dhPath, 'utf-8');
111
131
  const lastSession = maxDate(sessionDates);
112
132
  const lastDH = maxDate(parseDates(dhText, DESIGN_HISTORY_DATE_RE));
113
133
 
114
- if (!lastSession) continue;
115
-
116
134
  if (!lastDH || lastSession > lastDH) {
117
135
  const diffDays = lastDH ? Math.round((lastSession - lastDH) / (1000 * 60 * 60 * 24)) : null;
118
136
  stale.push({
119
137
  project: name,
138
+ kind: 'stale',
120
139
  lastSession: lastSession.toISOString().slice(0, 10),
121
140
  lastDesignHistory: lastDH ? lastDH.toISOString().slice(0, 10) : '(없음)',
122
141
  diffDays,
@@ -29,7 +29,11 @@ import {
29
29
  mkdirSync,
30
30
  lstatSync,
31
31
  statSync,
32
+ fstatSync,
32
33
  chmodSync,
34
+ fchmodSync,
35
+ openSync,
36
+ closeSync,
33
37
  rmdirSync,
34
38
  } from 'fs';
35
39
  import { join, dirname, relative, resolve, posix, sep } from 'path';
@@ -907,14 +911,34 @@ export function withSrcExecBits(destMode, srcMode) {
907
911
  return (destMode & ~0o111) | (srcMode & 0o111);
908
912
  }
909
913
 
914
+ /** Add only the execute bits src has that dest is missing, onto dest — every bit
915
+ * dest already carries (owner/group/other, ours or the user's) survives untouched.
916
+ * This is the additive counterpart to `withSrcExecBits` above: that one REPLACES
917
+ * dest's exec bits wholesale, which is right for a fresh write (there is no prior
918
+ * dest state worth keeping) but wrong for healing an existing file, where treating
919
+ * "executable" as one boolean instead of three independent bits made a cross
920
+ * combination (src owner-only, dest group-only) read as already-satisfied and
921
+ * never get healed. Used only by copyOne's content-identical branch. */
922
+ export function addSrcExecBits(destMode, srcMode) {
923
+ return destMode | (srcMode & 0o111);
924
+ }
925
+
910
926
  function writeFreshAtomic(dest, content, srcMode) {
927
+ // `wx` (O_CREAT|O_EXCL) refuses to open a path that already exists, symlink
928
+ // included, so a predictable tmp name can't be raced into following one, the
929
+ // same hardening capture.mjs's own writeAtomic already carries.
911
930
  const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
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));
931
+ const fd = openSync(tmp, 'wx');
932
+ try {
933
+ writeFileSync(fd, content);
934
+ if (srcMode != null) {
935
+ // No fd-based write has a mode option that survives umask, so the execute
936
+ // bit has to be applied explicitly, and before the rename: otherwise dest
937
+ // is briefly visible with the wrong mode to anything racing this write.
938
+ fchmodSync(fd, withSrcExecBits(fstatSync(fd).mode, srcMode));
939
+ }
940
+ } finally {
941
+ closeSync(fd);
918
942
  }
919
943
  try {
920
944
  renameSync(tmp, dest);
@@ -936,10 +960,17 @@ function writeFreshAtomic(dest, content, srcMode) {
936
960
  *
937
961
  * The executable bit is not tracked anywhere (no mode column in the SHA map's
938
962
  * 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.
963
+ * carried onto dest on every branch that (re)writes it. The content-identical
964
+ * branch below is the exception: it only ADDS a bit src has that dest lacks
965
+ * (per owner/group/other, not "executable" as one boolean), never turns one
966
+ * off, since a dest exec bit that src lacks can't be told apart from one the
967
+ * user set. `typeDir` is the symlink-ancestor boundary for that same branch's
968
+ * chmod: every caller passes the extension-type root (`~/.claude/hooks`, etc.)
969
+ * so a symlinked ancestor there is never chmod'd through to whatever it points
970
+ * at (codex pre-commit BLOCKER — the flat-file and manifest callers had no
971
+ * such guard, unlike the skill loop's own pre-check on `destPath`).
941
972
  */
942
- function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
973
+ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force, typeDir }) {
943
974
  const srcContent = readFileSync(srcPath);
944
975
  const srcSHA = sha256(srcContent);
945
976
  const srcMode = statSync(srcPath).mode;
@@ -959,17 +990,27 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
959
990
  const onDiskSHA = sha256(onDisk);
960
991
  if (onDiskSHA === srcSHA) {
961
992
  // 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.
993
+ // be a pure no-op, which is exactly why a missing bit here never healed. Only
994
+ // heal by ADDING the specific bits src has that dest lacks (owner/group/other
995
+ // checked independently — a prior boolean "is anything executable" check made
996
+ // a cross combination like src=owner-only/dest=group-only read as already
997
+ // satisfied and skip healing the owner bit entirely, codex pre-commit BLOCKER).
998
+ // A dest bit src lacks is always left alone, because that could be this same
999
+ // heal from an older run, or a bit the user set on purpose, and we have no way
1000
+ // to tell those apart. This also means a wiki copy captured before this fix
1001
+ // (recorded 644 for a 755 local original) can no longer strip the local file
1002
+ // back to 644 on the next sync; it can only ever add bits.
963
1003
  // Reported as 'update' (not a new action) so every existing "N to sync" /
964
1004
  // "N synced" count and log line already keyed on create/update/force-update
965
1005
  // 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.
1006
+ // ponytail: add-only means a dest that lost a bit src still has (content
1007
+ // matches, exec bit was stripped some other way) never gets it back either.
1008
+ // Upgrade path: record mode next to sha in the pkg-json map so a user's own
1009
+ // bit can be told apart from our default and healed in both directions.
970
1010
  const destMode = statSync(destPath).mode;
971
- if ((destMode & 0o111) !== (srcMode & 0o111)) {
972
- if (apply) chmodSync(destPath, withSrcExecBits(destMode, srcMode));
1011
+ const missingBits = srcMode & 0o111 & ~destMode;
1012
+ if (missingBits !== 0 && !hasSymlinkAncestor(typeDir, destPath)) {
1013
+ if (apply) chmodSync(destPath, addSrcExecBits(destMode, srcMode));
973
1014
  return { action: 'update', sha: srcSHA };
974
1015
  }
975
1016
  return { action: 'up-to-date', sha: srcSHA };
@@ -1097,6 +1138,7 @@ function syncOneSkill({
1097
1138
  recordedSHA: recordedNested[f.rel],
1098
1139
  apply,
1099
1140
  force,
1141
+ typeDir,
1100
1142
  });
1101
1143
  if (res.sha != null) newNested[f.rel] = res.sha;
1102
1144
  result.actions.push({ target, file: fileKey, action: res.action });
@@ -1414,6 +1456,7 @@ export function syncExtensions({
1414
1456
  recordedSHA: recorded[fileKey],
1415
1457
  apply,
1416
1458
  force,
1459
+ typeDir,
1417
1460
  });
1418
1461
  if (fileRes.sha != null) newSHAs[fileKey] = fileRes.sha;
1419
1462
  result.actions.push({ target, file: fileKey, action: fileRes.action });
@@ -1448,6 +1491,7 @@ export function syncExtensions({
1448
1491
  recordedSHA: recorded[mKey],
1449
1492
  apply,
1450
1493
  force,
1494
+ typeDir,
1451
1495
  });
1452
1496
  if (mRes.sha != null) newSHAs[mKey] = mRes.sha;
1453
1497
  result.actions.push({ target, file: mKey, action: mRes.action });
@@ -48,6 +48,133 @@ export const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
48
48
  export const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
49
49
  export const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
50
50
 
51
+ // ── marker-span validation (shared by both the writer in init.mjs and both
52
+ // removal paths in uninstall.mjs) ───────────────────────────────────────────
53
+ //
54
+ // Two independent indexOf() calls cannot tell "well-formed" apart from
55
+ // "duplicated" or "swapped": if a file happens to hold two full copies of the
56
+ // block, indexOf finds only the first END, so slicing [firstStart, firstEnd]
57
+ // leaves the second copy's install behind with no report of it. If END
58
+ // precedes START (a hand-edited or corrupted file), slicing [start, end) with
59
+ // start > end does not error, it silently duplicates whatever sits between
60
+ // them into the "removed" (or, on the writer's side, the "replaced") span.
61
+ // Neither script has a way back from either outcome, so a span is only
62
+ // trusted when both markers appear EXACTLY once and START comes before END.
63
+ function countOccurrences(content, needle) {
64
+ let count = 0;
65
+ let idx = 0;
66
+ while ((idx = content.indexOf(needle, idx)) !== -1) {
67
+ count++;
68
+ idx += needle.length;
69
+ }
70
+ return count;
71
+ }
72
+
73
+ export function findMarkerSpan(content, startMarker, endMarker) {
74
+ const startCount = countOccurrences(content, startMarker);
75
+ const endCount = countOccurrences(content, endMarker);
76
+ if (startCount !== 1 || endCount !== 1) {
77
+ return {
78
+ ok: false,
79
+ reason: `expected exactly one start and one end marker, found ${startCount} start / ${endCount} end`,
80
+ };
81
+ }
82
+ const startIdx = content.indexOf(startMarker);
83
+ const endIdx = content.indexOf(endMarker);
84
+ if (!(startIdx < endIdx)) {
85
+ return { ok: false, reason: 'the end marker appears before the start marker' };
86
+ }
87
+ return { ok: true, startIdx, endIdx };
88
+ }
89
+
90
+ // ── body-shape validation (shared by both removal paths in uninstall.mjs) ──
91
+ //
92
+ // findMarkerSpan proves the span itself is well-formed. It says nothing about
93
+ // what sits INSIDE that span. A well-formed marker pair is trivial to forge
94
+ // around arbitrary content — a user's own shell function, a user's own
95
+ // pre-commit check — and codex reproduced exactly that (2026-08-27): a marker
96
+ // pair wrapped around `echo USER_OWNED_DEPLOY_CHECK` passed every prior check
97
+ // (one start, one end, start before end, a leading shebang) and got deleted
98
+ // along with the user's line, because nothing ever looked at the body text
99
+ // itself. These two functions are that missing check.
100
+ //
101
+ // The shell block is fully static — init never bakes a path into it — so its
102
+ // body can be matched byte-for-byte against SHELL_FUNCTION_BODY below. The
103
+ // pre-commit body cannot: it embeds the absolute install root, which moves
104
+ // across machines and package versions, so requiring an exact match would
105
+ // refuse to remove a hook a real (older, or differently-installed) init.mjs
106
+ // actually wrote. It is matched structurally instead — the "one or two `node
107
+ // '<path>' ... || exit 1` steps, then `exit 0`" shape — checking only that
108
+ // the referenced script is ours (ends in `/hooks/hypo-pre-commit.mjs` or
109
+ // `/scripts/lint.mjs`), not which root it lives under.
110
+ //
111
+ // Both directions of a mismatch here are unequal: failing to recognize a
112
+ // hook init actually wrote costs a re-run with --force-*; deleting a file
113
+ // that was never ours has no recovery. So an unrecognized shape is always
114
+ // treated as "not ours" and left standing, never as "close enough".
115
+
116
+ const PRE_COMMIT_WORKER_LINE = /^node '(.+)' \|\| exit 1$/;
117
+ const PRE_COMMIT_LINT_LINE = /^node '(.+)' --hypo-dir='(?:.+)' --strict \|\| exit 1$/;
118
+
119
+ // Reverses shellSingleQuote()'s escaping (a literal `'` becomes `'\''`) so the
120
+ // captured path can be compared against the suffix it must end in.
121
+ function unescapeShellSingleQuoted(s) {
122
+ return s.split("'\\''").join("'");
123
+ }
124
+
125
+ /**
126
+ * @param {string} content full pre-commit hook file content
127
+ * @param {{startIdx: number, endIdx: number}} span a `findMarkerSpan` result
128
+ * already confirmed `ok: true` for WIKI_PRE_COMMIT_MARKER_START/END
129
+ * @returns {boolean} true when the text between the markers is recognizable
130
+ * as a body init.mjs's wikiPreCommitContent() writes
131
+ */
132
+ export function isOwnedWikiPreCommitBody(content, span) {
133
+ const body = content.slice(span.startIdx + WIKI_PRE_COMMIT_MARKER_START.length, span.endIdx);
134
+ const lines = body.split('\n');
135
+ // wikiPreCommitContent() always places a bare "\n" right after START and
136
+ // right before END, so the first and last split segments must be empty.
137
+ if (lines[0] !== '' || lines[lines.length - 1] !== '') return false;
138
+ const middle = lines.slice(1, -1);
139
+ if (middle.length < 2 || middle.length > 3 || middle[middle.length - 1] !== 'exit 0') {
140
+ return false;
141
+ }
142
+ const steps = middle.slice(0, -1);
143
+ const worker = PRE_COMMIT_WORKER_LINE.exec(steps[0]);
144
+ if (!worker || !unescapeShellSingleQuoted(worker[1]).endsWith('/hooks/hypo-pre-commit.mjs')) {
145
+ return false;
146
+ }
147
+ if (steps.length === 2) {
148
+ const lint = PRE_COMMIT_LINT_LINE.exec(steps[1]);
149
+ if (!lint || !unescapeShellSingleQuoted(lint[1]).endsWith('/scripts/lint.mjs')) return false;
150
+ }
151
+ return true;
152
+ }
153
+
154
+ // The exact text init.mjs's shellFunctionBlock() writes between the shell
155
+ // markers. Exported so init.mjs builds the block FROM this constant rather
156
+ // than a second copy of the same literal — the two can then never drift the
157
+ // way independent copies of the pre-commit worker line already could not
158
+ // (see the module-level comment on the markers above).
159
+ export const SHELL_FUNCTION_BODY = `
160
+ function claude() {
161
+ echo "{\\"cwd\\":\\"$(pwd)\\"}" | node "$HOME/.claude/hooks/hypo-session-start.mjs" > /dev/null 2>&1
162
+ command claude "$@"
163
+ }
164
+ `;
165
+
166
+ /**
167
+ * @param {string} content full rc file content
168
+ * @param {{startIdx: number, endIdx: number}} span a `findMarkerSpan` result
169
+ * already confirmed `ok: true` for SHELL_MARKER_START/END
170
+ * @returns {boolean} true when the text between the markers is byte-identical
171
+ * to what init.mjs writes
172
+ */
173
+ export function isOwnedShellFunctionBody(content, span) {
174
+ const body = content.slice(span.startIdx + SHELL_MARKER_START.length, span.endIdx);
175
+ return body === SHELL_FUNCTION_BODY;
176
+ }
177
+
51
178
  // Fallback scrub list for git versions without `rev-parse --local-env-vars`.
52
179
  // Mirrors scripts/install-git-hooks.mjs, which established this trust model.
53
180
  const STATIC_LOCAL_ENV_VARS = [
@@ -82,7 +209,7 @@ function buildScrubbedEnv(localEnvList) {
82
209
  // Canonicalize a path that may not exist yet: realpath the deepest existing
83
210
  // ancestor and re-append the rest. Without this, a hooks dir git will create
84
211
  // lazily could evade the containment check via an unresolved symlinked parent.
85
- function canonicalize(p) {
212
+ export function canonicalize(p) {
86
213
  let cur = resolve(p);
87
214
  const tail = [];
88
215
  for (;;) {
@@ -101,7 +228,7 @@ function canonicalize(p) {
101
228
  }
102
229
  }
103
230
 
104
- function isInside(child, parent) {
231
+ export function isInside(child, parent) {
105
232
  return child === parent || child.startsWith(parent + sep);
106
233
  }
107
234