hypomnema 1.7.3 → 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.
@@ -4306,9 +4306,41 @@ export function isClosePattern(text) {
4306
4306
  return [...krPatterns, ...enPatterns].some((re) => re.test(text));
4307
4307
  }
4308
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
+
4309
4341
  // ── close ARTIFACTS, not the marker (issue: close gate lives on one writer) ──
4310
4342
  //
4311
- // hasUserCloseSignal/isClosePattern answer "did the user ask to close" — the
4343
+ // isCloseGateOpen/isClosePattern answer "did the user ask to close" — the
4312
4344
  // input side of the gate. This answers a different question: "does this file
4313
4345
  // or commit message already READ as closed to a human", independent of
4314
4346
  // whether any marker writer ever ran. The 2026-07-28 incident produced a
@@ -4464,77 +4496,69 @@ export function resolveTranscriptBySessionId(
4464
4496
  }
4465
4497
 
4466
4498
  /**
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.
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.
4472
4504
  *
4473
4505
  * 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.
4490
- *
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.
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.
4496
4532
  *
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.
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.
4504
4539
  *
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.
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.
4520
4549
  *
4521
4550
  * 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.
4551
+ * transcript. This is a safety interlock, not an authorization boundary — a
4552
+ * model that forges records is out of scope and documented so.
4529
4553
  *
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.
4554
+ * Fail-closed: any read/parse error → not open.
4536
4555
  *
4537
- * 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.
4538
4562
  */
4539
4563
  // Genuine user text of a record, or null when the record is on a channel the
4540
4564
  // honest-but-drifting model can reach (so it is never user intent — NEUTRAL).
@@ -4549,10 +4573,20 @@ export function resolveTranscriptBySessionId(
4549
4573
  // companion of a delivered /compact)
4550
4574
  // • Stop-hook feedback the hook's own close nudge — circular
4551
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.
4552
4585
  // No promptSource allowlist is required: requiring `typed` would drop the
4553
4586
  // legacy absent-promptSource close the older gate has always honoured, while the
4554
4587
  // dangerous replay/injection paths carry system|sdk|isMeta|isSidechain and are
4555
4588
  // excluded here anyway.
4589
+ const COMMAND_INVOCATION_TAG = /<command-(?:name|message|args)>/;
4556
4590
  function eventUserText(obj) {
4557
4591
  if (obj.isMeta === true) return null;
4558
4592
  if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return null;
@@ -4562,16 +4596,42 @@ function eventUserText(obj) {
4562
4596
  const role = msg.role ?? obj.role ?? obj.type;
4563
4597
  if (role !== 'user') return null;
4564
4598
  const content = msg.content ?? obj.content;
4599
+ let text = null;
4565
4600
  if (typeof content === 'string') {
4566
- return content.startsWith('Stop hook feedback') ? null : content;
4567
- }
4568
- if (Array.isArray(content)) {
4601
+ text = content.startsWith('Stop hook feedback') ? null : content;
4602
+ } else if (Array.isArray(content)) {
4569
4603
  const texts = content
4570
4604
  .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
4571
4605
  .map((b) => b.text);
4572
- 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;
4573
4632
  }
4574
- return null;
4633
+ if (text != null && COMMAND_INVOCATION_TAG.test(text)) return null;
4634
+ return text;
4575
4635
  }
4576
4636
 
4577
4637
  // A record on a channel the honest-but-drifting model can reach, so it can never
@@ -4586,17 +4646,17 @@ function isModelReachableRecord(obj) {
4586
4646
  );
4587
4647
  }
4588
4648
 
4589
- export function hasUserCloseSignal(transcriptPath) {
4590
- if (!transcriptPath) return false;
4649
+ export function walkCloseGate(transcriptPath) {
4650
+ if (!transcriptPath) return { open: false, openedAtIndex: -1 };
4591
4651
  let raw;
4592
4652
  try {
4593
4653
  raw = readFileSync(transcriptPath, 'utf-8');
4594
4654
  } catch {
4595
- return false;
4655
+ return { open: false, openedAtIndex: -1 };
4596
4656
  }
4597
4657
  // FATAL: a non-empty line that will not parse means the transcript is being
4598
4658
  // 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
4659
+ // stale prior open survive past an event we cannot read, so refuse. A line
4600
4660
  // that parses to a non-object (a bare null / string / number) is valid JSON but
4601
4661
  // not a record — noise, not corruption — so it is skipped, not fatal, and never
4602
4662
  // reaches the field reads below.
@@ -4607,136 +4667,121 @@ export function hasUserCloseSignal(transcriptPath) {
4607
4667
  try {
4608
4668
  o = JSON.parse(line);
4609
4669
  } catch {
4610
- return false;
4670
+ return { open: false, openedAtIndex: -1 };
4611
4671
  }
4612
4672
  if (o === null || typeof o !== 'object') continue;
4613
4673
  recs.push(o);
4614
4674
  }
4615
4675
 
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.
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.
4622
4679
  //
4623
4680
  // Branch note: line order mixes an abandoned branch's records with the live
4624
4681
  // ones. A leaf-pointer ancestry filter was tried and withdrawn — the transcript
4625
4682
  // 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.
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.
4631
4688
  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) {
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];
4662
4705
  // 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).
4706
+ // the model can reach (including a command-invocation record).
4665
4707
  const userText = eventUserText(o);
4666
- if (userText == null) collectMinted(o);
4667
4708
 
4668
4709
  // Queue operations. The queue carries no correlation key (measured), so the
4669
4710
  // ENQUEUE content is the decision — not a later contentless dequeue, which
4670
4711
  // would need pairing we cannot do. Reading the enqueue also keeps the live
4671
4712
  // PreCompact gate working (it sees the enqueue) and avoids double-counting the
4672
4713
  // replay companion of an already-decided item (the /compact replay is not a
4673
- // fresh decision). popAll cancels the queue → invalidate. Delivery ops
4714
+ // fresh decision). popAll cancels the queue → close. Delivery ops
4674
4715
  // (dequeue, remove) carry no fresh decision here — a content-bearing remove of
4675
4716
  // an NL queued command is handled by its queued_command attachment below.
4676
4717
  if (o.type === 'queue-operation') {
4677
4718
  if (o.operation === 'popAll') {
4678
- granted = false;
4719
+ open = false;
4679
4720
  continue;
4680
4721
  }
4681
4722
  if (o.operation !== 'enqueue') continue;
4682
4723
  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>')) {
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>')) {
4688
4730
  /* model-caused / empty — neutral */
4689
4731
  } else if (isClosePattern(c)) {
4690
4732
  /* 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)
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)
4693
4735
  continue;
4694
4736
  }
4695
4737
 
4696
4738
  // remove-path delivery of a queued natural-language command (measured: the
4697
4739
  // 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
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
4700
4742
  // 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.
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.
4705
4747
  if (o.type === 'attachment' && o.attachment && o.attachment.type === 'queued_command') {
4706
4748
  const prompt = typeof o.attachment.prompt === 'string' ? o.attachment.prompt : '';
4707
4749
  const humanOrigin = !!(o.attachment.origin && o.attachment.origin.kind === 'human');
4708
4750
  if (isClosePattern(prompt)) {
4709
- if (humanOrigin) granted = true;
4751
+ if (humanOrigin) {
4752
+ open = true;
4753
+ openedAtIndex = i;
4754
+ }
4710
4755
  } else if (prompt) {
4711
- granted = false;
4756
+ open = false;
4712
4757
  }
4713
4758
  continue;
4714
4759
  }
4715
4760
 
4716
4761
  // 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.
4762
+ // answer in line order), and separately mark the ones that ARE our
4763
+ // close-reconfirm prompt (input carries CLOSE_RECONFIRM_MARK).
4719
4764
  const content = (o.message ?? o).content;
4720
4765
  if (Array.isArray(content)) {
4721
4766
  for (const b of content) {
4722
4767
  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);
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);
4725
4771
  }
4726
4772
  }
4727
4773
 
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.
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).
4730
4778
  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;
4779
+ if (isClosePattern(userText)) {
4780
+ open = true;
4781
+ openedAtIndex = i;
4782
+ } else if (isCloseRetractionPattern(userText)) {
4783
+ open = false;
4738
4784
  }
4739
- granted = isClosePattern(userText);
4740
4785
  continue;
4741
4786
  }
4742
4787
 
@@ -4745,7 +4790,20 @@ export function hasUserCloseSignal(transcriptPath) {
4745
4790
  // record from reaching the answer parser. is_error:false AND the host's
4746
4791
  // success marker are required because a malformed AskUserQuestion echoes the
4747
4792
  // 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.
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.
4749
4807
  if (Array.isArray(content) && !isModelReachableRecord(o)) {
4750
4808
  for (const b of content) {
4751
4809
  if (!b || typeof b !== 'object') continue;
@@ -4753,18 +4811,46 @@ export function hasUserCloseSignal(transcriptPath) {
4753
4811
  if (b.is_error === true) continue;
4754
4812
  const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
4755
4813
  if (!/have been answered/.test(s)) continue;
4756
- let sawAnswer = false;
4757
4814
  let sawClose = false;
4815
+ let sawDecline = false;
4758
4816
  for (const m of s.matchAll(/="([^"]*)"/g)) {
4759
- 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.
4760
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;
4761
4837
  }
4762
- if (sawClose) granted = true;
4763
- else if (sawAnswer) granted = false;
4764
4838
  }
4765
4839
  }
4766
4840
  }
4767
- 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;
4768
4854
  }
4769
4855
 
4770
4856
  /** The literal the user must type to approve a parked-overwrite batch. */
@@ -4774,7 +4860,7 @@ export const APPROVAL_PHRASE = 'apply-proposals';
4774
4860
  * True iff the transcript carries a user's TYPED approval of the batch this nonce
4775
4861
  * was minted for — the authorization gate for a transcript-approved apply.
4776
4862
  *
4777
- * Deliberately NOT hasUserCloseSignal. That function answers "did the user want to
4863
+ * Deliberately NOT isCloseGateOpen. That function answers "did the user want to
4778
4864
  * end the session", and it accepts a correlated AskUserQuestion answer as evidence
4779
4865
  * (see above). Reusing it here would let a SESSION-CLOSE approval spend itself as an
4780
4866
  * OVERWRITE approval: different authority, different question. Two gates, two
@@ -4887,6 +4973,16 @@ export function hasPendingBackgroundWork(payload) {
4887
4973
  // reason silently break this correlation with no test to catch it.
4888
4974
  export const CLOSE_RECONFIRM_MARK = '지금 닫기';
4889
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
+
4890
4986
  /**
4891
4987
  * True iff the LATEST correlated AskUserQuestion answer in the transcript
4892
4988
  * declined an autoclose reconfirm prompt ("아직" / "나중" / "not yet" /
@@ -4895,10 +4991,11 @@ export const CLOSE_RECONFIRM_MARK = '지금 닫기';
4895
4991
  *
4896
4992
  * Read-only, forward scan over the FULL transcript (no tail truncation — a
4897
4993
  * 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
4994
+ * askIds + tool_use_id correlation `walkCloseGate` (above) uses to bind
4899
4995
  * 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
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
4902
4999
  * scans: a decline answer sets it true, and any later GENUINE USER close
4903
5000
  * signal resets it to false — the user asked to close again, so the prior
4904
5001
  * decline no longer applies.
@@ -4931,7 +5028,6 @@ export function isCloseReconfirmDeclined(transcriptPath) {
4931
5028
  } catch {
4932
5029
  return false;
4933
5030
  }
4934
- const DECLINE = /(아직|나중|not\s?yet|later)/i;
4935
5031
  const askIds = new Set();
4936
5032
  let declined = false;
4937
5033
  for (const line of lines) {
@@ -4980,7 +5076,7 @@ export function isCloseReconfirmDeclined(transcriptPath) {
4980
5076
  if (b.type === 'tool_result' && b.tool_use_id && askIds.has(b.tool_use_id)) {
4981
5077
  const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
4982
5078
  for (const m of s.matchAll(/="([^"]*)"/g)) {
4983
- if (DECLINE.test(m[1])) declined = true;
5079
+ if (CLOSE_RECONFIRM_DECLINE_WORDS.test(m[1])) declined = true;
4984
5080
  }
4985
5081
  }
4986
5082
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.7.3",
3
+ "version": "1.7.4",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,6 +43,7 @@
43
43
  "scripts/lib/project-create.mjs",
44
44
  "scripts/lib/rename-marker.mjs",
45
45
  "scripts/lib/schema-vocab.mjs",
46
+ "scripts/lib/slug-resolver.mjs",
46
47
  "scripts/lib/template-schema-version.mjs",
47
48
  "scripts/lib/wd-match.mjs",
48
49
  "scripts/lib/wikilink.mjs",