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