hypomnema 1.7.2 → 1.7.3
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/commands/crystallize.md +5 -5
- package/hooks/hypo-hot-rebuild.mjs +22 -2
- package/hooks/hypo-shared.mjs +177 -31
- package/package.json +1 -1
- package/scripts/capture.mjs +15 -20
- package/scripts/crystallize.mjs +97 -12
- package/scripts/doctor.mjs +2 -2
- package/scripts/init.mjs +8 -7
- package/scripts/lib/extensions.mjs +43 -4
- package/scripts/lib/git-hooks-dir.mjs +10 -0
- package/scripts/lint.mjs +45 -1
- package/scripts/uninstall.mjs +254 -2
- package/templates/hypo-config.md +1 -1
package/commands/crystallize.md
CHANGED
|
@@ -153,13 +153,13 @@ The result JSON includes a `stage` field when `ok: false`. Branch on it:
|
|
|
153
153
|
| `post-apply-lint` | The payload introduced an error-level lint blocker in a payload file (malformed body / bad frontmatter), or lint crashed. | Fix the offending content in the payload, then re-run. (Broken wikilinks are W4 warnings — not gated.) |
|
|
154
154
|
| `post-apply-verification+lint` | Both above. | Fix both; re-run. |
|
|
155
155
|
|
|
156
|
-
Once `ok: true`, report:
|
|
156
|
+
Once `ok: true`, report from the result JSON's `applied` and `skipped` arrays together, not `applied` alone. `applied` lists only the fields this run actually wrote bytes for; `skipped` lists the fields that already matched what was on disk (a re-run of an already-applied payload). An idempotent re-run legitimately reports `applied: []`, and that is success, not a failure to report on: check `skipped` for the same 4-6 entries instead. Read `committed` the same way: `true` covers both a real commit and the case where nothing needed staging (a full no-op re-run); `false` means the commit itself ran and failed (see `markerSkipReason`); `null` means apply never reached the commit step at all, because `ok` was already false (an authority refusal before any write, or a verification/lint failure, or a withheld conflict, per `stage`). `null` does NOT mean nothing was written: `applied` can be non-empty (bytes landed on disk) while `committed` stays `null`, because those bytes were never staged into git.
|
|
157
157
|
|
|
158
|
-
- ✓ session-state.md applied
|
|
159
|
-
- ✓ hot.md (project + root) applied
|
|
160
|
-
- ✓ session-log entry appended
|
|
158
|
+
- ✓ session-state.md applied (or already current, per `skipped`)
|
|
159
|
+
- ✓ hot.md (project + root) applied (or already current)
|
|
160
|
+
- ✓ session-log entry appended (or already present)
|
|
161
161
|
- ✓ open-questions applied (or skipped if unchanged)
|
|
162
|
-
- ✓ log.md entry appended
|
|
162
|
+
- ✓ log.md entry appended (or already present)
|
|
163
163
|
- ✓ post-apply lint clean
|
|
164
164
|
- **marker written?** (required check): if `markerWritten: true`, report "session-close marker written"; if `markerWritten: false`, report "session-close marker NOT written (reason: `<markerSkipReason>`)" and do NOT declare the session "closed" or "complete". A missing marker means the Stop-chain is still open; recover per the `markerSkipReason` branch below.
|
|
165
165
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
deriveRootLogEntries,
|
|
20
20
|
recordTouchedPaths,
|
|
21
21
|
} from './hypo-shared.mjs';
|
|
22
|
+
import { advanceBase, hashContent } from './base-store.mjs';
|
|
22
23
|
|
|
23
24
|
const HOT_PATH = join(HYPO_DIR, 'hot.md');
|
|
24
25
|
const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
|
|
@@ -73,7 +74,7 @@ function parsePointerRows(content) {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/** @returns {boolean} true when hot.md was actually rewritten. */
|
|
76
|
-
function rebuild() {
|
|
77
|
+
function rebuild(sessionId) {
|
|
77
78
|
if (!existsSync(HOT_PATH)) return false;
|
|
78
79
|
|
|
79
80
|
const current = readFileSync(HOT_PATH, 'utf-8');
|
|
@@ -116,6 +117,25 @@ ${tableRows}
|
|
|
116
117
|
|
|
117
118
|
if (canonical !== current) {
|
|
118
119
|
writeFileSync(HOT_PATH, canonical);
|
|
120
|
+
// This write bypasses the Write/Edit tool, so hypo-auto-stage's
|
|
121
|
+
// PostToolUse-based advanceBaseForWrite never sees it (see the file-header
|
|
122
|
+
// comment above). Without advancing the base here, this session's own
|
|
123
|
+
// rewrite of hot.md looks -- at close time -- exactly like a DIFFERENT
|
|
124
|
+
// session having edited it, and the observed-base guard in crystallize.mjs
|
|
125
|
+
// parks a false conflict against this session's own work. Mirrors
|
|
126
|
+
// crystallize.mjs's overwrite(): advanceBase right after the write that
|
|
127
|
+
// made it true.
|
|
128
|
+
//
|
|
129
|
+
// No session_id on stdin (empty/malformed payload): advanceBase is a
|
|
130
|
+
// no-op without a snapshot anyway, so this stays silent-safe like before
|
|
131
|
+
// this fix -- just observable on stderr instead of a guess.
|
|
132
|
+
if (sessionId) {
|
|
133
|
+
advanceBase(HYPO_DIR, sessionId, 'hot.md', hashContent(canonical));
|
|
134
|
+
} else {
|
|
135
|
+
process.stderr.write(
|
|
136
|
+
'[hypo-hot-rebuild] no session_id on stdin; base not advanced for hot.md\n',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
119
139
|
return true;
|
|
120
140
|
}
|
|
121
141
|
return false;
|
|
@@ -134,7 +154,7 @@ function emitGrowth() {
|
|
|
134
154
|
|
|
135
155
|
let hotWritten = false;
|
|
136
156
|
try {
|
|
137
|
-
hotWritten = rebuild();
|
|
157
|
+
hotWritten = rebuild(sessionId);
|
|
138
158
|
} catch (err) {
|
|
139
159
|
process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
|
|
140
160
|
}
|
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -445,6 +445,72 @@ export function hypoIsClean(dir = HYPO_DIR) {
|
|
|
445
445
|
}
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
+
/**
|
|
449
|
+
* Repo-relative POSIX paths with uncommitted changes (tracked or untracked) in
|
|
450
|
+
* `dir`. Reuses the SAME `-z` rename-aware porcelain parsing as
|
|
451
|
+
* commitWikiChanges (PR #222): a rename or copy emits `to\0from`, and both
|
|
452
|
+
* sides count as dirty (a rename's source still shows staged until the rename
|
|
453
|
+
* itself lands). This is the read-only half of that fix's pathspec boundary:
|
|
454
|
+
* commitWikiChanges narrows what gets COMMITTED to a caller-supplied scope;
|
|
455
|
+
* this narrows what counts as a BLOCKER the same way, for precompactGateStatus
|
|
456
|
+
* to attribute dirty files to the session that owns them instead of the whole
|
|
457
|
+
* shared working tree.
|
|
458
|
+
*
|
|
459
|
+
* Paths are normalized to be relative to `dir` itself, NOT to the git
|
|
460
|
+
* repository's top level: `git -C <dir> status --porcelain` prints paths
|
|
461
|
+
* relative to the repo TOP LEVEL even under `-C` (verified empirically: a
|
|
462
|
+
* vault nested under `<repo>/vault/` reports `vault/hot.md`, not `hot.md`).
|
|
463
|
+
* Every other path this file compares against (closeAccountableScope,
|
|
464
|
+
* closeFileTargetsGlobal, extractTouchedWikiFiles) is `dir`-relative, so
|
|
465
|
+
* without this normalization a nested vault's OWN files would never match
|
|
466
|
+
* its own scope and every one of them would look foreign (codex pre-commit
|
|
467
|
+
* review BLOCKER 2). `git rev-parse --show-prefix` gives exactly the prefix
|
|
468
|
+
* to strip; a dirty path that does not start with it lives outside the
|
|
469
|
+
* vault entirely (the rest of a bigger host repo) and is dropped, never
|
|
470
|
+
* "mine".
|
|
471
|
+
*
|
|
472
|
+
* @returns {string[]} dirty paths relative to `dir`, or `[]` on any git
|
|
473
|
+
* failure (the caller already has its own git-status result via
|
|
474
|
+
* hypoIsClean and treats that failure as an unconditional blocker; an
|
|
475
|
+
* empty return here just means "cannot attribute", not "clean").
|
|
476
|
+
*/
|
|
477
|
+
function gitDirtyFiles(dir) {
|
|
478
|
+
const prefixRes = spawnSync('git', ['-C', dir, 'rev-parse', '--show-prefix'], {
|
|
479
|
+
encoding: 'utf-8',
|
|
480
|
+
});
|
|
481
|
+
if (prefixRes.status !== 0) return []; // can't resolve the repo → cannot attribute
|
|
482
|
+
const prefix = (prefixRes.stdout || '').trim();
|
|
483
|
+
|
|
484
|
+
const porcelain = spawnSync('git', ['-C', dir, 'status', '--porcelain', '-uall', '-z'], {
|
|
485
|
+
encoding: 'utf-8',
|
|
486
|
+
});
|
|
487
|
+
if (porcelain.status !== 0) return [];
|
|
488
|
+
const out = [];
|
|
489
|
+
const records = (porcelain.stdout || '').split('\0');
|
|
490
|
+
const toDirRelative = (f) => {
|
|
491
|
+
if (!f) return null;
|
|
492
|
+
if (!prefix) return f; // dir IS the repo top level, nothing to strip
|
|
493
|
+
return f.startsWith(prefix) ? f.slice(prefix.length) : null; // outside the vault
|
|
494
|
+
};
|
|
495
|
+
for (let i = 0; i < records.length; i++) {
|
|
496
|
+
const rec = records[i];
|
|
497
|
+
if (!rec) continue;
|
|
498
|
+
const xy = rec.slice(0, 2);
|
|
499
|
+
const file = rec.slice(3); // destination path for a rename/copy
|
|
500
|
+
const isRenameOrCopy = xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C';
|
|
501
|
+
let fromFile = null;
|
|
502
|
+
if (isRenameOrCopy) {
|
|
503
|
+
i++;
|
|
504
|
+
fromFile = records[i] || null;
|
|
505
|
+
}
|
|
506
|
+
const rel = toDirRelative(file);
|
|
507
|
+
if (rel) out.push(rel);
|
|
508
|
+
const relFrom = toDirRelative(fromFile);
|
|
509
|
+
if (relFrom) out.push(relFrom);
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
|
|
448
514
|
export function hotMdIsClean(dir = HYPO_DIR) {
|
|
449
515
|
const hotPath = dir === HYPO_DIR ? HOT_PATH : join(dir, 'hot.md');
|
|
450
516
|
if (!existsSync(hotPath)) return { clean: true };
|
|
@@ -3281,22 +3347,32 @@ function toHypoRel(absPath, hypoDir) {
|
|
|
3281
3347
|
}
|
|
3282
3348
|
|
|
3283
3349
|
/**
|
|
3284
|
-
*
|
|
3285
|
-
*
|
|
3286
|
-
*
|
|
3287
|
-
*
|
|
3350
|
+
* Same walk as extractTouchedWikiFiles, but also reports whether the walk can
|
|
3351
|
+
* be TRUSTED as a complete enumeration: `trusted: false` when the transcript
|
|
3352
|
+
* is missing, unreadable, or contains a line that failed to parse (transcripts
|
|
3353
|
+
* occasionally truncate mid-write). `extractTouchedWikiFiles` collapses all of
|
|
3354
|
+
* that to an empty/partial Set, indistinguishable from "this session touched
|
|
3355
|
+
* nothing", which is fine for lint's existing debt-partition (a false notice is not a
|
|
3356
|
+
* false pass), but NOT fine for an attribution-sensitive caller like
|
|
3357
|
+
* precompactGateStatus's git-scope check: treating an untrustworthy empty Set
|
|
3358
|
+
* as "nothing to widen" would let a session's own dirty file, invisible only
|
|
3359
|
+
* because its transcript could not be read, pass as someone else's foreign
|
|
3360
|
+
* debt (codex pre-commit review BLOCKER 1).
|
|
3361
|
+
*
|
|
3362
|
+
* @returns {{files: Set<string>, trusted: boolean}}
|
|
3288
3363
|
*/
|
|
3289
|
-
export function
|
|
3364
|
+
export function extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir) {
|
|
3290
3365
|
const out = new Set();
|
|
3291
3366
|
if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
|
|
3292
|
-
return out;
|
|
3367
|
+
return { files: out, trusted: false };
|
|
3293
3368
|
}
|
|
3294
3369
|
let raw;
|
|
3295
3370
|
try {
|
|
3296
3371
|
raw = readFileSync(transcriptPath, 'utf-8');
|
|
3297
3372
|
} catch {
|
|
3298
|
-
return out;
|
|
3373
|
+
return { files: out, trusted: false };
|
|
3299
3374
|
}
|
|
3375
|
+
let trusted = true;
|
|
3300
3376
|
for (const line of raw.split('\n')) {
|
|
3301
3377
|
const t = line.trim();
|
|
3302
3378
|
if (!t) continue;
|
|
@@ -3304,6 +3380,7 @@ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
|
|
|
3304
3380
|
try {
|
|
3305
3381
|
entry = JSON.parse(t);
|
|
3306
3382
|
} catch {
|
|
3383
|
+
trusted = false; // a truncated/corrupt line: the walk may be incomplete
|
|
3307
3384
|
continue;
|
|
3308
3385
|
}
|
|
3309
3386
|
for (const fp of extractTranscriptToolFilePaths(entry)) {
|
|
@@ -3311,7 +3388,19 @@ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
|
|
|
3311
3388
|
if (rel) out.add(rel);
|
|
3312
3389
|
}
|
|
3313
3390
|
}
|
|
3314
|
-
return out;
|
|
3391
|
+
return { files: out, trusted };
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
/**
|
|
3395
|
+
* Repo-relative POSIX paths of wiki files this session edited via direct
|
|
3396
|
+
* Edit/Write/MultiEdit/NotebookEdit tool_use. Returns a Set; empty when the
|
|
3397
|
+
* transcript is missing/unreadable (callers decide the fallback). A per-line
|
|
3398
|
+
* JSON parse error skips that line only (transcripts occasionally truncate).
|
|
3399
|
+
* A caller that needs to tell "genuinely empty" apart from "could not fully
|
|
3400
|
+
* enumerate" wants extractTouchedWikiFilesWithTrust instead.
|
|
3401
|
+
*/
|
|
3402
|
+
export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
|
|
3403
|
+
return extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir).files;
|
|
3315
3404
|
}
|
|
3316
3405
|
|
|
3317
3406
|
/**
|
|
@@ -3539,20 +3628,89 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
3539
3628
|
const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
|
|
3540
3629
|
const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
|
|
3541
3630
|
|
|
3542
|
-
//
|
|
3543
|
-
//
|
|
3631
|
+
// Paths this session is accountable for at THIS close: the same signals the
|
|
3632
|
+
// lint partition below already trusts for exactly this question ("is this
|
|
3633
|
+
// file mine or pre-existing debt"), hoisted so the git check (step 1) can
|
|
3634
|
+
// partition on it too. Base = the mandatory close-target files (opts.lintScope
|
|
3635
|
+
// override, else the log-only shared root files, else one project's close
|
|
3636
|
+
// files under projectOverride, else every today-active project's); widened by
|
|
3637
|
+
// every file this session's transcript shows it editing directly.
|
|
3638
|
+
const closeAccountableScope = new Set(
|
|
3639
|
+
opts.lintScope ||
|
|
3640
|
+
(logOnly
|
|
3641
|
+
? ['hot.md', 'log.md']
|
|
3642
|
+
: opts.projectOverride
|
|
3643
|
+
? closeFileTargetsForProject(hypoDir, opts.projectOverride)
|
|
3644
|
+
: closeFileTargetsGlobal(hypoDir)),
|
|
3645
|
+
);
|
|
3646
|
+
// Trust signal for the git-scope check below (codex pre-commit review
|
|
3647
|
+
// BLOCKER 1): closeAccountableScope's mandatory-files base is always
|
|
3648
|
+
// reliable, but the transcript widening that catches an ad hoc page this
|
|
3649
|
+
// session edited is only as good as the transcript. No opts.transcriptPath
|
|
3650
|
+
// at all, an unreadable file, or a line that fails to parse all mean the
|
|
3651
|
+
// widened scope may be under-inclusive, NOT that this session touched
|
|
3652
|
+
// nothing extra. Only a fully-read, fully-parsed transcript earns
|
|
3653
|
+
// sessionTouchTrusted:true. A single tool_use with no file_path does NOT
|
|
3654
|
+
// lower trust: most tool_use blocks (Read, Bash, Grep, ...) never carry one
|
|
3655
|
+
// and that is expected, not corruption, so treating it as untrustworthy
|
|
3656
|
+
// would make almost every real transcript untrusted and defeat the scoping
|
|
3657
|
+
// this fix exists to add.
|
|
3658
|
+
let sessionTouchTrusted = false;
|
|
3659
|
+
if (opts.transcriptPath) {
|
|
3660
|
+
const widened = extractTouchedWikiFilesWithTrust(opts.transcriptPath, hypoDir);
|
|
3661
|
+
sessionTouchTrusted = widened.trusted;
|
|
3662
|
+
for (const f of widened.files) closeAccountableScope.add(f);
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
// 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but only
|
|
3666
|
+
// the ones inside closeAccountableScope, and only when sessionTouchTrusted
|
|
3667
|
+
// (above) says that scope can actually be trusted: a session's own scoped
|
|
3668
|
+
// auto-commit (commitWikiChanges, PR #222) can leave the working tree
|
|
3669
|
+
// non-empty when a DIFFERENT session sharing this vault still has its own
|
|
3670
|
+
// file dirty, the 2026-08-03 multi-session block. That dirty file is
|
|
3671
|
+
// human-fixable by whoever owns it, not by this session, so it demotes to
|
|
3672
|
+
// a notice (listed by path, never silently dropped) instead of refusing
|
|
3673
|
+
// this session's marker. A dirty file THIS session owns still blocks
|
|
3674
|
+
// unconditionally (fail-closed is unchanged for scope this session
|
|
3675
|
+
// actually touched), and so does an unattributable state: a git failure
|
|
3676
|
+
// gitDirtyFiles can't enumerate (dirty.length === 0 despite
|
|
3677
|
+
// git.uncommitted === true), OR a scope we cannot trust
|
|
3678
|
+
// (!sessionTouchTrusted) both fall back to the original unscoped blocker:
|
|
3679
|
+
// "cannot attribute" is not "clean".
|
|
3680
|
+
// Unpushed commits (ahead) DEMOTE to a notice regardless of scope: push is
|
|
3544
3681
|
// automatic (auto-commit Stop hook) and its failures are already non-fatal, so
|
|
3545
3682
|
// "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
|
|
3546
3683
|
// here (the shared gate) keeps the marker == compact-ready invariant:
|
|
3547
3684
|
// a committed-but-unpushed close marks AND compacts, instead of the close writer
|
|
3548
3685
|
// committing its own payload and then being blocked by its own (unpushed) commit.
|
|
3549
3686
|
const git = hypoIsClean(hypoDir);
|
|
3550
|
-
if (git.uncommitted)
|
|
3551
|
-
|
|
3687
|
+
if (git.uncommitted) {
|
|
3688
|
+
const dirty = gitDirtyFiles(hypoDir);
|
|
3689
|
+
if (dirty.length === 0 || !sessionTouchTrusted) {
|
|
3690
|
+
blockers.push({ type: 'git', reason: git.reason });
|
|
3691
|
+
} else {
|
|
3692
|
+
const mine = dirty.filter((f) => closeAccountableScope.has(posixPath(f)));
|
|
3693
|
+
const foreign = dirty.filter((f) => !closeAccountableScope.has(posixPath(f)));
|
|
3694
|
+
if (mine.length > 0) {
|
|
3695
|
+
blockers.push({
|
|
3696
|
+
type: 'git',
|
|
3697
|
+
reason: `uncommitted changes in ${hypoDir}: ${mine.join(', ')}`,
|
|
3698
|
+
});
|
|
3699
|
+
}
|
|
3700
|
+
for (const f of foreign) {
|
|
3701
|
+
notices.push({
|
|
3702
|
+
type: 'git',
|
|
3703
|
+
file: f,
|
|
3704
|
+
reason: `uncommitted changes outside this session's scope: ${f}`,
|
|
3705
|
+
});
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
} else if (git.ahead) {
|
|
3552
3709
|
notices.push({
|
|
3553
3710
|
type: 'git-sync',
|
|
3554
3711
|
reason: `unpushed commits in ${hypoDir} (push deferred to Stop hook)`,
|
|
3555
3712
|
});
|
|
3713
|
+
}
|
|
3556
3714
|
|
|
3557
3715
|
// 2. root hot.md structure
|
|
3558
3716
|
const hot = hotMdIsClean(hypoDir);
|
|
@@ -3762,25 +3920,13 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
3762
3920
|
const parsed = JSON.parse(r.stdout);
|
|
3763
3921
|
const allErrors = parsed.errors || [];
|
|
3764
3922
|
const allW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
|
|
3765
|
-
//
|
|
3766
|
-
//
|
|
3767
|
-
//
|
|
3768
|
-
//
|
|
3769
|
-
//
|
|
3770
|
-
//
|
|
3771
|
-
|
|
3772
|
-
// files (matching the narrowed close status above); else the global set.
|
|
3773
|
-
const scope = new Set(
|
|
3774
|
-
opts.lintScope ||
|
|
3775
|
-
(logOnly
|
|
3776
|
-
? ['hot.md', 'log.md']
|
|
3777
|
-
: opts.projectOverride
|
|
3778
|
-
? closeFileTargetsForProject(hypoDir, opts.projectOverride)
|
|
3779
|
-
: closeFileTargetsGlobal(hypoDir)),
|
|
3780
|
-
);
|
|
3781
|
-
if (opts.transcriptPath && existsSync(opts.transcriptPath)) {
|
|
3782
|
-
for (const f of extractTouchedWikiFiles(opts.transcriptPath, hypoDir)) scope.add(f);
|
|
3783
|
-
}
|
|
3923
|
+
// Lint scope = closeAccountableScope, hoisted above step 1 so the git
|
|
3924
|
+
// check can partition on the same "is this mine" answer. See that
|
|
3925
|
+
// hoisted comment for what feeds it (opts.lintScope override, else
|
|
3926
|
+
// log-only's shared root files, else one project's close files under
|
|
3927
|
+
// projectOverride, else the global set, widened by transcript-touched
|
|
3928
|
+
// files).
|
|
3929
|
+
const scope = closeAccountableScope;
|
|
3784
3930
|
const part = partitionLintScope(allErrors, scope);
|
|
3785
3931
|
if (part.blocking.length > 0) {
|
|
3786
3932
|
blockers.push({
|
package/package.json
CHANGED
package/scripts/capture.mjs
CHANGED
|
@@ -42,9 +42,10 @@ import {
|
|
|
42
42
|
unlinkSync,
|
|
43
43
|
renameSync,
|
|
44
44
|
realpathSync,
|
|
45
|
-
lstatSync,
|
|
46
45
|
openSync,
|
|
47
46
|
closeSync,
|
|
47
|
+
statSync,
|
|
48
|
+
chmodSync,
|
|
48
49
|
} from 'fs';
|
|
49
50
|
import { randomBytes } from 'crypto';
|
|
50
51
|
import { join, dirname, relative, sep } from 'path';
|
|
@@ -76,6 +77,7 @@ import {
|
|
|
76
77
|
HOOK_EVENT_ALLOWLIST,
|
|
77
78
|
SKILL_ROOT_FILE,
|
|
78
79
|
EXT_PREFIX,
|
|
80
|
+
withSrcExecBits,
|
|
79
81
|
} from './lib/extensions.mjs';
|
|
80
82
|
import { readCoreHooksConfig, deriveCoreHookBasenames } from './lib/core-hooks.mjs';
|
|
81
83
|
|
|
@@ -582,7 +584,12 @@ function log(msg) {
|
|
|
582
584
|
// `${dest}.tmp.${pid}` name was predictable, and writeFileSync on a path someone had
|
|
583
585
|
// already planted a symlink at would follow it straight out of the wiki. O_EXCL fails
|
|
584
586
|
// on an existing path of any kind, symlink included.
|
|
585
|
-
|
|
587
|
+
// `srcMode`, when given, carries the source file's execute bit onto the wiki
|
|
588
|
+
// copy (openSync's own mode argument is clipped by umask same as writeFileSync,
|
|
589
|
+
// so this still has to be a separate chmod). Omitted for a manifest write: that
|
|
590
|
+
// content is JSON we generated, not a copy of something with a mode worth
|
|
591
|
+
// keeping.
|
|
592
|
+
function writeAtomic(dest, buf, srcMode) {
|
|
586
593
|
const tmp = `${dest}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
|
|
587
594
|
const fd = openSync(tmp, 'wx');
|
|
588
595
|
try {
|
|
@@ -590,6 +597,9 @@ function writeAtomic(dest, buf) {
|
|
|
590
597
|
} finally {
|
|
591
598
|
closeSync(fd);
|
|
592
599
|
}
|
|
600
|
+
if (srcMode != null) {
|
|
601
|
+
chmodSync(tmp, withSrcExecBits(statSync(tmp).mode, srcMode));
|
|
602
|
+
}
|
|
593
603
|
try {
|
|
594
604
|
renameSync(tmp, dest);
|
|
595
605
|
} catch (err) {
|
|
@@ -719,7 +729,8 @@ function writeSkill({ rec, skillRoot, manifestPath, manifest, files, guard, wiki
|
|
|
719
729
|
madeDirs.add(cur);
|
|
720
730
|
}
|
|
721
731
|
}
|
|
722
|
-
|
|
732
|
+
const buf = readFileSync(f.srcPath);
|
|
733
|
+
writeAtomic(destPath, buf, statSync(f.srcPath).mode);
|
|
723
734
|
rec.createdFiles.push(destPath);
|
|
724
735
|
}
|
|
725
736
|
}
|
|
@@ -814,22 +825,6 @@ function captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped,
|
|
|
814
825
|
return;
|
|
815
826
|
}
|
|
816
827
|
|
|
817
|
-
// Content round-trips; the executable bit does not (forward-sync writes with the
|
|
818
|
-
// default mode). Say so rather than let a captured `scripts/run.sh` arrive
|
|
819
|
-
// non-executable on the far machine without a word.
|
|
820
|
-
const execFiles = c.files.filter((f) => {
|
|
821
|
-
try {
|
|
822
|
-
return (lstatSync(f.srcPath).mode & 0o111) !== 0;
|
|
823
|
-
} catch {
|
|
824
|
-
return false;
|
|
825
|
-
}
|
|
826
|
-
});
|
|
827
|
-
if (execFiles.length > 0) {
|
|
828
|
-
log(
|
|
829
|
-
`! ${label}: ${execFiles.length} executable file(s) — content is captured, but the executable bit is not carried by sync`,
|
|
830
|
-
);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
828
|
if (!args.dryRun) {
|
|
834
829
|
// The ledger is owned by the caller so a throw MID-write is still recoverable: the
|
|
835
830
|
// paths created before the failure are already recorded in it.
|
|
@@ -1055,7 +1050,7 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
|
1055
1050
|
manifestPrevBuf: existingManifestBuf,
|
|
1056
1051
|
};
|
|
1057
1052
|
writeAtomic(manifestPath, JSON.stringify(plan.manifest, null, 2) + '\n');
|
|
1058
|
-
writeAtomic(filePath, srcBuf);
|
|
1053
|
+
writeAtomic(filePath, srcBuf, statSync(c.srcPath).mode);
|
|
1059
1054
|
created.push(rec);
|
|
1060
1055
|
}
|
|
1061
1056
|
captured.push({ ...c, installFile, requiredKeys, status: 'ready' });
|
package/scripts/crystallize.mjs
CHANGED
|
@@ -109,6 +109,7 @@ import {
|
|
|
109
109
|
scopeVisible,
|
|
110
110
|
readVisibilityScope,
|
|
111
111
|
withFileLock,
|
|
112
|
+
extractTouchedWikiFilesWithTrust,
|
|
112
113
|
} from '../hooks/hypo-shared.mjs';
|
|
113
114
|
import { hashContent, readBaseEntry, advanceBase } from '../hooks/base-store.mjs';
|
|
114
115
|
import { writeProposal } from '../hooks/proposal-store.mjs';
|
|
@@ -225,6 +226,35 @@ function requireProjectDir(args, slug) {
|
|
|
225
226
|
}
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
// When the global gate's own discovery (hot.md pointer table + today
|
|
230
|
+
// close-activity scan, both in hypo-shared.mjs) comes back with NO project at
|
|
231
|
+
// all, a real apply never hits that dead end: it is handed `payload.project`
|
|
232
|
+
// directly and never infers. --check-session-close has no payload, so its one
|
|
233
|
+
// remaining authoritative signal is the same session's own transcript — which
|
|
234
|
+
// project's files did THIS session actually touch. Reusing the exact
|
|
235
|
+
// evidence-resolution helper the widened-lint-scope path already trusts here
|
|
236
|
+
// keeps this a single inference vocabulary (touched wiki files), not a second
|
|
237
|
+
// one: the difference is only which project-shaped question gets asked of it.
|
|
238
|
+
// Never guessed: a transcript touching zero or more than one project's files
|
|
239
|
+
// leaves the check exactly as unresolved as it was before this fallback.
|
|
240
|
+
function deriveTouchedProject(hypoDir, transcriptPath) {
|
|
241
|
+
if (!transcriptPath) return null;
|
|
242
|
+
const { files, trusted } = extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir);
|
|
243
|
+
// `trusted:false` means the walk itself may be incomplete (a missing/unreadable
|
|
244
|
+
// transcript, or a line that failed to parse). A truncated line could have named
|
|
245
|
+
// a SECOND project the walk never saw, so treating this Set as "the whole
|
|
246
|
+
// truth" would resolve a single-project reading off a scope that is only
|
|
247
|
+
// single-project because part of it is missing, exactly the ambiguity this
|
|
248
|
+
// fallback exists to refuse rather than guess through.
|
|
249
|
+
if (!trusted) return null;
|
|
250
|
+
const slugs = new Set();
|
|
251
|
+
for (const f of files) {
|
|
252
|
+
const m = /^projects\/([^/]+)\//.exec(f);
|
|
253
|
+
if (m && existsSync(join(hypoDir, 'projects', m[1]))) slugs.add(m[1]);
|
|
254
|
+
}
|
|
255
|
+
return slugs.size === 1 ? [...slugs][0] : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
228
258
|
// ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
|
|
229
259
|
// Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
|
|
230
260
|
// flow can self-verify before /compact triggers PreCompact.
|
|
@@ -261,7 +291,7 @@ function runSessionCloseCheck(args) {
|
|
|
261
291
|
args.transcriptPath ||
|
|
262
292
|
(args.sessionId ? resolveTranscriptBySessionId(args.sessionId) : null) ||
|
|
263
293
|
null;
|
|
264
|
-
|
|
294
|
+
let status = precompactGateStatus(args.hypoDir, {
|
|
265
295
|
...(args.project
|
|
266
296
|
? { projectOverride: args.project }
|
|
267
297
|
: checkTranscript
|
|
@@ -276,7 +306,30 @@ function runSessionCloseCheck(args) {
|
|
|
276
306
|
// enforcement lives in the PreCompact/Stop hooks, which carry payload.cwd).
|
|
277
307
|
...(args.sessionCwd && !args.project ? { sessionCwd: args.sessionCwd } : {}),
|
|
278
308
|
});
|
|
309
|
+
|
|
310
|
+
// check/apply divergence (2026-08-25 QA): a real apply never hits discovery
|
|
311
|
+
// dead-ends because payload.project is required input, not an inference. This
|
|
312
|
+
// check has no payload, so when discovery finds NO project at all (not even
|
|
313
|
+
// the recency fallback), it retries scoped to whatever single project this
|
|
314
|
+
// session's own transcript shows it touching. This is a diagnostic estimate,
|
|
315
|
+
// not a preview of what a real apply will do: a payload's `project` field is
|
|
316
|
+
// whatever the caller puts there and can legitimately name a project the
|
|
317
|
+
// transcript never mentions. Only fires on a fully unresolved global result,
|
|
318
|
+
// and only on a TRUSTED single-project reading (see deriveTouchedProject): an
|
|
319
|
+
// already-successful discovery, an ambiguous/empty transcript, or one the walk
|
|
320
|
+
// could not fully read is left untouched rather than guessed at.
|
|
321
|
+
let inferredProject = null;
|
|
322
|
+
if (!args.project && !status.close.project) {
|
|
323
|
+
inferredProject = deriveTouchedProject(args.hypoDir, checkTranscript);
|
|
324
|
+
if (inferredProject) {
|
|
325
|
+
status = precompactGateStatus(args.hypoDir, {
|
|
326
|
+
projectOverride: inferredProject,
|
|
327
|
+
...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
279
331
|
const close = status.close;
|
|
332
|
+
const scopedProject = args.project || inferredProject;
|
|
280
333
|
|
|
281
334
|
// When a --session-id is supplied, report whether THIS session's
|
|
282
335
|
// per-session marker (the Stop-chain completion signal) exists. This is a
|
|
@@ -301,8 +354,8 @@ function runSessionCloseCheck(args) {
|
|
|
301
354
|
// log-only marker governs the session, the gate runs in log-only mode and the
|
|
302
355
|
// --project override is IGNORED — surface that rather than implying X was
|
|
303
356
|
// checked (it was not).
|
|
304
|
-
const logOnlyWon =
|
|
305
|
-
const scope =
|
|
357
|
+
const logOnlyWon = scopedProject != null && markerObj?.scope === 'log-only';
|
|
358
|
+
const scope = scopedProject ? (logOnlyWon ? 'log-only' : 'project') : 'global';
|
|
306
359
|
|
|
307
360
|
if (args.json) {
|
|
308
361
|
console.log(
|
|
@@ -325,9 +378,13 @@ function runSessionCloseCheck(args) {
|
|
|
325
378
|
skipped: status.skipped,
|
|
326
379
|
// scope is additive; `global` keeps prior semantics for existing readers
|
|
327
380
|
scope,
|
|
328
|
-
...(
|
|
381
|
+
...(scopedProject
|
|
329
382
|
? {
|
|
330
|
-
scoped_project:
|
|
383
|
+
scoped_project: scopedProject,
|
|
384
|
+
// Distinguishes a user-typed --project from this check picking one
|
|
385
|
+
// for itself off the transcript — a reader should not mistake the
|
|
386
|
+
// latter for an explicit ask (see deriveTouchedProject above).
|
|
387
|
+
...(inferredProject ? { project_inferred_from_transcript: true } : {}),
|
|
331
388
|
...(logOnlyWon ? { project_override_ignored: true } : {}),
|
|
332
389
|
}
|
|
333
390
|
: {}),
|
|
@@ -340,13 +397,20 @@ function runSessionCloseCheck(args) {
|
|
|
340
397
|
process.exit(status.ok ? 0 : 1);
|
|
341
398
|
}
|
|
342
399
|
|
|
400
|
+
// Label the scoped project by how it was chosen — an explicit --project reads
|
|
401
|
+
// as a flag the caller typed; an inferred one reads as this check's own guess
|
|
402
|
+
// off the transcript, so a reader does not credit the caller with an ask
|
|
403
|
+
// nobody made.
|
|
404
|
+
const scopedProjectLabel = args.project
|
|
405
|
+
? `--project=${args.project}`
|
|
406
|
+
: `project=${scopedProject} (inferred from the session transcript, no --project given)`;
|
|
343
407
|
if (logOnlyWon) {
|
|
344
408
|
console.log(
|
|
345
|
-
`Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and
|
|
409
|
+
`Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and ${scopedProjectLabel} was IGNORED (no project was checked).\n`,
|
|
346
410
|
);
|
|
347
411
|
} else if (scope === 'project') {
|
|
348
412
|
console.log(
|
|
349
|
-
`Note:
|
|
413
|
+
`Note: ${scopedProjectLabel} — this is a PROJECT-SCOPED diagnostic, not the global /compact gate. A green result means only ${scopedProject} is close-complete; another project can still block /compact.\n`,
|
|
350
414
|
);
|
|
351
415
|
}
|
|
352
416
|
|
|
@@ -398,8 +462,8 @@ function runSessionCloseCheck(args) {
|
|
|
398
462
|
// Do NOT claim global compact-readiness (the whole point of the narrow).
|
|
399
463
|
console.log(
|
|
400
464
|
status.ok
|
|
401
|
-
? `✓ ${
|
|
402
|
-
: `✗ ${
|
|
465
|
+
? `✓ ${scopedProject} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
|
|
466
|
+
: `✗ ${scopedProject} is not close-complete — resolve the ✗ items above.`,
|
|
403
467
|
);
|
|
404
468
|
} else {
|
|
405
469
|
console.log(
|
|
@@ -1043,7 +1107,10 @@ function applySessionClose(args) {
|
|
|
1043
1107
|
stage: 'no-user-close-signal',
|
|
1044
1108
|
reason: closeAuth.reason,
|
|
1045
1109
|
applied: [],
|
|
1046
|
-
|
|
1110
|
+
// `null`, not `false`: this refusal fires before the commit step is ever
|
|
1111
|
+
// reached (see the general result's own `committed` contract below).
|
|
1112
|
+
// `false` is reserved for a commit that actually ran and failed.
|
|
1113
|
+
committed: null,
|
|
1047
1114
|
error: closeAuth.error,
|
|
1048
1115
|
};
|
|
1049
1116
|
console.log(
|
|
@@ -1103,7 +1170,9 @@ function applySessionClose(args) {
|
|
|
1103
1170
|
stage: 'session-id-mismatch',
|
|
1104
1171
|
error: msg,
|
|
1105
1172
|
applied: [],
|
|
1106
|
-
|
|
1173
|
+
// `null`, not `false` — refused before the commit step, same contract as
|
|
1174
|
+
// the `no-user-close-signal` refusal above.
|
|
1175
|
+
committed: null,
|
|
1107
1176
|
};
|
|
1108
1177
|
console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
|
|
1109
1178
|
process.exit(1);
|
|
@@ -1734,6 +1803,10 @@ function applySessionClose(args) {
|
|
|
1734
1803
|
// but silently.
|
|
1735
1804
|
let markerWritten = false;
|
|
1736
1805
|
let markerSkipReason = null;
|
|
1806
|
+
// Hoisted so the result JSON below can report it: `null` when this apply never
|
|
1807
|
+
// reached the commit step at all (ok:false before the writes were even
|
|
1808
|
+
// verified), distinct from a commit that ran and reported `committed:false`.
|
|
1809
|
+
let commitOutcome = null;
|
|
1737
1810
|
if (ok && args.sessionId) {
|
|
1738
1811
|
// IO stays lazy so this preserves the exact side-effect order (codex design
|
|
1739
1812
|
// review): commit first (the only mutation), then resolve the
|
|
@@ -1749,7 +1822,6 @@ function applySessionClose(args) {
|
|
|
1749
1822
|
// apply's stage+commit. A lock-timeout is treated exactly like any other
|
|
1750
1823
|
// commit failure below (skip the marker, surface the reason) rather than
|
|
1751
1824
|
// crashing the apply.
|
|
1752
|
-
let commitOutcome;
|
|
1753
1825
|
try {
|
|
1754
1826
|
commitOutcome = withFileLock(vaultCommitLockTarget(args.hypoDir), () =>
|
|
1755
1827
|
commitWikiChanges(args.hypoDir, appliedPaths),
|
|
@@ -1843,6 +1915,19 @@ function applySessionClose(args) {
|
|
|
1843
1915
|
date,
|
|
1844
1916
|
applied,
|
|
1845
1917
|
skipped,
|
|
1918
|
+
// Was the general-shape sibling of the two early-refusal `committed:null`
|
|
1919
|
+
// fields (no-user-close-signal / session-id-mismatch), which this path never
|
|
1920
|
+
// carried before: a reader of `applied:[]` on a no-op re-run had no
|
|
1921
|
+
// `committed` value to check against and no way to tell it apart from a run
|
|
1922
|
+
// that never reached the commit step. `null` here means exactly that: `ok`
|
|
1923
|
+
// came back false before the commit ever ran (see `stage` for which check
|
|
1924
|
+
// failed: post-apply-verification, post-apply-lint, or proposal-pending). It
|
|
1925
|
+
// does NOT mean nothing was written — an overwrite/append can already be on
|
|
1926
|
+
// disk (see `applied` / `appliedUncommitted`) while `committed` stays `null`.
|
|
1927
|
+
// `true` covers both an actual commit and the legitimate "nothing to stage"
|
|
1928
|
+
// no-op (commitWikiChanges' own contract, see hooks/hypo-shared.mjs); `false`
|
|
1929
|
+
// is a real commit failure, surfaced together with markerSkipReason below.
|
|
1930
|
+
committed: commitOutcome ? commitOutcome.committed : null,
|
|
1846
1931
|
// Targets withheld: an overwrite drifted from this session's observed base, or
|
|
1847
1932
|
// an append could not take the file lock in time (`kind: 'append'`). Two
|
|
1848
1933
|
// channels resolve these, and `proposals` vs `conflicts[].kind` are the sole
|
package/scripts/doctor.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'url';
|
|
|
20
20
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
21
21
|
import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
|
|
22
22
|
import { readRenameMarker, renameMarkerPath, RENAME_MARKER_REL } from './lib/rename-marker.mjs';
|
|
23
|
-
import { resolveGitHooksDir } from './lib/git-hooks-dir.mjs';
|
|
23
|
+
import { resolveGitHooksDir, WIKI_PRE_COMMIT_MARKER_START } from './lib/git-hooks-dir.mjs';
|
|
24
24
|
import { parseFrontmatter } from './lib/frontmatter.mjs';
|
|
25
25
|
import {
|
|
26
26
|
readSyncState,
|
|
@@ -506,7 +506,7 @@ function checkGit(hypoDir) {
|
|
|
506
506
|
? 'Not installed — run /hypo:init to install .hypoignore guard'
|
|
507
507
|
: 'Not installed, and /hypo:init will not install into this path — point core.hooksPath back inside the repository, or install the guard yourself',
|
|
508
508
|
);
|
|
509
|
-
} else if (content.includes(
|
|
509
|
+
} else if (content.includes(WIKI_PRE_COMMIT_MARKER_START)) {
|
|
510
510
|
pass(label, 'Hypomnema .hypoignore guard installed');
|
|
511
511
|
} else {
|
|
512
512
|
warn(label, 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore');
|
package/scripts/init.mjs
CHANGED
|
@@ -38,7 +38,14 @@ import { execSync, spawnSync } from 'child_process';
|
|
|
38
38
|
import { fileURLToPath } from 'url';
|
|
39
39
|
import { createHash } from 'crypto';
|
|
40
40
|
import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
hooksDirForInstall,
|
|
43
|
+
unsafeHookTargetReason,
|
|
44
|
+
WIKI_PRE_COMMIT_MARKER_START,
|
|
45
|
+
WIKI_PRE_COMMIT_MARKER_END,
|
|
46
|
+
SHELL_MARKER_START,
|
|
47
|
+
SHELL_MARKER_END,
|
|
48
|
+
} from './lib/git-hooks-dir.mjs';
|
|
42
49
|
import { readCoreHooksConfig } from './lib/core-hooks.mjs';
|
|
43
50
|
import {
|
|
44
51
|
readPkgJson as readPkgJsonSafe,
|
|
@@ -748,9 +755,6 @@ function installPkgGitHook(dryRun) {
|
|
|
748
755
|
|
|
749
756
|
// ── wiki pre-commit hook ─────────────────────────────────────────────────────
|
|
750
757
|
|
|
751
|
-
const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
|
|
752
|
-
const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
|
|
753
|
-
|
|
754
758
|
// Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
|
|
755
759
|
function shellSingleQuote(p) {
|
|
756
760
|
return `'${p.replace(/'/g, "'\\''")}'`;
|
|
@@ -859,9 +863,6 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
|
|
|
859
863
|
|
|
860
864
|
// ── shell function setup ─────────────────────────────────────────────────────
|
|
861
865
|
|
|
862
|
-
const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
|
|
863
|
-
const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
|
|
864
|
-
|
|
865
866
|
function shellFunctionBlock() {
|
|
866
867
|
return `${SHELL_MARKER_START}
|
|
867
868
|
function claude() {
|
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
unlinkSync,
|
|
29
29
|
mkdirSync,
|
|
30
30
|
lstatSync,
|
|
31
|
+
statSync,
|
|
32
|
+
chmodSync,
|
|
31
33
|
rmdirSync,
|
|
32
34
|
} from 'fs';
|
|
33
35
|
import { join, dirname, relative, resolve, posix, sep } from 'path';
|
|
@@ -894,9 +896,26 @@ export function readExtensionPkgStateNoMutate(pkgPath, target) {
|
|
|
894
896
|
|
|
895
897
|
// ── sync orchestration ─────────────────────────────────────────────────────────
|
|
896
898
|
|
|
897
|
-
|
|
899
|
+
/** Carry only src's execute bits (owner/group/other) onto a mode value; every
|
|
900
|
+
* other permission bit (dest's own read/write, however umask shaped it) is
|
|
901
|
+
* left alone. This is the one place forward-sync decides "should this file be
|
|
902
|
+
* executable", so every writer of dest routes through it. Exported so
|
|
903
|
+
* capture.mjs's own atomic writer (a captured file's FIRST write into the wiki)
|
|
904
|
+
* applies the identical rule; the wiki copy must not lose the bit before
|
|
905
|
+
* forward-sync ever gets a chance to carry it further. */
|
|
906
|
+
export function withSrcExecBits(destMode, srcMode) {
|
|
907
|
+
return (destMode & ~0o111) | (srcMode & 0o111);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function writeFreshAtomic(dest, content, srcMode) {
|
|
898
911
|
const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
|
|
899
912
|
writeFileSync(tmp, content);
|
|
913
|
+
if (srcMode != null) {
|
|
914
|
+
// writeFileSync has no mode option that survives umask, so the execute bit
|
|
915
|
+
// has to be applied explicitly, and before the rename: otherwise dest is
|
|
916
|
+
// briefly visible with the wrong mode to anything racing this write.
|
|
917
|
+
chmodSync(tmp, withSrcExecBits(statSync(tmp).mode, srcMode));
|
|
918
|
+
}
|
|
900
919
|
try {
|
|
901
920
|
renameSync(tmp, dest);
|
|
902
921
|
} catch (err) {
|
|
@@ -914,13 +933,19 @@ function writeFreshAtomic(dest, content) {
|
|
|
914
933
|
* overwrites user-modified / unowned files; without it those are left untouched and
|
|
915
934
|
* surface as drift/conflict. A symlink/non-regular dest is never followed even under
|
|
916
935
|
* force (the isRegularFile guard precedes the force branch).
|
|
936
|
+
*
|
|
937
|
+
* The executable bit is not tracked anywhere (no mode column in the SHA map's
|
|
938
|
+
* ownership model), so src's mode is the only source of truth for it and gets
|
|
939
|
+
* carried onto dest on every branch that (re)writes it, including the
|
|
940
|
+
* content-identical `up-to-date` branch below.
|
|
917
941
|
*/
|
|
918
942
|
function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
|
|
919
943
|
const srcContent = readFileSync(srcPath);
|
|
920
944
|
const srcSHA = sha256(srcContent);
|
|
945
|
+
const srcMode = statSync(srcPath).mode;
|
|
921
946
|
|
|
922
947
|
if (!existsSync(destPath)) {
|
|
923
|
-
if (apply) writeFreshAtomic(destPath, srcContent);
|
|
948
|
+
if (apply) writeFreshAtomic(destPath, srcContent, srcMode);
|
|
924
949
|
return { action: 'create', sha: srcSHA };
|
|
925
950
|
}
|
|
926
951
|
if (!isRegularFile(destPath)) {
|
|
@@ -933,6 +958,20 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
|
|
|
933
958
|
}
|
|
934
959
|
const onDiskSHA = sha256(onDisk);
|
|
935
960
|
if (onDiskSHA === srcSHA) {
|
|
961
|
+
// Content already matches, but the exec bit can still be stale: this used to
|
|
962
|
+
// be a pure no-op, which is exactly why a mismatched bit here never healed.
|
|
963
|
+
// Reported as 'update' (not a new action) so every existing "N to sync" /
|
|
964
|
+
// "N synced" count and log line already keyed on create/update/force-update
|
|
965
|
+
// picks it up for free.
|
|
966
|
+
// ponytail: this also overwrites an exec bit a user deliberately flipped on a
|
|
967
|
+
// file whose content happens to still match src (chmod -x'd a script they
|
|
968
|
+
// like read-only). Upgrade path: record mode next to sha in the pkg-json map
|
|
969
|
+
// so a user's own bit can be told apart from our default and left alone.
|
|
970
|
+
const destMode = statSync(destPath).mode;
|
|
971
|
+
if ((destMode & 0o111) !== (srcMode & 0o111)) {
|
|
972
|
+
if (apply) chmodSync(destPath, withSrcExecBits(destMode, srcMode));
|
|
973
|
+
return { action: 'update', sha: srcSHA };
|
|
974
|
+
}
|
|
936
975
|
return { action: 'up-to-date', sha: srcSHA };
|
|
937
976
|
}
|
|
938
977
|
if (recordedSHA && onDiskSHA === recordedSHA) {
|
|
@@ -942,7 +981,7 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
|
|
|
942
981
|
if (!verify || sha256(verify) !== recordedSHA) {
|
|
943
982
|
return { action: 'skip-changed', sha: recordedSHA };
|
|
944
983
|
}
|
|
945
|
-
writeFreshAtomic(destPath, srcContent);
|
|
984
|
+
writeFreshAtomic(destPath, srcContent, srcMode);
|
|
946
985
|
}
|
|
947
986
|
return { action: 'update', sha: srcSHA };
|
|
948
987
|
}
|
|
@@ -950,7 +989,7 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
|
|
|
950
989
|
if (force) {
|
|
951
990
|
if (apply) {
|
|
952
991
|
writeFreshAtomic(`${destPath}.bak`, onDisk);
|
|
953
|
-
writeFreshAtomic(destPath, srcContent);
|
|
992
|
+
writeFreshAtomic(destPath, srcContent, srcMode);
|
|
954
993
|
}
|
|
955
994
|
return { action: 'force-update', sha: srcSHA };
|
|
956
995
|
}
|
|
@@ -38,6 +38,16 @@ import { execFileSync } from 'child_process';
|
|
|
38
38
|
import { existsSync, lstatSync, realpathSync, statSync } from 'fs';
|
|
39
39
|
import { basename, dirname, isAbsolute, join, resolve, sep } from 'path';
|
|
40
40
|
|
|
41
|
+
// ── shared install/uninstall markers ────────────────────────────────────────
|
|
42
|
+
// init.mjs writes these when it installs the wiki's git pre-commit hook and
|
|
43
|
+
// the shell rc block; uninstall.mjs reads them back to remove exactly what
|
|
44
|
+
// init created. Defined once here, imported by both, so the two scripts can
|
|
45
|
+
// never drift into recognizing different markers.
|
|
46
|
+
export const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
|
|
47
|
+
export const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
|
|
48
|
+
export const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
|
|
49
|
+
export const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
|
|
50
|
+
|
|
41
51
|
// Fallback scrub list for git versions without `rev-parse --local-env-vars`.
|
|
42
52
|
// Mirrors scripts/install-git-hooks.mjs, which established this trust model.
|
|
43
53
|
const STATIC_LOCAL_ENV_VARS = [
|
package/scripts/lint.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
20
20
|
import { join, extname, basename } from 'path';
|
|
21
21
|
import { resolveHypoRootInfo, checkVaultOrExit, expandHome } from './lib/hypo-root.mjs';
|
|
22
|
-
import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
|
|
22
|
+
import { SESSION_STATE_NEXT_HEADINGS, closeFileTargetsGlobal } from '../hooks/hypo-shared.mjs';
|
|
23
23
|
import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
|
|
24
24
|
import {
|
|
25
25
|
parseSchemaVocab,
|
|
@@ -554,6 +554,50 @@ const validTypes = new Set([...VALID_TYPES, ...parseSchemaTypes(args.hypoDir)]);
|
|
|
554
554
|
|
|
555
555
|
for (const page of pages) lintPage(page, slugMap, tagVocab, pageDirs, validTypes);
|
|
556
556
|
|
|
557
|
+
// W4 (broken-wikilink only, NOT the full lintPage) for close's root-level
|
|
558
|
+
// write targets that fall outside pages/projects/journal: hot.md and log.md
|
|
559
|
+
// today. closeFileTargetsGlobal is the one list of what close writes
|
|
560
|
+
// (hooks/hypo-shared.mjs), reused here instead of re-derived, so a future
|
|
561
|
+
// close target is covered automatically. Only the entries NOT already under
|
|
562
|
+
// a scanDir are new; closeFileTargetsGlobal's projects/<slug>/* entries are
|
|
563
|
+
// already linted in full above.
|
|
564
|
+
//
|
|
565
|
+
// Why link-only and not the full lintPage: measured, not assumed. On the
|
|
566
|
+
// packaged templates/ (hot.md type:reference, log.md type:log, both declared
|
|
567
|
+
// in templates/SCHEMA.md's taxonomy), full lintPage on both files comes back
|
|
568
|
+
// completely clean, so "these types trip W2" is not the reason to hold back.
|
|
569
|
+
// The real reason is that live vaults do not all match the template. A vault
|
|
570
|
+
// whose SCHEMA.md predates the `log` type row (or has none) gets a fresh
|
|
571
|
+
// "Unknown type: log" W2 the moment log.md is run through lintPage, and a
|
|
572
|
+
// vault whose root log.md predates the frontmatter convention entirely (no
|
|
573
|
+
// leading `---` block at all, confirmed against a real maintainer vault)
|
|
574
|
+
// gets a fresh "No frontmatter found" W1. Both W1 and W2 are in
|
|
575
|
+
// STRICT_PROMOTE_IDS, so under --strict a vault that lint has always passed
|
|
576
|
+
// would start failing on a file whose content this fix never touched. Full
|
|
577
|
+
// lintPage stays reserved for pages/projects/journal, where every file was
|
|
578
|
+
// already being linted before this change and there is no such newly-exposed
|
|
579
|
+
// vault. W4 itself stays warn-only outside --strict, and postApply
|
|
580
|
+
// (crystallize.mjs) gates only on errors, so this addition can add a new
|
|
581
|
+
// warning to a close's lint output but can never fail one.
|
|
582
|
+
const closeRootTargets = [...closeFileTargetsGlobal(args.hypoDir)].filter(
|
|
583
|
+
(f) => !f.startsWith('pages/') && !f.startsWith('projects/') && !f.startsWith('journal/'),
|
|
584
|
+
);
|
|
585
|
+
for (const rel of closeRootTargets) {
|
|
586
|
+
const full = join(args.hypoDir, rel);
|
|
587
|
+
if (!existsSync(full) || isScanIgnored(full, args.hypoDir, ignorePatterns)) continue;
|
|
588
|
+
let content;
|
|
589
|
+
try {
|
|
590
|
+
content = readFileSync(full, 'utf-8');
|
|
591
|
+
} catch {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
for (const link of extractWikilinks(content)) {
|
|
595
|
+
if (!slugMap.has(link)) {
|
|
596
|
+
issue('warn', rel, `Broken wikilink: [[${link}]]`, null, 'W4');
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
557
601
|
// W8: design-history.md stale relative to session-log.md. Emitted once per
|
|
558
602
|
// project (not per page) — runs outside the page loop. POSIX-separated path
|
|
559
603
|
// literal (not path.join) so consumers can rely on `file.split('/')` shape
|
package/scripts/uninstall.mjs
CHANGED
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
* --force-commands Remove user-modified slash commands instead of preserving them
|
|
15
15
|
* --force-extensions Remove user-modified extension files (hypo-ext-*) instead of preserving them
|
|
16
16
|
* --hooks-dir=<path> Override Claude hooks directory (default: ~/.claude/hooks)
|
|
17
|
+
* --hypo-dir=<path> Wiki vault to remove the pre-commit hook from (default: auto-resolve,
|
|
18
|
+
* same rules as init/lint/query)
|
|
19
|
+
* --shell-config=<path> Shell rc file to strip the shell block from (default: checks both
|
|
20
|
+
* ~/.zshrc and ~/.bashrc, since init may have run under either shell)
|
|
21
|
+
*
|
|
22
|
+
* The wiki's git pre-commit hook and the shell rc's `claude()` wrapper function are removed
|
|
23
|
+
* only when they still carry the marker init.mjs wrote (WIKI_PRE_COMMIT_MARKER_START /
|
|
24
|
+
* SHELL_MARKER_START, both from ./lib/git-hooks-dir.mjs). A user's own pre-commit hook, a
|
|
25
|
+
* symlinked hook target, and any rc content outside the marker block are never touched.
|
|
17
26
|
*
|
|
18
27
|
* Extensions: hypo-ext-* hard-copies under
|
|
19
28
|
* ~/.claude/{hooks,commands,skills,agents}/ and ~/.codex/{hooks,commands}/ (with
|
|
@@ -23,7 +32,16 @@
|
|
|
23
32
|
* does not follow them). The wiki source (~/hypomnema/extensions/) is preserved.
|
|
24
33
|
*/
|
|
25
34
|
|
|
26
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
existsSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
writeFileSync,
|
|
39
|
+
rmSync,
|
|
40
|
+
rmdirSync,
|
|
41
|
+
readdirSync,
|
|
42
|
+
statSync,
|
|
43
|
+
realpathSync,
|
|
44
|
+
} from 'fs';
|
|
27
45
|
import { join } from 'path';
|
|
28
46
|
import { homedir } from 'os';
|
|
29
47
|
import { fileURLToPath } from 'url';
|
|
@@ -48,6 +66,15 @@ import {
|
|
|
48
66
|
buildHookCommand,
|
|
49
67
|
} from './lib/extensions.mjs';
|
|
50
68
|
import { removeProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
69
|
+
import {
|
|
70
|
+
hooksDirForInstall,
|
|
71
|
+
unsafeHookTargetReason,
|
|
72
|
+
WIKI_PRE_COMMIT_MARKER_START,
|
|
73
|
+
WIKI_PRE_COMMIT_MARKER_END,
|
|
74
|
+
SHELL_MARKER_START,
|
|
75
|
+
SHELL_MARKER_END,
|
|
76
|
+
} from './lib/git-hooks-dir.mjs';
|
|
77
|
+
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
51
78
|
|
|
52
79
|
const HOME = homedir();
|
|
53
80
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -428,6 +455,182 @@ function stripExtensionSettings(settingsPath, hooksDir, apply, ownedCommands = n
|
|
|
428
455
|
return { stripped };
|
|
429
456
|
}
|
|
430
457
|
|
|
458
|
+
// ── marker-span validation (shared by both removal paths below) ────────────
|
|
459
|
+
|
|
460
|
+
// Two independent indexOf() calls cannot tell "well-formed" apart from
|
|
461
|
+
// "duplicated" or "swapped": if a file happens to hold two full copies of the
|
|
462
|
+
// block, indexOf finds only the first END, so slicing [firstStart, firstEnd]
|
|
463
|
+
// leaves the second copy's install behind with no report of it. If END
|
|
464
|
+
// precedes START (a hand-edited or corrupted file), slicing [start, end) with
|
|
465
|
+
// start > end does not error, it silently duplicates whatever sits between
|
|
466
|
+
// them into the "removed" span. Neither this script nor the file it is
|
|
467
|
+
// touching has a way back from either outcome, so a span is only trusted when
|
|
468
|
+
// both markers appear EXACTLY once and START comes before END.
|
|
469
|
+
function countOccurrences(content, needle) {
|
|
470
|
+
let count = 0;
|
|
471
|
+
let idx = 0;
|
|
472
|
+
while ((idx = content.indexOf(needle, idx)) !== -1) {
|
|
473
|
+
count++;
|
|
474
|
+
idx += needle.length;
|
|
475
|
+
}
|
|
476
|
+
return count;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function findMarkerSpan(content, startMarker, endMarker) {
|
|
480
|
+
const startCount = countOccurrences(content, startMarker);
|
|
481
|
+
const endCount = countOccurrences(content, endMarker);
|
|
482
|
+
if (startCount !== 1 || endCount !== 1) {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
reason: `expected exactly one start and one end marker, found ${startCount} start / ${endCount} end`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const startIdx = content.indexOf(startMarker);
|
|
489
|
+
const endIdx = content.indexOf(endMarker);
|
|
490
|
+
if (!(startIdx < endIdx)) {
|
|
491
|
+
return { ok: false, reason: 'the end marker appears before the start marker' };
|
|
492
|
+
}
|
|
493
|
+
return { ok: true, startIdx, endIdx };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── wiki pre-commit hook removal ────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
// Mirrors init.mjs's own resolution (hooksDirForInstall) as the PRIMARY
|
|
499
|
+
// candidate, so this finds the hook wherever init would put it today,
|
|
500
|
+
// including under a core.hooksPath override. A second, best-effort candidate
|
|
501
|
+
// (the vault's plain .git/hooks/pre-commit) is checked too: if core.hooksPath
|
|
502
|
+
// changed after install, the current resolution no longer points at the file
|
|
503
|
+
// init actually wrote, and that file would otherwise never be found. Both
|
|
504
|
+
// candidates go through the same marker/ownership gate below, so widening the
|
|
505
|
+
// search costs nothing in safety, only in how many places we bother to look.
|
|
506
|
+
// A hook that still carries our marker is removed; a user's own pre-commit (no
|
|
507
|
+
// marker), a symlinked/non-regular target, and a hook whose marker is
|
|
508
|
+
// duplicated, swapped, or missing its shebang are all left standing.
|
|
509
|
+
// `pre-commit.bak` (init's --force-commands backup of the user's original
|
|
510
|
+
// hook) is never removed here, only reported so the user knows it exists.
|
|
511
|
+
function removeWikiPreCommitHook(hypoDir, apply) {
|
|
512
|
+
const result = { removed: [], skipped: [], bakPresent: [] };
|
|
513
|
+
|
|
514
|
+
if (!hypoDir || !existsSync(join(hypoDir, 'hypo-config.md'))) {
|
|
515
|
+
result.skipped.push(`no Hypomnema vault found${hypoDir ? ` at ${hypoDir}` : ''} — nothing to remove`);
|
|
516
|
+
return result;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const candidateDirs = [];
|
|
520
|
+
const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
|
|
521
|
+
if (hooksDir) candidateDirs.push(hooksDir);
|
|
522
|
+
else if (skip) result.skipped.push(skip);
|
|
523
|
+
|
|
524
|
+
// Best-effort fallback: only when `.git` is a real directory here (a plain
|
|
525
|
+
// checkout, not a linked worktree's gitdir-pointer FILE, which this join
|
|
526
|
+
// would misread entirely — resolveGitHooksDir already handles that layout
|
|
527
|
+
// correctly via the primary candidate above).
|
|
528
|
+
const legacyGitDir = join(hypoDir, '.git');
|
|
529
|
+
if (existsSync(legacyGitDir) && statSync(legacyGitDir).isDirectory()) {
|
|
530
|
+
candidateDirs.push(join(legacyGitDir, 'hooks'));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const seen = new Set();
|
|
534
|
+
for (const dir of candidateDirs) {
|
|
535
|
+
const hookPath = join(dir, 'pre-commit');
|
|
536
|
+
// Dedupe by the resolved real path, not the string: the primary candidate
|
|
537
|
+
// is realpath'd internally (resolveGitHooksDir's canonicalize) while the
|
|
538
|
+
// legacy join above is not, so the same physical file can arrive under two
|
|
539
|
+
// different-looking paths. Falling back to the raw path when the file does
|
|
540
|
+
// not exist is fine — two distinct absent candidates never collide.
|
|
541
|
+
let key = hookPath;
|
|
542
|
+
try {
|
|
543
|
+
key = realpathSync(hookPath);
|
|
544
|
+
} catch {
|
|
545
|
+
// leave key as hookPath
|
|
546
|
+
}
|
|
547
|
+
if (seen.has(key)) continue;
|
|
548
|
+
seen.add(key);
|
|
549
|
+
|
|
550
|
+
const bakPath = `${hookPath}.bak`;
|
|
551
|
+
if (existsSync(bakPath)) result.bakPresent.push(bakPath);
|
|
552
|
+
|
|
553
|
+
const unsafe = unsafeHookTargetReason(hookPath);
|
|
554
|
+
if (unsafe) {
|
|
555
|
+
result.skipped.push(`${hookPath} (${unsafe})`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (!existsSync(hookPath)) continue; // already absent, nothing to report
|
|
559
|
+
|
|
560
|
+
let content;
|
|
561
|
+
try {
|
|
562
|
+
content = readFileSync(hookPath, 'utf-8');
|
|
563
|
+
} catch (e) {
|
|
564
|
+
result.skipped.push(`${hookPath} (cannot read: ${e.code || e.message})`);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (!content.includes(WIKI_PRE_COMMIT_MARKER_START) || !content.includes(WIKI_PRE_COMMIT_MARKER_END)) {
|
|
568
|
+
result.skipped.push(`${hookPath} (not managed by Hypomnema — preserving)`);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const span = findMarkerSpan(content, WIKI_PRE_COMMIT_MARKER_START, WIKI_PRE_COMMIT_MARKER_END);
|
|
573
|
+
if (!span.ok) {
|
|
574
|
+
result.skipped.push(`${hookPath} (${span.reason} — preserving)`);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// init writes the marker as the ENTIRE hook body: a bare "#!/bin/sh\n"
|
|
579
|
+
// right before WIKI_PRE_COMMIT_MARKER_START, nothing after
|
|
580
|
+
// WIKI_PRE_COMMIT_MARKER_END. A file with no shebang there was never
|
|
581
|
+
// written by init even if it happens to carry a well-formed marker span
|
|
582
|
+
// (hand-authored or copy-pasted) — deleting it on marker presence alone
|
|
583
|
+
// would remove code we do not own. A user who appended their own check
|
|
584
|
+
// after the block turned this into a file we only partly own. init's own
|
|
585
|
+
// --force-commands path handles the analogous case by OVERWRITING with
|
|
586
|
+
// equivalent content (a safe merge); uninstall has no such repair, only
|
|
587
|
+
// rmSync, so treating "extra content" the same as "fully ours" would
|
|
588
|
+
// silently delete the user's check with no way back.
|
|
589
|
+
const before = content.slice(0, span.startIdx);
|
|
590
|
+
const after = content.slice(span.endIdx + WIKI_PRE_COMMIT_MARKER_END.length);
|
|
591
|
+
if (!/^#![^\n]*\n$/.test(before)) {
|
|
592
|
+
result.skipped.push(`${hookPath} (hook carries content before the Hypomnema block — preserving)`);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (after.trim() !== '') {
|
|
596
|
+
result.skipped.push(`${hookPath} (hook carries content after the Hypomnema block — preserving)`);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (apply) rmSync(hookPath);
|
|
601
|
+
result.removed.push(hookPath);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return result;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ── shell function block removal ────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
// init picks ONE rc file at install time from $SHELL (or --shell-config), but
|
|
610
|
+
// $SHELL by uninstall time may point somewhere else, or init may have run in
|
|
611
|
+
// a different shell session altogether — so both common rc files are checked
|
|
612
|
+
// by default rather than guessing one. Only the marker span itself is
|
|
613
|
+
// stripped; every other byte in the file, including surrounding blank lines,
|
|
614
|
+
// is left exactly as it was. A malformed span (duplicated or swapped markers,
|
|
615
|
+
// see findMarkerSpan above) leaves the file completely untouched: an rc file
|
|
616
|
+
// is the user's own, and this script has no backup to restore it from.
|
|
617
|
+
function removeShellFunctionBlock(shellConfigPath, apply) {
|
|
618
|
+
if (!existsSync(shellConfigPath)) return null;
|
|
619
|
+
const content = readFileSync(shellConfigPath, 'utf-8');
|
|
620
|
+
if (!content.includes(SHELL_MARKER_START) && !content.includes(SHELL_MARKER_END)) {
|
|
621
|
+
return null; // block not present here at all
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const span = findMarkerSpan(content, SHELL_MARKER_START, SHELL_MARKER_END);
|
|
625
|
+
if (!span.ok) {
|
|
626
|
+
return { path: shellConfigPath, removed: false, skipped: span.reason };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const updated = content.slice(0, span.startIdx) + content.slice(span.endIdx + SHELL_MARKER_END.length);
|
|
630
|
+
if (apply) writeFileSync(shellConfigPath, updated);
|
|
631
|
+
return { path: shellConfigPath, removed: true, skipped: null };
|
|
632
|
+
}
|
|
633
|
+
|
|
431
634
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
432
635
|
|
|
433
636
|
function parseArgs(argv) {
|
|
@@ -437,6 +640,8 @@ function parseArgs(argv) {
|
|
|
437
640
|
hooksDir: null,
|
|
438
641
|
forceCommands: false,
|
|
439
642
|
forceExtensions: false,
|
|
643
|
+
hypoDir: null,
|
|
644
|
+
shellConfig: null,
|
|
440
645
|
};
|
|
441
646
|
for (const arg of argv.slice(2)) {
|
|
442
647
|
if (arg === '--apply') args.apply = true;
|
|
@@ -444,6 +649,16 @@ function parseArgs(argv) {
|
|
|
444
649
|
else if (arg === '--force-commands') args.forceCommands = true;
|
|
445
650
|
else if (arg === '--force-extensions') args.forceExtensions = true;
|
|
446
651
|
else if (arg.startsWith('--hooks-dir=')) args.hooksDir = arg.slice(12);
|
|
652
|
+
// expandHome mirrors init.mjs's own --hypo-dir/--shell-config parsing
|
|
653
|
+
// (init.mjs's parseArgs) exactly, reusing the same function from
|
|
654
|
+
// ./lib/hypo-root.mjs rather than re-deriving it. Uninstall must undo
|
|
655
|
+
// whatever path init actually wrote to disk, and init resolves a leading
|
|
656
|
+
// "~/" itself (the shell never does, since the value arrives already
|
|
657
|
+
// quoted inside "--flag=value"); skipping that step here would silently
|
|
658
|
+
// fail to find the vault or rc file a user installed with "~/..." to
|
|
659
|
+
// begin with.
|
|
660
|
+
else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
661
|
+
else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
|
|
447
662
|
}
|
|
448
663
|
return args;
|
|
449
664
|
}
|
|
@@ -589,6 +804,20 @@ const hookResult = removeHookFiles(claudeHooksDir, hookFiles, args.apply);
|
|
|
589
804
|
const settingsResult = stripSettingsJson(claudeSettings, claudeHooksDir, hookMap, args.apply);
|
|
590
805
|
const commandResult = removeCommands(args.apply, args.forceCommands);
|
|
591
806
|
|
|
807
|
+
// Wiki-side cleanup: the git pre-commit hook and the shell rc block init.mjs
|
|
808
|
+
// installs outside ~/.claude entirely. Both are independent of --codex/--hooks-dir.
|
|
809
|
+
const hypoDir = args.hypoDir ?? resolveHypoRoot();
|
|
810
|
+
const preCommitResult = removeWikiPreCommitHook(hypoDir, args.apply);
|
|
811
|
+
|
|
812
|
+
const shellConfigCandidates = args.shellConfig
|
|
813
|
+
? [args.shellConfig]
|
|
814
|
+
: [join(HOME, '.zshrc'), join(HOME, '.bashrc')];
|
|
815
|
+
const shellBlockOutcomes = shellConfigCandidates
|
|
816
|
+
.map((p) => removeShellFunctionBlock(p, args.apply))
|
|
817
|
+
.filter(Boolean);
|
|
818
|
+
const shellBlockResults = shellBlockOutcomes.filter((r) => r.removed).map((r) => r.path);
|
|
819
|
+
const shellBlockSkipped = shellBlockOutcomes.filter((r) => !r.removed);
|
|
820
|
+
|
|
592
821
|
// Extensions. Order matters: remove files first, then strip
|
|
593
822
|
// settings, then surgically clear the per-target SHA map. The SHA strip uses
|
|
594
823
|
// removedKeys so a user-modified file we left in place keeps its recorded SHA
|
|
@@ -736,6 +965,27 @@ if (hookResult.missing.length)
|
|
|
736
965
|
lines.push(
|
|
737
966
|
`⊘ Already absent (${hookResult.missing.length}):\n${hookResult.missing.map((p) => ` ${p}`).join('\n')}`,
|
|
738
967
|
);
|
|
968
|
+
if (preCommitResult.removed.length)
|
|
969
|
+
lines.push(
|
|
970
|
+
`✓ Wiki pre-commit hook ${dryRun ? 'to remove' : 'removed'} (${preCommitResult.removed.length}):\n${preCommitResult.removed.map((p) => ` ${p}`).join('\n')}`,
|
|
971
|
+
);
|
|
972
|
+
if (preCommitResult.skipped.length)
|
|
973
|
+
lines.push(
|
|
974
|
+
`⊘ Wiki pre-commit hook preserved:\n${preCommitResult.skipped.map((p) => ` ${p}`).join('\n')}`,
|
|
975
|
+
);
|
|
976
|
+
if (preCommitResult.bakPresent.length)
|
|
977
|
+
lines.push(
|
|
978
|
+
`ⓘ Pre-commit backup left in place (from --force-commands, never touched by uninstall):\n${preCommitResult.bakPresent.map((p) => ` ${p}`).join('\n')}`,
|
|
979
|
+
);
|
|
980
|
+
if (shellBlockResults.length)
|
|
981
|
+
lines.push(
|
|
982
|
+
`✓ Shell function block ${dryRun ? 'to remove' : 'removed'} (${shellBlockResults.length}):\n${shellBlockResults.map((p) => ` ${p}`).join('\n')}`,
|
|
983
|
+
);
|
|
984
|
+
if (shellBlockSkipped.length)
|
|
985
|
+
lines.push(
|
|
986
|
+
`⊘ Shell function block preserved:\n${shellBlockSkipped.map((r) => ` ${r.path} (${r.skipped})`).join('\n')}`,
|
|
987
|
+
);
|
|
988
|
+
|
|
739
989
|
if (settingsResult.error) lines.push(`⚠ ${settingsResult.error}`);
|
|
740
990
|
if (claudeExtSettings.error) lines.push(`⚠ ${claudeExtSettings.error}`);
|
|
741
991
|
if (codexExtSettings.error) lines.push(`⚠ ${codexExtSettings.error}`);
|
|
@@ -750,7 +1000,9 @@ if (
|
|
|
750
1000
|
!pkgJsonRemoved &&
|
|
751
1001
|
!commandResult.skippedUserModified.length &&
|
|
752
1002
|
!extSkippedUserModified.length &&
|
|
753
|
-
!extSkippedNonRegular.length
|
|
1003
|
+
!extSkippedNonRegular.length &&
|
|
1004
|
+
!preCommitResult.removed.length &&
|
|
1005
|
+
!shellBlockResults.length
|
|
754
1006
|
) {
|
|
755
1007
|
lines.push('Nothing to uninstall — Hypomnema does not appear to be installed.');
|
|
756
1008
|
}
|