hypomnema 1.7.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,6 +43,7 @@
43
43
  "scripts/lib/project-create.mjs",
44
44
  "scripts/lib/rename-marker.mjs",
45
45
  "scripts/lib/schema-vocab.mjs",
46
+ "scripts/lib/slug-resolver.mjs",
46
47
  "scripts/lib/template-schema-version.mjs",
47
48
  "scripts/lib/wd-match.mjs",
48
49
  "scripts/lib/wikilink.mjs",
@@ -42,9 +42,11 @@ import {
42
42
  unlinkSync,
43
43
  renameSync,
44
44
  realpathSync,
45
- lstatSync,
46
45
  openSync,
47
46
  closeSync,
47
+ statSync,
48
+ fstatSync,
49
+ fchmodSync,
48
50
  } from 'fs';
49
51
  import { randomBytes } from 'crypto';
50
52
  import { join, dirname, relative, sep } from 'path';
@@ -76,6 +78,7 @@ import {
76
78
  HOOK_EVENT_ALLOWLIST,
77
79
  SKILL_ROOT_FILE,
78
80
  EXT_PREFIX,
81
+ withSrcExecBits,
79
82
  } from './lib/extensions.mjs';
80
83
  import { readCoreHooksConfig, deriveCoreHookBasenames } from './lib/core-hooks.mjs';
81
84
 
@@ -582,11 +585,29 @@ function log(msg) {
582
585
  // `${dest}.tmp.${pid}` name was predictable, and writeFileSync on a path someone had
583
586
  // already planted a symlink at would follow it straight out of the wiki. O_EXCL fails
584
587
  // on an existing path of any kind, symlink included.
585
- function writeAtomic(dest, buf) {
588
+ // `srcMode`, when given, carries the source file's execute bit onto the wiki
589
+ // copy (openSync's own mode argument is clipped by umask same as writeFileSync,
590
+ // so this still has to be a separate chmod). Omitted for a manifest write: that
591
+ // content is JSON we generated, not a copy of something with a mode worth
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.
603
+ function writeAtomic(dest, buf, srcMode) {
586
604
  const tmp = `${dest}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
587
605
  const fd = openSync(tmp, 'wx');
588
606
  try {
589
607
  writeFileSync(fd, buf);
608
+ if (srcMode != null) {
609
+ fchmodSync(fd, withSrcExecBits(fstatSync(fd).mode, srcMode));
610
+ }
590
611
  } finally {
591
612
  closeSync(fd);
592
613
  }
@@ -719,7 +740,8 @@ function writeSkill({ rec, skillRoot, manifestPath, manifest, files, guard, wiki
719
740
  madeDirs.add(cur);
720
741
  }
721
742
  }
722
- writeAtomic(destPath, readFileSync(f.srcPath));
743
+ const buf = readFileSync(f.srcPath);
744
+ writeAtomic(destPath, buf, statSync(f.srcPath).mode);
723
745
  rec.createdFiles.push(destPath);
724
746
  }
725
747
  }
@@ -814,22 +836,6 @@ function captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped,
814
836
  return;
815
837
  }
816
838
 
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
839
  if (!args.dryRun) {
834
840
  // The ledger is owned by the caller so a throw MID-write is still recoverable: the
835
841
  // paths created before the failure are already recorded in it.
@@ -1055,7 +1061,7 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
1055
1061
  manifestPrevBuf: existingManifestBuf,
1056
1062
  };
1057
1063
  writeAtomic(manifestPath, JSON.stringify(plan.manifest, null, 2) + '\n');
1058
- writeAtomic(filePath, srcBuf);
1064
+ writeAtomic(filePath, srcBuf, statSync(c.srcPath).mode);
1059
1065
  created.push(rec);
1060
1066
  }
1061
1067
  captured.push({ ...c, installFile, requiredKeys, status: 'ready' });
@@ -102,16 +102,18 @@ import {
102
102
  hasSessionLogHeading,
103
103
  hasLogEntry,
104
104
  resolveTranscriptBySessionId,
105
- hasUserCloseSignal,
105
+ isCloseGateOpen,
106
106
  commitWikiChanges,
107
107
  vaultCommitLockTarget,
108
108
  currentDevice,
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';
116
+ import { recordGateClosed, resolutionStamp, closeGateStatus } from '../hooks/close-gate-store.mjs';
115
117
 
116
118
  // This script's own absolute path. Used to print copy-pasteable recovery
117
119
  // commands as `node <SELF_SCRIPT> ...` rather than a bare `crystallize` bin,
@@ -225,6 +227,35 @@ function requireProjectDir(args, slug) {
225
227
  }
226
228
  }
227
229
 
230
+ // When the global gate's own discovery (hot.md pointer table + today
231
+ // close-activity scan, both in hypo-shared.mjs) comes back with NO project at
232
+ // all, a real apply never hits that dead end: it is handed `payload.project`
233
+ // directly and never infers. --check-session-close has no payload, so its one
234
+ // remaining authoritative signal is the same session's own transcript — which
235
+ // project's files did THIS session actually touch. Reusing the exact
236
+ // evidence-resolution helper the widened-lint-scope path already trusts here
237
+ // keeps this a single inference vocabulary (touched wiki files), not a second
238
+ // one: the difference is only which project-shaped question gets asked of it.
239
+ // Never guessed: a transcript touching zero or more than one project's files
240
+ // leaves the check exactly as unresolved as it was before this fallback.
241
+ function deriveTouchedProject(hypoDir, transcriptPath) {
242
+ if (!transcriptPath) return null;
243
+ const { files, trusted } = extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir);
244
+ // `trusted:false` means the walk itself may be incomplete (a missing/unreadable
245
+ // transcript, or a line that failed to parse). A truncated line could have named
246
+ // a SECOND project the walk never saw, so treating this Set as "the whole
247
+ // truth" would resolve a single-project reading off a scope that is only
248
+ // single-project because part of it is missing, exactly the ambiguity this
249
+ // fallback exists to refuse rather than guess through.
250
+ if (!trusted) return null;
251
+ const slugs = new Set();
252
+ for (const f of files) {
253
+ const m = /^projects\/([^/]+)\//.exec(f);
254
+ if (m && existsSync(join(hypoDir, 'projects', m[1]))) slugs.add(m[1]);
255
+ }
256
+ return slugs.size === 1 ? [...slugs][0] : null;
257
+ }
258
+
228
259
  // ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
229
260
  // Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
230
261
  // flow can self-verify before /compact triggers PreCompact.
@@ -261,7 +292,7 @@ function runSessionCloseCheck(args) {
261
292
  args.transcriptPath ||
262
293
  (args.sessionId ? resolveTranscriptBySessionId(args.sessionId) : null) ||
263
294
  null;
264
- const status = precompactGateStatus(args.hypoDir, {
295
+ let status = precompactGateStatus(args.hypoDir, {
265
296
  ...(args.project
266
297
  ? { projectOverride: args.project }
267
298
  : checkTranscript
@@ -276,7 +307,30 @@ function runSessionCloseCheck(args) {
276
307
  // enforcement lives in the PreCompact/Stop hooks, which carry payload.cwd).
277
308
  ...(args.sessionCwd && !args.project ? { sessionCwd: args.sessionCwd } : {}),
278
309
  });
310
+
311
+ // check/apply divergence (2026-08-25 QA): a real apply never hits discovery
312
+ // dead-ends because payload.project is required input, not an inference. This
313
+ // check has no payload, so when discovery finds NO project at all (not even
314
+ // the recency fallback), it retries scoped to whatever single project this
315
+ // session's own transcript shows it touching. This is a diagnostic estimate,
316
+ // not a preview of what a real apply will do: a payload's `project` field is
317
+ // whatever the caller puts there and can legitimately name a project the
318
+ // transcript never mentions. Only fires on a fully unresolved global result,
319
+ // and only on a TRUSTED single-project reading (see deriveTouchedProject): an
320
+ // already-successful discovery, an ambiguous/empty transcript, or one the walk
321
+ // could not fully read is left untouched rather than guessed at.
322
+ let inferredProject = null;
323
+ if (!args.project && !status.close.project) {
324
+ inferredProject = deriveTouchedProject(args.hypoDir, checkTranscript);
325
+ if (inferredProject) {
326
+ status = precompactGateStatus(args.hypoDir, {
327
+ projectOverride: inferredProject,
328
+ ...(args.sessionId ? { sessionId: args.sessionId } : {}),
329
+ });
330
+ }
331
+ }
279
332
  const close = status.close;
333
+ const scopedProject = args.project || inferredProject;
280
334
 
281
335
  // When a --session-id is supplied, report whether THIS session's
282
336
  // per-session marker (the Stop-chain completion signal) exists. This is a
@@ -301,8 +355,8 @@ function runSessionCloseCheck(args) {
301
355
  // log-only marker governs the session, the gate runs in log-only mode and the
302
356
  // --project override is IGNORED — surface that rather than implying X was
303
357
  // checked (it was not).
304
- const logOnlyWon = args.project != null && markerObj?.scope === 'log-only';
305
- const scope = args.project ? (logOnlyWon ? 'log-only' : 'project') : 'global';
358
+ const logOnlyWon = scopedProject != null && markerObj?.scope === 'log-only';
359
+ const scope = scopedProject ? (logOnlyWon ? 'log-only' : 'project') : 'global';
306
360
 
307
361
  if (args.json) {
308
362
  console.log(
@@ -325,9 +379,13 @@ function runSessionCloseCheck(args) {
325
379
  skipped: status.skipped,
326
380
  // scope is additive; `global` keeps prior semantics for existing readers
327
381
  scope,
328
- ...(args.project
382
+ ...(scopedProject
329
383
  ? {
330
- scoped_project: args.project,
384
+ scoped_project: scopedProject,
385
+ // Distinguishes a user-typed --project from this check picking one
386
+ // for itself off the transcript — a reader should not mistake the
387
+ // latter for an explicit ask (see deriveTouchedProject above).
388
+ ...(inferredProject ? { project_inferred_from_transcript: true } : {}),
331
389
  ...(logOnlyWon ? { project_override_ignored: true } : {}),
332
390
  }
333
391
  : {}),
@@ -340,13 +398,20 @@ function runSessionCloseCheck(args) {
340
398
  process.exit(status.ok ? 0 : 1);
341
399
  }
342
400
 
401
+ // Label the scoped project by how it was chosen — an explicit --project reads
402
+ // as a flag the caller typed; an inferred one reads as this check's own guess
403
+ // off the transcript, so a reader does not credit the caller with an ask
404
+ // nobody made.
405
+ const scopedProjectLabel = args.project
406
+ ? `--project=${args.project}`
407
+ : `project=${scopedProject} (inferred from the session transcript, no --project given)`;
343
408
  if (logOnlyWon) {
344
409
  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`,
410
+ `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
411
  );
347
412
  } else if (scope === 'project') {
348
413
  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`,
414
+ `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
415
  );
351
416
  }
352
417
 
@@ -398,8 +463,8 @@ function runSessionCloseCheck(args) {
398
463
  // Do NOT claim global compact-readiness (the whole point of the narrow).
399
464
  console.log(
400
465
  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.`,
466
+ ? `✓ ${scopedProject} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
467
+ : `✗ ${scopedProject} is not close-complete — resolve the ✗ items above.`,
403
468
  );
404
469
  } else {
405
470
  console.log(
@@ -686,7 +751,7 @@ function runMarkSessionClosed(args) {
686
751
  // /compact, or an AskUserQuestion close answer). This is the hard backstop for
687
752
  // model over-close, where prose guidance lost to a conflicting global rule.
688
753
  // Fail-closed when the transcript can't be resolved.
689
- if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
754
+ if (!closeTranscript || !isCloseGateOpen(closeTranscript)) {
690
755
  const reason = !closeTranscript
691
756
  ? `cannot resolve a transcript for session ${args.sessionId} — the session-closed marker requires a verifiable user close signal`
692
757
  : "no user close signal in this session's transcript — marker refused (the user did not signal session close)";
@@ -874,7 +939,7 @@ const CLOSE_REFUSAL_HELP = [
874
939
  * { ok: false, reason, error } reason: session-id-required | transcript-unresolved
875
940
  * | no-user-close-signal
876
941
  */
877
- function verifyCloseAuthority(sessionId) {
942
+ function verifyCloseAuthority(sessionId, hypoDir) {
878
943
  if (!sessionId) {
879
944
  return {
880
945
  ok: false,
@@ -897,13 +962,15 @@ function verifyCloseAuthority(sessionId) {
897
962
  `authority here.`,
898
963
  };
899
964
  }
900
- if (!hasUserCloseSignal(transcript)) {
965
+ const gateStatus = closeGateStatus({ transcriptPath: transcript, hypoDir, sessionId });
966
+ if (!gateStatus.ok) {
901
967
  return {
902
968
  ok: false,
903
969
  reason: 'no-user-close-signal',
904
970
  error:
905
971
  "session-close apply refused before any wiki write or commit: this session's transcript " +
906
- '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}`,
907
974
  };
908
975
  }
909
976
  return { ok: true };
@@ -1036,14 +1103,19 @@ function applySessionClose(args) {
1036
1103
  // Only a payload-bearing call can write. A payload-less one falls through to the
1037
1104
  // "payload is required" error below without touching a byte, so gating it here
1038
1105
  // would just replace one refusal with a less accurate one.
1039
- const closeAuth = args.payload ? verifyCloseAuthority(args.sessionId) : { ok: true };
1106
+ const closeAuth = args.payload
1107
+ ? verifyCloseAuthority(args.sessionId, args.hypoDir)
1108
+ : { ok: true };
1040
1109
  if (!closeAuth.ok) {
1041
1110
  const out = {
1042
1111
  ok: false,
1043
1112
  stage: 'no-user-close-signal',
1044
1113
  reason: closeAuth.reason,
1045
1114
  applied: [],
1046
- committed: false,
1115
+ // `null`, not `false`: this refusal fires before the commit step is ever
1116
+ // reached (see the general result's own `committed` contract below).
1117
+ // `false` is reserved for a commit that actually ran and failed.
1118
+ committed: null,
1047
1119
  error: closeAuth.error,
1048
1120
  };
1049
1121
  console.log(
@@ -1103,7 +1175,9 @@ function applySessionClose(args) {
1103
1175
  stage: 'session-id-mismatch',
1104
1176
  error: msg,
1105
1177
  applied: [],
1106
- committed: false,
1178
+ // `null`, not `false` — refused before the commit step, same contract as
1179
+ // the `no-user-close-signal` refusal above.
1180
+ committed: null,
1107
1181
  };
1108
1182
  console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
1109
1183
  process.exit(1);
@@ -1734,7 +1808,44 @@ function applySessionClose(args) {
1734
1808
  // but silently.
1735
1809
  let markerWritten = false;
1736
1810
  let markerSkipReason = null;
1811
+ // Hoisted so the result JSON below can report it: `null` when this apply never
1812
+ // reached the commit step at all (ok:false before the writes were even
1813
+ // verified), distinct from a commit that ran and reported `committed:false`.
1814
+ let commitOutcome = null;
1737
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
+
1738
1849
  // IO stays lazy so this preserves the exact side-effect order (codex design
1739
1850
  // review): commit first (the only mutation), then resolve the
1740
1851
  // transcript, then run the compact gate with that transcript, then scan the
@@ -1749,7 +1860,6 @@ function applySessionClose(args) {
1749
1860
  // apply's stage+commit. A lock-timeout is treated exactly like any other
1750
1861
  // commit failure below (skip the marker, surface the reason) rather than
1751
1862
  // crashing the apply.
1752
- let commitOutcome;
1753
1863
  try {
1754
1864
  commitOutcome = withFileLock(vaultCommitLockTarget(args.hypoDir), () =>
1755
1865
  commitWikiChanges(args.hypoDir, appliedPaths),
@@ -1779,8 +1889,18 @@ function applySessionClose(args) {
1779
1889
  gateOk,
1780
1890
  transcriptResolved: !!closeTranscript,
1781
1891
  // Scan the signal only when the gate passed AND a transcript resolved —
1782
- // hasUserCloseSignal never runs earlier than the original nested `else if`.
1783
- 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),
1784
1904
  });
1785
1905
  markerSkipReason = decision.skipReason;
1786
1906
  if (decision.write) {
@@ -1843,6 +1963,19 @@ function applySessionClose(args) {
1843
1963
  date,
1844
1964
  applied,
1845
1965
  skipped,
1966
+ // Was the general-shape sibling of the two early-refusal `committed:null`
1967
+ // fields (no-user-close-signal / session-id-mismatch), which this path never
1968
+ // carried before: a reader of `applied:[]` on a no-op re-run had no
1969
+ // `committed` value to check against and no way to tell it apart from a run
1970
+ // that never reached the commit step. `null` here means exactly that: `ok`
1971
+ // came back false before the commit ever ran (see `stage` for which check
1972
+ // failed: post-apply-verification, post-apply-lint, or proposal-pending). It
1973
+ // does NOT mean nothing was written — an overwrite/append can already be on
1974
+ // disk (see `applied` / `appliedUncommitted`) while `committed` stays `null`.
1975
+ // `true` covers both an actual commit and the legitimate "nothing to stage"
1976
+ // no-op (commitWikiChanges' own contract, see hooks/hypo-shared.mjs); `false`
1977
+ // is a real commit failure, surfaced together with markerSkipReason below.
1978
+ committed: commitOutcome ? commitOutcome.committed : null,
1846
1979
  // Targets withheld: an overwrite drifted from this session's observed base, or
1847
1980
  // an append could not take the file lock in time (`kind: 'append'`). Two
1848
1981
  // 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,16 @@ 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
+ findMarkerSpan,
45
+ WIKI_PRE_COMMIT_MARKER_START,
46
+ WIKI_PRE_COMMIT_MARKER_END,
47
+ SHELL_MARKER_START,
48
+ SHELL_MARKER_END,
49
+ SHELL_FUNCTION_BODY,
50
+ } from './lib/git-hooks-dir.mjs';
42
51
  import { readCoreHooksConfig } from './lib/core-hooks.mjs';
43
52
  import {
44
53
  readPkgJson as readPkgJsonSafe,
@@ -748,9 +757,6 @@ function installPkgGitHook(dryRun) {
748
757
 
749
758
  // ── wiki pre-commit hook ─────────────────────────────────────────────────────
750
759
 
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
760
  // Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
755
761
  function shellSingleQuote(p) {
756
762
  return `'${p.replace(/'/g, "'\\''")}'`;
@@ -859,16 +865,13 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
859
865
 
860
866
  // ── shell function setup ─────────────────────────────────────────────────────
861
867
 
862
- const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
863
- const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
864
-
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.
865
873
  function shellFunctionBlock() {
866
- return `${SHELL_MARKER_START}
867
- function claude() {
868
- echo "{\\"cwd\\":\\"$(pwd)\\"}" | node "$HOME/.claude/hooks/hypo-session-start.mjs" > /dev/null 2>&1
869
- command claude "$@"
870
- }
871
- ${SHELL_MARKER_END}`;
874
+ return `${SHELL_MARKER_START}${SHELL_FUNCTION_BODY}${SHELL_MARKER_END}`;
872
875
  }
873
876
 
874
877
  function detectShellConfig(customPath) {
@@ -891,19 +894,32 @@ function installShellFunction(shellConfigPath, dryRun) {
891
894
  }
892
895
 
893
896
  const content = readFileSync(shellConfigPath, 'utf-8');
894
- const startIdx = content.indexOf(SHELL_MARKER_START);
895
- const endIdx = content.indexOf(SHELL_MARKER_END);
896
897
 
897
- 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
+ }
898
912
  // Block exists — check if already up to date
899
- const existing = content.slice(startIdx, endIdx + SHELL_MARKER_END.length);
913
+ const existing = content.slice(span.startIdx, span.endIdx + SHELL_MARKER_END.length);
900
914
  if (existing === block) {
901
915
  log('skipped', `${shellConfigPath} (shell function up to date)`);
902
916
  return;
903
917
  }
904
918
  // Replace stale block
905
919
  const updated =
906
- 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);
907
923
  if (!dryRun) writeFileSync(shellConfigPath, updated);
908
924
  log('merged', `${shellConfigPath} (shell function updated)`);
909
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,