hypomnema 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +904 -128
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -1,251 +0,0 @@
1
- /**
2
- * lib/pre-commit-format.mjs — pure logic for the auto-format-on-commit hook.
3
- *
4
- * Rule source: CLAUDE.md <formatting> directive. Pre-commit hook auto-runs the
5
- * project formatter on STAGED files only. Formatter failure is non-blocking;
6
- * only `git add` failure on restage is a true commit block.
7
- *
8
- * Why pure: lets tests construct synthetic staged sets without touching real
9
- * git or invoking prettier. The CLI shim in scripts/pre-commit-format.mjs
10
- * handles env resolution / repo-identity guards / process exit codes.
11
- *
12
- * Env discipline: every `git` spawn here MUST receive a caller-supplied `env`.
13
- * The lib never reads `process.env` for git operations — that defence is what
14
- * blocks GIT_DIR / GIT_WORK_TREE override attacks (see CONTRIBUTING.md).
15
- */
16
-
17
- import { spawnSync } from 'node:child_process';
18
- import { existsSync } from 'node:fs';
19
- import { join } from 'node:path';
20
-
21
- const STATUS_FILTER = 'ACMR';
22
-
23
- /**
24
- * Parse the NUL-token stream emitted by `git diff --cached --name-status -z`.
25
- *
26
- * Token shape (verified live against git 2.50): records are NUL-separated.
27
- * A\0path\0 (added)
28
- * M\0path\0 (modified)
29
- * D\0path\0 (deleted — filtered out by --diff-filter)
30
- * T\0path\0 (type change — filtered out)
31
- * R<score>\0old\0new\0 (rename)
32
- * C<score>\0old\0new\0 (copy)
33
- *
34
- * Paths containing TAB are valid — TAB is not a separator here (it appears in
35
- * the non-`-z` output, never in `-z`). Only NUL separates records.
36
- *
37
- * @param {string} buf Raw stdout from `git diff --cached --name-status -z`.
38
- * @returns {Array<{path: string, status: string}>}
39
- */
40
- export function parseNameStatus(buf) {
41
- const tokens = buf.split('\0');
42
- // Trailing NUL leaves an empty token; drop it (and any stray empties).
43
- while (tokens.length && tokens[tokens.length - 1] === '') tokens.pop();
44
- const out = [];
45
- for (let i = 0; i < tokens.length; ) {
46
- const status = tokens[i++];
47
- if (!status) continue;
48
- const head = status[0];
49
- if (head === 'R' || head === 'C') {
50
- // Two-path record. Old is irrelevant for formatting — only the new path
51
- // exists in the staged tree.
52
- i++; // consume old
53
- const next = tokens[i++];
54
- if (next) out.push({ path: next, status: head });
55
- } else if (head === 'A' || head === 'M') {
56
- const p = tokens[i++];
57
- if (p) out.push({ path: p, status: head });
58
- } else {
59
- // D, T, U, X — consume one path, drop. --diff-filter should exclude
60
- // these but we defensively skip.
61
- i++;
62
- }
63
- }
64
- return out;
65
- }
66
-
67
- /**
68
- * Parse `git ls-files --stage -z` output to map paths → file mode strings.
69
- * Output shape: `<mode> <hash> <stage>\t<path>\0`
70
- */
71
- export function parseLsFilesStage(buf) {
72
- const map = new Map();
73
- const records = buf.split('\0');
74
- for (const rec of records) {
75
- if (!rec) continue;
76
- const tabIdx = rec.indexOf('\t');
77
- if (tabIdx < 0) continue;
78
- const meta = rec.slice(0, tabIdx);
79
- const path = rec.slice(tabIdx + 1);
80
- const mode = meta.split(' ')[0];
81
- map.set(path, mode);
82
- }
83
- return map;
84
- }
85
-
86
- /**
87
- * Drop symlinks (120000) and gitlinks/submodules (160000). Regular file modes
88
- * (100644, 100755) are kept.
89
- */
90
- export function filterRegularFiles(entries, modeMap) {
91
- return entries.filter((e) => {
92
- const m = modeMap.get(e.path);
93
- if (!m) return false; // not in index — defensively skip
94
- return m !== '120000' && m !== '160000';
95
- });
96
- }
97
-
98
- /**
99
- * Partition staged paths into safe vs partial (also has unstaged hunks).
100
- * Partial files are skipped to avoid swallowing unstaged work.
101
- */
102
- export function partitionStagedFiles(entries, unstagedDirty) {
103
- const safe = [];
104
- const partial = [];
105
- for (const e of entries) {
106
- if (unstagedDirty.has(e.path)) partial.push(e);
107
- else safe.push(e);
108
- }
109
- return { safe, partial };
110
- }
111
-
112
- /**
113
- * Formatter dispatch table. Other entries (eslint, black, gofmt, cargo fmt)
114
- * are placeholders — the table is data, not a branch tree. Activate by
115
- * filling in an entry similar to `prettier`.
116
- *
117
- * Critically: NEVER use `npx` here. `npx prettier` may try a network install
118
- * on a cold machine; we want a local-only binary or no-op.
119
- */
120
- export function selectFormatter(repoRoot) {
121
- const prettierBin = join(repoRoot, 'node_modules', '.bin', 'prettier');
122
- if (existsSync(prettierBin)) {
123
- return {
124
- name: 'prettier',
125
- bin: prettierBin,
126
- buildArgs: (files) => ['--write', '--', ...files],
127
- };
128
- }
129
- return null;
130
- }
131
-
132
- /**
133
- * Run the formatter once with all safe files. Captures exit status; never
134
- * throws.
135
- */
136
- export function formatFiles(safe, formatter, { env, cwd } = {}) {
137
- if (!safe.length || !formatter) {
138
- return { ran: false, formatterFailed: false, reason: 'noop' };
139
- }
140
- const paths = safe.map((e) => e.path);
141
- const res = spawnSync(formatter.bin, formatter.buildArgs(paths), {
142
- cwd,
143
- env,
144
- encoding: 'utf-8',
145
- stdio: ['ignore', 'pipe', 'pipe'],
146
- });
147
- return {
148
- ran: true,
149
- formatterFailed: res.status !== 0,
150
- exitCode: res.status,
151
- stdout: res.stdout || '',
152
- stderr: res.stderr || '',
153
- };
154
- }
155
-
156
- /**
157
- * Re-stage the formatted files. Returns `{gitAddFailed: bool, stderr}`.
158
- * Prettier `--write` only writes on actual content change, so re-adding
159
- * unchanged files is a cheap no-op. Doing it unconditionally avoids a
160
- * before/after hash comparison.
161
- */
162
- export function restageFormatted(files, { env, cwd } = {}) {
163
- if (!files.length) return { gitAddFailed: false };
164
- const res = spawnSync('git', ['add', '--', ...files], {
165
- cwd,
166
- env,
167
- encoding: 'utf-8',
168
- stdio: ['ignore', 'pipe', 'pipe'],
169
- });
170
- return {
171
- gitAddFailed: res.status !== 0,
172
- stderr: res.stderr || '',
173
- };
174
- }
175
-
176
- /**
177
- * Top-level orchestrator used by the CLI shim. The caller supplies `cwd`
178
- * (the Hypomnema toplevel) and a sanitized `env` (no inherited `GIT_*`
179
- * except optionally a validated `GIT_INDEX_FILE`).
180
- *
181
- * @returns {{gitAddFailed: boolean, summary: string}}
182
- */
183
- export async function runPreCommitFormat({ cwd, env }) {
184
- const summary = [];
185
- const stagedRes = spawnSync(
186
- 'git',
187
- ['diff', '--cached', '--name-status', '-z', `--diff-filter=${STATUS_FILTER}`, '--'],
188
- { cwd, env, encoding: 'utf-8' },
189
- );
190
- if (stagedRes.status !== 0) {
191
- return { gitAddFailed: false, summary: 'git diff --cached failed; skipping' };
192
- }
193
- const staged = parseNameStatus(stagedRes.stdout || '');
194
- if (!staged.length) return { gitAddFailed: false, summary: 'no staged files' };
195
-
196
- // Filter out symlinks / submodules.
197
- const lsRes = spawnSync(
198
- 'git',
199
- ['ls-files', '--stage', '-z', '--', ...staged.map((e) => e.path)],
200
- { cwd, env, encoding: 'utf-8' },
201
- );
202
- let regular = staged;
203
- if (lsRes.status === 0) {
204
- const modeMap = parseLsFilesStage(lsRes.stdout || '');
205
- regular = filterRegularFiles(staged, modeMap);
206
- }
207
- if (!regular.length) return { gitAddFailed: false, summary: 'no regular staged files' };
208
-
209
- // Unstaged-dirty set for partition.
210
- const unstRes = spawnSync('git', ['diff', '--name-only', '-z', '--'], {
211
- cwd,
212
- env,
213
- encoding: 'utf-8',
214
- });
215
- const unstaged = new Set();
216
- if (unstRes.status === 0) {
217
- for (const p of (unstRes.stdout || '').split('\0')) {
218
- if (p) unstaged.add(p);
219
- }
220
- }
221
- const { safe, partial } = partitionStagedFiles(regular, unstaged);
222
- if (partial.length) {
223
- summary.push(`skipped ${partial.length} partially-staged file(s)`);
224
- }
225
- if (!safe.length) return { gitAddFailed: false, summary: summary.join('; ') || 'no safe files' };
226
-
227
- const formatter = selectFormatter(cwd);
228
- if (!formatter) {
229
- return {
230
- gitAddFailed: false,
231
- summary: [...summary, 'no formatter (node_modules/.bin/prettier missing)'].join('; '),
232
- };
233
- }
234
- const fmt = formatFiles(safe, formatter, { env, cwd });
235
- if (fmt.formatterFailed) {
236
- summary.push(`${formatter.name} exit ${fmt.exitCode} (non-blocking)`);
237
- return { gitAddFailed: false, summary: summary.join('; ') };
238
- }
239
- const restage = restageFormatted(
240
- safe.map((e) => e.path),
241
- { env, cwd },
242
- );
243
- if (restage.gitAddFailed) {
244
- return {
245
- gitAddFailed: true,
246
- summary: `git add failed: ${restage.stderr.trim()}`,
247
- };
248
- }
249
- summary.push(`formatted ${safe.length} file(s) via ${formatter.name}`);
250
- return { gitAddFailed: false, summary: summary.join('; ') };
251
- }
@@ -1,198 +0,0 @@
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
- }