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
@@ -18,8 +18,8 @@
18
18
  * than overwrites so it never erases the hook's `notifiedFor` marks.
19
19
  */
20
20
 
21
- import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
22
- import { dirname, join } from 'path';
21
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, realpathSync, existsSync } from 'fs';
22
+ import { dirname, join, delimiter } from 'path';
23
23
  import { homedir } from 'os';
24
24
 
25
25
  export const TTL_MS = 24 * 60 * 60 * 1000; // 24h
@@ -48,24 +48,72 @@ export function parseSemver(v) {
48
48
  .replace(/^v/, '')
49
49
  .match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
50
50
  if (!m) return null;
51
- return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] || '' };
51
+ // Keep core identifiers as RAW DIGIT STRINGS (not +Number) so compareSemver can
52
+ // order them precisely — SemVer caps neither core nor prerelease numeric length,
53
+ // and Number() silently loses precision past 2^53.
54
+ return { major: m[1], minor: m[2], patch: m[3], pre: m[4] || '' };
55
+ }
56
+
57
+ /**
58
+ * Compare two SemVer numeric identifier strings (digits only, no leading zeros).
59
+ * Done WITHOUT Number() so arbitrary-length identifiers order exactly: fewer
60
+ * digits ⇒ smaller value; equal length ⇒ ASCII order is numeric order.
61
+ */
62
+ function compareNumericId(x, y) {
63
+ if (x.length !== y.length) return x.length < y.length ? -1 : 1;
64
+ if (x !== y) return x < y ? -1 : 1;
65
+ return 0;
66
+ }
67
+
68
+ /**
69
+ * Compare two prerelease strings per the SemVer §11 precedence rules. Identifiers
70
+ * are dot-separated; numeric ones compare numerically and always rank LOWER than
71
+ * alphanumeric ones; a larger set of identifiers outranks a smaller one when all
72
+ * preceding identifiers are equal. Both inputs are non-empty prereleases here.
73
+ */
74
+ function comparePrerelease(a, b) {
75
+ const ai = a.split('.');
76
+ const bi = b.split('.');
77
+ const len = Math.max(ai.length, bi.length);
78
+ for (let i = 0; i < len; i++) {
79
+ // "a larger set of pre-release fields has higher precedence" → the one that
80
+ // still has identifiers wins once the shorter one runs out.
81
+ if (i >= ai.length) return -1;
82
+ if (i >= bi.length) return 1;
83
+ const x = ai[i];
84
+ const y = bi[i];
85
+ const xn = /^\d+$/.test(x);
86
+ const yn = /^\d+$/.test(y);
87
+ if (xn && yn) {
88
+ const c = compareNumericId(x, y);
89
+ if (c !== 0) return c;
90
+ } else if (xn !== yn) {
91
+ return xn ? -1 : 1; // numeric identifiers have lower precedence
92
+ } else if (x !== y) {
93
+ return x < y ? -1 : 1; // ASCII lexical for alphanumeric
94
+ }
95
+ }
96
+ return 0;
52
97
  }
53
98
 
54
99
  /**
55
100
  * Compare two semver strings. Returns -1 / 0 / 1, or null if either is invalid.
56
- * A release outranks a prerelease of the same x.y.z (1.2.3 > 1.2.3-rc.1).
101
+ * A release outranks a prerelease of the same x.y.z (1.2.3 > 1.2.3-rc.1), and
102
+ * prereleases follow full SemVer §11 precedence (1.2.3-rc.2 < 1.2.3-rc.10) — this
103
+ * matters because compareSemver now gates the init/upgrade downgrade guard.
57
104
  */
58
105
  export function compareSemver(a, b) {
59
106
  const pa = parseSemver(a);
60
107
  const pb = parseSemver(b);
61
108
  if (!pa || !pb) return null;
62
109
  for (const k of ['major', 'minor', 'patch']) {
63
- if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
110
+ const c = compareNumericId(pa[k], pb[k]);
111
+ if (c !== 0) return c;
64
112
  }
65
113
  if (pa.pre === pb.pre) return 0;
66
114
  if (!pa.pre) return 1; // release > prerelease
67
115
  if (!pb.pre) return -1;
68
- return pa.pre < pb.pre ? -1 : 1; // lexicographic fallback (good enough)
116
+ return comparePrerelease(pa.pre, pb.pre);
69
117
  }
70
118
 
71
119
  // ── channel detection ──────────────────────────────────────────────────────
@@ -182,3 +230,153 @@ export function mergeLatest(path, latest, now = Date.now()) {
182
230
  export function isOptedOut(env = process.env) {
183
231
  return Boolean(env.HYPO_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
184
232
  }
233
+
234
+ // ── stale-sibling detection (ADR 0038) ───────────────────────────────────────
235
+ //
236
+ // A second, OLDER Hypomnema can sit on $PATH (e.g. a stale `npm i -g hypomnema`)
237
+ // while a newer copy owns the active hooks. The CLI bin (`hypomnema`) then routes
238
+ // `hypomnema init` / `upgrade --apply` through the OLD package, which silently
239
+ // downgrades the newer registered hooks (dropping features like this notifier).
240
+ //
241
+ // The update-notifier above only asks "is MY install behind latest?" — it is
242
+ // blind to a stale SIBLING. These helpers add that axis. They are fs-only and
243
+ // offline (no `npm`, no `which` spawn) so they are safe inside the SessionStart
244
+ // hook and `doctor`.
245
+
246
+ /** realpathSync that returns null instead of throwing on a missing/broken path. */
247
+ export function realpathSafe(p) {
248
+ if (typeof p !== 'string' || !p) return null;
249
+ try {
250
+ return realpathSync(p);
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Read the nearest ancestor `package.json` named `hypomnema`, starting at `start`
258
+ * and walking up. Returns { pkgRoot, version } or null. Used to map a resolved
259
+ * bin path back to the package that owns it.
260
+ */
261
+ function readOwningPkg(start) {
262
+ let dir = start;
263
+ // Bounded ascent (filesystem depth is finite; cap defensively).
264
+ for (let i = 0; i < 64; i++) {
265
+ const pkgPath = join(dir, 'package.json');
266
+ if (existsSync(pkgPath)) {
267
+ try {
268
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
269
+ if (pkg && pkg.name === 'hypomnema' && typeof pkg.version === 'string') {
270
+ return { pkgRoot: dir, version: pkg.version };
271
+ }
272
+ } catch {
273
+ /* keep ascending — a non-hypomnema package.json is not our target */
274
+ }
275
+ }
276
+ const parent = dirname(dir);
277
+ if (parent === dir) break;
278
+ dir = parent;
279
+ }
280
+ return null;
281
+ }
282
+
283
+ /**
284
+ * Locate the `hypomnema` CLI on $PATH WITHOUT spawning `which`/`npm`.
285
+ *
286
+ * Splits $PATH, probes each dir for the bin (plus PATHEXT variants on Windows),
287
+ * resolves symlinks (npm global bins are symlinks into node_modules), then walks
288
+ * up to the owning package.json. Returns { binPath, pkgRoot, version } for the
289
+ * FIRST hit — that is the one the shell would actually run — or null.
290
+ *
291
+ * Windows note: npm installs `.cmd`/`.ps1` launcher shims (not symlinks), so the
292
+ * realpath→package.json walk usually fails there and we return null rather than
293
+ * guess. POSIX (the reported footgun) resolves cleanly.
294
+ */
295
+ export function resolveCliOnPath(binName = 'hypomnema', env = process.env) {
296
+ const pathVar = env.PATH || env.Path || '';
297
+ if (!pathVar) return null;
298
+ const dirs = pathVar.split(delimiter).filter(Boolean);
299
+ const exts =
300
+ process.platform === 'win32'
301
+ ? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
302
+ : [''];
303
+ for (const dir of dirs) {
304
+ for (const ext of exts) {
305
+ const candidate = join(dir, binName + ext.toLowerCase());
306
+ const real = realpathSafe(candidate);
307
+ if (!real) continue;
308
+ const owner = readOwningPkg(dirname(real));
309
+ if (owner) return { binPath: candidate, ...owner };
310
+ }
311
+ }
312
+ return null;
313
+ }
314
+
315
+ /**
316
+ * Classify two installs by version and identity. Returns:
317
+ * 'same' — same package root (dev re-run / npm-link) → never a downgrade
318
+ * 'downgrade' — `incoming` is strictly OLDER than `active`
319
+ * 'ok' — `incoming` >= `active`
320
+ * 'unknown' — either version unparseable; cannot prove a downgrade
321
+ *
322
+ * realpath-compares the roots first so a dev workspace re-running its own
323
+ * init/upgrade is never mis-flagged.
324
+ */
325
+ export function classifyInstall(incoming, active) {
326
+ const ri = realpathSafe(incoming && incoming.pkgRoot);
327
+ const ra = realpathSafe(active && active.pkgRoot);
328
+ if (ri && ra && ri === ra) return 'same';
329
+ const cmp = compareSemver(incoming && incoming.version, active && active.version);
330
+ if (cmp === null) return 'unknown';
331
+ return cmp < 0 ? 'downgrade' : 'ok';
332
+ }
333
+
334
+ /**
335
+ * Decide whether to warn about a stale sibling owning the CLI. Returns
336
+ * { cliVersion, line, key } or null. Warns only when the PATH CLI is a DIFFERENT,
337
+ * strictly OLDER package than the active install.
338
+ *
339
+ * `key` is a throttle token (cli path+version → active version) so the
340
+ * SessionStart hook can suppress repeats via `siblingNotifiedFor`.
341
+ */
342
+ export function computeSiblingNotice(cli, active) {
343
+ if (!cli || !active || !active.version) return null;
344
+ if (classifyInstall(cli, active) !== 'downgrade') return null;
345
+ const key = `${cli.binPath || cli.pkgRoot}@${cli.version}->${active.version}`;
346
+ const line =
347
+ `[Hypomnema] Stale install on PATH: \`${cli.binPath || cli.pkgRoot}\` is v${cli.version}, ` +
348
+ `but your active install is v${active.version}.\n` +
349
+ ` Running \`hypomnema init\`/\`upgrade\` from PATH would DOWNGRADE your hooks.\n` +
350
+ ` → remove the old one: npm uninstall -g hypomnema (then re-check with \`hypomnema doctor\`)`;
351
+ return { cliVersion: cli.version, line, key };
352
+ }
353
+
354
+ /** Has this exact sibling tuple already been surfaced? */
355
+ export function siblingAlreadyNotified(cache, key) {
356
+ return Boolean(cache && cache.siblingNotifiedFor === key);
357
+ }
358
+
359
+ /** Record that the sibling banner for `key` was shown (read-merge-write). */
360
+ export function markSiblingNotified(path, key) {
361
+ try {
362
+ const cache = readCache(path) || {};
363
+ cache.siblingNotifiedFor = key;
364
+ writeCacheAtomic(path, cache);
365
+ } catch {
366
+ /* best-effort */
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Shared one-line message for the init/upgrade downgrade guard (P). `op` is
372
+ * 'init' or 'upgrade'. Kept here so guard text stays identical across both CLIs.
373
+ */
374
+ export function downgradeGuardMessage(incomingVersion, activeVersion, op) {
375
+ return (
376
+ `[Hypomnema] Refusing to ${op}: this package is v${incomingVersion}, but your ` +
377
+ `active install is NEWER (v${activeVersion}).\n` +
378
+ ` This is usually a stale global CLI on PATH — proceeding would DOWNGRADE your hooks.\n` +
379
+ ` → upgrade the stale copy: npm install -g hypomnema\n` +
380
+ ` → or, if you really mean to downgrade: re-run with --allow-downgrade`
381
+ );
382
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,11 +39,14 @@
39
39
  "scripts": {
40
40
  "test": "node tests/runner.mjs",
41
41
  "lint": "node scripts/lint.mjs",
42
+ "fix:verify": "node scripts/fix-status-verify.mjs",
42
43
  "graph": "node scripts/graph.mjs",
43
44
  "smoke-pack": "node scripts/smoke-pack.mjs",
45
+ "check:bilingual": "node scripts/check-bilingual.mjs --changelog",
44
46
  "format": "prettier --write .",
45
47
  "format:check": "prettier --check .",
46
- "prepublishOnly": "npm test && npm run lint && npm run smoke-pack"
48
+ "prepare": "node scripts/install-git-hooks.mjs",
49
+ "prepublishOnly": "npm test && npm run lint && npm run smoke-pack && npm run check:bilingual"
47
50
  },
48
51
  "devDependencies": {
49
52
  "prettier": "^3.8.3"
@@ -52,6 +52,12 @@ for (const { path, pattern } of targets) {
52
52
  }
53
53
 
54
54
  console.log(`\nNext steps:`);
55
- console.log(` git add -A && git commit -m "chore(release): v${next}"`);
56
- console.log(` git tag v${next}`);
57
- console.log(` git push origin <branch> --tags`);
55
+ console.log(` 1. Edit CHANGELOG.md ensure the "## [${next}]" section has a`);
56
+ console.log(` "### 한글 요약" sub-section (release.yml check-bilingual gate).`);
57
+ console.log(` 2. node scripts/check-bilingual.mjs --changelog # local pre-check`);
58
+ console.log(` 3. git add -A && git commit -m "chore(release): v${next}"`);
59
+ console.log(` 4. Create an ANNOTATED tag (lightweight tags are rejected by CI):`);
60
+ console.log(` git tag -a v${next} -m "<English body>\\n\\n---\\n\\n<한글 요약>"`);
61
+ console.log(` See docs/CONTRIBUTING.md "Cutting a release" for the full template.`);
62
+ console.log(` 5. node scripts/check-bilingual.mjs --tag v${next} # local pre-check`);
63
+ console.log(` 6. git push origin <branch> --tags`);
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-bilingual.mjs — CLI gate for the bilingual release-doc rule.
4
+ *
5
+ * Modes:
6
+ * --changelog [version] Validate CHANGELOG.md section for given version.
7
+ * Defaults to package.json's "version" field.
8
+ * Wired into npm `prepublishOnly` so publishes fail
9
+ * when the Korean summary is missing.
10
+ *
11
+ * --tag <ref> Validate annotated tag body for given ref.
12
+ * Wired into .github/workflows/release.yml so a
13
+ * lightweight tag or a missing Korean section
14
+ * blocks the npm publish step.
15
+ *
16
+ * Exits 0 on pass, 1 on fail with a stderr diagnostic.
17
+ */
18
+
19
+ import { readFileSync } from 'fs';
20
+ import { spawnSync } from 'child_process';
21
+ import { fileURLToPath } from 'url';
22
+ import { dirname, join } from 'path';
23
+ import { validateChangelog, validateTagBody } from './lib/check-bilingual.mjs';
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+ const REPO_ROOT = join(__dirname, '..');
27
+
28
+ const RULE_REF =
29
+ 'Rule source: CLAUDE.md learned_behaviors (release-doc-bilingual, 2026-05-24). ' +
30
+ 'OSS Hypomnema ships must carry English body + Korean summary in both CHANGELOG section and git tag annotation.';
31
+
32
+ function fail(msg) {
33
+ process.stderr.write(`[check-bilingual] FAIL: ${msg}\n${RULE_REF}\n`);
34
+ process.exit(1);
35
+ }
36
+
37
+ function ok(msg) {
38
+ process.stdout.write(`[check-bilingual] OK: ${msg}\n`);
39
+ process.exit(0);
40
+ }
41
+
42
+ function usage(exitCode) {
43
+ process.stdout.write(
44
+ `Usage:\n` +
45
+ ` node scripts/check-bilingual.mjs --changelog [version]\n` +
46
+ ` Validate CHANGELOG.md "## [<version>]" section. Default version: package.json.\n` +
47
+ ` node scripts/check-bilingual.mjs --tag <ref>\n` +
48
+ ` Validate annotated tag body (lightweight tags are rejected).\n`,
49
+ );
50
+ process.exit(exitCode);
51
+ }
52
+
53
+ const args = process.argv.slice(2);
54
+ const mode = args[0];
55
+
56
+ if (mode === '--help' || mode === '-h') usage(0);
57
+ if (!mode) usage(1);
58
+
59
+ if (mode === '--changelog') {
60
+ let version = args[1];
61
+ if (!version) {
62
+ try {
63
+ const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8'));
64
+ version = pkg.version;
65
+ } catch (err) {
66
+ fail(`cannot read package.json: ${err.message}`);
67
+ }
68
+ }
69
+ if (!version) fail('no version (arg empty, package.json has no "version" field)');
70
+
71
+ let content;
72
+ try {
73
+ content = readFileSync(join(REPO_ROOT, 'CHANGELOG.md'), 'utf-8');
74
+ } catch (err) {
75
+ fail(`cannot read CHANGELOG.md: ${err.message}`);
76
+ }
77
+
78
+ const result = validateChangelog(content, version);
79
+ if (!result.ok) fail(result.reason);
80
+ ok(`CHANGELOG.md [${version}] — ${result.hangulCount} Hangul chars in "### 한글 요약".`);
81
+ } else if (mode === '--tag') {
82
+ const ref = args[1];
83
+ if (!ref) fail('--tag requires a ref argument (e.g. v1.2.1)');
84
+
85
+ // Reject lightweight tags. `git rev-parse <ref>^{tag}` succeeds ONLY for
86
+ // annotated tags. For lightweight tags the ^{tag} peel fails because there
87
+ // is no tag object — the ref points straight at a commit.
88
+ const tagObj = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{tag}`], {
89
+ encoding: 'utf-8',
90
+ cwd: REPO_ROOT,
91
+ });
92
+ if (tagObj.status !== 0) {
93
+ const exists = spawnSync('git', ['rev-parse', '--verify', '--quiet', ref], {
94
+ encoding: 'utf-8',
95
+ cwd: REPO_ROOT,
96
+ });
97
+ if (exists.status !== 0) fail(`tag ${ref} not found`);
98
+ fail(
99
+ `tag ${ref} is a lightweight tag, not annotated. ` +
100
+ `Re-create with: git tag -a ${ref} -m "<English body>\n\n---\n\n<Korean summary>"`,
101
+ );
102
+ }
103
+
104
+ const tagBody = spawnSync('git', ['tag', '-l', `--format=%(contents)`, ref], {
105
+ encoding: 'utf-8',
106
+ cwd: REPO_ROOT,
107
+ });
108
+ if (tagBody.status !== 0) fail(`failed to read tag ${ref}: ${tagBody.stderr}`);
109
+
110
+ const result = validateTagBody(tagBody.stdout || '');
111
+ if (!result.ok) fail(`tag ${ref} — ${result.reason}`);
112
+ ok(`tag ${ref} annotation — ${result.hangulCount} Hangul chars after last "---" separator.`);
113
+ } else {
114
+ fail(`unknown mode: ${mode}. Use --changelog or --tag (see --help).`);
115
+ }
@@ -55,7 +55,9 @@
55
55
  * hypo-personal-check is still the final enforcement.
56
56
  * • Post-apply — runs after the writes. Surfaces as stage='post-apply-lint'
57
57
  * (or 'post-apply-verification+lint' if freshness also fails). Catches
58
- * payloads that introduce a broken wikilink / malformed body.
58
+ * payloads that introduce a malformed body / bad frontmatter (error-level);
59
+ * broken wikilinks are lint W4 warnings and are not gated. A lint crash
60
+ * hard-fails regardless of scope.
59
61
  */
60
62
 
61
63
  import {
@@ -77,6 +79,9 @@ import {
77
79
  writeSessionClosedMarker,
78
80
  sessionClosedMarkerPath,
79
81
  hypoIsClean,
82
+ extractTouchedWikiFiles,
83
+ closeFileTargets,
84
+ partitionLintScope,
80
85
  } from '../hooks/hypo-shared.mjs';
81
86
 
82
87
  const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
@@ -117,6 +122,7 @@ function parseArgs(argv) {
117
122
  sessionId: null,
118
123
  payload: null,
119
124
  force: false,
125
+ transcriptPath: null,
120
126
  };
121
127
  for (const arg of argv.slice(2)) {
122
128
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
@@ -126,6 +132,7 @@ function parseArgs(argv) {
126
132
  else if (arg === '--mark-session-closed') args.markSessionClosed = true;
127
133
  else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
128
134
  else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
135
+ else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
129
136
  else if (arg === '--force') args.force = true;
130
137
  else if (arg === '--json') args.json = true;
131
138
  }
@@ -246,7 +253,7 @@ function writeIfChanged(path, content) {
246
253
  * Append `entry` to `path` only if `alreadyPresent(content)` is false.
247
254
  * Atomic: rebuilds the full file content and writes via atomicWrite — a crash
248
255
  * mid-append cannot leave log.md or session-log/YYYY-MM.md half-written, which
249
- * matters for these append-only history files (codex review of fix #38).
256
+ * matters for these append-only history files.
250
257
  */
251
258
  function appendIfAbsent(path, entry, alreadyPresent) {
252
259
  let content = '';
@@ -350,13 +357,58 @@ function runMarkSessionClosed(args) {
350
357
  if (args.json) {
351
358
  console.log(JSON.stringify(result, null, 2));
352
359
  } else {
353
- console.log(`✗ session-close gate not satisfied — marker not written (project: ${status.project || '(unresolved)'}):`);
360
+ console.log(
361
+ `✗ session-close gate not satisfied — marker not written (project: ${status.project || '(unresolved)'}):`,
362
+ );
354
363
  for (const f of status.missing) console.log(` ✗ ${f} (missing)`);
355
364
  for (const f of status.stale) console.log(` ✗ ${f} (stale)`);
356
365
  if (!git.clean) console.log(` ✗ git: ${git.reason}`);
357
366
  }
358
367
  process.exit(1);
359
368
  }
369
+ // Bug A coherence: the marker suppresses the Stop hook, but PreCompact blocks
370
+ // on lint. If a transcript is provided, refuse the marker when THIS session's
371
+ // own files (edited ∪ mandatory close files) carry lint errors — otherwise
372
+ // Stop would pass only for /compact to immediately re-block. No transcript →
373
+ // legacy freshness+git recovery path (lint left to PreCompact).
374
+ if (args.transcriptPath) {
375
+ let scopedLint = null;
376
+ try {
377
+ scopedLint = runLint(args.hypoDir);
378
+ } catch (err) {
379
+ // Lint crash → fail-open (never strand the marker writer on tooling), same
380
+ // posture as the PreCompact hook.
381
+ process.stderr.write(
382
+ `[crystallize] mark-session-closed lint skipped: ${err?.message ?? err}\n`,
383
+ );
384
+ }
385
+ if (scopedLint) {
386
+ const scope = new Set([
387
+ ...extractTouchedWikiFiles(args.transcriptPath, args.hypoDir),
388
+ ...closeFileTargets(args.hypoDir),
389
+ ]);
390
+ const { blocking } = partitionLintScope(scopedLint.errors || [], scope);
391
+ if (blocking.length > 0) {
392
+ const files = [...new Set(blocking.map((b) => b.file))];
393
+ const result = {
394
+ ok: false,
395
+ session_id: args.sessionId,
396
+ project: status.project,
397
+ lint_blockers: files,
398
+ error: "session-close gate not satisfied — lint errors in this session's files",
399
+ };
400
+ if (args.json) {
401
+ console.log(JSON.stringify(result, null, 2));
402
+ } else {
403
+ console.log(
404
+ `✗ lint errors in files this session touched — marker not written (fix then re-run):`,
405
+ );
406
+ for (const b of blocking) console.log(` ✗ ${b.file}: ${b.message}`);
407
+ }
408
+ process.exit(1);
409
+ }
410
+ }
411
+ }
360
412
  writeSessionClosedMarker(args.hypoDir, args.sessionId, { project: status.project });
361
413
  // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
362
414
  // the file actually landed before claiming success — otherwise CLI exits 0
@@ -364,7 +416,11 @@ function runMarkSessionClosed(args) {
364
416
  // Codex Worker-2 CONCERN (pre-commit review).
365
417
  if (!existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
366
418
  const err = 'marker file did not land after write (likely .cache permission/disk issue)';
367
- console.log(args.json ? JSON.stringify({ ok: false, session_id: args.sessionId, error: err }, null, 2) : `✗ ${err}`);
419
+ console.log(
420
+ args.json
421
+ ? JSON.stringify({ ok: false, session_id: args.sessionId, error: err }, null, 2)
422
+ : `✗ ${err}`,
423
+ );
368
424
  process.exit(1);
369
425
  }
370
426
  const result = {
@@ -376,7 +432,9 @@ function runMarkSessionClosed(args) {
376
432
  if (args.json) {
377
433
  console.log(JSON.stringify(result, null, 2));
378
434
  } else {
379
- console.log(`✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`);
435
+ console.log(
436
+ `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`,
437
+ );
380
438
  }
381
439
  process.exit(0);
382
440
  }
@@ -469,6 +527,19 @@ function applySessionClose(args) {
469
527
  if (payload.rootHot) overwriteTargets.add('hot.md');
470
528
  if (payload.openQuestions) overwriteTargets.add(join('pages', 'open-questions.md'));
471
529
 
530
+ // Bug B: the documented close path must not be blocked by lint debt OUTSIDE
531
+ // the files it writes (other projects, shared pages this close did not author).
532
+ // payloadScope = every file this apply writes or appends. Both lint passes are
533
+ // judged against it; errors elsewhere are surfaced as notices, never blocking.
534
+ const payloadScope = new Set([
535
+ join('projects', project, 'session-state.md'),
536
+ join('projects', project, 'hot.md'),
537
+ 'hot.md',
538
+ join('projects', project, 'session-log', `${ym}.md`),
539
+ 'log.md',
540
+ ...(payload.openQuestions ? [join('pages', 'open-questions.md')] : []),
541
+ ]);
542
+
472
543
  let preflightLint;
473
544
  try {
474
545
  preflightLint = runLint(args.hypoDir);
@@ -477,7 +548,13 @@ function applySessionClose(args) {
477
548
  console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${e.message}`);
478
549
  process.exit(1);
479
550
  }
480
- const blockingErrors = preflightLint.errors.filter((e) => !overwriteTargets.has(e.file));
551
+ // Block only on errors in payload files we are NOT about to overwrite (append
552
+ // targets — session-log, log.md — can't be repaired by appending, so existing
553
+ // corruption there must block). Overwrite targets are about to be replaced;
554
+ // out-of-scope debt is not this close's concern (Bug B).
555
+ const blockingErrors = preflightLint.errors.filter(
556
+ (e) => payloadScope.has(e.file) && !overwriteTargets.has(e.file),
557
+ );
481
558
  if (blockingErrors.length > 0) {
482
559
  const out = {
483
560
  ok: false,
@@ -536,16 +613,26 @@ function applySessionClose(args) {
536
613
  (wrote ? applied : skipped).push('log (log.md)');
537
614
  }
538
615
 
539
- const verification = sessionCloseFileStatus(args.hypoDir);
616
+ // ISSUE-7 Part A: verify against the SAME project this apply just wrote
617
+ // (`project` = payload.project || probe.project, resolved at the top). Without
618
+ // the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
619
+ // on a same-date root-hot.md tie, can pick a different project — false-failing
620
+ // a completed close (the 2026-06-09 security-ops-kb incident).
621
+ const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
540
622
 
541
- // Fix #40 post-apply lint: payload may have introduced a broken wikilink or
542
- // a malformed session-state body. Surface as a distinct `stage` so caller can
543
- // tell "lint broke" apart from "frontmatter stale". This runs even if the
544
- // freshness gate also failed — both failure modes are useful to the caller.
623
+ // Fix #40 post-apply lint: payload may have introduced a malformed body or
624
+ // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
625
+ // broke" apart from "frontmatter stale". This runs even if the freshness gate
626
+ // also failed — both failure modes are useful to the caller.
545
627
  let postApplyLint;
628
+ let postApplyCrashed = false;
546
629
  try {
547
630
  postApplyLint = runLint(args.hypoDir);
548
631
  } catch (e) {
632
+ // A lint crash (unparseable output) after writes is NOT scopeable — there is
633
+ // no reliable `file` to classify — and must stay a HARD failure, exactly as
634
+ // before scoping was introduced.
635
+ postApplyCrashed = true;
549
636
  postApplyLint = {
550
637
  ok: false,
551
638
  errors: [{ file: '(lint crash)', message: e.message }],
@@ -553,9 +640,24 @@ function applySessionClose(args) {
553
640
  };
554
641
  }
555
642
 
556
- const ok = verification.ok && postApplyLint.ok;
643
+ // Scope post-apply lint to payload files (Bug B): a payload-introduced error
644
+ // lands in a file this apply wrote, so it blocks; pre-existing debt elsewhere
645
+ // is a non-blocking notice. A lint crash bypasses scoping and blocks outright.
646
+ let postBlocking;
647
+ let postNotice;
648
+ if (postApplyCrashed) {
649
+ postBlocking = postApplyLint.errors;
650
+ postNotice = [];
651
+ } else {
652
+ ({ blocking: postBlocking, notice: postNotice } = partitionLintScope(
653
+ postApplyLint.errors || [],
654
+ payloadScope,
655
+ ));
656
+ }
657
+ const postLintOk = !postApplyCrashed && postBlocking.length === 0;
658
+ const ok = verification.ok && postLintOk;
557
659
 
558
- // fix #27 PR-C (ADR 0022 amendment 2026-05-19): auto-write the per-session
660
+ // ADR 0022 amendment 2026-05-19: auto-write the per-session
559
661
  // closed marker on a verified close. Hook authority is read-only; this is
560
662
  // one of the two writer paths (the other is --mark-session-closed standalone).
561
663
  // Marker requires BOTH file/lint gate (already in `ok`) AND clean git tree —
@@ -573,7 +675,7 @@ function applySessionClose(args) {
573
675
  }
574
676
  const stage = ok
575
677
  ? null
576
- : !verification.ok && !postApplyLint.ok
678
+ : !verification.ok && !postLintOk
577
679
  ? 'post-apply-verification+lint'
578
680
  : !verification.ok
579
681
  ? 'post-apply-verification'
@@ -587,6 +689,9 @@ function applySessionClose(args) {
587
689
  skipped,
588
690
  verification,
589
691
  lint: { preflight: preflightLint, postApply: postApplyLint },
692
+ // Pre-existing lint debt in files this close did not author (Bug B): surfaced
693
+ // for visibility, never gated. Empty on a clean vault.
694
+ notices: [...new Set(postNotice.map((e) => e.file))],
590
695
  };
591
696
 
592
697
  if (args.json) {
@@ -606,12 +711,21 @@ function applySessionClose(args) {
606
711
  console.log(`\n✗ session-close still incomplete after apply: ${bad}`);
607
712
  console.log(' Fix the payload (likely an `updated:` field) and retry.');
608
713
  }
609
- if (!postApplyLint.ok) {
714
+ if (!postLintOk) {
610
715
  console.log('\n✗ post-apply lint failed:');
611
- for (const e of postApplyLint.errors) console.log(` ✗ ${e.file}: ${e.message}`);
716
+ for (const e of postBlocking) console.log(` ✗ ${e.file}: ${e.message}`);
612
717
  console.log(' Payload introduced a lint blocker — fix the payload content and retry.');
613
718
  }
614
719
  }
720
+ if (postNotice.length > 0) {
721
+ console.log(
722
+ `\n· ${postNotice.length} pre-existing lint issue(s) in untouched files (not blocking): ${[
723
+ ...new Set(postNotice.map((e) => e.file)),
724
+ ]
725
+ .slice(0, 5)
726
+ .join(', ')}${postNotice.length > 5 ? ', …' : ''}`,
727
+ );
728
+ }
615
729
  }
616
730
  process.exit(ok ? 0 : 1);
617
731
  }