hypomnema 1.3.1 → 1.3.2
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 +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.ko.md +10 -10
- package/README.md +10 -10
- package/commands/crystallize.md +8 -8
- package/commands/feedback.md +1 -1
- package/commands/resume.md +1 -1
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
- package/hooks/hypo-compact-guard.mjs +1 -1
- package/hooks/hypo-cwd-change.mjs +1 -1
- package/hooks/hypo-first-prompt.mjs +2 -2
- package/hooks/hypo-hot-rebuild.mjs +14 -1
- package/hooks/hypo-personal-check.mjs +91 -179
- package/hooks/hypo-pre-commit.mjs +1 -1
- package/hooks/hypo-session-end.mjs +1 -1
- package/hooks/hypo-session-start.mjs +7 -7
- package/hooks/hypo-shared.mjs +788 -72
- package/hooks/hypo-web-fetch-ingest.mjs +2 -2
- package/hooks/version-check-fetch.mjs +2 -8
- package/hooks/version-check.mjs +18 -0
- package/package.json +3 -2
- package/scripts/check-tracker-ids.mjs +329 -0
- package/scripts/crystallize.mjs +244 -109
- package/scripts/doctor.mjs +6 -9
- package/scripts/feedback-sync.mjs +1 -1
- package/scripts/init.mjs +1 -1
- package/scripts/install-git-hooks.mjs +75 -40
- package/scripts/lib/check-tracker-ids.mjs +140 -0
- package/scripts/lib/design-history-stale.mjs +59 -14
- package/scripts/lib/extensions.mjs +4 -4
- package/scripts/lib/fix-manifest.mjs +2 -2
- package/scripts/lib/fix-status-verify.mjs +5 -4
- package/scripts/lib/plugin-detect.mjs +15 -6
- package/scripts/lib/project-create.mjs +1 -1
- package/scripts/lint.mjs +63 -8
- package/scripts/rename.mjs +373 -0
- package/scripts/resume.mjs +75 -19
- package/scripts/uninstall.mjs +1 -1
- package/scripts/upgrade.mjs +26 -25
- package/skills/crystallize/SKILL.md +10 -7
- package/templates/SCHEMA.md +2 -2
- package/templates/hypo-config.md +2 -2
- package/templates/hypo-guide.md +20 -1
- package/templates/projects/_template/index.md +1 -1
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -6,7 +6,15 @@
|
|
|
6
6
|
* Hooks are deployed to ~/.claude/hooks/ — no external imports allowed.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
appendFileSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
} from 'fs';
|
|
10
18
|
import { join, relative, basename } from 'path';
|
|
11
19
|
import { homedir, hostname, tmpdir } from 'os';
|
|
12
20
|
import { spawnSync } from 'child_process';
|
|
@@ -138,9 +146,10 @@ export function hypoIsClean(dir = HYPO_DIR) {
|
|
|
138
146
|
}
|
|
139
147
|
}
|
|
140
148
|
|
|
141
|
-
export function hotMdIsClean() {
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
export function hotMdIsClean(dir = HYPO_DIR) {
|
|
150
|
+
const hotPath = dir === HYPO_DIR ? HOT_PATH : join(dir, 'hot.md');
|
|
151
|
+
if (!existsSync(hotPath)) return { clean: true };
|
|
152
|
+
const content = readFileSync(hotPath, 'utf-8');
|
|
144
153
|
const reasons = [];
|
|
145
154
|
|
|
146
155
|
// Optional: check H2 allowlist if HYPO_ALLOWED_HOT_H2 is set
|
|
@@ -163,7 +172,7 @@ export function hotMdIsClean() {
|
|
|
163
172
|
// spec §5.2.7 / §8.3 (updated 2026-05-15): session-close = steps 1~6 of the
|
|
164
173
|
// 11-step crystallize checklist (synthesis is steps 7~11). The hard gate
|
|
165
174
|
// (sessionCloseFileStatus) confirms the 5 mandatory files — session-state.md,
|
|
166
|
-
// project hot.md, root hot.md, session-log/YYYY-MM.md, and log.md.
|
|
175
|
+
// project hot.md, root hot.md, session-log/YYYY-MM-DD.md, and log.md.
|
|
167
176
|
// pages/open-questions.md (step 5) is conditional ("변경 시") — it is a
|
|
168
177
|
// cross-project queue, so a session that raises no questions should not be
|
|
169
178
|
// forced to touch it. Gating it would produce false-blocks; spec §5.2.7
|
|
@@ -173,7 +182,7 @@ export function hotMdIsClean() {
|
|
|
173
182
|
// so a second session on the same day that skips updating a file still passes
|
|
174
183
|
// if an earlier close that day already stamped it. freshDates() accepting both
|
|
175
184
|
// local and UTC dates widens that window by up to one UTC offset. A per-session
|
|
176
|
-
// boundary is out of scope for
|
|
185
|
+
// boundary is out of scope for the strict session-close check.
|
|
177
186
|
|
|
178
187
|
/** Parse the frontmatter `updated:` field. Returns the trimmed value or null. */
|
|
179
188
|
function frontmatterUpdated(content) {
|
|
@@ -198,6 +207,56 @@ export function hasSessionLogHeading(content, date) {
|
|
|
198
207
|
return new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]', 'm').test(content || '');
|
|
199
208
|
}
|
|
200
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Canonical session-log shard path (repo-relative POSIX) for a single day.
|
|
212
|
+
* ADR 0050 / option D: the date IS the filename (`YYYY-MM-DD.md`), so no
|
|
213
|
+
* "current part" resolver is needed and the per-session write touches a small
|
|
214
|
+
* file instead of a multi-thousand-line monthly log. This is the WRITE target.
|
|
215
|
+
*/
|
|
216
|
+
export function sessionLogShardPath(project, date) {
|
|
217
|
+
return `projects/${project}/session-log/${date}.md`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Read candidates for a date's session-log entry, in priority order:
|
|
222
|
+
* 1. the daily shard `YYYY-MM-DD.md` (canonical, small)
|
|
223
|
+
* 2. the legacy monthly `YYYY-MM.md` (pre-shard history is never split)
|
|
224
|
+
* Callers iterate and short-circuit on the first file carrying the dated
|
|
225
|
+
* heading, so once a daily shard exists the large monthly file is never read —
|
|
226
|
+
* which is where the per-close token saving comes from. The monthly fallback
|
|
227
|
+
* keeps pre-cutover months (and a hybrid cutover month) resolving correctly
|
|
228
|
+
* without a retroactive split.
|
|
229
|
+
*/
|
|
230
|
+
export function sessionLogReadCandidates(project, date) {
|
|
231
|
+
return [
|
|
232
|
+
sessionLogShardPath(project, date),
|
|
233
|
+
`projects/${project}/session-log/${date.slice(0, 7)}.md`,
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The session-log file the close gate should hold accountable for `date` — i.e.
|
|
239
|
+
* the read candidate that ACTUALLY carries a today-dated heading (daily shard
|
|
240
|
+
* preferred), or the daily write target when none does yet. The freshness gate
|
|
241
|
+
* accepts a today-heading found in the legacy monthly file via fallback; if the
|
|
242
|
+
* lint scope only ever named the daily shard, a close could pass on a *corrupt*
|
|
243
|
+
* monthly evidence file while that file's lint error was demoted to a
|
|
244
|
+
* non-blocking notice (out of scope). Scoping to the resolved evidence file
|
|
245
|
+
* closes that gap: whatever file the gate trusts as proof, lint judges too.
|
|
246
|
+
*/
|
|
247
|
+
export function sessionLogScopePath(hypoDir, project, date) {
|
|
248
|
+
for (const rel of sessionLogReadCandidates(project, date)) {
|
|
249
|
+
const full = join(hypoDir, rel);
|
|
250
|
+
if (!existsSync(full)) continue;
|
|
251
|
+
try {
|
|
252
|
+
if (hasSessionLogHeading(readFileSync(full, 'utf-8'), date)) return rel;
|
|
253
|
+
} catch {
|
|
254
|
+
/* unreadable candidate — keep looking */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return sessionLogShardPath(project, date);
|
|
258
|
+
}
|
|
259
|
+
|
|
201
260
|
/**
|
|
202
261
|
* True if `content` carries a today-dated `## [date] session | <project>` entry
|
|
203
262
|
* in log.md.
|
|
@@ -217,6 +276,31 @@ export function hasLogEntry(content, date, project) {
|
|
|
217
276
|
).test(content || '');
|
|
218
277
|
}
|
|
219
278
|
|
|
279
|
+
/**
|
|
280
|
+
* True when log.md carries ANY `## [<freshDate>] session | <slug>` heading —
|
|
281
|
+
* project-agnostic. The minimum proof for a log-only close: a
|
|
282
|
+
* non-project (tooling/wiki-only) session has no project mandatory files, but it
|
|
283
|
+
* MUST still leave a today log.md trace so the close is not a content-free bypass.
|
|
284
|
+
* Uses the same canonical heading parser as hasLogEntry (not a loose substring —
|
|
285
|
+
* codex design review: keep the fresh-date/canonical-heading style) so a stray
|
|
286
|
+
* mention of the date elsewhere cannot satisfy it.
|
|
287
|
+
* @param {string} hypoDir
|
|
288
|
+
* @returns {boolean}
|
|
289
|
+
*/
|
|
290
|
+
export function hasAnyTodayLogEntry(hypoDir) {
|
|
291
|
+
const logPath = join(hypoDir, 'log.md');
|
|
292
|
+
if (!existsSync(logPath)) return false;
|
|
293
|
+
let content = '';
|
|
294
|
+
try {
|
|
295
|
+
content = readFileSync(logPath, 'utf-8');
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
return freshDates().some((d) =>
|
|
300
|
+
new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| \\S', 'm').test(content),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
220
304
|
/**
|
|
221
305
|
* Date strings that count as "today" for freshness checks. Both the local and
|
|
222
306
|
* UTC dates are accepted: Claude writes file dates in the user's local zone,
|
|
@@ -247,7 +331,8 @@ function parseFrontmatterField(content, key) {
|
|
|
247
331
|
|
|
248
332
|
// Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
|
|
249
333
|
// is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
|
|
250
|
-
// when cwd is falsy or matches none.
|
|
334
|
+
// when cwd is falsy or matches none. resume gives this authority OVER recency
|
|
335
|
+
// (ADR 0044); close callers never pass cwd, so it stays inert for them.
|
|
251
336
|
function pickByCwd(hypoDir, slugs, cwd) {
|
|
252
337
|
if (!cwd) return null;
|
|
253
338
|
let best = null;
|
|
@@ -268,15 +353,18 @@ function pickByCwd(hypoDir, slugs, cwd) {
|
|
|
268
353
|
}
|
|
269
354
|
|
|
270
355
|
/**
|
|
271
|
-
* Resolve the
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
356
|
+
* Resolve the active project slug from root hot.md. With a cwd, a project whose
|
|
357
|
+
* working_dir contains it wins (cwd-first, ADR 0044); otherwise the
|
|
358
|
+
* most-recently-active row is returned.
|
|
359
|
+
* The cwd helpers (parseFrontmatterField / pickByCwd) and the cwd-first body
|
|
360
|
+
* are kept in sync with scripts/resume.mjs by hand; the surrounding wrapper
|
|
361
|
+
* intentionally differs (resume.mjs adds an mtime fallback, this does not).
|
|
362
|
+
* `cwd` is an optional cwd-first selector (ADR 0044): a cwd↔working_dir match
|
|
363
|
+
* wins over recency. resume passes process.cwd(); session-close callers
|
|
364
|
+
* (sessionCloseFileStatus / closeFileTargets) intentionally pass null — close
|
|
365
|
+
* has a different authority (payload.project / freshness, the global invariant
|
|
366
|
+
* of ADR 0043), so it never picks by cwd. When cwd is omitted, behavior is
|
|
367
|
+
* identical to the legacy recency version.
|
|
280
368
|
* @param {string} hypoDir
|
|
281
369
|
* @param {string|null} [cwd]
|
|
282
370
|
* @returns {string|null}
|
|
@@ -299,41 +387,48 @@ export function resolveActiveProject(hypoDir, cwd = null) {
|
|
|
299
387
|
),
|
|
300
388
|
].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
|
|
301
389
|
if (wikiRows.length > 0) {
|
|
302
|
-
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
const topDate = wikiRows[0].date;
|
|
307
|
-
const tied = wikiRows.filter((r) => r.date === topDate);
|
|
308
|
-
if (cwd && tied.length > 1) {
|
|
390
|
+
// cwd-first (ADR 0044): a cwd↔working_dir match wins over recency, across
|
|
391
|
+
// ALL rows. Kept in sync with scripts/resume.mjs. close callers pass null →
|
|
392
|
+
// recency path below (resume=cwd-positive / close=no-pick).
|
|
393
|
+
if (cwd) {
|
|
309
394
|
const picked = pickByCwd(
|
|
310
395
|
hypoDir,
|
|
311
|
-
|
|
396
|
+
wikiRows.map((r) => r.slug),
|
|
312
397
|
cwd,
|
|
313
398
|
);
|
|
314
399
|
if (picked) return picked;
|
|
315
400
|
}
|
|
401
|
+
// No cwd match → most recent by date (stable-sort keeps the first table row
|
|
402
|
+
// on a tie, the legacy behavior).
|
|
403
|
+
wikiRows.sort((a, b) => b.date.localeCompare(a.date));
|
|
316
404
|
return wikiRows[0].slug;
|
|
317
405
|
}
|
|
318
406
|
// Legacy markdown-link rows: | [name](projects/name/...) | ...
|
|
319
|
-
const
|
|
320
|
-
if (
|
|
407
|
+
const mdSlugs = [...content.matchAll(/\|\s*\[([^\]]+)\]\(projects\/([^/)]+)/g)].map((m) => m[2]);
|
|
408
|
+
if (mdSlugs.length > 0) {
|
|
409
|
+
if (cwd) {
|
|
410
|
+
const picked = pickByCwd(hypoDir, mdSlugs, cwd);
|
|
411
|
+
if (picked) return picked;
|
|
412
|
+
}
|
|
413
|
+
return mdSlugs[0]; // legacy: first table row
|
|
414
|
+
}
|
|
321
415
|
return null;
|
|
322
416
|
}
|
|
323
417
|
|
|
324
418
|
/**
|
|
325
|
-
* Strict session-close verification (
|
|
419
|
+
* Strict session-close verification (spec §5.2.7 / §8.3).
|
|
326
420
|
* Confirms the memory files a session close must touch were updated today:
|
|
327
421
|
* - projects/<project>/session-state.md — frontmatter `updated:` is today
|
|
328
422
|
* - projects/<project>/hot.md — frontmatter `updated:` is today
|
|
329
423
|
* - hot.md (root) — frontmatter `updated:` is today
|
|
330
|
-
* - projects/<project>/session-log/YYYY-MM.md — has a `## [today]` heading
|
|
424
|
+
* - projects/<project>/session-log/YYYY-MM-DD.md — has a `## [today]` heading
|
|
425
|
+
* (daily shard, ADR 0050; legacy YYYY-MM.md is still accepted as fallback)
|
|
331
426
|
* - log.md — has a `## [today] session | <project>` entry
|
|
332
427
|
* The log.md check is project-scoped so a session close left incomplete for
|
|
333
428
|
* project A can't be masked by a fresh close of project B (and vice versa).
|
|
334
429
|
* open-questions.md (file #5) is conditional and not gated.
|
|
335
430
|
*
|
|
336
|
-
* `projectOverride` (
|
|
431
|
+
* `projectOverride` (same-date-tie fix): when the caller already holds the
|
|
337
432
|
* authoritative project being closed (e.g. crystallize apply derives it from
|
|
338
433
|
* `payload.project`), it passes that slug so verification checks the SAME
|
|
339
434
|
* project it just wrote — instead of re-deriving via resolveActiveProject(),
|
|
@@ -381,25 +476,30 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
|
|
|
381
476
|
checkUpdated(join('projects', project, 'hot.md'));
|
|
382
477
|
checkUpdated('hot.md');
|
|
383
478
|
|
|
384
|
-
// session-log:
|
|
385
|
-
//
|
|
479
|
+
// session-log: daily shard (ADR 0050), with legacy monthly fallback — must
|
|
480
|
+
// carry a today-dated heading in whichever file holds it. Daily-first read
|
|
481
|
+
// order short-circuits on the small shard. When no match is found, the gap is
|
|
482
|
+
// reported under the canonical daily shard for the local date (dates[0]).
|
|
386
483
|
let sessionLogOk = false;
|
|
387
484
|
for (const date of dates) {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
485
|
+
for (const rel of sessionLogReadCandidates(project, date)) {
|
|
486
|
+
const full = join(hypoDir, rel);
|
|
487
|
+
if (!existsSync(full)) continue;
|
|
488
|
+
let content = '';
|
|
489
|
+
try {
|
|
490
|
+
content = readFileSync(full, 'utf-8');
|
|
491
|
+
} catch {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (hasSessionLogHeading(content, date)) {
|
|
495
|
+
sessionLogOk = true;
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
399
498
|
}
|
|
499
|
+
if (sessionLogOk) break;
|
|
400
500
|
}
|
|
401
501
|
if (!sessionLogOk) {
|
|
402
|
-
const logRel =
|
|
502
|
+
const logRel = sessionLogShardPath(project, dates[0]);
|
|
403
503
|
(existsSync(join(hypoDir, logRel)) ? stale : missing).push(logRel);
|
|
404
504
|
}
|
|
405
505
|
|
|
@@ -421,11 +521,292 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
|
|
|
421
521
|
return { ok: stale.length === 0 && missing.length === 0, project, dates, stale, missing };
|
|
422
522
|
}
|
|
423
523
|
|
|
524
|
+
// ── global session-close gate (ADR 0043) ────
|
|
525
|
+
// The no-payload close paths must NOT pick one project (recency / cwd) and check
|
|
526
|
+
// it — that re-derivation is the prior session-close false-block, and a cwd
|
|
527
|
+
// tie-break here would let a fresh cwd mask a DIFFERENT project's dangling
|
|
528
|
+
// close. Instead the gate enforces a global invariant: no project may end a
|
|
529
|
+
// session with a partial close. resume stays cwd-positive (ADR 0044); close
|
|
530
|
+
// never picks. The two copies of resolveActiveProject share the cwd-first body
|
|
531
|
+
// but the resume.mjs copy adds an mtime fallback this one omits — see resume.mjs.
|
|
532
|
+
|
|
533
|
+
// Root hot.md Active-Projects rows as {slug, date}. The per-row date column is
|
|
534
|
+
// project-scoped (unlike the shared frontmatter `updated:`), so a today-dated
|
|
535
|
+
// row is a legitimate close-activity signal. Mirrors resolveActiveProject's regex.
|
|
536
|
+
function rootHotRows(hypoDir) {
|
|
537
|
+
const hotPath = join(hypoDir, 'hot.md');
|
|
538
|
+
if (!existsSync(hotPath)) return [];
|
|
539
|
+
let content;
|
|
540
|
+
try {
|
|
541
|
+
content = readFileSync(hotPath, 'utf-8').replace(/<!--[\s\S]*?-->/g, '');
|
|
542
|
+
} catch {
|
|
543
|
+
return [];
|
|
544
|
+
}
|
|
545
|
+
return [
|
|
546
|
+
...content.matchAll(
|
|
547
|
+
/\|\s*([^|]+?)\s*\|\s*(\d{4}-\d{2}-\d{2})?\s*\|\s*\[\[projects\/([^\]/]+)\/[^\]]+\]\]/g,
|
|
548
|
+
),
|
|
549
|
+
].map((m) => ({ slug: m[3], date: m[2] || '' }));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Candidate slugs the global gate must consider: real project dirs (with a
|
|
553
|
+
// session-state.md, skip _template) ∪ slugs in a today-dated `## [today] session
|
|
554
|
+
// | P` log.md entry ∪ slugs in a today-dated root hot.md row. The log/row unions
|
|
555
|
+
// catch a dangling close whose own project files are missing —
|
|
556
|
+
// sessionCloseFileStatus(projectOverride) reports those as `missing` correctly.
|
|
557
|
+
function closeCandidateSlugs(hypoDir, dates) {
|
|
558
|
+
const slugs = new Set();
|
|
559
|
+
const projectsDir = join(hypoDir, 'projects');
|
|
560
|
+
if (existsSync(projectsDir)) {
|
|
561
|
+
let entries = [];
|
|
562
|
+
try {
|
|
563
|
+
entries = readdirSync(projectsDir);
|
|
564
|
+
} catch {
|
|
565
|
+
entries = [];
|
|
566
|
+
}
|
|
567
|
+
for (const p of entries) {
|
|
568
|
+
if (p === '_template') continue;
|
|
569
|
+
if (existsSync(join(projectsDir, p, 'session-state.md'))) slugs.add(p);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
for (const r of rootHotRows(hypoDir)) {
|
|
573
|
+
if (r.date && dates.includes(r.date)) slugs.add(r.slug);
|
|
574
|
+
}
|
|
575
|
+
const logPath = join(hypoDir, 'log.md');
|
|
576
|
+
if (existsSync(logPath)) {
|
|
577
|
+
let content = '';
|
|
578
|
+
try {
|
|
579
|
+
content = readFileSync(logPath, 'utf-8');
|
|
580
|
+
} catch {
|
|
581
|
+
content = '';
|
|
582
|
+
}
|
|
583
|
+
for (const d of dates) {
|
|
584
|
+
const re = new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| (\\S+)', 'gm');
|
|
585
|
+
for (const m of content.matchAll(re)) slugs.add(m[1]);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return slugs;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// True when project P shows ANY today close-activity signal: session-state or
|
|
592
|
+
// project hot.md frontmatter `updated:` today, a today-dated session-log heading,
|
|
593
|
+
// a today log.md `session | P` entry, or a today-dated root hot.md row for P.
|
|
594
|
+
// (Root hot.md *frontmatter* is shared and is NOT a signal; the per-project ROW
|
|
595
|
+
// date is.)
|
|
596
|
+
function hasTodayCloseActivity(hypoDir, project, dates) {
|
|
597
|
+
const fresh = (rel) => {
|
|
598
|
+
const full = join(hypoDir, rel);
|
|
599
|
+
if (!existsSync(full)) return false;
|
|
600
|
+
try {
|
|
601
|
+
return dates.includes(frontmatterUpdated(readFileSync(full, 'utf-8')));
|
|
602
|
+
} catch {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
if (fresh(join('projects', project, 'session-state.md'))) return true;
|
|
607
|
+
if (fresh(join('projects', project, 'hot.md'))) return true;
|
|
608
|
+
for (const d of dates) {
|
|
609
|
+
for (const rel of sessionLogReadCandidates(project, d)) {
|
|
610
|
+
const sl = join(hypoDir, rel);
|
|
611
|
+
if (!existsSync(sl)) continue;
|
|
612
|
+
try {
|
|
613
|
+
if (hasSessionLogHeading(readFileSync(sl, 'utf-8'), d)) return true;
|
|
614
|
+
} catch {
|
|
615
|
+
/* skip */
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const logPath = join(hypoDir, 'log.md');
|
|
620
|
+
if (existsSync(logPath)) {
|
|
621
|
+
try {
|
|
622
|
+
const c = readFileSync(logPath, 'utf-8');
|
|
623
|
+
if (dates.some((d) => hasLogEntry(c, d, project))) return true;
|
|
624
|
+
} catch {
|
|
625
|
+
/* skip */
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
for (const r of rootHotRows(hypoDir)) {
|
|
629
|
+
if (r.slug === project && r.date && dates.includes(r.date)) return true;
|
|
630
|
+
}
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Global session-close status for the no-payload close paths (ADR 0043).
|
|
636
|
+
* Checks EVERY project with today close-activity; ok only when all are complete.
|
|
637
|
+
* When no project has today activity, falls back to the legacy single recency
|
|
638
|
+
* project (preserves "force the initial close" behavior, byte-identical gate).
|
|
639
|
+
*
|
|
640
|
+
* stale/missing entries are self-describing paths — project-specific files carry
|
|
641
|
+
* `projects/<slug>/…`, root files (`hot.md`/`log.md`) are shared — so the flat
|
|
642
|
+
* aliases need no project prefix and a single-project session is byte-identical
|
|
643
|
+
* to sessionCloseFileStatus (back-compat for the flat-field readers).
|
|
644
|
+
*
|
|
645
|
+
* @param {string} hypoDir
|
|
646
|
+
* @returns {{ok: boolean, projects: Array<{project:string, ok:boolean, stale:string[], missing:string[]}>,
|
|
647
|
+
* dates: string[], fallback: boolean, primary: string|null,
|
|
648
|
+
* project: string|null, stale: string[], missing: string[]}}
|
|
649
|
+
*/
|
|
650
|
+
export function sessionCloseGlobalStatus(hypoDir) {
|
|
651
|
+
const dates = freshDates();
|
|
652
|
+
const recency = resolveActiveProject(hypoDir); // no cwd — close never picks by cwd
|
|
653
|
+
const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
|
|
654
|
+
hasTodayCloseActivity(hypoDir, p, dates),
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
if (todayActive.length === 0) {
|
|
658
|
+
const legacy = sessionCloseFileStatus(hypoDir);
|
|
659
|
+
return {
|
|
660
|
+
ok: legacy.ok,
|
|
661
|
+
projects: legacy.project
|
|
662
|
+
? [{ project: legacy.project, ok: legacy.ok, stale: legacy.stale, missing: legacy.missing }]
|
|
663
|
+
: [],
|
|
664
|
+
dates,
|
|
665
|
+
fallback: true,
|
|
666
|
+
primary: legacy.project,
|
|
667
|
+
project: legacy.project,
|
|
668
|
+
stale: legacy.stale,
|
|
669
|
+
missing: legacy.missing,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// primary = the recency project when it is itself today-active, else the first
|
|
674
|
+
// today-active slug (stable order from the candidate set). Used only as the
|
|
675
|
+
// single-slug alias (marker `project` field, message header) — never to gate.
|
|
676
|
+
const primary = recency && todayActive.includes(recency) ? recency : todayActive[0];
|
|
677
|
+
const ordered = [primary, ...todayActive.filter((p) => p !== primary)];
|
|
678
|
+
|
|
679
|
+
const projects = ordered.map((p) => {
|
|
680
|
+
const s = sessionCloseFileStatus(hypoDir, { projectOverride: p });
|
|
681
|
+
return { project: p, ok: s.ok, stale: s.stale, missing: s.missing };
|
|
682
|
+
});
|
|
683
|
+
const ok = projects.every((x) => x.ok);
|
|
684
|
+
const stale = [...new Set(projects.flatMap((x) => x.stale))];
|
|
685
|
+
const missing = [...new Set(projects.flatMap((x) => x.missing))];
|
|
686
|
+
return { ok, projects, dates, fallback: false, primary, project: primary, stale, missing };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ── derivable-artifact auto-derive: root log.md session entry ──────────────────
|
|
690
|
+
// The root log.md `## [date] session | <slug>` entry is a DERIVABLE artifact —
|
|
691
|
+
// it restates a project's session-log heading, which the close already authored.
|
|
692
|
+
// root hot.md is already auto-derived here (rebuild() above); log.md was the only
|
|
693
|
+
// derivable still left as a manual checklist step, so a hand-edited close that
|
|
694
|
+
// skipped it left the global gate (sessionCloseGlobalStatus) hard-blocking /compact
|
|
695
|
+
// for EVERY today-active project — cross-session, looking like a fresh bug each
|
|
696
|
+
// time. This derives the missing entry from the session-log heading so the gate
|
|
697
|
+
// never blocks on a purely-derivable gap. The session-closed MARKER is NOT derived
|
|
698
|
+
// here: it is a proof artifact the close gate actually ran (ADR 0022 invariant).
|
|
699
|
+
//
|
|
700
|
+
// Safety guard (codex design review): derive ONLY for a project whose close is
|
|
701
|
+
// otherwise complete — i.e. its sole gate problem is log.md. If session-state /
|
|
702
|
+
// project hot / session-log are also stale/missing, the authored close is genuinely
|
|
703
|
+
// incomplete and MUST keep blocking; deriving log.md then would mask it.
|
|
704
|
+
|
|
705
|
+
// Build the canonical root log.md heading from a raw session-log heading tail.
|
|
706
|
+
// The gate's session-log freshness check accepts ANY `## [date] ...` heading, but
|
|
707
|
+
// the log.md check requires `## [date] session | <slug>` — so normalise to that
|
|
708
|
+
// shape rather than copying a heading that might not carry `session | <slug>`.
|
|
709
|
+
function deriveLogTitle(tail) {
|
|
710
|
+
let t = (tail || '').trim();
|
|
711
|
+
t = t.replace(/^session\b\s*/i, ''); // drop a leading "session" token
|
|
712
|
+
if (t.startsWith('|')) {
|
|
713
|
+
// Drop a leading "| <old label/slug>" segment up to the first em-dash, so a
|
|
714
|
+
// renamed-project heading (`| oldslug — title`) does not leak its old slug
|
|
715
|
+
// into the derived title. A pipe segment with no separator is a bare label
|
|
716
|
+
// (e.g. `| slug`) → no title.
|
|
717
|
+
const dash = t.indexOf('—');
|
|
718
|
+
t = dash === -1 ? '' : t.slice(dash);
|
|
719
|
+
}
|
|
720
|
+
return t.replace(/^\s*[—:-]\s*/, '').trim(); // drop a leading separator
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Append any missing root log.md `## [date] session | <slug>` entries derived from
|
|
725
|
+
* each today-active project's session-log heading(s). Idempotent: dedups on the
|
|
726
|
+
* exact generated heading line, so re-running (or a same-day apply that already
|
|
727
|
+
* wrote the entry) is a no-op, and multiple same-day sessions each get their own
|
|
728
|
+
* entry. Best-effort and read-mostly: returns the number of entries appended.
|
|
729
|
+
*
|
|
730
|
+
* @param {string} hypoDir
|
|
731
|
+
* @returns {number} count of entries appended to log.md
|
|
732
|
+
*/
|
|
733
|
+
export function deriveRootLogEntries(hypoDir) {
|
|
734
|
+
const logPath = join(hypoDir, 'log.md');
|
|
735
|
+
if (!existsSync(logPath)) return 0;
|
|
736
|
+
const dates = freshDates();
|
|
737
|
+
const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
|
|
738
|
+
hasTodayCloseActivity(hypoDir, p, dates),
|
|
739
|
+
);
|
|
740
|
+
if (todayActive.length === 0) return 0;
|
|
741
|
+
|
|
742
|
+
let logContent;
|
|
743
|
+
try {
|
|
744
|
+
logContent = readFileSync(logPath, 'utf-8');
|
|
745
|
+
} catch {
|
|
746
|
+
return 0;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Exact-LINE dedup: a titleless heading (`## [d] session | a`) is a substring/
|
|
750
|
+
// prefix of a titled one (`## [d] session | a — first`), so substring checks
|
|
751
|
+
// would wrongly drop a distinct same-day session. Track whole heading lines.
|
|
752
|
+
const seenHeadings = new Set((logContent || '').split(/\r?\n/));
|
|
753
|
+
const additions = [];
|
|
754
|
+
for (const slug of todayActive) {
|
|
755
|
+
// Guard: only the log.md entry may be the gap; an otherwise-incomplete close
|
|
756
|
+
// must keep blocking (do not mask missing authored files).
|
|
757
|
+
const st = sessionCloseFileStatus(hypoDir, { projectOverride: slug });
|
|
758
|
+
const problems = [...st.stale, ...st.missing];
|
|
759
|
+
if (!(problems.length === 1 && problems[0] === 'log.md')) continue;
|
|
760
|
+
|
|
761
|
+
for (const date of dates) {
|
|
762
|
+
// Pick the candidate that actually CARRIES this date's heading (daily shard
|
|
763
|
+
// preferred), mirroring the freshness gate's resolution. Selecting merely
|
|
764
|
+
// the first *existing* file would diverge from freshness: a header-only
|
|
765
|
+
// daily shard (e.g. a seeded-but-not-yet-appended file) would be chosen and
|
|
766
|
+
// the real heading in the legacy monthly fallback missed — freshness would
|
|
767
|
+
// pass while derive failed to recover the log.md entry.
|
|
768
|
+
let slog = null;
|
|
769
|
+
for (const rel of sessionLogReadCandidates(slug, date)) {
|
|
770
|
+
const slogPath = join(hypoDir, rel);
|
|
771
|
+
if (!existsSync(slogPath)) continue;
|
|
772
|
+
let content;
|
|
773
|
+
try {
|
|
774
|
+
content = readFileSync(slogPath, 'utf-8');
|
|
775
|
+
} catch {
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
if (!hasSessionLogHeading(content, date)) continue;
|
|
779
|
+
slog = content;
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
if (slog === null) continue;
|
|
783
|
+
const headingRe = new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]\\s*(.*)$', 'gm');
|
|
784
|
+
let m;
|
|
785
|
+
while ((m = headingRe.exec(slog)) !== null) {
|
|
786
|
+
const title = deriveLogTitle(m[1]);
|
|
787
|
+
const heading = `## [${date}] session | ${slug}` + (title ? ` — ${title}` : '');
|
|
788
|
+
if (seenHeadings.has(heading)) continue; // exact-line dedup (log.md + queued)
|
|
789
|
+
seenHeadings.add(heading);
|
|
790
|
+
additions.push(`${heading}\n→ [[projects/${slug}/hot]]`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (additions.length === 0) return 0;
|
|
796
|
+
const sep = logContent.endsWith('\n') ? '\n' : '\n\n';
|
|
797
|
+
try {
|
|
798
|
+
writeFileSync(logPath, logContent + sep + additions.join('\n\n') + '\n');
|
|
799
|
+
} catch {
|
|
800
|
+
return 0;
|
|
801
|
+
}
|
|
802
|
+
return additions.length;
|
|
803
|
+
}
|
|
804
|
+
|
|
424
805
|
// ── sync-state ────────────────────────────────────────────
|
|
425
806
|
// `.cache/sync-state.json` is JSONL: one {timestamp, op, error, host} entry per
|
|
426
|
-
// line. hypo-auto-commit
|
|
427
|
-
//
|
|
428
|
-
// doctor
|
|
807
|
+
// line. hypo-auto-commit appends on pull/push failure; hypo-session-start
|
|
808
|
+
// surfaces open entries and clears them once sync is healthy again;
|
|
809
|
+
// doctor warns while entries remain. Keep the schema defined here only.
|
|
429
810
|
|
|
430
811
|
/** @returns {string} path to the sync-state JSONL file for a wiki root. */
|
|
431
812
|
function syncStatePath(hypoDir) {
|
|
@@ -727,19 +1108,25 @@ export function sessionClosedMarkerPath(hypoDir, sessionId) {
|
|
|
727
1108
|
*
|
|
728
1109
|
* @param {string} hypoDir
|
|
729
1110
|
* @param {string} sessionId
|
|
730
|
-
* @param {{project?: string, transcript_path?: string}} info
|
|
1111
|
+
* @param {{project?: string, scope?: string, transcript_path?: string}} info
|
|
731
1112
|
*/
|
|
732
1113
|
export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
|
|
733
1114
|
if (!sessionId) return;
|
|
734
1115
|
try {
|
|
735
1116
|
const cacheDir = join(hypoDir, '.cache');
|
|
736
1117
|
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
1118
|
+
// scope distinguishes a project close (the 5 mandatory files were verified
|
|
1119
|
+
// fresh) from a log-only close (a non-project session, no project
|
|
1120
|
+
// attribution). Readers (precompactGateStatus / --check-session-close) key
|
|
1121
|
+
// the gate semantics on this field, so it must be recorded.
|
|
1122
|
+
const scope = info.scope === 'log-only' ? 'log-only' : 'project';
|
|
737
1123
|
const payload = {
|
|
738
1124
|
session_id: sessionId,
|
|
739
1125
|
project: info.project || null,
|
|
1126
|
+
scope,
|
|
740
1127
|
transcript_path: info.transcript_path || null,
|
|
741
1128
|
closed_at: new Date().toISOString(),
|
|
742
|
-
verification: 'session-close-file-status:ok',
|
|
1129
|
+
verification: scope === 'log-only' ? 'log-only-close:ok' : 'session-close-file-status:ok',
|
|
743
1130
|
};
|
|
744
1131
|
writeFileSync(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
|
|
745
1132
|
} catch (err) {
|
|
@@ -790,15 +1177,33 @@ export function clearSessionClosedMarker(hypoDir, sessionId) {
|
|
|
790
1177
|
}
|
|
791
1178
|
}
|
|
792
1179
|
|
|
793
|
-
// ── transcript activity heuristic (ADR 0022 amendment 2026-05-19) ──
|
|
794
|
-
// Substantial-session gate for the Stop hook: a session
|
|
795
|
-
//
|
|
796
|
-
//
|
|
1180
|
+
// ── transcript activity heuristic (ADR 0022 amendment 2026-05-19; 6a 2026-06-14) ──
|
|
1181
|
+
// Substantial-session gate for the Stop hook: a session "worth" blocking on for
|
|
1182
|
+
// session-close is either (a) any mutation (Edit/Write/MultiEdit/NotebookEdit)
|
|
1183
|
+
// or (b) a high-volume read-only investigation (≥ READONLY_SUBSTANTIAL_THRESHOLD
|
|
1184
|
+
// Read/Grep/Glob/Bash calls). Pure Q&A / incidental lookups skip the block.
|
|
797
1185
|
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
1186
|
+
// 6a rationale: code-review / debugging sessions reach real conclusions worth
|
|
1187
|
+
// crystallizing while touching only read-only tools. The original gate keyed
|
|
1188
|
+
// purely on mutation tools, so those sessions were never nudged to close. The
|
|
1189
|
+
// investigation threshold (5) mirrors session-audit.mjs's "search-many" cutoff
|
|
1190
|
+
// (scripts/session-audit.mjs:206) and sits in the empirical gap between
|
|
1191
|
+
// incidental lookups (0–3 calls) and real investigation (16–22 calls). Bash is
|
|
1192
|
+
// now counted (it is the dominant signal in read-only sessions); over-firing is
|
|
1193
|
+
// bounded by the close-intent gate (the Stop hook only blocks when the user also
|
|
1194
|
+
// signalled wrap-up) — see hypo-auto-minimal-crystallize.mjs.
|
|
1195
|
+
//
|
|
1196
|
+
// NOTE: counting a Bash call here does NOT widen the lint scope. Lint scoping
|
|
1197
|
+
// (precompactGateStatus) still seeds only Edit/Write-touched files plus the
|
|
1198
|
+
// mandatory close files — a Bash-written wiki file is not auto-scoped.
|
|
800
1199
|
|
|
801
1200
|
const MUTATING_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
1201
|
+
// Read-only investigation tools. Bash included: read-only sessions are
|
|
1202
|
+
// Bash-dominant (git/grep/cat), so excluding it would leave most of them
|
|
1203
|
+
// undetectable (a real session had read=0, bash=16).
|
|
1204
|
+
const INVESTIGATION_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'Bash']);
|
|
1205
|
+
// Investigation-volume cutoff for a read-only session to count as substantial.
|
|
1206
|
+
const READONLY_SUBSTANTIAL_THRESHOLD = 5;
|
|
802
1207
|
|
|
803
1208
|
/** Mirror of `scripts/session-audit.mjs` extractToolNames: handles both top-level
|
|
804
1209
|
* `tool_use` entries (legacy fixtures) and nested `message.content[].tool_use`
|
|
@@ -828,27 +1233,30 @@ function extractTranscriptToolNames(entry) {
|
|
|
828
1233
|
}
|
|
829
1234
|
|
|
830
1235
|
/**
|
|
831
|
-
*
|
|
832
|
-
*
|
|
1236
|
+
* Single-pass tool-use census over a JSONL transcript. Both public predicates
|
|
1237
|
+
* (`hasMutatingTranscriptActivity`, `isSubstantialSession`) derive from this one
|
|
1238
|
+
* census so the mutation leg stays identical between them. (They still diverge
|
|
1239
|
+
* on read-only ≥ threshold sessions, where only `isSubstantialSession` is true.)
|
|
833
1240
|
*
|
|
834
|
-
* Granularity:
|
|
835
|
-
* • Whole-file unreadable / missing path →
|
|
1241
|
+
* Granularity (shared contract):
|
|
1242
|
+
* • Whole-file unreadable / missing path → all counts 0 (fail-open).
|
|
836
1243
|
* • Per-line malformed JSON → that line is skipped, scan continues. Real
|
|
837
1244
|
* transcripts occasionally carry truncated lines; one bad line must not
|
|
838
|
-
* hide a clearly-
|
|
1245
|
+
* hide a clearly-active session that follows. (Codex Worker-2 CONCERN
|
|
839
1246
|
* resolved 2026-05-19: line-level skip is the intended contract.)
|
|
840
1247
|
*
|
|
841
1248
|
* @param {string|null|undefined} transcriptPath
|
|
842
|
-
* @returns {
|
|
1249
|
+
* @returns {{ mutationCount: number, investigationCount: number }}
|
|
843
1250
|
*/
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
if (!
|
|
1251
|
+
function transcriptActivityStats(transcriptPath) {
|
|
1252
|
+
const stats = { mutationCount: 0, investigationCount: 0 };
|
|
1253
|
+
if (!transcriptPath || typeof transcriptPath !== 'string') return stats;
|
|
1254
|
+
if (!existsSync(transcriptPath)) return stats;
|
|
847
1255
|
let raw;
|
|
848
1256
|
try {
|
|
849
1257
|
raw = readFileSync(transcriptPath, 'utf-8');
|
|
850
1258
|
} catch {
|
|
851
|
-
return
|
|
1259
|
+
return stats;
|
|
852
1260
|
}
|
|
853
1261
|
for (const line of raw.split('\n')) {
|
|
854
1262
|
const trimmed = line.trim();
|
|
@@ -860,10 +1268,41 @@ export function hasMutatingTranscriptActivity(transcriptPath) {
|
|
|
860
1268
|
continue;
|
|
861
1269
|
}
|
|
862
1270
|
for (const name of extractTranscriptToolNames(entry)) {
|
|
863
|
-
if (MUTATING_TOOL_NAMES.has(name))
|
|
1271
|
+
if (MUTATING_TOOL_NAMES.has(name)) stats.mutationCount++;
|
|
1272
|
+
else if (INVESTIGATION_TOOL_NAMES.has(name)) stats.investigationCount++;
|
|
864
1273
|
}
|
|
865
1274
|
}
|
|
866
|
-
return
|
|
1275
|
+
return stats;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* True if the JSONL transcript at `transcriptPath` contains ≥1 mutation
|
|
1280
|
+
* tool_use (Edit/Write/MultiEdit/NotebookEdit). Kept as a precise, standalone
|
|
1281
|
+
* helper (and regression oracle) even though the Stop hook now gates on the
|
|
1282
|
+
* broader `isSubstantialSession`.
|
|
1283
|
+
*
|
|
1284
|
+
* @param {string|null|undefined} transcriptPath
|
|
1285
|
+
* @returns {boolean}
|
|
1286
|
+
*/
|
|
1287
|
+
export function hasMutatingTranscriptActivity(transcriptPath) {
|
|
1288
|
+
return transcriptActivityStats(transcriptPath).mutationCount > 0;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* True if the session is "substantial" enough to nudge a session-close:
|
|
1293
|
+
* any mutation, OR a read-only investigation of at least
|
|
1294
|
+
* READONLY_SUBSTANTIAL_THRESHOLD Read/Grep/Glob/Bash calls (6a). The mutation
|
|
1295
|
+
* leg is identical to `hasMutatingTranscriptActivity`, so mutating sessions
|
|
1296
|
+
* behave exactly as before; only high-volume read-only sessions are newly
|
|
1297
|
+
* caught. Over-firing on read-only sessions is bounded downstream by the
|
|
1298
|
+
* close-intent gate in hypo-auto-minimal-crystallize.mjs.
|
|
1299
|
+
*
|
|
1300
|
+
* @param {string|null|undefined} transcriptPath
|
|
1301
|
+
* @returns {boolean}
|
|
1302
|
+
*/
|
|
1303
|
+
export function isSubstantialSession(transcriptPath) {
|
|
1304
|
+
const { mutationCount, investigationCount } = transcriptActivityStats(transcriptPath);
|
|
1305
|
+
return mutationCount > 0 || investigationCount >= READONLY_SUBSTANTIAL_THRESHOLD;
|
|
867
1306
|
}
|
|
868
1307
|
|
|
869
1308
|
// ── session-scoped lint classification ──────────────────────────────────────
|
|
@@ -959,8 +1398,35 @@ export function closeFileTargets(hypoDir) {
|
|
|
959
1398
|
if (project) {
|
|
960
1399
|
out.add(`projects/${project}/session-state.md`);
|
|
961
1400
|
out.add(`projects/${project}/hot.md`);
|
|
962
|
-
|
|
963
|
-
|
|
1401
|
+
out.add(sessionLogScopePath(hypoDir, project, freshDates()[0]));
|
|
1402
|
+
}
|
|
1403
|
+
return out;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Global variant of closeFileTargets for the no-payload lint-scope callers
|
|
1408
|
+
* (ADR 0043). Union of the close files over every today-active project
|
|
1409
|
+
* (fallback: the recency project when none is active). Includes the session-log
|
|
1410
|
+
* evidence file for EVERY freshDate (not just dates[0]) so the lint scope matches
|
|
1411
|
+
* what sessionCloseFileStatus actually checks across a local/UTC date boundary.
|
|
1412
|
+
*/
|
|
1413
|
+
export function closeFileTargetsGlobal(hypoDir) {
|
|
1414
|
+
const dates = freshDates();
|
|
1415
|
+
const out = new Set(['hot.md', 'log.md']);
|
|
1416
|
+
let active = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
|
|
1417
|
+
hasTodayCloseActivity(hypoDir, p, dates),
|
|
1418
|
+
);
|
|
1419
|
+
if (active.length === 0) {
|
|
1420
|
+
const recency = resolveActiveProject(hypoDir);
|
|
1421
|
+
if (recency) active = [recency];
|
|
1422
|
+
}
|
|
1423
|
+
for (const p of active) {
|
|
1424
|
+
out.add(`projects/${p}/session-state.md`);
|
|
1425
|
+
out.add(`projects/${p}/hot.md`);
|
|
1426
|
+
// Scope to the file each date's freshness is PROVEN by (daily shard or, via
|
|
1427
|
+
// fallback, the legacy monthly), so a corrupt evidence file can't pass the
|
|
1428
|
+
// gate while its lint error is demoted to an out-of-scope notice.
|
|
1429
|
+
for (const d of dates) out.add(sessionLogScopePath(hypoDir, p, d));
|
|
964
1430
|
}
|
|
965
1431
|
return out;
|
|
966
1432
|
}
|
|
@@ -993,6 +1459,253 @@ export function partitionLintScope(findings, scope) {
|
|
|
993
1459
|
return { blocking, notice };
|
|
994
1460
|
}
|
|
995
1461
|
|
|
1462
|
+
// ── PreCompact gate — single source of truth ────────────────────────────────
|
|
1463
|
+
/**
|
|
1464
|
+
* The full PreCompact gate decision as a READ-ONLY status. This is the single
|
|
1465
|
+
* source of truth for "is the wiki compact-ready?": both hypo-personal-check.mjs
|
|
1466
|
+
* (the PreCompact hook) and `crystallize --check-session-close` call it, so a
|
|
1467
|
+
* green status means /compact will not block on a human-fixable issue.
|
|
1468
|
+
*
|
|
1469
|
+
* Read-only: feedback projection PURE drift is reported as a non-blocking notice
|
|
1470
|
+
* with its targets in `driftTargets` (an "effect requirement"), NOT a blocker —
|
|
1471
|
+
* the hook self-heals it with `feedback-sync --write` before continuing (ADR
|
|
1472
|
+
* 0045) and a verify caller needs no human action for it. over-cap and conflict
|
|
1473
|
+
* DO block (ADR 0031 rules 3 & 6 — human demote/import required).
|
|
1474
|
+
*
|
|
1475
|
+
* Faithfulness caveats (why "compact-ready", not "guaranteed pass"): the hook
|
|
1476
|
+
* has paths outside this status — a context-≥70% early block, HYPO_SKIP_GATE
|
|
1477
|
+
* bypass, and a fail-closed if the self-heal `--write` itself errors. And without
|
|
1478
|
+
* a transcript the lint scope is the mandatory close files only; pass
|
|
1479
|
+
* opts.transcriptPath to widen it to the session's edited files exactly as the
|
|
1480
|
+
* hook does.
|
|
1481
|
+
*
|
|
1482
|
+
* @param {string} hypoDir
|
|
1483
|
+
* @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string}} [opts]
|
|
1484
|
+
* @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
|
|
1485
|
+
*/
|
|
1486
|
+
export function precompactGateStatus(hypoDir, opts = {}) {
|
|
1487
|
+
const blockers = [];
|
|
1488
|
+
const notices = [];
|
|
1489
|
+
const driftTargets = [];
|
|
1490
|
+
const skipped = { lint: false, feedback: false };
|
|
1491
|
+
|
|
1492
|
+
// log-only synthetic close mode. A non-project (tooling / wiki-only)
|
|
1493
|
+
// session has no project to close. Activated either by the explicit writer flag
|
|
1494
|
+
// (opts.logOnly, from `--mark-session-closed --log-only`) or by a log-only marker
|
|
1495
|
+
// for opts.sessionId (the PreCompact / --check-session-close readers). In this
|
|
1496
|
+
// mode the project-close invariant is replaced by a today log.md entry (minimum
|
|
1497
|
+
// proof), and — critically — the active/phantom project is NEVER put in
|
|
1498
|
+
// close.projects, because that set ALSO drives the lint scope and W8 ownership
|
|
1499
|
+
// below. Exempting only the close blocker would still let an unrelated project's
|
|
1500
|
+
// stale design-history / lint block the non-project session (codex design
|
|
1501
|
+
// BLOCKER). git / hot / lint(self) / feedback all still apply — not a bypass.
|
|
1502
|
+
const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
|
|
1503
|
+
const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
|
|
1504
|
+
|
|
1505
|
+
// 1. wiki git clean
|
|
1506
|
+
const git = hypoIsClean(hypoDir);
|
|
1507
|
+
if (!git.clean) blockers.push({ type: 'git', reason: git.reason });
|
|
1508
|
+
|
|
1509
|
+
// 2. root hot.md structure
|
|
1510
|
+
const hot = hotMdIsClean(hypoDir);
|
|
1511
|
+
if (!hot.clean) blockers.push({ type: 'hot', reason: hot.reason });
|
|
1512
|
+
|
|
1513
|
+
// 3. session-close files (global invariant, ADR 0043) — or, in log-only mode,
|
|
1514
|
+
// the minimum proof (a today log.md entry) with NO project attribution.
|
|
1515
|
+
let close;
|
|
1516
|
+
if (logOnly) {
|
|
1517
|
+
const hasLog = hasAnyTodayLogEntry(hypoDir);
|
|
1518
|
+
close = {
|
|
1519
|
+
ok: hasLog,
|
|
1520
|
+
projects: [],
|
|
1521
|
+
dates: freshDates(),
|
|
1522
|
+
fallback: false,
|
|
1523
|
+
primary: null,
|
|
1524
|
+
project: null,
|
|
1525
|
+
stale: [],
|
|
1526
|
+
missing: hasLog ? [] : ['log.md (no today session entry)'],
|
|
1527
|
+
};
|
|
1528
|
+
if (!hasLog) {
|
|
1529
|
+
blockers.push({
|
|
1530
|
+
type: 'close',
|
|
1531
|
+
reason: 'log-only close: log.md has no today session entry (minimum proof)',
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
} else {
|
|
1535
|
+
close = sessionCloseGlobalStatus(hypoDir);
|
|
1536
|
+
if (!close.ok) {
|
|
1537
|
+
blockers.push({
|
|
1538
|
+
type: 'close',
|
|
1539
|
+
reason: `memory files not updated this session: ${[
|
|
1540
|
+
...close.missing.map((f) => `${f} (missing)`),
|
|
1541
|
+
...close.stale.map((f) => `${f} (stale)`),
|
|
1542
|
+
].join(', ')}`,
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// 4. lint blockers + W8 design-history (scoped). Mirrors hypo-personal-check.
|
|
1548
|
+
const lintPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'lint.mjs') : null;
|
|
1549
|
+
if (!lintPath || !existsSync(lintPath)) {
|
|
1550
|
+
skipped.lint = true; // no package → fail-open (never block on missing tooling)
|
|
1551
|
+
} else {
|
|
1552
|
+
try {
|
|
1553
|
+
// Pass --hypo-dir explicitly: lint.mjs resolves the vault via HYPO_DIR /
|
|
1554
|
+
// home dirs and ignores cwd, so a --hypo-dir caller (crystallize, tests)
|
|
1555
|
+
// would otherwise lint the ambient wiki, not the one under test.
|
|
1556
|
+
// maxBuffer matches crystallize's runLint (64 MiB): warn-heavy output on a
|
|
1557
|
+
// large wiki easily exceeds Node's 1 MiB default, which would truncate
|
|
1558
|
+
// stdout and (via the catch below) silently fail-open this gate.
|
|
1559
|
+
const r = spawnSync('node', [lintPath, '--json', `--hypo-dir=${hypoDir}`], {
|
|
1560
|
+
encoding: 'utf-8',
|
|
1561
|
+
cwd: hypoDir,
|
|
1562
|
+
timeout: 30000,
|
|
1563
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1564
|
+
});
|
|
1565
|
+
// A spawn failure (ENOENT), a timeout/kill (status === null), or a crash
|
|
1566
|
+
// that produced no stdout must NOT be parsed as `{}` and treated as a clean
|
|
1567
|
+
// lint — that path leaves skipped.lint=false with no notice, an INVISIBLE
|
|
1568
|
+
// fail-open. Throw instead so the catch below records skipped.lint=true WITH
|
|
1569
|
+
// a reason notice.
|
|
1570
|
+
if (r.error || r.status === null) {
|
|
1571
|
+
throw new Error(`lint spawn failed: ${r.error?.code || `signal ${r.signal}`}`);
|
|
1572
|
+
}
|
|
1573
|
+
if (!r.stdout || !r.stdout.trim()) {
|
|
1574
|
+
throw new Error(
|
|
1575
|
+
`lint produced no stdout (exit=${r.status})${r.stderr ? `: ${r.stderr.slice(-500)}` : ''}`,
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
const parsed = JSON.parse(r.stdout);
|
|
1579
|
+
const allErrors = parsed.errors || [];
|
|
1580
|
+
const allW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
|
|
1581
|
+
// log-only base scope = the shared root files only (hot.md / log.md) — NOT
|
|
1582
|
+
// closeFileTargetsGlobal, which would fold the active/phantom project's
|
|
1583
|
+
// mandatory files in and re-introduce the cross-project attribution. The
|
|
1584
|
+
// session's own transcript-touched files are still added below (a log-only
|
|
1585
|
+
// session is accountable for the wiki files it actually edited).
|
|
1586
|
+
const scope = new Set(
|
|
1587
|
+
opts.lintScope || (logOnly ? ['hot.md', 'log.md'] : closeFileTargetsGlobal(hypoDir)),
|
|
1588
|
+
);
|
|
1589
|
+
if (opts.transcriptPath && existsSync(opts.transcriptPath)) {
|
|
1590
|
+
for (const f of extractTouchedWikiFiles(opts.transcriptPath, hypoDir)) scope.add(f);
|
|
1591
|
+
}
|
|
1592
|
+
const part = partitionLintScope(allErrors, scope);
|
|
1593
|
+
if (part.blocking.length > 0) {
|
|
1594
|
+
blockers.push({
|
|
1595
|
+
type: 'lint',
|
|
1596
|
+
reason: `lint blockers: ${[...new Set(part.blocking.map((b) => b.id || b.file))].join(', ')}`,
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
for (const n of part.notice)
|
|
1600
|
+
notices.push({ type: 'lint', reason: `${n.file}${n.id ? ` (${n.id})` : ''}` });
|
|
1601
|
+
// W8 (design-history stale) is each today-active project's own close
|
|
1602
|
+
// responsibility; others' are non-blocking notices (ADR 0043). In log-only
|
|
1603
|
+
// mode there is NO project this session is accountable for, so every W8 is a
|
|
1604
|
+
// notice — a non-project session must never be blocked by some project's
|
|
1605
|
+
// stale design-history (codex design BLOCKER: the attribution leak this fix
|
|
1606
|
+
// closes).
|
|
1607
|
+
const activeSlugs = logOnly
|
|
1608
|
+
? []
|
|
1609
|
+
: (close.projects || []).map((p) => p.project).filter(Boolean);
|
|
1610
|
+
const mine = new Set(activeSlugs.map((s) => `projects/${s}/design-history.md`));
|
|
1611
|
+
const w8Blocking = logOnly
|
|
1612
|
+
? []
|
|
1613
|
+
: activeSlugs.length > 0
|
|
1614
|
+
? allW8.filter((w) => mine.has(w.file))
|
|
1615
|
+
: allW8;
|
|
1616
|
+
const w8Notice = logOnly
|
|
1617
|
+
? allW8
|
|
1618
|
+
: activeSlugs.length > 0
|
|
1619
|
+
? allW8.filter((w) => !mine.has(w.file))
|
|
1620
|
+
: [];
|
|
1621
|
+
if (w8Blocking.length > 0) {
|
|
1622
|
+
blockers.push({
|
|
1623
|
+
type: 'design-history',
|
|
1624
|
+
reason: `design-history stale: ${w8Blocking.map((w) => w.file.split('/')[1]).join(', ')}`,
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
for (const w of w8Notice) notices.push({ type: 'design-history', reason: w.file });
|
|
1628
|
+
} catch (e) {
|
|
1629
|
+
skipped.lint = true; // fail-open on tooling error
|
|
1630
|
+
// Surface WHY the gate skipped lint (truncated stdout, timeout, spawn error)
|
|
1631
|
+
// instead of silently dropping the check — a fail-open is invisible otherwise.
|
|
1632
|
+
notices.push({
|
|
1633
|
+
type: 'lint',
|
|
1634
|
+
reason: `lint skipped (fail-open): ${e.message || e.code || 'unknown error'}`,
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// 5. feedback projection (ADR 0031 / 0045). over-cap/conflict block; pure
|
|
1640
|
+
// drift is a self-healable notice (driftTargets = effect requirement the
|
|
1641
|
+
// hook runs as --write). Classification mirrors hypo-personal-check exactly.
|
|
1642
|
+
const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
|
|
1643
|
+
const claudeHome = opts.claudeHome || join(HOME, '.claude');
|
|
1644
|
+
if (!feedbackPath || !existsSync(feedbackPath)) {
|
|
1645
|
+
skipped.feedback = true;
|
|
1646
|
+
} else {
|
|
1647
|
+
try {
|
|
1648
|
+
const r = spawnSync(
|
|
1649
|
+
process.execPath,
|
|
1650
|
+
[
|
|
1651
|
+
feedbackPath,
|
|
1652
|
+
'--check',
|
|
1653
|
+
'--strict',
|
|
1654
|
+
'--no-input',
|
|
1655
|
+
'--json',
|
|
1656
|
+
`--hypo-dir=${hypoDir}`,
|
|
1657
|
+
`--claude-home=${claudeHome}`,
|
|
1658
|
+
],
|
|
1659
|
+
{ encoding: 'utf-8', timeout: 30000 },
|
|
1660
|
+
);
|
|
1661
|
+
if (r.error || r.status === null) {
|
|
1662
|
+
skipped.feedback = true; // spawn failure → fail-open
|
|
1663
|
+
} else if (r.status !== 0) {
|
|
1664
|
+
// Only a non-zero exit carries an actionable issue. A clean check exits 0
|
|
1665
|
+
// (the implicit else below) — that is NOT skipped, just nothing to do.
|
|
1666
|
+
let report = null;
|
|
1667
|
+
try {
|
|
1668
|
+
report = JSON.parse(r.stdout || '');
|
|
1669
|
+
} catch {
|
|
1670
|
+
/* unparseable → fail-open below */
|
|
1671
|
+
}
|
|
1672
|
+
const entries = report ? Object.entries(report.targets || {}) : [];
|
|
1673
|
+
const conflictedT = entries
|
|
1674
|
+
.filter(
|
|
1675
|
+
([, t]) =>
|
|
1676
|
+
t.intruder || t.unpaired || t.outOfContainer || (t.conflicts && t.conflicts.length),
|
|
1677
|
+
)
|
|
1678
|
+
.map(([n]) => n);
|
|
1679
|
+
const overCapT = entries.filter(([, t]) => t.overCap).map(([n]) => n);
|
|
1680
|
+
const driftedT = entries.filter(([, t]) => t.dirty).map(([n]) => n);
|
|
1681
|
+
if (!report || !(conflictedT.length || overCapT.length || driftedT.length)) {
|
|
1682
|
+
skipped.feedback = true; // buildError / unparseable / non-actionable → fail-open
|
|
1683
|
+
} else if (conflictedT.length) {
|
|
1684
|
+
blockers.push({
|
|
1685
|
+
type: 'feedback',
|
|
1686
|
+
reason: `feedback projection conflict (manual edit of ${conflictedT.join(', ')}) — run \`hypomnema feedback-sync --import-target-change --from=<memory|claude>\``,
|
|
1687
|
+
});
|
|
1688
|
+
} else if (overCapT.length) {
|
|
1689
|
+
blockers.push({
|
|
1690
|
+
type: 'feedback',
|
|
1691
|
+
reason: `feedback projection over cap (${overCapT.join(', ')}) — demote/archive feedback pages`,
|
|
1692
|
+
});
|
|
1693
|
+
} else {
|
|
1694
|
+
driftTargets.push(...driftedT); // pure drift → self-healable, not a blocker
|
|
1695
|
+
notices.push({
|
|
1696
|
+
type: 'feedback',
|
|
1697
|
+
reason: `feedback projection drift (${driftedT.join(', ')}) — will self-heal at /compact`,
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
} catch {
|
|
1702
|
+
skipped.feedback = true;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
return { ok: blockers.length === 0, close, blockers, notices, driftTargets, skipped };
|
|
1707
|
+
}
|
|
1708
|
+
|
|
996
1709
|
// ── session-close checklist ────────────────────────────────────────────────
|
|
997
1710
|
|
|
998
1711
|
/**
|
|
@@ -1036,14 +1749,14 @@ export function isClearCommand(prompt) {
|
|
|
1036
1749
|
return prompt === '/clear' || /^\/clear(\s|$)/.test(prompt);
|
|
1037
1750
|
}
|
|
1038
1751
|
|
|
1039
|
-
/** Returns true if the prompt is either /compact or /clear (ADR 0022 Layer 2
|
|
1752
|
+
/** Returns true if the prompt is either /compact or /clear (ADR 0022 Layer 2). */
|
|
1040
1753
|
export function isCompactOrClearCommand(prompt) {
|
|
1041
1754
|
return isCompactCommand(prompt) || isClearCommand(prompt);
|
|
1042
1755
|
}
|
|
1043
1756
|
|
|
1044
1757
|
/**
|
|
1045
1758
|
* Extract recent user-role message text from a JSONL transcript (last `tailN`
|
|
1046
|
-
* lines). Promoted from hypo-personal-check.mjs
|
|
1759
|
+
* lines). Promoted from hypo-personal-check.mjs so both the
|
|
1047
1760
|
* PreCompact gate and the Stop-chain Layer 3 hook share one close-intent
|
|
1048
1761
|
* signal source. Claude Code transcript format: each line is
|
|
1049
1762
|
* `{ type:"user", message:{ role:"user", content: ... } }`; the older
|
|
@@ -1100,8 +1813,11 @@ export function isClosePattern(text) {
|
|
|
1100
1813
|
/오늘은?\s*이만/,
|
|
1101
1814
|
];
|
|
1102
1815
|
const enPatterns = [
|
|
1103
|
-
// wrap up: requires session-level context or sentence-end, not code-level
|
|
1104
|
-
/
|
|
1816
|
+
// wrap up: requires session-level context or sentence-end, not code-level
|
|
1817
|
+
// objects. The review/analysis/debug/audit/investigation nouns were added
|
|
1818
|
+
// for 6a — read-only review sessions are now "substantial", so "wrap up the
|
|
1819
|
+
// review" must read as a task-level signal, not a session-close one.
|
|
1820
|
+
/wrap(?:ping)?\s+up(?!\s+(?:this|the)\s+(?:pr|issue|bug|task|function|component|module|feature|code|test|review|analysis|investigation|debugging|debug|audit|refactor)\b)/i,
|
|
1105
1821
|
/done\s+for\s+(?:today|now|the\s+day)/i,
|
|
1106
1822
|
/that'?s?\s+(?:all|it)\s+for\s+(?:today|now|the\s+day)/i,
|
|
1107
1823
|
/signing\s+off/i,
|