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
|
@@ -37,6 +37,10 @@ import {
|
|
|
37
37
|
computeNotice,
|
|
38
38
|
markNotified,
|
|
39
39
|
isOptedOut,
|
|
40
|
+
resolveCliOnPath,
|
|
41
|
+
computeSiblingNotice,
|
|
42
|
+
siblingAlreadyNotified,
|
|
43
|
+
markSiblingNotified,
|
|
40
44
|
} from './version-check.mjs';
|
|
41
45
|
|
|
42
46
|
// Privacy guard: refuse to read+inject .hypoignore-matched
|
|
@@ -119,6 +123,45 @@ function buildUpdateNotice() {
|
|
|
119
123
|
}
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Stale-sibling notice (ADR 0038, D3). The update-notifier above only knows
|
|
128
|
+
* whether the ACTIVE install is behind latest — it is blind to an OLDER sibling
|
|
129
|
+
* that owns the `hypomnema` bin on PATH. That sibling is the live footgun:
|
|
130
|
+
* running `hypomnema init`/`upgrade` through it downgrades the active hooks.
|
|
131
|
+
*
|
|
132
|
+
* This is the ONLY surface that reaches a user already in that state, because it
|
|
133
|
+
* runs from the (newer) active hook — `doctor` invoked via the stale CLI would
|
|
134
|
+
* run the stale doctor. fs-only (no npm/which spawn). Throttled via the cache so
|
|
135
|
+
* it nags once per (cliPath@cliVersion → activeVersion) tuple. Best-effort.
|
|
136
|
+
*/
|
|
137
|
+
function buildSiblingNotice() {
|
|
138
|
+
try {
|
|
139
|
+
if (isOptedOut()) return '';
|
|
140
|
+
// Active install identity = hypo-pkg.json (what init/upgrade write). This is
|
|
141
|
+
// the authoritative pkgRoot+version; ACTIVE_ROOT (~/.claude) has no package.json.
|
|
142
|
+
let active = null;
|
|
143
|
+
try {
|
|
144
|
+
active = JSON.parse(readFileSync(join(homedir(), '.claude', 'hypo-pkg.json'), 'utf-8'));
|
|
145
|
+
} catch {
|
|
146
|
+
return ''; // no active metadata → nothing to compare a sibling against
|
|
147
|
+
}
|
|
148
|
+
if (!active || !active.pkgVersion) return '';
|
|
149
|
+
const cli = resolveCliOnPath('hypomnema');
|
|
150
|
+
const notice = computeSiblingNotice(cli, {
|
|
151
|
+
pkgRoot: active.pkgRoot,
|
|
152
|
+
version: active.pkgVersion,
|
|
153
|
+
});
|
|
154
|
+
if (!notice) return '';
|
|
155
|
+
const cachePath = defaultCachePath();
|
|
156
|
+
const cache = readCache(cachePath);
|
|
157
|
+
if (siblingAlreadyNotified(cache, notice.key)) return '';
|
|
158
|
+
markSiblingNotified(cachePath, notice.key);
|
|
159
|
+
return notice.line;
|
|
160
|
+
} catch {
|
|
161
|
+
return '';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
122
165
|
const PROJECTS_DIR = join(HYPO_DIR, 'projects');
|
|
123
166
|
const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
|
|
124
167
|
|
|
@@ -170,7 +213,7 @@ function gitPull(dir) {
|
|
|
170
213
|
|
|
171
214
|
/**
|
|
172
215
|
* fix #10: surface unresolved sync failures recorded by a prior session's
|
|
173
|
-
* Stop hook (#9). The entry is cleared only once this session's pull has
|
|
216
|
+
* Stop hook (fix #9). The entry is cleared only once this session's pull has
|
|
174
217
|
* succeeded AND there is no unpushed commit left behind by a failed push
|
|
175
218
|
* (`[ahead N]`).
|
|
176
219
|
*
|
|
@@ -280,6 +323,10 @@ let raw = '';
|
|
|
280
323
|
process.stdin.setEncoding('utf-8');
|
|
281
324
|
process.stdin.on('data', (chunk) => (raw += chunk));
|
|
282
325
|
process.stdin.on('end', () => {
|
|
326
|
+
// ISSUE-5: declared before the try so every emit branch — including the outer
|
|
327
|
+
// catch — carries the same `systemMessage` (the user-visible update/sibling
|
|
328
|
+
// banner). Reassigned once below after the notices are computed.
|
|
329
|
+
let outExtra = { continue: true, suppressOutput: true };
|
|
283
330
|
try {
|
|
284
331
|
let data = {};
|
|
285
332
|
try {
|
|
@@ -289,23 +336,33 @@ process.stdin.on('end', () => {
|
|
|
289
336
|
const pullOk = gitPull(HYPO_DIR);
|
|
290
337
|
const syncLine = syncStateNotice(pullOk);
|
|
291
338
|
const growthLine = readLastGrowthLine();
|
|
292
|
-
//
|
|
339
|
+
// ADR 0022 amendment: on source='clear', surface the dying
|
|
293
340
|
// session's identity that hypo-session-end stashed so Claude can recover
|
|
294
341
|
// session-close work that /clear skipped. One-shot: marker is unlinked
|
|
295
342
|
// immediately after read.
|
|
296
343
|
const clearRecoveryLine = buildClearRecoveryLine(data.source);
|
|
297
344
|
const updateLine = buildUpdateNotice();
|
|
298
|
-
|
|
299
|
-
// the
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
345
|
+
const siblingLine = buildSiblingNotice();
|
|
346
|
+
// ISSUE-5: the update + stale-sibling banners must reach the USER. On a
|
|
347
|
+
// SessionStart hook that exits 0, stderr is invisible in the normal TUI
|
|
348
|
+
// (only shown on exit 2 / --verbose) and additionalContext is model-only —
|
|
349
|
+
// `systemMessage` is the documented user-visible channel. Route those two
|
|
350
|
+
// banners there. They ALSO stay in noticePrefix → additionalContext below,
|
|
351
|
+
// so the model and the user start the session looking at the same state.
|
|
352
|
+
// (The other stderr notices — sync/growth/clear/suggest — are intentionally
|
|
353
|
+
// transcript/--verbose only and out of ISSUE-5's scope.)
|
|
354
|
+
const userMessage = [updateLine, siblingLine].filter(Boolean).join('\n\n');
|
|
355
|
+
if (userMessage) outExtra = { ...outExtra, systemMessage: userMessage };
|
|
356
|
+
const notices = [syncLine, growthLine, clearRecoveryLine, updateLine, siblingLine].filter(
|
|
357
|
+
Boolean,
|
|
358
|
+
);
|
|
303
359
|
let noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
304
360
|
if (syncLine) process.stderr.write(`\n\x1b[33m${syncLine}\x1b[0m\n`);
|
|
305
361
|
if (growthLine) process.stderr.write(`\n\x1b[36m${growthLine}\x1b[0m\n`);
|
|
306
362
|
if (clearRecoveryLine)
|
|
307
363
|
process.stderr.write(`\n\x1b[33m${clearRecoveryLine.split('\n')[0]}\x1b[0m\n`);
|
|
308
364
|
if (updateLine) process.stderr.write(`\n\x1b[33m${updateLine}\x1b[0m\n`);
|
|
365
|
+
if (siblingLine) process.stderr.write(`\n\x1b[33m${siblingLine}\x1b[0m\n`);
|
|
309
366
|
const cwd = data.cwd || data.directory || process.cwd();
|
|
310
367
|
const sessionId = data.session_id || 'default';
|
|
311
368
|
const MARKER_FILE = sessionMarkerPath(sessionId);
|
|
@@ -336,7 +393,7 @@ process.stdin.on('end', () => {
|
|
|
336
393
|
JSON.stringify(
|
|
337
394
|
buildOutput(
|
|
338
395
|
`${noticePrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}]\n\n${parts.join('\n\n')}`,
|
|
339
|
-
|
|
396
|
+
outExtra,
|
|
340
397
|
),
|
|
341
398
|
),
|
|
342
399
|
);
|
|
@@ -352,10 +409,7 @@ process.stdin.on('end', () => {
|
|
|
352
409
|
JSON.stringify(
|
|
353
410
|
buildOutput(
|
|
354
411
|
`${noticePrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}, no snapshot yet]`,
|
|
355
|
-
|
|
356
|
-
continue: true,
|
|
357
|
-
suppressOutput: true,
|
|
358
|
-
},
|
|
412
|
+
outExtra,
|
|
359
413
|
),
|
|
360
414
|
),
|
|
361
415
|
);
|
|
@@ -378,9 +432,9 @@ process.stdin.on('end', () => {
|
|
|
378
432
|
if (!existsSync(GLOBAL_HOT)) {
|
|
379
433
|
const notice = notices.join('\n\n');
|
|
380
434
|
if (notice) {
|
|
381
|
-
console.log(JSON.stringify(buildOutput(notice,
|
|
435
|
+
console.log(JSON.stringify(buildOutput(notice, outExtra)));
|
|
382
436
|
} else {
|
|
383
|
-
console.log(JSON.stringify(
|
|
437
|
+
console.log(JSON.stringify(outExtra));
|
|
384
438
|
}
|
|
385
439
|
return;
|
|
386
440
|
}
|
|
@@ -389,12 +443,12 @@ process.stdin.on('end', () => {
|
|
|
389
443
|
if (!globalContent) {
|
|
390
444
|
// GLOBAL_HOT exists but is empty or .hypoignore'd — still surface any
|
|
391
445
|
// pending notices (sync state, growth, AND the auto-project offer), which
|
|
392
|
-
// would otherwise be silently dropped here
|
|
446
|
+
// would otherwise be silently dropped here.
|
|
393
447
|
const notice = notices.join('\n\n');
|
|
394
448
|
if (notice) {
|
|
395
|
-
console.log(JSON.stringify(buildOutput(notice,
|
|
449
|
+
console.log(JSON.stringify(buildOutput(notice, outExtra)));
|
|
396
450
|
} else {
|
|
397
|
-
console.log(JSON.stringify(
|
|
451
|
+
console.log(JSON.stringify(outExtra));
|
|
398
452
|
}
|
|
399
453
|
return;
|
|
400
454
|
}
|
|
@@ -402,12 +456,12 @@ process.stdin.on('end', () => {
|
|
|
402
456
|
JSON.stringify(
|
|
403
457
|
buildOutput(
|
|
404
458
|
`${noticePrefix}[WIKI HOT CACHE: global — no project matched cwd=${cwd}]\n\n${globalContent}`,
|
|
405
|
-
|
|
459
|
+
outExtra,
|
|
406
460
|
),
|
|
407
461
|
),
|
|
408
462
|
);
|
|
409
463
|
} catch (err) {
|
|
410
464
|
process.stderr.write(`[hypo-session-start] error: ${err?.message ?? String(err)}\n`);
|
|
411
|
-
console.log(JSON.stringify(
|
|
465
|
+
console.log(JSON.stringify(outExtra));
|
|
412
466
|
}
|
|
413
467
|
});
|
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -17,8 +17,8 @@ const HOME = homedir();
|
|
|
17
17
|
// hypo-session-start / hypo-cwd-change WRITE this marker; hypo-first-prompt
|
|
18
18
|
// READS + unlinks it. The session_id comes from the Claude Code runtime (a
|
|
19
19
|
// UUID), but we sanitize defensively so a malformed id with path separators or
|
|
20
|
-
// `..` can never escape tmpdir or collide on an empty value
|
|
21
|
-
//
|
|
20
|
+
// `..` can never escape tmpdir or collide on an empty value. Non-alphanumeric
|
|
21
|
+
// chars collapse to `_`.
|
|
22
22
|
export function sessionMarkerPath(sessionId) {
|
|
23
23
|
const safe = String(sessionId || 'default').replace(/[^A-Za-z0-9._-]/g, '_') || 'default';
|
|
24
24
|
return join(tmpdir(), `hypo-session-marker-${safe}.json`);
|
|
@@ -207,8 +207,8 @@ export function hasSessionLogHeading(content, date) {
|
|
|
207
207
|
* "foo-bar" (hyphen is non-word). The canonical log format always separates the
|
|
208
208
|
* project slug from anything that follows by whitespace or end-of-line, so the
|
|
209
209
|
* lookahead correctly rejects "session | foo-bar" when looking for "foo".
|
|
210
|
-
* (
|
|
211
|
-
*
|
|
210
|
+
* (Was a pre-existing bug in sessionCloseFileStatus that the helper extraction
|
|
211
|
+
* inherited.)
|
|
212
212
|
*/
|
|
213
213
|
export function hasLogEntry(content, date, project) {
|
|
214
214
|
return new RegExp(
|
|
@@ -232,13 +232,56 @@ export function freshDates() {
|
|
|
232
232
|
return local === utc ? [local] : [local, utc];
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
// Parse a single frontmatter scalar (mirrors hypo-session-start.mjs /
|
|
236
|
+
// hypo-cwd-change.mjs; local copy per the hook self-contained convention).
|
|
237
|
+
function parseFrontmatterField(content, key) {
|
|
238
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
239
|
+
if (!m) return null;
|
|
240
|
+
const line = m[1].split('\n').find((l) => l.startsWith(`${key}:`));
|
|
241
|
+
if (!line) return null;
|
|
242
|
+
return line
|
|
243
|
+
.slice(key.length + 1)
|
|
244
|
+
.trim()
|
|
245
|
+
.replace(/^['"]|['"]$/g, '');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
|
|
249
|
+
// is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
|
|
250
|
+
// when cwd is falsy or matches none. Used only as a same-date tie-breaker.
|
|
251
|
+
function pickByCwd(hypoDir, slugs, cwd) {
|
|
252
|
+
if (!cwd) return null;
|
|
253
|
+
let best = null;
|
|
254
|
+
let bestLen = -1;
|
|
255
|
+
for (const slug of slugs) {
|
|
256
|
+
const indexPath = join(hypoDir, 'projects', slug, 'index.md');
|
|
257
|
+
if (!existsSync(indexPath)) continue;
|
|
258
|
+
const wd = parseFrontmatterField(readFileSync(indexPath, 'utf-8'), 'working_dir');
|
|
259
|
+
if (!wd) continue;
|
|
260
|
+
let resolved = wd.startsWith('~/') ? join(homedir(), wd.slice(2)) : wd;
|
|
261
|
+
resolved = resolved.replace(/\/+$/, ''); // trailing-slash normalize
|
|
262
|
+
if ((cwd === resolved || cwd.startsWith(resolved + '/')) && resolved.length > bestLen) {
|
|
263
|
+
bestLen = resolved.length;
|
|
264
|
+
best = slug;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return best;
|
|
268
|
+
}
|
|
269
|
+
|
|
235
270
|
/**
|
|
236
271
|
* Resolve the most-recently-active project slug from root hot.md.
|
|
237
|
-
*
|
|
272
|
+
* The cwd helpers (parseFrontmatterField / pickByCwd) and the same-date
|
|
273
|
+
* tie-break are kept in sync with scripts/resume.mjs by hand; the surrounding
|
|
274
|
+
* wrapper intentionally differs (resume.mjs adds an mtime fallback, this does not).
|
|
275
|
+
* `cwd` is an optional same-date tie-breaker (ISSUE-1): resume passes
|
|
276
|
+
* process.cwd(); session-close callers (sessionCloseFileStatus /
|
|
277
|
+
* closeFileTargets) intentionally pass null — close has a different
|
|
278
|
+
* authority (payload.project / freshness), tracked separately as ISSUE-7.
|
|
279
|
+
* When cwd is omitted, behavior is identical to the legacy version.
|
|
238
280
|
* @param {string} hypoDir
|
|
281
|
+
* @param {string|null} [cwd]
|
|
239
282
|
* @returns {string|null}
|
|
240
283
|
*/
|
|
241
|
-
export function resolveActiveProject(hypoDir) {
|
|
284
|
+
export function resolveActiveProject(hypoDir, cwd = null) {
|
|
242
285
|
const hotPath = join(hypoDir, 'hot.md');
|
|
243
286
|
if (!existsSync(hotPath)) return null;
|
|
244
287
|
let content;
|
|
@@ -257,6 +300,19 @@ export function resolveActiveProject(hypoDir) {
|
|
|
257
300
|
].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
|
|
258
301
|
if (wikiRows.length > 0) {
|
|
259
302
|
wikiRows.sort((a, b) => b.date.localeCompare(a.date));
|
|
303
|
+
// Same-date tie-break (ISSUE-1): when the top date is shared by >1 row,
|
|
304
|
+
// prefer the project whose working_dir contains cwd. No cwd / no match →
|
|
305
|
+
// keep the stable-sort winner (the legacy "first table row" behavior).
|
|
306
|
+
const topDate = wikiRows[0].date;
|
|
307
|
+
const tied = wikiRows.filter((r) => r.date === topDate);
|
|
308
|
+
if (cwd && tied.length > 1) {
|
|
309
|
+
const picked = pickByCwd(
|
|
310
|
+
hypoDir,
|
|
311
|
+
tied.map((r) => r.slug),
|
|
312
|
+
cwd,
|
|
313
|
+
);
|
|
314
|
+
if (picked) return picked;
|
|
315
|
+
}
|
|
260
316
|
return wikiRows[0].slug;
|
|
261
317
|
}
|
|
262
318
|
// Legacy markdown-link rows: | [name](projects/name/...) | ...
|
|
@@ -277,12 +333,21 @@ export function resolveActiveProject(hypoDir) {
|
|
|
277
333
|
* project A can't be masked by a fresh close of project B (and vice versa).
|
|
278
334
|
* open-questions.md (file #5) is conditional and not gated.
|
|
279
335
|
*
|
|
336
|
+
* `projectOverride` (ISSUE-7 Part A): when the caller already holds the
|
|
337
|
+
* authoritative project being closed (e.g. crystallize apply derives it from
|
|
338
|
+
* `payload.project`), it passes that slug so verification checks the SAME
|
|
339
|
+
* project it just wrote — instead of re-deriving via resolveActiveProject(),
|
|
340
|
+
* which on a same-date root-hot.md tie can resolve a DIFFERENT project and
|
|
341
|
+
* false-fail a completed close. When omitted, behavior is byte-identical to the
|
|
342
|
+
* legacy single-arg version (resolve from root hot.md).
|
|
343
|
+
*
|
|
280
344
|
* @param {string} hypoDir
|
|
345
|
+
* @param {{projectOverride?: string|null}} [opts]
|
|
281
346
|
* @returns {{ok: boolean, project: string|null, dates: string[], stale: string[], missing: string[]}}
|
|
282
347
|
*/
|
|
283
|
-
export function sessionCloseFileStatus(hypoDir) {
|
|
348
|
+
export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {}) {
|
|
284
349
|
const dates = freshDates();
|
|
285
|
-
const project = resolveActiveProject(hypoDir);
|
|
350
|
+
const project = projectOverride || resolveActiveProject(hypoDir);
|
|
286
351
|
if (!project) {
|
|
287
352
|
return {
|
|
288
353
|
ok: false,
|
|
@@ -358,9 +423,9 @@ export function sessionCloseFileStatus(hypoDir) {
|
|
|
358
423
|
|
|
359
424
|
// ── sync-state ────────────────────────────────────────────
|
|
360
425
|
// `.cache/sync-state.json` is JSONL: one {timestamp, op, error, host} entry per
|
|
361
|
-
// line. hypo-auto-commit (#9) appends on pull/push failure; hypo-session-start
|
|
362
|
-
// (#10) surfaces open entries and clears them once sync is healthy again;
|
|
363
|
-
// doctor (#11) warns while entries remain. Keep the schema defined here only.
|
|
426
|
+
// line. hypo-auto-commit (fix #9) appends on pull/push failure; hypo-session-start
|
|
427
|
+
// (fix #10) surfaces open entries and clears them once sync is healthy again;
|
|
428
|
+
// doctor (fix #11) warns while entries remain. Keep the schema defined here only.
|
|
364
429
|
|
|
365
430
|
/** @returns {string} path to the sync-state JSONL file for a wiki root. */
|
|
366
431
|
function syncStatePath(hypoDir) {
|
|
@@ -527,14 +592,12 @@ export function shouldSuggestProjectCreation(cwd, hypoDir = HYPO_DIR, now = Date
|
|
|
527
592
|
* Build the §8.11 auto-project offer line for a cwd. The display name is the
|
|
528
593
|
* cwd basename, which is attacker-influenced (a directory name can contain
|
|
529
594
|
* newlines/control chars on Unix). Strip control characters and length-cap it
|
|
530
|
-
* so a crafted dir name cannot spoof extra instructions in additionalContext
|
|
531
|
-
* (codex review 2026-05-22).
|
|
595
|
+
* so a crafted dir name cannot spoof extra instructions in additionalContext.
|
|
532
596
|
*/
|
|
533
597
|
export function buildProjectSuggestionLine(cwd) {
|
|
534
598
|
// Replace any control char (code < 0x20 or === 0x7F) with a space so a
|
|
535
|
-
// crafted dir name cannot inject newlines/instructions into additionalContext
|
|
536
|
-
//
|
|
537
|
-
// this source file.
|
|
599
|
+
// crafted dir name cannot inject newlines/instructions into additionalContext.
|
|
600
|
+
// Done by codepoint to keep control bytes out of this source file.
|
|
538
601
|
const sanitized = Array.from(basename(cwd))
|
|
539
602
|
.map((ch) => {
|
|
540
603
|
const code = ch.codePointAt(0);
|
|
@@ -803,6 +866,133 @@ export function hasMutatingTranscriptActivity(transcriptPath) {
|
|
|
803
866
|
return false;
|
|
804
867
|
}
|
|
805
868
|
|
|
869
|
+
// ── session-scoped lint classification ──────────────────────────────────────
|
|
870
|
+
// Bug A/B fix: the close gate must judge a session on the files IT touched, not
|
|
871
|
+
// the whole vault. Lint debt from another project/session (often in shared
|
|
872
|
+
// pages/) must not block this session's close/compact. Two scope builders feed
|
|
873
|
+
// one shared classifier: transcript-derived (hooks + standalone marker) and
|
|
874
|
+
// close-file/payload-derived (the documented apply path writes via Bash, so its
|
|
875
|
+
// files never appear as Edit/Write file_paths and must be seeded explicitly).
|
|
876
|
+
|
|
877
|
+
const MUTATING_FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
878
|
+
|
|
879
|
+
/** Pull file_path/notebook_path args from mutating tool_use blocks in one
|
|
880
|
+
* transcript entry. Mirrors extractTranscriptToolNames' shape handling
|
|
881
|
+
* (top-level tool_use + nested message.content[] blocks). */
|
|
882
|
+
function extractTranscriptToolFilePaths(entry) {
|
|
883
|
+
const paths = [];
|
|
884
|
+
if (!entry || typeof entry !== 'object') return paths;
|
|
885
|
+
const pull = (name, input) => {
|
|
886
|
+
if (!name || !MUTATING_FILE_TOOLS.has(name) || !input || typeof input !== 'object') return;
|
|
887
|
+
const fp = input.file_path || input.notebook_path;
|
|
888
|
+
if (typeof fp === 'string' && fp) paths.push(fp);
|
|
889
|
+
};
|
|
890
|
+
if (entry.type === 'tool_use') pull(entry.name || entry.tool_name, entry.input);
|
|
891
|
+
const content = entry.message?.content ?? (Array.isArray(entry.content) ? entry.content : null);
|
|
892
|
+
if (Array.isArray(content)) {
|
|
893
|
+
for (const block of content) {
|
|
894
|
+
if (block && typeof block === 'object' && block.type === 'tool_use') {
|
|
895
|
+
pull(block.name || block.tool_name, block.input);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return paths;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Normalize an absolute path to a repo-relative POSIX path under hypoDir, or
|
|
903
|
+
* null if it resolves outside the wiki. */
|
|
904
|
+
function toHypoRel(absPath, hypoDir) {
|
|
905
|
+
let rel;
|
|
906
|
+
try {
|
|
907
|
+
rel = relative(hypoDir, absPath);
|
|
908
|
+
} catch {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null;
|
|
912
|
+
return rel.split('\\').join('/');
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Repo-relative POSIX paths of wiki files this session edited via direct
|
|
917
|
+
* Edit/Write/MultiEdit/NotebookEdit tool_use. Returns a Set; empty when the
|
|
918
|
+
* transcript is missing/unreadable (callers decide the fallback). A per-line
|
|
919
|
+
* JSON parse error skips that line only (transcripts occasionally truncate).
|
|
920
|
+
*/
|
|
921
|
+
export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
|
|
922
|
+
const out = new Set();
|
|
923
|
+
if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
|
|
924
|
+
return out;
|
|
925
|
+
}
|
|
926
|
+
let raw;
|
|
927
|
+
try {
|
|
928
|
+
raw = readFileSync(transcriptPath, 'utf-8');
|
|
929
|
+
} catch {
|
|
930
|
+
return out;
|
|
931
|
+
}
|
|
932
|
+
for (const line of raw.split('\n')) {
|
|
933
|
+
const t = line.trim();
|
|
934
|
+
if (!t) continue;
|
|
935
|
+
let entry;
|
|
936
|
+
try {
|
|
937
|
+
entry = JSON.parse(t);
|
|
938
|
+
} catch {
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
for (const fp of extractTranscriptToolFilePaths(entry)) {
|
|
942
|
+
const rel = toHypoRel(fp, hypoDir);
|
|
943
|
+
if (rel) out.add(rel);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return out;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* The mandatory session-close files (repo-relative POSIX). The documented close
|
|
951
|
+
* path `crystallize.mjs --apply-session-close` writes these from inside a Bash
|
|
952
|
+
* call, so they never surface as Edit/Write file_paths — they must seed the
|
|
953
|
+
* scoped-lint set explicitly or a close-introduced error would escape the gate.
|
|
954
|
+
* Mirrors the file list in sessionCloseFileStatus.
|
|
955
|
+
*/
|
|
956
|
+
export function closeFileTargets(hypoDir) {
|
|
957
|
+
const out = new Set(['hot.md', 'log.md']);
|
|
958
|
+
const project = resolveActiveProject(hypoDir);
|
|
959
|
+
if (project) {
|
|
960
|
+
out.add(`projects/${project}/session-state.md`);
|
|
961
|
+
out.add(`projects/${project}/hot.md`);
|
|
962
|
+
const month = freshDates()[0].slice(0, 7);
|
|
963
|
+
out.add(`projects/${project}/session-log/${month}.md`);
|
|
964
|
+
}
|
|
965
|
+
return out;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Normalize a path's separators to POSIX so scope membership is OS-independent.
|
|
969
|
+
* lint.mjs emits `file` via path.relative (back-slashes on Windows) while the
|
|
970
|
+
* scope builders produce forward-slash paths — normalize both sides. */
|
|
971
|
+
function posixPath(p) {
|
|
972
|
+
return (p || '').split('\\').join('/');
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Partition lint findings into `blocking` (a file this session is accountable
|
|
977
|
+
* for) vs `notice` (pre-existing debt elsewhere — surfaced, not blocking).
|
|
978
|
+
*
|
|
979
|
+
* `scope` = iterable of repo-relative paths the session is accountable for.
|
|
980
|
+
* Membership is exact on the normalized path. Only findings passed in are
|
|
981
|
+
* classified — callers pass lint ERRORS; broken wikilinks (lint W4 warnings) are
|
|
982
|
+
* intentionally warn-only (forward references to planned pages are normal in a
|
|
983
|
+
* wiki) and are NOT promoted to blocking by this gate.
|
|
984
|
+
*/
|
|
985
|
+
export function partitionLintScope(findings, scope) {
|
|
986
|
+
const normScope = new Set([...scope].map(posixPath));
|
|
987
|
+
const blocking = [];
|
|
988
|
+
const notice = [];
|
|
989
|
+
for (const f of findings || []) {
|
|
990
|
+
if (normScope.has(posixPath(f.file))) blocking.push(f);
|
|
991
|
+
else notice.push(f);
|
|
992
|
+
}
|
|
993
|
+
return { blocking, notice };
|
|
994
|
+
}
|
|
995
|
+
|
|
806
996
|
// ── session-close checklist ────────────────────────────────────────────────
|
|
807
997
|
|
|
808
998
|
/**
|