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
|
@@ -0,0 +1,251 @@
|
|
|
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
|
+
}
|
|
@@ -64,8 +64,8 @@ export function insertHotRow(content, name, today) {
|
|
|
64
64
|
if (content.includes(link)) return content; // already present
|
|
65
65
|
const lines = content.split('\n');
|
|
66
66
|
// Scope the search to the "## Active Projects" section so a table appearing
|
|
67
|
-
// earlier in hot.md can't capture the row
|
|
68
|
-
//
|
|
67
|
+
// earlier in hot.md can't capture the row. Start looking from the heading;
|
|
68
|
+
// stop at the next H2 so we never cross sections.
|
|
69
69
|
const headingIdx = lines.findIndex((l) => /^##\s+Active Projects\s*$/.test(l));
|
|
70
70
|
if (headingIdx === -1) return null;
|
|
71
71
|
let sepIdx = -1;
|
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
|
@@ -9,13 +9,22 @@
|
|
|
9
9
|
* node scripts/resume.mjs [options]
|
|
10
10
|
*
|
|
11
11
|
* Options:
|
|
12
|
-
* --hypo-dir=<path> Hypomnema root
|
|
12
|
+
* --hypo-dir=<path> Hypomnema root. When omitted, resolveHypoRoot()
|
|
13
|
+
* (see lib/hypo-root.mjs) resolves it in priority order:
|
|
14
|
+
* 1. $HYPO_DIR if set — returned immediately; the
|
|
15
|
+
* hypo-config.md scan below is then skipped.
|
|
16
|
+
* 2. else the first of 7 fixed candidates
|
|
17
|
+
* (~/{hypomnema,wiki,notes,knowledge},
|
|
18
|
+
* ~/Documents/{hypomnema,wiki,notes}) that contains
|
|
19
|
+
* a hypo-config.md marker.
|
|
20
|
+
* 3. else the default ~/hypomnema.
|
|
13
21
|
* --project=<name> Project name (default: most recently active from hot.md)
|
|
14
22
|
* --json Output as JSON
|
|
15
23
|
*/
|
|
16
24
|
|
|
17
25
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
18
26
|
import { join } from 'path';
|
|
27
|
+
import { homedir } from 'os';
|
|
19
28
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
20
29
|
|
|
21
30
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
@@ -33,7 +42,43 @@ function parseArgs(argv) {
|
|
|
33
42
|
|
|
34
43
|
// ── active project from hot.md ───────────────────────────────────────────────
|
|
35
44
|
|
|
36
|
-
|
|
45
|
+
// Parse a single frontmatter scalar (mirrors the hook helpers in
|
|
46
|
+
// hypo-session-start.mjs / hypo-cwd-change.mjs — kept local per the hook
|
|
47
|
+
// self-contained convention rather than shared, to avoid script↔hook coupling).
|
|
48
|
+
function parseFrontmatterField(content, key) {
|
|
49
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
50
|
+
if (!m) return null;
|
|
51
|
+
const line = m[1].split('\n').find((l) => l.startsWith(`${key}:`));
|
|
52
|
+
if (!line) return null;
|
|
53
|
+
return line
|
|
54
|
+
.slice(key.length + 1)
|
|
55
|
+
.trim()
|
|
56
|
+
.replace(/^['"]|['"]$/g, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
|
|
60
|
+
// is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
|
|
61
|
+
// when cwd is falsy or matches none. Used only as a same-date tie-breaker.
|
|
62
|
+
function pickByCwd(hypoDir, slugs, cwd) {
|
|
63
|
+
if (!cwd) return null;
|
|
64
|
+
let best = null;
|
|
65
|
+
let bestLen = -1;
|
|
66
|
+
for (const slug of slugs) {
|
|
67
|
+
const indexPath = join(hypoDir, 'projects', slug, 'index.md');
|
|
68
|
+
if (!existsSync(indexPath)) continue;
|
|
69
|
+
const wd = parseFrontmatterField(readFileSync(indexPath, 'utf-8'), 'working_dir');
|
|
70
|
+
if (!wd) continue;
|
|
71
|
+
let resolved = wd.startsWith('~/') ? join(homedir(), wd.slice(2)) : wd;
|
|
72
|
+
resolved = resolved.replace(/\/+$/, ''); // trailing-slash normalize
|
|
73
|
+
if ((cwd === resolved || cwd.startsWith(resolved + '/')) && resolved.length > bestLen) {
|
|
74
|
+
bestLen = resolved.length;
|
|
75
|
+
best = slug;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return best;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveActiveProject(hypoDir, cwd = null) {
|
|
37
82
|
const hotPath = join(hypoDir, 'hot.md');
|
|
38
83
|
if (!existsSync(hotPath)) return null;
|
|
39
84
|
|
|
@@ -49,6 +94,19 @@ function resolveActiveProject(hypoDir) {
|
|
|
49
94
|
].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
|
|
50
95
|
if (wikiRows.length > 0) {
|
|
51
96
|
wikiRows.sort((a, b) => b.date.localeCompare(a.date));
|
|
97
|
+
// Same-date tie-break (ISSUE-1): when the top date is shared by >1 row,
|
|
98
|
+
// prefer the project whose working_dir contains cwd. No cwd / no match →
|
|
99
|
+
// keep the stable-sort winner (the legacy "first table row" behavior).
|
|
100
|
+
const topDate = wikiRows[0].date;
|
|
101
|
+
const tied = wikiRows.filter((r) => r.date === topDate);
|
|
102
|
+
if (cwd && tied.length > 1) {
|
|
103
|
+
const picked = pickByCwd(
|
|
104
|
+
hypoDir,
|
|
105
|
+
tied.map((r) => r.slug),
|
|
106
|
+
cwd,
|
|
107
|
+
);
|
|
108
|
+
if (picked) return picked;
|
|
109
|
+
}
|
|
52
110
|
return wikiRows[0].slug;
|
|
53
111
|
}
|
|
54
112
|
// Legacy markdown-link rows: | [name](projects/name/...) | ...
|
|
@@ -93,7 +151,7 @@ function readHot(hypoDir, project) {
|
|
|
93
151
|
|
|
94
152
|
const args = parseArgs(process.argv);
|
|
95
153
|
|
|
96
|
-
const project = args.project || resolveActiveProject(args.hypoDir);
|
|
154
|
+
const project = args.project || resolveActiveProject(args.hypoDir, process.cwd());
|
|
97
155
|
|
|
98
156
|
if (!project) {
|
|
99
157
|
console.error('Error: no active project found. Use --project=<name> or create a hot.md entry.');
|