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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +4 -2
- package/README.md +4 -2
- package/commands/crystallize.md +23 -6
- package/commands/feedback.md +1 -1
- package/commands/upgrade.md +2 -0
- package/docs/CONTRIBUTING.md +96 -11
- package/hooks/hypo-auto-commit.mjs +3 -3
- package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
- package/hooks/hypo-cwd-change.mjs +2 -2
- package/hooks/hypo-first-prompt.mjs +1 -1
- package/hooks/hypo-personal-check.mjs +57 -7
- package/hooks/hypo-session-start.mjs +73 -19
- package/hooks/hypo-shared.mjs +206 -16
- package/hooks/version-check.mjs +204 -6
- package/package.json +5 -2
- package/scripts/bump-version.mjs +9 -3
- package/scripts/check-bilingual.mjs +115 -0
- package/scripts/crystallize.mjs +130 -16
- package/scripts/doctor.mjs +45 -9
- package/scripts/feedback-sync.mjs +44 -15
- package/scripts/feedback.mjs +5 -5
- package/scripts/fix-status-verify.mjs +256 -0
- package/scripts/init.mjs +45 -4
- package/scripts/install-git-hooks.mjs +258 -0
- package/scripts/lib/adr-corpus.mjs +79 -0
- package/scripts/lib/check-bilingual.mjs +141 -0
- package/scripts/lib/extensions.mjs +3 -3
- package/scripts/lib/feedback-scope.mjs +21 -0
- package/scripts/lib/fix-manifest.mjs +109 -0
- package/scripts/lib/fix-status-verify.mjs +438 -0
- package/scripts/lib/plugin-detect.mjs +51 -0
- package/scripts/lib/pre-commit-format.mjs +251 -0
- package/scripts/lib/project-create.mjs +2 -2
- package/scripts/lint.mjs +48 -8
- package/scripts/pre-commit-format.mjs +198 -0
- package/scripts/resume.mjs +61 -3
- package/scripts/smoke-pack.mjs +39 -2
- package/scripts/upgrade.mjs +308 -58
- package/skills/crystallize/SKILL.md +13 -2
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +4 -0
package/hooks/version-check.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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"
|
package/scripts/bump-version.mjs
CHANGED
|
@@ -52,6 +52,12 @@ for (const { path, pattern } of targets) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
console.log(`\nNext steps:`);
|
|
55
|
-
console.log(`
|
|
56
|
-
console.log(`
|
|
57
|
-
console.log(`
|
|
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
|
+
}
|
package/scripts/crystallize.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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 && !
|
|
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 (!
|
|
714
|
+
if (!postLintOk) {
|
|
610
715
|
console.log('\n✗ post-apply lint failed:');
|
|
611
|
-
for (const e of
|
|
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
|
}
|