hypomnema 1.2.1 → 1.3.1

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 (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +4 -2
  4. package/README.md +4 -2
  5. package/commands/crystallize.md +23 -6
  6. package/commands/feedback.md +1 -1
  7. package/commands/upgrade.md +2 -0
  8. package/docs/CONTRIBUTING.md +96 -11
  9. package/hooks/hypo-auto-commit.mjs +3 -3
  10. package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
  11. package/hooks/hypo-cwd-change.mjs +2 -2
  12. package/hooks/hypo-first-prompt.mjs +1 -1
  13. package/hooks/hypo-personal-check.mjs +57 -7
  14. package/hooks/hypo-session-start.mjs +73 -19
  15. package/hooks/hypo-shared.mjs +206 -16
  16. package/hooks/version-check.mjs +204 -6
  17. package/package.json +5 -2
  18. package/scripts/bump-version.mjs +9 -3
  19. package/scripts/check-bilingual.mjs +115 -0
  20. package/scripts/crystallize.mjs +130 -16
  21. package/scripts/doctor.mjs +45 -9
  22. package/scripts/feedback-sync.mjs +44 -15
  23. package/scripts/feedback.mjs +5 -5
  24. package/scripts/fix-status-verify.mjs +256 -0
  25. package/scripts/init.mjs +45 -4
  26. package/scripts/install-git-hooks.mjs +258 -0
  27. package/scripts/lib/adr-corpus.mjs +79 -0
  28. package/scripts/lib/check-bilingual.mjs +141 -0
  29. package/scripts/lib/extensions.mjs +3 -3
  30. package/scripts/lib/feedback-scope.mjs +21 -0
  31. package/scripts/lib/fix-manifest.mjs +109 -0
  32. package/scripts/lib/fix-status-verify.mjs +438 -0
  33. package/scripts/lib/plugin-detect.mjs +51 -0
  34. package/scripts/lib/pre-commit-format.mjs +251 -0
  35. package/scripts/lib/project-create.mjs +2 -2
  36. package/scripts/lint.mjs +48 -8
  37. package/scripts/pre-commit-format.mjs +198 -0
  38. package/scripts/resume.mjs +61 -3
  39. package/scripts/smoke-pack.mjs +39 -2
  40. package/scripts/upgrade.mjs +308 -58
  41. package/skills/crystallize/SKILL.md +13 -2
  42. package/templates/hypo-config.md +1 -1
  43. package/templates/hypo-guide.md +4 -0
@@ -0,0 +1,251 @@
1
+ /**
2
+ * lib/pre-commit-format.mjs — pure logic for the auto-format-on-commit hook.
3
+ *
4
+ * Rule source: CLAUDE.md <formatting> directive. Pre-commit hook auto-runs the
5
+ * project formatter on STAGED files only. Formatter failure is non-blocking;
6
+ * only `git add` failure on restage is a true commit block.
7
+ *
8
+ * Why pure: lets tests construct synthetic staged sets without touching real
9
+ * git or invoking prettier. The CLI shim in scripts/pre-commit-format.mjs
10
+ * handles env resolution / repo-identity guards / process exit codes.
11
+ *
12
+ * Env discipline: every `git` spawn here MUST receive a caller-supplied `env`.
13
+ * The lib never reads `process.env` for git operations — that defence is what
14
+ * blocks GIT_DIR / GIT_WORK_TREE override attacks (see CONTRIBUTING.md).
15
+ */
16
+
17
+ import { spawnSync } from 'node:child_process';
18
+ import { existsSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ const STATUS_FILTER = 'ACMR';
22
+
23
+ /**
24
+ * Parse the NUL-token stream emitted by `git diff --cached --name-status -z`.
25
+ *
26
+ * Token shape (verified live against git 2.50): records are NUL-separated.
27
+ * A\0path\0 (added)
28
+ * M\0path\0 (modified)
29
+ * D\0path\0 (deleted — filtered out by --diff-filter)
30
+ * T\0path\0 (type change — filtered out)
31
+ * R<score>\0old\0new\0 (rename)
32
+ * C<score>\0old\0new\0 (copy)
33
+ *
34
+ * Paths containing TAB are valid — TAB is not a separator here (it appears in
35
+ * the non-`-z` output, never in `-z`). Only NUL separates records.
36
+ *
37
+ * @param {string} buf Raw stdout from `git diff --cached --name-status -z`.
38
+ * @returns {Array<{path: string, status: string}>}
39
+ */
40
+ export function parseNameStatus(buf) {
41
+ const tokens = buf.split('\0');
42
+ // Trailing NUL leaves an empty token; drop it (and any stray empties).
43
+ while (tokens.length && tokens[tokens.length - 1] === '') tokens.pop();
44
+ const out = [];
45
+ for (let i = 0; i < tokens.length; ) {
46
+ const status = tokens[i++];
47
+ if (!status) continue;
48
+ const head = status[0];
49
+ if (head === 'R' || head === 'C') {
50
+ // Two-path record. Old is irrelevant for formatting — only the new path
51
+ // exists in the staged tree.
52
+ i++; // consume old
53
+ const next = tokens[i++];
54
+ if (next) out.push({ path: next, status: head });
55
+ } else if (head === 'A' || head === 'M') {
56
+ const p = tokens[i++];
57
+ if (p) out.push({ path: p, status: head });
58
+ } else {
59
+ // D, T, U, X — consume one path, drop. --diff-filter should exclude
60
+ // these but we defensively skip.
61
+ i++;
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Parse `git ls-files --stage -z` output to map paths → file mode strings.
69
+ * Output shape: `<mode> <hash> <stage>\t<path>\0`
70
+ */
71
+ export function parseLsFilesStage(buf) {
72
+ const map = new Map();
73
+ const records = buf.split('\0');
74
+ for (const rec of records) {
75
+ if (!rec) continue;
76
+ const tabIdx = rec.indexOf('\t');
77
+ if (tabIdx < 0) continue;
78
+ const meta = rec.slice(0, tabIdx);
79
+ const path = rec.slice(tabIdx + 1);
80
+ const mode = meta.split(' ')[0];
81
+ map.set(path, mode);
82
+ }
83
+ return map;
84
+ }
85
+
86
+ /**
87
+ * Drop symlinks (120000) and gitlinks/submodules (160000). Regular file modes
88
+ * (100644, 100755) are kept.
89
+ */
90
+ export function filterRegularFiles(entries, modeMap) {
91
+ return entries.filter((e) => {
92
+ const m = modeMap.get(e.path);
93
+ if (!m) return false; // not in index — defensively skip
94
+ return m !== '120000' && m !== '160000';
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Partition staged paths into safe vs partial (also has unstaged hunks).
100
+ * Partial files are skipped to avoid swallowing unstaged work.
101
+ */
102
+ export function partitionStagedFiles(entries, unstagedDirty) {
103
+ const safe = [];
104
+ const partial = [];
105
+ for (const e of entries) {
106
+ if (unstagedDirty.has(e.path)) partial.push(e);
107
+ else safe.push(e);
108
+ }
109
+ return { safe, partial };
110
+ }
111
+
112
+ /**
113
+ * Formatter dispatch table. Other entries (eslint, black, gofmt, cargo fmt)
114
+ * are placeholders — the table is data, not a branch tree. Activate by
115
+ * filling in an entry similar to `prettier`.
116
+ *
117
+ * Critically: NEVER use `npx` here. `npx prettier` may try a network install
118
+ * on a cold machine; we want a local-only binary or no-op.
119
+ */
120
+ export function selectFormatter(repoRoot) {
121
+ const prettierBin = join(repoRoot, 'node_modules', '.bin', 'prettier');
122
+ if (existsSync(prettierBin)) {
123
+ return {
124
+ name: 'prettier',
125
+ bin: prettierBin,
126
+ buildArgs: (files) => ['--write', '--', ...files],
127
+ };
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Run the formatter once with all safe files. Captures exit status; never
134
+ * throws.
135
+ */
136
+ export function formatFiles(safe, formatter, { env, cwd } = {}) {
137
+ if (!safe.length || !formatter) {
138
+ return { ran: false, formatterFailed: false, reason: 'noop' };
139
+ }
140
+ const paths = safe.map((e) => e.path);
141
+ const res = spawnSync(formatter.bin, formatter.buildArgs(paths), {
142
+ cwd,
143
+ env,
144
+ encoding: 'utf-8',
145
+ stdio: ['ignore', 'pipe', 'pipe'],
146
+ });
147
+ return {
148
+ ran: true,
149
+ formatterFailed: res.status !== 0,
150
+ exitCode: res.status,
151
+ stdout: res.stdout || '',
152
+ stderr: res.stderr || '',
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Re-stage the formatted files. Returns `{gitAddFailed: bool, stderr}`.
158
+ * Prettier `--write` only writes on actual content change, so re-adding
159
+ * unchanged files is a cheap no-op. Doing it unconditionally avoids a
160
+ * before/after hash comparison.
161
+ */
162
+ export function restageFormatted(files, { env, cwd } = {}) {
163
+ if (!files.length) return { gitAddFailed: false };
164
+ const res = spawnSync('git', ['add', '--', ...files], {
165
+ cwd,
166
+ env,
167
+ encoding: 'utf-8',
168
+ stdio: ['ignore', 'pipe', 'pipe'],
169
+ });
170
+ return {
171
+ gitAddFailed: res.status !== 0,
172
+ stderr: res.stderr || '',
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Top-level orchestrator used by the CLI shim. The caller supplies `cwd`
178
+ * (the Hypomnema toplevel) and a sanitized `env` (no inherited `GIT_*`
179
+ * except optionally a validated `GIT_INDEX_FILE`).
180
+ *
181
+ * @returns {{gitAddFailed: boolean, summary: string}}
182
+ */
183
+ export async function runPreCommitFormat({ cwd, env }) {
184
+ const summary = [];
185
+ const stagedRes = spawnSync(
186
+ 'git',
187
+ ['diff', '--cached', '--name-status', '-z', `--diff-filter=${STATUS_FILTER}`, '--'],
188
+ { cwd, env, encoding: 'utf-8' },
189
+ );
190
+ if (stagedRes.status !== 0) {
191
+ return { gitAddFailed: false, summary: 'git diff --cached failed; skipping' };
192
+ }
193
+ const staged = parseNameStatus(stagedRes.stdout || '');
194
+ if (!staged.length) return { gitAddFailed: false, summary: 'no staged files' };
195
+
196
+ // Filter out symlinks / submodules.
197
+ const lsRes = spawnSync(
198
+ 'git',
199
+ ['ls-files', '--stage', '-z', '--', ...staged.map((e) => e.path)],
200
+ { cwd, env, encoding: 'utf-8' },
201
+ );
202
+ let regular = staged;
203
+ if (lsRes.status === 0) {
204
+ const modeMap = parseLsFilesStage(lsRes.stdout || '');
205
+ regular = filterRegularFiles(staged, modeMap);
206
+ }
207
+ if (!regular.length) return { gitAddFailed: false, summary: 'no regular staged files' };
208
+
209
+ // Unstaged-dirty set for partition.
210
+ const unstRes = spawnSync('git', ['diff', '--name-only', '-z', '--'], {
211
+ cwd,
212
+ env,
213
+ encoding: 'utf-8',
214
+ });
215
+ const unstaged = new Set();
216
+ if (unstRes.status === 0) {
217
+ for (const p of (unstRes.stdout || '').split('\0')) {
218
+ if (p) unstaged.add(p);
219
+ }
220
+ }
221
+ const { safe, partial } = partitionStagedFiles(regular, unstaged);
222
+ if (partial.length) {
223
+ summary.push(`skipped ${partial.length} partially-staged file(s)`);
224
+ }
225
+ if (!safe.length) return { gitAddFailed: false, summary: summary.join('; ') || 'no safe files' };
226
+
227
+ const formatter = selectFormatter(cwd);
228
+ if (!formatter) {
229
+ return {
230
+ gitAddFailed: false,
231
+ summary: [...summary, 'no formatter (node_modules/.bin/prettier missing)'].join('; '),
232
+ };
233
+ }
234
+ const fmt = formatFiles(safe, formatter, { env, cwd });
235
+ if (fmt.formatterFailed) {
236
+ summary.push(`${formatter.name} exit ${fmt.exitCode} (non-blocking)`);
237
+ return { gitAddFailed: false, summary: summary.join('; ') };
238
+ }
239
+ const restage = restageFormatted(
240
+ safe.map((e) => e.path),
241
+ { env, cwd },
242
+ );
243
+ if (restage.gitAddFailed) {
244
+ return {
245
+ gitAddFailed: true,
246
+ summary: `git add failed: ${restage.stderr.trim()}`,
247
+ };
248
+ }
249
+ summary.push(`formatted ${safe.length} file(s) via ${formatter.name}`);
250
+ return { gitAddFailed: false, summary: summary.join('; ') };
251
+ }
@@ -64,8 +64,8 @@ export function insertHotRow(content, name, today) {
64
64
  if (content.includes(link)) return content; // already present
65
65
  const lines = content.split('\n');
66
66
  // Scope the search to the "## Active Projects" section so a table appearing
67
- // earlier in hot.md can't capture the row (codex review 2026-05-22). Start
68
- // looking from the heading; stop at the next H2 so we never cross sections.
67
+ // earlier in hot.md can't capture the row. Start looking from the heading;
68
+ // stop at the next H2 so we never cross sections.
69
69
  const headingIdx = lines.findIndex((l) => /^##\s+Active Projects\s*$/.test(l));
70
70
  if (headingIdx === -1) return null;
71
71
  let sepIdx = -1;
package/scripts/lint.mjs CHANGED
@@ -11,6 +11,9 @@
11
11
  * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
12
12
  * --json Output results as JSON
13
13
  * --fix Auto-add missing `updated` field (safe repairs only)
14
+ * --strict Promote selected warnings (STRICT_PROMOTE_IDS) to errors
15
+ * so they exit 1. Opt-in gate for release-checklist /
16
+ * pre-commit; default mode stays byte-identical.
14
17
  */
15
18
 
16
19
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
@@ -20,15 +23,17 @@ import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
20
23
  import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
21
24
  import { parseSchemaVocab, checkForbidden, parseSchemaPageDirs } from './lib/schema-vocab.mjs';
22
25
  import { findDesignHistoryStale } from './lib/design-history-stale.mjs';
26
+ import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
23
27
 
24
28
  // ── arg parsing ──────────────────────────────────────────────────────────────
25
29
 
26
30
  function parseArgs(argv) {
27
- const args = { hypoDir: null, json: false, fix: false };
31
+ const args = { hypoDir: null, json: false, fix: false, strict: false };
28
32
  for (const arg of argv.slice(2)) {
29
33
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
30
34
  else if (arg === '--json') args.json = true;
31
35
  else if (arg === '--fix') args.fix = true;
36
+ else if (arg === '--strict') args.strict = true;
32
37
  }
33
38
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
34
39
  return args;
@@ -198,6 +203,20 @@ const VALID_TYPES = [
198
203
 
199
204
  const issues = [];
200
205
 
206
+ // Stable warning class IDs (W1..Wn). `--strict` promotes a frozen subset to
207
+ // errors by ID — never by brittle message-text matching. W8 (design-history
208
+ // stale) predates this scheme; hooks/hypo-personal-check.mjs filters `w.id ===
209
+ // 'W8'`, so it must keep that number — the W5..W7 gap is honest history, not a
210
+ // bug. (spec-v1.3.0 Track E)
211
+ //
212
+ // STRICT_PROMOTE_IDS (OQ-E1, frozen as a code constant): confirmed content
213
+ // defects only.
214
+ // W1 no-frontmatter / W2 unknown-type / W4 broken-wikilink → promote.
215
+ // W3 missing-updated → excluded (auto-repaired by --fix).
216
+ // W8 design-history-stale → excluded (hypo-personal-check handles it; would
217
+ // double-gate).
218
+ const STRICT_PROMOTE_IDS = new Set(['W1', 'W2', 'W4']);
219
+
201
220
  function issue(severity, rel, msg, fullPath = null, id = null) {
202
221
  issues.push({ severity, file: rel, message: msg, path: fullPath, id });
203
222
  }
@@ -249,7 +268,7 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
249
268
  }
250
269
 
251
270
  if (!content.match(/^---\r?\n/)) {
252
- issue('warn', rel, 'No frontmatter found');
271
+ issue('warn', rel, 'No frontmatter found', null, 'W1');
253
272
  return;
254
273
  }
255
274
 
@@ -264,11 +283,11 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
264
283
  }
265
284
 
266
285
  if (fm.type && !VALID_TYPES.includes(fm.type)) {
267
- issue('warn', rel, `Unknown type: "${fm.type}"`);
286
+ issue('warn', rel, `Unknown type: "${fm.type}"`, null, 'W2');
268
287
  }
269
288
 
270
289
  if (!fm.updated) {
271
- issue('warn', rel, 'Missing frontmatter field: updated', path);
290
+ issue('warn', rel, 'Missing frontmatter field: updated', path, 'W3');
272
291
  }
273
292
 
274
293
  // type-conditional required fields
@@ -296,8 +315,12 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
296
315
  // feedback: scope vocabulary + conditional claude-learned fields (ADR 0031)
297
316
  if (fm.type === 'feedback') {
298
317
  const scope = fm.scope || '';
299
- if (scope && scope !== 'global' && !/^project:[a-z0-9][a-z0-9-]*$/.test(scope)) {
300
- issue('error', rel, `Invalid feedback scope: "${scope}" (allowed: global | project:<slug>)`);
318
+ if (scope && !FEEDBACK_SCOPE_RE.test(scope)) {
319
+ issue(
320
+ 'error',
321
+ rel,
322
+ `Invalid feedback scope: "${scope}" (allowed: global | project:<project-id>)`,
323
+ );
301
324
  }
302
325
  const fbTargets = parseTagsField(fm.targets) || [];
303
326
  if (fbTargets.includes('claude-learned')) {
@@ -332,7 +355,7 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
332
355
 
333
356
  for (const link of extractWikilinks(content)) {
334
357
  if (!slugMap.has(link)) {
335
- issue('warn', rel, `Broken wikilink: [[${link}]]`);
358
+ issue('warn', rel, `Broken wikilink: [[${link}]]`, null, 'W4');
336
359
  }
337
360
  }
338
361
  }
@@ -404,12 +427,29 @@ if (args.fix) {
404
427
  }
405
428
  }
406
429
 
430
+ // --strict: promote selected warnings (by stable ID) to errors *before* the
431
+ // errors/warns split, so exit code, counts, plain-text icons, and --json `ok`
432
+ // all derive from the post-promotion severities through the existing paths.
433
+ if (args.strict) {
434
+ for (const iss of issues) {
435
+ if (iss.severity === 'warn' && iss.id && STRICT_PROMOTE_IDS.has(iss.id)) {
436
+ iss.severity = 'error';
437
+ }
438
+ }
439
+ }
440
+
407
441
  const errors = issues.filter((i) => i.severity === 'error');
408
442
  const warns = issues.filter((i) => i.severity === 'warn');
409
443
 
410
444
  if (args.json) {
445
+ // Default mode is byte-identical: only W8 carries an `id` in the JSON payload
446
+ // (hooks/hypo-personal-check.mjs filters on it). All other IDs stay internal
447
+ // unless `--strict` is set, where the full ID set is exposed so promoted
448
+ // findings are traceable to their warning class.
411
449
  const toOut = ({ severity, file, message, id }) =>
412
- id ? { severity, file, message, id } : { severity, file, message };
450
+ id && (id === 'W8' || args.strict)
451
+ ? { severity, file, message, id }
452
+ : { severity, file, message };
413
453
  console.log(
414
454
  JSON.stringify(
415
455
  {
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/pre-commit-format.mjs — Node-side entry for the pre-commit hook.
4
+ *
5
+ * Invoked by the shell shim installed at <git-dir>/hooks/pre-commit. The shim
6
+ * already verifies HYPOMNEMA_ROOT + HYPOMNEMA_GIT_DIR; this script is the
7
+ * second layer of identity defence and the only place that may exit nonzero
8
+ * (only when `git add` fails on restage).
9
+ *
10
+ * Env discipline:
11
+ * - probes git twice with ambient env to learn what Git thinks the repo is
12
+ * - builds `cleanEnv` by stripping every name from `git rev-parse --local-env-vars`
13
+ * plus GIT_NAMESPACE / GIT_CEILING_DIRECTORIES / GIT_CONFIG_* (belt-and-suspenders)
14
+ * - validates inherited GIT_INDEX_FILE: preserve if inside HYPOMNEMA_GIT_DIR
15
+ * (Git exports this for commit -am / commit -- path / commit --amend);
16
+ * otherwise refuse — that's a foreign-index attack vector
17
+ * - lib spawns get `cleanEnv` only; lib never touches process.env directly
18
+ *
19
+ * Why dynamic import: keeping the lib import behind every guard makes the
20
+ * fail-open story water-tight. A static import at file head would throw at
21
+ * module load if the lib were missing or syntactically broken, before any
22
+ * identity check could exit 0.
23
+ *
24
+ * Why pathToFileURL for the import: absolute filesystem paths fed to
25
+ * `import()` break when the checkout path contains URL-significant characters
26
+ * (#, %). pathToFileURL is the canonical Node way to bridge.
27
+ */
28
+
29
+ try {
30
+ const { execFileSync } = await import('node:child_process');
31
+ const fs = await import('node:fs');
32
+ const path = await import('node:path');
33
+ const { pathToFileURL, fileURLToPath } = await import('node:url');
34
+
35
+ const probe = (args, env) => execFileSync('git', args, { encoding: 'utf8', env }).trim();
36
+
37
+ // (0) Derive expectedRoot from THIS script's filesystem location. The shell
38
+ // shim already verifies HYPOMNEMA_ROOT/HYPOMNEMA_GIT_DIR before exec'ing
39
+ // us, but a direct `node scripts/pre-commit-format.mjs` invocation with
40
+ // hostile GIT_DIR/GIT_WORK_TREE pointing at another repo that ALSO calls
41
+ // itself "hypomnema" would bypass the package.json identity check unless
42
+ // we anchor on the script's own location. import.meta.url cannot be
43
+ // redirected by ambient env.
44
+ let expectedRoot;
45
+ try {
46
+ const here = path.dirname(fileURLToPath(import.meta.url));
47
+ expectedRoot = fs.realpathSync(path.resolve(here, '..'));
48
+ } catch {
49
+ process.exit(0);
50
+ }
51
+
52
+ // (1) Probe with ambient env to learn what Git thinks the repo is.
53
+ let toplevel, absGitDir, commonDir;
54
+ try {
55
+ toplevel = probe(['rev-parse', '--show-toplevel'], process.env);
56
+ absGitDir = probe(['rev-parse', '--absolute-git-dir'], process.env);
57
+ commonDir = probe(['rev-parse', '--git-common-dir'], process.env);
58
+ } catch {
59
+ process.exit(0);
60
+ }
61
+
62
+ // (2) Realpath-resolve for stable comparison.
63
+ let absGitDirR, commonDirR, toplevelR;
64
+ try {
65
+ absGitDirR = fs.realpathSync(absGitDir);
66
+ const cdAbs = path.isAbsolute(commonDir) ? commonDir : path.join(absGitDir, '..', commonDir);
67
+ commonDirR = fs.realpathSync(cdAbs);
68
+ toplevelR = fs.realpathSync(toplevel);
69
+ } catch {
70
+ process.exit(0);
71
+ }
72
+
73
+ // (3) Trust anchor — refuse to run against any toplevel other than this
74
+ // script's own checkout. Closes the GIT_DIR/GIT_WORK_TREE attack where a
75
+ // foreign hypomnema-named repo would otherwise pass the package.json check.
76
+ if (toplevelR !== expectedRoot) process.exit(0);
77
+
78
+ // (3a) Anchor the git dir to the expected location too. Without this, a
79
+ // mixed-env attack — GIT_DIR=/foreign/.git + GIT_WORK_TREE=expectedRoot
80
+ // + GIT_INDEX_FILE=/foreign/.git/index — would let `absGitDirR` point at
81
+ // the foreign repo while `--show-toplevel` reports expectedRoot. The
82
+ // subsequent GIT_INDEX_FILE check would then pass relative to the
83
+ // foreign git dir, and the lib would operate on a foreign index while
84
+ // mutating real files. (Live-verified by codex round 7.)
85
+ let expectedGitDirR;
86
+ try {
87
+ expectedGitDirR = fs.realpathSync(path.join(expectedRoot, '.git'));
88
+ } catch {
89
+ process.exit(0);
90
+ }
91
+ if (absGitDirR !== expectedGitDirR) process.exit(0);
92
+
93
+ // (4) Linked worktree → main-worktree only (documented limitation).
94
+ if (absGitDirR !== commonDirR) process.exit(0);
95
+
96
+ // (5) Repo identity check — package.json name must be "hypomnema" (defence in
97
+ // depth alongside the expectedRoot anchor above).
98
+ const pkgPath = path.join(toplevelR, 'package.json');
99
+ if (!fs.existsSync(pkgPath)) process.exit(0);
100
+ let pkg;
101
+ try {
102
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
103
+ } catch {
104
+ process.exit(0);
105
+ }
106
+ if (pkg?.name !== 'hypomnema') process.exit(0);
107
+
108
+ // (5) Build trusted env from --local-env-vars + GIT_CONFIG_* belt.
109
+ let localEnvList;
110
+ try {
111
+ localEnvList = probe(['rev-parse', '--local-env-vars'], process.env)
112
+ .split(/\r?\n/)
113
+ .filter(Boolean);
114
+ } catch {
115
+ // Static fallback (Git versions where --local-env-vars is unavailable).
116
+ localEnvList = [
117
+ 'GIT_DIR',
118
+ 'GIT_WORK_TREE',
119
+ 'GIT_INDEX_FILE',
120
+ 'GIT_OBJECT_DIRECTORY',
121
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
122
+ 'GIT_COMMON_DIR',
123
+ 'GIT_CONFIG',
124
+ 'GIT_CONFIG_PARAMETERS',
125
+ 'GIT_PREFIX',
126
+ 'GIT_IMPLICIT_WORK_TREE',
127
+ 'GIT_GRAFT_FILE',
128
+ 'GIT_NO_REPLACE_OBJECTS',
129
+ 'GIT_REPLACE_REF_BASE',
130
+ 'GIT_SHALLOW_FILE',
131
+ ];
132
+ }
133
+ const scrub = new Set([
134
+ ...localEnvList,
135
+ 'GIT_NAMESPACE',
136
+ 'GIT_CEILING_DIRECTORIES',
137
+ ...Object.keys(process.env).filter((k) => /^GIT_CONFIG_/.test(k)),
138
+ ]);
139
+ const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !scrub.has(k)));
140
+
141
+ // (6) GIT_INDEX_FILE preservation gated on shim invocation. Git legitimately
142
+ // exports this for these commit shapes (verified live by codex round 5/9
143
+ // on Git 2.50.1):
144
+ // - .git/index normal commit, commit --amend, merge commit
145
+ // - .git/index.lock commit -am, commit -p, commit --interactive
146
+ // - .git/next-index-*.lock commit -- <pathspec>, rebase partial commit
147
+ // The basename whitelist used in v8/v9 had a residual gap: a crafted
148
+ // .git/next-index-attack.lock matches the `next-index-*` prefix even
149
+ // though Git itself uses `next-index-<pid>.lock` (codex round 9 live
150
+ // replay). Closing that prefix gap by tightening pattern just invites
151
+ // more attacker iteration.
152
+ //
153
+ // The cleaner defence: only honour inherited GIT_INDEX_FILE when we
154
+ // were invoked from our own trusted shell shim (HYPOMNEMA_HOOK_INVOCATION
155
+ // sentinel set there). Direct invocation — which is the only path an
156
+ // attacker can use to plant a crafted index — drops the inherited value
157
+ // and lets git fall back to the default `.git/index`, i.e. the real
158
+ // staged set. The hook then either has nothing to do (no real stage) or
159
+ // formats the real stage (correct behaviour). An attacker that can also
160
+ // set HYPOMNEMA_HOOK_INVOCATION already has full env control and can
161
+ // mutate files directly without going through the hook gadget.
162
+ const fromShim = process.env.HYPOMNEMA_HOOK_INVOCATION === '1';
163
+ if (fromShim && process.env.GIT_INDEX_FILE) {
164
+ // Even when trusted, sanity-check that the path lives inside our git dir.
165
+ // Belt-and-suspenders against shim invocation with an inherited but
166
+ // misdirected GIT_INDEX_FILE (e.g. by a wrapper that exec'd our shim).
167
+ const inherited = process.env.GIT_INDEX_FILE;
168
+ const lexical = path.isAbsolute(inherited) ? inherited : path.join(toplevelR, inherited);
169
+ let absIdx;
170
+ try {
171
+ absIdx = fs.realpathSync(lexical);
172
+ } catch {
173
+ absIdx = path.resolve(lexical);
174
+ }
175
+ if (path.dirname(absIdx) === absGitDirR) {
176
+ cleanEnv.GIT_INDEX_FILE = inherited;
177
+ }
178
+ }
179
+ // If !fromShim, GIT_INDEX_FILE stays scrubbed → lib uses default .git/index.
180
+
181
+ // (7) Dynamic-import the lib through a file:// URL so checkout paths with
182
+ // URL-significant chars (#, %) don't break ESM resolution. We import
183
+ // from expectedRoot, not toplevel — the script's own location is the
184
+ // anchor of trust.
185
+ const libPath = path.join(expectedRoot, 'scripts/lib/pre-commit-format.mjs');
186
+ let lib;
187
+ try {
188
+ lib = await import(pathToFileURL(libPath).href);
189
+ } catch {
190
+ process.exit(0);
191
+ }
192
+
193
+ const result = await lib.runPreCommitFormat({ cwd: toplevelR, env: cleanEnv });
194
+ if (result.summary) process.stderr.write(`[pre-commit-format] ${result.summary}\n`);
195
+ process.exit(result.gitAddFailed ? 1 : 0);
196
+ } catch {
197
+ process.exit(0);
198
+ }
@@ -9,13 +9,22 @@
9
9
  * node scripts/resume.mjs [options]
10
10
  *
11
11
  * Options:
12
- * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
12
+ * --hypo-dir=<path> Hypomnema root. When omitted, resolveHypoRoot()
13
+ * (see lib/hypo-root.mjs) resolves it in priority order:
14
+ * 1. $HYPO_DIR if set — returned immediately; the
15
+ * hypo-config.md scan below is then skipped.
16
+ * 2. else the first of 7 fixed candidates
17
+ * (~/{hypomnema,wiki,notes,knowledge},
18
+ * ~/Documents/{hypomnema,wiki,notes}) that contains
19
+ * a hypo-config.md marker.
20
+ * 3. else the default ~/hypomnema.
13
21
  * --project=<name> Project name (default: most recently active from hot.md)
14
22
  * --json Output as JSON
15
23
  */
16
24
 
17
25
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
18
26
  import { join } from 'path';
27
+ import { homedir } from 'os';
19
28
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
20
29
 
21
30
  // ── arg parsing ──────────────────────────────────────────────────────────────
@@ -33,7 +42,43 @@ function parseArgs(argv) {
33
42
 
34
43
  // ── active project from hot.md ───────────────────────────────────────────────
35
44
 
36
- function resolveActiveProject(hypoDir) {
45
+ // Parse a single frontmatter scalar (mirrors the hook helpers in
46
+ // hypo-session-start.mjs / hypo-cwd-change.mjs — kept local per the hook
47
+ // self-contained convention rather than shared, to avoid script↔hook coupling).
48
+ function parseFrontmatterField(content, key) {
49
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
50
+ if (!m) return null;
51
+ const line = m[1].split('\n').find((l) => l.startsWith(`${key}:`));
52
+ if (!line) return null;
53
+ return line
54
+ .slice(key.length + 1)
55
+ .trim()
56
+ .replace(/^['"]|['"]$/g, '');
57
+ }
58
+
59
+ // Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
60
+ // is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
61
+ // when cwd is falsy or matches none. Used only as a same-date tie-breaker.
62
+ function pickByCwd(hypoDir, slugs, cwd) {
63
+ if (!cwd) return null;
64
+ let best = null;
65
+ let bestLen = -1;
66
+ for (const slug of slugs) {
67
+ const indexPath = join(hypoDir, 'projects', slug, 'index.md');
68
+ if (!existsSync(indexPath)) continue;
69
+ const wd = parseFrontmatterField(readFileSync(indexPath, 'utf-8'), 'working_dir');
70
+ if (!wd) continue;
71
+ let resolved = wd.startsWith('~/') ? join(homedir(), wd.slice(2)) : wd;
72
+ resolved = resolved.replace(/\/+$/, ''); // trailing-slash normalize
73
+ if ((cwd === resolved || cwd.startsWith(resolved + '/')) && resolved.length > bestLen) {
74
+ bestLen = resolved.length;
75
+ best = slug;
76
+ }
77
+ }
78
+ return best;
79
+ }
80
+
81
+ function resolveActiveProject(hypoDir, cwd = null) {
37
82
  const hotPath = join(hypoDir, 'hot.md');
38
83
  if (!existsSync(hotPath)) return null;
39
84
 
@@ -49,6 +94,19 @@ function resolveActiveProject(hypoDir) {
49
94
  ].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
50
95
  if (wikiRows.length > 0) {
51
96
  wikiRows.sort((a, b) => b.date.localeCompare(a.date));
97
+ // Same-date tie-break (ISSUE-1): when the top date is shared by >1 row,
98
+ // prefer the project whose working_dir contains cwd. No cwd / no match →
99
+ // keep the stable-sort winner (the legacy "first table row" behavior).
100
+ const topDate = wikiRows[0].date;
101
+ const tied = wikiRows.filter((r) => r.date === topDate);
102
+ if (cwd && tied.length > 1) {
103
+ const picked = pickByCwd(
104
+ hypoDir,
105
+ tied.map((r) => r.slug),
106
+ cwd,
107
+ );
108
+ if (picked) return picked;
109
+ }
52
110
  return wikiRows[0].slug;
53
111
  }
54
112
  // Legacy markdown-link rows: | [name](projects/name/...) | ...
@@ -93,7 +151,7 @@ function readHot(hypoDir, project) {
93
151
 
94
152
  const args = parseArgs(process.argv);
95
153
 
96
- const project = args.project || resolveActiveProject(args.hypoDir);
154
+ const project = args.project || resolveActiveProject(args.hypoDir, process.cwd());
97
155
 
98
156
  if (!project) {
99
157
  console.error('Error: no active project found. Use --project=<name> or create a hot.md entry.');