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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/install-git-hooks.mjs — idempotent git pre-commit hook installer.
|
|
4
|
+
*
|
|
5
|
+
* Wired into package.json as the `prepare` script so it runs after every
|
|
6
|
+
* `npm install` / `npm ci` in this checkout. The installer is FULLY fail-open:
|
|
7
|
+
* any filesystem or git error → silent exit 0. The CLAUDE.md formatter rule
|
|
8
|
+
* is best-effort; a failed install must never block contributor onboarding.
|
|
9
|
+
*
|
|
10
|
+
* Trust model:
|
|
11
|
+
* - `expectedRoot` is derived from THIS script's filesystem location (via
|
|
12
|
+
* `import.meta.url` → realpath), NOT from `git rev-parse`. Git probes
|
|
13
|
+
* can be redirected by ambient GIT_DIR/GIT_WORK_TREE; the script's own
|
|
14
|
+
* path cannot.
|
|
15
|
+
* - All git probes use a scrubbed env (every name from `--local-env-vars`
|
|
16
|
+
* plus GIT_NAMESPACE / GIT_CEILING_DIRECTORIES / GIT_CONFIG_*).
|
|
17
|
+
* - Probes run with `cwd: expectedRoot` so npm `--prefix` invocations
|
|
18
|
+
* can't redirect resolution either.
|
|
19
|
+
* - Generated shim embeds `HYPOMNEMA_ROOT` + `HYPOMNEMA_GIT_DIR`. Runtime
|
|
20
|
+
* shim refuses to exec unless both literals match the current values.
|
|
21
|
+
*
|
|
22
|
+
* Refusal conditions (all exit 0):
|
|
23
|
+
* - `CI=true` env (npm ci on CI runs prepare; we must not touch hooks)
|
|
24
|
+
* - `npm_command` in {pack, publish} or `npm_lifecycle_event=prepublishOnly`
|
|
25
|
+
* - `.git/` absent (consumer install of the published tarball)
|
|
26
|
+
* - linked worktree (--absolute-git-dir != --git-common-dir)
|
|
27
|
+
* - toplevel != expectedRoot
|
|
28
|
+
* - hooks dir / pre-commit file is a symlink
|
|
29
|
+
* - existing non-marker pre-commit (don't clobber the user's own hook)
|
|
30
|
+
* - any filesystem error (ENOENT, EPERM, EACCES, …)
|
|
31
|
+
*
|
|
32
|
+
* Verbose mode: set HYPOMNEMA_HOOK_VERBOSE=1 to see skip/install reasons.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { execFileSync } from 'node:child_process';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import url from 'node:url';
|
|
39
|
+
|
|
40
|
+
function exitSilent(msg) {
|
|
41
|
+
if (process.env.HYPOMNEMA_HOOK_VERBOSE === '1') {
|
|
42
|
+
process.stderr.write(`[install-git-hooks] ${msg}\n`);
|
|
43
|
+
}
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Static fallback list for `--local-env-vars`. Used when we don't yet have a
|
|
48
|
+
// trusted git invocation (the installer is bootstrapping its own trust chain),
|
|
49
|
+
// and also when git is too old to support `--local-env-vars`.
|
|
50
|
+
const STATIC_LOCAL_ENV_VARS = [
|
|
51
|
+
'GIT_DIR',
|
|
52
|
+
'GIT_WORK_TREE',
|
|
53
|
+
'GIT_INDEX_FILE',
|
|
54
|
+
'GIT_OBJECT_DIRECTORY',
|
|
55
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
56
|
+
'GIT_COMMON_DIR',
|
|
57
|
+
'GIT_CONFIG',
|
|
58
|
+
'GIT_CONFIG_PARAMETERS',
|
|
59
|
+
'GIT_PREFIX',
|
|
60
|
+
'GIT_IMPLICIT_WORK_TREE',
|
|
61
|
+
'GIT_GRAFT_FILE',
|
|
62
|
+
'GIT_NO_REPLACE_OBJECTS',
|
|
63
|
+
'GIT_REPLACE_REF_BASE',
|
|
64
|
+
'GIT_SHALLOW_FILE',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
function buildScrubbedEnv(localEnvList) {
|
|
68
|
+
const scrub = new Set([
|
|
69
|
+
...(localEnvList || STATIC_LOCAL_ENV_VARS),
|
|
70
|
+
'GIT_NAMESPACE',
|
|
71
|
+
'GIT_CEILING_DIRECTORIES',
|
|
72
|
+
...Object.keys(process.env).filter((k) => /^GIT_CONFIG_/.test(k)),
|
|
73
|
+
]);
|
|
74
|
+
return Object.fromEntries(Object.entries(process.env).filter(([k]) => !scrub.has(k)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function shellSingleQuote(s) {
|
|
78
|
+
// POSIX-safe: 'x' → 'x', x'y → 'x'\''y'
|
|
79
|
+
return `'` + s.replace(/'/g, `'\\''`) + `'`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function shimBody(root, gitDir) {
|
|
83
|
+
return `#!/bin/sh
|
|
84
|
+
# hypomnema-pre-commit-marker v1
|
|
85
|
+
# Fail-open at every guard. Only the .mjs (after identity checks) can exit nonzero.
|
|
86
|
+
set +e
|
|
87
|
+
HYPOMNEMA_ROOT=${shellSingleQuote(root)}
|
|
88
|
+
HYPOMNEMA_GIT_DIR=${shellSingleQuote(gitDir)}
|
|
89
|
+
TOPLEVEL="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
|
|
90
|
+
[ -z "$TOPLEVEL" ] && exit 0
|
|
91
|
+
[ "$TOPLEVEL" = "$HYPOMNEMA_ROOT" ] || exit 0
|
|
92
|
+
ABSGITDIR="$(git rev-parse --absolute-git-dir 2>/dev/null)" || exit 0
|
|
93
|
+
[ "$ABSGITDIR" = "$HYPOMNEMA_GIT_DIR" ] || exit 0
|
|
94
|
+
SCRIPT="$HYPOMNEMA_ROOT/scripts/pre-commit-format.mjs"
|
|
95
|
+
[ -f "$SCRIPT" ] || exit 0
|
|
96
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
97
|
+
# Sentinel: tells the .mjs it is running under our trusted shim (not direct
|
|
98
|
+
# attacker invocation). The .mjs preserves inherited GIT_INDEX_FILE only when
|
|
99
|
+
# this sentinel is present — direct invocation drops it and falls back to the
|
|
100
|
+
# default \`.git/index\`. This closes prefix-matching attacks on the index
|
|
101
|
+
# whitelist (e.g. attacker-crafted .git/next-index-attack.lock).
|
|
102
|
+
HYPOMNEMA_HOOK_INVOCATION=1 exec node "$SCRIPT"
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeShim(target, root, gitDir) {
|
|
107
|
+
try {
|
|
108
|
+
fs.writeFileSync(target, shimBody(root, gitDir), { mode: 0o755 });
|
|
109
|
+
try {
|
|
110
|
+
fs.chmodSync(target, 0o755);
|
|
111
|
+
} catch {}
|
|
112
|
+
return exitSilent(`installed pre-commit hook for ${root}`);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return exitSilent(`write hook failed: ${e.code || e.message}; skipping`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function main() {
|
|
119
|
+
try {
|
|
120
|
+
// (0) CI / lifecycle guards.
|
|
121
|
+
if (process.env.CI === 'true') return exitSilent('CI=true; skipping');
|
|
122
|
+
const lc = process.env.npm_command;
|
|
123
|
+
if (lc === 'pack' || lc === 'publish') {
|
|
124
|
+
return exitSilent(`npm_command=${lc}; skipping`);
|
|
125
|
+
}
|
|
126
|
+
if (process.env.npm_lifecycle_event === 'prepublishOnly') {
|
|
127
|
+
return exitSilent('prepublishOnly; skipping');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// (1) Derive expectedRoot from THIS script's location, not from git.
|
|
131
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
132
|
+
let expectedRoot;
|
|
133
|
+
try {
|
|
134
|
+
expectedRoot = fs.realpathSync(path.resolve(here, '..'));
|
|
135
|
+
} catch {
|
|
136
|
+
return exitSilent('cannot resolve script location; skipping');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// (2) Bootstrap scrubbed env with the static fallback list, just enough to
|
|
140
|
+
// get a trusted git probe running. Then enrich from --local-env-vars at
|
|
141
|
+
// runtime (modern git) so the scrub list always tracks git's own truth.
|
|
142
|
+
let cleanEnv = buildScrubbedEnv(null);
|
|
143
|
+
let run = (args) =>
|
|
144
|
+
execFileSync('git', args, {
|
|
145
|
+
encoding: 'utf-8',
|
|
146
|
+
env: cleanEnv,
|
|
147
|
+
cwd: expectedRoot,
|
|
148
|
+
}).trim();
|
|
149
|
+
try {
|
|
150
|
+
const list = run(['rev-parse', '--local-env-vars']).split(/\r?\n/).filter(Boolean);
|
|
151
|
+
cleanEnv = buildScrubbedEnv(list);
|
|
152
|
+
run = (args) =>
|
|
153
|
+
execFileSync('git', args, {
|
|
154
|
+
encoding: 'utf-8',
|
|
155
|
+
env: cleanEnv,
|
|
156
|
+
cwd: expectedRoot,
|
|
157
|
+
}).trim();
|
|
158
|
+
} catch {
|
|
159
|
+
// Old git without --local-env-vars; keep static-list cleanEnv.
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// (3) Probe git with sanitized env.
|
|
163
|
+
let topR, absGitDir, commonDir;
|
|
164
|
+
try {
|
|
165
|
+
topR = fs.realpathSync(run(['rev-parse', '--show-toplevel']));
|
|
166
|
+
absGitDir = fs.realpathSync(run(['rev-parse', '--absolute-git-dir']));
|
|
167
|
+
const cd = run(['rev-parse', '--git-common-dir']);
|
|
168
|
+
commonDir = fs.realpathSync(path.isAbsolute(cd) ? cd : path.join(absGitDir, '..', cd));
|
|
169
|
+
} catch {
|
|
170
|
+
return exitSilent('git probe failed; skipping');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// (4) Identity + worktree + containment.
|
|
174
|
+
if (topR !== expectedRoot) {
|
|
175
|
+
return exitSilent('toplevel != expectedRoot; skipping');
|
|
176
|
+
}
|
|
177
|
+
if (absGitDir !== commonDir) {
|
|
178
|
+
return exitSilent('linked worktree; skipping');
|
|
179
|
+
}
|
|
180
|
+
if (!absGitDir.startsWith(expectedRoot + path.sep)) {
|
|
181
|
+
return exitSilent('gitDir outside expectedRoot; skipping');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// (5) Resolve hooks dir (still under sanitized env).
|
|
185
|
+
let rawHooksDir;
|
|
186
|
+
try {
|
|
187
|
+
rawHooksDir = run(['rev-parse', '--git-path', 'hooks']);
|
|
188
|
+
} catch {
|
|
189
|
+
return exitSilent('cannot resolve hooks dir; skipping');
|
|
190
|
+
}
|
|
191
|
+
if (!path.isAbsolute(rawHooksDir)) {
|
|
192
|
+
rawHooksDir = path.resolve(expectedRoot, rawHooksDir);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!fs.existsSync(rawHooksDir)) {
|
|
196
|
+
// Git creates .git/hooks lazily. Only create when it would land inside
|
|
197
|
+
// absGitDir — protects against `core.hooksPath=/elsewhere`.
|
|
198
|
+
if (!rawHooksDir.startsWith(absGitDir + path.sep)) {
|
|
199
|
+
return exitSilent('hooks dir outside gitDir; skipping');
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
fs.mkdirSync(rawHooksDir, { recursive: true });
|
|
203
|
+
} catch {
|
|
204
|
+
return exitSilent('mkdir hooks dir failed; skipping');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let absHooksDir;
|
|
209
|
+
try {
|
|
210
|
+
absHooksDir = fs.realpathSync(rawHooksDir);
|
|
211
|
+
} catch {
|
|
212
|
+
return exitSilent('realpath hooks dir failed; skipping');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// (6) Symlink rejection + containment.
|
|
216
|
+
try {
|
|
217
|
+
if (fs.lstatSync(rawHooksDir).isSymbolicLink()) {
|
|
218
|
+
return exitSilent('hooks dir is symlink; skipping');
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
return exitSilent('lstat hooks dir failed; skipping');
|
|
222
|
+
}
|
|
223
|
+
const rel = path.relative(absGitDir, absHooksDir);
|
|
224
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
225
|
+
return exitSilent('hooks dir outside .git/; skipping');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// (7) Existing pre-commit logic.
|
|
229
|
+
const target = path.join(absHooksDir, 'pre-commit');
|
|
230
|
+
let existing;
|
|
231
|
+
try {
|
|
232
|
+
existing = fs.lstatSync(target);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
if (e.code === 'ENOENT') {
|
|
235
|
+
return writeShim(target, expectedRoot, absGitDir);
|
|
236
|
+
}
|
|
237
|
+
return exitSilent(`stat target failed: ${e.code}; skipping`);
|
|
238
|
+
}
|
|
239
|
+
if (existing.isSymbolicLink()) {
|
|
240
|
+
return exitSilent('pre-commit is symlink; not overwriting');
|
|
241
|
+
}
|
|
242
|
+
let head;
|
|
243
|
+
try {
|
|
244
|
+
head = fs.readFileSync(target, 'utf-8').split('\n').slice(0, 3).join('\n');
|
|
245
|
+
} catch {
|
|
246
|
+
return exitSilent('read existing pre-commit failed; skipping');
|
|
247
|
+
}
|
|
248
|
+
if (head.includes('hypomnema-pre-commit-marker v1')) {
|
|
249
|
+
// Same marker — regenerate (refreshes embedded root if checkout moved).
|
|
250
|
+
return writeShim(target, expectedRoot, absGitDir);
|
|
251
|
+
}
|
|
252
|
+
return exitSilent('existing non-marker pre-commit; not overwriting');
|
|
253
|
+
} catch (e) {
|
|
254
|
+
return exitSilent(`unexpected: ${e.code || e.message}; skipping`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
main();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adr-corpus — fs-backed production-code corpus search for the ADR-line grep.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from the pure verifier (scripts/lib/fix-status-verify.mjs) so
|
|
5
|
+
* that layer stays IO-free and unit-testable with injected searchFns. This
|
|
6
|
+
* module is itself testable against real temp directories.
|
|
7
|
+
*
|
|
8
|
+
* CRITICAL (self-match): the manifest module (scripts/lib/fix-manifest.mjs)
|
|
9
|
+
* lives *inside* the scripts/ corpus and holds every adrKeyLine as a literal.
|
|
10
|
+
* If it were scanned, every line would self-match and ADR_LINE_MISSING could
|
|
11
|
+
* never fire — the gate would be silently vacuous. The builder therefore
|
|
12
|
+
* excludes caller-supplied paths, resolved absolute, BEFORE reading any file.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_EXTENSIONS = ['.mjs', '.js', '.md', '.json', '.cjs'];
|
|
19
|
+
|
|
20
|
+
function* walk(dir, excludeAbs, extensions) {
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
24
|
+
} catch {
|
|
25
|
+
return; // missing corpus dir is not fatal — other dirs may exist
|
|
26
|
+
}
|
|
27
|
+
for (const ent of entries) {
|
|
28
|
+
const full = join(dir, ent.name);
|
|
29
|
+
if (excludeAbs.has(resolve(full))) continue;
|
|
30
|
+
if (ent.isDirectory()) {
|
|
31
|
+
if (ent.name === 'node_modules' || ent.name === '.git') continue;
|
|
32
|
+
yield* walk(full, excludeAbs, extensions);
|
|
33
|
+
} else if (ent.isFile()) {
|
|
34
|
+
if (extensions.some((e) => ent.name.endsWith(e))) yield full;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a fixed-string corpus search function.
|
|
41
|
+
*
|
|
42
|
+
* buildCorpusSearch({ repoRoot, includeDirs, excludePaths, extensions })
|
|
43
|
+
* → (literal:string) => boolean
|
|
44
|
+
*
|
|
45
|
+
* - includeDirs / excludePaths are resolved relative to repoRoot.
|
|
46
|
+
* - excludePaths are matched by resolved absolute path (handles symlinks of the
|
|
47
|
+
* caller-supplied path consistently with the walk's resolve()).
|
|
48
|
+
* - search is case-sensitive String.includes (fixed string, not regex).
|
|
49
|
+
*
|
|
50
|
+
* Files are read once and cached so repeated searches (one per manifest row)
|
|
51
|
+
* do not re-walk the tree.
|
|
52
|
+
*/
|
|
53
|
+
export function buildCorpusSearch({
|
|
54
|
+
repoRoot,
|
|
55
|
+
includeDirs,
|
|
56
|
+
excludePaths = [],
|
|
57
|
+
extensions = DEFAULT_EXTENSIONS,
|
|
58
|
+
}) {
|
|
59
|
+
const excludeAbs = new Set(excludePaths.map((p) => resolve(repoRoot, p)));
|
|
60
|
+
const contents = [];
|
|
61
|
+
for (const dir of includeDirs) {
|
|
62
|
+
const abs = resolve(repoRoot, dir);
|
|
63
|
+
let isDir = false;
|
|
64
|
+
try {
|
|
65
|
+
isDir = statSync(abs).isDirectory();
|
|
66
|
+
} catch {
|
|
67
|
+
isDir = false;
|
|
68
|
+
}
|
|
69
|
+
if (!isDir) continue;
|
|
70
|
+
for (const file of walk(abs, excludeAbs, extensions)) {
|
|
71
|
+
try {
|
|
72
|
+
contents.push(readFileSync(file, 'utf-8'));
|
|
73
|
+
} catch {
|
|
74
|
+
/* unreadable file — skip */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return (literal) => contents.some((text) => text.includes(literal));
|
|
79
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/check-bilingual.mjs — pure validators for the bilingual release-doc rule.
|
|
3
|
+
*
|
|
4
|
+
* Rule source: CLAUDE.md learned_behaviors release-doc-bilingual (2026-05-24).
|
|
5
|
+
* Every Hypomnema OSS ship must carry an English body PLUS a Korean summary
|
|
6
|
+
* block in both the CHANGELOG section and the git tag annotation. This module
|
|
7
|
+
* exports the parsing/validation primitives; the CLI wrapper in
|
|
8
|
+
* scripts/check-bilingual.mjs handles I/O and exit codes.
|
|
9
|
+
*
|
|
10
|
+
* Scope (deliberate): this gate only enforces the KOREAN half. The English
|
|
11
|
+
* half has been historically present in every ship — the rule exists because
|
|
12
|
+
* the Korean half is what gets silently dropped under time pressure (see
|
|
13
|
+
* release-doc-bilingual feedback page). A tag body of "---" + Korean with an
|
|
14
|
+
* empty English section would technically pass these validators, but that's
|
|
15
|
+
* acceptable because such a body is not a realistic failure mode for a
|
|
16
|
+
* maintainer who already wrote the English release notes. If "English missing"
|
|
17
|
+
* ever becomes a real regression vector, add an English-half threshold.
|
|
18
|
+
*
|
|
19
|
+
* Why pure functions: lets tests/runner.mjs construct synthetic CHANGELOG /
|
|
20
|
+
* tag-body strings without touching real git or real CHANGELOG.md.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// AC00–D7A3 covers all precomposed Hangul syllables. We NFC-normalize the
|
|
24
|
+
// input before counting so jamo-only inputs (decomposed) still match after
|
|
25
|
+
// composition.
|
|
26
|
+
export const HANGUL_RE = /[가-힣]/g;
|
|
27
|
+
export const HANGUL_BODY_THRESHOLD = 10;
|
|
28
|
+
|
|
29
|
+
export function countHangul(text) {
|
|
30
|
+
return (text.normalize('NFC').match(HANGUL_RE) || []).length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function escapeRegex(s) {
|
|
34
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate that CHANGELOG.md content has a "## [<version>]" section containing
|
|
39
|
+
* a "### 한글 요약" sub-section with >= HANGUL_BODY_THRESHOLD Hangul chars.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} content CHANGELOG.md raw content (CRLF tolerated).
|
|
42
|
+
* @param {string} version Semver string to look up (e.g. "1.2.1").
|
|
43
|
+
* @returns {{ok: true, hangulCount: number} | {ok: false, reason: string}}
|
|
44
|
+
*/
|
|
45
|
+
export function validateChangelog(content, version) {
|
|
46
|
+
if (!version) return { ok: false, reason: 'no version supplied' };
|
|
47
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
48
|
+
const versionEsc = escapeRegex(version);
|
|
49
|
+
// Anchor closing bracket so 1.2.1 does NOT match the prefix of 1.2.10.
|
|
50
|
+
// Allow trailing " - YYYY-MM-DD" or nothing.
|
|
51
|
+
const sectionRe = new RegExp(`^## \\[${versionEsc}\\](\\s.*)?$`);
|
|
52
|
+
|
|
53
|
+
const startIndices = [];
|
|
54
|
+
for (let i = 0; i < lines.length; i++) {
|
|
55
|
+
if (sectionRe.test(lines[i])) startIndices.push(i);
|
|
56
|
+
}
|
|
57
|
+
if (startIndices.length === 0) {
|
|
58
|
+
return { ok: false, reason: `no "## [${version}]" section in CHANGELOG.md` };
|
|
59
|
+
}
|
|
60
|
+
if (startIndices.length > 1) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
reason: `duplicate "## [${version}]" sections (${startIndices.length}) in CHANGELOG.md`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const start = startIndices[0];
|
|
68
|
+
let sectionEnd = lines.length;
|
|
69
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
70
|
+
if (/^## /.test(lines[i])) {
|
|
71
|
+
sectionEnd = i;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const sectionLines = lines.slice(start, sectionEnd);
|
|
76
|
+
|
|
77
|
+
const koreanHeadIdx = sectionLines.findIndex((l) => l.trim() === '### 한글 요약');
|
|
78
|
+
if (koreanHeadIdx === -1) {
|
|
79
|
+
return { ok: false, reason: `section [${version}] missing "### 한글 요약" sub-section` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Bound the Korean block: stop at the next H2 OR H3. CHANGELOG.md has
|
|
83
|
+
// sibling H3s like "### Internal", "### Fixed" — Hangul in those sections
|
|
84
|
+
// does not count toward the "### 한글 요약" requirement.
|
|
85
|
+
let koreanEnd = sectionLines.length;
|
|
86
|
+
for (let i = koreanHeadIdx + 1; i < sectionLines.length; i++) {
|
|
87
|
+
if (/^(##|###) /.test(sectionLines[i])) {
|
|
88
|
+
koreanEnd = i;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const body = sectionLines.slice(koreanHeadIdx + 1, koreanEnd).join('\n');
|
|
93
|
+
const count = countHangul(body);
|
|
94
|
+
if (count < HANGUL_BODY_THRESHOLD) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
reason:
|
|
98
|
+
`section [${version}] "### 한글 요약" body has ${count} Hangul chars ` +
|
|
99
|
+
`(threshold: ${HANGUL_BODY_THRESHOLD}). Heading alone does not count — write real Korean summary.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, hangulCount: count };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate that a git tag annotation body has a "---" separator with Korean
|
|
107
|
+
* text after the LAST such separator. Tolerates earlier "---" markdown
|
|
108
|
+
* horizontal rules in the English body.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} body Tag annotation body, as returned by
|
|
111
|
+
* `git tag -l --format='%(contents)' <ref>`.
|
|
112
|
+
* @returns {{ok: true, hangulCount: number} | {ok: false, reason: string}}
|
|
113
|
+
*/
|
|
114
|
+
export function validateTagBody(body) {
|
|
115
|
+
const lines = body.replace(/\r\n/g, '\n').split('\n');
|
|
116
|
+
let lastSepIdx = -1;
|
|
117
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
118
|
+
if (lines[i].trim() === '---') {
|
|
119
|
+
lastSepIdx = i;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (lastSepIdx === -1) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
reason:
|
|
127
|
+
'tag annotation has no "---" separator line (expected: English body + "---" + Korean)',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const tail = lines.slice(lastSepIdx + 1).join('\n');
|
|
131
|
+
const count = countHangul(tail);
|
|
132
|
+
if (count < HANGUL_BODY_THRESHOLD) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason:
|
|
136
|
+
`tag body after the last "---" has only ${count} Hangul chars ` +
|
|
137
|
+
`(threshold: ${HANGUL_BODY_THRESHOLD}). Write a real Korean summary after the separator.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, hangulCount: count };
|
|
141
|
+
}
|
|
@@ -80,8 +80,8 @@ function pkgRootDir(target) {
|
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Discover sync-eligible extensions under `extDir`. Returns a per-type map plus a
|
|
83
|
-
* `warnings` array. Applies the `.hypoignore` filter (#30), the basename
|
|
84
|
-
* whitelist (#9), and pairs each file with its optional `<name>.manifest.json`.
|
|
83
|
+
* `warnings` array. Applies the `.hypoignore` filter (fix #30), the basename
|
|
84
|
+
* whitelist (plan §5 #9), and pairs each file with its optional `<name>.manifest.json`.
|
|
85
85
|
* No-ops gracefully when extDir is absent (e.g. --from-remote clones, plan §5 #8).
|
|
86
86
|
*/
|
|
87
87
|
export function discoverExtensions(extDir, hypoignorePatterns, hypoDir) {
|
|
@@ -364,7 +364,7 @@ export function syncExtensions({
|
|
|
364
364
|
const discovered = discoverExtensions(extDir, patterns, hypoDir);
|
|
365
365
|
result.warnings.push(...discovered.warnings);
|
|
366
366
|
|
|
367
|
-
// E4 (#32): Codex supports hooks + commands only. If the user authored
|
|
367
|
+
// E4 (fix #32): Codex supports hooks + commands only. If the user authored
|
|
368
368
|
// skills/agents extensions, surface a one-time notice that they are skipped
|
|
369
369
|
// for this target rather than silently dropping them (plan §2 E4).
|
|
370
370
|
if (target === 'codex') {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Feedback page `scope:` field vocabulary — shared single source of truth.
|
|
2
|
+
//
|
|
3
|
+
// Consumed by:
|
|
4
|
+
// - scripts/lint.mjs (lint-time validation of feedback page frontmatter)
|
|
5
|
+
// - scripts/feedback.mjs (create-time --scope validation in /hypo:feedback)
|
|
6
|
+
// Keep this the ONLY definition; both consumers import it so the two validators
|
|
7
|
+
// never drift (ADR 0034 / OQ-34). feedback-sync.mjs matches scope by plain string
|
|
8
|
+
// equality (not this regex), so it is intentionally not a consumer.
|
|
9
|
+
//
|
|
10
|
+
// Accepts `global` or `project:<id>`. The `<id>` charset matches what
|
|
11
|
+
// deriveProjectId() (feedback-sync.mjs) emits from a cwd: `/` and `.` are both
|
|
12
|
+
// replaced with `-`, producing leading-dash, mixed-case ids like
|
|
13
|
+
// `-Users-you-Workspace-Project`. So the class allows a leading `-`, mixed case,
|
|
14
|
+
// and `_`/`-` — but deliberately NOT `.`:
|
|
15
|
+
// - deriveProjectId never emits `.` (it is replaced), so excluding it loses
|
|
16
|
+
// nothing for the derived path; and
|
|
17
|
+
// - the resolved project-id is path-joined in feedback-sync.mjs:213, so keeping
|
|
18
|
+
// `.` out of the vocabulary avoids ever blessing `project:.` / `project:..`.
|
|
19
|
+
// Known limit: a cwd containing spaces (or other path chars outside [A-Za-z0-9_-])
|
|
20
|
+
// still derives an id this regex rejects; pass `--project-id=<id>` for those.
|
|
21
|
+
export const FEEDBACK_SCOPE_RE = /^(global|project:[A-Za-z0-9_-]+)$/;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fix-manifest — evidence mapping for claimed-merged fixes (Phase 2, A-sot).
|
|
3
|
+
*
|
|
4
|
+
* ADR 0036: this module is the *evidence* SoT (fix → test + ADR-line), NOT the
|
|
5
|
+
* *status* SoT. "Is fix N merged?" is answered solely by the wiki spec
|
|
6
|
+
* (parseStatus). A manifest row says "if the spec claims N merged, here is the
|
|
7
|
+
* test that proves the behavior and the production-code line that proves the
|
|
8
|
+
* ADR's core decision shipped."
|
|
9
|
+
*
|
|
10
|
+
* Shape (ADR 0036 decision 2 — NO `status` field):
|
|
11
|
+
* { fixId:number, testNames:string[], adrPath:string|null, adrKeyLine:string }
|
|
12
|
+
*
|
|
13
|
+
* - testNames: MUST set-equal the `// @fix #N:` anchors in tests/runner.mjs
|
|
14
|
+
* (drift is an error — MANIFEST_TEST_DRIFT). Multiple anchors → multiple
|
|
15
|
+
* names. The NO_AUTO_TEST sentinel is the ONLY allowed lone entry; it may
|
|
16
|
+
* not be mixed with real test names.
|
|
17
|
+
* - adrPath: path (relative to the wiki root) of the ADR whose core decision
|
|
18
|
+
* this fix implements. `null` iff adrKeyLine is the NO_ADR sentinel.
|
|
19
|
+
* - adrKeyLine: a maintainer-curated literal that embodies the fix's shipped
|
|
20
|
+
* decision and exists verbatim in production code (scripts/ hooks/ commands/
|
|
21
|
+
* skills/ templates/). Verified by fixed-string grep — 0 hits is
|
|
22
|
+
* ADR_LINE_MISSING. The NO_ADR sentinel exempts a fix that has no ADR (small
|
|
23
|
+
* / doctor fixes); the test-green check still applies. NO_ADR is NOT for
|
|
24
|
+
* fixes that have an ADR but whose evidence lives outside the corpus.
|
|
25
|
+
*
|
|
26
|
+
* Coverage contract: every fix that is BOTH claimed-merged in the spec AND
|
|
27
|
+
* anchored in the runner must have exactly one row here (MANIFEST_MISSING_ROW
|
|
28
|
+
* is an error). Fixes anchored but not claimed (e.g. #18) are ORPHAN_ANCHOR
|
|
29
|
+
* warnings and need no row.
|
|
30
|
+
*
|
|
31
|
+
* Corpus note (spec §A amendment, 2026-06-07): the ADR-line grep corpus is
|
|
32
|
+
* scripts/ hooks/ commands/ skills/ AND templates/. templates/ ships via npm
|
|
33
|
+
* `files`, so prompt-driven fixes whose decision is installed as template text
|
|
34
|
+
* (e.g. #20 proactive close offer) are honestly verifiable there.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export const NO_ADR = 'NO_ADR';
|
|
38
|
+
export const NO_AUTO_TEST = 'NO_AUTO_TEST';
|
|
39
|
+
|
|
40
|
+
export const FIX_MANIFEST = [
|
|
41
|
+
{
|
|
42
|
+
fixId: 15,
|
|
43
|
+
testNames: ['all type-conditional fields present → green'],
|
|
44
|
+
adrPath: 'decisions/0030-hypoignore-enforce-all-injection-hooks.md',
|
|
45
|
+
adrKeyLine: 'isIgnored(path, HYPO_DIR, patterns)',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
fixId: 17,
|
|
49
|
+
testNames: [
|
|
50
|
+
'5 mandatory memory files fresh → suppressOutput:true',
|
|
51
|
+
'project hot.md not updated today → block, reason names the file',
|
|
52
|
+
'open-questions.md absent/stale → still passes (conditional, not gated)',
|
|
53
|
+
],
|
|
54
|
+
adrPath: 'decisions/0022-session-close-ux-automation.md',
|
|
55
|
+
adrKeyLine: 'sessionCloseFileStatus',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
// Behavioral / prompt-driven: no automated test, evidence is the installed
|
|
59
|
+
// template prompt (templates/hypo-guide.md). Has an ADR (0022), so NOT
|
|
60
|
+
// NO_ADR — the adrKeyLine greps the shipped template text.
|
|
61
|
+
fixId: 20,
|
|
62
|
+
testNames: [NO_AUTO_TEST],
|
|
63
|
+
adrPath: 'decisions/0022-session-close-ux-automation.md',
|
|
64
|
+
adrKeyLine: '이 작업이 마무리되었나요? 세션을 정리(crystallize)할까요?',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
fixId: 25,
|
|
68
|
+
testNames: [
|
|
69
|
+
'replay-compact-guard-detects-slash-clear: /clear with incomplete wiki → WIKI_AUTOCLOSE',
|
|
70
|
+
],
|
|
71
|
+
adrPath: 'decisions/0022-session-close-ux-automation.md',
|
|
72
|
+
adrKeyLine: '[WIKI_AUTOCLOSE]',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
// Removal fix (capacity bypass deleted). The shipped evidence is the
|
|
76
|
+
// deliberate removal-marker comment + the negative-control test.
|
|
77
|
+
fixId: 26,
|
|
78
|
+
testNames: [
|
|
79
|
+
'replay-personal-check-bypass-order: wiki-context-critical.json does NOT bypass (fix #26 negative control)',
|
|
80
|
+
],
|
|
81
|
+
adrPath: 'decisions/0022-session-close-ux-automation.md',
|
|
82
|
+
adrKeyLine: 'Capacity bypass (≥90%) REMOVED — fix #26',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
fixId: 27,
|
|
86
|
+
testNames: [
|
|
87
|
+
'replay-auto-minimal-crystallize-on-incomplete-close: mutating + no marker + close-intent → block',
|
|
88
|
+
'replay-auto-minimal-crystallize-on-incomplete-close: valid marker → continue (even with close-intent)',
|
|
89
|
+
],
|
|
90
|
+
adrPath: 'decisions/0022-session-close-ux-automation.md',
|
|
91
|
+
adrKeyLine: 'The hook NEVER writes the marker',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
// No dedicated ADR (schema-vocab tag validation); test-green only.
|
|
95
|
+
fixId: 36,
|
|
96
|
+
testNames: ['PascalCase tag → error', 'unknown tag (not in vocab) → error'],
|
|
97
|
+
adrPath: null,
|
|
98
|
+
adrKeyLine: NO_ADR,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
fixId: 38,
|
|
102
|
+
testNames: [
|
|
103
|
+
'clean-wiki payload → ok:true, new entries appended (apply dedup is exact-entry, not date-based)',
|
|
104
|
+
'idempotent: re-running same payload produces no new bytes (file mtimes unchanged)',
|
|
105
|
+
],
|
|
106
|
+
adrPath: 'decisions/0029-crystallize-session-close-depth-expansion.md',
|
|
107
|
+
adrKeyLine: 'exact-entry append dedup',
|
|
108
|
+
},
|
|
109
|
+
];
|