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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +141 -51
  4. package/README.ko.md +18 -17
  5. package/README.md +16 -15
  6. package/commands/crystallize.md +89 -24
  7. package/commands/doctor.md +3 -1
  8. package/docs/ARCHITECTURE.md +45 -14
  9. package/docs/CONTRIBUTING.md +26 -8
  10. package/hooks/atomic-write.mjs +44 -0
  11. package/hooks/base-store.mjs +409 -90
  12. package/hooks/close-gate-store.mjs +131 -16
  13. package/hooks/close-journal.mjs +250 -0
  14. package/hooks/close-receipt.mjs +559 -0
  15. package/hooks/hooks.json +3 -38
  16. package/hooks/hypo-auto-commit.mjs +80 -10
  17. package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
  18. package/hooks/hypo-auto-stage.mjs +13 -1
  19. package/hooks/hypo-close-guard.mjs +2 -1
  20. package/hooks/hypo-cwd-change.mjs +45 -20
  21. package/hooks/hypo-file-watch.mjs +44 -6
  22. package/hooks/hypo-first-prompt.mjs +15 -9
  23. package/hooks/hypo-hot-rebuild.mjs +169 -112
  24. package/hooks/hypo-personal-check.mjs +9 -1
  25. package/hooks/hypo-session-record.mjs +1 -1
  26. package/hooks/hypo-session-start.mjs +566 -20
  27. package/hooks/hypo-shared.mjs +2250 -206
  28. package/hooks/hypo-stop.mjs +207 -0
  29. package/hooks/proposal-store.mjs +201 -35
  30. package/hooks/shared.json +15 -0
  31. package/hooks/version-check.mjs +9 -7
  32. package/package.json +3 -1
  33. package/scripts/crystallize.mjs +3 -4
  34. package/scripts/doctor.mjs +549 -123
  35. package/scripts/init.mjs +86 -109
  36. package/scripts/lib/core-hooks.mjs +48 -22
  37. package/scripts/lib/crystallize-close-apply.mjs +2397 -188
  38. package/scripts/lib/crystallize-close-check.mjs +43 -23
  39. package/scripts/lib/design-history-stale.mjs +36 -9
  40. package/scripts/lib/frontmatter.mjs +39 -0
  41. package/scripts/lib/git-hooks-dir.mjs +427 -52
  42. package/scripts/lib/hook-inventory.mjs +180 -0
  43. package/scripts/lib/page-usage.mjs +1 -1
  44. package/scripts/lib/pkg-provenance.mjs +18 -1
  45. package/scripts/lib/plugin-detect.mjs +43 -9
  46. package/scripts/lib/project-create.mjs +40 -44
  47. package/scripts/lib/schema-vocab.mjs +22 -11
  48. package/scripts/lib/template-schema-version.mjs +48 -0
  49. package/scripts/lib/time.mjs +46 -0
  50. package/scripts/lint.mjs +66 -18
  51. package/scripts/proposal.mjs +423 -18
  52. package/scripts/resume.mjs +3 -2
  53. package/scripts/session-audit.mjs +2 -1
  54. package/scripts/uninstall.mjs +144 -66
  55. package/scripts/upgrade.mjs +523 -234
  56. package/scripts/verify.mjs +2 -1
  57. package/scripts/weekly-report.mjs +2 -1
  58. package/templates/SCHEMA.md +27 -7
  59. package/templates/gitignore +11 -0
  60. package/templates/hypo-automation.md +10 -13
  61. package/templates/hypo-config.md +1 -1
  62. package/templates/hypo-guide.md +53 -25
  63. 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
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
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" as a DURABLE install root only if it is an ABSOLUTE path to
102
- // a real package directory whose package.json carries a version. A relative path
103
- // (e.g. installPath ".") would be resolved against the caller's cwd and break the
104
- // vault git hook from any other directory; a version-less package.json cannot be
105
- // attributed a version without lying. A bare path that merely exists is a pointer
106
- // the runtime cannot resolve scripts through. Shared by init's registry resolution,
107
- // its durable-root fallback, and upgrade's dualSkip provenance correction so they
108
- // all agree on what is real.
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) => e && (scope === undefined || e.scope === scope) && usablePkgRoot(e.installPath),
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. append a row to root hot.md "Active Projects" table
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
- const hotPath = join(hypoDir, 'hot.md');
167
- if (existsSync(hotPath)) {
168
- const orig = readFileSync(hotPath, 'utf-8');
169
- const next = insertHotRow(orig, name, today);
170
- if (next === null) {
171
- warnings.push('root hot.md: no Active Projects table found — add row manually');
172
- } else if (next !== orig) {
173
- writeFileSync(hotPath, next);
174
- created.push('hot.md row');
175
- } else {
176
- skipped.push('hot.md row');
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
- warnings.push('root hot.md missing — skipped pointer row');
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, writeFileSync } from 'fs';
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 (returns []) when SCHEMA.md or the Tag Vocabulary header is absent, when
91
- // every tag is already in the vocabulary, or when a tag is forbidden — registering
92
- // a forbidden pattern is pointless (lint errors on it before the vocab check) and
93
- // would only pollute Pending. Idempotent: re-running with the same tags writes
94
- // nothing (they are already in the parsed vocabulary on the second call).
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
- writeFileSync(path, content.slice(0, sectionStart) + newSection + content.slice(sectionEnd));
170
- return newTags;
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
+ }