hypomnema 1.8.0 → 1.8.2
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 +46 -0
- package/README.ko.md +8 -6
- package/README.md +8 -6
- package/commands/crystallize.md +32 -6
- package/commands/graph.md +7 -4
- package/commands/lint.md +8 -1
- package/commands/query.md +6 -4
- package/commands/resume.md +1 -0
- package/commands/verify.md +12 -1
- package/docs/ARCHITECTURE.md +26 -12
- package/docs/CONTRIBUTING.md +15 -6
- package/hooks/hypo-compact-guard.mjs +126 -23
- package/hooks/hypo-cwd-change.mjs +20 -19
- package/hooks/hypo-first-prompt.mjs +31 -16
- package/hooks/hypo-lookup.mjs +10 -5
- package/hooks/hypo-session-start.mjs +180 -2
- package/hooks/hypo-shared.mjs +410 -83
- package/hooks/hypo-web-fetch-ingest.mjs +9 -13
- package/package.json +2 -1
- package/scripts/doctor.mjs +32 -3
- package/scripts/graph.mjs +22 -2
- package/scripts/init.mjs +5 -1
- package/scripts/lib/crystallize-args.mjs +38 -2
- package/scripts/lib/crystallize-close-apply.mjs +745 -450
- package/scripts/lint.mjs +242 -39
- package/scripts/query.mjs +22 -2
- package/scripts/resume.mjs +177 -2
- package/scripts/upgrade.mjs +2 -2
- package/scripts/verify.mjs +22 -2
- package/templates/SCHEMA.md +23 -1
- package/templates/hypo-automation.md +4 -2
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +1 -1
- package/skills/crystallize/SKILL.md +0 -189
- package/skills/graph/SKILL.md +0 -58
- package/skills/ingest/SKILL.md +0 -107
- package/skills/lint/SKILL.md +0 -59
- package/skills/query/SKILL.md +0 -62
- package/skills/verify/SKILL.md +0 -96
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -396,8 +396,23 @@ export function isGateSkipped() {
|
|
|
396
396
|
// ── state checkers ─────────────────────────────────────────────────────────
|
|
397
397
|
|
|
398
398
|
export function lastSubstantialOpIsSession() {
|
|
399
|
-
|
|
400
|
-
|
|
399
|
+
// Single read, no existsSync precheck: a check-then-read pair leaves a race
|
|
400
|
+
// window where the file exists at the check and is gone by the read, and
|
|
401
|
+
// the old code treated that ENOENT the same as a stably-missing file
|
|
402
|
+
// (fail-open, `true`). Only a genuinely absent log.md (ENOENT) folds to
|
|
403
|
+
// `false` here; any other read failure (EISDIR, EACCES, ...) is a real
|
|
404
|
+
// problem the caller needs to see, not a silent "no session", so it is
|
|
405
|
+
// rethrown. hypo-compact-guard.mjs is the only in-repo caller (verified via
|
|
406
|
+
// grep) and its own try/catch already turns any throw here into
|
|
407
|
+
// fail-closed ("session log entry missing") plus a stderr line, so this
|
|
408
|
+
// fail-open-to-fail-closed flip needed no other caller to be re-audited.
|
|
409
|
+
let log;
|
|
410
|
+
try {
|
|
411
|
+
log = readFileSync(LOG_PATH, 'utf-8');
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (err && err.code === 'ENOENT') return false;
|
|
414
|
+
throw err;
|
|
415
|
+
}
|
|
401
416
|
const substantial = log
|
|
402
417
|
.split('\n')
|
|
403
418
|
.filter((l) => /^## \[\d{4}-\d{2}-\d{2}\] (session|ingest)/.test(l));
|
|
@@ -414,12 +429,61 @@ export function lastSubstantialOpIsSession() {
|
|
|
414
429
|
// read it. Callers that gate session-close / compact distinguish the two: they
|
|
415
430
|
// block on `uncommitted` and demote `ahead` to a notice (precompactGateStatus,
|
|
416
431
|
// hypo-compact-guard) so a committed-but-unpushed close is still "compact-ready".
|
|
417
|
-
|
|
432
|
+
// A deadline shared across every git spawn one "am I clean" check can make:
|
|
433
|
+
// `{ end: <performance.now()-based timestamp> }`, built once by a caller that
|
|
434
|
+
// needs to bound hypoIsClean and gitDirtyFiles to a slice of its own hook
|
|
435
|
+
// timeout (session-close-scope-boundary spec §5). remainingSpawnTimeoutMs
|
|
436
|
+
// turns "time left" into what spawnSync's own `timeout` option actually
|
|
437
|
+
// requires — verified on Node 26: `timeout: 0` DISABLES the timeout (waits
|
|
438
|
+
// forever) rather than firing immediately, and a negative or fractional value
|
|
439
|
+
// THROWS ERR_OUT_OF_RANGE. A throw here would escape as an exception through
|
|
440
|
+
// a hook's outermost catch and come back silent, so a caller must never pass
|
|
441
|
+
// a value this function did not produce, and must never spawn once it
|
|
442
|
+
// returns 0.
|
|
443
|
+
// A deadline built from a bad `end` (Infinity, NaN, or already past) must
|
|
444
|
+
// also fold to 0, not just a plain negative one: a caller-supplied
|
|
445
|
+
// `{ end: Infinity }` produces `remaining = Infinity`, and `Infinity` is
|
|
446
|
+
// exactly as unsafe a `timeout` value as a negative or fractional one, same
|
|
447
|
+
// `ERR_OUT_OF_RANGE` throw, just via a different guard. `Number.isFinite`
|
|
448
|
+
// alone is not enough, though: it also passes `Number.MAX_VALUE`, which is
|
|
449
|
+
// finite but still outside spawnSync's documented `timeout` range (0 through
|
|
450
|
+
// `Number.MAX_SAFE_INTEGER`) and throws the same `ERR_OUT_OF_RANGE` (verified
|
|
451
|
+
// directly: `gitDirtyFiles(cwd, { deadline: { end: Number.MAX_VALUE } })`
|
|
452
|
+
// threw before this guard, since MAX_SAFE_INTEGER is exactly spawnSync's
|
|
453
|
+
// upper bound, `Number.isSafeInteger` is the one predicate that matches it.
|
|
454
|
+
// undefined — no deadline given: omit `timeout` entirely (today's behavior)
|
|
455
|
+
// 0 — budget exhausted, or the deadline itself was not a safe
|
|
456
|
+
// positive integer: do not spawn
|
|
457
|
+
// >0 — a whole positive integer number of ms safe to pass as `timeout`
|
|
458
|
+
function remainingSpawnTimeoutMs(deadline) {
|
|
459
|
+
if (!deadline) return undefined;
|
|
460
|
+
const remaining = Math.floor(deadline.end - performance.now());
|
|
461
|
+
return Number.isSafeInteger(remaining) && remaining > 0 ? remaining : 0;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// `opts.deadline` is optional (session-close-scope-boundary spec §5): when
|
|
465
|
+
// omitted, both spawns run exactly as before, no timeout, no status/error
|
|
466
|
+
// check on the second one. hypo-compact-guard.mjs is the only caller that
|
|
467
|
+
// passes one; precompactGateStatus (`:3872`) and all six direct test call
|
|
468
|
+
// sites still pass a bare `dir` and must see byte-identical behavior.
|
|
469
|
+
export function hypoIsClean(dir = HYPO_DIR, opts = {}) {
|
|
470
|
+
const { deadline } = opts;
|
|
418
471
|
try {
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
472
|
+
const t1 = remainingSpawnTimeoutMs(deadline);
|
|
473
|
+
if (t1 === 0) {
|
|
474
|
+
return {
|
|
475
|
+
clean: false,
|
|
476
|
+
uncommitted: true,
|
|
477
|
+
ahead: false,
|
|
478
|
+
reason: `git check deadline exhausted in ${dir}`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const porcelain = spawnSync(
|
|
482
|
+
'git',
|
|
483
|
+
['-C', dir, 'status', '--porcelain'],
|
|
484
|
+
t1 === undefined ? { encoding: 'utf-8' } : { encoding: 'utf-8', timeout: t1 },
|
|
485
|
+
);
|
|
486
|
+
if (porcelain.error || porcelain.status !== 0)
|
|
423
487
|
return {
|
|
424
488
|
clean: false,
|
|
425
489
|
uncommitted: true,
|
|
@@ -427,9 +491,38 @@ export function hypoIsClean(dir = HYPO_DIR) {
|
|
|
427
491
|
reason: `git check failed in ${dir}`,
|
|
428
492
|
};
|
|
429
493
|
const uncommitted = porcelain.stdout.trim() !== '';
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
494
|
+
|
|
495
|
+
const t2 = remainingSpawnTimeoutMs(deadline);
|
|
496
|
+
if (t2 === 0) {
|
|
497
|
+
return {
|
|
498
|
+
clean: false,
|
|
499
|
+
uncommitted: true,
|
|
500
|
+
ahead: false,
|
|
501
|
+
reason: `git check deadline exhausted in ${dir}`,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
const aheadRes = spawnSync(
|
|
505
|
+
'git',
|
|
506
|
+
['-C', dir, 'status', '--branch', '--porcelain'],
|
|
507
|
+
t2 === undefined ? { encoding: 'utf-8' } : { encoding: 'utf-8', timeout: t2 },
|
|
508
|
+
);
|
|
509
|
+
// Only enforced when a deadline is in play. Without one this spawn cannot
|
|
510
|
+
// time out, and its `stdout` (empty on any other kind of failure too) was
|
|
511
|
+
// already read as "not ahead" before this change — harmless when nothing
|
|
512
|
+
// can kill the process out from under it. WITH a deadline this spawn CAN
|
|
513
|
+
// now die from the same budget the first spawn already spent, and a
|
|
514
|
+
// silent "not ahead" would make the caller's own notification vanish
|
|
515
|
+
// (spec §5's own regression). The first spawn already fails closed this
|
|
516
|
+
// way (`porcelain.status !== 0` above); this brings the second spawn to
|
|
517
|
+
// the same contract, but only where a deadline made it possible to break.
|
|
518
|
+
if (deadline && (aheadRes.error || aheadRes.status !== 0)) {
|
|
519
|
+
return {
|
|
520
|
+
clean: false,
|
|
521
|
+
uncommitted: true,
|
|
522
|
+
ahead: false,
|
|
523
|
+
reason: `git check failed in ${dir}`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
433
526
|
const ahead = /\[ahead \d+\]/.test(aheadRes.stdout || '');
|
|
434
527
|
const reasons = [];
|
|
435
528
|
if (uncommitted) reasons.push(`uncommitted changes in ${dir}`);
|
|
@@ -473,18 +566,33 @@ export function hypoIsClean(dir = HYPO_DIR) {
|
|
|
473
566
|
* failure (the caller already has its own git-status result via
|
|
474
567
|
* hypoIsClean and treats that failure as an unconditional blocker; an
|
|
475
568
|
* empty return here just means "cannot attribute", not "clean").
|
|
569
|
+
*
|
|
570
|
+
* `opts.deadline` (session-close-scope-boundary spec §5): the SAME shared
|
|
571
|
+
* deadline object passed to hypoIsClean, so the two functions' spawns split
|
|
572
|
+
* one budget instead of each getting their own (which would let the pair
|
|
573
|
+
* together run twice as long as intended). Omitted, both spawns run exactly
|
|
574
|
+
* as before — precompactGateStatus's own call (`:3874`) does not pass one.
|
|
476
575
|
*/
|
|
477
|
-
function gitDirtyFiles(dir) {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
576
|
+
export function gitDirtyFiles(dir = HYPO_DIR, opts = {}) {
|
|
577
|
+
const { deadline } = opts;
|
|
578
|
+
const t1 = remainingSpawnTimeoutMs(deadline);
|
|
579
|
+
if (t1 === 0) return []; // budget exhausted before the first spawn → cannot attribute
|
|
580
|
+
const prefixRes = spawnSync(
|
|
581
|
+
'git',
|
|
582
|
+
['-C', dir, 'rev-parse', '--show-prefix'],
|
|
583
|
+
t1 === undefined ? { encoding: 'utf-8' } : { encoding: 'utf-8', timeout: t1 },
|
|
584
|
+
);
|
|
585
|
+
if (prefixRes.error || prefixRes.status !== 0) return []; // can't resolve the repo → cannot attribute
|
|
482
586
|
const prefix = (prefixRes.stdout || '').trim();
|
|
483
587
|
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
588
|
+
const t2 = remainingSpawnTimeoutMs(deadline);
|
|
589
|
+
if (t2 === 0) return [];
|
|
590
|
+
const porcelain = spawnSync(
|
|
591
|
+
'git',
|
|
592
|
+
['-C', dir, 'status', '--porcelain', '-uall', '-z'],
|
|
593
|
+
t2 === undefined ? { encoding: 'utf-8' } : { encoding: 'utf-8', timeout: t2 },
|
|
594
|
+
);
|
|
595
|
+
if (porcelain.error || porcelain.status !== 0) return [];
|
|
488
596
|
const out = [];
|
|
489
597
|
const records = (porcelain.stdout || '').split('\0');
|
|
490
598
|
const toDirRelative = (f) => {
|
|
@@ -511,6 +619,74 @@ function gitDirtyFiles(dir) {
|
|
|
511
619
|
return out;
|
|
512
620
|
}
|
|
513
621
|
|
|
622
|
+
// session-close-scope-boundary spec §2b/§5: the ONE path-prefix rule that
|
|
623
|
+
// decides a dirty file structurally belongs to a DIFFERENT eligible project
|
|
624
|
+
// than the one this call is scoped to. Extracted from precompactGateStatus's
|
|
625
|
+
// former inline closure so hypo-compact-guard.mjs (spec §5) can call the
|
|
626
|
+
// exact same predicate instead of duplicating it — a closure over the gate's
|
|
627
|
+
// local variables cannot be called, or asserted against, from anywhere else.
|
|
628
|
+
//
|
|
629
|
+
// Lexical, on the RAW git-porcelain path `f`, BEFORE posixPath()'s
|
|
630
|
+
// unconditional `\` -> `/` conversion: a file whose actual NAME contains a
|
|
631
|
+
// literal backslash (`projects\other\x.md`, one path segment, no real
|
|
632
|
+
// subdirectory) must not be reinterpreted as living under `projects/other/`
|
|
633
|
+
// just because posixPath() would rewrite it that way — testing the raw
|
|
634
|
+
// string means it never matches the regex below and falls through to
|
|
635
|
+
// "not foreign", the fail-closed default. `transcriptTouched` IS keyed by
|
|
636
|
+
// posix paths (it comes from extractTouchedWikiFiles), so that one check
|
|
637
|
+
// still normalizes `f` before the lookup.
|
|
638
|
+
//
|
|
639
|
+
// @param {string} f - a raw dirty path from gitDirtyFiles/git porcelain
|
|
640
|
+
// @param {{eligibleSlugs: Set<string>|null, effectiveOverride: string|null,
|
|
641
|
+
// transcriptTouched?: Set<string>}} ctx
|
|
642
|
+
// @returns {boolean}
|
|
643
|
+
export function isForeignProjectFile(
|
|
644
|
+
f,
|
|
645
|
+
{ eligibleSlugs, effectiveOverride, transcriptTouched = new Set() },
|
|
646
|
+
) {
|
|
647
|
+
// Transcript evidence outranks the path-prefix heuristic below: it PROVES
|
|
648
|
+
// this session edited f, whatever its path prefix says. The heuristic only
|
|
649
|
+
// exists to cover files an untrusted (or absent) transcript could not
|
|
650
|
+
// vouch for either way.
|
|
651
|
+
if (transcriptTouched.has(posixPath(f))) return false;
|
|
652
|
+
if (!eligibleSlugs) return false;
|
|
653
|
+
const m = /^projects\/([^/]+)\/.+$/.exec(f);
|
|
654
|
+
if (!m) return false;
|
|
655
|
+
const slug = m[1];
|
|
656
|
+
return slug !== effectiveOverride && eligibleSlugs.has(slug);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// session-close-scope-boundary spec §5: hypo-compact-guard.mjs only needs a
|
|
660
|
+
// yes/no on "may I drop my git notice line", never precompactGateStatus's
|
|
661
|
+
// full per-file partition, and the answer must never escape as a throw — this
|
|
662
|
+
// hook's outermost catch turns ANY exception into a fully suppressed
|
|
663
|
+
// {suppressOutput:true}, so a failure inside collectProjectWorkingDirs (a
|
|
664
|
+
// readdirSync it does not itself catch) must come back as data instead.
|
|
665
|
+
//
|
|
666
|
+
// Returns one of two sentinels, never throws:
|
|
667
|
+
// 'foreign-only' — dirty is non-empty, at least one eligible project is
|
|
668
|
+
// known, and EVERY dirty file is provably someone
|
|
669
|
+
// else's under isForeignProjectFile.
|
|
670
|
+
// 'unattributable' — anything short of that full proof (no dirty files, no
|
|
671
|
+
// eligible project known, project enumeration failed,
|
|
672
|
+
// or a mix of foreign and non-foreign files). The
|
|
673
|
+
// caller must treat this exactly like today's unscoped
|
|
674
|
+
// git blocker/notice — this is the fail-closed default.
|
|
675
|
+
export function classifyForeignOnlyDirty(hypoDir, dirty, { effectiveOverride = null } = {}) {
|
|
676
|
+
if (!dirty || dirty.length === 0) return 'unattributable'; // vacuous every() must not pass
|
|
677
|
+
let eligibleSlugs;
|
|
678
|
+
try {
|
|
679
|
+
eligibleSlugs = new Set(collectProjectWorkingDirs(hypoDir).map((p) => p.slug));
|
|
680
|
+
} catch {
|
|
681
|
+
return 'unattributable';
|
|
682
|
+
}
|
|
683
|
+
if (eligibleSlugs.size === 0) return 'unattributable';
|
|
684
|
+
const allForeign = dirty.every((f) =>
|
|
685
|
+
isForeignProjectFile(f, { eligibleSlugs, effectiveOverride }),
|
|
686
|
+
);
|
|
687
|
+
return allForeign ? 'foreign-only' : 'unattributable';
|
|
688
|
+
}
|
|
689
|
+
|
|
514
690
|
export function hotMdIsClean(dir = HYPO_DIR) {
|
|
515
691
|
const hotPath = dir === HYPO_DIR ? HOT_PATH : join(dir, 'hot.md');
|
|
516
692
|
if (!existsSync(hotPath)) return { clean: true };
|
|
@@ -1592,8 +1768,11 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
|
|
|
1592
1768
|
// invariant that once justified keeping the writer paths fully unscoped
|
|
1593
1769
|
// ("marker == compact-ready", codex design review) is superseded by that
|
|
1594
1770
|
// partition, not restored by it — a marker's own `verified_scope`, attesting
|
|
1595
|
-
// exactly what
|
|
1596
|
-
//
|
|
1771
|
+
// exactly what this function's caller evaluated (never `markerProjects`'
|
|
1772
|
+
// evidence-based attribution), now ships (session-close-scope-boundary
|
|
1773
|
+
// spec §3). The writer is `scripts/lib/crystallize-close-apply.mjs`
|
|
1774
|
+
// (`runMarkSessionClosed` and `runMarkerPhase`); the reader is
|
|
1775
|
+
// `markerCoversArtifact` in `scripts/doctor.mjs`.
|
|
1597
1776
|
if (opts.projectOverride) {
|
|
1598
1777
|
const s = sessionCloseFileStatus(hypoDir, { projectOverride: opts.projectOverride });
|
|
1599
1778
|
return {
|
|
@@ -1634,6 +1813,13 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
|
|
|
1634
1813
|
hasTodayCloseActivity(hypoDir, p, dates),
|
|
1635
1814
|
);
|
|
1636
1815
|
|
|
1816
|
+
// This branch drops `mustEvaluate` on the floor and reports the recency
|
|
1817
|
+
// project instead, so `projects` here can name a DIFFERENT project than the
|
|
1818
|
+
// caller asked for. No marker is ever written from it, though: `close.fallback`
|
|
1819
|
+
// is an unconditional gate blocker (see the fail-closed guards in the partition
|
|
1820
|
+
// below) and both marker writers only stamp on a green gate. So a marker whose
|
|
1821
|
+
// `verified_scope` names the recency project instead of the requested one is
|
|
1822
|
+
// unreachable, not merely fail-safe — do not write code defending that state.
|
|
1637
1823
|
if (activeCandidates.length === 0) {
|
|
1638
1824
|
const legacy = sessionCloseFileStatus(hypoDir);
|
|
1639
1825
|
return {
|
|
@@ -3166,6 +3352,33 @@ export function sessionClosedMarkerPath(hypoDir, sessionId) {
|
|
|
3166
3352
|
return join(hypoDir, '.cache', `session-closed-${sanitizeSessionId(sessionId)}.marker`);
|
|
3167
3353
|
}
|
|
3168
3354
|
|
|
3355
|
+
// verified_scope (session-close-scope-boundary spec §3) records the scope the
|
|
3356
|
+
// gate ABOVE this write actually verified, not merely the attribution the
|
|
3357
|
+
// marker's `projects` field carries — those can diverge whenever a gate run
|
|
3358
|
+
// widens attribution beyond what it narrowed the check to. The caller
|
|
3359
|
+
// decides `kind`: this function only refuses to persist a shape it cannot
|
|
3360
|
+
// stand behind. Exported so scripts/doctor.mjs's reader runs the SAME
|
|
3361
|
+
// collapse instead of a hand-rolled copy — an unrecognized shape, or a
|
|
3362
|
+
// 'project'/'global' scope with an empty `projects` (a narrower-than-nothing
|
|
3363
|
+
// claim), reads back as "field absent", never as a false claim.
|
|
3364
|
+
export function normalizeVerifiedScope(verifiedScope) {
|
|
3365
|
+
if (!verifiedScope || typeof verifiedScope !== 'object') return null;
|
|
3366
|
+
if (verifiedScope.kind === 'log-only') return { kind: 'log-only' };
|
|
3367
|
+
if (verifiedScope.kind === 'project' || verifiedScope.kind === 'global') {
|
|
3368
|
+
const projects = Array.isArray(verifiedScope.projects)
|
|
3369
|
+
? [...new Set(verifiedScope.projects.filter((p) => typeof p === 'string' && p))]
|
|
3370
|
+
: [];
|
|
3371
|
+
// An empty projects list under 'project'/'global' asserts a verified
|
|
3372
|
+
// boundary with nothing in it — that is not a narrower claim, it is a
|
|
3373
|
+
// malformed one. Drop it rather than persist a scope nothing can satisfy
|
|
3374
|
+
// by design (verified_scope must only ever tighten, never silently gate
|
|
3375
|
+
// out everything).
|
|
3376
|
+
if (projects.length === 0) return null;
|
|
3377
|
+
return { kind: verifiedScope.kind, projects };
|
|
3378
|
+
}
|
|
3379
|
+
return null;
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3169
3382
|
/**
|
|
3170
3383
|
* Persist a per-session close proof. Caller MUST verify
|
|
3171
3384
|
* `sessionCloseFileStatus(hypoDir).ok` before invoking — this helper does NOT
|
|
@@ -3175,7 +3388,7 @@ export function sessionClosedMarkerPath(hypoDir, sessionId) {
|
|
|
3175
3388
|
*
|
|
3176
3389
|
* @param {string} hypoDir
|
|
3177
3390
|
* @param {string} sessionId
|
|
3178
|
-
* @param {{project?: string, scope?: string, transcript_path?: string}} info
|
|
3391
|
+
* @param {{project?: string, scope?: string, transcript_path?: string, verifiedScope?: {kind: 'log-only'|'project'|'global', projects?: string[]}}} info
|
|
3179
3392
|
*/
|
|
3180
3393
|
export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
|
|
3181
3394
|
if (!sessionId) return;
|
|
@@ -3199,6 +3412,7 @@ export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
|
|
|
3199
3412
|
: info.project
|
|
3200
3413
|
? [info.project]
|
|
3201
3414
|
: [];
|
|
3415
|
+
const verifiedScope = normalizeVerifiedScope(info.verifiedScope);
|
|
3202
3416
|
const payload = {
|
|
3203
3417
|
session_id: sessionId,
|
|
3204
3418
|
project: info.project || projects[0] || null,
|
|
@@ -3207,6 +3421,11 @@ export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
|
|
|
3207
3421
|
transcript_path: info.transcript_path || null,
|
|
3208
3422
|
closed_at: new Date().toISOString(),
|
|
3209
3423
|
verification: scope === 'log-only' ? 'log-only-close:ok' : 'session-close-file-status:ok',
|
|
3424
|
+
// Omitted entirely (not even `null`) when the caller passes nothing or
|
|
3425
|
+
// an unrecognized shape, so a marker written by a caller that hasn't
|
|
3426
|
+
// adopted this field reads back byte-identical to before it existed —
|
|
3427
|
+
// doctor's reader treats "field absent" as "no additional scope check".
|
|
3428
|
+
...(verifiedScope ? { verified_scope: verifiedScope } : {}),
|
|
3210
3429
|
};
|
|
3211
3430
|
writeFileSync(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
|
|
3212
3431
|
} catch (err) {
|
|
@@ -3711,8 +3930,12 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
|
|
|
3711
3930
|
* below can tell their own incomplete close from someone else's debt. The
|
|
3712
3931
|
* invariant that once justified keeping those paths fully unscoped ("marker ==
|
|
3713
3932
|
* compact-ready", codex design review) is replaced by that partition, not
|
|
3714
|
-
* restored by it — a marker's own `verified_scope`, attesting exactly what
|
|
3715
|
-
*
|
|
3933
|
+
* restored by it — a marker's own `verified_scope`, attesting exactly what
|
|
3934
|
+
* this status's caller evaluated (never the marker's `projects`, which is
|
|
3935
|
+
* evidence-based attribution), now ships (session-close-scope-boundary
|
|
3936
|
+
* spec §3). The writer is `scripts/lib/crystallize-close-apply.mjs`
|
|
3937
|
+
* (`runMarkSessionClosed` and `runMarkerPhase`); the reader is
|
|
3938
|
+
* `markerCoversArtifact` in `scripts/doctor.mjs`.
|
|
3716
3939
|
* Either key ALSO feeds the git-dirty partition (spec §2b): with an untrusted
|
|
3717
3940
|
* transcript (missing/corrupt), a dirty file structurally under a DIFFERENT
|
|
3718
3941
|
* eligible project's own directory is demoted to a notice, because the path
|
|
@@ -3848,33 +4071,19 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
3848
4071
|
// exception, the PreCompact hook's outermost catch would turn that
|
|
3849
4072
|
// into a silent, fully-suppressed gate result, the opposite of "fail
|
|
3850
4073
|
// closed". Treat any failure as "no eligible projects known", which
|
|
3851
|
-
// makes
|
|
3852
|
-
// through to the unconditional blocker.
|
|
4074
|
+
// makes isForeignProjectFile() below always false and every dirty file
|
|
4075
|
+
// falls through to the unconditional blocker.
|
|
3853
4076
|
let eligibleSlugs = null;
|
|
3854
4077
|
try {
|
|
3855
4078
|
eligibleSlugs = new Set(collectProjectWorkingDirs(hypoDir).map((p) => p.slug));
|
|
3856
4079
|
} catch {
|
|
3857
4080
|
eligibleSlugs = null;
|
|
3858
4081
|
}
|
|
3859
|
-
//
|
|
3860
|
-
//
|
|
3861
|
-
//
|
|
3862
|
-
|
|
3863
|
-
|
|
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
|
-
};
|
|
4082
|
+
// isForeignProjectFile is the extracted, exported predicate (spec §5):
|
|
4083
|
+
// hypo-compact-guard.mjs calls the exact same function on its own git
|
|
4084
|
+
// axis, instead of a second, silently-diverging copy of this rule.
|
|
4085
|
+
const isForeign = (f) =>
|
|
4086
|
+
isForeignProjectFile(f, { eligibleSlugs, effectiveOverride, transcriptTouched });
|
|
3878
4087
|
const foreign = dirty.filter(isForeign);
|
|
3879
4088
|
const rest = dirty.filter((f) => !isForeign(f));
|
|
3880
4089
|
if (rest.length > 0) {
|
|
@@ -4809,6 +5018,48 @@ export function resolveTranscriptBySessionId(
|
|
|
4809
5018
|
// dangerous replay/injection paths carry system|sdk|isMeta|isSidechain and are
|
|
4810
5019
|
// excluded here anyway.
|
|
4811
5020
|
const COMMAND_INVOCATION_TAG = /<command-(?:name|message|args)>/;
|
|
5021
|
+
|
|
5022
|
+
// Text out of a content-block array, or null when the array carries a command
|
|
5023
|
+
// invocation (which is a host artifact, not something the user typed as prose).
|
|
5024
|
+
// Extracted so the queued_command attachment branch in walkCloseGate can reuse
|
|
5025
|
+
// the exact same rule: a queued prompt arrives as this array shape whenever the
|
|
5026
|
+
// user pasted an image alongside their words (measured: 8 such deliveries, all
|
|
5027
|
+
// origin.kind "human", host versions 2.1.226 through 2.1.263). Reading only
|
|
5028
|
+
// `typeof prompt === 'string'` there dropped those to '' and read a real change
|
|
5029
|
+
// of mind as nothing at all.
|
|
5030
|
+
function contentBlocksText(content) {
|
|
5031
|
+
const texts = content
|
|
5032
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
5033
|
+
.map((b) => b.text);
|
|
5034
|
+
const text = texts.length ? texts.join('\n') : null;
|
|
5035
|
+
// A command-invocation tag split across adjacent text blocks (e.g.
|
|
5036
|
+
// '<command-na' + 'me>/hypo:crystallize</command-name>') would survive the
|
|
5037
|
+
// '\n'-joined `text` above with a newline spliced into the middle of the
|
|
5038
|
+
// tag name, so the plain check below can miss it entirely. Today's actual
|
|
5039
|
+
// host format sends the whole invocation as ONE string, so this exact
|
|
5040
|
+
// split is not reproducible against a live session yet — but a check for
|
|
5041
|
+
// "did the host format ever put the tag exactly on a block boundary"
|
|
5042
|
+
// should not depend on where a future host happens to cut the blocks. So
|
|
5043
|
+
// also test each run of CONSECUTIVE text blocks joined with no separator.
|
|
5044
|
+
// This must stay scoped to consecutive text blocks only, never the whole
|
|
5045
|
+
// array: joining across a non-text block in between (an image, say) would
|
|
5046
|
+
// synthesize a tag that never existed in the real content, and that is a
|
|
5047
|
+
// different bug, not a fix — it would throw away a genuine close spoken
|
|
5048
|
+
// next to an unrelated attachment. So a non-text block ends the current
|
|
5049
|
+
// run and starts a new one; it never bridges two runs into one string.
|
|
5050
|
+
let tightRun = '';
|
|
5051
|
+
for (const b of content) {
|
|
5052
|
+
if (b && b.type === 'text' && typeof b.text === 'string') {
|
|
5053
|
+
tightRun += b.text;
|
|
5054
|
+
continue;
|
|
5055
|
+
}
|
|
5056
|
+
if (COMMAND_INVOCATION_TAG.test(tightRun)) return null;
|
|
5057
|
+
tightRun = '';
|
|
5058
|
+
}
|
|
5059
|
+
if (COMMAND_INVOCATION_TAG.test(tightRun)) return null;
|
|
5060
|
+
return text;
|
|
5061
|
+
}
|
|
5062
|
+
|
|
4812
5063
|
function eventUserText(obj) {
|
|
4813
5064
|
if (obj.isMeta === true) return null;
|
|
4814
5065
|
if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return null;
|
|
@@ -4822,35 +5073,9 @@ function eventUserText(obj) {
|
|
|
4822
5073
|
if (typeof content === 'string') {
|
|
4823
5074
|
text = content.startsWith('Stop hook feedback') ? null : content;
|
|
4824
5075
|
} else if (Array.isArray(content)) {
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
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;
|
|
5076
|
+
// null from either cause (no text blocks, or a command invocation) means
|
|
5077
|
+
// "no user prose here", which is exactly what this function returns anyway.
|
|
5078
|
+
text = contentBlocksText(content);
|
|
4854
5079
|
}
|
|
4855
5080
|
if (text != null && COMMAND_INVOCATION_TAG.test(text)) return null;
|
|
4856
5081
|
return text;
|
|
@@ -4868,6 +5093,20 @@ function isModelReachableRecord(obj) {
|
|
|
4868
5093
|
);
|
|
4869
5094
|
}
|
|
4870
5095
|
|
|
5096
|
+
// Queue content that carries no fresh USER decision: empty, or a background-task
|
|
5097
|
+
// notification the host injects on the model's behalf. Shared by the enqueue
|
|
5098
|
+
// branch and the remove-path (queued_command attachment) branch below so a
|
|
5099
|
+
// task notification reads as neutral on BOTH delivery shapes of the same host
|
|
5100
|
+
// event. Before this was extracted, only the enqueue branch filtered it — the
|
|
5101
|
+
// attachment branch treated any non-close prompt, including a task
|
|
5102
|
+
// notification, as a change-of-mind close: a close typed by the user opened
|
|
5103
|
+
// the gate, then the notification for an unrelated background task landed
|
|
5104
|
+
// via this path and flipped it shut again.
|
|
5105
|
+
function isModelCausedQueueContent(text) {
|
|
5106
|
+
const c = typeof text === 'string' ? text.trim() : '';
|
|
5107
|
+
return !c || c.startsWith('<task-notification>');
|
|
5108
|
+
}
|
|
5109
|
+
|
|
4871
5110
|
export function walkCloseGate(transcriptPath) {
|
|
4872
5111
|
if (!transcriptPath) return { open: false, openedAtIndex: -1 };
|
|
4873
5112
|
let raw;
|
|
@@ -4948,7 +5187,7 @@ export function walkCloseGate(transcriptPath) {
|
|
|
4948
5187
|
openedAtIndex = i;
|
|
4949
5188
|
} else if (/^\/clear(?:\s|$)/.test(c)) {
|
|
4950
5189
|
open = false; // abandons context → close
|
|
4951
|
-
} else if (
|
|
5190
|
+
} else if (isModelCausedQueueContent(c)) {
|
|
4952
5191
|
/* model-caused / empty — neutral */
|
|
4953
5192
|
} else if (isClosePattern(c)) {
|
|
4954
5193
|
/* NL close via the queue — the open dequeue gap: the producer cannot be
|
|
@@ -4965,16 +5204,80 @@ export function walkCloseGate(transcriptPath) {
|
|
|
4965
5204
|
// cannot attest a producer, so it does not open (fail-closed). A NON-close
|
|
4966
5205
|
// queued command (e.g. "keep working") is a fresh user intent and CLOSES
|
|
4967
5206
|
// 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.
|
|
5207
|
+
// where a queued "continue" after a close leaves the stale open live. A
|
|
5208
|
+
// task notification is not that: `modelCaused` below filters it out before
|
|
5209
|
+
// this reaches the change-of-mind close, because the model, not the user,
|
|
5210
|
+
// produced it. This delivery path used to skip that filter and let an
|
|
5211
|
+
// unrelated background-task notification flip a just-opened gate shut.
|
|
5212
|
+
//
|
|
5213
|
+
// Scope of "both shapes classify alike": it holds for task notifications and
|
|
5214
|
+
// for empty content, which is what this fix is about. It does not hold for
|
|
5215
|
+
// slash commands. The enqueue branch reads a queued `/compact` as an opener
|
|
5216
|
+
// and `/clear` as a close, and nothing here mirrors that, so the same
|
|
5217
|
+
// command would close the gate if it ever arrived on this path. Measured
|
|
5218
|
+
// 0 of 1321 attachment deliveries carry a slash prompt, so the divergence
|
|
5219
|
+
// is unreachable today rather than fixed; mirroring it would mean copying
|
|
5220
|
+
// an opener that tests already mark a fail-open defect.
|
|
4969
5221
|
if (o.type === 'attachment' && o.attachment && o.attachment.type === 'queued_command') {
|
|
4970
|
-
|
|
5222
|
+
// Same channel guard the typed path applies, for the same reason. This
|
|
5223
|
+
// branch can OPEN the gate, and `origin.kind` alone does not say which
|
|
5224
|
+
// channel the record came in on: a sidechain, injected, sdk or meta
|
|
5225
|
+
// record could carry a human origin and close text and mint an approval
|
|
5226
|
+
// the user never gave in this conversation. eventUserText has refused
|
|
5227
|
+
// those channels all along; the opener next to it did not, so one
|
|
5228
|
+
// classifier trusted a record the other threw away. Measured 0 of 1340
|
|
5229
|
+
// queued_command attachments carry any of these flags, so this closes a
|
|
5230
|
+
// shape the corpus has not produced rather than an observed failure.
|
|
5231
|
+
// `interruptedMessageId` rides along because eventUserText refuses it too.
|
|
5232
|
+
if (isModelReachableRecord(o) || o.interruptedMessageId) continue;
|
|
5233
|
+
// A queued prompt arrives as a content-block array whenever the user
|
|
5234
|
+
// pasted an image alongside their words. Reading only the string shape
|
|
5235
|
+
// dropped those to '' and threw away a real change of mind: the gate
|
|
5236
|
+
// stayed open and the close went through anyway. Measured 8 such
|
|
5237
|
+
// deliveries, every one origin.kind "human", host 2.1.226 to 2.1.263.
|
|
5238
|
+
const rawPrompt = o.attachment.prompt;
|
|
5239
|
+
const prompt =
|
|
5240
|
+
typeof rawPrompt === 'string'
|
|
5241
|
+
? rawPrompt
|
|
5242
|
+
: Array.isArray(rawPrompt)
|
|
5243
|
+
? (contentBlocksText(rawPrompt) ?? '')
|
|
5244
|
+
: '';
|
|
4971
5245
|
const humanOrigin = !!(o.attachment.origin && o.attachment.origin.kind === 'human');
|
|
4972
|
-
|
|
5246
|
+
// The host labels these deliveries on the record itself, and that label is
|
|
5247
|
+
// the authoritative one: every measured task-notification attachment
|
|
5248
|
+
// (1040 of 1040) carries commandMode 'task-notification'. Keying only on
|
|
5249
|
+
// the '<task-notification>' body prefix would leave the whole filter
|
|
5250
|
+
// resting on a string the host owns and can restyle, and a body that
|
|
5251
|
+
// merely gains a line before the tag would slip past it. The queue-op
|
|
5252
|
+
// branch above has no such field to read, so it keeps the body check
|
|
5253
|
+
// alone; here both are available and both are used.
|
|
5254
|
+
const modelCaused =
|
|
5255
|
+
o.attachment.commandMode === 'task-notification' || isModelCausedQueueContent(prompt);
|
|
5256
|
+
// The filter runs FIRST here, exactly as it does in the enqueue branch
|
|
5257
|
+
// above. Order is load-bearing, not cosmetic: a notification body carries
|
|
5258
|
+
// whatever text the finished task was named after, and isClosePattern
|
|
5259
|
+
// matches on a substring ("wrap up", "오늘 여기까지" inside a <summary>
|
|
5260
|
+
// both measure true). Testing isClosePattern first therefore lets a
|
|
5261
|
+
// model-produced event reach the opener, where a human-origin delivery
|
|
5262
|
+
// would set `open` and push `openedAtIndex` forward. That manufactures a
|
|
5263
|
+
// close signal the user never gave, and moves the index the resolution
|
|
5264
|
+
// comparison reads.
|
|
5265
|
+
//
|
|
5266
|
+
// Reachability, so the next reader does not have to re-measure it: no
|
|
5267
|
+
// recorded delivery hits that path. All 1040 task-notification
|
|
5268
|
+
// attachments in the corpus arrive with no `origin`, so `humanOrigin` is
|
|
5269
|
+
// false and the opener is skipped whichever order the two checks run in.
|
|
5270
|
+
// This is a defensive pin against a host that starts stamping origin on
|
|
5271
|
+
// them, not a repair of an observed failure.
|
|
5272
|
+
if (modelCaused) {
|
|
5273
|
+
/* model-caused / empty: neutral, the same filter the enqueue branch
|
|
5274
|
+
above applies to the same host event on its other delivery shape */
|
|
5275
|
+
} else if (isClosePattern(prompt)) {
|
|
4973
5276
|
if (humanOrigin) {
|
|
4974
5277
|
open = true;
|
|
4975
5278
|
openedAtIndex = i;
|
|
4976
5279
|
}
|
|
4977
|
-
} else
|
|
5280
|
+
} else {
|
|
4978
5281
|
open = false;
|
|
4979
5282
|
}
|
|
4980
5283
|
continue;
|
|
@@ -5306,12 +5609,36 @@ export function isCloseReconfirmDeclined(transcriptPath) {
|
|
|
5306
5609
|
return declined;
|
|
5307
5610
|
}
|
|
5308
5611
|
|
|
5612
|
+
// Events Claude Code actually reads additionalContext from, per the "Add
|
|
5613
|
+
// context for Claude" docs. A hookEventName outside this set has no
|
|
5614
|
+
// documented injection path, so buildOutput still emits the nested shape
|
|
5615
|
+
// (the caller may be adding a NEW event later) but warns to stderr.
|
|
5616
|
+
const INJECTABLE_EVENTS = new Set(['UserPromptSubmit', 'SessionStart', 'PostToolUse', 'Stop']);
|
|
5617
|
+
|
|
5309
5618
|
/**
|
|
5310
|
-
* Build hook output for Claude Code (
|
|
5311
|
-
* Codex hooks write systemMessage directly in their own files.
|
|
5619
|
+
* Build hook output for Claude Code (nested hookSpecificOutput.additionalContext
|
|
5620
|
+
* channel). Codex hooks write systemMessage directly in their own files.
|
|
5621
|
+
*
|
|
5622
|
+
* @param {string} hookEventName - the event this hook fires on, e.g. 'UserPromptSubmit'.
|
|
5623
|
+
* @param {string} context - the text to inject.
|
|
5624
|
+
* @param {object} [extra] - control fields (continue, suppressOutput, ...) that
|
|
5625
|
+
* stay top-level siblings of hookSpecificOutput.
|
|
5312
5626
|
*/
|
|
5313
|
-
export function buildOutput(context, extra = {}) {
|
|
5314
|
-
|
|
5627
|
+
export function buildOutput(hookEventName, context, extra = {}) {
|
|
5628
|
+
if (!INJECTABLE_EVENTS.has(hookEventName)) {
|
|
5629
|
+
process.stderr.write(
|
|
5630
|
+
`[hypo] buildOutput: ${hookEventName} has no documented context-injection path\n`,
|
|
5631
|
+
);
|
|
5632
|
+
}
|
|
5633
|
+
// `extra` carries control fields, never context. Dropping the key here is
|
|
5634
|
+
// what makes the nested shape the only channel: without it a caller could
|
|
5635
|
+
// pass `{ additionalContext }` and the spread would put a top-level copy
|
|
5636
|
+
// right back, which is the exact bug this function exists to prevent.
|
|
5637
|
+
const { additionalContext: _shadowed, ...control } = extra;
|
|
5638
|
+
return {
|
|
5639
|
+
...control,
|
|
5640
|
+
hookSpecificOutput: { hookEventName, additionalContext: context },
|
|
5641
|
+
};
|
|
5315
5642
|
}
|
|
5316
5643
|
|
|
5317
5644
|
// ── growth metrics (F2 + E4) ───────────────────────────────────────────────
|