hypomnema 1.7.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +1202 -0
  4. package/README.ko.md +7 -7
  5. package/README.md +7 -7
  6. package/commands/audit.md +1 -1
  7. package/commands/capture.md +2 -2
  8. package/commands/crystallize.md +4 -4
  9. package/commands/doctor.md +15 -1
  10. package/commands/feedback.md +1 -1
  11. package/commands/graph.md +1 -1
  12. package/commands/ingest.md +1 -1
  13. package/commands/init.md +2 -2
  14. package/commands/lint.md +1 -1
  15. package/commands/query.md +1 -1
  16. package/commands/rename.md +1 -1
  17. package/commands/resume.md +1 -1
  18. package/commands/stats.md +1 -1
  19. package/commands/uninstall.md +17 -5
  20. package/commands/upgrade.md +2 -2
  21. package/commands/verify.md +1 -1
  22. package/docs/ARCHITECTURE.md +9 -4
  23. package/docs/CONTRIBUTING.md +15 -5
  24. package/hooks/base-store.mjs +198 -0
  25. package/hooks/close-gate-store.mjs +436 -0
  26. package/hooks/hooks.json +2 -1
  27. package/hooks/hypo-auto-minimal-crystallize.mjs +10 -0
  28. package/hooks/hypo-close-guard.mjs +24 -4
  29. package/hooks/hypo-compact-guard.mjs +5 -3
  30. package/hooks/hypo-cwd-change.mjs +8 -0
  31. package/hooks/hypo-personal-check.mjs +128 -68
  32. package/hooks/hypo-session-end.mjs +21 -2
  33. package/hooks/hypo-session-start.mjs +91 -10
  34. package/hooks/hypo-shared.mjs +498 -180
  35. package/hooks/version-check.mjs +44 -6
  36. package/package.json +10 -3
  37. package/scripts/capture.mjs +64 -21
  38. package/scripts/crystallize.mjs +20 -2044
  39. package/scripts/doctor.mjs +334 -14
  40. package/scripts/init.mjs +132 -92
  41. package/scripts/lib/crystallize-args.mjs +50 -0
  42. package/scripts/lib/crystallize-close-apply.mjs +1830 -0
  43. package/scripts/lib/crystallize-close-check.mjs +238 -0
  44. package/scripts/lib/crystallize-close-gate.mjs +58 -0
  45. package/scripts/lib/crystallize-helpers.mjs +55 -0
  46. package/scripts/lib/design-history-stale.mjs +26 -7
  47. package/scripts/lib/extensions.mjs +235 -65
  48. package/scripts/lib/git-hooks-dir.mjs +214 -2
  49. package/scripts/lib/plugin-detect.mjs +176 -21
  50. package/scripts/lib/slug-resolver.mjs +181 -0
  51. package/scripts/lint.mjs +33 -23
  52. package/scripts/proposal.mjs +3 -3
  53. package/scripts/rename.mjs +38 -141
  54. package/scripts/uninstall.mjs +155 -58
  55. package/scripts/upgrade.mjs +243 -20
  56. package/skills/crystallize/SKILL.md +16 -14
  57. package/skills/graph/SKILL.md +3 -3
  58. package/skills/ingest/SKILL.md +2 -2
  59. package/skills/lint/SKILL.md +3 -3
  60. package/skills/query/SKILL.md +3 -3
  61. package/skills/verify/SKILL.md +3 -3
  62. package/templates/hypo-automation.md +59 -17
  63. package/templates/hypo-config.md +1 -1
  64. package/templates/hypo-guide.md +12 -9
  65. package/templates/hypo-help.md +1 -1
@@ -1063,6 +1063,63 @@ export function collectProjectWorkingDirs(hypoDir) {
1063
1063
  return out;
1064
1064
  }
1065
1065
 
1066
+ // One place that answers "is this string a slug naming a real project directory?".
1067
+ // Both resolveGateProjectOverride (a caller-supplied --project) and the gate's
1068
+ // mustEvaluate filter (slugs arriving from a transcript or a marker) need it, and
1069
+ // having the grammar in two inline copies is the duplication this release spent a
1070
+ // codex round removing elsewhere. Not exported: scripts/ has its own validator,
1071
+ // and importing that here would invert the one-way hooks/ <- scripts/ dependency.
1072
+ function isProjectSlugDirectory(hypoDir, slug) {
1073
+ if (typeof slug !== 'string' || !/^[A-Za-z0-9._-]+$/.test(slug)) return false;
1074
+ if (/^\.+$/.test(slug) || !/[A-Za-z0-9]/.test(slug)) return false;
1075
+ try {
1076
+ return statSync(join(hypoDir, 'projects', slug)).isDirectory();
1077
+ } catch {
1078
+ return false;
1079
+ }
1080
+ }
1081
+
1082
+ // session-close-scope-boundary spec §2: the one place that turns a caller-
1083
+ // supplied project name into precompactGateStatus's opts.projectOverride. Four
1084
+ // call sites share it (--mark-session-closed, apply's auto-marker, PreCompact,
1085
+ // Stop) so a foreign project's dangling close files demote to a notice instead
1086
+ // of blocking a session that never touched them, everywhere the gate runs.
1087
+ //
1088
+ // `project` is trusted nowhere upstream of this function: crystallize.mjs's
1089
+ // own --project only gets slug-syntax + directory-existence checked when the
1090
+ // CLI itself parses it (parseArgs + requireProjectDir); a value that arrives
1091
+ // via a hook payload or apply's JSON body never passes through that. So this
1092
+ // re-validates from scratch — same charset rule as project-create.mjs's
1093
+ // isValidProjectName, duplicated rather than imported because hooks/ cannot
1094
+ // import scripts/ (dependency direction is one-way) — plus a directory
1095
+ // existence check. Either failure returns null, the same as "no project was
1096
+ // given": a garbage slug must never narrow the gate's scope, only a real one
1097
+ // may (spec success criterion: "opts.projectOverride 가 없는 slug 이면 게이트가
1098
+ // 그것을 거절한다").
1099
+ //
1100
+ // With no `project`, the override is inferred from `sessionCwd` via
1101
+ // pickProjectByCwd's rejectAmbiguous mode — the same mode the gate's own
1102
+ // close-cwd check already trusts. Without rejectAmbiguous, two projects
1103
+ // sharing one working_dir (a monorepo with no uniqueness invariant) would
1104
+ // silently resolve to the first slug and could narrow the gate to a project
1105
+ // whose OWN close is still incomplete while the real cwd project goes
1106
+ // unchecked. A decline here (null) is not a failure: precompactGateStatus
1107
+ // falls back to the global, unscoped judgment, which is exactly the STOP-hook
1108
+ // call site's actual point (session-close-scope-boundary spec §2: "해석 실패면
1109
+ // 기존 fail-closed 가 받아 준다는 틀렸다 ... cwd 미해결을 마커 차단으로 만들지 않는다").
1110
+ //
1111
+ // @param {string} hypoDir
1112
+ // @param {{project?: string|null, sessionCwd?: string|null}} [opts]
1113
+ // @returns {string|null}
1114
+ export function resolveGateProjectOverride(hypoDir, { project = null, sessionCwd = null } = {}) {
1115
+ if (project) {
1116
+ return isProjectSlugDirectory(hypoDir, project) ? project : null;
1117
+ }
1118
+ return pickProjectByCwd(collectProjectWorkingDirs(hypoDir), sessionCwd, {
1119
+ rejectAmbiguous: true,
1120
+ });
1121
+ }
1122
+
1066
1123
  // A project without a working_dir anchor (no index.md, or an index.md missing
1067
1124
  // the field) never enters collectProjectWorkingDirs' universe, so cwd-first
1068
1125
  // resume always MISSes for it — the SessionStart/CwdChanged MISS branch would
@@ -1451,7 +1508,7 @@ function closeCandidateSlugs(hypoDir, dates) {
1451
1508
  // slug yields a token that no longer maps to a `projects/<slug>/`
1452
1509
  // directory — gating on disk keeps it out of the dangling-close set.
1453
1510
  // Directory (not bare-exists) mirrors the apply-path project check
1454
- // (crystallize.mjs:193).
1511
+ // (crystallize-args.mjs).
1455
1512
  const dir = join(projectsDir, m[1]);
1456
1513
  if (existsSync(dir) && statSync(dir).isDirectory()) slugs.add(m[1]);
1457
1514
  }
@@ -1526,10 +1583,17 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
1526
1583
  // projectOverride (check-only): the caller (`crystallize --check-session-close
1527
1584
  // --project=<slug>`) wants THIS project's close status, not the recency pick —
1528
1585
  // bypass discovery and report the single project, preserving the global return
1529
- // shape. NEVER threaded from a marker-writing path (--mark / apply auto-marker /
1530
- // PreCompact): those stay global so the marker == compact-ready invariant holds
1531
- // (codex design review). A green status here is a project-scoped
1532
- // diagnostic, not the global compact-readiness verdict.
1586
+ // shape. This is the ONLY caller of projectOverride now: the four marker-writing
1587
+ // paths (--mark / apply auto-marker / PreCompact / Stop) attribute through
1588
+ // `opts.attributionScope` instead (resolveCloseScope, below this function), which
1589
+ // only widens the mine/foreign partition's scope Set and leaves this function
1590
+ // evaluating every project, same as an unscoped call. A green status here is a
1591
+ // project-scoped diagnostic, not the global compact-readiness verdict. The
1592
+ // invariant that once justified keeping the writer paths fully unscoped
1593
+ // ("marker == compact-ready", codex design review) is superseded by that
1594
+ // 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).
1533
1597
  if (opts.projectOverride) {
1534
1598
  const s = sessionCloseFileStatus(hypoDir, { projectOverride: opts.projectOverride });
1535
1599
  return {
@@ -1547,11 +1611,30 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
1547
1611
  }
1548
1612
  const dates = freshDates();
1549
1613
  const recency = resolveActiveProject(hypoDir); // no cwd — close never picks by cwd
1550
- const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
1614
+ // opts.mustEvaluate: slugs this session ATTRIBUTED its close to (explicit
1615
+ // --project, transcript close-file edits, a prior marker). They are evaluated
1616
+ // even when they are not today-active candidates, because the marker writer
1617
+ // stamps the attribution scope: a slug that reaches the marker but never
1618
+ // reached this evaluation would be attested as closed without ever being
1619
+ // checked (codex pre-commit BLOCKER, reproduced with an empty projects/orphan/).
1620
+ // `projects/<slug>/` must exist on disk; a slug naming a directory that is gone
1621
+ // (a stale marker inside its 7-day TTL) is dropped rather than turned into a
1622
+ // permanent blocker for a project nobody can close any more.
1623
+ // existsSync alone would admit a file, or a traversal like '..', from a
1624
+ // malformed marker or transcript slug and turn it into a surprise blocker.
1625
+ // Same slug grammar the CLI and resolveGateProjectOverride validate against.
1626
+ const mustEvaluate = [...(opts.mustEvaluate || [])].filter((p) =>
1627
+ isProjectSlugDirectory(hypoDir, p),
1628
+ );
1629
+ // The fallback decision reads REAL today activity only. Letting mustEvaluate
1630
+ // populate it would mean an attributed slug suppresses the "this session closed
1631
+ // nothing at all" branch, and that branch is a fail-closed guard the partition
1632
+ // below depends on (`!close.fallback`).
1633
+ const activeCandidates = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
1551
1634
  hasTodayCloseActivity(hypoDir, p, dates),
1552
1635
  );
1553
1636
 
1554
- if (todayActive.length === 0) {
1637
+ if (activeCandidates.length === 0) {
1555
1638
  const legacy = sessionCloseFileStatus(hypoDir);
1556
1639
  return {
1557
1640
  ok: legacy.ok,
@@ -1567,6 +1650,8 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
1567
1650
  };
1568
1651
  }
1569
1652
 
1653
+ const todayActive = [...new Set([...activeCandidates, ...mustEvaluate])];
1654
+
1570
1655
  // primary = the recency project when it is itself today-active, else the first
1571
1656
  // today-active slug (stable order from the candidate set). Used only as the
1572
1657
  // single-slug alias for the message header and the flat `close.project` field.
@@ -3504,6 +3589,12 @@ export function partitionLintScope(findings, scope) {
3504
3589
  * every close. Same path policy as the lint-scope matcher above: separators
3505
3590
  * normalized to POSIX, exact prefix at a segment boundary.
3506
3591
  */
3592
+ // This function keeps the posixPath() normalization deliberately, unlike
3593
+ // isForeign (§2b), which reads the raw porcelain path so a literal-backslash
3594
+ // filename cannot be misread as a nested projects/<slug>/ path. Every caller
3595
+ // of isUnderProjectDirs would need to be checked before applying the same
3596
+ // raw-path treatment here, and that audit has not been done, so the existing
3597
+ // behavior stays until it has.
3507
3598
  export function isUnderProjectDirs(file, slugs) {
3508
3599
  const f = posixPath(file);
3509
3600
  return (slugs || []).some((s) => s && (f === `projects/${s}` || f.startsWith(`projects/${s}/`)));
@@ -3550,6 +3641,15 @@ function projectsFromTouchedCloseFiles(transcriptPath, hypoDir) {
3550
3641
  * the `project` that same writer recorded, so the two can never disagree.
3551
3642
  */
3552
3643
  export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
3644
+ // `direct` is the subset attributable to THIS close attempt: explicit
3645
+ // --project/closeScope, this session's transcript close-file edits, and the
3646
+ // resolved attributionScope. A marker's `projects` is PAST evidence of a close
3647
+ // that already happened, so it belongs in the partition scope (it must not be
3648
+ // demoted as foreign debt) but NOT in the mandatory-evaluation set: a 7-day-TTL
3649
+ // marker naming a project that is no longer today-active would otherwise make
3650
+ // that project's stale close block an unrelated close today (codex round 2,
3651
+ // reproduced with a 24h-old marker). The returned Set carries `direct` as a
3652
+ // property so the caller does not re-derive it, and the transcript is parsed once.
3553
3653
  const scope = new Set(opts.closeScope || []);
3554
3654
  for (const p of projectsFromTouchedCloseFiles(opts.transcriptPath, hypoDir)) scope.add(p);
3555
3655
  // Marker attribution (session-close attribution). A v4 marker carries `projects` — the
@@ -3561,6 +3661,20 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
3561
3661
  // above (explicit close scope or transcript close-file edits) already corroborate
3562
3662
  // the SAME slug; otherwise it stays a display hint. This narrow gap self-heals as
3563
3663
  // pre-v4 markers expire (7-day TTL).
3664
+ // opts.attributionScope (session-close-scope-boundary spec §2/§3): a fourth
3665
+ // signal, one slug, from resolveGateProjectOverride. A cwd that unambiguously
3666
+ // owns one project, or an explicit --project the caller already validated, is
3667
+ // evidence this session is closing THAT project — the same standing the three
3668
+ // signals above already have. It is the opt key the four marker-writing paths
3669
+ // (PreCompact, Stop, --mark-session-closed, apply's auto-marker) pass instead
3670
+ // of `opts.projectOverride`: that key stays the check-only diagnostic's (it
3671
+ // also narrows sessionCloseGlobalStatus's own evaluation and turns off the
3672
+ // partition below), while this one only widens the set the partition demotes
3673
+ // everything outside of.
3674
+ if (opts.attributionScope) scope.add(opts.attributionScope);
3675
+ // Snapshot the direct signals BEFORE the marker union below widens the set.
3676
+ scope.direct = new Set(scope);
3677
+
3564
3678
  if (Array.isArray(marker?.projects)) {
3565
3679
  for (const p of marker.projects) if (p) scope.add(p);
3566
3680
  }
@@ -3590,12 +3704,22 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
3590
3704
  * opts.projectOverride (CHECK-ONLY) narrows BOTH the close status and the lint
3591
3705
  * scope to a single project, for `--check-session-close --project=<slug>`. A
3592
3706
  * green result is then a project-scoped diagnostic, NOT the global compact-ready
3593
- * verdict: the caller must surface the scope. It is NEVER passed from
3594
- * a marker-writing path (--mark / apply auto-marker / PreCompact); those stay
3595
- * global so a marker can't attest compact-ready while PreCompact re-checks red
3596
- * (the marker == compact-ready invariant, codex design review). When a
3597
- * log-only marker governs the session, log-only mode wins and projectOverride is
3598
- * ignored.
3707
+ * verdict: the caller must surface the scope. The four marker-writing paths
3708
+ * (--mark / apply auto-marker / PreCompact / Stop) never set this — they pass
3709
+ * `opts.attributionScope` instead, which only feeds resolveCloseScope's set and
3710
+ * leaves this status evaluating every project, so the mine/foreign partition
3711
+ * below can tell their own incomplete close from someone else's debt. The
3712
+ * invariant that once justified keeping those paths fully unscoped ("marker ==
3713
+ * 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).
3716
+ * Either key ALSO feeds the git-dirty partition (spec §2b): with an untrusted
3717
+ * transcript (missing/corrupt), a dirty file structurally under a DIFFERENT
3718
+ * eligible project's own directory is demoted to a notice, because the path
3719
+ * alone proves it is not this session's; everything else keeps blocking.
3720
+ * When a log-only marker governs the session, log-only mode wins for the
3721
+ * close-status and lint scope (scope selection above); the git-dirty
3722
+ * partition in spec §2b still applies regardless of projectOverride.
3599
3723
  *
3600
3724
  * @param {string} hypoDir
3601
3725
  * opts.sessionCwd (session-cwd close check): the authoritative cwd of the session being
@@ -3606,7 +3730,7 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
3606
3730
  * cwd yields a best-effort notice, not a block. apply never passes it (its launch
3607
3731
  * cwd may differ from the authoritative payload.project).
3608
3732
  *
3609
- * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null, sessionCwd?: string|null, sessionId?: string|null, logOnly?: boolean, closeScope?: string[]}} [opts]
3733
+ * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null, attributionScope?: string|null, sessionCwd?: string|null, sessionId?: string|null, logOnly?: boolean, closeScope?: string[]}} [opts]
3610
3734
  * @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string,file?:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
3611
3735
  */
3612
3736
  export function precompactGateStatus(hypoDir, opts = {}) {
@@ -3656,10 +3780,25 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3656
3780
  // would make almost every real transcript untrusted and defeat the scoping
3657
3781
  // this fix exists to add.
3658
3782
  let sessionTouchTrusted = false;
3783
+ // Transcript-derived files ONLY (not closeAccountableScope's mandatory-files
3784
+ // base, which comes from closeFileTargetsGlobal and would fold every close
3785
+ // file of every eligible project into "this session touched it"). §2b's
3786
+ // isForeign below needs exactly this narrower set: a file this session's own
3787
+ // Edit/Write proves it touched, even on a PARTIALLY-parsed transcript (the
3788
+ // trailing line of an append-in-progress JSONL truncates in the ordinary
3789
+ // course of a live session, not as an exceptional failure), must never be
3790
+ // classified foreign by path prefix alone. Widening this to
3791
+ // closeAccountableScope would re-open the exact foreign-close-file leak
3792
+ // (b) fixed above (a genuinely different project's close file would count
3793
+ // as "touched" just because it shares the mandatory-files base).
3794
+ let transcriptTouched = new Set();
3659
3795
  if (opts.transcriptPath) {
3660
3796
  const widened = extractTouchedWikiFilesWithTrust(opts.transcriptPath, hypoDir);
3661
3797
  sessionTouchTrusted = widened.trusted;
3662
- for (const f of widened.files) closeAccountableScope.add(f);
3798
+ for (const f of widened.files) {
3799
+ closeAccountableScope.add(f);
3800
+ transcriptTouched.add(f);
3801
+ }
3663
3802
  }
3664
3803
 
3665
3804
  // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but only
@@ -3686,7 +3825,72 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3686
3825
  const git = hypoIsClean(hypoDir);
3687
3826
  if (git.uncommitted) {
3688
3827
  const dirty = gitDirtyFiles(hypoDir);
3689
- if (dirty.length === 0 || !sessionTouchTrusted) {
3828
+ if (dirty.length === 0) {
3829
+ // Enumeration itself failed (or nothing is actually dirty despite the
3830
+ // porcelain status): "cannot attribute" is not "clean", block exactly
3831
+ // as before, override or not.
3832
+ blockers.push({ type: 'git', reason: git.reason });
3833
+ } else if (!sessionTouchTrusted && (opts.projectOverride || opts.attributionScope)) {
3834
+ // session-close-scope-boundary spec §2b: a broken/missing transcript
3835
+ // normally means "cannot attribute, fail closed" (the branch below).
3836
+ // But when the CALLER told us which project is ours (an explicit
3837
+ // --project, or the cwd-derived attributionScope), a dirty file that
3838
+ // lives structurally under a DIFFERENT eligible project's own directory
3839
+ // needs no attribution inference at all: the path alone proves it is
3840
+ // not this session's file. Everything else (this session's own
3841
+ // project, pages/, an unregistered or _template project dir) keeps the
3842
+ // pre-existing fail-closed behavior; only a provably-foreign path is
3843
+ // demoted.
3844
+ const effectiveOverride = opts.projectOverride || opts.attributionScope;
3845
+ // Computed once per gate call, never per file (collectProjectWorkingDirs
3846
+ // walks the projects/ dir and reads every index.md). A throw here (a
3847
+ // readdirSync failure it does not catch itself) must not escape as an
3848
+ // exception, the PreCompact hook's outermost catch would turn that
3849
+ // into a silent, fully-suppressed gate result, the opposite of "fail
3850
+ // 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.
3853
+ let eligibleSlugs = null;
3854
+ try {
3855
+ eligibleSlugs = new Set(collectProjectWorkingDirs(hypoDir).map((p) => p.slug));
3856
+ } catch {
3857
+ eligibleSlugs = null;
3858
+ }
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
+ };
3878
+ const foreign = dirty.filter(isForeign);
3879
+ const rest = dirty.filter((f) => !isForeign(f));
3880
+ if (rest.length > 0) {
3881
+ blockers.push({
3882
+ type: 'git',
3883
+ reason: `uncommitted changes in ${hypoDir}: ${rest.join(', ')}`,
3884
+ });
3885
+ }
3886
+ for (const f of foreign) {
3887
+ notices.push({
3888
+ type: 'git',
3889
+ file: f,
3890
+ reason: `uncommitted changes outside this session's scope: ${f}`,
3891
+ });
3892
+ }
3893
+ } else if (!sessionTouchTrusted) {
3690
3894
  blockers.push({ type: 'git', reason: git.reason });
3691
3895
  } else {
3692
3896
  const mine = dirty.filter((f) => closeAccountableScope.has(posixPath(f)));
@@ -3739,8 +3943,21 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3739
3943
  }
3740
3944
  } else {
3741
3945
  // projectOverride narrows the close status to one project (check-only); a
3742
- // marker-writing caller never sets it, so the marker path stays global.
3743
- close = sessionCloseGlobalStatus(hypoDir, { projectOverride: opts.projectOverride });
3946
+ // marker-writing caller never sets it. It attributes instead through
3947
+ // opts.attributionScope, consumed a few lines down by resolveCloseScope, so
3948
+ // the status computed here stays global and only the partition below scopes it.
3949
+ // scopeSet is computed BEFORE the status so the attributed slugs become
3950
+ // mandatory evaluation targets (sessionCloseGlobalStatus's mustEvaluate).
3951
+ // Computing it after would let a slug reach the marker that the status never
3952
+ // looked at, which is the false-green this ordering exists to stop.
3953
+ const attributedScope = resolveCloseScope(hypoDir, opts, marker);
3954
+ close = sessionCloseGlobalStatus(hypoDir, {
3955
+ projectOverride: opts.projectOverride,
3956
+ // direct only: a past marker's projects must not become mandatory
3957
+ // evaluation targets (codex round 2). They stay in attributedScope so the
3958
+ // partition still treats them as this session's, not foreign debt.
3959
+ mustEvaluate: attributedScope.direct,
3960
+ });
3744
3961
 
3745
3962
  // Attribute the close debt before blocking on it. An incomplete close belongs
3746
3963
  // to whichever session performed it; charging it to an unrelated session is the
@@ -3771,7 +3988,7 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3771
3988
  // pre-existing tradeoff documented there). And the marker is never written, so the
3772
3989
  // Stop hook still refuses to end the session. Closing this properly needs a
3773
3990
  // durable close-attempt record; it is not worth a new state-file lifecycle here.
3774
- const scopeSet = resolveCloseScope(hypoDir, opts, marker);
3991
+ const scopeSet = attributedScope;
3775
3992
  const failed = (close.projects || []).filter((p) => !p.ok);
3776
3993
  const partition =
3777
3994
  !close.fallback && !opts.projectOverride && scopeSet.size > 0 && failed.length > 0;
@@ -3878,6 +4095,11 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3878
4095
  // monorepo tie pickProjectByCwd declined): we cannot attribute a close
3879
4096
  // responsibility, so we do NOT hard-block (nothing proves there is anything
3880
4097
  // to close). Surface a best-effort notice so the coverage gap is visible.
4098
+ // This reason names --project / --log-only, the crystallize CLI flags:
4099
+ // correct for a --check-session-close caller, but not a lever a
4100
+ // PreCompact hook reader has. The hook renderer (hypo-personal-check.mjs)
4101
+ // builds its own sentence for this notice type instead of echoing this
4102
+ // one verbatim; keep both in sync if the underlying condition changes.
3881
4103
  notices.push({
3882
4104
  type: 'close-cwd-unresolved',
3883
4105
  reason:
@@ -4306,9 +4528,41 @@ export function isClosePattern(text) {
4306
4528
  return [...krPatterns, ...enPatterns].some((re) => re.test(text));
4307
4529
  }
4308
4530
 
4531
+ /**
4532
+ * Retraction TRIPWIRE for the close gate — a mitigation that can only CLOSE the
4533
+ * gate, never open it. The gate no longer has its old per-turn overwrite (any
4534
+ * typed text that was not a close phrase used to expire the approval outright),
4535
+ * because an unknown typed phrase must not silently retract an approval the
4536
+ * user actually gave — that is what turned the close skill's own "did you mean
4537
+ * X or Y" reflection question into a false retraction. Under the new
4538
+ * rule, typed text that is neither a close phrase nor a retraction phrase is
4539
+ * NEUTRAL: it leaves the gate exactly where it was.
4540
+ *
4541
+ * This function is the one deliberate exception, and its asymmetry is the
4542
+ * safety property: a phrase MISSING from the corpus below just falls through
4543
+ * to neutral, which is the ordinary, already-accepted behaviour of an unknown
4544
+ * typed message. So the corpus can be grown at any time without weakening an
4545
+ * existing guarantee — the worst case of a miss is a gate that stays open,
4546
+ * not one that opens when it should not. That asymmetry is also why this is
4547
+ * NOT a counterexample to the rule that a defence is written as an effect,
4548
+ * not a name list: a name list is dangerous exactly when it backs a GRANT,
4549
+ * because the one name missing from it is a silent bypass. Here the missing
4550
+ * name is a silent no-op.
4551
+ *
4552
+ * If this function is ever consumed by an opening decision instead of a
4553
+ * closing one, that asymmetry disappears and the rule for an unknown
4554
+ * value (route to the cautious branch) applies again — do not repurpose it
4555
+ * without re-deriving the corpus under that rule.
4556
+ */
4557
+ export function isCloseRetractionPattern(text) {
4558
+ if (!text || typeof text !== 'string') return false;
4559
+ const patterns = [/아\s*잠깐,?\s*이(?:것도|거)\s*(?:먼저\s*)?고쳐줘/, /하나만\s*더\s*해줘/];
4560
+ return patterns.some((re) => re.test(text));
4561
+ }
4562
+
4309
4563
  // ── close ARTIFACTS, not the marker (issue: close gate lives on one writer) ──
4310
4564
  //
4311
- // hasUserCloseSignal/isClosePattern answer "did the user ask to close" — the
4565
+ // isCloseGateOpen/isClosePattern answer "did the user ask to close" — the
4312
4566
  // input side of the gate. This answers a different question: "does this file
4313
4567
  // or commit message already READ as closed to a human", independent of
4314
4568
  // whether any marker writer ever ran. The 2026-07-28 incident produced a
@@ -4464,77 +4718,69 @@ export function resolveTranscriptBySessionId(
4464
4718
  }
4465
4719
 
4466
4720
  /**
4467
- * Returns true iff the transcript's LATEST live user decision is to close — the
4468
- * hard gate for the session-closed marker writers. This is a state predicate, not
4469
- * an existence one: "is close still approved right now", not "did a
4470
- * close signal ever appear". Scans the FULL transcript in line order, classifies
4471
- * each record, and tracks the approval as a LEASE.
4721
+ * Internal walk: does this transcript's typed/queued/answered event history
4722
+ * currently leave the close gate OPEN? This is the TRANSCRIPT axis only — a
4723
+ * resolution recorded separately in the vault (`close-gate-store.mjs`)
4724
+ * can only narrow this result further, never widen it, and is computed
4725
+ * elsewhere entirely.
4472
4726
  *
4473
4727
  * Classification (structural fields only — never content heuristics for producer):
4474
- * • GRANT a genuine user close: an NL close phrase in user text that
4475
- * survives {@link eventUserText}'s exclusions; a `/compact`
4476
- * queue-op; a remove-path queued_command attachment carrying a
4477
- * close with an audited human producer (origin.kind "human"); a
4478
- * correlated, non-error AskUserQuestion answer naming a close.
4479
- * • INVALIDATE a fresh user intent that expires the lease: any other genuine
4480
- * user text, `/clear`, `popAll`, a non-close queued_command, a
4481
- * non-close AskUserQuestion selection.
4482
- * • NEUTRAL additionally: a typed `apply-proposals <nonce>` whose nonce this
4483
- * transcript shows `proposal challenge` minting (see "the approval
4484
- * line" below).
4485
- * • NEUTRAL everything the model can produce or the harness injects: system/
4486
- * sdk replay, isMeta bodies, sidechain, interruptedMessageId
4487
- * companions, assistant, tool_result, task-notification.
4488
- * • FATAL an unparseable line — the transcript is being appended to or is
4489
- * corrupt, so refuse rather than read a half-written record.
4728
+ * • OPEN a genuine user close: an NL close phrase in user text that
4729
+ * survives {@link eventUserText}'s exclusions; a `/compact`
4730
+ * queue-op; a remove-path queued_command attachment carrying a
4731
+ * close with an audited human producer (origin.kind "human"); a
4732
+ * correlated, non-error AskUserQuestion answer naming a close.
4733
+ * • CLOSE a fresh user intent that closes the gate: `/clear`, `popAll`, a
4734
+ * non-close queued_command, a decline answering an AskUserQuestion
4735
+ * whose own prompt carried {@link CLOSE_RECONFIRM_MARK} (ours, so
4736
+ * the decline rejects OUR close question and nobody else's), or
4737
+ * typed text matching {@link isCloseRetractionPattern} (the
4738
+ * retraction tripwire). Two things that used to close no longer do:
4739
+ * plain typed text that is merely not a close phrase, and an
4740
+ * ordinary non-close answer to an unmarked question. Both are
4741
+ * NEUTRAL below.
4742
+ * • NEUTRAL everything the model can produce or the harness injects: system/
4743
+ * sdk replay, isMeta bodies, sidechain, interruptedMessageId
4744
+ * companions, a command-invocation record, assistant,
4745
+ * tool_result, task-notification — and, under the current rule, any typed
4746
+ * text that is neither a close phrase nor a retraction phrase. An
4747
+ * unrecognized message must not silently close a gate the user
4748
+ * already opened; that per-turn overwrite (`granted =
4749
+ * isClosePattern(userText)` on every typed record) is exactly what
4750
+ * produced the close skill's own reflection question reading as a
4751
+ * retraction, and is what this walk replaces.
4752
+ * • FATAL an unparseable line — the transcript is being appended to or is
4753
+ * corrupt, so refuse rather than read a half-written record.
4490
4754
  *
4491
- * The last grant wins and a later invalidate expires it, so a stale close (Defect
4492
- * B), a queued "keep working" after a close, and a non-close AskUserQuestion
4493
- * selection all correctly read as NOT closed. Abandoned-branch staleness is a
4494
- * known limit (no leaf pointer exists to resolve it — see the branch note on the
4495
- * function), mitigated by the lease.
4755
+ * The last OPEN/CLOSE event wins, so a stale close (Defect B), a queued "keep
4756
+ * working" after a close, and a non-close AskUserQuestion selection all
4757
+ * correctly read as not open. Abandoned-branch staleness is a known limit (no
4758
+ * leaf pointer exists to resolve it — see the branch note in the body),
4759
+ * mitigated the same way: any later live user intent, on any branch, still
4760
+ * closes it.
4496
4761
  *
4497
- * THE APPROVAL LINE IS NOT A CHANGE OF MIND. The lease exists to catch a user who
4498
- * changed their mind, and it also caught the user for doing what the close
4499
- * procedure told them to do. `proposal challenge` instructs the user to type
4500
- * `apply-proposals <nonce>`, which can never be a close phrase, so typing it
4501
- * expired the close grant given a turn earlier (measured 2026-08-06: the close
4502
- * needed three approval round trips because of it). Two gates read one transcript
4503
- * under two rules, and passing one broke the other.
4504
- *
4505
- * So one carve-out: a user message that IS the approval line, for a nonce this
4506
- * transcript shows being minted, is NEUTRAL. It never grants; a lease that was not
4507
- * already live stays dead. The mint has to be correlated, not merely present:
4508
- * `collectMinted` reads only a tool_result correlated to a Bash tool_use, and only
4509
- * when the surrounding challenge framing is there too, so a hex the model typed in
4510
- * its own prose (or any other tool's output) is not a mint. Order is part of it:
4511
- * the set fills as the walk goes, so a message can never mint the nonce it spends.
4512
- * An unminted hex is ordinary text and still expires the lease.
4513
- *
4514
- * What this does NOT cover: an AskUserQuestion answer during the close procedure
4515
- * (the skill's Step 1a reflections) still expires the lease. A carve-out was
4516
- * written for it and withdrawn, because "the answer does not read as a retraction"
4517
- * is fail-open on an open class of phrasings, and this repo routes an unknown value
4518
- * to the cautious branch. Recognizing a close-procedure answer POSITIVELY needs a
4519
- * marker on the question, and the model authors the question. The round trip stays.
4762
+ * The typed `apply-proposals <nonce>` approval line no longer needs its own
4763
+ * carve-out. It used to: the old per-turn rule closed on ANY typed text that
4764
+ * was not itself a close phrase, and the approval line the close procedure
4765
+ * instructs the user to type can never BE a close phrase — so following that
4766
+ * instruction closed the gate the instruction was for (measured 2026-08-06:
4767
+ * three round trips to close because of it). With the per-turn overwrite gone,
4768
+ * the approval line is ordinary typed text — neither a close phrase nor a
4769
+ * retraction phrase — so it falls to NEUTRAL by the same rule every other
4770
+ * unrecognized message gets, with no special case required.
4520
4771
  *
4521
4772
  * Threat model: an honest-but-drifting model that does not forge the
4522
- * transcript. This is a safety interlock, not an authorization boundary — a model
4523
- * that forges records is out of scope and documented so. The carve-out does not
4524
- * move that boundary: it only PRESERVES a grant the user already gave, so the model
4525
- * still cannot manufacture one. The residual it does carry: a model that runs an
4526
- * arbitrary Bash command can print the challenge framing itself and plant a mint.
4527
- * That buys it nothing on its own, because the user still has to send the approval
4528
- * line, and the result is neutrality rather than a grant.
4773
+ * transcript. This is a safety interlock, not an authorization boundary — a
4774
+ * model that forges records is out of scope and documented so.
4529
4775
  *
4530
- * A command invocation is NOT a channel here. `/hypo:crystallize` typed by the user
4531
- * was accepted as a grant for one review round and removed: the harness records a
4532
- * model-issued Skill call as the same non-meta `<command-name>` user record
4533
- * (verified against Claude Code 2.1.243's `processPromptSlashCommand`), so the
4534
- * model could have closed the session by calling its own skill. The premise that a
4535
- * command tag proves a human typed it is false.
4776
+ * Fail-closed: any read/parse error → not open.
4536
4777
  *
4537
- * Fail-closed: any read/parse error, or an unresolvable branch, → false.
4778
+ * @returns {{open: boolean, openedAtIndex: number}} `openedAtIndex` is the
4779
+ * position, in the FATAL-filtered record array this walk builds internally
4780
+ * (blank lines dropped, non-object JSON values dropped, first parse failure
4781
+ * returns early), of the most recent event that opened the gate, or -1 if
4782
+ * none ever did. `close-gate-store.mjs`'s `resolutionStamp` counts records
4783
+ * by the same definition, so the two indices are comparable.
4538
4784
  */
4539
4785
  // Genuine user text of a record, or null when the record is on a channel the
4540
4786
  // honest-but-drifting model can reach (so it is never user intent — NEUTRAL).
@@ -4549,10 +4795,20 @@ export function resolveTranscriptBySessionId(
4549
4795
  // companion of a delivered /compact)
4550
4796
  // • Stop-hook feedback the hook's own close nudge — circular
4551
4797
  // • tool_result blocks tool output, not typed text
4798
+ // • a command-invocation tag (<command-name>/<command-message>/<command-args>)
4799
+ // anywhere in the text. Claude Code routes a model-issued Skill
4800
+ // call through the same processPromptSlashCommand path a human's typed
4801
+ // `/hypo:crystallize` goes through, and both land as this identical
4802
+ // non-meta role:user record — so a tag proves nothing about who produced
4803
+ // it. Excluding the whole text, not just the tag substring, means a close
4804
+ // phrase riding along in <command-args> (e.g. `/hypo:crystallize 세션
4805
+ // 마무리해줘`) cannot open the gate either; the user still opens it by
4806
+ // saying the same words in plain text.
4552
4807
  // No promptSource allowlist is required: requiring `typed` would drop the
4553
4808
  // legacy absent-promptSource close the older gate has always honoured, while the
4554
4809
  // dangerous replay/injection paths carry system|sdk|isMeta|isSidechain and are
4555
4810
  // excluded here anyway.
4811
+ const COMMAND_INVOCATION_TAG = /<command-(?:name|message|args)>/;
4556
4812
  function eventUserText(obj) {
4557
4813
  if (obj.isMeta === true) return null;
4558
4814
  if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return null;
@@ -4562,16 +4818,42 @@ function eventUserText(obj) {
4562
4818
  const role = msg.role ?? obj.role ?? obj.type;
4563
4819
  if (role !== 'user') return null;
4564
4820
  const content = msg.content ?? obj.content;
4821
+ let text = null;
4565
4822
  if (typeof content === 'string') {
4566
- return content.startsWith('Stop hook feedback') ? null : content;
4567
- }
4568
- if (Array.isArray(content)) {
4823
+ text = content.startsWith('Stop hook feedback') ? null : content;
4824
+ } else if (Array.isArray(content)) {
4569
4825
  const texts = content
4570
4826
  .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
4571
4827
  .map((b) => b.text);
4572
- return texts.length ? texts.join('\n') : null;
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;
4573
4854
  }
4574
- return null;
4855
+ if (text != null && COMMAND_INVOCATION_TAG.test(text)) return null;
4856
+ return text;
4575
4857
  }
4576
4858
 
4577
4859
  // A record on a channel the honest-but-drifting model can reach, so it can never
@@ -4586,17 +4868,17 @@ function isModelReachableRecord(obj) {
4586
4868
  );
4587
4869
  }
4588
4870
 
4589
- export function hasUserCloseSignal(transcriptPath) {
4590
- if (!transcriptPath) return false;
4871
+ export function walkCloseGate(transcriptPath) {
4872
+ if (!transcriptPath) return { open: false, openedAtIndex: -1 };
4591
4873
  let raw;
4592
4874
  try {
4593
4875
  raw = readFileSync(transcriptPath, 'utf-8');
4594
4876
  } catch {
4595
- return false;
4877
+ return { open: false, openedAtIndex: -1 };
4596
4878
  }
4597
4879
  // FATAL: a non-empty line that will not parse means the transcript is being
4598
4880
  // appended to (a half-written record) or is corrupt. Skipping it would let a
4599
- // stale prior grant survive past an event we cannot read, so refuse. A line
4881
+ // stale prior open survive past an event we cannot read, so refuse. A line
4600
4882
  // that parses to a non-object (a bare null / string / number) is valid JSON but
4601
4883
  // not a record — noise, not corruption — so it is skipped, not fatal, and never
4602
4884
  // reaches the field reads below.
@@ -4607,136 +4889,121 @@ export function hasUserCloseSignal(transcriptPath) {
4607
4889
  try {
4608
4890
  o = JSON.parse(line);
4609
4891
  } catch {
4610
- return false;
4892
+ return { open: false, openedAtIndex: -1 };
4611
4893
  }
4612
4894
  if (o === null || typeof o !== 'object') continue;
4613
4895
  recs.push(o);
4614
4896
  }
4615
4897
 
4616
- // The approval is a LEASE, not an existence fact: walk the transcript in line
4617
- // order and track whether the LATEST user decision is a grant. Each grant sets
4618
- // it true, each invalidate sets it false, neutral leaves it — so at the end
4619
- // `granted` is "the most recent user decision was to close, and nothing has
4620
- // expired it since", which is how a stale close and a queued change-of-mind
4621
- // read as NOT closed.
4898
+ // Walk the transcript in line order and track whether the LATEST event opened
4899
+ // or closed the gate — last event wins, so a stale open and a queued
4900
+ // change-of-mind both correctly read as closed.
4622
4901
  //
4623
4902
  // Branch note: line order mixes an abandoned branch's records with the live
4624
4903
  // ones. A leaf-pointer ancestry filter was tried and withdrawn — the transcript
4625
4904
  // carries no authoritative leaf pointer (measured: 0 leafUuid / summary
4626
- // records), so a heuristic leaf can skip the real invalidator and PRESERVE a
4627
- // stale grant (a fail-open, not a conservative filter). Until such a pointer
4628
- // exists, a close on a branch abandoned under a neutral tail is a known
4629
- // staleness limit, mitigated by the lease: any later live user intent, on any
4630
- // branch, still expires it.
4905
+ // records), so a heuristic leaf can skip the real closing event and PRESERVE a
4906
+ // stale open (a fail-open, not a conservative filter). Until such a pointer
4907
+ // exists, an open on a branch abandoned under a neutral tail is a known
4908
+ // staleness limit, mitigated the same way: any later live user intent, on any
4909
+ // branch, still closes it.
4631
4910
  const askIds = new Set();
4632
- // Bash tool_use ids, so a mint can be tied to a command that actually ran rather
4633
- // than to any string in the file. Filled in the same content scan as askIds.
4634
- const bashIds = new Set();
4635
- let granted = false;
4636
- // The nonces this transcript shows `proposal challenge` minting. Producer, not
4637
- // just presence: the hex has to arrive in a NON-error tool_result of a Bash
4638
- // tool_use, wrapped in the challenge's own framing. Model prose carrying a
4639
- // plausible hex, an isMeta/system body, and any other tool's output all fail
4640
- // that, which matters because a mint neutralizes the user's next message. The
4641
- // set fills as the walk goes, so a message cannot mint the nonce it spends.
4642
- const mintedNonces = new Set();
4643
- const challengeMint = new RegExp(
4644
- `must type this line in the conversation:\\s*${APPROVAL_PHRASE} ([a-f0-9]{32,})\\s*Then run: hypomnema proposal resolve`,
4645
- 'g',
4646
- );
4647
- const approvalLine = new RegExp(`^${APPROVAL_PHRASE}\\s+([a-f0-9]{32,})$`);
4648
- const collectMinted = (o) => {
4649
- const c = (o.message ?? o).content;
4650
- if (!Array.isArray(c)) return;
4651
- for (const b of c) {
4652
- if (!b || typeof b !== 'object') continue;
4653
- if (b.type !== 'tool_result' || !b.tool_use_id || !bashIds.has(b.tool_use_id)) continue;
4654
- if (b.is_error === true) continue;
4655
- const text = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
4656
- if (typeof text !== 'string' || !text.includes(APPROVAL_PHRASE)) continue;
4657
- for (const m of text.matchAll(challengeMint)) mintedNonces.add(m[1]);
4658
- }
4659
- };
4660
-
4661
- for (const o of recs) {
4911
+ // Subset of askIds whose tool_use input carries CLOSE_RECONFIRM_MARK, i.e.
4912
+ // it IS our close-reconfirm prompt, not some unrelated question the model
4913
+ // happens to ask around the same time. Same correlation guard
4914
+ // isCloseReconfirmDeclined uses (see its doc comment below), reimplemented
4915
+ // here rather than calling that function: its re-arm rule only recognizes a
4916
+ // typed close phrase, which misses the three new openers this walk already
4917
+ // tracks (a queued /compact, a human-origin queued delivery, an
4918
+ // AskUserQuestion close answer). Driving decline off this walk instead means
4919
+ // any of those already reopens the gate for free, with no separate re-arm
4920
+ // rule to keep in sync.
4921
+ const markedAskIds = new Set();
4922
+ let open = false;
4923
+ let openedAtIndex = -1;
4924
+
4925
+ for (let i = 0; i < recs.length; i++) {
4926
+ const o = recs[i];
4662
4927
  // Genuine user text of this record, or null when the record is on a channel
4663
- // the model can reach. Computed once here: the mint scan below is the exact
4664
- // complement of it (mint from everything that is NOT the user's own text).
4928
+ // the model can reach (including a command-invocation record).
4665
4929
  const userText = eventUserText(o);
4666
- if (userText == null) collectMinted(o);
4667
4930
 
4668
4931
  // Queue operations. The queue carries no correlation key (measured), so the
4669
4932
  // ENQUEUE content is the decision — not a later contentless dequeue, which
4670
4933
  // would need pairing we cannot do. Reading the enqueue also keeps the live
4671
4934
  // PreCompact gate working (it sees the enqueue) and avoids double-counting the
4672
4935
  // replay companion of an already-decided item (the /compact replay is not a
4673
- // fresh decision). popAll cancels the queue → invalidate. Delivery ops
4936
+ // fresh decision). popAll cancels the queue → close. Delivery ops
4674
4937
  // (dequeue, remove) carry no fresh decision here — a content-bearing remove of
4675
4938
  // an NL queued command is handled by its queued_command attachment below.
4676
4939
  if (o.type === 'queue-operation') {
4677
4940
  if (o.operation === 'popAll') {
4678
- granted = false;
4941
+ open = false;
4679
4942
  continue;
4680
4943
  }
4681
4944
  if (o.operation !== 'enqueue') continue;
4682
4945
  const c = typeof o.content === 'string' ? o.content.trim() : '';
4683
- if (/^\/compact(?:\s|$)/.test(c))
4684
- granted = true; // a user compaction preserves the work → grant
4685
- else if (/^\/clear(?:\s|$)/.test(c))
4686
- granted = false; // abandons context → invalidate
4687
- else if (!c || c.startsWith('<task-notification>')) {
4946
+ if (/^\/compact(?:\s|$)/.test(c)) {
4947
+ open = true; // a user compaction preserves the work → open
4948
+ openedAtIndex = i;
4949
+ } else if (/^\/clear(?:\s|$)/.test(c)) {
4950
+ open = false; // abandons context → close
4951
+ } else if (!c || c.startsWith('<task-notification>')) {
4688
4952
  /* model-caused / empty — neutral */
4689
4953
  } else if (isClosePattern(c)) {
4690
4954
  /* NL close via the queue — the open dequeue gap: the producer cannot be
4691
- attributed (a peer/model enqueue wears the same shape), so no grant */
4692
- } else granted = false; // a queued non-close user intent → invalidate (change of mind)
4955
+ attributed (a peer/model enqueue wears the same shape), so no open */
4956
+ } else open = false; // a queued non-close user intent → close (change of mind)
4693
4957
  continue;
4694
4958
  }
4695
4959
 
4696
4960
  // remove-path delivery of a queued natural-language command (measured: the
4697
4961
  // item leaves the queue as it is handed to the model, landing as an
4698
- // `attachment` of type queued_command with the prompt verbatim). A close here
4699
- // grants ONLY with an audited human producer — origin.kind "human", present
4962
+ // `attachment` of type queued_command with the prompt verbatim). This opens
4963
+ // the gate ONLY with an audited human producer — origin.kind "human", present
4700
4964
  // on every 2.1.181+ user delivery (measured). A legacy origin-absent delivery
4701
- // cannot attest a producer, so it does not grant (fail-closed). A NON-close
4702
- // queued command (e.g. "keep working") is a fresh user intent and INVALIDATES
4703
- // a prior grant regardless of origin — that is what closes the re-close hole
4704
- // where a queued "continue" after a close leaves the stale lease live.
4965
+ // cannot attest a producer, so it does not open (fail-closed). A NON-close
4966
+ // queued command (e.g. "keep working") is a fresh user intent and CLOSES
4967
+ // 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.
4705
4969
  if (o.type === 'attachment' && o.attachment && o.attachment.type === 'queued_command') {
4706
4970
  const prompt = typeof o.attachment.prompt === 'string' ? o.attachment.prompt : '';
4707
4971
  const humanOrigin = !!(o.attachment.origin && o.attachment.origin.kind === 'human');
4708
4972
  if (isClosePattern(prompt)) {
4709
- if (humanOrigin) granted = true;
4973
+ if (humanOrigin) {
4974
+ open = true;
4975
+ openedAtIndex = i;
4976
+ }
4710
4977
  } else if (prompt) {
4711
- granted = false;
4978
+ open = false;
4712
4979
  }
4713
4980
  continue;
4714
4981
  }
4715
4982
 
4716
4983
  // Record AskUserQuestion tool_use ids (assistant record, always precedes its
4717
- // answer in line order). Bash ids ride along in the same scan, for the mint
4718
- // correlation above.
4984
+ // answer in line order), and separately mark the ones that ARE our
4985
+ // close-reconfirm prompt (input carries CLOSE_RECONFIRM_MARK).
4719
4986
  const content = (o.message ?? o).content;
4720
4987
  if (Array.isArray(content)) {
4721
4988
  for (const b of content) {
4722
4989
  if (!b || typeof b !== 'object' || b.type !== 'tool_use' || !b.id) continue;
4723
- if (b.name === 'AskUserQuestion') askIds.add(b.id);
4724
- else if (b.name === 'Bash') bashIds.add(b.id);
4990
+ if (b.name !== 'AskUserQuestion') continue;
4991
+ askIds.add(b.id);
4992
+ if (JSON.stringify(b.input ?? null).includes(CLOSE_RECONFIRM_MARK)) markedAskIds.add(b.id);
4725
4993
  }
4726
4994
  }
4727
4995
 
4728
- // Genuine user text → grant on a close phrase, invalidate on anything else,
4729
- // minus the one carve-out for what the product itself asked the user to send.
4996
+ // Genuine typed user text. A close phrase opens the gate; the retraction
4997
+ // tripwire closes it; everything else is neutral (an
4998
+ // unrecognized phrase must not silently close a gate the user opened, so
4999
+ // there is no default "else close" branch here the way there used to be).
4730
5000
  if (userText != null && userText !== '') {
4731
- const spend = approvalLine.exec(userText.trim());
4732
- if (spend && mintedNonces.has(spend[1])) {
4733
- // The approval line `proposal challenge` tells the user to type, bound to a
4734
- // nonce this transcript minted. Neutral, never a grant: an untouched
4735
- // `granted` that was false stays false. An unminted hex is ordinary text and
4736
- // falls through to the invalidating branch below.
4737
- continue;
5001
+ if (isClosePattern(userText)) {
5002
+ open = true;
5003
+ openedAtIndex = i;
5004
+ } else if (isCloseRetractionPattern(userText)) {
5005
+ open = false;
4738
5006
  }
4739
- granted = isClosePattern(userText);
4740
5007
  continue;
4741
5008
  }
4742
5009
 
@@ -4745,7 +5012,20 @@ export function hasUserCloseSignal(transcriptPath) {
4745
5012
  // record from reaching the answer parser. is_error:false AND the host's
4746
5013
  // success marker are required because a malformed AskUserQuestion echoes the
4747
5014
  // raw input back in an is_error result, and the model authors the option
4748
- // labels. A close selection grants; any other real selection invalidates.
5015
+ // labels.
5016
+ //
5017
+ // T5: a close phrase in the answer opens the gate, same as before. But a
5018
+ // decline no longer closes it on its own: only a decline answering a
5019
+ // MARKED prompt (ours, see markedAskIds above) does. Every other answer,
5020
+ // including a non-close, non-decline click and a decline to an unmarked
5021
+ // question, is neutral. The old default (any other real selection closed
5022
+ // the gate) let a reflection question the close skill's own Step 1a asks
5023
+ // mid-procedure cancel the approval that started that procedure: the
5024
+ // model asks something adjacent, the user answers plainly, and the answer
5025
+ // was not a close phrase, so the old rule read it as a change of mind.
5026
+ // The loss this buys: an unmarked question answered with "지금 종료하지
5027
+ // 말아 줘" (a real request to keep going, just not phrased as a decline
5028
+ // to OUR prompt) now passes through neutral instead of closing the gate.
4749
5029
  if (Array.isArray(content) && !isModelReachableRecord(o)) {
4750
5030
  for (const b of content) {
4751
5031
  if (!b || typeof b !== 'object') continue;
@@ -4753,18 +5033,46 @@ export function hasUserCloseSignal(transcriptPath) {
4753
5033
  if (b.is_error === true) continue;
4754
5034
  const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
4755
5035
  if (!/have been answered/.test(s)) continue;
4756
- let sawAnswer = false;
4757
5036
  let sawClose = false;
5037
+ let sawDecline = false;
4758
5038
  for (const m of s.matchAll(/="([^"]*)"/g)) {
4759
- sawAnswer = true;
5039
+ // Independent checks, not else-if: a marked prompt's decline option
5040
+ // can itself contain close wording (the model authors the text),
5041
+ // and the decline-wins priority below only works if sawDecline gets
5042
+ // set even when the same string also matches isClosePattern.
4760
5043
  if (isClosePattern(m[1])) sawClose = true;
5044
+ if (CLOSE_RECONFIRM_DECLINE_WORDS.test(m[1])) sawDecline = true;
5045
+ }
5046
+ // A decline on a MARKED prompt wins over a close match checked on the
5047
+ // same answer. The model authors the option text, and a marked prompt
5048
+ // exists precisely to ask "close now, yes or no", so an answer that
5049
+ // reads as a decline of THAT question must not be overridable by a
5050
+ // close phrase riding along elsewhere in the same answer string. If
5051
+ // the close check ran first, the model could word a decline option to
5052
+ // also contain a close phrase and force the gate open on its own
5053
+ // rejection.
5054
+ if (sawDecline && markedAskIds.has(b.tool_use_id)) {
5055
+ open = false;
5056
+ } else if (sawClose) {
5057
+ open = true;
5058
+ openedAtIndex = i;
4761
5059
  }
4762
- if (sawClose) granted = true;
4763
- else if (sawAnswer) granted = false;
4764
5060
  }
4765
5061
  }
4766
5062
  }
4767
- return granted;
5063
+ return { open, openedAtIndex };
5064
+ }
5065
+
5066
+ /**
5067
+ * Public opening-axis predicate: does this transcript's typed/queued/answered
5068
+ * event history currently leave the close gate open? A thin boolean wrapper
5069
+ * over {@link walkCloseGate} — every caller that only needs the verdict uses
5070
+ * this. `close-gate-store.mjs`'s `closeGateStatus` imports `walkCloseGate`
5071
+ * directly instead, because it also needs `openedAtIndex` to order the open
5072
+ * against a recorded resolution.
5073
+ */
5074
+ export function isCloseGateOpen(transcriptPath) {
5075
+ return walkCloseGate(transcriptPath).open;
4768
5076
  }
4769
5077
 
4770
5078
  /** The literal the user must type to approve a parked-overwrite batch. */
@@ -4774,7 +5082,7 @@ export const APPROVAL_PHRASE = 'apply-proposals';
4774
5082
  * True iff the transcript carries a user's TYPED approval of the batch this nonce
4775
5083
  * was minted for — the authorization gate for a transcript-approved apply.
4776
5084
  *
4777
- * Deliberately NOT hasUserCloseSignal. That function answers "did the user want to
5085
+ * Deliberately NOT isCloseGateOpen. That function answers "did the user want to
4778
5086
  * end the session", and it accepts a correlated AskUserQuestion answer as evidence
4779
5087
  * (see above). Reusing it here would let a SESSION-CLOSE approval spend itself as an
4780
5088
  * OVERWRITE approval: different authority, different question. Two gates, two
@@ -4887,6 +5195,16 @@ export function hasPendingBackgroundWork(payload) {
4887
5195
  // reason silently break this correlation with no test to catch it.
4888
5196
  export const CLOSE_RECONFIRM_MARK = '지금 닫기';
4889
5197
 
5198
+ // The decline vocabulary shared by walkCloseGate's marked-reconfirm click
5199
+ // branch and isCloseReconfirmDeclined below. One constant so the two walks
5200
+ // cannot drift into recognizing different words for the same "not now"
5201
+ // answer: they stay separate WALKS on purpose (isCloseReconfirmDeclined's
5202
+ // re-arm rule only recognizes a typed close phrase, which is too narrow for
5203
+ // walkCloseGate's other openers), but the vocabulary they match against is
5204
+ // one value, not two copies that a future edit could update in only one
5205
+ // place.
5206
+ const CLOSE_RECONFIRM_DECLINE_WORDS = /(아직|나중|not\s?yet|later)/i;
5207
+
4890
5208
  /**
4891
5209
  * True iff the LATEST correlated AskUserQuestion answer in the transcript
4892
5210
  * declined an autoclose reconfirm prompt ("아직" / "나중" / "not yet" /
@@ -4895,10 +5213,11 @@ export const CLOSE_RECONFIRM_MARK = '지금 닫기';
4895
5213
  *
4896
5214
  * Read-only, forward scan over the FULL transcript (no tail truncation — a
4897
5215
  * decline can precede the next Stop by any number of turns). Reuses the same
4898
- * askIds + tool_use_id correlation `hasUserCloseSignal` (above) uses to bind
5216
+ * askIds + tool_use_id correlation `walkCloseGate` (above) uses to bind
4899
5217
  * an AskUserQuestion answer to its own tool_use, so an unrelated tool_result
4900
- * string can't forge a decline. Unlike `hasUserCloseSignal` (which is a
4901
- * "any evidence, ever" OR), this tracks a single latest-wins boolean as it
5218
+ * string can't forge a decline. Unlike `walkCloseGate` (which is already a
5219
+ * latest-event-wins walk, not an "any evidence, ever" OR), this tracks a
5220
+ * separate single latest-wins boolean as it
4902
5221
  * scans: a decline answer sets it true, and any later GENUINE USER close
4903
5222
  * signal resets it to false — the user asked to close again, so the prior
4904
5223
  * decline no longer applies.
@@ -4931,7 +5250,6 @@ export function isCloseReconfirmDeclined(transcriptPath) {
4931
5250
  } catch {
4932
5251
  return false;
4933
5252
  }
4934
- const DECLINE = /(아직|나중|not\s?yet|later)/i;
4935
5253
  const askIds = new Set();
4936
5254
  let declined = false;
4937
5255
  for (const line of lines) {
@@ -4980,7 +5298,7 @@ export function isCloseReconfirmDeclined(transcriptPath) {
4980
5298
  if (b.type === 'tool_result' && b.tool_use_id && askIds.has(b.tool_use_id)) {
4981
5299
  const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
4982
5300
  for (const m of s.matchAll(/="([^"]*)"/g)) {
4983
- if (DECLINE.test(m[1])) declined = true;
5301
+ if (CLOSE_RECONFIRM_DECLINE_WORDS.test(m[1])) declined = true;
4984
5302
  }
4985
5303
  }
4986
5304
  }