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