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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +2 -1
- package/README.md +2 -1
- package/commands/audit.md +1 -1
- package/commands/crystallize.md +3 -3
- package/commands/doctor.md +1 -1
- package/commands/feedback.md +1 -1
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +1 -1
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +79 -0
- package/commands/resume.md +1 -1
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +1 -1
- package/commands/upgrade.md +1 -1
- package/commands/verify.md +1 -1
- package/hooks/hypo-auto-commit.mjs +8 -24
- package/hooks/hypo-compact-guard.mjs +5 -2
- package/hooks/hypo-shared.mjs +318 -41
- package/package.json +1 -1
- package/scripts/crystallize.mjs +91 -14
- package/scripts/doctor.mjs +2 -2
- package/scripts/graph.mjs +2 -2
- package/scripts/init.mjs +31 -2
- package/scripts/lib/hypo-ignore.mjs +24 -0
- package/scripts/lint.mjs +7 -6
- package/scripts/query.mjs +2 -2
- package/scripts/rename.mjs +480 -17
- package/scripts/stats.mjs +2 -2
- package/scripts/verify.mjs +2 -2
- package/skills/crystallize/SKILL.md +2 -2
- package/skills/graph/SKILL.md +1 -1
- package/skills/ingest/SKILL.md +1 -1
- package/skills/lint/SKILL.md +1 -1
- package/skills/query/SKILL.md +1 -1
- package/skills/verify/SKILL.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +2 -0
- package/templates/hypo-help.md +1 -0
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -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)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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:`)
|
|
535
|
-
//
|
|
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
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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
package/scripts/crystallize.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
...(
|
|
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).
|
|
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
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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 (
|
|
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') {
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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,
|
|
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 &&
|
|
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);
|