hypomnema 1.8.2 → 1.8.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/CHANGELOG.md +105 -51
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/commands/crystallize.md +14 -3
- package/docs/ARCHITECTURE.md +13 -5
- package/docs/CONTRIBUTING.md +21 -7
- package/hooks/close-journal.mjs +128 -0
- package/hooks/hooks.json +1 -9
- package/hooks/hypo-session-start.mjs +194 -11
- package/hooks/hypo-shared.mjs +117 -39
- package/hooks/proposal-store.mjs +35 -1
- package/hooks/shared.json +9 -0
- package/package.json +2 -1
- package/scripts/doctor.mjs +43 -91
- package/scripts/init.mjs +54 -106
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +569 -79
- package/scripts/lib/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +150 -0
- package/scripts/lib/pkg-provenance.mjs +11 -0
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/template-schema-version.mjs +47 -0
- package/scripts/uninstall.mjs +130 -66
- package/scripts/upgrade.mjs +243 -145
- package/templates/hypo-config.md +1 -1
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -1953,7 +1953,21 @@ function atomicWriteShared(path, content) {
|
|
|
1953
1953
|
mkdirSync(dirname(path), { recursive: true });
|
|
1954
1954
|
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
1955
1955
|
writeFileSync(tmp, content);
|
|
1956
|
-
|
|
1956
|
+
try {
|
|
1957
|
+
renameSync(tmp, path);
|
|
1958
|
+
} catch (err) {
|
|
1959
|
+
// The rename is what makes this atomic, so a failure here leaves the
|
|
1960
|
+
// target untouched, which is the point. What it also leaves is the tmp
|
|
1961
|
+
// file, and nothing else ever looks at that name again: the suffix
|
|
1962
|
+
// carries this pid and a fresh random, so the next run picks a
|
|
1963
|
+
// different one and this one sits in the vault forever, close after
|
|
1964
|
+
// close. Take it back out before rethrowing, and do not let the
|
|
1965
|
+
// cleanup hide the real error.
|
|
1966
|
+
try {
|
|
1967
|
+
rmSync(tmp, { force: true });
|
|
1968
|
+
} catch {}
|
|
1969
|
+
throw err;
|
|
1970
|
+
}
|
|
1957
1971
|
}
|
|
1958
1972
|
|
|
1959
1973
|
// Read the pid the current holder recorded in its lockfile (see withFileLock).
|
|
@@ -3391,7 +3405,7 @@ export function normalizeVerifiedScope(verifiedScope) {
|
|
|
3391
3405
|
* @param {{project?: string, scope?: string, transcript_path?: string, verifiedScope?: {kind: 'log-only'|'project'|'global', projects?: string[]}}} info
|
|
3392
3406
|
*/
|
|
3393
3407
|
export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
|
|
3394
|
-
if (!sessionId) return;
|
|
3408
|
+
if (!sessionId) return false;
|
|
3395
3409
|
try {
|
|
3396
3410
|
const cacheDir = join(hypoDir, '.cache');
|
|
3397
3411
|
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
@@ -3427,9 +3441,24 @@ export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
|
|
|
3427
3441
|
// doctor's reader treats "field absent" as "no additional scope check".
|
|
3428
3442
|
...(verifiedScope ? { verified_scope: verifiedScope } : {}),
|
|
3429
3443
|
};
|
|
3430
|
-
|
|
3444
|
+
// Atomic, and it reports. Two reasons, and the caller needs both.
|
|
3445
|
+
//
|
|
3446
|
+
// A plain writeFileSync can leave a truncated file behind, and the reader
|
|
3447
|
+
// below drops a marker it cannot parse. That combination turns one failed
|
|
3448
|
+
// write into a session that can never close: the caller sees a file, calls
|
|
3449
|
+
// the close signal spent, and the next Stop deletes the unparseable marker.
|
|
3450
|
+
// temp+rename means the target is either the old bytes or the whole new
|
|
3451
|
+
// ones, never half.
|
|
3452
|
+
//
|
|
3453
|
+
// And existsSync cannot answer the question the caller is really asking.
|
|
3454
|
+
// "A marker file is there" is not "this run put it there" — a corrupt
|
|
3455
|
+
// marker from an earlier attempt satisfies it just as well. So say whether
|
|
3456
|
+
// THIS write landed and let the caller key on that.
|
|
3457
|
+
atomicWriteShared(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
|
|
3458
|
+
return true;
|
|
3431
3459
|
} catch (err) {
|
|
3432
3460
|
process.stderr.write(`[hypo] session-closed marker write failed: ${err?.message || err}\n`);
|
|
3461
|
+
return false;
|
|
3433
3462
|
}
|
|
3434
3463
|
}
|
|
3435
3464
|
|
|
@@ -4024,21 +4053,25 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
4024
4053
|
}
|
|
4025
4054
|
}
|
|
4026
4055
|
|
|
4027
|
-
// 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but
|
|
4028
|
-
//
|
|
4029
|
-
//
|
|
4030
|
-
//
|
|
4031
|
-
//
|
|
4032
|
-
//
|
|
4033
|
-
//
|
|
4034
|
-
//
|
|
4035
|
-
//
|
|
4036
|
-
//
|
|
4037
|
-
//
|
|
4038
|
-
//
|
|
4039
|
-
//
|
|
4040
|
-
//
|
|
4041
|
-
//
|
|
4056
|
+
// 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but a
|
|
4057
|
+
// file this session cannot be held to still demotes to a notice: a
|
|
4058
|
+
// session's own scoped auto-commit (commitWikiChanges, PR #222) can
|
|
4059
|
+
// leave the working tree non-empty when a DIFFERENT session sharing
|
|
4060
|
+
// this vault still has its own file dirty, the 2026-08-03 multi-session
|
|
4061
|
+
// block. That dirty file is human-fixable by whoever owns it, not by
|
|
4062
|
+
// this session, so it demotes to a notice (listed by path, never
|
|
4063
|
+
// silently dropped) instead of refusing this session's marker. Which
|
|
4064
|
+
// set decides "not mine" depends on what the caller told us: a
|
|
4065
|
+
// caller-provided project (--project, or the cwd-derived
|
|
4066
|
+
// attributionScope) demotes by PATH STRUCTURE alone
|
|
4067
|
+
// (isForeignProjectFile below), independent of transcript trust; with
|
|
4068
|
+
// no such scope, only a trusted transcript's closeAccountableScope can
|
|
4069
|
+
// tell mine from foreign, so an untrusted,
|
|
4070
|
+
// unscoped session falls back to the original unscoped blocker:
|
|
4071
|
+
// "cannot attribute" is not "clean". A dirty file THIS session owns
|
|
4072
|
+
// still blocks unconditionally either way, and so does the
|
|
4073
|
+
// enumeration-failed case below (dirty.length === 0 despite
|
|
4074
|
+
// git.uncommitted === true).
|
|
4042
4075
|
// Unpushed commits (ahead) DEMOTE to a notice regardless of scope: push is
|
|
4043
4076
|
// automatic (auto-commit Stop hook) and its failures are already non-fatal, so
|
|
4044
4077
|
// "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
|
|
@@ -4053,17 +4086,26 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
4053
4086
|
// porcelain status): "cannot attribute" is not "clean", block exactly
|
|
4054
4087
|
// as before, override or not.
|
|
4055
4088
|
blockers.push({ type: 'git', reason: git.reason });
|
|
4056
|
-
} else if (
|
|
4057
|
-
// session-close-scope-boundary spec §2b: a
|
|
4058
|
-
//
|
|
4059
|
-
//
|
|
4060
|
-
//
|
|
4061
|
-
//
|
|
4062
|
-
//
|
|
4063
|
-
//
|
|
4064
|
-
//
|
|
4065
|
-
//
|
|
4066
|
-
//
|
|
4089
|
+
} else if (opts.projectOverride || opts.attributionScope) {
|
|
4090
|
+
// session-close-scope-boundary spec §2b, revised 2026-09-11: a
|
|
4091
|
+
// caller-provided scope (an explicit --project, or the cwd-derived
|
|
4092
|
+
// attributionScope) proves which project is ours PATH-STRUCTURALLY,
|
|
4093
|
+
// regardless of transcript trust. This used to run only when
|
|
4094
|
+
// !sessionTouchTrusted, on the theory that a trusted transcript could
|
|
4095
|
+
// fall back to closeAccountableScope instead. But closeAccountableScope
|
|
4096
|
+
// is `closeFileTargetsGlobal` whenever opts.projectOverride is unset
|
|
4097
|
+
// (every caller here passes attributionScope, never projectOverride;
|
|
4098
|
+
// see crystallize-close-apply.mjs), which unions in every OTHER
|
|
4099
|
+
// today-active project's own mandatory close files too. That let a
|
|
4100
|
+
// different session's still-dirty close files (session-state.md,
|
|
4101
|
+
// project hot.md, today's session-log shard) block THIS session's
|
|
4102
|
+
// marker even with --project set. A dirty file that lives structurally
|
|
4103
|
+
// under a DIFFERENT eligible project's own
|
|
4104
|
+
// directory needs no attribution inference at all: the path alone
|
|
4105
|
+
// proves it is not this session's file. Everything else (this
|
|
4106
|
+
// session's own project, pages/, an unregistered or _template project
|
|
4107
|
+
// dir) keeps the pre-existing fail-closed behavior; only a
|
|
4108
|
+
// provably-foreign path is demoted.
|
|
4067
4109
|
const effectiveOverride = opts.projectOverride || opts.attributionScope;
|
|
4068
4110
|
// Computed once per gate call, never per file (collectProjectWorkingDirs
|
|
4069
4111
|
// walks the projects/ dir and reads every index.md). A throw here (a
|
|
@@ -4085,6 +4127,20 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
4085
4127
|
const isForeign = (f) =>
|
|
4086
4128
|
isForeignProjectFile(f, { eligibleSlugs, effectiveOverride, transcriptTouched });
|
|
4087
4129
|
const foreign = dirty.filter(isForeign);
|
|
4130
|
+
// A dirty file inside the scoped project's OWN directory blocks, even one
|
|
4131
|
+
// this close does not write. An earlier revision demoted those to notices
|
|
4132
|
+
// to escape a deadlock: a close whose commit fails leaves
|
|
4133
|
+
// `projects/<p>/index.md` (seeded by ensureProjectIndex) uncommitted, the
|
|
4134
|
+
// retry skips every payload field as already-current without re-staging
|
|
4135
|
+
// it, and the marker can then never land again no matter how often the
|
|
4136
|
+
// user retries.
|
|
4137
|
+
//
|
|
4138
|
+
// That demotion was far wider than the deadlock it answered. It waved
|
|
4139
|
+
// through every unsaved file in the project, which is exactly the work a
|
|
4140
|
+
// close is supposed to refuse to walk away from. The deadlock is fixed at
|
|
4141
|
+
// its source instead: applyOverwrites re-stages index.md on the retry
|
|
4142
|
+
// path (crystallize-close-apply.mjs), so the file this branch used to
|
|
4143
|
+
// demote is now in the close's own commit scope and never reaches here.
|
|
4088
4144
|
const rest = dirty.filter((f) => !isForeign(f));
|
|
4089
4145
|
if (rest.length > 0) {
|
|
4090
4146
|
blockers.push({
|
|
@@ -5201,14 +5257,25 @@ export function walkCloseGate(transcriptPath) {
|
|
|
5201
5257
|
// `attachment` of type queued_command with the prompt verbatim). This opens
|
|
5202
5258
|
// the gate ONLY with an audited human producer — origin.kind "human", present
|
|
5203
5259
|
// on every 2.1.181+ user delivery (measured). A legacy origin-absent delivery
|
|
5204
|
-
// cannot attest a producer, so it does not open (fail-closed).
|
|
5205
|
-
// queued command (e.g. "keep working") is a
|
|
5206
|
-
//
|
|
5207
|
-
//
|
|
5208
|
-
//
|
|
5209
|
-
//
|
|
5210
|
-
//
|
|
5211
|
-
//
|
|
5260
|
+
// cannot attest a producer, so it does not open (fail-closed). Closing asks
|
|
5261
|
+
// for the same proof: a NON-close queued command (e.g. "keep working") is a
|
|
5262
|
+
// fresh user intent and retracts a prior open, but only with that same
|
|
5263
|
+
// audited human producer. This branch used to close by default instead,
|
|
5264
|
+
// on the theory that anything the model-caused list did not recognise had
|
|
5265
|
+
// to be the user. That default is what let an unrelated background-task
|
|
5266
|
+
// notification flip a just-opened gate shut, and every new machine-minted
|
|
5267
|
+
// event would have needed its own line in that list to stay safe. Requiring
|
|
5268
|
+
// a positive producer means an event this filter has never seen is neutral
|
|
5269
|
+
// rather than hostile. `modelCaused` below still runs first, so a
|
|
5270
|
+
// recognised notification never reaches either decision.
|
|
5271
|
+
//
|
|
5272
|
+
// The enqueue branch above does NOT share this rule: those records carry no
|
|
5273
|
+
// `origin` at all, so requiring one would stop them closing anything. It
|
|
5274
|
+
// keeps the old enumerate-the-machine default, and the re-close hole (a
|
|
5275
|
+
// queued "continue" after a close leaving the stale open live) is closed
|
|
5276
|
+
// there rather than here. The two delivery shapes of one user action are
|
|
5277
|
+
// therefore judged differently on purpose; see the residual noted on the
|
|
5278
|
+
// queue-operation branch.
|
|
5212
5279
|
//
|
|
5213
5280
|
// Scope of "both shapes classify alike": it holds for task notifications and
|
|
5214
5281
|
// for empty content, which is what this fix is about. It does not hold for
|
|
@@ -5277,9 +5344,20 @@ export function walkCloseGate(transcriptPath) {
|
|
|
5277
5344
|
open = true;
|
|
5278
5345
|
openedAtIndex = i;
|
|
5279
5346
|
}
|
|
5280
|
-
} else {
|
|
5281
|
-
open = false;
|
|
5347
|
+
} else if (humanOrigin) {
|
|
5348
|
+
open = false; // an audited human change of mind → close
|
|
5282
5349
|
}
|
|
5350
|
+
// else: not caught by the known-machine shapes above, not a close
|
|
5351
|
+
// phrase, and no audited human producer either — a host event this
|
|
5352
|
+
// filter does not yet recognize. The old rule closed here by default
|
|
5353
|
+
// (anything not on the model-caused list must be the user), which is
|
|
5354
|
+
// exactly the shape of bug #289 fixed for <task-notification>: a new
|
|
5355
|
+
// kind of unattributed host event would silently retract a close the
|
|
5356
|
+
// user already granted, and every future one would need its own line
|
|
5357
|
+
// in isModelCausedQueueContent to avoid repeating that. Requiring a
|
|
5358
|
+
// POSITIVE human producer to close, instead of enumerating every way to
|
|
5359
|
+
// recognize a machine one, means an event this filter has never seen
|
|
5360
|
+
// still cannot close the gate on its own.
|
|
5283
5361
|
continue;
|
|
5284
5362
|
}
|
|
5285
5363
|
|
package/hooks/proposal-store.mjs
CHANGED
|
@@ -228,13 +228,37 @@ export function deleteProposal(hypoDir, id) {
|
|
|
228
228
|
* @param {string} fields.sessionId owning session
|
|
229
229
|
* @param {string} fields.device machine identifier (crystallize passes currentDevice())
|
|
230
230
|
* @param {string} [fields.createdAt] ISO timestamp; defaults to now
|
|
231
|
+
* @param {string} [fields.parkReason] human-readable cause of the park (crystallize's
|
|
232
|
+
* conflictWhy(c)) — this is the artifact's own copy of the same string a `--json`
|
|
233
|
+
* close now also puts in that close's conflicts[].why, kept here because the T7
|
|
234
|
+
* CLI's list/apply/discard/challenge/resolve actions read only the artifact and
|
|
235
|
+
* never that close's own stdout. Additive: absent on artifacts written before this
|
|
236
|
+
* field existed, and every reader (this module's own readArtifactFile, and the T7
|
|
237
|
+
* CLI's actions above) treats an absent value as "no cause recorded", never as
|
|
238
|
+
* malformed — none of them requires this key to be present.
|
|
239
|
+
* @param {string[]} [fields.lostSections] the `##` headings a section-loss park
|
|
240
|
+
* dropped (crystallize-close-apply.mjs's sectionLossReason). Present only when
|
|
241
|
+
* `parkReason` names that cause; omitted for every other park reason, same as
|
|
242
|
+
* `diskSectionCount` below.
|
|
243
|
+
* @param {number} [fields.diskSectionCount] how many `##` headings disk had at
|
|
244
|
+
* park time, alongside `lostSections`.
|
|
231
245
|
* @returns {{id: string, target: string, path: string, supersedeWarnings: string[]}}
|
|
232
246
|
* `supersedeWarnings` is non-empty only when the new artifact WAS written but an
|
|
233
247
|
* older same-target sibling could not be removed — a non-fatal condition (the
|
|
234
248
|
* payload is parked), reported separately from a write failure (which throws).
|
|
235
249
|
*/
|
|
236
250
|
export function writeProposal(hypoDir, fields) {
|
|
237
|
-
const {
|
|
251
|
+
const {
|
|
252
|
+
target,
|
|
253
|
+
baseHash,
|
|
254
|
+
currentAtProposalHash,
|
|
255
|
+
proposedContent,
|
|
256
|
+
sessionId,
|
|
257
|
+
device,
|
|
258
|
+
parkReason,
|
|
259
|
+
lostSections,
|
|
260
|
+
diskSectionCount,
|
|
261
|
+
} = fields;
|
|
238
262
|
const createdAt = fields.createdAt || new Date().toISOString();
|
|
239
263
|
|
|
240
264
|
// Match on the parsed `target` field, never on the filename slug: two distinct
|
|
@@ -266,6 +290,16 @@ export function writeProposal(hypoDir, fields) {
|
|
|
266
290
|
sessionId: sessionId != null ? String(sessionId) : null,
|
|
267
291
|
device: device != null ? String(device) : null,
|
|
268
292
|
createdAt,
|
|
293
|
+
// Additive fields (see this function's own doc comment): omitted rather
|
|
294
|
+
// than stored as `null` when the caller does not pass them, so an artifact
|
|
295
|
+
// written before this field existed and one written by a caller that
|
|
296
|
+
// genuinely has no cause to report look identical on disk — both simply
|
|
297
|
+
// lack the key, and every reader already treats an absent key as "not
|
|
298
|
+
// recorded", never as a malformed body (readArtifactFile only requires
|
|
299
|
+
// `id` and `target`; nothing here reads these three as mandatory).
|
|
300
|
+
...(parkReason != null ? { parkReason: String(parkReason) } : {}),
|
|
301
|
+
...(Array.isArray(lostSections) ? { lostSections } : {}),
|
|
302
|
+
...(typeof diskSectionCount === 'number' ? { diskSectionCount } : {}),
|
|
269
303
|
};
|
|
270
304
|
// Write the new artifact DURABLY before removing the old one: a crash in the
|
|
271
305
|
// gap leaves a stale sibling (superseded next close), never a lost payload.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hypomnema",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.4",
|
|
4
4
|
"description": "LLM-native personal wiki system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"scripts/lib/feedback-scope.mjs",
|
|
40
40
|
"scripts/lib/frontmatter.mjs",
|
|
41
41
|
"scripts/lib/git-hooks-dir.mjs",
|
|
42
|
+
"scripts/lib/hook-inventory.mjs",
|
|
42
43
|
"scripts/lib/hypo-ignore.mjs",
|
|
43
44
|
"scripts/lib/hypo-root.mjs",
|
|
44
45
|
"scripts/lib/page-usage.mjs",
|
package/scripts/doctor.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { homedir } from 'os';
|
|
|
18
18
|
import { spawnSync } from 'child_process';
|
|
19
19
|
import { fileURLToPath } from 'url';
|
|
20
20
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
21
|
+
import { loadHookInventory } from './lib/hook-inventory.mjs';
|
|
21
22
|
import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
|
|
22
23
|
import { readRenameMarker, renameMarkerPath, RENAME_MARKER_REL } from './lib/rename-marker.mjs';
|
|
23
24
|
import {
|
|
@@ -154,94 +155,22 @@ function fail(label, detail = '') {
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
// ── hook map (loaded from hooks/hooks.json — single source of truth) ─────────
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (!
|
|
167
|
-
console.error(
|
|
168
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
169
|
-
process.exit(1);
|
|
170
|
-
}
|
|
171
|
-
if (
|
|
172
|
-
!_hookConfig.hooks ||
|
|
173
|
-
typeof _hookConfig.hooks !== 'object' ||
|
|
174
|
-
Array.isArray(_hookConfig.hooks)
|
|
175
|
-
) {
|
|
176
|
-
console.error('Error: hooks/hooks.json must contain a "hooks" object');
|
|
177
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
178
|
-
process.exit(1);
|
|
179
|
-
}
|
|
180
|
-
function _extractCommandFileName(command) {
|
|
181
|
-
if (typeof command !== 'string') return null;
|
|
182
|
-
const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
|
|
183
|
-
if (matches.length > 0) return matches[matches.length - 1][1];
|
|
184
|
-
const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
|
|
185
|
-
return bare ? bare[1] : null;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function _isHookFileName(file) {
|
|
189
|
-
return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function _isHookGroup(group) {
|
|
193
|
-
return (
|
|
194
|
-
group &&
|
|
195
|
-
typeof group === 'object' &&
|
|
196
|
-
!Array.isArray(group) &&
|
|
197
|
-
Array.isArray(group.hooks) &&
|
|
198
|
-
group.hooks.length > 0 &&
|
|
199
|
-
group.hooks.every(
|
|
200
|
-
(hook) =>
|
|
201
|
-
hook &&
|
|
202
|
-
typeof hook === 'object' &&
|
|
203
|
-
!Array.isArray(hook) &&
|
|
204
|
-
hook.type === 'command' &&
|
|
205
|
-
_extractCommandFileName(hook.command),
|
|
206
|
-
)
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
|
|
211
|
-
function _extractFileNames(groups) {
|
|
212
|
-
return groups.flatMap((group) => {
|
|
213
|
-
if (typeof group === 'string') return [group.trim()];
|
|
214
|
-
return group.hooks.map((hook) => _extractCommandFileName(hook.command));
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
|
|
219
|
-
const valid =
|
|
220
|
-
Array.isArray(groups) &&
|
|
221
|
-
groups.length > 0 &&
|
|
222
|
-
groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
|
|
223
|
-
_extractFileNames(groups).length > 0;
|
|
224
|
-
if (!valid) {
|
|
225
|
-
console.error(
|
|
226
|
-
`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
|
|
227
|
-
);
|
|
228
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
229
|
-
process.exit(1);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
if (
|
|
233
|
-
_hookConfig.shared !== undefined &&
|
|
234
|
-
(!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every((f) => _isHookFileName(f)))
|
|
235
|
-
) {
|
|
236
|
-
console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
|
|
158
|
+
//
|
|
159
|
+
// loadHookInventory (scripts/lib/hook-inventory.mjs) is the one parser every
|
|
160
|
+
// hooks.json/shared.json consumer reads through — smoke-plugin, init, upgrade,
|
|
161
|
+
// and uninstall all call the same function. It existence-checks every named
|
|
162
|
+
// file against THIS package's own hooks/ before returning ok:true, so a
|
|
163
|
+
// corrupt or incomplete doctor.mjs's OWN package (never the vault being
|
|
164
|
+
// audited — that stays checkHooks()'s job below) fails here rather than
|
|
165
|
+
// auditing an install against a hook list doctor cannot trust.
|
|
166
|
+
const _hookInventory = loadHookInventory(PKG_ROOT);
|
|
167
|
+
if (!_hookInventory.ok) {
|
|
168
|
+
console.error(`Error: ${_hookInventory.error}`);
|
|
237
169
|
console.error(PKG_INTEGRITY_HINT);
|
|
238
170
|
process.exit(1);
|
|
239
171
|
}
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]),
|
|
243
|
-
);
|
|
244
|
-
const SHARED_FILES = _hookConfig.shared ?? [];
|
|
172
|
+
const HOOK_MAP = _hookInventory.hookMap;
|
|
173
|
+
const SHARED_FILES = _hookInventory.shared;
|
|
245
174
|
|
|
246
175
|
// ── checks ───────────────────────────────────────────────────────────────────
|
|
247
176
|
|
|
@@ -543,15 +472,38 @@ function checkGit(hypoDir) {
|
|
|
543
472
|
// plugin-cache leaf-drift precedent: reporting must never turn into
|
|
544
473
|
// filtering, or a still-real, still-resolvable root looks like it vanished).
|
|
545
474
|
const parsedRoot = parseWikiPreCommitRoot(content);
|
|
546
|
-
|
|
547
|
-
|
|
475
|
+
if (parsedRoot.ok && parsedRoot.root === null) {
|
|
476
|
+
// The runtime-resolving form: the hook looks up its own
|
|
477
|
+
// install root at commit time, so there is no baked value to compare
|
|
478
|
+
// against the active install — it cannot structurally go stale the way
|
|
479
|
+
// the old, version-pinned form could. Report that fact in place of the
|
|
480
|
+
// stale-root comparison below, rather than falling silent.
|
|
481
|
+
pass(`${label} root`, 'Resolved at commit time by the hook itself — never goes stale');
|
|
482
|
+
} else if (!parsedRoot.ok) {
|
|
483
|
+
// The pass above only checked that our START marker is present, not
|
|
484
|
+
// that the body it wraps is one this codebase can actually run. This is
|
|
485
|
+
// the fail-open this branch used to leave: a hand-edited body, a
|
|
486
|
+
// corrupted one, or (before the rewrite-safe root check) an OLD-form
|
|
487
|
+
// hook whose baked-in install root no longer existed all read as a
|
|
488
|
+
// clean "guard installed" pass while the hook fails MODULE_NOT_FOUND on
|
|
489
|
+
// every real commit.
|
|
548
490
|
warn(
|
|
549
491
|
`${label} root`,
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
)}.`,
|
|
492
|
+
'Guard marker present but the hook body is not recognized, it may be failing on ' +
|
|
493
|
+
`every commit. Run \`hypomnema init --force-commands\` (or the plugin's ` +
|
|
494
|
+
`\`/hypo:init --force-commands\`) to reinstall it.`,
|
|
554
495
|
);
|
|
496
|
+
} else {
|
|
497
|
+
const wantRoot = resolveDurableHookRoot();
|
|
498
|
+
if (wantRoot !== null && wantRoot !== parsedRoot.root) {
|
|
499
|
+
warn(
|
|
500
|
+
`${label} root`,
|
|
501
|
+
`Points at an old install root (${parsedRoot.root}) — the active install is ` +
|
|
502
|
+
`${wantRoot}. To repoint it, run ${upgradeApplyHint(
|
|
503
|
+
pluginMode || hypomnemaPluginEnabled,
|
|
504
|
+
)}.`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
555
507
|
}
|
|
556
508
|
} else {
|
|
557
509
|
warn(label, 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore');
|