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.
- 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 +7 -7
- 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-hot-rebuild.mjs +22 -2
- package/hooks/hypo-personal-check.mjs +1 -1
- package/hooks/hypo-session-end.mjs +21 -2
- package/hooks/hypo-shared.mjs +434 -192
- package/package.json +2 -1
- package/scripts/capture.mjs +26 -20
- package/scripts/crystallize.mjs +153 -20
- package/scripts/doctor.mjs +2 -2
- package/scripts/init.mjs +34 -18
- package/scripts/lib/design-history-stale.mjs +26 -7
- package/scripts/lib/extensions.mjs +89 -6
- package/scripts/lib/git-hooks-dir.mjs +139 -2
- package/scripts/lib/slug-resolver.mjs +181 -0
- package/scripts/lint.mjs +72 -24
- package/scripts/rename.mjs +38 -141
- package/scripts/uninstall.mjs +351 -2
- package/skills/crystallize/SKILL.md +2 -2
- package/templates/hypo-config.md +1 -1
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -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
|
-
*
|
|
3285
|
-
*
|
|
3286
|
-
*
|
|
3287
|
-
*
|
|
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
|
|
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
|
-
//
|
|
3543
|
-
//
|
|
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)
|
|
3551
|
-
|
|
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
|
-
//
|
|
3766
|
-
//
|
|
3767
|
-
//
|
|
3768
|
-
//
|
|
3769
|
-
//
|
|
3770
|
-
//
|
|
3771
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
4322
|
-
*
|
|
4323
|
-
*
|
|
4324
|
-
*
|
|
4325
|
-
*
|
|
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
|
-
* •
|
|
4329
|
-
*
|
|
4330
|
-
*
|
|
4331
|
-
*
|
|
4332
|
-
*
|
|
4333
|
-
* •
|
|
4334
|
-
*
|
|
4335
|
-
*
|
|
4336
|
-
*
|
|
4337
|
-
*
|
|
4338
|
-
*
|
|
4339
|
-
*
|
|
4340
|
-
*
|
|
4341
|
-
*
|
|
4342
|
-
* •
|
|
4343
|
-
*
|
|
4344
|
-
*
|
|
4345
|
-
*
|
|
4346
|
-
*
|
|
4347
|
-
*
|
|
4348
|
-
*
|
|
4349
|
-
*
|
|
4350
|
-
*
|
|
4351
|
-
*
|
|
4352
|
-
*
|
|
4353
|
-
*
|
|
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
|
-
*
|
|
4360
|
-
*
|
|
4361
|
-
*
|
|
4362
|
-
*
|
|
4363
|
-
*
|
|
4364
|
-
*
|
|
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
|
-
*
|
|
4369
|
-
*
|
|
4370
|
-
*
|
|
4371
|
-
*
|
|
4372
|
-
*
|
|
4373
|
-
*
|
|
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
|
|
4377
|
-
* that forges records is out of scope and documented so.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
4471
|
-
//
|
|
4472
|
-
//
|
|
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
|
|
4481
|
-
// stale
|
|
4482
|
-
// exists,
|
|
4483
|
-
// staleness limit, mitigated
|
|
4484
|
-
// 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.
|
|
4485
4688
|
const askIds = new Set();
|
|
4486
|
-
//
|
|
4487
|
-
//
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
//
|
|
4491
|
-
//
|
|
4492
|
-
//
|
|
4493
|
-
//
|
|
4494
|
-
//
|
|
4495
|
-
//
|
|
4496
|
-
const
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
)
|
|
4501
|
-
|
|
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
|
|
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 →
|
|
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
|
-
|
|
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
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
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
|
|
4546
|
-
} 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)
|
|
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).
|
|
4553
|
-
//
|
|
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
|
|
4556
|
-
// queued command (e.g. "keep working") is a fresh user intent and
|
|
4557
|
-
// a prior
|
|
4558
|
-
// 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.
|
|
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)
|
|
4751
|
+
if (humanOrigin) {
|
|
4752
|
+
open = true;
|
|
4753
|
+
openedAtIndex = i;
|
|
4754
|
+
}
|
|
4564
4755
|
} else if (prompt) {
|
|
4565
|
-
|
|
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)
|
|
4572
|
-
//
|
|
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
|
|
4578
|
-
|
|
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
|
|
4583
|
-
//
|
|
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
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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 `
|
|
4755
|
-
* "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
|
|
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 (
|
|
5079
|
+
if (CLOSE_RECONFIRM_DECLINE_WORDS.test(m[1])) declined = true;
|
|
4838
5080
|
}
|
|
4839
5081
|
}
|
|
4840
5082
|
}
|