hypomnema 1.8.2 → 1.9.0
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 +141 -51
- package/README.ko.md +18 -17
- package/README.md +16 -15
- package/commands/crystallize.md +89 -24
- package/commands/doctor.md +3 -1
- package/docs/ARCHITECTURE.md +45 -14
- package/docs/CONTRIBUTING.md +26 -8
- package/hooks/atomic-write.mjs +44 -0
- package/hooks/base-store.mjs +409 -90
- package/hooks/close-gate-store.mjs +131 -16
- package/hooks/close-journal.mjs +250 -0
- package/hooks/close-receipt.mjs +559 -0
- package/hooks/hooks.json +3 -38
- package/hooks/hypo-auto-commit.mjs +80 -10
- package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
- package/hooks/hypo-auto-stage.mjs +13 -1
- package/hooks/hypo-close-guard.mjs +2 -1
- package/hooks/hypo-cwd-change.mjs +45 -20
- package/hooks/hypo-file-watch.mjs +44 -6
- package/hooks/hypo-first-prompt.mjs +15 -9
- package/hooks/hypo-hot-rebuild.mjs +169 -112
- package/hooks/hypo-personal-check.mjs +9 -1
- package/hooks/hypo-session-record.mjs +1 -1
- package/hooks/hypo-session-start.mjs +566 -20
- package/hooks/hypo-shared.mjs +2250 -206
- package/hooks/hypo-stop.mjs +207 -0
- package/hooks/proposal-store.mjs +201 -35
- package/hooks/shared.json +15 -0
- package/hooks/version-check.mjs +9 -7
- package/package.json +3 -1
- package/scripts/crystallize.mjs +3 -4
- package/scripts/doctor.mjs +549 -123
- package/scripts/init.mjs +86 -109
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +2397 -188
- package/scripts/lib/crystallize-close-check.mjs +43 -23
- package/scripts/lib/design-history-stale.mjs +36 -9
- package/scripts/lib/frontmatter.mjs +39 -0
- package/scripts/lib/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +180 -0
- package/scripts/lib/page-usage.mjs +1 -1
- package/scripts/lib/pkg-provenance.mjs +18 -1
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/project-create.mjs +40 -44
- package/scripts/lib/schema-vocab.mjs +22 -11
- package/scripts/lib/template-schema-version.mjs +48 -0
- package/scripts/lib/time.mjs +46 -0
- package/scripts/lint.mjs +66 -18
- package/scripts/proposal.mjs +423 -18
- package/scripts/resume.mjs +3 -2
- package/scripts/session-audit.mjs +2 -1
- package/scripts/uninstall.mjs +144 -66
- package/scripts/upgrade.mjs +523 -234
- package/scripts/verify.mjs +2 -1
- package/scripts/weekly-report.mjs +2 -1
- package/templates/SCHEMA.md +27 -7
- package/templates/gitignore +11 -0
- package/templates/hypo-automation.md +10 -13
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +53 -25
- package/templates/hot.md +0 -24
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// hook-inventory.mjs — the one grammar every hooks.json/shared.json consumer reads
|
|
2
|
+
// through: smoke-plugin.mjs, init.mjs, upgrade.mjs, doctor.mjs, uninstall.mjs.
|
|
3
|
+
//
|
|
4
|
+
// Before this file existed, five scripts each grew their own regex for pulling a
|
|
5
|
+
// .mjs basename out of a hooks.json `command` string, and the five disagreed:
|
|
6
|
+
// - smoke-plugin accepted ANY path after `${CLAUDE_PLUGIN_ROOT}/`, so a command
|
|
7
|
+
// pointing at `${CLAUDE_PLUGIN_ROOT}/scripts/foo.mjs` (the wrong directory —
|
|
8
|
+
// init only ever copies hooks/) passed smoke as long as that file happened to
|
|
9
|
+
// exist, while init/upgrade/doctor/uninstall all resolve the same command to
|
|
10
|
+
// `hooks/foo.mjs` and never look in scripts/ at all.
|
|
11
|
+
// - init/upgrade/doctor took the LAST `.mjs` path segment in the command, so a
|
|
12
|
+
// trailing argument like `--config bar.mjs` would have picked `bar.mjs` up as
|
|
13
|
+
// the hook file instead of the hook itself.
|
|
14
|
+
// - uninstall anchored on `/hooks/([^/\s]+\.mjs)$` — the command had to literally
|
|
15
|
+
// END in `.mjs`, so the same trailing-argument shape it would silently ignore
|
|
16
|
+
// instead of misreading.
|
|
17
|
+
// One parser closes all three gaps by only ever accepting one shape.
|
|
18
|
+
//
|
|
19
|
+
// This module also folds in the existence check that used to be missing entirely:
|
|
20
|
+
// loadHookInventory() only returns ok:true once every name on both lists resolves
|
|
21
|
+
// to a real regular file under `<pkgRoot>/hooks/`. A caller that runs this BEFORE
|
|
22
|
+
// its first write (as init.mjs now does) can never install a partial hook set from
|
|
23
|
+
// a package a file was dropped out of.
|
|
24
|
+
//
|
|
25
|
+
// Deliberately does NOT accept the legacy bare-filename group format
|
|
26
|
+
// (`"hooks.json"`: `{ "SomeEvent": ["foo.mjs"] }`) that ../core-hooks.mjs's
|
|
27
|
+
// readCoreHooksConfig still tolerates for reverse-capture's basename reservation.
|
|
28
|
+
// That tolerance exists so a stale or hand-edited hooks.json never lets a core
|
|
29
|
+
// hook slip into a user's captured extensions; it has nothing to do with what
|
|
30
|
+
// install/uninstall are willing to act on, so narrowing the grammar here does not
|
|
31
|
+
// need to touch core-hooks.mjs at all.
|
|
32
|
+
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { readCoreHooksConfig } from './core-hooks.mjs';
|
|
35
|
+
import { isRegularFile } from './pkg-json.mjs';
|
|
36
|
+
|
|
37
|
+
// A plain .mjs basename: word characters, dots, and hyphens only, no path
|
|
38
|
+
// separator (so a joined path can never climb out of the directory it is joined
|
|
39
|
+
// onto) and no leading dot (rules out a hidden file and `..`, which does not
|
|
40
|
+
// match `\w` but a bare `.` prefix like `.mjs` alone still would). Mirrors the
|
|
41
|
+
// basename check smoke-plugin.mjs already applied to hooks/shared.json entries.
|
|
42
|
+
const SAFE_MJS_BASENAME = /^[\w.-]+\.mjs$/;
|
|
43
|
+
|
|
44
|
+
export function isSafeMjsBasename(name) {
|
|
45
|
+
return typeof name === 'string' && SAFE_MJS_BASENAME.test(name) && !name.startsWith('.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The one command shape this module accepts: `${CLAUDE_PLUGIN_ROOT}/hooks/<basename>.mjs`,
|
|
49
|
+
// optionally followed by more of the command (a trailing argument, a redirect —
|
|
50
|
+
// anything after whitespace or a closing quote). No sub-directory under hooks/,
|
|
51
|
+
// no other top-level directory, no bare filename with no `${CLAUDE_PLUGIN_ROOT}/hooks/`
|
|
52
|
+
// prefix at all.
|
|
53
|
+
const PLUGIN_HOOKS_TARGET = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^\s/"'`]+)(?=$|[\s"'`])/;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract the hooks/<basename>.mjs a command string targets, or null when the
|
|
57
|
+
* command does not match the one accepted shape (wrong directory, a
|
|
58
|
+
* sub-directory, a basename that fails isSafeMjsBasename, or no
|
|
59
|
+
* `${CLAUDE_PLUGIN_ROOT}/hooks/` segment at all).
|
|
60
|
+
*/
|
|
61
|
+
export function extractPluginHookBasename(command) {
|
|
62
|
+
if (typeof command !== 'string') return null;
|
|
63
|
+
const m = command.match(PLUGIN_HOOKS_TARGET);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
return isSafeMjsBasename(m[1]) ? m[1] : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load, narrowly validate, and existence-check the hook inventory rooted at
|
|
70
|
+
* `pkgRoot`: hooks/hooks.json's per-event basenames plus hooks/shared.json's
|
|
71
|
+
* shared-module basenames. Fails closed on the FIRST problem, whether that is a
|
|
72
|
+
* read/parse failure (delegated to readCoreHooksConfig), an empty hooks map, a
|
|
73
|
+
* command that does not match the one accepted grammar, an unsafe shared.json
|
|
74
|
+
* basename, or a named file that is not actually a regular file under
|
|
75
|
+
* `<pkgRoot>/hooks/`.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} pkgRoot
|
|
78
|
+
* @returns {{ ok: true, hookMap: Record<string, string[]>, shared: string[] }
|
|
79
|
+
* | { ok: false, error: string }}
|
|
80
|
+
*/
|
|
81
|
+
export function loadHookInventory(pkgRoot) {
|
|
82
|
+
const res = readCoreHooksConfig(pkgRoot);
|
|
83
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
84
|
+
const cfg = res.cfg;
|
|
85
|
+
|
|
86
|
+
if (Object.keys(cfg.hooks).length === 0) {
|
|
87
|
+
return { ok: false, error: 'hooks/hooks.json "hooks" must not be empty' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hookMap = {};
|
|
91
|
+
for (const [event, groups] of Object.entries(cfg.hooks)) {
|
|
92
|
+
const basenames = [];
|
|
93
|
+
for (const group of groups) {
|
|
94
|
+
// readCoreHooksConfig still accepts a bare-filename group (the legacy
|
|
95
|
+
// shape reverse-capture's basename reservation tolerates on purpose — see
|
|
96
|
+
// the module header). Every real install/uninstall consumer of THIS
|
|
97
|
+
// function only ever shipped the hook-group object form, so a bare string
|
|
98
|
+
// reaching here is not something any of the five knows how to act on.
|
|
99
|
+
if (typeof group === 'string') {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
error: `hooks.${event}: a bare filename group ("${group}") is not accepted here — wrap it in a hook-group object with an explicit command`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (!Array.isArray(group.hooks) || group.hooks.length === 0) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: `hooks.${event}: a hook group must have a non-empty "hooks" array`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
for (const hook of group.hooks) {
|
|
112
|
+
if (hook.type !== 'command') {
|
|
113
|
+
return { ok: false, error: `hooks.${event}: hook entry "type" must be "command"` };
|
|
114
|
+
}
|
|
115
|
+
const base = extractPluginHookBasename(hook.command);
|
|
116
|
+
if (!base) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: `hooks.${event}: command does not match "\${CLAUDE_PLUGIN_ROOT}/hooks/<basename>.mjs": ${hook.command}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
basenames.push(base);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (basenames.length === 0) {
|
|
126
|
+
return { ok: false, error: `hooks.${event} yields no hook files` };
|
|
127
|
+
}
|
|
128
|
+
hookMap[event] = basenames;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const file of cfg.shared) {
|
|
132
|
+
if (!isSafeMjsBasename(file)) {
|
|
133
|
+
return { ok: false, error: `hooks/shared.json: "${file}" is not a plain .mjs basename` };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Existence, checked last and over the UNION of both lists: a caller that
|
|
138
|
+
// reaches this point has already paid for the grammar checks above, and a
|
|
139
|
+
// missing file (dropped from `package.json`'s `files` allowlist, or from a
|
|
140
|
+
// corrupted copy) is exactly the gap major C closed — nothing may be
|
|
141
|
+
// installed, refreshed, or reported healthy on the strength of a name alone.
|
|
142
|
+
const allBasenames = new Set([...Object.values(hookMap).flat(), ...cfg.shared]);
|
|
143
|
+
for (const base of allBasenames) {
|
|
144
|
+
if (!isRegularFile(join(pkgRoot, 'hooks', base))) {
|
|
145
|
+
return { ok: false, error: `hooks/${base} is not a file` };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { ok: true, hookMap, shared: cfg.shared, ordered: installOrder(hookMap, cfg.shared) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The order the hook files must be written to an install, and the reverse of
|
|
154
|
+
* the order they must be removed in.
|
|
155
|
+
*
|
|
156
|
+
* Shared modules first. An entry hook copied ahead of a module it imports is
|
|
157
|
+
* live against something that may not be there yet if the run is interrupted,
|
|
158
|
+
* and the session that would have run the repair is the one that then cannot
|
|
159
|
+
* start. The reverse order leaves the harmless state instead: an updated shared
|
|
160
|
+
* module under an entry hook that has not caught up.
|
|
161
|
+
*
|
|
162
|
+
* It lives here so init, upgrade and uninstall read one list rather than each
|
|
163
|
+
* deriving its own. They did derive their own, and the three sets were not even
|
|
164
|
+
* equal: init copied every `.mjs` in the directory, which pulled in
|
|
165
|
+
* `hypo-pre-commit.mjs` (invoked from the package root, never from the install)
|
|
166
|
+
* and left a copy in every install that upgrade would not refresh, doctor would
|
|
167
|
+
* not report, and uninstall would not remove.
|
|
168
|
+
*
|
|
169
|
+
* Alphabetical order puts today's shared modules first by luck; a shared module
|
|
170
|
+
* named later in the alphabet would take that away with nothing failing.
|
|
171
|
+
*/
|
|
172
|
+
export function installOrder(hookMap, shared) {
|
|
173
|
+
const sharedList = Array.isArray(shared) ? shared.filter((f) => typeof f === 'string') : [];
|
|
174
|
+
const sharedSet = new Set(sharedList);
|
|
175
|
+
const entries = [];
|
|
176
|
+
for (const file of Object.values(hookMap).flat()) {
|
|
177
|
+
if (!sharedSet.has(file) && !entries.includes(file)) entries.push(file);
|
|
178
|
+
}
|
|
179
|
+
return [...sharedList, ...entries];
|
|
180
|
+
}
|
|
@@ -11,10 +11,10 @@ import { readFileSync, existsSync } from 'fs';
|
|
|
11
11
|
import { join } from 'path';
|
|
12
12
|
import { collectPagesCrystallize, extractWikilinks, slugForms } from './wikilink.mjs';
|
|
13
13
|
import { parseFrontmatter } from './frontmatter.mjs';
|
|
14
|
+
import { DAY_MS } from './time.mjs';
|
|
14
15
|
import { currentDevice, scopeVisible, readVisibilityScope } from '../../hooks/hypo-shared.mjs';
|
|
15
16
|
|
|
16
17
|
const PAGE_USAGE_REL = '.cache/page-usage.jsonl';
|
|
17
|
-
const DAY_MS = 86400000;
|
|
18
18
|
|
|
19
19
|
// The distinct link forms a slug may appear as in a [[wikilink]] or in the log:
|
|
20
20
|
// its full path, its basename, and (when nested) the path minus its first
|
|
@@ -106,6 +106,14 @@ export function provenancePath(hooksDir) {
|
|
|
106
106
|
* it is a doctor-only freshness signal, not part of the runtime contract
|
|
107
107
|
* (hooks/hypo-shared.mjs never reads this field), so a sidecar the runtime
|
|
108
108
|
* can verify should not be withheld just because the digest step failed.
|
|
109
|
+
*
|
|
110
|
+
* `managedFiles` (major D) is the same best-effort shape: the sorted basename
|
|
111
|
+
* list this run's own copy targeted (every hooks.json event command plus every
|
|
112
|
+
* hooks/shared.json entry, derived from `pkgRoot` the same way the digest
|
|
113
|
+
* above is). scripts/uninstall.mjs reads it back as a fallback source of truth
|
|
114
|
+
* when THIS package's own hooks.json can no longer be read at uninstall time —
|
|
115
|
+
* the sidecar was written by whatever package last actually ran the copy, so it
|
|
116
|
+
* names the truth of what is on disk even after the current package rots.
|
|
109
117
|
*/
|
|
110
118
|
export function writeProvenanceSidecar(hooksDir, pkgRoot, pkgVersion, hooksSrcDir, dryRun) {
|
|
111
119
|
let hypoSharedSha256;
|
|
@@ -115,12 +123,15 @@ export function writeProvenanceSidecar(hooksDir, pkgRoot, pkgVersion, hooksSrcDi
|
|
|
115
123
|
return null;
|
|
116
124
|
}
|
|
117
125
|
const hooksDigest = computeHooksDigest(pkgRoot, hooksSrcDir);
|
|
126
|
+
const cfgRes = readCoreHooksConfig(pkgRoot);
|
|
127
|
+
const managedFiles = cfgRes.ok ? [...deriveCoreHookBasenames(cfgRes.cfg)].sort() : null;
|
|
118
128
|
const dest = provenancePath(hooksDir);
|
|
119
129
|
const data = {
|
|
120
130
|
pkgRoot,
|
|
121
131
|
pkgVersion,
|
|
122
132
|
hypoSharedSha256,
|
|
123
133
|
...(hooksDigest ? { [HOOKS_DIGEST_FIELD]: hooksDigest } : {}),
|
|
134
|
+
...(managedFiles ? { managedFiles } : {}),
|
|
124
135
|
copiedAt: new Date().toISOString(),
|
|
125
136
|
};
|
|
126
137
|
if (!dryRun) atomicWriteJson(dest, data);
|
|
@@ -135,8 +146,14 @@ export function writeProvenanceSidecar(hooksDir, pkgRoot, pkgVersion, hooksSrcDi
|
|
|
135
146
|
// filesystem boundary would fall back to a non-atomic copy+delete.
|
|
136
147
|
function atomicWriteJson(dest, data) {
|
|
137
148
|
const tmp = `${dest}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
138
|
-
|
|
149
|
+
// The write is inside the try as well as the rename. A write that fails
|
|
150
|
+
// partway (ENOSPC, EDQUOT) has already created the temp file, and the temp
|
|
151
|
+
// name carries this pid and a fresh random, so nothing ever picks it up
|
|
152
|
+
// again: it would sit next to the sidecar forever. Not switched to the
|
|
153
|
+
// shared hooks/atomic-write.mjs because that one mkdirSync's the parent,
|
|
154
|
+
// which this path deliberately leaves to its caller.
|
|
139
155
|
try {
|
|
156
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
140
157
|
renameSync(tmp, dest);
|
|
141
158
|
} catch (err) {
|
|
142
159
|
try {
|
|
@@ -98,14 +98,20 @@ function readPkgVersionAt(root) {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// A pkgRoot is "usable"
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
101
|
+
// A pkgRoot is "usable" only if it is an ABSOLUTE path to a real package directory
|
|
102
|
+
// whose package.json carries a version. A relative path (e.g. installPath ".")
|
|
103
|
+
// would be resolved against the caller's cwd and break the vault git hook from any
|
|
104
|
+
// other directory; a version-less package.json cannot be attributed a version
|
|
105
|
+
// without lying. This is the WEAK predicate: it says nothing about WHOSE package
|
|
106
|
+
// sits at that path, only that a version can be read from it. That is enough for a
|
|
107
|
+
// diagnostic that reads and reports (doctor.mjs's per-row leaf-drift scan, which
|
|
108
|
+
// deliberately widens rather than narrows — see leafVersionDrift above), but it is
|
|
109
|
+
// NOT enough for a caller that adopts the path as Hypomnema's own durable identity
|
|
110
|
+
// and writes it to a sidecar (hypo-pkg.json) or a vault's pre-commit hook: nothing
|
|
111
|
+
// here stops a foreign package at an absolute, version-bearing path from being
|
|
112
|
+
// adopted as if it were this one. isHypomnemaInstallRoot below is the strong
|
|
113
|
+
// predicate for that case; keep the two in sync; a change to one's shape
|
|
114
|
+
// (absolute + version) almost certainly belongs in the other too.
|
|
109
115
|
export function usablePkgRoot(pkgRoot) {
|
|
110
116
|
return (
|
|
111
117
|
typeof pkgRoot === 'string' &&
|
|
@@ -116,6 +122,26 @@ export function usablePkgRoot(pkgRoot) {
|
|
|
116
122
|
);
|
|
117
123
|
}
|
|
118
124
|
|
|
125
|
+
// The STRONG predicate: usablePkgRoot, AND the package at that path is literally
|
|
126
|
+
// named "hypomnema". A registry or hypo-pkg.json entry can carry any absolute,
|
|
127
|
+
// version-bearing path — a corrupted sidecar, a hand-edited registry row, or (per
|
|
128
|
+
// the commit-time resolver's own `usable()` in git-hooks-dir.mjs) an attacker's
|
|
129
|
+
// project — so a caller that RECORDS a root as durable identity (init.mjs's
|
|
130
|
+
// resolveDurableRoot, selectEntry below) or WRITES it back to disk (upgrade.mjs's
|
|
131
|
+
// dualSkip provenance correction) must check producer identity, not just
|
|
132
|
+
// "a version is readable here". A caller that only READS for display (doctor.mjs's
|
|
133
|
+
// per-row leaf scan) stays on the weak usablePkgRoot: narrowing it would silence a
|
|
134
|
+
// foreign registry row doctor exists to surface, per the "does NOT exclude"
|
|
135
|
+
// rationale on leafVersionDrift above.
|
|
136
|
+
export function isHypomnemaInstallRoot(pkgRoot) {
|
|
137
|
+
if (!usablePkgRoot(pkgRoot)) return false;
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).name === 'hypomnema';
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
119
145
|
// The version-shaped leaf of a plugin cache path (its last path component), or
|
|
120
146
|
// null when that leaf does not look like a version. Claude Code names a plugin
|
|
121
147
|
// cache directory after the release it copied in (`cache/<marketplace>/<name>/
|
|
@@ -185,10 +211,18 @@ export function leafVersionDrift(pkgRoot) {
|
|
|
185
211
|
// #269 reimplemented this exact filter-then-prefer-scope rule at its call site,
|
|
186
212
|
// in the opposite order, which silently swaps rows whenever the user row has no
|
|
187
213
|
// gitCommitSha).
|
|
214
|
+
// isHypomnemaInstallRoot, not the weak usablePkgRoot: this is the row
|
|
215
|
+
// resolveEnabledPluginEntry hands to callers that RECORD it as durable identity
|
|
216
|
+
// (init.mjs's resolveDurableRoot, upgrade.mjs's dualSkip provenance write), so a
|
|
217
|
+
// registry row whose installPath merely resolves a readable version, without
|
|
218
|
+
// being a real Hypomnema package, must not be selected here. codex reproduction
|
|
219
|
+
// (2026-09-11): a registry row's package.json carried no `name` field at all and
|
|
220
|
+
// was still adopted as the durable root under the old, name-blind check.
|
|
188
221
|
function selectEntry(entries, scope) {
|
|
189
222
|
return (
|
|
190
223
|
entries.find(
|
|
191
|
-
(e) =>
|
|
224
|
+
(e) =>
|
|
225
|
+
e && (scope === undefined || e.scope === scope) && isHypomnemaInstallRoot(e.installPath),
|
|
192
226
|
) ?? null
|
|
193
227
|
);
|
|
194
228
|
}
|
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
* 2. copy templates/projects/_template/*.md with token substitution
|
|
11
11
|
* <project-name> → name, <started> → date, <working_dir> → cwd,
|
|
12
12
|
* YYYY-MM-DD → today (frontmatter `updated:` only)
|
|
13
|
-
* 3.
|
|
13
|
+
* 3. regenerate root hot.md's "Active Projects" table via the canonical
|
|
14
|
+
* projection (writeRootHotProjection, hooks/hypo-shared.mjs), which
|
|
15
|
+
* picks up this project's own row from the directory scan step 2 just
|
|
16
|
+
* wrote, never a hand-inserted row. A direct table edit here (the old
|
|
17
|
+
* insertHotRow) left the ownership hash pointing at whatever a
|
|
18
|
+
* session's projection write last produced, so the next SessionStart or
|
|
19
|
+
* Stop read this edit as external and backed it up with a false "손으로
|
|
20
|
+
* 편집한 내용이 있었습니다" alarm on every normal project creation.
|
|
14
21
|
* 4. append a `## [today] project-create | <name>` entry to log.md
|
|
15
22
|
*
|
|
16
23
|
* Idempotent: existing files/rows/entries are preserved, never overwritten or
|
|
@@ -25,6 +32,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from
|
|
|
25
32
|
import { join, dirname, resolve, sep } from 'path';
|
|
26
33
|
import { fileURLToPath } from 'url';
|
|
27
34
|
import { resolveHypoRoot, expandHome } from './hypo-root.mjs';
|
|
35
|
+
import { writeRootHotProjection, rootHotBackupRecoveryNotice } from '../../hooks/hypo-shared.mjs';
|
|
28
36
|
|
|
29
37
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
30
38
|
const PKG_ROOT = join(SCRIPT_DIR, '..', '..');
|
|
@@ -57,35 +65,6 @@ export function substituteTokens(content, { name, started, workingDir, today })
|
|
|
57
65
|
.join(today);
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
/**
|
|
61
|
-
* Insert a project row into the root hot.md "Active Projects" table.
|
|
62
|
-
* Idempotent: returns the original content unchanged if a row already links
|
|
63
|
-
* to `[[projects/<name>/hot]]`. Returns null if the table cannot be located.
|
|
64
|
-
* @returns {string|null} new content, or null when no table marker is found
|
|
65
|
-
*/
|
|
66
|
-
export function insertHotRow(content, name, today) {
|
|
67
|
-
const link = `[[projects/${name}/hot]]`;
|
|
68
|
-
if (content.includes(link)) return content; // already present
|
|
69
|
-
const lines = content.split('\n');
|
|
70
|
-
// Scope the search to the "## Active Projects" section so a table appearing
|
|
71
|
-
// earlier in hot.md can't capture the row. Start looking from the heading;
|
|
72
|
-
// stop at the next H2 so we never cross sections.
|
|
73
|
-
const headingIdx = lines.findIndex((l) => /^##\s+Active Projects\s*$/.test(l));
|
|
74
|
-
if (headingIdx === -1) return null;
|
|
75
|
-
let sepIdx = -1;
|
|
76
|
-
for (let i = headingIdx + 1; i < lines.length; i++) {
|
|
77
|
-
if (/^##\s/.test(lines[i])) break; // next section — table not found in scope
|
|
78
|
-
if (/^\|\s*-{2,}\s*\|/.test(lines[i])) {
|
|
79
|
-
sepIdx = i;
|
|
80
|
-
break;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
if (sepIdx === -1) return null;
|
|
84
|
-
const row = `| ${name} | ${today} | ${link} |`;
|
|
85
|
-
lines.splice(sepIdx + 1, 0, row);
|
|
86
|
-
return lines.join('\n');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
68
|
/**
|
|
90
69
|
* Create a project. Idempotent and best-effort per side effect: a missing root
|
|
91
70
|
* hot.md / log.md is reported in `warnings` rather than thrown, so the core
|
|
@@ -162,21 +141,38 @@ export function createProject(opts) {
|
|
|
162
141
|
created.push(`projects/${name}/${file}`);
|
|
163
142
|
}
|
|
164
143
|
|
|
165
|
-
// root hot.md pointer row
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
144
|
+
// root hot.md pointer row: regenerate via the canonical projection (n2 fix,
|
|
145
|
+
// see this function's own doc comment above) rather than hand-inserting a
|
|
146
|
+
// row. The directory scan already sees this project's own hot.md, written
|
|
147
|
+
// by the TEMPLATE_FILES loop above, so the new row lands in the same pass.
|
|
148
|
+
const hotResult = writeRootHotProjection(hypoDir);
|
|
149
|
+
if (hotResult.scanError) {
|
|
150
|
+
warnings.push('root hot.md: projects/ 디렉터리를 읽지 못해 표를 갱신하지 못했습니다');
|
|
151
|
+
} else if (hotResult.lockTimeout) {
|
|
152
|
+
// Distinct from the plain "already current" skip below: the row was
|
|
153
|
+
// never even attempted this run because another session held the vault
|
|
154
|
+
// lock, not because there was nothing new to write. `warnings` already
|
|
155
|
+
// carries writeRootHotProjection's own lock-busy message; this label is
|
|
156
|
+
// what lets a caller reading only `skipped` tell "not created" apart
|
|
157
|
+
// from "could not check whether it needed creating".
|
|
158
|
+
skipped.push('hot.md row (lock timeout)');
|
|
159
|
+
} else if (hotResult.written) {
|
|
160
|
+
created.push('hot.md row');
|
|
178
161
|
} else {
|
|
179
|
-
|
|
162
|
+
skipped.push('hot.md row');
|
|
163
|
+
}
|
|
164
|
+
if (hotResult.warnings.length > 0) warnings.push(...hotResult.warnings);
|
|
165
|
+
// MAJOR fix (codex 3rd-tier finding 6): a hand-authored root hot.md this
|
|
166
|
+
// scaffold's own writeRootHotProjection call just backed up never reached
|
|
167
|
+
// the user at all here. `backedUp`/`backupPath` were read by
|
|
168
|
+
// SessionStart and the Stop-hook rebuild but silently dropped on this
|
|
169
|
+
// third writer, the one call site with the least excuse for it (`main`'s
|
|
170
|
+
// own CLI output prints every entry in `warnings`). Same shared sentence
|
|
171
|
+
// the other two writers use, so a person who created a project right
|
|
172
|
+
// after hand-editing root hot.md gets the SAME recovery guidance, not a
|
|
173
|
+
// silently vanished edit.
|
|
174
|
+
if (hotResult.backedUp && hotResult.backupPath) {
|
|
175
|
+
warnings.push(rootHotBackupRecoveryNotice(hotResult.backupPath));
|
|
180
176
|
}
|
|
181
177
|
|
|
182
178
|
// log.md entry
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync, readFileSync
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
|
+
import { atomicWrite } from '../../hooks/atomic-write.mjs';
|
|
3
4
|
|
|
4
5
|
const VOCAB_HEADER_RE = /^##\s+(?:\d+\.\s+)?Tag\s+(?:Vocabulary|Taxonomy)\s*$/m;
|
|
5
6
|
const TYPE_TAXONOMY_HEADER_RE = /^##\s+(?:\d+\.\s+)?Page\s+Type\s+Taxonomy\s*$/m;
|
|
@@ -87,18 +88,27 @@ function backtickList(tags) {
|
|
|
87
88
|
// section so a close never stalls on a vocabulary gap and the next lint sees them
|
|
88
89
|
// as known (B-4). Pending lives INSIDE the "## Tag Vocabulary" H2 section because
|
|
89
90
|
// parseSchemaVocab is H2-bounded — placing it elsewhere would not widen the vocab.
|
|
90
|
-
// No-ops (
|
|
91
|
-
// every tag is already in the vocabulary, or when a tag is
|
|
92
|
-
// a forbidden pattern is pointless (lint errors on it
|
|
93
|
-
// would only pollute Pending. Idempotent: re-running
|
|
94
|
-
// nothing (they are already in the parsed vocabulary on
|
|
91
|
+
// No-ops (`{ tags: [], content: null }`) when SCHEMA.md or the Tag Vocabulary
|
|
92
|
+
// header is absent, when every tag is already in the vocabulary, or when a tag is
|
|
93
|
+
// forbidden: registering a forbidden pattern is pointless (lint errors on it
|
|
94
|
+
// before the vocab check) and would only pollute Pending. Idempotent: re-running
|
|
95
|
+
// with the same tags writes nothing (they are already in the parsed vocabulary on
|
|
96
|
+
// the second call).
|
|
97
|
+
//
|
|
98
|
+
// Returns `{ tags, content }`: `tags` is the same registered-tag list this
|
|
99
|
+
// helper always returned, and `content` is the exact bytes just written (or
|
|
100
|
+
// `null` on a no-op): a close-receipt caller needs the written version to
|
|
101
|
+
// hash for its proof entry, and re-reading the file a moment later cannot
|
|
102
|
+
// tell that read apart from a concurrent writer's edit landing in between.
|
|
103
|
+
// Writes via `atomicWrite` (temp + rename), not a bare `writeFileSync`, so a
|
|
104
|
+
// reader can never observe a half-written SCHEMA.md.
|
|
95
105
|
export function appendPendingTags(hypoDir, tags) {
|
|
96
106
|
const path = join(hypoDir, 'SCHEMA.md');
|
|
97
|
-
if (!existsSync(path)) return [];
|
|
107
|
+
if (!existsSync(path)) return { tags: [], content: null };
|
|
98
108
|
const content = readFileSync(path, 'utf-8');
|
|
99
109
|
|
|
100
110
|
const headerMatch = VOCAB_HEADER_RE.exec(content);
|
|
101
|
-
if (!headerMatch) return [];
|
|
111
|
+
if (!headerMatch) return { tags: [], content: null };
|
|
102
112
|
|
|
103
113
|
const current = parseSchemaVocab(hypoDir);
|
|
104
114
|
const seen = new Set();
|
|
@@ -115,7 +125,7 @@ export function appendPendingTags(hypoDir, tags) {
|
|
|
115
125
|
seen.add(t);
|
|
116
126
|
newTags.push(t);
|
|
117
127
|
}
|
|
118
|
-
if (newTags.length === 0) return [];
|
|
128
|
+
if (newTags.length === 0) return { tags: [], content: null };
|
|
119
129
|
|
|
120
130
|
const sectionStart = headerMatch.index + headerMatch[0].length;
|
|
121
131
|
const rest = content.slice(sectionStart);
|
|
@@ -166,8 +176,9 @@ export function appendPendingTags(hypoDir, tags) {
|
|
|
166
176
|
}
|
|
167
177
|
}
|
|
168
178
|
|
|
169
|
-
|
|
170
|
-
|
|
179
|
+
const nextContent = content.slice(0, sectionStart) + newSection + content.slice(sectionEnd);
|
|
180
|
+
atomicWrite(path, nextContent);
|
|
181
|
+
return { tags: newTags, content: nextContent };
|
|
171
182
|
}
|
|
172
183
|
|
|
173
184
|
// Derive the set of valid immediate subdirectories under pages/ from the
|
|
@@ -19,3 +19,51 @@ export function templateSchemaVersion(pkgRoot) {
|
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
// One line per SCHEMA.md version bump, naming what that version added over the
|
|
24
|
+
// one before it. This is upgrade.mjs's only source for "what changed" text
|
|
25
|
+
// when it tells a user their installed SCHEMA.md is behind — without it, the
|
|
26
|
+
// notice can only name the two version numbers, and the two SCHEMA.md copies
|
|
27
|
+
// diverge (translation, local additions) far enough that a raw diff is
|
|
28
|
+
// dominated by noise unrelated to the actual upstream change. Add an entry
|
|
29
|
+
// here whenever templates/SCHEMA.md's `version:` frontmatter bumps; a version
|
|
30
|
+
// missing from this map falls back to the plain "review manually" notice
|
|
31
|
+
// (schemaVersionDeltas below returns nothing for it), so leaving one out is
|
|
32
|
+
// silent, not wrong.
|
|
33
|
+
// Quote any key with a trailing zero. An unquoted `2.10:` is a NUMBER literal
|
|
34
|
+
// and JS normalizes it to the string "2.1", so it would silently overwrite
|
|
35
|
+
// 2.1's line (or hand 2.10's text to somebody upgrading across 2.1). Verified:
|
|
36
|
+
// `Object.keys({2.10: 'x'})` is `['2.1']`. Prettier will not undo the quotes
|
|
37
|
+
// there — stripping them would change meaning, so it leaves "2.10" alone even
|
|
38
|
+
// though it rewrites '2.2' to 2.2. What prettier cannot save you from is
|
|
39
|
+
// writing 2.10 unquoted in the first place, which is why a test reads this
|
|
40
|
+
// file's text and rejects that shape rather than trusting the convention.
|
|
41
|
+
export const SCHEMA_VERSION_DELTAS = {
|
|
42
|
+
2.2: 'documents `sources_consulted` on `type: synthesis` pages (lint W15/W16 read it to flag a synthesis that has fallen behind the pages it condenses)',
|
|
43
|
+
2.3: 'root `hot.md` is now a generated projection of `projects/*/hot.md` that the hooks rebuild at every session start and stop: session close no longer updates it by hand, and the pointer-table section describes the generated row format',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function parseMinorVersion(v) {
|
|
47
|
+
const [major, minor] = String(v).split('.').map(Number);
|
|
48
|
+
return { major, minor: Number.isFinite(minor) ? minor : 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function compareMinorVersions(a, b) {
|
|
52
|
+
return a.major - b.major || a.minor - b.minor;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Returns one line per version strictly after `installed` and up to and
|
|
56
|
+
// including `current`, oldest first. `deltas` defaults to the real map above;
|
|
57
|
+
// tests pass their own to exercise the multi-version stepping logic without
|
|
58
|
+
// depending on how many real bumps have landed.
|
|
59
|
+
export function schemaVersionDeltas(installed, current, deltas = SCHEMA_VERSION_DELTAS) {
|
|
60
|
+
if (!installed || !current) return [];
|
|
61
|
+
const from = parseMinorVersion(installed);
|
|
62
|
+
const to = parseMinorVersion(current);
|
|
63
|
+
if (compareMinorVersions(to, from) <= 0) return [];
|
|
64
|
+
return Object.keys(deltas)
|
|
65
|
+
.map((v) => ({ v, mv: parseMinorVersion(v) }))
|
|
66
|
+
.filter(({ mv }) => compareMinorVersions(mv, from) > 0 && compareMinorVersions(mv, to) <= 0)
|
|
67
|
+
.sort((a, b) => compareMinorVersions(a.mv, b.mv))
|
|
68
|
+
.map(({ v }) => `${v}: ${deltas[v]}`);
|
|
69
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// time.mjs: shared time constants for scripts/. Imports nothing, so a script
|
|
2
|
+
// that only needs a day length does not pull in the modules it would otherwise
|
|
3
|
+
// share a file with (page-usage.mjs loads hooks/hypo-shared.mjs, for one).
|
|
4
|
+
//
|
|
5
|
+
// hooks/ keeps its own copies: hooks may not import scripts/ (they are copied
|
|
6
|
+
// standalone into ~/.claude/hooks/).
|
|
7
|
+
|
|
8
|
+
export const DAY_MS = 86400000;
|
|
9
|
+
|
|
10
|
+
// The strict YYYY-MM-DD parser used by lint.mjs (W15/W16), the design-history
|
|
11
|
+
// heading check, and the close-path payload.date check. `new Date(literal)`
|
|
12
|
+
// accepts more than a format check implies: a calendar-overflow value like
|
|
13
|
+
// `2026-02-30` silently normalizes to March 2 instead of failing, so a bare
|
|
14
|
+
// regex test on the shape (`/^\d{4}-\d{2}-\d{2}$/`) lets it through looking
|
|
15
|
+
// validated. Returns a comparable UTC timestamp for a literal that is exactly
|
|
16
|
+
// YYYY-MM-DD and round-trips to the same calendar date through a UTC Date, or
|
|
17
|
+
// null otherwise (wrong shape or calendar overflow).
|
|
18
|
+
//
|
|
19
|
+
// verify/doctor and the hook date checks (e.g. isOverdueDate in
|
|
20
|
+
// hooks/hypo-shared.mjs) do NOT use this: doctor.mjs and hypo-shared.mjs
|
|
21
|
+
// agree to read the same literal with the same characters and same meaning,
|
|
22
|
+
// and hooks may not import scripts/ (they are copied standalone into
|
|
23
|
+
// ~/.claude/hooks/), so those stayed on the plain format check rather than
|
|
24
|
+
// pull this file's logic across that boundary.
|
|
25
|
+
const STRICT_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
26
|
+
export function parseStrictDate(literal) {
|
|
27
|
+
if (literal == null) return null;
|
|
28
|
+
const m = STRICT_DATE_RE.exec(String(literal));
|
|
29
|
+
if (!m) return null;
|
|
30
|
+
const year = Number(m[1]);
|
|
31
|
+
const month = Number(m[2]);
|
|
32
|
+
const day = Number(m[3]);
|
|
33
|
+
// setUTCFullYear, not Date.UTC: Date.UTC maps years 0-99 onto 1900-1999,
|
|
34
|
+
// so a valid `0001-01-01` would fail the round-trip below and be rejected.
|
|
35
|
+
const check = new Date(0);
|
|
36
|
+
check.setUTCFullYear(year, month - 1, day);
|
|
37
|
+
const ts = check.getTime();
|
|
38
|
+
if (
|
|
39
|
+
check.getUTCFullYear() !== year ||
|
|
40
|
+
check.getUTCMonth() !== month - 1 ||
|
|
41
|
+
check.getUTCDate() !== day
|
|
42
|
+
) {
|
|
43
|
+
return null; // calendar overflow, e.g. month 13 or Feb 30
|
|
44
|
+
}
|
|
45
|
+
return ts;
|
|
46
|
+
}
|