hypomnema 1.2.0 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +72 -26
- package/README.md +53 -7
- package/commands/crystallize.md +23 -6
- package/commands/feedback.md +1 -1
- 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 +10 -6
- package/hooks/hypo-first-prompt.mjs +40 -8
- package/hooks/hypo-personal-check.mjs +60 -8
- package/hooks/hypo-session-start.mjs +60 -9
- package/hooks/hypo-shared.mjs +162 -13
- 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 +124 -15
- 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/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 +5 -1
- package/scripts/smoke-pack.mjs +16 -0
- package/scripts/upgrade.mjs +55 -23
- package/skills/crystallize/SKILL.md +13 -2
- package/templates/hot.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +4 -0
package/scripts/lint.mjs
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
|
|
12
12
|
* --json Output results as JSON
|
|
13
13
|
* --fix Auto-add missing `updated` field (safe repairs only)
|
|
14
|
+
* --strict Promote selected warnings (STRICT_PROMOTE_IDS) to errors
|
|
15
|
+
* so they exit 1. Opt-in gate for release-checklist /
|
|
16
|
+
* pre-commit; default mode stays byte-identical.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
@@ -20,15 +23,17 @@ import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
|
|
|
20
23
|
import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
|
|
21
24
|
import { parseSchemaVocab, checkForbidden, parseSchemaPageDirs } from './lib/schema-vocab.mjs';
|
|
22
25
|
import { findDesignHistoryStale } from './lib/design-history-stale.mjs';
|
|
26
|
+
import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
|
|
23
27
|
|
|
24
28
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
25
29
|
|
|
26
30
|
function parseArgs(argv) {
|
|
27
|
-
const args = { hypoDir: null, json: false, fix: false };
|
|
31
|
+
const args = { hypoDir: null, json: false, fix: false, strict: false };
|
|
28
32
|
for (const arg of argv.slice(2)) {
|
|
29
33
|
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
30
34
|
else if (arg === '--json') args.json = true;
|
|
31
35
|
else if (arg === '--fix') args.fix = true;
|
|
36
|
+
else if (arg === '--strict') args.strict = true;
|
|
32
37
|
}
|
|
33
38
|
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
34
39
|
return args;
|
|
@@ -198,6 +203,20 @@ const VALID_TYPES = [
|
|
|
198
203
|
|
|
199
204
|
const issues = [];
|
|
200
205
|
|
|
206
|
+
// Stable warning class IDs (W1..Wn). `--strict` promotes a frozen subset to
|
|
207
|
+
// errors by ID — never by brittle message-text matching. W8 (design-history
|
|
208
|
+
// stale) predates this scheme; hooks/hypo-personal-check.mjs filters `w.id ===
|
|
209
|
+
// 'W8'`, so it must keep that number — the W5..W7 gap is honest history, not a
|
|
210
|
+
// bug. (spec-v1.3.0 Track E)
|
|
211
|
+
//
|
|
212
|
+
// STRICT_PROMOTE_IDS (OQ-E1, frozen as a code constant): confirmed content
|
|
213
|
+
// defects only.
|
|
214
|
+
// W1 no-frontmatter / W2 unknown-type / W4 broken-wikilink → promote.
|
|
215
|
+
// W3 missing-updated → excluded (auto-repaired by --fix).
|
|
216
|
+
// W8 design-history-stale → excluded (hypo-personal-check handles it; would
|
|
217
|
+
// double-gate).
|
|
218
|
+
const STRICT_PROMOTE_IDS = new Set(['W1', 'W2', 'W4']);
|
|
219
|
+
|
|
201
220
|
function issue(severity, rel, msg, fullPath = null, id = null) {
|
|
202
221
|
issues.push({ severity, file: rel, message: msg, path: fullPath, id });
|
|
203
222
|
}
|
|
@@ -249,7 +268,7 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
|
|
|
249
268
|
}
|
|
250
269
|
|
|
251
270
|
if (!content.match(/^---\r?\n/)) {
|
|
252
|
-
issue('warn', rel, 'No frontmatter found');
|
|
271
|
+
issue('warn', rel, 'No frontmatter found', null, 'W1');
|
|
253
272
|
return;
|
|
254
273
|
}
|
|
255
274
|
|
|
@@ -264,11 +283,11 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
|
|
|
264
283
|
}
|
|
265
284
|
|
|
266
285
|
if (fm.type && !VALID_TYPES.includes(fm.type)) {
|
|
267
|
-
issue('warn', rel, `Unknown type: "${fm.type}"
|
|
286
|
+
issue('warn', rel, `Unknown type: "${fm.type}"`, null, 'W2');
|
|
268
287
|
}
|
|
269
288
|
|
|
270
289
|
if (!fm.updated) {
|
|
271
|
-
issue('warn', rel, 'Missing frontmatter field: updated', path);
|
|
290
|
+
issue('warn', rel, 'Missing frontmatter field: updated', path, 'W3');
|
|
272
291
|
}
|
|
273
292
|
|
|
274
293
|
// type-conditional required fields
|
|
@@ -296,8 +315,12 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
|
|
|
296
315
|
// feedback: scope vocabulary + conditional claude-learned fields (ADR 0031)
|
|
297
316
|
if (fm.type === 'feedback') {
|
|
298
317
|
const scope = fm.scope || '';
|
|
299
|
-
if (scope &&
|
|
300
|
-
issue(
|
|
318
|
+
if (scope && !FEEDBACK_SCOPE_RE.test(scope)) {
|
|
319
|
+
issue(
|
|
320
|
+
'error',
|
|
321
|
+
rel,
|
|
322
|
+
`Invalid feedback scope: "${scope}" (allowed: global | project:<project-id>)`,
|
|
323
|
+
);
|
|
301
324
|
}
|
|
302
325
|
const fbTargets = parseTagsField(fm.targets) || [];
|
|
303
326
|
if (fbTargets.includes('claude-learned')) {
|
|
@@ -332,7 +355,7 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
|
|
|
332
355
|
|
|
333
356
|
for (const link of extractWikilinks(content)) {
|
|
334
357
|
if (!slugMap.has(link)) {
|
|
335
|
-
issue('warn', rel, `Broken wikilink: [[${link}]]
|
|
358
|
+
issue('warn', rel, `Broken wikilink: [[${link}]]`, null, 'W4');
|
|
336
359
|
}
|
|
337
360
|
}
|
|
338
361
|
}
|
|
@@ -404,12 +427,29 @@ if (args.fix) {
|
|
|
404
427
|
}
|
|
405
428
|
}
|
|
406
429
|
|
|
430
|
+
// --strict: promote selected warnings (by stable ID) to errors *before* the
|
|
431
|
+
// errors/warns split, so exit code, counts, plain-text icons, and --json `ok`
|
|
432
|
+
// all derive from the post-promotion severities through the existing paths.
|
|
433
|
+
if (args.strict) {
|
|
434
|
+
for (const iss of issues) {
|
|
435
|
+
if (iss.severity === 'warn' && iss.id && STRICT_PROMOTE_IDS.has(iss.id)) {
|
|
436
|
+
iss.severity = 'error';
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
407
441
|
const errors = issues.filter((i) => i.severity === 'error');
|
|
408
442
|
const warns = issues.filter((i) => i.severity === 'warn');
|
|
409
443
|
|
|
410
444
|
if (args.json) {
|
|
445
|
+
// Default mode is byte-identical: only W8 carries an `id` in the JSON payload
|
|
446
|
+
// (hooks/hypo-personal-check.mjs filters on it). All other IDs stay internal
|
|
447
|
+
// unless `--strict` is set, where the full ID set is exposed so promoted
|
|
448
|
+
// findings are traceable to their warning class.
|
|
411
449
|
const toOut = ({ severity, file, message, id }) =>
|
|
412
|
-
id
|
|
450
|
+
id && (id === 'W8' || args.strict)
|
|
451
|
+
? { severity, file, message, id }
|
|
452
|
+
: { severity, file, message };
|
|
413
453
|
console.log(
|
|
414
454
|
JSON.stringify(
|
|
415
455
|
{
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/pre-commit-format.mjs — Node-side entry for the pre-commit hook.
|
|
4
|
+
*
|
|
5
|
+
* Invoked by the shell shim installed at <git-dir>/hooks/pre-commit. The shim
|
|
6
|
+
* already verifies HYPOMNEMA_ROOT + HYPOMNEMA_GIT_DIR; this script is the
|
|
7
|
+
* second layer of identity defence and the only place that may exit nonzero
|
|
8
|
+
* (only when `git add` fails on restage).
|
|
9
|
+
*
|
|
10
|
+
* Env discipline:
|
|
11
|
+
* - probes git twice with ambient env to learn what Git thinks the repo is
|
|
12
|
+
* - builds `cleanEnv` by stripping every name from `git rev-parse --local-env-vars`
|
|
13
|
+
* plus GIT_NAMESPACE / GIT_CEILING_DIRECTORIES / GIT_CONFIG_* (belt-and-suspenders)
|
|
14
|
+
* - validates inherited GIT_INDEX_FILE: preserve if inside HYPOMNEMA_GIT_DIR
|
|
15
|
+
* (Git exports this for commit -am / commit -- path / commit --amend);
|
|
16
|
+
* otherwise refuse — that's a foreign-index attack vector
|
|
17
|
+
* - lib spawns get `cleanEnv` only; lib never touches process.env directly
|
|
18
|
+
*
|
|
19
|
+
* Why dynamic import: keeping the lib import behind every guard makes the
|
|
20
|
+
* fail-open story water-tight. A static import at file head would throw at
|
|
21
|
+
* module load if the lib were missing or syntactically broken, before any
|
|
22
|
+
* identity check could exit 0.
|
|
23
|
+
*
|
|
24
|
+
* Why pathToFileURL for the import: absolute filesystem paths fed to
|
|
25
|
+
* `import()` break when the checkout path contains URL-significant characters
|
|
26
|
+
* (#, %). pathToFileURL is the canonical Node way to bridge.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const { execFileSync } = await import('node:child_process');
|
|
31
|
+
const fs = await import('node:fs');
|
|
32
|
+
const path = await import('node:path');
|
|
33
|
+
const { pathToFileURL, fileURLToPath } = await import('node:url');
|
|
34
|
+
|
|
35
|
+
const probe = (args, env) => execFileSync('git', args, { encoding: 'utf8', env }).trim();
|
|
36
|
+
|
|
37
|
+
// (0) Derive expectedRoot from THIS script's filesystem location. The shell
|
|
38
|
+
// shim already verifies HYPOMNEMA_ROOT/HYPOMNEMA_GIT_DIR before exec'ing
|
|
39
|
+
// us, but a direct `node scripts/pre-commit-format.mjs` invocation with
|
|
40
|
+
// hostile GIT_DIR/GIT_WORK_TREE pointing at another repo that ALSO calls
|
|
41
|
+
// itself "hypomnema" would bypass the package.json identity check unless
|
|
42
|
+
// we anchor on the script's own location. import.meta.url cannot be
|
|
43
|
+
// redirected by ambient env.
|
|
44
|
+
let expectedRoot;
|
|
45
|
+
try {
|
|
46
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
expectedRoot = fs.realpathSync(path.resolve(here, '..'));
|
|
48
|
+
} catch {
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// (1) Probe with ambient env to learn what Git thinks the repo is.
|
|
53
|
+
let toplevel, absGitDir, commonDir;
|
|
54
|
+
try {
|
|
55
|
+
toplevel = probe(['rev-parse', '--show-toplevel'], process.env);
|
|
56
|
+
absGitDir = probe(['rev-parse', '--absolute-git-dir'], process.env);
|
|
57
|
+
commonDir = probe(['rev-parse', '--git-common-dir'], process.env);
|
|
58
|
+
} catch {
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// (2) Realpath-resolve for stable comparison.
|
|
63
|
+
let absGitDirR, commonDirR, toplevelR;
|
|
64
|
+
try {
|
|
65
|
+
absGitDirR = fs.realpathSync(absGitDir);
|
|
66
|
+
const cdAbs = path.isAbsolute(commonDir) ? commonDir : path.join(absGitDir, '..', commonDir);
|
|
67
|
+
commonDirR = fs.realpathSync(cdAbs);
|
|
68
|
+
toplevelR = fs.realpathSync(toplevel);
|
|
69
|
+
} catch {
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// (3) Trust anchor — refuse to run against any toplevel other than this
|
|
74
|
+
// script's own checkout. Closes the GIT_DIR/GIT_WORK_TREE attack where a
|
|
75
|
+
// foreign hypomnema-named repo would otherwise pass the package.json check.
|
|
76
|
+
if (toplevelR !== expectedRoot) process.exit(0);
|
|
77
|
+
|
|
78
|
+
// (3a) Anchor the git dir to the expected location too. Without this, a
|
|
79
|
+
// mixed-env attack — GIT_DIR=/foreign/.git + GIT_WORK_TREE=expectedRoot
|
|
80
|
+
// + GIT_INDEX_FILE=/foreign/.git/index — would let `absGitDirR` point at
|
|
81
|
+
// the foreign repo while `--show-toplevel` reports expectedRoot. The
|
|
82
|
+
// subsequent GIT_INDEX_FILE check would then pass relative to the
|
|
83
|
+
// foreign git dir, and the lib would operate on a foreign index while
|
|
84
|
+
// mutating real files. (Live-verified by codex round 7.)
|
|
85
|
+
let expectedGitDirR;
|
|
86
|
+
try {
|
|
87
|
+
expectedGitDirR = fs.realpathSync(path.join(expectedRoot, '.git'));
|
|
88
|
+
} catch {
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
if (absGitDirR !== expectedGitDirR) process.exit(0);
|
|
92
|
+
|
|
93
|
+
// (4) Linked worktree → main-worktree only (documented limitation).
|
|
94
|
+
if (absGitDirR !== commonDirR) process.exit(0);
|
|
95
|
+
|
|
96
|
+
// (5) Repo identity check — package.json name must be "hypomnema" (defence in
|
|
97
|
+
// depth alongside the expectedRoot anchor above).
|
|
98
|
+
const pkgPath = path.join(toplevelR, 'package.json');
|
|
99
|
+
if (!fs.existsSync(pkgPath)) process.exit(0);
|
|
100
|
+
let pkg;
|
|
101
|
+
try {
|
|
102
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
103
|
+
} catch {
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
if (pkg?.name !== 'hypomnema') process.exit(0);
|
|
107
|
+
|
|
108
|
+
// (5) Build trusted env from --local-env-vars + GIT_CONFIG_* belt.
|
|
109
|
+
let localEnvList;
|
|
110
|
+
try {
|
|
111
|
+
localEnvList = probe(['rev-parse', '--local-env-vars'], process.env)
|
|
112
|
+
.split(/\r?\n/)
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
} catch {
|
|
115
|
+
// Static fallback (Git versions where --local-env-vars is unavailable).
|
|
116
|
+
localEnvList = [
|
|
117
|
+
'GIT_DIR',
|
|
118
|
+
'GIT_WORK_TREE',
|
|
119
|
+
'GIT_INDEX_FILE',
|
|
120
|
+
'GIT_OBJECT_DIRECTORY',
|
|
121
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
122
|
+
'GIT_COMMON_DIR',
|
|
123
|
+
'GIT_CONFIG',
|
|
124
|
+
'GIT_CONFIG_PARAMETERS',
|
|
125
|
+
'GIT_PREFIX',
|
|
126
|
+
'GIT_IMPLICIT_WORK_TREE',
|
|
127
|
+
'GIT_GRAFT_FILE',
|
|
128
|
+
'GIT_NO_REPLACE_OBJECTS',
|
|
129
|
+
'GIT_REPLACE_REF_BASE',
|
|
130
|
+
'GIT_SHALLOW_FILE',
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
const scrub = new Set([
|
|
134
|
+
...localEnvList,
|
|
135
|
+
'GIT_NAMESPACE',
|
|
136
|
+
'GIT_CEILING_DIRECTORIES',
|
|
137
|
+
...Object.keys(process.env).filter((k) => /^GIT_CONFIG_/.test(k)),
|
|
138
|
+
]);
|
|
139
|
+
const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !scrub.has(k)));
|
|
140
|
+
|
|
141
|
+
// (6) GIT_INDEX_FILE preservation gated on shim invocation. Git legitimately
|
|
142
|
+
// exports this for these commit shapes (verified live by codex round 5/9
|
|
143
|
+
// on Git 2.50.1):
|
|
144
|
+
// - .git/index normal commit, commit --amend, merge commit
|
|
145
|
+
// - .git/index.lock commit -am, commit -p, commit --interactive
|
|
146
|
+
// - .git/next-index-*.lock commit -- <pathspec>, rebase partial commit
|
|
147
|
+
// The basename whitelist used in v8/v9 had a residual gap: a crafted
|
|
148
|
+
// .git/next-index-attack.lock matches the `next-index-*` prefix even
|
|
149
|
+
// though Git itself uses `next-index-<pid>.lock` (codex round 9 live
|
|
150
|
+
// replay). Closing that prefix gap by tightening pattern just invites
|
|
151
|
+
// more attacker iteration.
|
|
152
|
+
//
|
|
153
|
+
// The cleaner defence: only honour inherited GIT_INDEX_FILE when we
|
|
154
|
+
// were invoked from our own trusted shell shim (HYPOMNEMA_HOOK_INVOCATION
|
|
155
|
+
// sentinel set there). Direct invocation — which is the only path an
|
|
156
|
+
// attacker can use to plant a crafted index — drops the inherited value
|
|
157
|
+
// and lets git fall back to the default `.git/index`, i.e. the real
|
|
158
|
+
// staged set. The hook then either has nothing to do (no real stage) or
|
|
159
|
+
// formats the real stage (correct behaviour). An attacker that can also
|
|
160
|
+
// set HYPOMNEMA_HOOK_INVOCATION already has full env control and can
|
|
161
|
+
// mutate files directly without going through the hook gadget.
|
|
162
|
+
const fromShim = process.env.HYPOMNEMA_HOOK_INVOCATION === '1';
|
|
163
|
+
if (fromShim && process.env.GIT_INDEX_FILE) {
|
|
164
|
+
// Even when trusted, sanity-check that the path lives inside our git dir.
|
|
165
|
+
// Belt-and-suspenders against shim invocation with an inherited but
|
|
166
|
+
// misdirected GIT_INDEX_FILE (e.g. by a wrapper that exec'd our shim).
|
|
167
|
+
const inherited = process.env.GIT_INDEX_FILE;
|
|
168
|
+
const lexical = path.isAbsolute(inherited) ? inherited : path.join(toplevelR, inherited);
|
|
169
|
+
let absIdx;
|
|
170
|
+
try {
|
|
171
|
+
absIdx = fs.realpathSync(lexical);
|
|
172
|
+
} catch {
|
|
173
|
+
absIdx = path.resolve(lexical);
|
|
174
|
+
}
|
|
175
|
+
if (path.dirname(absIdx) === absGitDirR) {
|
|
176
|
+
cleanEnv.GIT_INDEX_FILE = inherited;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// If !fromShim, GIT_INDEX_FILE stays scrubbed → lib uses default .git/index.
|
|
180
|
+
|
|
181
|
+
// (7) Dynamic-import the lib through a file:// URL so checkout paths with
|
|
182
|
+
// URL-significant chars (#, %) don't break ESM resolution. We import
|
|
183
|
+
// from expectedRoot, not toplevel — the script's own location is the
|
|
184
|
+
// anchor of trust.
|
|
185
|
+
const libPath = path.join(expectedRoot, 'scripts/lib/pre-commit-format.mjs');
|
|
186
|
+
let lib;
|
|
187
|
+
try {
|
|
188
|
+
lib = await import(pathToFileURL(libPath).href);
|
|
189
|
+
} catch {
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = await lib.runPreCommitFormat({ cwd: toplevelR, env: cleanEnv });
|
|
194
|
+
if (result.summary) process.stderr.write(`[pre-commit-format] ${result.summary}\n`);
|
|
195
|
+
process.exit(result.gitAddFailed ? 1 : 0);
|
|
196
|
+
} catch {
|
|
197
|
+
process.exit(0);
|
|
198
|
+
}
|
package/scripts/resume.mjs
CHANGED
|
@@ -37,7 +37,9 @@ function resolveActiveProject(hypoDir) {
|
|
|
37
37
|
const hotPath = join(hypoDir, 'hot.md');
|
|
38
38
|
if (!existsSync(hotPath)) return null;
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// Strip HTML comments before parsing so the canonical-format example row
|
|
41
|
+
// in templates/hot.md (`<!-- Row format: ... -->`) is not picked up as data.
|
|
42
|
+
const content = readFileSync(hotPath, 'utf-8').replace(/<!--[\s\S]*?-->/g, '');
|
|
41
43
|
// Canonical hot.md uses wikilinks: | name | date | [[projects/slug/hot]] |
|
|
42
44
|
// Pick the most recent row by the date column when present.
|
|
43
45
|
const wikiRows = [
|
|
@@ -60,6 +62,8 @@ function resolveActiveProject(hypoDir) {
|
|
|
60
62
|
let latest = null;
|
|
61
63
|
let latestMtime = 0;
|
|
62
64
|
for (const p of readdirSync(projectsDir)) {
|
|
65
|
+
// Skip the scaffold project init.mjs writes — it isn't a real active project.
|
|
66
|
+
if (p === '_template') continue;
|
|
63
67
|
const ssPath = join(projectsDir, p, 'session-state.md');
|
|
64
68
|
if (!existsSync(ssPath)) continue;
|
|
65
69
|
const mtime = statSync(ssPath).mtimeMs;
|
package/scripts/smoke-pack.mjs
CHANGED
|
@@ -56,6 +56,13 @@ const wikiDir = join(work, 'wiki');
|
|
|
56
56
|
let cleanupOk = false;
|
|
57
57
|
|
|
58
58
|
try {
|
|
59
|
+
// Capture pre-commit hook contents (if any) BEFORE pack so we can prove
|
|
60
|
+
// `npm pack` didn't mutate it. The `prepare` lifecycle script runs during
|
|
61
|
+
// `npm pack` and could theoretically touch .git/hooks/pre-commit; the
|
|
62
|
+
// installer's CI/lifecycle guards must prevent that.
|
|
63
|
+
const preCommitPath = join(REPO, '.git', 'hooks', 'pre-commit');
|
|
64
|
+
const preCommitBefore = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : null;
|
|
65
|
+
|
|
59
66
|
step('npm pack');
|
|
60
67
|
const pack = run('npm', ['pack', '--json'], { cwd: REPO });
|
|
61
68
|
const meta = JSON.parse(pack.stdout)[0];
|
|
@@ -203,6 +210,15 @@ try {
|
|
|
203
210
|
);
|
|
204
211
|
}
|
|
205
212
|
|
|
213
|
+
step('verify pre-commit hook was not mutated by npm pack');
|
|
214
|
+
const preCommitAfter = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : null;
|
|
215
|
+
if (preCommitBefore !== preCommitAfter) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
'npm pack mutated .git/hooks/pre-commit — the prepare lifecycle ' +
|
|
218
|
+
'guard (npm_command=pack) is not firing.',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
206
222
|
console.log('\n✓ smoke-pack passed');
|
|
207
223
|
cleanupOk = true;
|
|
208
224
|
} catch (err) {
|
package/scripts/upgrade.mjs
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* wiki-*.mjs → hypo-*.mjs rename migration (fix #48), and
|
|
20
20
|
* the user-extensions companion sync (E4 / fix #32).
|
|
21
21
|
* --json Output results as JSON
|
|
22
|
+
* --allow-downgrade Override the guard that refuses to overwrite a NEWER
|
|
23
|
+
* active install with an older package (ADR 0038)
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
import {
|
|
@@ -45,6 +47,7 @@ import {
|
|
|
45
47
|
readFileIfRegular,
|
|
46
48
|
} from './lib/pkg-json.mjs';
|
|
47
49
|
import { syncExtensions } from './lib/extensions.mjs';
|
|
50
|
+
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
48
51
|
|
|
49
52
|
const HOME = homedir();
|
|
50
53
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -76,6 +79,7 @@ function parseArgs(argv) {
|
|
|
76
79
|
forceCommands: false,
|
|
77
80
|
forceExtensions: false,
|
|
78
81
|
codex: false,
|
|
82
|
+
allowDowngrade: false,
|
|
79
83
|
};
|
|
80
84
|
for (const arg of argv.slice(2)) {
|
|
81
85
|
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
@@ -84,6 +88,7 @@ function parseArgs(argv) {
|
|
|
84
88
|
else if (arg === '--force-extensions') args.forceExtensions = true;
|
|
85
89
|
else if (arg === '--codex') args.codex = true;
|
|
86
90
|
else if (arg === '--json') args.json = true;
|
|
91
|
+
else if (arg === '--allow-downgrade') args.allowDowngrade = true;
|
|
87
92
|
}
|
|
88
93
|
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
89
94
|
return args;
|
|
@@ -312,7 +317,7 @@ function applySettingsJson(settingsResults, settingsPath) {
|
|
|
312
317
|
for (const s of settingsResults) {
|
|
313
318
|
if (s.status !== 'missing') continue;
|
|
314
319
|
if (!Array.isArray(settings.hooks[s.event])) settings.hooks[s.event] = [];
|
|
315
|
-
//
|
|
320
|
+
// re-check the current parsed settings before appending.
|
|
316
321
|
// applyHookNameMigration may have rewritten a legacy wiki-*.mjs command to
|
|
317
322
|
// exactly `s.cmd` between checkSettingsJson and now — appending without
|
|
318
323
|
// this guard creates a duplicate registration (codex 2-worker review
|
|
@@ -521,15 +526,16 @@ checklist below is manual:
|
|
|
521
526
|
- [ ] **Re-run \`hypomnema lint\` after backfilling — confirm 0 feedback errors
|
|
522
527
|
remain (including the conditional \`claude-learned\` fields above)**
|
|
523
528
|
|
|
524
|
-
##
|
|
529
|
+
## Note — \`scope: project:<project-id>\` and the scope regex
|
|
525
530
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
\`-Users-you-Workspace-Project\`),
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
531
|
+
As of v1.3.0 the feedback scope regex \`^(global|project:[A-Za-z0-9_-]+)\$\`
|
|
532
|
+
accepts cwd-derived project-ids directly (e.g.
|
|
533
|
+
\`-Users-you-Workspace-Project\`), so a \`scope: project:*\` page no longer needs
|
|
534
|
+
a \`--project-id=<slug>\` override just to pass \`lint\`. The resolved id must
|
|
535
|
+
still exact-match \`feedback-sync\`'s project-id for projection (default: cwd
|
|
536
|
+
with \`/\` and \`.\` replaced by \`-\`). Known limit: a cwd containing spaces or
|
|
537
|
+
other characters outside \`[A-Za-z0-9_-]\` still derives an id the regex
|
|
538
|
+
rejects — pass \`--project-id=<id>\` for those.
|
|
533
539
|
`
|
|
534
540
|
: `## What changed
|
|
535
541
|
|
|
@@ -762,7 +768,7 @@ const commands = checkCommands();
|
|
|
762
768
|
const oldHookRefs = checkOldHookNames(claudeSettingsPath);
|
|
763
769
|
const hypoignore = checkHypoignore(args.hypoDir);
|
|
764
770
|
|
|
765
|
-
//
|
|
771
|
+
// when --codex is set, mirror the same core-hook checks against ~/.codex/
|
|
766
772
|
// so `hypomnema upgrade --codex` reports drift symmetrically and `--apply --codex`
|
|
767
773
|
// updates both targets in one pass (matching init.mjs behaviour).
|
|
768
774
|
const hooksCodex = args.codex ? checkHookFiles(codexHooksDir) : null;
|
|
@@ -784,7 +790,7 @@ const extCheck = syncExtensions({
|
|
|
784
790
|
force: args.forceExtensions,
|
|
785
791
|
});
|
|
786
792
|
|
|
787
|
-
// E4 (#32): --codex mirrors the extensions sync into ~/.codex (hooks + commands
|
|
793
|
+
// E4 (fix #32): --codex mirrors the extensions sync into ~/.codex (hooks + commands
|
|
788
794
|
// only; skills/agents skipped with a notice). The per-target SHA map lives in the
|
|
789
795
|
// same ~/.claude/hypo-pkg.json under extensions.codex, so pkgPath is unchanged.
|
|
790
796
|
const extCodexSettingsPath = codexSettingsPath;
|
|
@@ -839,6 +845,32 @@ let appliedExtensions = null;
|
|
|
839
845
|
let appliedExtensionsCodex = null;
|
|
840
846
|
|
|
841
847
|
if (args.apply) {
|
|
848
|
+
// Downgrade guard (ADR 0038, P): an `--apply` from an OLDER package than the
|
|
849
|
+
// active install would overwrite newer hooks (upgrade.mjs:287 copyFileSync) and
|
|
850
|
+
// rewrite hypo-pkg.json to the older version. Refuse before the first mutation.
|
|
851
|
+
// A dev workspace re-running its own --apply (incl. the post-commit sync hook)
|
|
852
|
+
// is exempt via realpath'd pkgRoot equality. Exit 2 = refused downgrade.
|
|
853
|
+
if (!args.allowDowngrade) {
|
|
854
|
+
const _active = readPkgJsonSafe(pkgJsonPath());
|
|
855
|
+
let _incomingVersion = null;
|
|
856
|
+
try {
|
|
857
|
+
_incomingVersion = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
|
|
858
|
+
} catch {
|
|
859
|
+
/* unreadable own package.json — cannot prove a downgrade, allow */
|
|
860
|
+
}
|
|
861
|
+
if (
|
|
862
|
+
_active &&
|
|
863
|
+
_active.pkgVersion &&
|
|
864
|
+
_incomingVersion &&
|
|
865
|
+
classifyInstall(
|
|
866
|
+
{ pkgRoot: PKG_ROOT, version: _incomingVersion },
|
|
867
|
+
{ pkgRoot: _active.pkgRoot, version: _active.pkgVersion },
|
|
868
|
+
) === 'downgrade'
|
|
869
|
+
) {
|
|
870
|
+
console.error(downgradeGuardMessage(_incomingVersion, _active.pkgVersion, 'upgrade --apply'));
|
|
871
|
+
process.exit(2);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
842
874
|
if (oldHookRefs.length > 0) {
|
|
843
875
|
appliedHookNameRenames = applyHookNameMigration(
|
|
844
876
|
oldHookRefs,
|
|
@@ -855,7 +887,7 @@ if (args.apply) {
|
|
|
855
887
|
appliedCommands = applyCommands(commands, args.forceCommands);
|
|
856
888
|
appliedPkgJson = true;
|
|
857
889
|
appliedHypoignore = applyHypoignoreMigration(hypoignore);
|
|
858
|
-
//
|
|
890
|
+
// codex core hooks + settings + wiki-*→hypo-* rename mirror. Same order
|
|
859
891
|
// as the claude side (rename first so subsequent hook copy can find renamed targets).
|
|
860
892
|
if (args.codex) {
|
|
861
893
|
if (oldHookRefsCodex.length > 0) {
|
|
@@ -878,7 +910,7 @@ if (args.apply) {
|
|
|
878
910
|
apply: true,
|
|
879
911
|
force: args.forceExtensions,
|
|
880
912
|
});
|
|
881
|
-
// E4 (#32): codex apply runs AFTER the claude apply so it reads the freshly
|
|
913
|
+
// E4 (fix #32): codex apply runs AFTER the claude apply so it reads the freshly
|
|
882
914
|
// written hypo-pkg.json and merges extensions.codex alongside extensions.claude
|
|
883
915
|
// (the per-target spread in syncExtensions preserves the other target's map).
|
|
884
916
|
if (args.codex) {
|
|
@@ -898,7 +930,7 @@ if (args.apply) {
|
|
|
898
930
|
|
|
899
931
|
const extDrift = extCheck.needsWork || (extCheckCodex?.needsWork ?? false);
|
|
900
932
|
|
|
901
|
-
//
|
|
933
|
+
// codex drift only counts when --codex is set — without the flag the codex
|
|
902
934
|
// target is intentionally unobserved (parity with the existing extensions pattern).
|
|
903
935
|
const codexCoreDrift =
|
|
904
936
|
args.codex &&
|
|
@@ -935,7 +967,7 @@ if (args.json) {
|
|
|
935
967
|
hypoignore,
|
|
936
968
|
extensions: extCheck,
|
|
937
969
|
extensionsCodex: extCheckCodex,
|
|
938
|
-
//
|
|
970
|
+
// codex core mirror (null when --codex absent).
|
|
939
971
|
hooksCodex,
|
|
940
972
|
settingsCodex,
|
|
941
973
|
oldHookRefsCodex,
|
|
@@ -1090,7 +1122,7 @@ if (commands.length === 0) {
|
|
|
1090
1122
|
}
|
|
1091
1123
|
|
|
1092
1124
|
// Old hook names (wiki-*.mjs → hypo-*.mjs rename migration). Target-aware so
|
|
1093
|
-
//
|
|
1125
|
+
// codex settings.json entries that still reference the v1.0/v1.1 names are surfaced.
|
|
1094
1126
|
function pushHookNameSummary(refs, label) {
|
|
1095
1127
|
if (refs.length > 0) {
|
|
1096
1128
|
lines.push(
|
|
@@ -1142,7 +1174,7 @@ function pushExtSummary(check, label) {
|
|
|
1142
1174
|
for (const c of check.conflicts) lines.push(` ✗ ${c.file} [${c.action} — left untouched]`);
|
|
1143
1175
|
for (const d of check.drifts) lines.push(` ⚠ ${d.file} [drift — left untouched]`);
|
|
1144
1176
|
}
|
|
1145
|
-
// E3 (#31): a hard conflict blocks install (exit 1, even under --apply); drift is
|
|
1177
|
+
// E3 (fix #31): a hard conflict blocks install (exit 1, even under --apply); drift is
|
|
1146
1178
|
// resolvable advisory. Emit the spec'd WIKI messages so the user knows the recovery.
|
|
1147
1179
|
if (nConflicts > 0) {
|
|
1148
1180
|
lines.push(' [WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
|
|
@@ -1200,7 +1232,7 @@ if (
|
|
|
1200
1232
|
lines.push(`✓ Appended .hypoignore entries (${appliedHypoignore.length}):`);
|
|
1201
1233
|
for (const e of appliedHypoignore) lines.push(` → ${e}`);
|
|
1202
1234
|
}
|
|
1203
|
-
//
|
|
1235
|
+
// codex-target applied actions (mirrors claude blocks above).
|
|
1204
1236
|
if (appliedHookNameRenamesCodex.length > 0) {
|
|
1205
1237
|
lines.push(`✓ Renamed legacy hook references (codex) (${appliedHookNameRenamesCodex.length}):`);
|
|
1206
1238
|
for (const r of appliedHookNameRenamesCodex) lines.push(` → ${r}`);
|
|
@@ -1252,11 +1284,11 @@ const totalDrift =
|
|
|
1252
1284
|
extCheck.actions.filter(
|
|
1253
1285
|
(a) => a.action === 'create' || a.action === 'update' || a.action === 'force-update',
|
|
1254
1286
|
).length +
|
|
1255
|
-
// E3 (#31): unresolved drift/conflict is pending work too — without these the
|
|
1256
|
-
// summary printed "up to date" while the exit code was 1
|
|
1287
|
+
// E3 (fix #31): unresolved drift/conflict is pending work too — without these the
|
|
1288
|
+
// summary printed "up to date" while the exit code was 1.
|
|
1257
1289
|
extCheck.conflicts.length +
|
|
1258
1290
|
extCheck.drifts.length +
|
|
1259
|
-
// E4 (#32): codex-target pending work counts identically (same message/exit
|
|
1291
|
+
// E4 (fix #32): codex-target pending work counts identically (same message/exit
|
|
1260
1292
|
// consistency the E3 review caught — a codex conflict must not read "up to date").
|
|
1261
1293
|
(extCheckCodex
|
|
1262
1294
|
? extCheckCodex.actions.filter(
|
|
@@ -1265,7 +1297,7 @@ const totalDrift =
|
|
|
1265
1297
|
extCheckCodex.conflicts.length +
|
|
1266
1298
|
extCheckCodex.drifts.length
|
|
1267
1299
|
: 0) +
|
|
1268
|
-
//
|
|
1300
|
+
// codex core mirror counts the same way as the claude side.
|
|
1269
1301
|
staleHooksCodex.length +
|
|
1270
1302
|
missingSettingsCodex.length +
|
|
1271
1303
|
(invalidSettingsCodex ? 1 : 0) +
|
|
@@ -1297,7 +1329,7 @@ if (totalDrift === 0) {
|
|
|
1297
1329
|
|
|
1298
1330
|
console.log(lines.join('\n'));
|
|
1299
1331
|
|
|
1300
|
-
// E3 (#31): a hard extension conflict blocks even under --apply (unlike ordinary
|
|
1332
|
+
// E3 (fix #31): a hard extension conflict blocks even under --apply (unlike ordinary
|
|
1301
1333
|
// drift, which only fails check mode). --force-extensions clears the resolvable
|
|
1302
1334
|
// cases; an unfollowable symlink/non-regular dest still counts and stays exit 1.
|
|
1303
1335
|
const extBlocked = extCheck.conflicts.length > 0 || (extCheckCodex?.conflicts.length ?? 0) > 0;
|
|
@@ -11,7 +11,7 @@ When invoked at the end of a session (or with phrases like "세션 종료", "wra
|
|
|
11
11
|
|
|
12
12
|
## What this does
|
|
13
13
|
|
|
14
|
-
- **Close mode**: walks the
|
|
14
|
+
- **Close mode**: walks the checklist (session-state, project hot.md, root hot.md, session-log, open-questions(변경 시), log.md) plus a lint step, then writes via `crystallize.mjs --apply-session-close --payload=<path>` — which runs the lint gate automatically, **scoped to the files it writes** (debt elsewhere is a non-blocking notice). `--check-session-close` remains a read-only probe (freshness only). The PreCompact gate runs the same scoped lint, judging the session on the files it touched.
|
|
15
15
|
- **Synthesis mode**: finds tag clusters (≥ N pages), orphan pages (no outbound `[[wikilinks]]`), and draft / stub pages, then guides consolidation into `pages/syntheses/<topic>.md` with back-links and `index.md` updates.
|
|
16
16
|
|
|
17
17
|
---
|
|
@@ -43,7 +43,18 @@ Show the output verbatim.
|
|
|
43
43
|
|
|
44
44
|
## Step 3 — Session-close checklist (if triggered at session end)
|
|
45
45
|
|
|
46
|
-
If `/hypo:crystallize` was invoked as a session-close action, run through this checklist before synthesizing.
|
|
46
|
+
If `/hypo:crystallize` was invoked as a session-close action, run through this checklist before synthesizing. The mechanical checklist items (1–6 below) proceed automatically without confirmation unless the user has not said "auto"; the advisory reflections that precede them (#41~#44) always surface to the user for confirmation — they are recommendations, never auto-actions.
|
|
47
|
+
|
|
48
|
+
### Advisory reflections (run before the checklist below — advisory only)
|
|
49
|
+
|
|
50
|
+
Surface each of these four to the user first. Every one is **advisory** (ADR 0029 identity guard): the user confirms or declines, and none performs an automatic action, writes a file on its own, or bypasses the mandatory gate.
|
|
51
|
+
|
|
52
|
+
- **Trivial-session check (#44)** — Was this session trivial (a single bug fix, a single-file edit, or Q&A with no durable artifact)? If so, recommend skipping session-close: *"이 세션은 trivial해 보입니다 — session-close를 건너뛸까요?"* A trivial skip is a recommendation, **not** a bypass: it must not mark the session closed, must not run `--mark-session-closed`, and must not claim `/compact` can pass. Any real close still requires all 5 mandatory files.
|
|
53
|
+
- **ADR-candidate check (#41)** — Did this session make an architectural or design decision (a new pattern, a tradeoff, a convention)? If yes, ask whether it warrants an ADR and capture that intent in the session-log entry. If nothing rose to ADR level, record `ADR 없음 — <one-line reason>` in that same session-log entry. **Never auto-write an ADR file** — the session-log note is the only action here.
|
|
54
|
+
- **design-history staleness check (#42)** — If `projects/<name>/design-history.md` exists and this session changed design decisions it does not yet reflect, recommend updating it (W8 lint flags this mechanically; an active-project W8 can also block at PreCompact). If the file does not exist, skip silently — do **not** create it just for this check. Never auto-update it.
|
|
55
|
+
- **Ingest check (#43)** — Did this session consume trustworthy external knowledge (a fetched URL, official docs, or code you verified directly)? If so, recommend running `/hypo:ingest` to capture it under `sources/`. Proceed only on the user's confirmation.
|
|
56
|
+
|
|
57
|
+
When uncertain, surface the question rather than skip it. None of the four blocks the close or writes on its own.
|
|
47
58
|
|
|
48
59
|
1. **session-state.md** — update `projects/<name>/session-state.md` with the next tasks list (what to tackle first next time).
|
|
49
60
|
2. **hot.md (project)** — update `projects/<name>/hot.md` with a session snapshot: what changed and decisions made. Keep under 500 words. Do not put next-step tasks here; those belong in session-state.md.
|
package/templates/hot.md
CHANGED
|
@@ -14,7 +14,7 @@ tags: [wiki, operations]
|
|
|
14
14
|
|
|
15
15
|
| Project | Last Session | Hot Cache |
|
|
16
16
|
|---|---|---|
|
|
17
|
-
<!-- Row format: | Project Name | YYYY-MM-DD |
|
|
17
|
+
<!-- Row format: | Project Name | YYYY-MM-DD | projects/<slug>/hot (wikilink) | -->
|
|
18
18
|
<!-- col2 date is rebuilt from projects/<slug>/hot.md frontmatter on each session close -->
|
|
19
19
|
|
|
20
20
|
## Session Start Checklist
|
package/templates/hypo-config.md
CHANGED
package/templates/hypo-guide.md
CHANGED
|
@@ -80,6 +80,10 @@ Ask: *"이 작업이 마무리되었나요? 세션을 정리(crystallize)할까
|
|
|
80
80
|
2. Update `projects/<name>/hot.md` (what was done, ≤500 words, overwrite)
|
|
81
81
|
3. Append to `projects/<name>/session-log/YYYY-MM.md` (narrative entry, append-only)
|
|
82
82
|
4. Update root `hot.md` pointer table + date
|
|
83
|
+
5. Run `scripts/lint.mjs` and fix errors in files **you** touched — debt in other
|
|
84
|
+
projects / shared pages you did not author is reported as a non-blocking
|
|
85
|
+
notice, not a gate. (The documented `crystallize.mjs --apply-session-close`
|
|
86
|
+
path runs this lint automatically, scoped to the files it writes.)
|
|
83
87
|
|
|
84
88
|
Skip session close for: single bug fix, single-file edit, Q&A only.
|
|
85
89
|
|