hypomnema 1.3.2 → 1.3.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.
@@ -14,6 +14,7 @@ import {
14
14
  mkdirSync,
15
15
  rmSync,
16
16
  readdirSync,
17
+ realpathSync,
17
18
  } from 'fs';
18
19
  import { join, relative, basename } from 'path';
19
20
  import { homedir, hostname, tmpdir } from 'os';
@@ -127,22 +128,43 @@ export function lastSubstantialOpIsSession() {
127
128
  return /^## \[\d{4}-\d{2}-\d{2}\] session/.test(substantial[substantial.length - 1]);
128
129
  }
129
130
 
131
+ // Returns the wiki's git state split into its two independent axes (ADR 0056):
132
+ // uncommitted — working-tree changes (real unsaved work; a human-fixable blocker)
133
+ // ahead — committed-but-unpushed commits (a soft, auto-synced state: the
134
+ // auto-commit Stop hook pushes, and push failures are non-fatal —
135
+ // see hypo-auto-commit.mjs / appendSyncFailure)
136
+ // `clean` stays `!uncommitted && !ahead` for back-compat with callers that only
137
+ // read it. Callers that gate session-close / compact distinguish the two: they
138
+ // block on `uncommitted` and demote `ahead` to a notice (precompactGateStatus,
139
+ // hypo-compact-guard) so a committed-but-unpushed close is still "compact-ready".
130
140
  export function hypoIsClean(dir = HYPO_DIR) {
131
141
  try {
132
142
  const porcelain = spawnSync('git', ['-C', dir, 'status', '--porcelain'], {
133
143
  encoding: 'utf-8',
134
144
  });
135
- if (porcelain.status !== 0) return { clean: false, reason: `git check failed in ${dir}` };
136
- if (porcelain.stdout.trim() !== '')
137
- return { clean: false, reason: `uncommitted changes in ${dir}` };
138
- const ahead = spawnSync('git', ['-C', dir, 'status', '--branch', '--porcelain'], {
145
+ if (porcelain.status !== 0)
146
+ return {
147
+ clean: false,
148
+ uncommitted: true,
149
+ ahead: false,
150
+ reason: `git check failed in ${dir}`,
151
+ };
152
+ const uncommitted = porcelain.stdout.trim() !== '';
153
+ const aheadRes = spawnSync('git', ['-C', dir, 'status', '--branch', '--porcelain'], {
139
154
  encoding: 'utf-8',
140
155
  });
141
- if (/\[ahead \d+\]/.test(ahead.stdout || ''))
142
- return { clean: false, reason: `unpushed commits in ${dir}` };
143
- return { clean: true };
156
+ const ahead = /\[ahead \d+\]/.test(aheadRes.stdout || '');
157
+ const reasons = [];
158
+ if (uncommitted) reasons.push(`uncommitted changes in ${dir}`);
159
+ if (ahead) reasons.push(`unpushed commits in ${dir}`);
160
+ return {
161
+ clean: !uncommitted && !ahead,
162
+ uncommitted,
163
+ ahead,
164
+ reason: reasons.length ? reasons.join('; ') : undefined,
165
+ };
144
166
  } catch {
145
- return { clean: false, reason: `git check failed in ${dir}` };
167
+ return { clean: false, uncommitted: true, ahead: false, reason: `git check failed in ${dir}` };
146
168
  }
147
169
  }
148
170
 
@@ -531,8 +553,11 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
531
553
  // but the resume.mjs copy adds an mtime fallback this one omits — see resume.mjs.
532
554
 
533
555
  // Root hot.md Active-Projects rows as {slug, date}. The per-row date column is
534
- // project-scoped (unlike the shared frontmatter `updated:`), so a today-dated
535
- // row is a legitimate close-activity signal. Mirrors resolveActiveProject's regex.
556
+ // project-scoped (unlike the shared frontmatter `updated:`). Used for candidate
557
+ // DISCOVERY in closeCandidateSlugs NOT as close-activity evidence: ADR 0057
558
+ // dropped the row date as an activity signal because project-create and
559
+ // hypo-hot-rebuild both stamp rows today without a real session. Mirrors
560
+ // resolveActiveProject's regex.
536
561
  function rootHotRows(hypoDir) {
537
562
  const hotPath = join(hypoDir, 'hot.md');
538
563
  if (!existsSync(hotPath)) return [];
@@ -588,23 +613,29 @@ function closeCandidateSlugs(hypoDir, dates) {
588
613
  return slugs;
589
614
  }
590
615
 
591
- // True when project P shows ANY today close-activity signal: session-state or
592
- // project hot.md frontmatter `updated:` today, a today-dated session-log heading,
593
- // a today log.md `session | P` entry, or a today-dated root hot.md row for P.
594
- // (Root hot.md *frontmatter* is shared and is NOT a signal; the per-project ROW
595
- // date is.)
616
+ // True when project P shows an AUTHORITATIVE today close-activity signal: a
617
+ // today-dated session-log heading, or a today-dated `## [today] session | P`
618
+ // log.md entry. These are written ONLY by a real session close (apply, or its
619
+ // auto-derived root log ADR 0049).
620
+ //
621
+ // ADR 0057: soft state files are EXCLUDED, because each is bumped to today by
622
+ // non-session tooling and is therefore indistinguishable from a real close:
623
+ // - session-state.md `updated:` — tracker bookkeeping mirrors a new item into
624
+ // the "next tasks" section (a cross-block incident: editing one project's
625
+ // tracker bumped session-state and blocked an unrelated project's /compact).
626
+ // - project hot.md `updated:` — project-create stamps the template `updated: today`.
627
+ // - root hot.md ROW date — project-create inserts a today-dated row, and
628
+ // hypo-hot-rebuild defaults a row to today when a project hot.md is missing.
629
+ // (Root hot.md *frontmatter* was already never a signal — it is shared and
630
+ // hypo-hot-rebuild stamps it today every session.)
631
+ //
632
+ // Tradeoff (documented, accepted): apply writes session-state.md FIRST, then the
633
+ // project files, then the session-log + log entry. A process crash before the
634
+ // session-log write leaves a torn close that this gate no longer flags. Accepted:
635
+ // a torn close never reached apply's ok=true so it wrote no marker (ADR 0055/0056);
636
+ // the surviving session-state is the resume pointer the next session overwrites;
637
+ // what is lost is a narrative log entry, not continuity.
596
638
  function hasTodayCloseActivity(hypoDir, project, dates) {
597
- const fresh = (rel) => {
598
- const full = join(hypoDir, rel);
599
- if (!existsSync(full)) return false;
600
- try {
601
- return dates.includes(frontmatterUpdated(readFileSync(full, 'utf-8')));
602
- } catch {
603
- return false;
604
- }
605
- };
606
- if (fresh(join('projects', project, 'session-state.md'))) return true;
607
- if (fresh(join('projects', project, 'hot.md'))) return true;
608
639
  for (const d of dates) {
609
640
  for (const rel of sessionLogReadCandidates(project, d)) {
610
641
  const sl = join(hypoDir, rel);
@@ -625,9 +656,6 @@ function hasTodayCloseActivity(hypoDir, project, dates) {
625
656
  /* skip */
626
657
  }
627
658
  }
628
- for (const r of rootHotRows(hypoDir)) {
629
- if (r.slug === project && r.date && dates.includes(r.date)) return true;
630
- }
631
659
  return false;
632
660
  }
633
661
 
@@ -842,6 +870,61 @@ export function appendSyncFailure(hypoDir, op, error) {
842
870
  }
843
871
  }
844
872
 
873
+ /**
874
+ * Stage + commit every non-.hypoignore change in the wiki. Does NOT pull/push —
875
+ * remote sync stays in the auto-commit Stop hook (commit is local + cheap; sync is
876
+ * network + soft-fail). Shared by hypo-auto-commit.mjs and crystallize.mjs's
877
+ * --apply-session-close path so the .hypoignore staging filter cannot diverge
878
+ * between the two commit loci (ADR 0056).
879
+ *
880
+ * "Nothing to commit" (clean tree, or only .hypoignore'd changes) is SUCCESS, not
881
+ * failure — the caller's tree is already in the committed state it wanted.
882
+ *
883
+ * @param {string} hypoDir
884
+ * @returns {{committed: boolean, reason?: string}} committed:true when a commit was
885
+ * created OR nothing needed committing; committed:false (with reason) on a real
886
+ * failure: not a git repo, or git status/add/commit erroring.
887
+ */
888
+ export function commitWikiChanges(hypoDir) {
889
+ const git = (...args) =>
890
+ spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', timeout: 30000 });
891
+ if (git('rev-parse', '--is-inside-work-tree').status !== 0)
892
+ return { committed: false, reason: `not a git repository: ${hypoDir}` };
893
+ const porcelain = git('status', '--porcelain', '-uall');
894
+ if (porcelain.status !== 0)
895
+ return { committed: false, reason: `git status failed in ${hypoDir}` };
896
+ // `.hypoignore` is the project privacy boundary. `git add -A` ignores it, so
897
+ // enumerate changed paths, drop ignored ones, then stage explicitly.
898
+ const ignorePatterns = loadHypoIgnore(hypoDir);
899
+ const paths = [];
900
+ for (const line of (porcelain.stdout || '').split('\n')) {
901
+ if (!line) continue;
902
+ const file = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop().trim();
903
+ if (!file) continue;
904
+ if (ignorePatterns.length > 0 && isIgnored(join(hypoDir, file), hypoDir, ignorePatterns))
905
+ continue;
906
+ paths.push(file);
907
+ }
908
+ if (paths.length > 0) {
909
+ const add = git('add', '--', ...paths);
910
+ if (add.status !== 0)
911
+ return {
912
+ committed: false,
913
+ reason: `git add failed: ${(add.stderr || '').trim() || 'unknown'}`,
914
+ };
915
+ }
916
+ const staged = git('diff', '--cached', '--name-only').stdout?.trim() || '';
917
+ if (!staged) return { committed: true }; // nothing to commit = success (idempotent)
918
+ const today = new Date().toISOString().slice(0, 10);
919
+ const commit = git('commit', '-m', `auto: ${today} wiki update`);
920
+ if (commit.status !== 0)
921
+ return {
922
+ committed: false,
923
+ reason: `git commit failed: ${(commit.stderr || '').trim() || 'unknown'}`,
924
+ };
925
+ return { committed: true };
926
+ }
927
+
845
928
  /**
846
929
  * Read sync-state entries.
847
930
  * @param {string} hypoDir
@@ -1502,9 +1585,20 @@ export function precompactGateStatus(hypoDir, opts = {}) {
1502
1585
  const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
1503
1586
  const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
1504
1587
 
1505
- // 1. wiki git clean
1588
+ // 1. wiki git state (ADR 0056). Uncommitted changes (real unsaved work) BLOCK —
1589
+ // they are human-fixable. Unpushed commits (ahead) DEMOTE to a notice: push is
1590
+ // automatic (auto-commit Stop hook) and its failures are already non-fatal, so
1591
+ // "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
1592
+ // here (the shared gate) keeps the marker == compact-ready invariant (ADR 0047):
1593
+ // a committed-but-unpushed close marks AND compacts, instead of the close writer
1594
+ // committing its own payload and then being blocked by its own (unpushed) commit.
1506
1595
  const git = hypoIsClean(hypoDir);
1507
- if (!git.clean) blockers.push({ type: 'git', reason: git.reason });
1596
+ if (git.uncommitted) blockers.push({ type: 'git', reason: git.reason });
1597
+ else if (git.ahead)
1598
+ notices.push({
1599
+ type: 'git-sync',
1600
+ reason: `unpushed commits in ${hypoDir} (push deferred to Stop hook)`,
1601
+ });
1508
1602
 
1509
1603
  // 2. root hot.md structure
1510
1604
  const hot = hotMdIsClean(hypoDir);
@@ -1760,7 +1854,9 @@ export function isCompactOrClearCommand(prompt) {
1760
1854
  * PreCompact gate and the Stop-chain Layer 3 hook share one close-intent
1761
1855
  * signal source. Claude Code transcript format: each line is
1762
1856
  * `{ type:"user", message:{ role:"user", content: ... } }`; the older
1763
- * top-level `{ role, content }` shape is also accepted.
1857
+ * top-level `{ role, content }` shape is also accepted. tool_result blocks
1858
+ * (also role:'user') are excluded so tool output never feeds the close-intent
1859
+ * gate — only top-level `type:'text'` blocks and string content count.
1764
1860
  *
1765
1861
  * @param {string} transcriptPath
1766
1862
  * @param {number} tailN how many trailing lines to scan (default 30)
@@ -1769,16 +1865,49 @@ export function isCompactOrClearCommand(prompt) {
1769
1865
  export function extractUserMessages(transcriptPath, tailN = 30) {
1770
1866
  try {
1771
1867
  const lines = readFileSync(transcriptPath, 'utf-8').split('\n');
1772
- const tail = lines.slice(-tailN);
1868
+ // tailN === Infinity → whole transcript (the marker-write hard gate needs the
1869
+ // full prefix; a close request can precede the marker by the entire close
1870
+ // checklist). The Stop hook keeps the 30-line default so a stale old close
1871
+ // signal doesn't re-trigger every turn.
1872
+ const tail = Number.isFinite(tailN) ? lines.slice(-tailN) : lines;
1773
1873
  return tail
1774
1874
  .map((line) => {
1775
1875
  try {
1776
1876
  const obj = JSON.parse(line);
1877
+ // Skill-injection vector (ADR 0055): drop system-injected role:user
1878
+ // messages before they pollute the close-intent signal.
1879
+ // • isMeta:true — slash-command bodies, skill bodies, local-command
1880
+ // caveats. Their text is docs/specs, often full of close vocabulary
1881
+ // (e.g. the /hypo:crystallize spec literally contains close phrases),
1882
+ // which would let the gate self-satisfy the moment the model invokes
1883
+ // a close command. Confirmed isMeta:true in the transcript.
1884
+ // • promptSource system|sdk — task-notifications (system) and
1885
+ // SDK / QA-harness synthetic prompts (sdk). Neither is user-typed.
1886
+ if (obj.isMeta === true) return '';
1887
+ if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return '';
1777
1888
  const msg = obj.message ?? obj;
1778
1889
  const role = msg.role ?? obj.role ?? obj.type;
1779
1890
  if (role !== 'user') return '';
1780
1891
  const content = msg.content ?? obj.content;
1781
- return typeof content === 'string' ? content : JSON.stringify(content);
1892
+ if (typeof content === 'string') {
1893
+ // Stop-hook block feedback is recorded as a role:user string. It is
1894
+ // the hook's OWN nudge ("[WIKI_AUTOCLOSE] … Run crystallize …"), not
1895
+ // user intent — counting it would be circular (the hook that prods the
1896
+ // model to close would become proof the user wanted to close).
1897
+ return content.startsWith('Stop hook feedback') ? '' : content;
1898
+ }
1899
+ if (Array.isArray(content)) {
1900
+ // Only genuine user-typed text blocks. tool_result blocks are also
1901
+ // recorded with role:'user' in the Claude Code transcript; slurping
1902
+ // them via JSON.stringify let tool output (e.g. close-pattern example
1903
+ // strings read out of code/docs) trip the close-intent gate.
1904
+ // Do NOT recurse into tool_result.content, or the pollution returns.
1905
+ return content
1906
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
1907
+ .map((b) => b.text)
1908
+ .join('\n');
1909
+ }
1910
+ return '';
1782
1911
  } catch {
1783
1912
  return '';
1784
1913
  }
@@ -1800,7 +1929,28 @@ export function extractUserMessages(transcriptPath, tailN = 30) {
1800
1929
  export function isClosePattern(text) {
1801
1930
  if (!text || typeof text !== 'string') return false;
1802
1931
  const krPatterns = [
1803
- /세션\s*(끝|종료|마무리)/,
1932
+ // 끝: bare terminal noun, boundary-guarded so "세션 끝내는 방법" /
1933
+ // "세션 끝나면" don't trip.
1934
+ /세션\s*끝(?![가-힣])/,
1935
+ // 세션 마무리/종료 (ADR 0055): the OLD pattern required a fixed verb suffix
1936
+ // (하자/할게/했어) and missed the most common real phrasings — "세션 마무리
1937
+ // 해줘" (imperative), bare "세션 마무리", "세션마무리" (no space). A
1938
+ // blacklist lookahead (excluding 조건/로직/여부/…) is whack-a-mole because
1939
+ // noun-modifiers are an open class. WHITELIST instead: match only when
1940
+ // 마무리/종료 is followed by a close-intent verb suffix OR sentence-end. This
1941
+ // structurally rejects noun-modifiers (세션 종료 여부/로직/작업 정리) and
1942
+ // negations (세션 종료 안 해도 돼, 세션 마무리하지 않아도) without enumerating.
1943
+ // The suffixes are COMPLETE terminal forms followed by a non-Hangul boundary
1944
+ // (?![가-힣]), so connective continuations die: 해주는/해주기 (해 alone, then
1945
+ // 주 follows), 해야 하는 / 해도 되는지 (해 alone, then 야/도 follows). 하고 is
1946
+ // deliberately dropped — "마무리하고 블로그"(close) and "마무리하고 싶은지"(not)
1947
+ // are structurally identical, and over-close is the worse failure, so we accept
1948
+ // the rare FN over the FP. The residual: connective forms that happen to put a
1949
+ // space after a complete terminal can't be separated by regex without a
1950
+ // morphological parser — that's bounded by the compound gate (precompact-green
1951
+ // + signal) and the unambiguous /compact and AskUserQuestion channels (ADR 0055
1952
+ // threat boundary), not chased further.
1953
+ /세션\s*(?:마무리|종료)(?:\s*(?:해줘|해주세요|해요|해|하자|하죠|했어|했다|했음|했지|합시다|합니다|할게|할께|할래|할까|할까요|한\s?거(?:지|야|니)?|함)(?![가-힣])|\s*[)\].,!?~。…]|\s*$)/m,
1804
1954
  /오늘\s*은?\s*(여기|작업|세션).*(끝|마치|마무리|종료)/,
1805
1955
  // 여기까지: requires no continuation action word (e.g. 여기까지 구현해줘 is not a close signal)
1806
1956
  /여기(서)?까지(?!\s*(?:구현|작성|완성|수정|변경|추가|삭제|테스트|확인|검토|해줘|해야|하고|하면))/,
@@ -1817,16 +1967,143 @@ export function isClosePattern(text) {
1817
1967
  // objects. The review/analysis/debug/audit/investigation nouns were added
1818
1968
  // for 6a — read-only review sessions are now "substantial", so "wrap up the
1819
1969
  // review" must read as a task-level signal, not a session-close one.
1820
- /wrap(?:ping)?\s+up(?!\s+(?:this|the)\s+(?:pr|issue|bug|task|function|component|module|feature|code|test|review|analysis|investigation|debugging|debug|audit|refactor)\b)/i,
1821
- /done\s+for\s+(?:today|now|the\s+day)/i,
1822
- /that'?s?\s+(?:all|it)\s+for\s+(?:today|now|the\s+day)/i,
1823
- /signing\s+off/i,
1824
- /end(?:ing)?\s+(?:the|this)\s+(?:session|work|day)/i,
1825
- /close\s+(?:the|this)\s+session/i,
1970
+ // Leading \b on each pattern (ADR 0055) so an EN close phrase embedded as a
1971
+ // substring of a longer token can't trip the gate (e.g. "designing off…").
1972
+ /\bwrap(?:ping)?\s+up(?!\s+(?:this|the)\s+(?:pr|issue|bug|task|function|component|module|feature|code|test|review|analysis|investigation|debugging|debug|audit|refactor)\b)/i,
1973
+ /\bdone\s+for\s+(?:today|now|the\s+day)\b/i,
1974
+ /\bthat'?s?\s+(?:all|it)\s+for\s+(?:today|now|the\s+day)\b/i,
1975
+ /\bsigning\s+off\b/i,
1976
+ /\bend(?:ing)?\s+(?:the|this)\s+(?:session|work|day)\b/i,
1977
+ /\bclose\s+(?:the|this)\s+session\b/i,
1826
1978
  ];
1827
1979
  return [...krPatterns, ...enPatterns].some((re) => re.test(text));
1828
1980
  }
1829
1981
 
1982
+ /**
1983
+ * Resolve a session's transcript path from its (globally-unique) session id by
1984
+ * globbing every Claude project dir: ~/.claude/projects/<slug>/<id>.jsonl.
1985
+ *
1986
+ * Why glob, not cwd-derive: the transcript lives under a slug built from the cwd
1987
+ * AT SESSION START, but the marker-writer subprocess can run from a DIFFERENT cwd
1988
+ * (the real over-close ran `cd ~/hypomnema && crystallize …`), so a cwd-derived
1989
+ * slug would miss the file. The session id is a UUID, so the glob disambiguates
1990
+ * without needing the slug — verified globally unique across all project dirs.
1991
+ *
1992
+ * Fail-closed on ambiguity (ADR 0055, codex review): returns the single resolved
1993
+ * path, or null when ZERO or MORE-THAN-ONE distinct files match (realpath-deduped
1994
+ * so a symlink to the same file is not "multiple"). The caller treats null as
1995
+ * "refuse the marker".
1996
+ *
1997
+ * `projectsRoot` defaults to the real ~/.claude/projects; tests pass a controlled
1998
+ * root so they exercise the real resolver instead of a forgeable path override.
1999
+ */
2000
+ export function resolveTranscriptBySessionId(
2001
+ sessionId,
2002
+ projectsRoot = join(HOME, '.claude', 'projects'),
2003
+ ) {
2004
+ if (!sessionId || typeof sessionId !== 'string') return null;
2005
+ // Session ids are UUID-shaped; reject anything with path separators / glob
2006
+ // chars so a crafted id can't escape the projects root or match siblings.
2007
+ if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
2008
+ try {
2009
+ const base = projectsRoot;
2010
+ const seen = new Set();
2011
+ for (const ent of readdirSync(base, { withFileTypes: true })) {
2012
+ if (!ent.isDirectory()) continue;
2013
+ const p = join(base, ent.name, `${sessionId}.jsonl`);
2014
+ if (!existsSync(p)) continue;
2015
+ try {
2016
+ seen.add(realpathSync(p));
2017
+ } catch {
2018
+ seen.add(p);
2019
+ }
2020
+ }
2021
+ return seen.size === 1 ? [...seen][0] : null;
2022
+ } catch {
2023
+ return null;
2024
+ }
2025
+ }
2026
+
2027
+ /**
2028
+ * Returns true iff the transcript carries a genuine USER session-close signal —
2029
+ * the hard gate for the session-closed marker writers (ADR 0055). Scans the FULL
2030
+ * transcript: a close request can precede the marker write by the entire close
2031
+ * checklist, so the Stop hook's 30-line tail would miss it.
2032
+ *
2033
+ * Evidence (any one is sufficient):
2034
+ * 1. a de-polluted NL close phrase — isClosePattern over extractUserMessages,
2035
+ * which already drops injected / tool / hook-feedback text (ADR 0055);
2036
+ * 2. a `/compact` invocation (queue-operation). `/clear` is deliberately NOT
2037
+ * counted: it abandons context, whereas a session-close PRESERVES the work
2038
+ * to the wiki — a different intent;
2039
+ * 3. an AskUserQuestion answer whose SELECTED value names a close action (the
2040
+ * canonical "offer [세션 마무리] → user picks it" flow).
2041
+ *
2042
+ * A Stop-hook block is NOT evidence: it is the hook's own nudge to close, so
2043
+ * counting it would be circular (the incident's block told the model to write the
2044
+ * marker). extractUserMessages already strips it.
2045
+ *
2046
+ * Fail-closed: any read error → false (caller refuses the marker).
2047
+ */
2048
+ export function hasUserCloseSignal(transcriptPath) {
2049
+ if (!transcriptPath) return false;
2050
+ let lines;
2051
+ try {
2052
+ lines = readFileSync(transcriptPath, 'utf-8').split('\n');
2053
+ } catch {
2054
+ return false;
2055
+ }
2056
+ // (1) NL close over the full, de-polluted transcript.
2057
+ if (isClosePattern(extractUserMessages(transcriptPath, Infinity))) return true;
2058
+ // AskUserQuestion answers (3) must be correlated to a real AskUserQuestion
2059
+ // tool_use by id — otherwise ANY tool_result string containing "have been
2060
+ // answered" (e.g. a Read/Grep of this very file, or of a transcript) would
2061
+ // satisfy the gate, reintroducing the tool_result pollution the de-pollution
2062
+ // layer closes. First pass collects the genuine AskUserQuestion tool_use ids;
2063
+ // the tool_use (assistant) always precedes its tool_result (user) in the log,
2064
+ // so a single forward scan suffices.
2065
+ const askIds = new Set();
2066
+ for (const line of lines) {
2067
+ if (!line.trim()) continue;
2068
+ let obj;
2069
+ try {
2070
+ obj = JSON.parse(line);
2071
+ } catch {
2072
+ continue;
2073
+ }
2074
+ // (2) /compact (queue-operation). Not /clear — see doc above.
2075
+ if (
2076
+ obj.type === 'queue-operation' &&
2077
+ typeof obj.content === 'string' &&
2078
+ /^\/compact(?:\s|$)/.test(obj.content.trim())
2079
+ ) {
2080
+ return true;
2081
+ }
2082
+ const content = (obj.message ?? obj).content;
2083
+ if (!Array.isArray(content)) continue;
2084
+ for (const b of content) {
2085
+ if (!b || typeof b !== 'object') continue;
2086
+ // record AskUserQuestion tool_use ids
2087
+ if (b.type === 'tool_use' && b.name === 'AskUserQuestion' && b.id) {
2088
+ askIds.add(b.id);
2089
+ continue;
2090
+ }
2091
+ // (3) AskUserQuestion answer naming a close action — only when this
2092
+ // tool_result actually answers a recorded AskUserQuestion. The answer lands
2093
+ // in a role:user tool_result string: `… have been answered: "Q"="A". …`.
2094
+ // Match the answer value(s) (the `="…"` side), never the question text, and
2095
+ // run the SAME isClosePattern as the NL path so the two channels agree.
2096
+ if (b.type === 'tool_result' && b.tool_use_id && askIds.has(b.tool_use_id)) {
2097
+ const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
2098
+ for (const m of s.matchAll(/="([^"]*)"/g)) {
2099
+ if (isClosePattern(m[1])) return true;
2100
+ }
2101
+ }
2102
+ }
2103
+ }
2104
+ return false;
2105
+ }
2106
+
1830
2107
  /**
1831
2108
  * Build hook output for Claude Code (additionalContext channel).
1832
2109
  * Codex hooks write systemMessage directly in their own files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -73,7 +73,7 @@ import { join, relative, extname, dirname } from 'path';
73
73
  import { spawnSync } from 'child_process';
74
74
  import { fileURLToPath } from 'url';
75
75
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
76
- import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
76
+ import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
77
77
  import {
78
78
  sessionCloseFileStatus,
79
79
  sessionCloseGlobalStatus,
@@ -85,6 +85,9 @@ import {
85
85
  sessionLogShardPath,
86
86
  sessionLogReadCandidates,
87
87
  sessionLogScopePath,
88
+ resolveTranscriptBySessionId,
89
+ hasUserCloseSignal,
90
+ commitWikiChanges,
88
91
  } from '../hooks/hypo-shared.mjs';
89
92
 
90
93
  const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
@@ -154,6 +157,18 @@ function parseArgs(argv) {
154
157
  return args;
155
158
  }
156
159
 
160
+ // ── session-close hard gate (ADR 0055) ───────────────────────────────────────
161
+ // The marker attests "the USER closed this session". Its evidence transcript is
162
+ // resolved STRICTLY from the session id (a globally-unique UUID) by globbing the
163
+ // Claude project dirs — never from a CLI arg. A model owns the whole subprocess
164
+ // invocation, so trusting a `--transcript-path` it supplies would let it point at
165
+ // a forged `<session-id>.jsonl` it just wrote with a fake close phrase. Resolving
166
+ // from the id alone closes that: the only file the glob finds is the live
167
+ // transcript the harness itself maintains, which the model cannot author. (If the
168
+ // model drops a second `<id>.jsonl` elsewhere the glob returns >1 and fails
169
+ // closed.) `--transcript-path` survives ONLY for `--check-session-close`'s lint
170
+ // scope, which writes no marker and so cannot cause an over-close.
171
+
157
172
  // ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
158
173
  // Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
159
174
  // flow can self-verify before /compact triggers PreCompact.
@@ -426,8 +441,12 @@ function runMarkSessionClosed(args) {
426
441
  // (project-close invariant → a today log.md entry; lint/W8 scoped to shared +
427
442
  // touched files, never the active/phantom project), but git / hot / feedback
428
443
  // still apply — log-only is NOT a global-gate bypass.
444
+ // Resolve the close transcript once from the session id (glob, never a CLI
445
+ // arg): it both widens the lint scope inside the gate AND is the evidence
446
+ // source for the user-close hard gate below.
447
+ const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
429
448
  const gate = precompactGateStatus(args.hypoDir, {
430
- ...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}),
449
+ ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
431
450
  ...(args.logOnly ? { logOnly: true } : {}),
432
451
  });
433
452
  const status = gate.close;
@@ -451,6 +470,26 @@ function runMarkSessionClosed(args) {
451
470
  }
452
471
  process.exit(1);
453
472
  }
473
+ // User-close hard gate (ADR 0055): the compact gate above only proves the wiki
474
+ // is compact-ready; it does NOT prove the USER asked to close. Refuse the marker
475
+ // unless the transcript carries a genuine user close signal (NL close phrase,
476
+ // /compact, or an AskUserQuestion close answer). This is the hard backstop for
477
+ // model over-close, where prose guidance lost to a conflicting global rule.
478
+ // Fail-closed when the transcript can't be resolved.
479
+ if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
480
+ const reason = !closeTranscript
481
+ ? `cannot resolve a transcript for session ${args.sessionId} — the session-closed marker requires a verifiable user close signal`
482
+ : "no user close signal in this session's transcript — marker refused (the user did not signal session close)";
483
+ const result = {
484
+ ok: false,
485
+ session_id: args.sessionId,
486
+ project: status.project,
487
+ skipReason: 'no-user-close-signal',
488
+ error: reason,
489
+ };
490
+ console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
491
+ process.exit(1);
492
+ }
454
493
  writeSessionClosedMarker(args.hypoDir, args.sessionId, {
455
494
  project: status.project,
456
495
  ...(args.logOnly ? { scope: 'log-only' } : {}),
@@ -794,19 +833,54 @@ function applySessionClose(args) {
794
833
  // govern apply SUCCESS (exit code below), but the marker must additionally
795
834
  // clear feedback projection / W8 design-history / hot.md structure, else this
796
835
  // path could issue a marker the standalone path would refuse (the second
797
- // divergence codex flagged). git-clean is subsumed by the gate's git blocker.
836
+ // divergence codex flagged).
837
+ //
838
+ // ADR 0056: apply just wrote the payload, so the tree is dirty by its OWN
839
+ // writes — the gate's `uncommitted` git blocker would always trip and the
840
+ // marker would be skipped, deferring the close to a manual --mark-session-closed
841
+ // (the ADR 0047 "done but still blocked" regression). Commit the payload HERE, via
842
+ // the SAME .hypoignore-aware helper the auto-commit Stop hook uses, so the gate sees
843
+ // a committed tree. Push stays deferred to the Stop hook; the resulting
844
+ // committed-but-unpushed state is a gate notice, not a blocker (ADR 0056), so
845
+ // this still marks. A commit failure (not a repo / pre-commit reject / git error)
846
+ // skips the marker WITH a surfaced reason — today's behavior was also "no marker",
847
+ // but silently.
848
+ let markerWritten = false;
849
+ let markerSkipReason = null;
798
850
  if (ok && args.sessionId) {
799
- const markerGate = precompactGateStatus(
800
- args.hypoDir,
801
- args.transcriptPath ? { transcriptPath: args.transcriptPath } : {},
802
- );
803
- if (markerGate.ok) {
804
- writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
851
+ const commitOutcome = commitWikiChanges(args.hypoDir);
852
+ if (!commitOutcome.committed) {
853
+ markerSkipReason = `commit-failed: ${commitOutcome.reason}`;
854
+ } else {
855
+ const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
856
+ const markerGate = precompactGateStatus(
857
+ args.hypoDir,
858
+ closeTranscript ? { transcriptPath: closeTranscript } : {},
859
+ );
860
+ if (!markerGate.ok) {
861
+ // compact gate not ok → skip. Caller's `result.ok` already reflects the
862
+ // file/lint state; next Stop re-blocks until the remaining blocker
863
+ // (feedback/W8/hot/lint — git is now committed) is resolved.
864
+ markerSkipReason = 'compact-gate-not-ok';
865
+ } else if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
866
+ // User-close hard gate (ADR 0055): apply succeeded (payload files written)
867
+ // but the user never signalled session close, so the marker — which attests
868
+ // "user closed" — is withheld. The wiki record stands; the session is simply
869
+ // not marked closed. Surfaced (not silent) so the caller knows.
870
+ markerSkipReason = closeTranscript ? 'no-user-close-signal' : 'transcript-unresolved';
871
+ } else {
872
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
873
+ // Codex CONCERN (ADR 0055/0056): the writer swallows IO errors (best-effort).
874
+ // Verify the file actually landed — mirroring the standalone path — instead of
875
+ // asserting markerWritten=true, so a .cache permission/disk problem surfaces
876
+ // rather than the caller reporting "closed" while the next Stop re-blocks.
877
+ if (existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
878
+ markerWritten = true;
879
+ } else {
880
+ markerSkipReason = 'marker-did-not-land';
881
+ }
882
+ }
805
883
  }
806
- // gate not ok → silent skip: caller's `result.ok` already reflects the
807
- // file/lint state; surfacing a "marker skipped" warning here would
808
- // confuse the close-applied success path. Next Stop re-blocks until the
809
- // remaining blocker (git/feedback/W8/hot/lint) is resolved.
810
884
  }
811
885
  const stage = ok
812
886
  ? null
@@ -823,6 +897,9 @@ function applySessionClose(args) {
823
897
  applied,
824
898
  skipped,
825
899
  verification,
900
+ // ADR 0055: surface the marker outcome instead of skipping silently, so the
901
+ // caller can tell "closed" from "applied but not marked".
902
+ ...(args.sessionId ? { markerWritten, markerSkipReason } : {}),
826
903
  lint: { preflight: preflightLint, postApply: postApplyLint },
827
904
  // Pre-existing lint debt in files this close did not author (Bug B): surfaced
828
905
  // for visibility, never gated. Empty on a clean vault.
@@ -872,7 +949,7 @@ function collectPages(dir, root, acc = [], ignorePatterns = []) {
872
949
  for (const entry of readdirSync(dir)) {
873
950
  if (entry.startsWith('.')) continue;
874
951
  const full = join(dir, entry);
875
- if (isIgnored(full, root, ignorePatterns)) continue;
952
+ if (isScanIgnored(full, root, ignorePatterns)) continue;
876
953
  const st = statSync(full);
877
954
  if (st.isDirectory()) collectPages(full, root, acc, ignorePatterns);
878
955
  else if (extname(entry) === '.md') {
@@ -18,7 +18,7 @@ import { homedir } from 'os';
18
18
  import { spawnSync } from 'child_process';
19
19
  import { fileURLToPath } from 'url';
20
20
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
- import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
21
+ import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
22
22
  import { parseFrontmatter } from './lib/frontmatter.mjs';
23
23
  import { readSyncState, projectSuggestionsPath } from '../hooks/hypo-shared.mjs';
24
24
  import {
@@ -443,7 +443,7 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
443
443
  for (const entry of readdirSync(dir)) {
444
444
  if (entry.startsWith('.')) continue;
445
445
  const full = join(dir, entry);
446
- if (hypoDir && isIgnored(full, hypoDir, ignorePatterns)) continue;
446
+ if (hypoDir && isScanIgnored(full, hypoDir, ignorePatterns)) continue;
447
447
  const st = statSync(full);
448
448
  if (st.isDirectory()) collectMdFiles(full, acc, hypoDir, ignorePatterns);
449
449
  else if (extname(entry) === '.md') acc.push(full);