hypomnema 1.2.1 → 1.3.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 (38) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/commands/crystallize.md +23 -6
  4. package/commands/feedback.md +1 -1
  5. package/docs/CONTRIBUTING.md +96 -11
  6. package/hooks/hypo-auto-commit.mjs +3 -3
  7. package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
  8. package/hooks/hypo-cwd-change.mjs +2 -2
  9. package/hooks/hypo-first-prompt.mjs +1 -1
  10. package/hooks/hypo-personal-check.mjs +57 -7
  11. package/hooks/hypo-session-start.mjs +51 -4
  12. package/hooks/hypo-shared.mjs +137 -12
  13. package/hooks/version-check.mjs +204 -6
  14. package/package.json +5 -2
  15. package/scripts/bump-version.mjs +9 -3
  16. package/scripts/check-bilingual.mjs +115 -0
  17. package/scripts/crystallize.mjs +124 -15
  18. package/scripts/doctor.mjs +45 -9
  19. package/scripts/feedback-sync.mjs +44 -15
  20. package/scripts/feedback.mjs +5 -5
  21. package/scripts/fix-status-verify.mjs +256 -0
  22. package/scripts/init.mjs +45 -4
  23. package/scripts/install-git-hooks.mjs +258 -0
  24. package/scripts/lib/adr-corpus.mjs +79 -0
  25. package/scripts/lib/check-bilingual.mjs +141 -0
  26. package/scripts/lib/extensions.mjs +3 -3
  27. package/scripts/lib/feedback-scope.mjs +21 -0
  28. package/scripts/lib/fix-manifest.mjs +109 -0
  29. package/scripts/lib/fix-status-verify.mjs +438 -0
  30. package/scripts/lib/pre-commit-format.mjs +251 -0
  31. package/scripts/lib/project-create.mjs +2 -2
  32. package/scripts/lint.mjs +48 -8
  33. package/scripts/pre-commit-format.mjs +198 -0
  34. package/scripts/smoke-pack.mjs +16 -0
  35. package/scripts/upgrade.mjs +55 -23
  36. package/skills/crystallize/SKILL.md +13 -2
  37. package/templates/hypo-config.md +1 -1
  38. package/templates/hypo-guide.md +4 -0
@@ -0,0 +1,438 @@
1
+ /**
2
+ * fix-status-verify (lib) — pure functions for verifying fix→test linkage.
3
+ *
4
+ * Phase 1 of CLAUDE.md learned_behavior #6 (2026-05-16): "merged 표기 전
5
+ * (1) ADR 핵심 결정 라인 grep + (2) replay/integration test green 양쪽 충족".
6
+ * This module automates the *test green* half. ADR core decision grep is out
7
+ * of scope (Phase 2 / v1.3.0 manifest PR).
8
+ *
9
+ * SoT split (after codex 3-worker review 2026-05-27):
10
+ * - fix→test mapping SoT: `// @fix #N: <full test name>` anchor comments in
11
+ * tests/runner.mjs (sits next to the assertion that verifies the fix).
12
+ * - fix status SoT: wiki spec-v1.2.md word-boundary grep
13
+ * (\bfix\s*#N\b ... \b(TRUE_MERGED|merged|resolved)\b).
14
+ *
15
+ * Word-boundary on status terms is required so STALE_MERGED / partial /
16
+ * retired are NOT matched as positive claims.
17
+ */
18
+
19
+ import { parseFrontmatter } from './frontmatter.mjs';
20
+
21
+ const POSITIVE_STATUSES = new Set(['merged', 'TRUE_MERGED', 'resolved']);
22
+
23
+ // Words that disqualify a line as a positive claim (case-sensitive).
24
+ // STALE_MERGED contains "MERGED" as a substring but is the opposite signal.
25
+ const NEGATIVE_STATUS_TOKENS = ['STALE_MERGED', 'partial', 'retired'];
26
+
27
+ /**
28
+ * Parse anchor comments out of runner.mjs source text.
29
+ *
30
+ * // @fix #15: all type-conditional fields present → green
31
+ * // @fix #15: another test name
32
+ *
33
+ * The `@fix` prefix is mandatory — distinguishes anchors from prose comments
34
+ * that mention "fix #N" in passing. Each anchor line maps ONE fix # to ONE
35
+ * test name (whole captured group is the name, no comma-splitting). Multiple
36
+ * anchors for the same fix # accumulate (union, order-preserving, dedupe).
37
+ *
38
+ * Sentinel: NAME = "NO_AUTO_TEST" declares the fix has no automated test by
39
+ * design (behavioral / prompt-driven). Verified upstream in verifyMatrix.
40
+ */
41
+ export function parseAnchors(runnerText) {
42
+ const out = new Map();
43
+ const re = /^\s*\/\/\s*@fix\s*#(\d+)\s*:\s*(.+?)\s*$/gim;
44
+ let m;
45
+ while ((m = re.exec(runnerText)) !== null) {
46
+ const fixNum = Number(m[1]);
47
+ const name = m[2].trim();
48
+ if (!name) continue;
49
+ if (!out.has(fixNum)) out.set(fixNum, []);
50
+ const list = out.get(fixNum);
51
+ if (!list.includes(name)) list.push(name);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * Detect a redirect-stub spec: a page whose frontmatter declares
58
+ * `type: reference`. These are placeholders left behind after an archive move
59
+ * (e.g. spec-v1.2.md → archive/spec-v1.2.md) and carry zero fix-status claims.
60
+ * Pointing the verifier at one yields a vacuous green, so verifyMatrix rejects
61
+ * it up front.
62
+ */
63
+ export function isReferenceStub(specText) {
64
+ const fm = parseFrontmatter(specText);
65
+ return fm?.type === 'reference';
66
+ }
67
+
68
+ /**
69
+ * Parse fix status claims out of wiki spec-v1.2.md.
70
+ *
71
+ * Returns Map<fixNum:number, status:string>. status is the most recent
72
+ * positive status token in the file (last mention wins so `merged → resolved`
73
+ * narrative normalises to `resolved`). Fixes whose only mentions are negative
74
+ * (STALE_MERGED / partial / retired) are NOT added — they're considered
75
+ * incomplete claims and skipped by verifyMatrix.
76
+ */
77
+ export function parseStatus(specText) {
78
+ const out = new Map();
79
+ // For each line, find every fix # mention and check whether a positive
80
+ // status token sits within a small proximity window AFTER the mention. This
81
+ // avoids false positives when a line mentions multiple fix #s with status
82
+ // tokens that only apply to some of them (e.g. "fix #38 (resolved); fix #41
83
+ // (v1.3.0 advisory)" — only #38 should be picked up).
84
+ const lines = specText.split('\n');
85
+ const PROXIMITY = 120; // chars after fix # to scan for status
86
+ for (const line of lines) {
87
+ // Quick reject: line must mention a positive status token at all.
88
+ const hasPositive =
89
+ /\bTRUE_MERGED\b/.test(line) ||
90
+ /\bresolved\b/.test(line) ||
91
+ /(?<![A-Z_])merged(?![A-Z_])/.test(line);
92
+ if (!hasPositive) continue;
93
+ // Two accepted fix # forms:
94
+ // (a) inline prose: "fix #N" (word-boundary)
95
+ // (b) table cell start: "| #N |" (§9.1.0 status correction table)
96
+ const matches = [];
97
+ let m2;
98
+ const inlineRe = /\bfix\s*#(\d+)\b/gi;
99
+ while ((m2 = inlineRe.exec(line)) !== null) {
100
+ matches.push({ fixNum: Number(m2[1]), end: m2.index + m2[0].length });
101
+ }
102
+ const tableRe = /\|\s*#(\d+)\s*\|/g;
103
+ while ((m2 = tableRe.exec(line)) !== null) {
104
+ matches.push({ fixNum: Number(m2[1]), end: m2.index + m2[0].length });
105
+ }
106
+ for (const { fixNum, end } of matches) {
107
+ const window = line.slice(end, end + PROXIMITY);
108
+ // Determine the strongest status in the proximity window. Priority:
109
+ // TRUE_MERGED > resolved > merged.
110
+ let status = null;
111
+ if (/\bTRUE_MERGED\b/.test(window)) status = 'TRUE_MERGED';
112
+ else if (/\bresolved\b/.test(window)) status = 'resolved';
113
+ else if (/(?<![A-Z_])merged(?![A-Z_])/.test(window)) status = 'merged';
114
+ if (status) out.set(fixNum, status); // last positive mention wins
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Parse runner.mjs stdout to map test names → "pass" | "fail".
122
+ *
123
+ * The harness prints ` ✓ <name>` on pass and ` ✗ <name>` on fail.
124
+ */
125
+ export function parseRunnerOutput(stdout) {
126
+ const out = new Map();
127
+ const lines = stdout.split('\n');
128
+ for (const line of lines) {
129
+ const passM = line.match(/^\s*✓\s+(.+?)\s*$/);
130
+ if (passM) {
131
+ // Sticky pass — only set if no prior result. A later fail must NOT be
132
+ // overridden, and a prior fail must not be flipped back to pass.
133
+ if (!out.has(passM[1])) out.set(passM[1], 'pass');
134
+ continue;
135
+ }
136
+ const failM = line.match(/^\s*✗\s+(.+?)\s*$/);
137
+ if (failM) {
138
+ // Fail is sticky: once a name has any failure, the verdict stays fail
139
+ // even if a duplicate test() with the same name passed elsewhere.
140
+ out.set(failM[1], 'fail');
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * Cross-check anchors × status × test results.
148
+ *
149
+ * Finding classes:
150
+ * NO_ANCHOR — fix claimed positive in spec, no anchor in runner.
151
+ * MISSING_TEST — anchor names a test, runner output does not contain it.
152
+ * FAILING_TEST — anchor names a test, runner output marks it failed.
153
+ * ORPHAN_ANCHOR — anchor exists, no positive status claim in spec (warn).
154
+ * STUB_SPEC — spec is unusable: a `type: reference` redirect stub, or
155
+ * it parses zero positive status claims while anchors exist
156
+ * (the vacuous-gate the tool exists to prevent). Error.
157
+ *
158
+ * Returns { ok, findings: [...] }. ok=false if any ERROR-level finding.
159
+ * ORPHAN_ANCHOR is WARN-only.
160
+ *
161
+ * STUB_SPEC is a precondition failure, so it short-circuits: when the spec is
162
+ * unusable there is nothing meaningful to cross-check, and the per-anchor
163
+ * ORPHAN noise would only bury the one decisive error.
164
+ */
165
+ export function verifyMatrix({ anchors, status, testResults, specIsStub = false }) {
166
+ if (specIsStub) {
167
+ return {
168
+ ok: false,
169
+ findings: [
170
+ {
171
+ level: 'error',
172
+ class: 'STUB_SPEC',
173
+ detail:
174
+ 'spec is a `type: reference` redirect stub (0 fix-status claims by design) — pass --spec pointing at the real spec',
175
+ },
176
+ ],
177
+ };
178
+ }
179
+ // Vacuous-gate invariant: anchors exist in the runner but the spec yields no
180
+ // positive status claim to verify them against. Greening here would defeat
181
+ // the tool's purpose. (No anchors + no claims is an empty/custom matrix, not
182
+ // a vacuous gate, so it is left to the normal path.)
183
+ if (status.size === 0 && anchors.size > 0) {
184
+ return {
185
+ ok: false,
186
+ findings: [
187
+ {
188
+ level: 'error',
189
+ class: 'STUB_SPEC',
190
+ detail: `${anchors.size} anchor(s) in runner but 0 positive status claims parsed from spec — gate would be vacuous`,
191
+ },
192
+ ],
193
+ };
194
+ }
195
+
196
+ const findings = [];
197
+
198
+ for (const [fixNum, statusValue] of status.entries()) {
199
+ const anchored = anchors.get(fixNum);
200
+ if (!anchored || anchored.length === 0) {
201
+ findings.push({
202
+ level: 'error',
203
+ class: 'NO_ANCHOR',
204
+ fixNum,
205
+ status: statusValue,
206
+ detail: `claimed ${statusValue} in spec but no // fix #${fixNum}: anchor in runner.mjs`,
207
+ });
208
+ continue;
209
+ }
210
+ // Sentinel: explicit "no automated test by design" (behavioral /
211
+ // prompt-driven fixes). The fix is still claimed-merged but verifying it
212
+ // is out of scope for an integration runner.
213
+ if (anchored.length === 1 && anchored[0] === 'NO_AUTO_TEST') {
214
+ findings.push({
215
+ level: 'info',
216
+ class: 'NO_AUTO_TEST',
217
+ fixNum,
218
+ status: statusValue,
219
+ detail: `fix #${fixNum} declares NO_AUTO_TEST (behavioral / prompt-driven)`,
220
+ });
221
+ continue;
222
+ }
223
+ for (const testName of anchored) {
224
+ const result = testResults.get(testName);
225
+ if (result === undefined) {
226
+ findings.push({
227
+ level: 'error',
228
+ class: 'MISSING_TEST',
229
+ fixNum,
230
+ status: statusValue,
231
+ testName,
232
+ detail: `anchor names "${testName}" but no such test ran`,
233
+ });
234
+ } else if (result === 'fail') {
235
+ findings.push({
236
+ level: 'error',
237
+ class: 'FAILING_TEST',
238
+ fixNum,
239
+ status: statusValue,
240
+ testName,
241
+ detail: `test "${testName}" failed`,
242
+ });
243
+ }
244
+ }
245
+ }
246
+
247
+ for (const [fixNum, names] of anchors.entries()) {
248
+ if (!status.has(fixNum)) {
249
+ findings.push({
250
+ level: 'warn',
251
+ class: 'ORPHAN_ANCHOR',
252
+ fixNum,
253
+ tests: names,
254
+ detail: `anchor exists for fix #${fixNum} but no positive status claim in spec`,
255
+ });
256
+ }
257
+ }
258
+
259
+ const ok = !findings.some((f) => f.level === 'error');
260
+ return { ok, findings };
261
+ }
262
+
263
+ // ── Phase 2 (A-sot) — manifest validation, coverage/drift, ADR-line grep ─────
264
+ // ADR 0036: manifest is the evidence SoT (fix → test + ADR-line). status SoT
265
+ // stays in the spec. These are pure functions; the CLI injects fs-backed
266
+ // searchFn / adrExistsFn so the corpus walk stays out of the pure layer.
267
+
268
+ import { FIX_MANIFEST, NO_ADR, NO_AUTO_TEST } from './fix-manifest.mjs';
269
+
270
+ /** Order-insensitive set equality over string arrays (deduped). */
271
+ function sameStringSet(a, b) {
272
+ const sa = new Set(a);
273
+ const sb = new Set(b);
274
+ if (sa.size !== sb.size) return false;
275
+ for (const x of sa) if (!sb.has(x)) return false;
276
+ return true;
277
+ }
278
+
279
+ /**
280
+ * Structural validation of the manifest shape (ADR 0036).
281
+ *
282
+ * Findings (all error):
283
+ * MANIFEST_DUP_FIXID — two rows share a fixId.
284
+ * MANIFEST_EMPTY_TESTS — testNames is empty.
285
+ * MANIFEST_SENTINEL_MIX — NO_AUTO_TEST mixed with real test names.
286
+ * MANIFEST_EMPTY_KEYLINE — adrKeyLine missing/blank.
287
+ * MANIFEST_NO_ADR_SHAPE — NO_ADR row with non-null adrPath, or a non-NO_ADR
288
+ * row with null adrPath.
289
+ */
290
+ export function validateManifest(manifest = FIX_MANIFEST) {
291
+ const findings = [];
292
+ const seen = new Set();
293
+ for (const row of manifest) {
294
+ const fixNum = row.fixId;
295
+ if (seen.has(fixNum)) {
296
+ findings.push({
297
+ level: 'error',
298
+ class: 'MANIFEST_DUP_FIXID',
299
+ fixNum,
300
+ detail: `duplicate manifest row for fix #${fixNum}`,
301
+ });
302
+ }
303
+ seen.add(fixNum);
304
+
305
+ const names = Array.isArray(row.testNames) ? row.testNames : [];
306
+ if (names.length === 0) {
307
+ findings.push({
308
+ level: 'error',
309
+ class: 'MANIFEST_EMPTY_TESTS',
310
+ fixNum,
311
+ detail: `fix #${fixNum} manifest row has empty testNames`,
312
+ });
313
+ }
314
+ if (names.includes(NO_AUTO_TEST) && names.length > 1) {
315
+ findings.push({
316
+ level: 'error',
317
+ class: 'MANIFEST_SENTINEL_MIX',
318
+ fixNum,
319
+ detail: `fix #${fixNum} mixes NO_AUTO_TEST sentinel with real test names`,
320
+ });
321
+ }
322
+
323
+ const keyLine = typeof row.adrKeyLine === 'string' ? row.adrKeyLine.trim() : '';
324
+ if (!keyLine) {
325
+ findings.push({
326
+ level: 'error',
327
+ class: 'MANIFEST_EMPTY_KEYLINE',
328
+ fixNum,
329
+ detail: `fix #${fixNum} manifest row has empty adrKeyLine`,
330
+ });
331
+ continue;
332
+ }
333
+ const isNoAdr = row.adrKeyLine === NO_ADR;
334
+ if (isNoAdr && row.adrPath != null) {
335
+ findings.push({
336
+ level: 'error',
337
+ class: 'MANIFEST_NO_ADR_SHAPE',
338
+ fixNum,
339
+ detail: `fix #${fixNum} is NO_ADR but adrPath is not null`,
340
+ });
341
+ }
342
+ if (!isNoAdr && row.adrPath == null) {
343
+ findings.push({
344
+ level: 'error',
345
+ class: 'MANIFEST_NO_ADR_SHAPE',
346
+ fixNum,
347
+ detail: `fix #${fixNum} has a real adrKeyLine but null adrPath`,
348
+ });
349
+ }
350
+ }
351
+ return findings;
352
+ }
353
+
354
+ /**
355
+ * Coverage + drift between manifest, runner anchors, and spec status claims.
356
+ *
357
+ * MANIFEST_MISSING_ROW — a fix claimed-merged AND anchored has no manifest
358
+ * row (its ADR-line check would be silently skipped).
359
+ * Error: a missing row bypasses the whole gate.
360
+ * MANIFEST_TEST_DRIFT — a manifest row's testNames do not set-equal the
361
+ * runner anchors for that fix (stale evidence).
362
+ *
363
+ * Both error-level. The claimed∩anchored requirement mirrors the manifest
364
+ * scope (ADR 0036): rows exist to prove claims; anchors-without-claims are
365
+ * ORPHAN_ANCHOR (handled in verifyMatrix), not manifest gaps.
366
+ */
367
+ export function checkManifestCoverage({ manifest = FIX_MANIFEST, anchors, status }) {
368
+ const findings = [];
369
+ const byFix = new Map(manifest.map((r) => [r.fixId, r]));
370
+
371
+ for (const fixNum of status.keys()) {
372
+ if (!anchors.has(fixNum)) continue; // claimed but unanchored → NO_ANCHOR (verifyMatrix)
373
+ if (!byFix.has(fixNum)) {
374
+ findings.push({
375
+ level: 'error',
376
+ class: 'MANIFEST_MISSING_ROW',
377
+ fixNum,
378
+ detail: `fix #${fixNum} is claimed-merged and anchored but has no manifest row`,
379
+ });
380
+ }
381
+ }
382
+
383
+ for (const row of manifest) {
384
+ const fixNum = row.fixId;
385
+ const anchored = anchors.get(fixNum) || [];
386
+ const names = Array.isArray(row.testNames) ? row.testNames : [];
387
+ if (!sameStringSet(names, anchored)) {
388
+ findings.push({
389
+ level: 'error',
390
+ class: 'MANIFEST_TEST_DRIFT',
391
+ fixNum,
392
+ detail:
393
+ `fix #${fixNum} manifest testNames ${JSON.stringify(names)} ` +
394
+ `≠ runner anchors ${JSON.stringify(anchored)}`,
395
+ });
396
+ }
397
+ }
398
+ return findings;
399
+ }
400
+
401
+ /**
402
+ * ADR-line grep: each non-NO_ADR manifest row must point at an existing ADR
403
+ * file and its adrKeyLine must exist verbatim in the production-code corpus.
404
+ *
405
+ * ADR_PATH_MISSING — adrPath does not resolve to a file.
406
+ * ADR_LINE_MISSING — adrKeyLine not found in the corpus (fixed-string).
407
+ *
408
+ * searchFn(literal) → boolean: true iff the literal appears in the corpus
409
+ * (the corpus MUST exclude scripts/lib/fix-manifest.mjs, else every line
410
+ * self-matches and the gate is vacuous — see the CLI corpus builder).
411
+ * adrExistsFn(adrPath) → boolean. NO_ADR rows are skipped (test-green only).
412
+ */
413
+ export function checkAdrLines({ manifest = FIX_MANIFEST, searchFn, adrExistsFn }) {
414
+ const findings = [];
415
+ for (const row of manifest) {
416
+ if (row.adrKeyLine === NO_ADR) continue;
417
+ const fixNum = row.fixId;
418
+ if (row.adrPath != null && !adrExistsFn(row.adrPath)) {
419
+ findings.push({
420
+ level: 'error',
421
+ class: 'ADR_PATH_MISSING',
422
+ fixNum,
423
+ detail: `fix #${fixNum} adrPath does not resolve: ${row.adrPath}`,
424
+ });
425
+ }
426
+ if (!searchFn(row.adrKeyLine)) {
427
+ findings.push({
428
+ level: 'error',
429
+ class: 'ADR_LINE_MISSING',
430
+ fixNum,
431
+ detail: `fix #${fixNum} adrKeyLine not found in production corpus: "${row.adrKeyLine}"`,
432
+ });
433
+ }
434
+ }
435
+ return findings;
436
+ }
437
+
438
+ export { POSITIVE_STATUSES, NEGATIVE_STATUS_TOKENS, FIX_MANIFEST, NO_ADR, NO_AUTO_TEST };
@@ -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;