hypomnema 1.7.2 → 1.7.4

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.
@@ -445,6 +445,72 @@ export function hypoIsClean(dir = HYPO_DIR) {
445
445
  }
446
446
  }
447
447
 
448
+ /**
449
+ * Repo-relative POSIX paths with uncommitted changes (tracked or untracked) in
450
+ * `dir`. Reuses the SAME `-z` rename-aware porcelain parsing as
451
+ * commitWikiChanges (PR #222): a rename or copy emits `to\0from`, and both
452
+ * sides count as dirty (a rename's source still shows staged until the rename
453
+ * itself lands). This is the read-only half of that fix's pathspec boundary:
454
+ * commitWikiChanges narrows what gets COMMITTED to a caller-supplied scope;
455
+ * this narrows what counts as a BLOCKER the same way, for precompactGateStatus
456
+ * to attribute dirty files to the session that owns them instead of the whole
457
+ * shared working tree.
458
+ *
459
+ * Paths are normalized to be relative to `dir` itself, NOT to the git
460
+ * repository's top level: `git -C <dir> status --porcelain` prints paths
461
+ * relative to the repo TOP LEVEL even under `-C` (verified empirically: a
462
+ * vault nested under `<repo>/vault/` reports `vault/hot.md`, not `hot.md`).
463
+ * Every other path this file compares against (closeAccountableScope,
464
+ * closeFileTargetsGlobal, extractTouchedWikiFiles) is `dir`-relative, so
465
+ * without this normalization a nested vault's OWN files would never match
466
+ * its own scope and every one of them would look foreign (codex pre-commit
467
+ * review BLOCKER 2). `git rev-parse --show-prefix` gives exactly the prefix
468
+ * to strip; a dirty path that does not start with it lives outside the
469
+ * vault entirely (the rest of a bigger host repo) and is dropped, never
470
+ * "mine".
471
+ *
472
+ * @returns {string[]} dirty paths relative to `dir`, or `[]` on any git
473
+ * failure (the caller already has its own git-status result via
474
+ * hypoIsClean and treats that failure as an unconditional blocker; an
475
+ * empty return here just means "cannot attribute", not "clean").
476
+ */
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
482
+ const prefix = (prefixRes.stdout || '').trim();
483
+
484
+ const porcelain = spawnSync('git', ['-C', dir, 'status', '--porcelain', '-uall', '-z'], {
485
+ encoding: 'utf-8',
486
+ });
487
+ if (porcelain.status !== 0) return [];
488
+ const out = [];
489
+ const records = (porcelain.stdout || '').split('\0');
490
+ const toDirRelative = (f) => {
491
+ if (!f) return null;
492
+ if (!prefix) return f; // dir IS the repo top level, nothing to strip
493
+ return f.startsWith(prefix) ? f.slice(prefix.length) : null; // outside the vault
494
+ };
495
+ for (let i = 0; i < records.length; i++) {
496
+ const rec = records[i];
497
+ if (!rec) continue;
498
+ const xy = rec.slice(0, 2);
499
+ const file = rec.slice(3); // destination path for a rename/copy
500
+ const isRenameOrCopy = xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C';
501
+ let fromFile = null;
502
+ if (isRenameOrCopy) {
503
+ i++;
504
+ fromFile = records[i] || null;
505
+ }
506
+ const rel = toDirRelative(file);
507
+ if (rel) out.push(rel);
508
+ const relFrom = toDirRelative(fromFile);
509
+ if (relFrom) out.push(relFrom);
510
+ }
511
+ return out;
512
+ }
513
+
448
514
  export function hotMdIsClean(dir = HYPO_DIR) {
449
515
  const hotPath = dir === HYPO_DIR ? HOT_PATH : join(dir, 'hot.md');
450
516
  if (!existsSync(hotPath)) return { clean: true };
@@ -3281,22 +3347,32 @@ function toHypoRel(absPath, hypoDir) {
3281
3347
  }
3282
3348
 
3283
3349
  /**
3284
- * Repo-relative POSIX paths of wiki files this session edited via direct
3285
- * Edit/Write/MultiEdit/NotebookEdit tool_use. Returns a Set; empty when the
3286
- * transcript is missing/unreadable (callers decide the fallback). A per-line
3287
- * JSON parse error skips that line only (transcripts occasionally truncate).
3350
+ * Same walk as extractTouchedWikiFiles, but also reports whether the walk can
3351
+ * be TRUSTED as a complete enumeration: `trusted: false` when the transcript
3352
+ * is missing, unreadable, or contains a line that failed to parse (transcripts
3353
+ * occasionally truncate mid-write). `extractTouchedWikiFiles` collapses all of
3354
+ * that to an empty/partial Set, indistinguishable from "this session touched
3355
+ * nothing", which is fine for lint's existing debt-partition (a false notice is not a
3356
+ * false pass), but NOT fine for an attribution-sensitive caller like
3357
+ * precompactGateStatus's git-scope check: treating an untrustworthy empty Set
3358
+ * as "nothing to widen" would let a session's own dirty file, invisible only
3359
+ * because its transcript could not be read, pass as someone else's foreign
3360
+ * debt (codex pre-commit review BLOCKER 1).
3361
+ *
3362
+ * @returns {{files: Set<string>, trusted: boolean}}
3288
3363
  */
3289
- export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
3364
+ export function extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir) {
3290
3365
  const out = new Set();
3291
3366
  if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
3292
- return out;
3367
+ return { files: out, trusted: false };
3293
3368
  }
3294
3369
  let raw;
3295
3370
  try {
3296
3371
  raw = readFileSync(transcriptPath, 'utf-8');
3297
3372
  } catch {
3298
- return out;
3373
+ return { files: out, trusted: false };
3299
3374
  }
3375
+ let trusted = true;
3300
3376
  for (const line of raw.split('\n')) {
3301
3377
  const t = line.trim();
3302
3378
  if (!t) continue;
@@ -3304,6 +3380,7 @@ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
3304
3380
  try {
3305
3381
  entry = JSON.parse(t);
3306
3382
  } catch {
3383
+ trusted = false; // a truncated/corrupt line: the walk may be incomplete
3307
3384
  continue;
3308
3385
  }
3309
3386
  for (const fp of extractTranscriptToolFilePaths(entry)) {
@@ -3311,7 +3388,19 @@ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
3311
3388
  if (rel) out.add(rel);
3312
3389
  }
3313
3390
  }
3314
- return out;
3391
+ return { files: out, trusted };
3392
+ }
3393
+
3394
+ /**
3395
+ * Repo-relative POSIX paths of wiki files this session edited via direct
3396
+ * Edit/Write/MultiEdit/NotebookEdit tool_use. Returns a Set; empty when the
3397
+ * transcript is missing/unreadable (callers decide the fallback). A per-line
3398
+ * JSON parse error skips that line only (transcripts occasionally truncate).
3399
+ * A caller that needs to tell "genuinely empty" apart from "could not fully
3400
+ * enumerate" wants extractTouchedWikiFilesWithTrust instead.
3401
+ */
3402
+ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
3403
+ return extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir).files;
3315
3404
  }
3316
3405
 
3317
3406
  /**
@@ -3539,20 +3628,89 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3539
3628
  const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
3540
3629
  const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
3541
3630
 
3542
- // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK:
3543
- // they are human-fixable. Unpushed commits (ahead) DEMOTE to a notice: push is
3631
+ // Paths this session is accountable for at THIS close: the same signals the
3632
+ // lint partition below already trusts for exactly this question ("is this
3633
+ // file mine or pre-existing debt"), hoisted so the git check (step 1) can
3634
+ // partition on it too. Base = the mandatory close-target files (opts.lintScope
3635
+ // override, else the log-only shared root files, else one project's close
3636
+ // files under projectOverride, else every today-active project's); widened by
3637
+ // every file this session's transcript shows it editing directly.
3638
+ const closeAccountableScope = new Set(
3639
+ opts.lintScope ||
3640
+ (logOnly
3641
+ ? ['hot.md', 'log.md']
3642
+ : opts.projectOverride
3643
+ ? closeFileTargetsForProject(hypoDir, opts.projectOverride)
3644
+ : closeFileTargetsGlobal(hypoDir)),
3645
+ );
3646
+ // Trust signal for the git-scope check below (codex pre-commit review
3647
+ // BLOCKER 1): closeAccountableScope's mandatory-files base is always
3648
+ // reliable, but the transcript widening that catches an ad hoc page this
3649
+ // session edited is only as good as the transcript. No opts.transcriptPath
3650
+ // at all, an unreadable file, or a line that fails to parse all mean the
3651
+ // widened scope may be under-inclusive, NOT that this session touched
3652
+ // nothing extra. Only a fully-read, fully-parsed transcript earns
3653
+ // sessionTouchTrusted:true. A single tool_use with no file_path does NOT
3654
+ // lower trust: most tool_use blocks (Read, Bash, Grep, ...) never carry one
3655
+ // and that is expected, not corruption, so treating it as untrustworthy
3656
+ // would make almost every real transcript untrusted and defeat the scoping
3657
+ // this fix exists to add.
3658
+ let sessionTouchTrusted = false;
3659
+ if (opts.transcriptPath) {
3660
+ const widened = extractTouchedWikiFilesWithTrust(opts.transcriptPath, hypoDir);
3661
+ sessionTouchTrusted = widened.trusted;
3662
+ for (const f of widened.files) closeAccountableScope.add(f);
3663
+ }
3664
+
3665
+ // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but only
3666
+ // the ones inside closeAccountableScope, and only when sessionTouchTrusted
3667
+ // (above) says that scope can actually be trusted: a session's own scoped
3668
+ // auto-commit (commitWikiChanges, PR #222) can leave the working tree
3669
+ // non-empty when a DIFFERENT session sharing this vault still has its own
3670
+ // file dirty, the 2026-08-03 multi-session block. That dirty file is
3671
+ // human-fixable by whoever owns it, not by this session, so it demotes to
3672
+ // a notice (listed by path, never silently dropped) instead of refusing
3673
+ // this session's marker. A dirty file THIS session owns still blocks
3674
+ // unconditionally (fail-closed is unchanged for scope this session
3675
+ // actually touched), and so does an unattributable state: a git failure
3676
+ // gitDirtyFiles can't enumerate (dirty.length === 0 despite
3677
+ // git.uncommitted === true), OR a scope we cannot trust
3678
+ // (!sessionTouchTrusted) both fall back to the original unscoped blocker:
3679
+ // "cannot attribute" is not "clean".
3680
+ // Unpushed commits (ahead) DEMOTE to a notice regardless of scope: push is
3544
3681
  // automatic (auto-commit Stop hook) and its failures are already non-fatal, so
3545
3682
  // "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
3546
3683
  // here (the shared gate) keeps the marker == compact-ready invariant:
3547
3684
  // a committed-but-unpushed close marks AND compacts, instead of the close writer
3548
3685
  // committing its own payload and then being blocked by its own (unpushed) commit.
3549
3686
  const git = hypoIsClean(hypoDir);
3550
- if (git.uncommitted) blockers.push({ type: 'git', reason: git.reason });
3551
- else if (git.ahead)
3687
+ if (git.uncommitted) {
3688
+ const dirty = gitDirtyFiles(hypoDir);
3689
+ if (dirty.length === 0 || !sessionTouchTrusted) {
3690
+ blockers.push({ type: 'git', reason: git.reason });
3691
+ } else {
3692
+ const mine = dirty.filter((f) => closeAccountableScope.has(posixPath(f)));
3693
+ const foreign = dirty.filter((f) => !closeAccountableScope.has(posixPath(f)));
3694
+ if (mine.length > 0) {
3695
+ blockers.push({
3696
+ type: 'git',
3697
+ reason: `uncommitted changes in ${hypoDir}: ${mine.join(', ')}`,
3698
+ });
3699
+ }
3700
+ for (const f of foreign) {
3701
+ notices.push({
3702
+ type: 'git',
3703
+ file: f,
3704
+ reason: `uncommitted changes outside this session's scope: ${f}`,
3705
+ });
3706
+ }
3707
+ }
3708
+ } else if (git.ahead) {
3552
3709
  notices.push({
3553
3710
  type: 'git-sync',
3554
3711
  reason: `unpushed commits in ${hypoDir} (push deferred to Stop hook)`,
3555
3712
  });
3713
+ }
3556
3714
 
3557
3715
  // 2. root hot.md structure
3558
3716
  const hot = hotMdIsClean(hypoDir);
@@ -3762,25 +3920,13 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3762
3920
  const parsed = JSON.parse(r.stdout);
3763
3921
  const allErrors = parsed.errors || [];
3764
3922
  const allW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
3765
- // log-only base scope = the shared root files only (hot.md / log.md) — NOT
3766
- // closeFileTargetsGlobal, which would fold the active/phantom project's
3767
- // mandatory files in and re-introduce the cross-project attribution. The
3768
- // session's own transcript-touched files are still added below (a log-only
3769
- // session is accountable for the wiki files it actually edited).
3770
- // Lint scope: explicit opts.lintScope wins; else log-only uses the shared
3771
- // root files only; else projectOverride narrows to that one project's close
3772
- // files (matching the narrowed close status above); else the global set.
3773
- const scope = new Set(
3774
- opts.lintScope ||
3775
- (logOnly
3776
- ? ['hot.md', 'log.md']
3777
- : opts.projectOverride
3778
- ? closeFileTargetsForProject(hypoDir, opts.projectOverride)
3779
- : closeFileTargetsGlobal(hypoDir)),
3780
- );
3781
- if (opts.transcriptPath && existsSync(opts.transcriptPath)) {
3782
- for (const f of extractTouchedWikiFiles(opts.transcriptPath, hypoDir)) scope.add(f);
3783
- }
3923
+ // Lint scope = closeAccountableScope, hoisted above step 1 so the git
3924
+ // check can partition on the same "is this mine" answer. See that
3925
+ // hoisted comment for what feeds it (opts.lintScope override, else
3926
+ // log-only's shared root files, else one project's close files under
3927
+ // projectOverride, else the global set, widened by transcript-touched
3928
+ // files).
3929
+ const scope = closeAccountableScope;
3784
3930
  const part = partitionLintScope(allErrors, scope);
3785
3931
  if (part.blocking.length > 0) {
3786
3932
  blockers.push({
@@ -4160,9 +4306,41 @@ export function isClosePattern(text) {
4160
4306
  return [...krPatterns, ...enPatterns].some((re) => re.test(text));
4161
4307
  }
4162
4308
 
4309
+ /**
4310
+ * Retraction TRIPWIRE for the close gate — a mitigation that can only CLOSE the
4311
+ * gate, never open it. The gate no longer has its old per-turn overwrite (any
4312
+ * typed text that was not a close phrase used to expire the approval outright),
4313
+ * because an unknown typed phrase must not silently retract an approval the
4314
+ * user actually gave — that is what turned the close skill's own "did you mean
4315
+ * X or Y" reflection question into a false retraction. Under the new
4316
+ * rule, typed text that is neither a close phrase nor a retraction phrase is
4317
+ * NEUTRAL: it leaves the gate exactly where it was.
4318
+ *
4319
+ * This function is the one deliberate exception, and its asymmetry is the
4320
+ * safety property: a phrase MISSING from the corpus below just falls through
4321
+ * to neutral, which is the ordinary, already-accepted behaviour of an unknown
4322
+ * typed message. So the corpus can be grown at any time without weakening an
4323
+ * existing guarantee — the worst case of a miss is a gate that stays open,
4324
+ * not one that opens when it should not. That asymmetry is also why this is
4325
+ * NOT a counterexample to the rule that a defence is written as an effect,
4326
+ * not a name list: a name list is dangerous exactly when it backs a GRANT,
4327
+ * because the one name missing from it is a silent bypass. Here the missing
4328
+ * name is a silent no-op.
4329
+ *
4330
+ * If this function is ever consumed by an opening decision instead of a
4331
+ * closing one, that asymmetry disappears and the rule for an unknown
4332
+ * value (route to the cautious branch) applies again — do not repurpose it
4333
+ * without re-deriving the corpus under that rule.
4334
+ */
4335
+ export function isCloseRetractionPattern(text) {
4336
+ if (!text || typeof text !== 'string') return false;
4337
+ const patterns = [/아\s*잠깐,?\s*이(?:것도|거)\s*(?:먼저\s*)?고쳐줘/, /하나만\s*더\s*해줘/];
4338
+ return patterns.some((re) => re.test(text));
4339
+ }
4340
+
4163
4341
  // ── close ARTIFACTS, not the marker (issue: close gate lives on one writer) ──
4164
4342
  //
4165
- // hasUserCloseSignal/isClosePattern answer "did the user ask to close" — the
4343
+ // isCloseGateOpen/isClosePattern answer "did the user ask to close" — the
4166
4344
  // input side of the gate. This answers a different question: "does this file
4167
4345
  // or commit message already READ as closed to a human", independent of
4168
4346
  // whether any marker writer ever ran. The 2026-07-28 incident produced a
@@ -4318,77 +4496,69 @@ export function resolveTranscriptBySessionId(
4318
4496
  }
4319
4497
 
4320
4498
  /**
4321
- * Returns true iff the transcript's LATEST live user decision is to close — the
4322
- * hard gate for the session-closed marker writers. This is a state predicate, not
4323
- * an existence one: "is close still approved right now", not "did a
4324
- * close signal ever appear". Scans the FULL transcript in line order, classifies
4325
- * each record, and tracks the approval as a LEASE.
4499
+ * Internal walk: does this transcript's typed/queued/answered event history
4500
+ * currently leave the close gate OPEN? This is the TRANSCRIPT axis only — a
4501
+ * resolution recorded separately in the vault (`close-gate-store.mjs`)
4502
+ * can only narrow this result further, never widen it, and is computed
4503
+ * elsewhere entirely.
4326
4504
  *
4327
4505
  * Classification (structural fields only — never content heuristics for producer):
4328
- * • GRANT a genuine user close: an NL close phrase in user text that
4329
- * survives {@link eventUserText}'s exclusions; a `/compact`
4330
- * queue-op; a remove-path queued_command attachment carrying a
4331
- * close with an audited human producer (origin.kind "human"); a
4332
- * correlated, non-error AskUserQuestion answer naming a close.
4333
- * • INVALIDATE a fresh user intent that expires the lease: any other genuine
4334
- * user text, `/clear`, `popAll`, a non-close queued_command, a
4335
- * non-close AskUserQuestion selection.
4336
- * • NEUTRAL additionally: a typed `apply-proposals <nonce>` whose nonce this
4337
- * transcript shows `proposal challenge` minting (see "the approval
4338
- * line" below).
4339
- * • NEUTRAL everything the model can produce or the harness injects: system/
4340
- * sdk replay, isMeta bodies, sidechain, interruptedMessageId
4341
- * companions, assistant, tool_result, task-notification.
4342
- * • FATAL an unparseable line — the transcript is being appended to or is
4343
- * corrupt, so refuse rather than read a half-written record.
4344
- *
4345
- * The last grant wins and a later invalidate expires it, so a stale close (Defect
4346
- * B), a queued "keep working" after a close, and a non-close AskUserQuestion
4347
- * selection all correctly read as NOT closed. Abandoned-branch staleness is a
4348
- * known limit (no leaf pointer exists to resolve it — see the branch note on the
4349
- * function), mitigated by the lease.
4350
- *
4351
- * THE APPROVAL LINE IS NOT A CHANGE OF MIND. The lease exists to catch a user who
4352
- * changed their mind, and it also caught the user for doing what the close
4353
- * procedure told them to do. `proposal challenge` instructs the user to type
4354
- * `apply-proposals <nonce>`, which can never be a close phrase, so typing it
4355
- * expired the close grant given a turn earlier (measured 2026-08-06: the close
4356
- * needed three approval round trips because of it). Two gates read one transcript
4357
- * under two rules, and passing one broke the other.
4506
+ * • OPEN a genuine user close: an NL close phrase in user text that
4507
+ * survives {@link eventUserText}'s exclusions; a `/compact`
4508
+ * queue-op; a remove-path queued_command attachment carrying a
4509
+ * close with an audited human producer (origin.kind "human"); a
4510
+ * correlated, non-error AskUserQuestion answer naming a close.
4511
+ * • CLOSE a fresh user intent that closes the gate: `/clear`, `popAll`, a
4512
+ * non-close queued_command, a decline answering an AskUserQuestion
4513
+ * whose own prompt carried {@link CLOSE_RECONFIRM_MARK} (ours, so
4514
+ * the decline rejects OUR close question and nobody else's), or
4515
+ * typed text matching {@link isCloseRetractionPattern} (the
4516
+ * retraction tripwire). Two things that used to close no longer do:
4517
+ * plain typed text that is merely not a close phrase, and an
4518
+ * ordinary non-close answer to an unmarked question. Both are
4519
+ * NEUTRAL below.
4520
+ * • NEUTRAL everything the model can produce or the harness injects: system/
4521
+ * sdk replay, isMeta bodies, sidechain, interruptedMessageId
4522
+ * companions, a command-invocation record, assistant,
4523
+ * tool_result, task-notification — and, under the current rule, any typed
4524
+ * text that is neither a close phrase nor a retraction phrase. An
4525
+ * unrecognized message must not silently close a gate the user
4526
+ * already opened; that per-turn overwrite (`granted =
4527
+ * isClosePattern(userText)` on every typed record) is exactly what
4528
+ * produced the close skill's own reflection question reading as a
4529
+ * retraction, and is what this walk replaces.
4530
+ * • FATAL an unparseable line — the transcript is being appended to or is
4531
+ * corrupt, so refuse rather than read a half-written record.
4358
4532
  *
4359
- * So one carve-out: a user message that IS the approval line, for a nonce this
4360
- * transcript shows being minted, is NEUTRAL. It never grants; a lease that was not
4361
- * already live stays dead. The mint has to be correlated, not merely present:
4362
- * `collectMinted` reads only a tool_result correlated to a Bash tool_use, and only
4363
- * when the surrounding challenge framing is there too, so a hex the model typed in
4364
- * its own prose (or any other tool's output) is not a mint. Order is part of it:
4365
- * the set fills as the walk goes, so a message can never mint the nonce it spends.
4366
- * An unminted hex is ordinary text and still expires the lease.
4533
+ * The last OPEN/CLOSE event wins, so a stale close (Defect B), a queued "keep
4534
+ * working" after a close, and a non-close AskUserQuestion selection all
4535
+ * correctly read as not open. Abandoned-branch staleness is a known limit (no
4536
+ * leaf pointer exists to resolve it — see the branch note in the body),
4537
+ * mitigated the same way: any later live user intent, on any branch, still
4538
+ * closes it.
4367
4539
  *
4368
- * What this does NOT cover: an AskUserQuestion answer during the close procedure
4369
- * (the skill's Step 1a reflections) still expires the lease. A carve-out was
4370
- * written for it and withdrawn, because "the answer does not read as a retraction"
4371
- * is fail-open on an open class of phrasings, and this repo routes an unknown value
4372
- * to the cautious branch. Recognizing a close-procedure answer POSITIVELY needs a
4373
- * marker on the question, and the model authors the question. The round trip stays.
4540
+ * The typed `apply-proposals <nonce>` approval line no longer needs its own
4541
+ * carve-out. It used to: the old per-turn rule closed on ANY typed text that
4542
+ * was not itself a close phrase, and the approval line the close procedure
4543
+ * instructs the user to type can never BE a close phrase — so following that
4544
+ * instruction closed the gate the instruction was for (measured 2026-08-06:
4545
+ * three round trips to close because of it). With the per-turn overwrite gone,
4546
+ * the approval line is ordinary typed text — neither a close phrase nor a
4547
+ * retraction phrase — so it falls to NEUTRAL by the same rule every other
4548
+ * unrecognized message gets, with no special case required.
4374
4549
  *
4375
4550
  * Threat model: an honest-but-drifting model that does not forge the
4376
- * transcript. This is a safety interlock, not an authorization boundary — a model
4377
- * that forges records is out of scope and documented so. The carve-out does not
4378
- * move that boundary: it only PRESERVES a grant the user already gave, so the model
4379
- * still cannot manufacture one. The residual it does carry: a model that runs an
4380
- * arbitrary Bash command can print the challenge framing itself and plant a mint.
4381
- * That buys it nothing on its own, because the user still has to send the approval
4382
- * line, and the result is neutrality rather than a grant.
4551
+ * transcript. This is a safety interlock, not an authorization boundary — a
4552
+ * model that forges records is out of scope and documented so.
4383
4553
  *
4384
- * A command invocation is NOT a channel here. `/hypo:crystallize` typed by the user
4385
- * was accepted as a grant for one review round and removed: the harness records a
4386
- * model-issued Skill call as the same non-meta `<command-name>` user record
4387
- * (verified against Claude Code 2.1.243's `processPromptSlashCommand`), so the
4388
- * model could have closed the session by calling its own skill. The premise that a
4389
- * command tag proves a human typed it is false.
4554
+ * Fail-closed: any read/parse error → not open.
4390
4555
  *
4391
- * Fail-closed: any read/parse error, or an unresolvable branch, → false.
4556
+ * @returns {{open: boolean, openedAtIndex: number}} `openedAtIndex` is the
4557
+ * position, in the FATAL-filtered record array this walk builds internally
4558
+ * (blank lines dropped, non-object JSON values dropped, first parse failure
4559
+ * returns early), of the most recent event that opened the gate, or -1 if
4560
+ * none ever did. `close-gate-store.mjs`'s `resolutionStamp` counts records
4561
+ * by the same definition, so the two indices are comparable.
4392
4562
  */
4393
4563
  // Genuine user text of a record, or null when the record is on a channel the
4394
4564
  // honest-but-drifting model can reach (so it is never user intent — NEUTRAL).
@@ -4403,10 +4573,20 @@ export function resolveTranscriptBySessionId(
4403
4573
  // companion of a delivered /compact)
4404
4574
  // • Stop-hook feedback the hook's own close nudge — circular
4405
4575
  // • tool_result blocks tool output, not typed text
4576
+ // • a command-invocation tag (<command-name>/<command-message>/<command-args>)
4577
+ // anywhere in the text. Claude Code routes a model-issued Skill
4578
+ // call through the same processPromptSlashCommand path a human's typed
4579
+ // `/hypo:crystallize` goes through, and both land as this identical
4580
+ // non-meta role:user record — so a tag proves nothing about who produced
4581
+ // it. Excluding the whole text, not just the tag substring, means a close
4582
+ // phrase riding along in <command-args> (e.g. `/hypo:crystallize 세션
4583
+ // 마무리해줘`) cannot open the gate either; the user still opens it by
4584
+ // saying the same words in plain text.
4406
4585
  // No promptSource allowlist is required: requiring `typed` would drop the
4407
4586
  // legacy absent-promptSource close the older gate has always honoured, while the
4408
4587
  // dangerous replay/injection paths carry system|sdk|isMeta|isSidechain and are
4409
4588
  // excluded here anyway.
4589
+ const COMMAND_INVOCATION_TAG = /<command-(?:name|message|args)>/;
4410
4590
  function eventUserText(obj) {
4411
4591
  if (obj.isMeta === true) return null;
4412
4592
  if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return null;
@@ -4416,16 +4596,42 @@ function eventUserText(obj) {
4416
4596
  const role = msg.role ?? obj.role ?? obj.type;
4417
4597
  if (role !== 'user') return null;
4418
4598
  const content = msg.content ?? obj.content;
4599
+ let text = null;
4419
4600
  if (typeof content === 'string') {
4420
- return content.startsWith('Stop hook feedback') ? null : content;
4421
- }
4422
- if (Array.isArray(content)) {
4601
+ text = content.startsWith('Stop hook feedback') ? null : content;
4602
+ } else if (Array.isArray(content)) {
4423
4603
  const texts = content
4424
4604
  .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
4425
4605
  .map((b) => b.text);
4426
- return texts.length ? texts.join('\n') : null;
4606
+ text = texts.length ? texts.join('\n') : null;
4607
+ // A command-invocation tag split across adjacent text blocks (e.g.
4608
+ // '<command-na' + 'me>/hypo:crystallize</command-name>') would survive the
4609
+ // '\n'-joined `text` above with a newline spliced into the middle of the
4610
+ // tag name, so the plain check below can miss it entirely. Today's actual
4611
+ // host format sends the whole invocation as ONE string, so this exact
4612
+ // split is not reproducible against a live session yet — but a check for
4613
+ // "did the host format ever put the tag exactly on a block boundary"
4614
+ // should not depend on where a future host happens to cut the blocks. So
4615
+ // also test each run of CONSECUTIVE text blocks joined with no separator.
4616
+ // This must stay scoped to consecutive text blocks only, never the whole
4617
+ // array: joining across a non-text block in between (an image, say) would
4618
+ // synthesize a tag that never existed in the real content, and that is a
4619
+ // different bug, not a fix — it would throw away a genuine close spoken
4620
+ // next to an unrelated attachment. So a non-text block ends the current
4621
+ // run and starts a new one; it never bridges two runs into one string.
4622
+ let tightRun = '';
4623
+ for (const b of content) {
4624
+ if (b && b.type === 'text' && typeof b.text === 'string') {
4625
+ tightRun += b.text;
4626
+ continue;
4627
+ }
4628
+ if (COMMAND_INVOCATION_TAG.test(tightRun)) return null;
4629
+ tightRun = '';
4630
+ }
4631
+ if (COMMAND_INVOCATION_TAG.test(tightRun)) return null;
4427
4632
  }
4428
- return null;
4633
+ if (text != null && COMMAND_INVOCATION_TAG.test(text)) return null;
4634
+ return text;
4429
4635
  }
4430
4636
 
4431
4637
  // A record on a channel the honest-but-drifting model can reach, so it can never
@@ -4440,17 +4646,17 @@ function isModelReachableRecord(obj) {
4440
4646
  );
4441
4647
  }
4442
4648
 
4443
- export function hasUserCloseSignal(transcriptPath) {
4444
- if (!transcriptPath) return false;
4649
+ export function walkCloseGate(transcriptPath) {
4650
+ if (!transcriptPath) return { open: false, openedAtIndex: -1 };
4445
4651
  let raw;
4446
4652
  try {
4447
4653
  raw = readFileSync(transcriptPath, 'utf-8');
4448
4654
  } catch {
4449
- return false;
4655
+ return { open: false, openedAtIndex: -1 };
4450
4656
  }
4451
4657
  // FATAL: a non-empty line that will not parse means the transcript is being
4452
4658
  // appended to (a half-written record) or is corrupt. Skipping it would let a
4453
- // stale prior grant survive past an event we cannot read, so refuse. A line
4659
+ // stale prior open survive past an event we cannot read, so refuse. A line
4454
4660
  // that parses to a non-object (a bare null / string / number) is valid JSON but
4455
4661
  // not a record — noise, not corruption — so it is skipped, not fatal, and never
4456
4662
  // reaches the field reads below.
@@ -4461,136 +4667,121 @@ export function hasUserCloseSignal(transcriptPath) {
4461
4667
  try {
4462
4668
  o = JSON.parse(line);
4463
4669
  } catch {
4464
- return false;
4670
+ return { open: false, openedAtIndex: -1 };
4465
4671
  }
4466
4672
  if (o === null || typeof o !== 'object') continue;
4467
4673
  recs.push(o);
4468
4674
  }
4469
4675
 
4470
- // The approval is a LEASE, not an existence fact: walk the transcript in line
4471
- // order and track whether the LATEST user decision is a grant. Each grant sets
4472
- // it true, each invalidate sets it false, neutral leaves it — so at the end
4473
- // `granted` is "the most recent user decision was to close, and nothing has
4474
- // expired it since", which is how a stale close and a queued change-of-mind
4475
- // read as NOT closed.
4676
+ // Walk the transcript in line order and track whether the LATEST event opened
4677
+ // or closed the gate — last event wins, so a stale open and a queued
4678
+ // change-of-mind both correctly read as closed.
4476
4679
  //
4477
4680
  // Branch note: line order mixes an abandoned branch's records with the live
4478
4681
  // ones. A leaf-pointer ancestry filter was tried and withdrawn — the transcript
4479
4682
  // carries no authoritative leaf pointer (measured: 0 leafUuid / summary
4480
- // records), so a heuristic leaf can skip the real invalidator and PRESERVE a
4481
- // stale grant (a fail-open, not a conservative filter). Until such a pointer
4482
- // exists, a close on a branch abandoned under a neutral tail is a known
4483
- // staleness limit, mitigated by the lease: any later live user intent, on any
4484
- // branch, still expires it.
4683
+ // records), so a heuristic leaf can skip the real closing event and PRESERVE a
4684
+ // stale open (a fail-open, not a conservative filter). Until such a pointer
4685
+ // exists, an open on a branch abandoned under a neutral tail is a known
4686
+ // staleness limit, mitigated the same way: any later live user intent, on any
4687
+ // branch, still closes it.
4485
4688
  const askIds = new Set();
4486
- // Bash tool_use ids, so a mint can be tied to a command that actually ran rather
4487
- // than to any string in the file. Filled in the same content scan as askIds.
4488
- const bashIds = new Set();
4489
- let granted = false;
4490
- // The nonces this transcript shows `proposal challenge` minting. Producer, not
4491
- // just presence: the hex has to arrive in a NON-error tool_result of a Bash
4492
- // tool_use, wrapped in the challenge's own framing. Model prose carrying a
4493
- // plausible hex, an isMeta/system body, and any other tool's output all fail
4494
- // that, which matters because a mint neutralizes the user's next message. The
4495
- // set fills as the walk goes, so a message cannot mint the nonce it spends.
4496
- const mintedNonces = new Set();
4497
- const challengeMint = new RegExp(
4498
- `must type this line in the conversation:\\s*${APPROVAL_PHRASE} ([a-f0-9]{32,})\\s*Then run: hypomnema proposal resolve`,
4499
- 'g',
4500
- );
4501
- const approvalLine = new RegExp(`^${APPROVAL_PHRASE}\\s+([a-f0-9]{32,})$`);
4502
- const collectMinted = (o) => {
4503
- const c = (o.message ?? o).content;
4504
- if (!Array.isArray(c)) return;
4505
- for (const b of c) {
4506
- if (!b || typeof b !== 'object') continue;
4507
- if (b.type !== 'tool_result' || !b.tool_use_id || !bashIds.has(b.tool_use_id)) continue;
4508
- if (b.is_error === true) continue;
4509
- const text = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
4510
- if (typeof text !== 'string' || !text.includes(APPROVAL_PHRASE)) continue;
4511
- for (const m of text.matchAll(challengeMint)) mintedNonces.add(m[1]);
4512
- }
4513
- };
4514
-
4515
- for (const o of recs) {
4689
+ // Subset of askIds whose tool_use input carries CLOSE_RECONFIRM_MARK, i.e.
4690
+ // it IS our close-reconfirm prompt, not some unrelated question the model
4691
+ // happens to ask around the same time. Same correlation guard
4692
+ // isCloseReconfirmDeclined uses (see its doc comment below), reimplemented
4693
+ // here rather than calling that function: its re-arm rule only recognizes a
4694
+ // typed close phrase, which misses the three new openers this walk already
4695
+ // tracks (a queued /compact, a human-origin queued delivery, an
4696
+ // AskUserQuestion close answer). Driving decline off this walk instead means
4697
+ // any of those already reopens the gate for free, with no separate re-arm
4698
+ // rule to keep in sync.
4699
+ const markedAskIds = new Set();
4700
+ let open = false;
4701
+ let openedAtIndex = -1;
4702
+
4703
+ for (let i = 0; i < recs.length; i++) {
4704
+ const o = recs[i];
4516
4705
  // Genuine user text of this record, or null when the record is on a channel
4517
- // the model can reach. Computed once here: the mint scan below is the exact
4518
- // complement of it (mint from everything that is NOT the user's own text).
4706
+ // the model can reach (including a command-invocation record).
4519
4707
  const userText = eventUserText(o);
4520
- if (userText == null) collectMinted(o);
4521
4708
 
4522
4709
  // Queue operations. The queue carries no correlation key (measured), so the
4523
4710
  // ENQUEUE content is the decision — not a later contentless dequeue, which
4524
4711
  // would need pairing we cannot do. Reading the enqueue also keeps the live
4525
4712
  // PreCompact gate working (it sees the enqueue) and avoids double-counting the
4526
4713
  // replay companion of an already-decided item (the /compact replay is not a
4527
- // fresh decision). popAll cancels the queue → invalidate. Delivery ops
4714
+ // fresh decision). popAll cancels the queue → close. Delivery ops
4528
4715
  // (dequeue, remove) carry no fresh decision here — a content-bearing remove of
4529
4716
  // an NL queued command is handled by its queued_command attachment below.
4530
4717
  if (o.type === 'queue-operation') {
4531
4718
  if (o.operation === 'popAll') {
4532
- granted = false;
4719
+ open = false;
4533
4720
  continue;
4534
4721
  }
4535
4722
  if (o.operation !== 'enqueue') continue;
4536
4723
  const c = typeof o.content === 'string' ? o.content.trim() : '';
4537
- if (/^\/compact(?:\s|$)/.test(c))
4538
- granted = true; // a user compaction preserves the work → grant
4539
- else if (/^\/clear(?:\s|$)/.test(c))
4540
- granted = false; // abandons context → invalidate
4541
- else if (!c || c.startsWith('<task-notification>')) {
4724
+ if (/^\/compact(?:\s|$)/.test(c)) {
4725
+ open = true; // a user compaction preserves the work → open
4726
+ openedAtIndex = i;
4727
+ } else if (/^\/clear(?:\s|$)/.test(c)) {
4728
+ open = false; // abandons context → close
4729
+ } else if (!c || c.startsWith('<task-notification>')) {
4542
4730
  /* model-caused / empty — neutral */
4543
4731
  } else if (isClosePattern(c)) {
4544
4732
  /* NL close via the queue — the open dequeue gap: the producer cannot be
4545
- attributed (a peer/model enqueue wears the same shape), so no grant */
4546
- } else granted = false; // a queued non-close user intent → invalidate (change of mind)
4733
+ attributed (a peer/model enqueue wears the same shape), so no open */
4734
+ } else open = false; // a queued non-close user intent → close (change of mind)
4547
4735
  continue;
4548
4736
  }
4549
4737
 
4550
4738
  // remove-path delivery of a queued natural-language command (measured: the
4551
4739
  // item leaves the queue as it is handed to the model, landing as an
4552
- // `attachment` of type queued_command with the prompt verbatim). A close here
4553
- // grants ONLY with an audited human producer — origin.kind "human", present
4740
+ // `attachment` of type queued_command with the prompt verbatim). This opens
4741
+ // the gate ONLY with an audited human producer — origin.kind "human", present
4554
4742
  // on every 2.1.181+ user delivery (measured). A legacy origin-absent delivery
4555
- // cannot attest a producer, so it does not grant (fail-closed). A NON-close
4556
- // queued command (e.g. "keep working") is a fresh user intent and INVALIDATES
4557
- // a prior grant regardless of origin — that is what closes the re-close hole
4558
- // where a queued "continue" after a close leaves the stale lease live.
4743
+ // cannot attest a producer, so it does not open (fail-closed). A NON-close
4744
+ // queued command (e.g. "keep working") is a fresh user intent and CLOSES
4745
+ // a prior open regardless of origin — that is what closes the re-close hole
4746
+ // where a queued "continue" after a close leaves the stale open live.
4559
4747
  if (o.type === 'attachment' && o.attachment && o.attachment.type === 'queued_command') {
4560
4748
  const prompt = typeof o.attachment.prompt === 'string' ? o.attachment.prompt : '';
4561
4749
  const humanOrigin = !!(o.attachment.origin && o.attachment.origin.kind === 'human');
4562
4750
  if (isClosePattern(prompt)) {
4563
- if (humanOrigin) granted = true;
4751
+ if (humanOrigin) {
4752
+ open = true;
4753
+ openedAtIndex = i;
4754
+ }
4564
4755
  } else if (prompt) {
4565
- granted = false;
4756
+ open = false;
4566
4757
  }
4567
4758
  continue;
4568
4759
  }
4569
4760
 
4570
4761
  // Record AskUserQuestion tool_use ids (assistant record, always precedes its
4571
- // answer in line order). Bash ids ride along in the same scan, for the mint
4572
- // correlation above.
4762
+ // answer in line order), and separately mark the ones that ARE our
4763
+ // close-reconfirm prompt (input carries CLOSE_RECONFIRM_MARK).
4573
4764
  const content = (o.message ?? o).content;
4574
4765
  if (Array.isArray(content)) {
4575
4766
  for (const b of content) {
4576
4767
  if (!b || typeof b !== 'object' || b.type !== 'tool_use' || !b.id) continue;
4577
- if (b.name === 'AskUserQuestion') askIds.add(b.id);
4578
- else if (b.name === 'Bash') bashIds.add(b.id);
4768
+ if (b.name !== 'AskUserQuestion') continue;
4769
+ askIds.add(b.id);
4770
+ if (JSON.stringify(b.input ?? null).includes(CLOSE_RECONFIRM_MARK)) markedAskIds.add(b.id);
4579
4771
  }
4580
4772
  }
4581
4773
 
4582
- // Genuine user text → grant on a close phrase, invalidate on anything else,
4583
- // minus the one carve-out for what the product itself asked the user to send.
4774
+ // Genuine typed user text. A close phrase opens the gate; the retraction
4775
+ // tripwire closes it; everything else is neutral (an
4776
+ // unrecognized phrase must not silently close a gate the user opened, so
4777
+ // there is no default "else close" branch here the way there used to be).
4584
4778
  if (userText != null && userText !== '') {
4585
- const spend = approvalLine.exec(userText.trim());
4586
- if (spend && mintedNonces.has(spend[1])) {
4587
- // The approval line `proposal challenge` tells the user to type, bound to a
4588
- // nonce this transcript minted. Neutral, never a grant: an untouched
4589
- // `granted` that was false stays false. An unminted hex is ordinary text and
4590
- // falls through to the invalidating branch below.
4591
- continue;
4779
+ if (isClosePattern(userText)) {
4780
+ open = true;
4781
+ openedAtIndex = i;
4782
+ } else if (isCloseRetractionPattern(userText)) {
4783
+ open = false;
4592
4784
  }
4593
- granted = isClosePattern(userText);
4594
4785
  continue;
4595
4786
  }
4596
4787
 
@@ -4599,7 +4790,20 @@ export function hasUserCloseSignal(transcriptPath) {
4599
4790
  // record from reaching the answer parser. is_error:false AND the host's
4600
4791
  // success marker are required because a malformed AskUserQuestion echoes the
4601
4792
  // raw input back in an is_error result, and the model authors the option
4602
- // labels. A close selection grants; any other real selection invalidates.
4793
+ // labels.
4794
+ //
4795
+ // T5: a close phrase in the answer opens the gate, same as before. But a
4796
+ // decline no longer closes it on its own: only a decline answering a
4797
+ // MARKED prompt (ours, see markedAskIds above) does. Every other answer,
4798
+ // including a non-close, non-decline click and a decline to an unmarked
4799
+ // question, is neutral. The old default (any other real selection closed
4800
+ // the gate) let a reflection question the close skill's own Step 1a asks
4801
+ // mid-procedure cancel the approval that started that procedure: the
4802
+ // model asks something adjacent, the user answers plainly, and the answer
4803
+ // was not a close phrase, so the old rule read it as a change of mind.
4804
+ // The loss this buys: an unmarked question answered with "지금 종료하지
4805
+ // 말아 줘" (a real request to keep going, just not phrased as a decline
4806
+ // to OUR prompt) now passes through neutral instead of closing the gate.
4603
4807
  if (Array.isArray(content) && !isModelReachableRecord(o)) {
4604
4808
  for (const b of content) {
4605
4809
  if (!b || typeof b !== 'object') continue;
@@ -4607,18 +4811,46 @@ export function hasUserCloseSignal(transcriptPath) {
4607
4811
  if (b.is_error === true) continue;
4608
4812
  const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
4609
4813
  if (!/have been answered/.test(s)) continue;
4610
- let sawAnswer = false;
4611
4814
  let sawClose = false;
4815
+ let sawDecline = false;
4612
4816
  for (const m of s.matchAll(/="([^"]*)"/g)) {
4613
- sawAnswer = true;
4817
+ // Independent checks, not else-if: a marked prompt's decline option
4818
+ // can itself contain close wording (the model authors the text),
4819
+ // and the decline-wins priority below only works if sawDecline gets
4820
+ // set even when the same string also matches isClosePattern.
4614
4821
  if (isClosePattern(m[1])) sawClose = true;
4822
+ if (CLOSE_RECONFIRM_DECLINE_WORDS.test(m[1])) sawDecline = true;
4823
+ }
4824
+ // A decline on a MARKED prompt wins over a close match checked on the
4825
+ // same answer. The model authors the option text, and a marked prompt
4826
+ // exists precisely to ask "close now, yes or no", so an answer that
4827
+ // reads as a decline of THAT question must not be overridable by a
4828
+ // close phrase riding along elsewhere in the same answer string. If
4829
+ // the close check ran first, the model could word a decline option to
4830
+ // also contain a close phrase and force the gate open on its own
4831
+ // rejection.
4832
+ if (sawDecline && markedAskIds.has(b.tool_use_id)) {
4833
+ open = false;
4834
+ } else if (sawClose) {
4835
+ open = true;
4836
+ openedAtIndex = i;
4615
4837
  }
4616
- if (sawClose) granted = true;
4617
- else if (sawAnswer) granted = false;
4618
4838
  }
4619
4839
  }
4620
4840
  }
4621
- return granted;
4841
+ return { open, openedAtIndex };
4842
+ }
4843
+
4844
+ /**
4845
+ * Public opening-axis predicate: does this transcript's typed/queued/answered
4846
+ * event history currently leave the close gate open? A thin boolean wrapper
4847
+ * over {@link walkCloseGate} — every caller that only needs the verdict uses
4848
+ * this. `close-gate-store.mjs`'s `closeGateStatus` imports `walkCloseGate`
4849
+ * directly instead, because it also needs `openedAtIndex` to order the open
4850
+ * against a recorded resolution.
4851
+ */
4852
+ export function isCloseGateOpen(transcriptPath) {
4853
+ return walkCloseGate(transcriptPath).open;
4622
4854
  }
4623
4855
 
4624
4856
  /** The literal the user must type to approve a parked-overwrite batch. */
@@ -4628,7 +4860,7 @@ export const APPROVAL_PHRASE = 'apply-proposals';
4628
4860
  * True iff the transcript carries a user's TYPED approval of the batch this nonce
4629
4861
  * was minted for — the authorization gate for a transcript-approved apply.
4630
4862
  *
4631
- * Deliberately NOT hasUserCloseSignal. That function answers "did the user want to
4863
+ * Deliberately NOT isCloseGateOpen. That function answers "did the user want to
4632
4864
  * end the session", and it accepts a correlated AskUserQuestion answer as evidence
4633
4865
  * (see above). Reusing it here would let a SESSION-CLOSE approval spend itself as an
4634
4866
  * OVERWRITE approval: different authority, different question. Two gates, two
@@ -4741,6 +4973,16 @@ export function hasPendingBackgroundWork(payload) {
4741
4973
  // reason silently break this correlation with no test to catch it.
4742
4974
  export const CLOSE_RECONFIRM_MARK = '지금 닫기';
4743
4975
 
4976
+ // The decline vocabulary shared by walkCloseGate's marked-reconfirm click
4977
+ // branch and isCloseReconfirmDeclined below. One constant so the two walks
4978
+ // cannot drift into recognizing different words for the same "not now"
4979
+ // answer: they stay separate WALKS on purpose (isCloseReconfirmDeclined's
4980
+ // re-arm rule only recognizes a typed close phrase, which is too narrow for
4981
+ // walkCloseGate's other openers), but the vocabulary they match against is
4982
+ // one value, not two copies that a future edit could update in only one
4983
+ // place.
4984
+ const CLOSE_RECONFIRM_DECLINE_WORDS = /(아직|나중|not\s?yet|later)/i;
4985
+
4744
4986
  /**
4745
4987
  * True iff the LATEST correlated AskUserQuestion answer in the transcript
4746
4988
  * declined an autoclose reconfirm prompt ("아직" / "나중" / "not yet" /
@@ -4749,10 +4991,11 @@ export const CLOSE_RECONFIRM_MARK = '지금 닫기';
4749
4991
  *
4750
4992
  * Read-only, forward scan over the FULL transcript (no tail truncation — a
4751
4993
  * decline can precede the next Stop by any number of turns). Reuses the same
4752
- * askIds + tool_use_id correlation `hasUserCloseSignal` (above) uses to bind
4994
+ * askIds + tool_use_id correlation `walkCloseGate` (above) uses to bind
4753
4995
  * an AskUserQuestion answer to its own tool_use, so an unrelated tool_result
4754
- * string can't forge a decline. Unlike `hasUserCloseSignal` (which is a
4755
- * "any evidence, ever" OR), this tracks a single latest-wins boolean as it
4996
+ * string can't forge a decline. Unlike `walkCloseGate` (which is already a
4997
+ * latest-event-wins walk, not an "any evidence, ever" OR), this tracks a
4998
+ * separate single latest-wins boolean as it
4756
4999
  * scans: a decline answer sets it true, and any later GENUINE USER close
4757
5000
  * signal resets it to false — the user asked to close again, so the prior
4758
5001
  * decline no longer applies.
@@ -4785,7 +5028,6 @@ export function isCloseReconfirmDeclined(transcriptPath) {
4785
5028
  } catch {
4786
5029
  return false;
4787
5030
  }
4788
- const DECLINE = /(아직|나중|not\s?yet|later)/i;
4789
5031
  const askIds = new Set();
4790
5032
  let declined = false;
4791
5033
  for (const line of lines) {
@@ -4834,7 +5076,7 @@ export function isCloseReconfirmDeclined(transcriptPath) {
4834
5076
  if (b.type === 'tool_result' && b.tool_use_id && askIds.has(b.tool_use_id)) {
4835
5077
  const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
4836
5078
  for (const m of s.matchAll(/="([^"]*)"/g)) {
4837
- if (DECLINE.test(m[1])) declined = true;
5079
+ if (CLOSE_RECONFIRM_DECLINE_WORDS.test(m[1])) declined = true;
4838
5080
  }
4839
5081
  }
4840
5082
  }