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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/commands/capture.md +1 -1
- package/commands/crystallize.md +2 -2
- package/commands/uninstall.md +16 -4
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONTRIBUTING.md +13 -4
- package/hooks/close-gate-store.mjs +435 -0
- package/hooks/hooks.json +2 -1
- package/hooks/hypo-close-guard.mjs +24 -4
- package/hooks/hypo-personal-check.mjs +1 -1
- package/hooks/hypo-session-end.mjs +21 -2
- package/hooks/hypo-shared.mjs +257 -161
- package/package.json +2 -1
- package/scripts/capture.mjs +15 -4
- package/scripts/crystallize.mjs +56 -8
- package/scripts/init.mjs +26 -11
- package/scripts/lib/design-history-stale.mjs +26 -7
- package/scripts/lib/extensions.mjs +60 -16
- package/scripts/lib/git-hooks-dir.mjs +129 -2
- package/scripts/lib/slug-resolver.mjs +181 -0
- package/scripts/lint.mjs +27 -23
- package/scripts/rename.mjs +38 -141
- package/scripts/uninstall.mjs +155 -58
- package/skills/crystallize/SKILL.md +2 -2
- package/templates/hypo-config.md +1 -1
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
*
|
|
4468
|
-
*
|
|
4469
|
-
*
|
|
4470
|
-
*
|
|
4471
|
-
*
|
|
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
|
-
* •
|
|
4475
|
-
*
|
|
4476
|
-
*
|
|
4477
|
-
*
|
|
4478
|
-
*
|
|
4479
|
-
* •
|
|
4480
|
-
*
|
|
4481
|
-
*
|
|
4482
|
-
*
|
|
4483
|
-
*
|
|
4484
|
-
*
|
|
4485
|
-
*
|
|
4486
|
-
*
|
|
4487
|
-
*
|
|
4488
|
-
* •
|
|
4489
|
-
*
|
|
4490
|
-
*
|
|
4491
|
-
*
|
|
4492
|
-
*
|
|
4493
|
-
*
|
|
4494
|
-
*
|
|
4495
|
-
*
|
|
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
|
-
*
|
|
4498
|
-
*
|
|
4499
|
-
*
|
|
4500
|
-
*
|
|
4501
|
-
*
|
|
4502
|
-
*
|
|
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
|
-
*
|
|
4506
|
-
*
|
|
4507
|
-
*
|
|
4508
|
-
*
|
|
4509
|
-
*
|
|
4510
|
-
*
|
|
4511
|
-
* the
|
|
4512
|
-
*
|
|
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
|
|
4523
|
-
* that forges records is out of scope and documented so.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
4617
|
-
//
|
|
4618
|
-
//
|
|
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
|
|
4627
|
-
// stale
|
|
4628
|
-
// exists,
|
|
4629
|
-
// staleness limit, mitigated
|
|
4630
|
-
// branch, still
|
|
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
|
-
//
|
|
4633
|
-
//
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
//
|
|
4637
|
-
//
|
|
4638
|
-
//
|
|
4639
|
-
//
|
|
4640
|
-
//
|
|
4641
|
-
//
|
|
4642
|
-
const
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
)
|
|
4647
|
-
|
|
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
|
|
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 →
|
|
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
|
-
|
|
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
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
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
|
|
4692
|
-
} else
|
|
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).
|
|
4699
|
-
//
|
|
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
|
|
4702
|
-
// queued command (e.g. "keep working") is a fresh user intent and
|
|
4703
|
-
// a prior
|
|
4704
|
-
// where a queued "continue" after a close leaves the stale
|
|
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)
|
|
4751
|
+
if (humanOrigin) {
|
|
4752
|
+
open = true;
|
|
4753
|
+
openedAtIndex = i;
|
|
4754
|
+
}
|
|
4710
4755
|
} else if (prompt) {
|
|
4711
|
-
|
|
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)
|
|
4718
|
-
//
|
|
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
|
|
4724
|
-
|
|
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
|
|
4729
|
-
//
|
|
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
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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 `
|
|
4901
|
-
* "any evidence, ever" OR), this tracks a
|
|
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 (
|
|
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
|
+
"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",
|