hypomnema 1.8.0 → 1.8.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.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.ko.md +8 -6
  5. package/README.md +8 -6
  6. package/commands/crystallize.md +32 -6
  7. package/commands/graph.md +7 -4
  8. package/commands/lint.md +8 -1
  9. package/commands/query.md +6 -4
  10. package/commands/resume.md +1 -0
  11. package/commands/verify.md +12 -1
  12. package/docs/ARCHITECTURE.md +26 -12
  13. package/docs/CONTRIBUTING.md +15 -6
  14. package/hooks/hypo-compact-guard.mjs +126 -23
  15. package/hooks/hypo-cwd-change.mjs +20 -19
  16. package/hooks/hypo-first-prompt.mjs +31 -16
  17. package/hooks/hypo-lookup.mjs +10 -5
  18. package/hooks/hypo-session-start.mjs +180 -2
  19. package/hooks/hypo-shared.mjs +410 -83
  20. package/hooks/hypo-web-fetch-ingest.mjs +9 -13
  21. package/package.json +2 -1
  22. package/scripts/doctor.mjs +32 -3
  23. package/scripts/graph.mjs +22 -2
  24. package/scripts/init.mjs +5 -1
  25. package/scripts/lib/crystallize-args.mjs +38 -2
  26. package/scripts/lib/crystallize-close-apply.mjs +745 -450
  27. package/scripts/lint.mjs +242 -39
  28. package/scripts/query.mjs +22 -2
  29. package/scripts/resume.mjs +177 -2
  30. package/scripts/upgrade.mjs +2 -2
  31. package/scripts/verify.mjs +22 -2
  32. package/templates/SCHEMA.md +23 -1
  33. package/templates/hypo-automation.md +4 -2
  34. package/templates/hypo-config.md +1 -1
  35. package/templates/hypo-guide.md +1 -1
  36. package/skills/crystallize/SKILL.md +0 -189
  37. package/skills/graph/SKILL.md +0 -58
  38. package/skills/ingest/SKILL.md +0 -107
  39. package/skills/lint/SKILL.md +0 -59
  40. package/skills/query/SKILL.md +0 -62
  41. package/skills/verify/SKILL.md +0 -96
@@ -396,8 +396,23 @@ export function isGateSkipped() {
396
396
  // ── state checkers ─────────────────────────────────────────────────────────
397
397
 
398
398
  export function lastSubstantialOpIsSession() {
399
- if (!existsSync(LOG_PATH)) return true;
400
- const log = readFileSync(LOG_PATH, 'utf-8');
399
+ // Single read, no existsSync precheck: a check-then-read pair leaves a race
400
+ // window where the file exists at the check and is gone by the read, and
401
+ // the old code treated that ENOENT the same as a stably-missing file
402
+ // (fail-open, `true`). Only a genuinely absent log.md (ENOENT) folds to
403
+ // `false` here; any other read failure (EISDIR, EACCES, ...) is a real
404
+ // problem the caller needs to see, not a silent "no session", so it is
405
+ // rethrown. hypo-compact-guard.mjs is the only in-repo caller (verified via
406
+ // grep) and its own try/catch already turns any throw here into
407
+ // fail-closed ("session log entry missing") plus a stderr line, so this
408
+ // fail-open-to-fail-closed flip needed no other caller to be re-audited.
409
+ let log;
410
+ try {
411
+ log = readFileSync(LOG_PATH, 'utf-8');
412
+ } catch (err) {
413
+ if (err && err.code === 'ENOENT') return false;
414
+ throw err;
415
+ }
401
416
  const substantial = log
402
417
  .split('\n')
403
418
  .filter((l) => /^## \[\d{4}-\d{2}-\d{2}\] (session|ingest)/.test(l));
@@ -414,12 +429,61 @@ export function lastSubstantialOpIsSession() {
414
429
  // read it. Callers that gate session-close / compact distinguish the two: they
415
430
  // block on `uncommitted` and demote `ahead` to a notice (precompactGateStatus,
416
431
  // hypo-compact-guard) so a committed-but-unpushed close is still "compact-ready".
417
- export function hypoIsClean(dir = HYPO_DIR) {
432
+ // A deadline shared across every git spawn one "am I clean" check can make:
433
+ // `{ end: <performance.now()-based timestamp> }`, built once by a caller that
434
+ // needs to bound hypoIsClean and gitDirtyFiles to a slice of its own hook
435
+ // timeout (session-close-scope-boundary spec §5). remainingSpawnTimeoutMs
436
+ // turns "time left" into what spawnSync's own `timeout` option actually
437
+ // requires — verified on Node 26: `timeout: 0` DISABLES the timeout (waits
438
+ // forever) rather than firing immediately, and a negative or fractional value
439
+ // THROWS ERR_OUT_OF_RANGE. A throw here would escape as an exception through
440
+ // a hook's outermost catch and come back silent, so a caller must never pass
441
+ // a value this function did not produce, and must never spawn once it
442
+ // returns 0.
443
+ // A deadline built from a bad `end` (Infinity, NaN, or already past) must
444
+ // also fold to 0, not just a plain negative one: a caller-supplied
445
+ // `{ end: Infinity }` produces `remaining = Infinity`, and `Infinity` is
446
+ // exactly as unsafe a `timeout` value as a negative or fractional one, same
447
+ // `ERR_OUT_OF_RANGE` throw, just via a different guard. `Number.isFinite`
448
+ // alone is not enough, though: it also passes `Number.MAX_VALUE`, which is
449
+ // finite but still outside spawnSync's documented `timeout` range (0 through
450
+ // `Number.MAX_SAFE_INTEGER`) and throws the same `ERR_OUT_OF_RANGE` (verified
451
+ // directly: `gitDirtyFiles(cwd, { deadline: { end: Number.MAX_VALUE } })`
452
+ // threw before this guard, since MAX_SAFE_INTEGER is exactly spawnSync's
453
+ // upper bound, `Number.isSafeInteger` is the one predicate that matches it.
454
+ // undefined — no deadline given: omit `timeout` entirely (today's behavior)
455
+ // 0 — budget exhausted, or the deadline itself was not a safe
456
+ // positive integer: do not spawn
457
+ // >0 — a whole positive integer number of ms safe to pass as `timeout`
458
+ function remainingSpawnTimeoutMs(deadline) {
459
+ if (!deadline) return undefined;
460
+ const remaining = Math.floor(deadline.end - performance.now());
461
+ return Number.isSafeInteger(remaining) && remaining > 0 ? remaining : 0;
462
+ }
463
+
464
+ // `opts.deadline` is optional (session-close-scope-boundary spec §5): when
465
+ // omitted, both spawns run exactly as before, no timeout, no status/error
466
+ // check on the second one. hypo-compact-guard.mjs is the only caller that
467
+ // passes one; precompactGateStatus (`:3872`) and all six direct test call
468
+ // sites still pass a bare `dir` and must see byte-identical behavior.
469
+ export function hypoIsClean(dir = HYPO_DIR, opts = {}) {
470
+ const { deadline } = opts;
418
471
  try {
419
- const porcelain = spawnSync('git', ['-C', dir, 'status', '--porcelain'], {
420
- encoding: 'utf-8',
421
- });
422
- if (porcelain.status !== 0)
472
+ const t1 = remainingSpawnTimeoutMs(deadline);
473
+ if (t1 === 0) {
474
+ return {
475
+ clean: false,
476
+ uncommitted: true,
477
+ ahead: false,
478
+ reason: `git check deadline exhausted in ${dir}`,
479
+ };
480
+ }
481
+ const porcelain = spawnSync(
482
+ 'git',
483
+ ['-C', dir, 'status', '--porcelain'],
484
+ t1 === undefined ? { encoding: 'utf-8' } : { encoding: 'utf-8', timeout: t1 },
485
+ );
486
+ if (porcelain.error || porcelain.status !== 0)
423
487
  return {
424
488
  clean: false,
425
489
  uncommitted: true,
@@ -427,9 +491,38 @@ export function hypoIsClean(dir = HYPO_DIR) {
427
491
  reason: `git check failed in ${dir}`,
428
492
  };
429
493
  const uncommitted = porcelain.stdout.trim() !== '';
430
- const aheadRes = spawnSync('git', ['-C', dir, 'status', '--branch', '--porcelain'], {
431
- encoding: 'utf-8',
432
- });
494
+
495
+ const t2 = remainingSpawnTimeoutMs(deadline);
496
+ if (t2 === 0) {
497
+ return {
498
+ clean: false,
499
+ uncommitted: true,
500
+ ahead: false,
501
+ reason: `git check deadline exhausted in ${dir}`,
502
+ };
503
+ }
504
+ const aheadRes = spawnSync(
505
+ 'git',
506
+ ['-C', dir, 'status', '--branch', '--porcelain'],
507
+ t2 === undefined ? { encoding: 'utf-8' } : { encoding: 'utf-8', timeout: t2 },
508
+ );
509
+ // Only enforced when a deadline is in play. Without one this spawn cannot
510
+ // time out, and its `stdout` (empty on any other kind of failure too) was
511
+ // already read as "not ahead" before this change — harmless when nothing
512
+ // can kill the process out from under it. WITH a deadline this spawn CAN
513
+ // now die from the same budget the first spawn already spent, and a
514
+ // silent "not ahead" would make the caller's own notification vanish
515
+ // (spec §5's own regression). The first spawn already fails closed this
516
+ // way (`porcelain.status !== 0` above); this brings the second spawn to
517
+ // the same contract, but only where a deadline made it possible to break.
518
+ if (deadline && (aheadRes.error || aheadRes.status !== 0)) {
519
+ return {
520
+ clean: false,
521
+ uncommitted: true,
522
+ ahead: false,
523
+ reason: `git check failed in ${dir}`,
524
+ };
525
+ }
433
526
  const ahead = /\[ahead \d+\]/.test(aheadRes.stdout || '');
434
527
  const reasons = [];
435
528
  if (uncommitted) reasons.push(`uncommitted changes in ${dir}`);
@@ -473,18 +566,33 @@ export function hypoIsClean(dir = HYPO_DIR) {
473
566
  * failure (the caller already has its own git-status result via
474
567
  * hypoIsClean and treats that failure as an unconditional blocker; an
475
568
  * empty return here just means "cannot attribute", not "clean").
569
+ *
570
+ * `opts.deadline` (session-close-scope-boundary spec §5): the SAME shared
571
+ * deadline object passed to hypoIsClean, so the two functions' spawns split
572
+ * one budget instead of each getting their own (which would let the pair
573
+ * together run twice as long as intended). Omitted, both spawns run exactly
574
+ * as before — precompactGateStatus's own call (`:3874`) does not pass one.
476
575
  */
477
- function gitDirtyFiles(dir) {
478
- const prefixRes = spawnSync('git', ['-C', dir, 'rev-parse', '--show-prefix'], {
479
- encoding: 'utf-8',
480
- });
481
- if (prefixRes.status !== 0) return []; // can't resolve the repo → cannot attribute
576
+ export function gitDirtyFiles(dir = HYPO_DIR, opts = {}) {
577
+ const { deadline } = opts;
578
+ const t1 = remainingSpawnTimeoutMs(deadline);
579
+ if (t1 === 0) return []; // budget exhausted before the first spawn → cannot attribute
580
+ const prefixRes = spawnSync(
581
+ 'git',
582
+ ['-C', dir, 'rev-parse', '--show-prefix'],
583
+ t1 === undefined ? { encoding: 'utf-8' } : { encoding: 'utf-8', timeout: t1 },
584
+ );
585
+ if (prefixRes.error || prefixRes.status !== 0) return []; // can't resolve the repo → cannot attribute
482
586
  const prefix = (prefixRes.stdout || '').trim();
483
587
 
484
- const porcelain = spawnSync('git', ['-C', dir, 'status', '--porcelain', '-uall', '-z'], {
485
- encoding: 'utf-8',
486
- });
487
- if (porcelain.status !== 0) return [];
588
+ const t2 = remainingSpawnTimeoutMs(deadline);
589
+ if (t2 === 0) return [];
590
+ const porcelain = spawnSync(
591
+ 'git',
592
+ ['-C', dir, 'status', '--porcelain', '-uall', '-z'],
593
+ t2 === undefined ? { encoding: 'utf-8' } : { encoding: 'utf-8', timeout: t2 },
594
+ );
595
+ if (porcelain.error || porcelain.status !== 0) return [];
488
596
  const out = [];
489
597
  const records = (porcelain.stdout || '').split('\0');
490
598
  const toDirRelative = (f) => {
@@ -511,6 +619,74 @@ function gitDirtyFiles(dir) {
511
619
  return out;
512
620
  }
513
621
 
622
+ // session-close-scope-boundary spec §2b/§5: the ONE path-prefix rule that
623
+ // decides a dirty file structurally belongs to a DIFFERENT eligible project
624
+ // than the one this call is scoped to. Extracted from precompactGateStatus's
625
+ // former inline closure so hypo-compact-guard.mjs (spec §5) can call the
626
+ // exact same predicate instead of duplicating it — a closure over the gate's
627
+ // local variables cannot be called, or asserted against, from anywhere else.
628
+ //
629
+ // Lexical, on the RAW git-porcelain path `f`, BEFORE posixPath()'s
630
+ // unconditional `\` -> `/` conversion: a file whose actual NAME contains a
631
+ // literal backslash (`projects\other\x.md`, one path segment, no real
632
+ // subdirectory) must not be reinterpreted as living under `projects/other/`
633
+ // just because posixPath() would rewrite it that way — testing the raw
634
+ // string means it never matches the regex below and falls through to
635
+ // "not foreign", the fail-closed default. `transcriptTouched` IS keyed by
636
+ // posix paths (it comes from extractTouchedWikiFiles), so that one check
637
+ // still normalizes `f` before the lookup.
638
+ //
639
+ // @param {string} f - a raw dirty path from gitDirtyFiles/git porcelain
640
+ // @param {{eligibleSlugs: Set<string>|null, effectiveOverride: string|null,
641
+ // transcriptTouched?: Set<string>}} ctx
642
+ // @returns {boolean}
643
+ export function isForeignProjectFile(
644
+ f,
645
+ { eligibleSlugs, effectiveOverride, transcriptTouched = new Set() },
646
+ ) {
647
+ // Transcript evidence outranks the path-prefix heuristic below: it PROVES
648
+ // this session edited f, whatever its path prefix says. The heuristic only
649
+ // exists to cover files an untrusted (or absent) transcript could not
650
+ // vouch for either way.
651
+ if (transcriptTouched.has(posixPath(f))) return false;
652
+ if (!eligibleSlugs) return false;
653
+ const m = /^projects\/([^/]+)\/.+$/.exec(f);
654
+ if (!m) return false;
655
+ const slug = m[1];
656
+ return slug !== effectiveOverride && eligibleSlugs.has(slug);
657
+ }
658
+
659
+ // session-close-scope-boundary spec §5: hypo-compact-guard.mjs only needs a
660
+ // yes/no on "may I drop my git notice line", never precompactGateStatus's
661
+ // full per-file partition, and the answer must never escape as a throw — this
662
+ // hook's outermost catch turns ANY exception into a fully suppressed
663
+ // {suppressOutput:true}, so a failure inside collectProjectWorkingDirs (a
664
+ // readdirSync it does not itself catch) must come back as data instead.
665
+ //
666
+ // Returns one of two sentinels, never throws:
667
+ // 'foreign-only' — dirty is non-empty, at least one eligible project is
668
+ // known, and EVERY dirty file is provably someone
669
+ // else's under isForeignProjectFile.
670
+ // 'unattributable' — anything short of that full proof (no dirty files, no
671
+ // eligible project known, project enumeration failed,
672
+ // or a mix of foreign and non-foreign files). The
673
+ // caller must treat this exactly like today's unscoped
674
+ // git blocker/notice — this is the fail-closed default.
675
+ export function classifyForeignOnlyDirty(hypoDir, dirty, { effectiveOverride = null } = {}) {
676
+ if (!dirty || dirty.length === 0) return 'unattributable'; // vacuous every() must not pass
677
+ let eligibleSlugs;
678
+ try {
679
+ eligibleSlugs = new Set(collectProjectWorkingDirs(hypoDir).map((p) => p.slug));
680
+ } catch {
681
+ return 'unattributable';
682
+ }
683
+ if (eligibleSlugs.size === 0) return 'unattributable';
684
+ const allForeign = dirty.every((f) =>
685
+ isForeignProjectFile(f, { eligibleSlugs, effectiveOverride }),
686
+ );
687
+ return allForeign ? 'foreign-only' : 'unattributable';
688
+ }
689
+
514
690
  export function hotMdIsClean(dir = HYPO_DIR) {
515
691
  const hotPath = dir === HYPO_DIR ? HOT_PATH : join(dir, 'hot.md');
516
692
  if (!existsSync(hotPath)) return { clean: true };
@@ -1592,8 +1768,11 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
1592
1768
  // invariant that once justified keeping the writer paths fully unscoped
1593
1769
  // ("marker == compact-ready", codex design review) is superseded by that
1594
1770
  // partition, not restored by it — a marker's own `verified_scope`, attesting
1595
- // exactly what it checked, is still missing (session-close-scope-boundary
1596
- // spec §3, next wave).
1771
+ // exactly what this function's caller evaluated (never `markerProjects`'
1772
+ // evidence-based attribution), now ships (session-close-scope-boundary
1773
+ // spec §3). The writer is `scripts/lib/crystallize-close-apply.mjs`
1774
+ // (`runMarkSessionClosed` and `runMarkerPhase`); the reader is
1775
+ // `markerCoversArtifact` in `scripts/doctor.mjs`.
1597
1776
  if (opts.projectOverride) {
1598
1777
  const s = sessionCloseFileStatus(hypoDir, { projectOverride: opts.projectOverride });
1599
1778
  return {
@@ -1634,6 +1813,13 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
1634
1813
  hasTodayCloseActivity(hypoDir, p, dates),
1635
1814
  );
1636
1815
 
1816
+ // This branch drops `mustEvaluate` on the floor and reports the recency
1817
+ // project instead, so `projects` here can name a DIFFERENT project than the
1818
+ // caller asked for. No marker is ever written from it, though: `close.fallback`
1819
+ // is an unconditional gate blocker (see the fail-closed guards in the partition
1820
+ // below) and both marker writers only stamp on a green gate. So a marker whose
1821
+ // `verified_scope` names the recency project instead of the requested one is
1822
+ // unreachable, not merely fail-safe — do not write code defending that state.
1637
1823
  if (activeCandidates.length === 0) {
1638
1824
  const legacy = sessionCloseFileStatus(hypoDir);
1639
1825
  return {
@@ -3166,6 +3352,33 @@ export function sessionClosedMarkerPath(hypoDir, sessionId) {
3166
3352
  return join(hypoDir, '.cache', `session-closed-${sanitizeSessionId(sessionId)}.marker`);
3167
3353
  }
3168
3354
 
3355
+ // verified_scope (session-close-scope-boundary spec §3) records the scope the
3356
+ // gate ABOVE this write actually verified, not merely the attribution the
3357
+ // marker's `projects` field carries — those can diverge whenever a gate run
3358
+ // widens attribution beyond what it narrowed the check to. The caller
3359
+ // decides `kind`: this function only refuses to persist a shape it cannot
3360
+ // stand behind. Exported so scripts/doctor.mjs's reader runs the SAME
3361
+ // collapse instead of a hand-rolled copy — an unrecognized shape, or a
3362
+ // 'project'/'global' scope with an empty `projects` (a narrower-than-nothing
3363
+ // claim), reads back as "field absent", never as a false claim.
3364
+ export function normalizeVerifiedScope(verifiedScope) {
3365
+ if (!verifiedScope || typeof verifiedScope !== 'object') return null;
3366
+ if (verifiedScope.kind === 'log-only') return { kind: 'log-only' };
3367
+ if (verifiedScope.kind === 'project' || verifiedScope.kind === 'global') {
3368
+ const projects = Array.isArray(verifiedScope.projects)
3369
+ ? [...new Set(verifiedScope.projects.filter((p) => typeof p === 'string' && p))]
3370
+ : [];
3371
+ // An empty projects list under 'project'/'global' asserts a verified
3372
+ // boundary with nothing in it — that is not a narrower claim, it is a
3373
+ // malformed one. Drop it rather than persist a scope nothing can satisfy
3374
+ // by design (verified_scope must only ever tighten, never silently gate
3375
+ // out everything).
3376
+ if (projects.length === 0) return null;
3377
+ return { kind: verifiedScope.kind, projects };
3378
+ }
3379
+ return null;
3380
+ }
3381
+
3169
3382
  /**
3170
3383
  * Persist a per-session close proof. Caller MUST verify
3171
3384
  * `sessionCloseFileStatus(hypoDir).ok` before invoking — this helper does NOT
@@ -3175,7 +3388,7 @@ export function sessionClosedMarkerPath(hypoDir, sessionId) {
3175
3388
  *
3176
3389
  * @param {string} hypoDir
3177
3390
  * @param {string} sessionId
3178
- * @param {{project?: string, scope?: string, transcript_path?: string}} info
3391
+ * @param {{project?: string, scope?: string, transcript_path?: string, verifiedScope?: {kind: 'log-only'|'project'|'global', projects?: string[]}}} info
3179
3392
  */
3180
3393
  export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
3181
3394
  if (!sessionId) return;
@@ -3199,6 +3412,7 @@ export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
3199
3412
  : info.project
3200
3413
  ? [info.project]
3201
3414
  : [];
3415
+ const verifiedScope = normalizeVerifiedScope(info.verifiedScope);
3202
3416
  const payload = {
3203
3417
  session_id: sessionId,
3204
3418
  project: info.project || projects[0] || null,
@@ -3207,6 +3421,11 @@ export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
3207
3421
  transcript_path: info.transcript_path || null,
3208
3422
  closed_at: new Date().toISOString(),
3209
3423
  verification: scope === 'log-only' ? 'log-only-close:ok' : 'session-close-file-status:ok',
3424
+ // Omitted entirely (not even `null`) when the caller passes nothing or
3425
+ // an unrecognized shape, so a marker written by a caller that hasn't
3426
+ // adopted this field reads back byte-identical to before it existed —
3427
+ // doctor's reader treats "field absent" as "no additional scope check".
3428
+ ...(verifiedScope ? { verified_scope: verifiedScope } : {}),
3210
3429
  };
3211
3430
  writeFileSync(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
3212
3431
  } catch (err) {
@@ -3711,8 +3930,12 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
3711
3930
  * below can tell their own incomplete close from someone else's debt. The
3712
3931
  * invariant that once justified keeping those paths fully unscoped ("marker ==
3713
3932
  * compact-ready", codex design review) is replaced by that partition, not
3714
- * restored by it — a marker's own `verified_scope`, attesting exactly what it
3715
- * checked, is still missing (session-close-scope-boundary spec §3, next wave).
3933
+ * restored by it — a marker's own `verified_scope`, attesting exactly what
3934
+ * this status's caller evaluated (never the marker's `projects`, which is
3935
+ * evidence-based attribution), now ships (session-close-scope-boundary
3936
+ * spec §3). The writer is `scripts/lib/crystallize-close-apply.mjs`
3937
+ * (`runMarkSessionClosed` and `runMarkerPhase`); the reader is
3938
+ * `markerCoversArtifact` in `scripts/doctor.mjs`.
3716
3939
  * Either key ALSO feeds the git-dirty partition (spec §2b): with an untrusted
3717
3940
  * transcript (missing/corrupt), a dirty file structurally under a DIFFERENT
3718
3941
  * eligible project's own directory is demoted to a notice, because the path
@@ -3848,33 +4071,19 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3848
4071
  // exception, the PreCompact hook's outermost catch would turn that
3849
4072
  // into a silent, fully-suppressed gate result, the opposite of "fail
3850
4073
  // closed". Treat any failure as "no eligible projects known", which
3851
- // makes isForeign() below always false and every dirty file falls
3852
- // through to the unconditional blocker.
4074
+ // makes isForeignProjectFile() below always false and every dirty file
4075
+ // falls through to the unconditional blocker.
3853
4076
  let eligibleSlugs = null;
3854
4077
  try {
3855
4078
  eligibleSlugs = new Set(collectProjectWorkingDirs(hypoDir).map((p) => p.slug));
3856
4079
  } catch {
3857
4080
  eligibleSlugs = null;
3858
4081
  }
3859
- // Lexical, on the RAW git-porcelain path, BEFORE posixPath()'s
3860
- // unconditional `\` -> `/` conversion. A file whose actual NAME
3861
- // contains a literal backslash (`projects\other\x.md`, one path
3862
- // segment, no real subdirectory) must not be reinterpreted as living
3863
- // under `projects/other/` just because posixPath() would rewrite it
3864
- // that way; testing the raw string here means it never matches this
3865
- // regex and falls through to fail-closed instead.
3866
- const isForeign = (f) => {
3867
- // The transcript already PROVES this session edited f, whatever its
3868
- // path prefix says: transcript evidence outranks the path-prefix
3869
- // heuristic below, which only exists to cover files the (untrusted)
3870
- // transcript could not vouch for either way.
3871
- if (transcriptTouched.has(posixPath(f))) return false;
3872
- if (!eligibleSlugs) return false;
3873
- const m = /^projects\/([^/]+)\/.+$/.exec(f);
3874
- if (!m) return false;
3875
- const slug = m[1];
3876
- return slug !== effectiveOverride && eligibleSlugs.has(slug);
3877
- };
4082
+ // isForeignProjectFile is the extracted, exported predicate (spec §5):
4083
+ // hypo-compact-guard.mjs calls the exact same function on its own git
4084
+ // axis, instead of a second, silently-diverging copy of this rule.
4085
+ const isForeign = (f) =>
4086
+ isForeignProjectFile(f, { eligibleSlugs, effectiveOverride, transcriptTouched });
3878
4087
  const foreign = dirty.filter(isForeign);
3879
4088
  const rest = dirty.filter((f) => !isForeign(f));
3880
4089
  if (rest.length > 0) {
@@ -4809,6 +5018,48 @@ export function resolveTranscriptBySessionId(
4809
5018
  // dangerous replay/injection paths carry system|sdk|isMeta|isSidechain and are
4810
5019
  // excluded here anyway.
4811
5020
  const COMMAND_INVOCATION_TAG = /<command-(?:name|message|args)>/;
5021
+
5022
+ // Text out of a content-block array, or null when the array carries a command
5023
+ // invocation (which is a host artifact, not something the user typed as prose).
5024
+ // Extracted so the queued_command attachment branch in walkCloseGate can reuse
5025
+ // the exact same rule: a queued prompt arrives as this array shape whenever the
5026
+ // user pasted an image alongside their words (measured: 8 such deliveries, all
5027
+ // origin.kind "human", host versions 2.1.226 through 2.1.263). Reading only
5028
+ // `typeof prompt === 'string'` there dropped those to '' and read a real change
5029
+ // of mind as nothing at all.
5030
+ function contentBlocksText(content) {
5031
+ const texts = content
5032
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
5033
+ .map((b) => b.text);
5034
+ const text = texts.length ? texts.join('\n') : null;
5035
+ // A command-invocation tag split across adjacent text blocks (e.g.
5036
+ // '<command-na' + 'me>/hypo:crystallize</command-name>') would survive the
5037
+ // '\n'-joined `text` above with a newline spliced into the middle of the
5038
+ // tag name, so the plain check below can miss it entirely. Today's actual
5039
+ // host format sends the whole invocation as ONE string, so this exact
5040
+ // split is not reproducible against a live session yet — but a check for
5041
+ // "did the host format ever put the tag exactly on a block boundary"
5042
+ // should not depend on where a future host happens to cut the blocks. So
5043
+ // also test each run of CONSECUTIVE text blocks joined with no separator.
5044
+ // This must stay scoped to consecutive text blocks only, never the whole
5045
+ // array: joining across a non-text block in between (an image, say) would
5046
+ // synthesize a tag that never existed in the real content, and that is a
5047
+ // different bug, not a fix — it would throw away a genuine close spoken
5048
+ // next to an unrelated attachment. So a non-text block ends the current
5049
+ // run and starts a new one; it never bridges two runs into one string.
5050
+ let tightRun = '';
5051
+ for (const b of content) {
5052
+ if (b && b.type === 'text' && typeof b.text === 'string') {
5053
+ tightRun += b.text;
5054
+ continue;
5055
+ }
5056
+ if (COMMAND_INVOCATION_TAG.test(tightRun)) return null;
5057
+ tightRun = '';
5058
+ }
5059
+ if (COMMAND_INVOCATION_TAG.test(tightRun)) return null;
5060
+ return text;
5061
+ }
5062
+
4812
5063
  function eventUserText(obj) {
4813
5064
  if (obj.isMeta === true) return null;
4814
5065
  if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return null;
@@ -4822,35 +5073,9 @@ function eventUserText(obj) {
4822
5073
  if (typeof content === 'string') {
4823
5074
  text = content.startsWith('Stop hook feedback') ? null : content;
4824
5075
  } else if (Array.isArray(content)) {
4825
- const texts = content
4826
- .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
4827
- .map((b) => b.text);
4828
- text = texts.length ? texts.join('\n') : null;
4829
- // A command-invocation tag split across adjacent text blocks (e.g.
4830
- // '<command-na' + 'me>/hypo:crystallize</command-name>') would survive the
4831
- // '\n'-joined `text` above with a newline spliced into the middle of the
4832
- // tag name, so the plain check below can miss it entirely. Today's actual
4833
- // host format sends the whole invocation as ONE string, so this exact
4834
- // split is not reproducible against a live session yet — but a check for
4835
- // "did the host format ever put the tag exactly on a block boundary"
4836
- // should not depend on where a future host happens to cut the blocks. So
4837
- // also test each run of CONSECUTIVE text blocks joined with no separator.
4838
- // This must stay scoped to consecutive text blocks only, never the whole
4839
- // array: joining across a non-text block in between (an image, say) would
4840
- // synthesize a tag that never existed in the real content, and that is a
4841
- // different bug, not a fix — it would throw away a genuine close spoken
4842
- // next to an unrelated attachment. So a non-text block ends the current
4843
- // run and starts a new one; it never bridges two runs into one string.
4844
- let tightRun = '';
4845
- for (const b of content) {
4846
- if (b && b.type === 'text' && typeof b.text === 'string') {
4847
- tightRun += b.text;
4848
- continue;
4849
- }
4850
- if (COMMAND_INVOCATION_TAG.test(tightRun)) return null;
4851
- tightRun = '';
4852
- }
4853
- if (COMMAND_INVOCATION_TAG.test(tightRun)) return null;
5076
+ // null from either cause (no text blocks, or a command invocation) means
5077
+ // "no user prose here", which is exactly what this function returns anyway.
5078
+ text = contentBlocksText(content);
4854
5079
  }
4855
5080
  if (text != null && COMMAND_INVOCATION_TAG.test(text)) return null;
4856
5081
  return text;
@@ -4868,6 +5093,20 @@ function isModelReachableRecord(obj) {
4868
5093
  );
4869
5094
  }
4870
5095
 
5096
+ // Queue content that carries no fresh USER decision: empty, or a background-task
5097
+ // notification the host injects on the model's behalf. Shared by the enqueue
5098
+ // branch and the remove-path (queued_command attachment) branch below so a
5099
+ // task notification reads as neutral on BOTH delivery shapes of the same host
5100
+ // event. Before this was extracted, only the enqueue branch filtered it — the
5101
+ // attachment branch treated any non-close prompt, including a task
5102
+ // notification, as a change-of-mind close: a close typed by the user opened
5103
+ // the gate, then the notification for an unrelated background task landed
5104
+ // via this path and flipped it shut again.
5105
+ function isModelCausedQueueContent(text) {
5106
+ const c = typeof text === 'string' ? text.trim() : '';
5107
+ return !c || c.startsWith('<task-notification>');
5108
+ }
5109
+
4871
5110
  export function walkCloseGate(transcriptPath) {
4872
5111
  if (!transcriptPath) return { open: false, openedAtIndex: -1 };
4873
5112
  let raw;
@@ -4948,7 +5187,7 @@ export function walkCloseGate(transcriptPath) {
4948
5187
  openedAtIndex = i;
4949
5188
  } else if (/^\/clear(?:\s|$)/.test(c)) {
4950
5189
  open = false; // abandons context → close
4951
- } else if (!c || c.startsWith('<task-notification>')) {
5190
+ } else if (isModelCausedQueueContent(c)) {
4952
5191
  /* model-caused / empty — neutral */
4953
5192
  } else if (isClosePattern(c)) {
4954
5193
  /* NL close via the queue — the open dequeue gap: the producer cannot be
@@ -4965,16 +5204,80 @@ export function walkCloseGate(transcriptPath) {
4965
5204
  // cannot attest a producer, so it does not open (fail-closed). A NON-close
4966
5205
  // queued command (e.g. "keep working") is a fresh user intent and CLOSES
4967
5206
  // a prior open regardless of origin — that is what closes the re-close hole
4968
- // where a queued "continue" after a close leaves the stale open live.
5207
+ // where a queued "continue" after a close leaves the stale open live. A
5208
+ // task notification is not that: `modelCaused` below filters it out before
5209
+ // this reaches the change-of-mind close, because the model, not the user,
5210
+ // produced it. This delivery path used to skip that filter and let an
5211
+ // unrelated background-task notification flip a just-opened gate shut.
5212
+ //
5213
+ // Scope of "both shapes classify alike": it holds for task notifications and
5214
+ // for empty content, which is what this fix is about. It does not hold for
5215
+ // slash commands. The enqueue branch reads a queued `/compact` as an opener
5216
+ // and `/clear` as a close, and nothing here mirrors that, so the same
5217
+ // command would close the gate if it ever arrived on this path. Measured
5218
+ // 0 of 1321 attachment deliveries carry a slash prompt, so the divergence
5219
+ // is unreachable today rather than fixed; mirroring it would mean copying
5220
+ // an opener that tests already mark a fail-open defect.
4969
5221
  if (o.type === 'attachment' && o.attachment && o.attachment.type === 'queued_command') {
4970
- const prompt = typeof o.attachment.prompt === 'string' ? o.attachment.prompt : '';
5222
+ // Same channel guard the typed path applies, for the same reason. This
5223
+ // branch can OPEN the gate, and `origin.kind` alone does not say which
5224
+ // channel the record came in on: a sidechain, injected, sdk or meta
5225
+ // record could carry a human origin and close text and mint an approval
5226
+ // the user never gave in this conversation. eventUserText has refused
5227
+ // those channels all along; the opener next to it did not, so one
5228
+ // classifier trusted a record the other threw away. Measured 0 of 1340
5229
+ // queued_command attachments carry any of these flags, so this closes a
5230
+ // shape the corpus has not produced rather than an observed failure.
5231
+ // `interruptedMessageId` rides along because eventUserText refuses it too.
5232
+ if (isModelReachableRecord(o) || o.interruptedMessageId) continue;
5233
+ // A queued prompt arrives as a content-block array whenever the user
5234
+ // pasted an image alongside their words. Reading only the string shape
5235
+ // dropped those to '' and threw away a real change of mind: the gate
5236
+ // stayed open and the close went through anyway. Measured 8 such
5237
+ // deliveries, every one origin.kind "human", host 2.1.226 to 2.1.263.
5238
+ const rawPrompt = o.attachment.prompt;
5239
+ const prompt =
5240
+ typeof rawPrompt === 'string'
5241
+ ? rawPrompt
5242
+ : Array.isArray(rawPrompt)
5243
+ ? (contentBlocksText(rawPrompt) ?? '')
5244
+ : '';
4971
5245
  const humanOrigin = !!(o.attachment.origin && o.attachment.origin.kind === 'human');
4972
- if (isClosePattern(prompt)) {
5246
+ // The host labels these deliveries on the record itself, and that label is
5247
+ // the authoritative one: every measured task-notification attachment
5248
+ // (1040 of 1040) carries commandMode 'task-notification'. Keying only on
5249
+ // the '<task-notification>' body prefix would leave the whole filter
5250
+ // resting on a string the host owns and can restyle, and a body that
5251
+ // merely gains a line before the tag would slip past it. The queue-op
5252
+ // branch above has no such field to read, so it keeps the body check
5253
+ // alone; here both are available and both are used.
5254
+ const modelCaused =
5255
+ o.attachment.commandMode === 'task-notification' || isModelCausedQueueContent(prompt);
5256
+ // The filter runs FIRST here, exactly as it does in the enqueue branch
5257
+ // above. Order is load-bearing, not cosmetic: a notification body carries
5258
+ // whatever text the finished task was named after, and isClosePattern
5259
+ // matches on a substring ("wrap up", "오늘 여기까지" inside a <summary>
5260
+ // both measure true). Testing isClosePattern first therefore lets a
5261
+ // model-produced event reach the opener, where a human-origin delivery
5262
+ // would set `open` and push `openedAtIndex` forward. That manufactures a
5263
+ // close signal the user never gave, and moves the index the resolution
5264
+ // comparison reads.
5265
+ //
5266
+ // Reachability, so the next reader does not have to re-measure it: no
5267
+ // recorded delivery hits that path. All 1040 task-notification
5268
+ // attachments in the corpus arrive with no `origin`, so `humanOrigin` is
5269
+ // false and the opener is skipped whichever order the two checks run in.
5270
+ // This is a defensive pin against a host that starts stamping origin on
5271
+ // them, not a repair of an observed failure.
5272
+ if (modelCaused) {
5273
+ /* model-caused / empty: neutral, the same filter the enqueue branch
5274
+ above applies to the same host event on its other delivery shape */
5275
+ } else if (isClosePattern(prompt)) {
4973
5276
  if (humanOrigin) {
4974
5277
  open = true;
4975
5278
  openedAtIndex = i;
4976
5279
  }
4977
- } else if (prompt) {
5280
+ } else {
4978
5281
  open = false;
4979
5282
  }
4980
5283
  continue;
@@ -5306,12 +5609,36 @@ export function isCloseReconfirmDeclined(transcriptPath) {
5306
5609
  return declined;
5307
5610
  }
5308
5611
 
5612
+ // Events Claude Code actually reads additionalContext from, per the "Add
5613
+ // context for Claude" docs. A hookEventName outside this set has no
5614
+ // documented injection path, so buildOutput still emits the nested shape
5615
+ // (the caller may be adding a NEW event later) but warns to stderr.
5616
+ const INJECTABLE_EVENTS = new Set(['UserPromptSubmit', 'SessionStart', 'PostToolUse', 'Stop']);
5617
+
5309
5618
  /**
5310
- * Build hook output for Claude Code (additionalContext channel).
5311
- * Codex hooks write systemMessage directly in their own files.
5619
+ * Build hook output for Claude Code (nested hookSpecificOutput.additionalContext
5620
+ * channel). Codex hooks write systemMessage directly in their own files.
5621
+ *
5622
+ * @param {string} hookEventName - the event this hook fires on, e.g. 'UserPromptSubmit'.
5623
+ * @param {string} context - the text to inject.
5624
+ * @param {object} [extra] - control fields (continue, suppressOutput, ...) that
5625
+ * stay top-level siblings of hookSpecificOutput.
5312
5626
  */
5313
- export function buildOutput(context, extra = {}) {
5314
- return { ...extra, additionalContext: context };
5627
+ export function buildOutput(hookEventName, context, extra = {}) {
5628
+ if (!INJECTABLE_EVENTS.has(hookEventName)) {
5629
+ process.stderr.write(
5630
+ `[hypo] buildOutput: ${hookEventName} has no documented context-injection path\n`,
5631
+ );
5632
+ }
5633
+ // `extra` carries control fields, never context. Dropping the key here is
5634
+ // what makes the nested shape the only channel: without it a caller could
5635
+ // pass `{ additionalContext }` and the spread would put a top-level copy
5636
+ // right back, which is the exact bug this function exists to prevent.
5637
+ const { additionalContext: _shadowed, ...control } = extra;
5638
+ return {
5639
+ ...control,
5640
+ hookSpecificOutput: { hookEventName, additionalContext: context },
5641
+ };
5315
5642
  }
5316
5643
 
5317
5644
  // ── growth metrics (F2 + E4) ───────────────────────────────────────────────