klypix-mcp 1.86.1 → 1.86.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/bin/klypix-install.mjs +3 -1
- package/package.json +1 -1
- package/src/brain-doctor.mjs +10 -1
- package/src/global-brain-hook.mjs +457 -102
- package/src/klypix-core.mjs +5 -2
- package/src/klypix-format.mjs +163 -37
package/bin/klypix-install.mjs
CHANGED
|
@@ -436,7 +436,9 @@ try {
|
|
|
436
436
|
// deliberately preserving every host/project config byte.
|
|
437
437
|
const brainCmd = (arg) => `node "${fwd(path.join(BRAIN_DIR, 'global-brain-hook.mjs'))}"${arg ? ' ' + arg : ''}`;
|
|
438
438
|
const GROUPS = [
|
|
439
|
-
|
|
439
|
+
// "clear" (1.86.2): a /clear starts a new conversation that needs the
|
|
440
|
+
// brief as much as a fresh one does.
|
|
441
|
+
['SessionStart', { matcher: 'startup|resume|clear', hooks: [{ type: 'command', command: brainCmd('') }] }],
|
|
440
442
|
['UserPromptSubmit', { hooks: [{ type: 'command', command: brainCmd('--prompt'), timeout: 10 }] }],
|
|
441
443
|
['Stop', { hooks: [{ type: 'command', command: brainCmd('--capture') }] }],
|
|
442
444
|
['PostToolUse', { matcher: 'Bash|PowerShell|Edit|Write', hooks: [{ type: 'command', command: brainCmd('--live'), timeout: 10 }] }],
|
package/package.json
CHANGED
package/src/brain-doctor.mjs
CHANGED
|
@@ -111,7 +111,15 @@ function inspectHooks(home) {
|
|
|
111
111
|
const present = !!settings;
|
|
112
112
|
const wired = present ? HOOK_EVENTS.filter(wiredFor) : [];
|
|
113
113
|
const missing = present ? HOOK_EVENTS.filter(e => !wired.includes(e)) : HOOK_EVENTS.slice();
|
|
114
|
-
|
|
114
|
+
// Informational (never a verdict): installs before 1.86.2 wired SessionStart
|
|
115
|
+
// for "startup|resume" only, so /clear starts a conversation with no brain
|
|
116
|
+
// brief. A runtime-only update never rewrites settings.json; a full
|
|
117
|
+
// `npx klypix-mcp install` does. An empty/absent matcher means every source.
|
|
118
|
+
const ours = (Array.isArray(settings?.hooks?.SessionStart) ? settings.hooks.SessionStart : [])
|
|
119
|
+
.filter(g => Array.isArray(g?.hooks) && g.hooks.some(h => typeof h?.command === 'string' && h.command.includes(HOOK_MARK)));
|
|
120
|
+
const coversClear = (g) => !g.matcher || String(g.matcher).split('|').map(x => x.trim()).some(x => x === 'clear' || x === '*');
|
|
121
|
+
const sessionStartMissesClear = ours.length > 0 && !ours.some(coversClear);
|
|
122
|
+
return { settingsPresent: present, wired, missing, sessionStartMissesClear };
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
// ── TOOLS (discoverable manifest) layer ──────────────────────────────────────
|
|
@@ -788,6 +796,7 @@ export function render(r, opts = {}) {
|
|
|
788
796
|
else if (r.hooks.missing.length === 1 && r.hooks.missing[0] === 'PreToolUse') L.push(`${hmark} ${c.bold}CLAUDE${c.rst} capture path intact; ${c.yel}guard lane not wired yet${c.rst} — \`npx klypix-mcp install\` adds the PreToolUse hook (guard cards)`);
|
|
789
797
|
else if (r.hooks.missing.length) L.push(`${hmark} ${c.bold}CLAUDE${c.rst} half-wired — missing: ${c.yel}${r.hooks.missing.join(', ')}${c.rst} ${c.dim}(liveness up, readiness no)${c.rst}`);
|
|
790
798
|
else L.push(`${hmark} ${c.bold}CLAUDE${c.rst} existing 5-hook capture path intact: ${r.hooks.wired.join(', ')}`);
|
|
799
|
+
if (r.hooks.sessionStartMissesClear) L.push(` ${c.dim}note: SessionStart is not wired for /clear — a cleared conversation starts without the brain brief; "npx klypix-mcp install" adds "clear" to its matcher${c.rst}`);
|
|
791
800
|
const chmark = r.layers.codexHooks === 'warning' ? warn : ok;
|
|
792
801
|
const smart = r.codexSmart?.globalInstructions
|
|
793
802
|
? 'approval-free Context Gateway active (task memory + clean peers + proactive/guaranteed alerts)'
|
|
@@ -420,8 +420,51 @@ function reconcileFooter(lib, struct) {
|
|
|
420
420
|
// project into two registry entries AND two parse-cache files.
|
|
421
421
|
const normBrainPath = (p) => String(p).replace(/\\/g, '/').replace(/^[a-zA-Z]:/, (m) => m.toLowerCase());
|
|
422
422
|
|
|
423
|
+
// The seen set is a FIFO capped at STATE_SEEN_MAX. Every Stop re-reads the
|
|
424
|
+
// whole transcript, so a key is only safe to evict once no live transcript
|
|
425
|
+
// still carries its line: a capture moves every key it HIT to the young end
|
|
426
|
+
// (doCapture), and the cap leaves room for several busy sessions (review
|
|
427
|
+
// 2026-09-18, third round: KLYPIX held 1,658 of 2,000, and a resumed old
|
|
428
|
+
// session whose keys aged out re-applied its ~ lines over newer edits).
|
|
429
|
+
const STATE_SEEN_MAX = 5000;
|
|
423
430
|
const readState = () => { try { return new Set(JSON.parse(fs.readFileSync(STATE, 'utf8')).seen || []); } catch { return new Set(); } };
|
|
424
|
-
|
|
431
|
+
// `legacyUntil` (1.86.2): the moment before which an OLDER hook may have keyed
|
|
432
|
+
// this project's markers. Older hooks keyed a marker on the text they CUT it to
|
|
433
|
+
// (1.86.0 at the first whitespace + closes:/ev:/verify:/q:, 1.85 the same
|
|
434
|
+
// without q:), and wrote no per-line key for ~ / ✓, so the upgrade path consults
|
|
435
|
+
// those old keys, and treats a ~ / ✓ line as already applied, ONLY for
|
|
436
|
+
// transcript events older than this. It used to consult them forever, and a
|
|
437
|
+
// 1.86.1 key is byte-identical to the old-cut key of a LATER note that starts
|
|
438
|
+
// with the same sentence ("X ev: README.md", then "X Q: and A: …"): the later
|
|
439
|
+
// note was folded into the earlier card as a "repair", dropped as "not
|
|
440
|
+
// re-added", or had its closes: skipped (review 2026-09-18, third round).
|
|
441
|
+
// no state file yet → 0: no older hook ever keyed this project;
|
|
442
|
+
// a state file without it → its mtime: the last write an older hook made
|
|
443
|
+
// (every event it keyed is older than that);
|
|
444
|
+
// a stamped state file → the stamp, carried by every writeState.
|
|
445
|
+
// An older hook that writes the file later drops the field, and the next run
|
|
446
|
+
// re-stamps from that newer mtime — which is right: it keyed events up to then.
|
|
447
|
+
let stateLegacyUntil = null;
|
|
448
|
+
function captureLegacyUntil() {
|
|
449
|
+
if (stateLegacyUntil !== null) return stateLegacyUntil;
|
|
450
|
+
try {
|
|
451
|
+
const raw = fs.readFileSync(STATE, 'utf8');
|
|
452
|
+
let parsed = null; try { parsed = JSON.parse(raw); } catch { /* corrupt → treat as an older writer */ }
|
|
453
|
+
const stamp = Number(parsed && parsed.legacyUntil);
|
|
454
|
+
if (parsed && Object.prototype.hasOwnProperty.call(parsed, 'legacyUntil') && Number.isFinite(stamp) && stamp >= 0) stateLegacyUntil = stamp;
|
|
455
|
+
else { let mtime = Date.now(); try { mtime = fs.statSync(STATE).mtimeMs; } catch { /* keep now */ } stateLegacyUntil = Math.min(mtime, Date.now()); }
|
|
456
|
+
} catch (error) {
|
|
457
|
+
stateLegacyUntil = error && error.code === 'ENOENT' ? 0 : Date.now();
|
|
458
|
+
}
|
|
459
|
+
return stateLegacyUntil;
|
|
460
|
+
}
|
|
461
|
+
const writeState = (seen) => {
|
|
462
|
+
try {
|
|
463
|
+
const legacyUntil = captureLegacyUntil(); // resolved BEFORE this write changes the mtime
|
|
464
|
+
fs.mkdirSync(path.dirname(STATE), { recursive: true });
|
|
465
|
+
fs.writeFileSync(STATE, JSON.stringify({ seen: [...seen].slice(-STATE_SEEN_MAX), legacyUntil }));
|
|
466
|
+
} catch { /* ignore */ }
|
|
467
|
+
};
|
|
425
468
|
const messageHandledKey = (messageId) => `message:${String(messageId || '')}`;
|
|
426
469
|
// Persist only these message keys into a fresh read. The in-memory capture set
|
|
427
470
|
// may already contain brain-card markers whose brain write has not happened;
|
|
@@ -523,21 +566,86 @@ function appendJsonl(file, obj, maxLines = 0) {
|
|
|
523
566
|
// is stolen so a crashed session can't wedge the brain forever. Best-effort: if
|
|
524
567
|
// it can't get the lock within the budget it writes anyway (better than dropping
|
|
525
568
|
// the markers) and flags it in the health log.
|
|
569
|
+
//
|
|
570
|
+
// Stale-lock break (review 2026-09-18, third round). The holder writes
|
|
571
|
+
// "<pid> <token>"; a lock is stale when that pid is provably dead (a crash no
|
|
572
|
+
// longer costs every waiter up to 15 s — ~2.5 s per prompt), or when its mtime
|
|
573
|
+
// is more than LOCK_STALE_MS away from now in EITHER direction (a crashed
|
|
574
|
+
// holder's lock after the clock stepped back used to stay forever). Breaking is
|
|
575
|
+
// serialized by a short-lived "<lock>.break" file and re-checks that the lock
|
|
576
|
+
// is still the SAME stale one before unlinking it: two waiters that both saw it
|
|
577
|
+
// stale used to unlink it in turn, and the second deleted the first one's
|
|
578
|
+
// fresh lock, so both held it. A release only removes a lock this process
|
|
579
|
+
// still owns. Lock files written by other writers (a bare pid, a JSON object,
|
|
580
|
+
// a test's placeholder) are read the same way; one whose pid cannot be read is
|
|
581
|
+
// only ever broken by age.
|
|
526
582
|
const LOCK_STALE_MS = 15000;
|
|
583
|
+
const LOCK_BREAK_STALE_MS = 5000;
|
|
527
584
|
const sleepSync = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* */ } };
|
|
585
|
+
const heldLockTokens = new Map(); // lockPath → the content this process wrote
|
|
586
|
+
const lockHolderPid = (content) => {
|
|
587
|
+
const text = String(content || '').trim();
|
|
588
|
+
if (text.startsWith('{')) { try { return Number(JSON.parse(text).pid) || null; } catch { return null; } }
|
|
589
|
+
const m = /^(\d+)(?:\s|$)/.exec(text);
|
|
590
|
+
return m ? Number(m[1]) : null;
|
|
591
|
+
};
|
|
592
|
+
// `isProcessAlive` is declared with the presence helpers further down; every
|
|
593
|
+
// lock call site runs from main(), long after module init, so the forward
|
|
594
|
+
// reference is a runtime read, never a TDZ one. Keep it that way.
|
|
595
|
+
function lockIsStale(lockPath, now = Date.now()) {
|
|
596
|
+
let content, mtimeMs;
|
|
597
|
+
try { content = fs.readFileSync(lockPath, 'utf8'); mtimeMs = fs.statSync(lockPath).mtimeMs; } catch { return null; }
|
|
598
|
+
const pid = lockHolderPid(content);
|
|
599
|
+
const stale = (pid && pid !== process.pid && isProcessAlive(pid) === false) || Math.abs(now - mtimeMs) > LOCK_STALE_MS;
|
|
600
|
+
return stale ? { content, mtimeMs } : null;
|
|
601
|
+
}
|
|
602
|
+
function breakStaleLock(lockPath, seen) {
|
|
603
|
+
const breaker = `${lockPath}.break`;
|
|
604
|
+
let fd;
|
|
605
|
+
try { fd = fs.openSync(breaker, 'wx'); }
|
|
606
|
+
catch (e) {
|
|
607
|
+
// Another waiter is breaking it. A breaker file left by a crash inside
|
|
608
|
+
// this few-microsecond window is itself broken by age.
|
|
609
|
+
try { if (e?.code === 'EEXIST' && Math.abs(Date.now() - fs.statSync(breaker).mtimeMs) > LOCK_BREAK_STALE_MS) fs.unlinkSync(breaker); } catch { /* raced */ }
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
fs.closeSync(fd);
|
|
614
|
+
const now = lockIsStale(lockPath);
|
|
615
|
+
if (!now || now.content !== seen.content || now.mtimeMs !== seen.mtimeMs) return false; // not the lock we judged
|
|
616
|
+
fs.unlinkSync(lockPath);
|
|
617
|
+
return true;
|
|
618
|
+
} catch { return false; }
|
|
619
|
+
finally { try { fs.unlinkSync(breaker); } catch { /* */ } }
|
|
620
|
+
}
|
|
528
621
|
function acquireLock(lockPath, { tries = 60, waitMs = 60 } = {}) {
|
|
529
622
|
try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch { /* */ }
|
|
530
623
|
for (let i = 0; i < tries; i++) {
|
|
531
|
-
try {
|
|
624
|
+
try {
|
|
625
|
+
const token = `${process.pid} ${crypto.randomBytes(6).toString('hex')}`;
|
|
626
|
+
const fd = fs.openSync(lockPath, 'wx'); fs.writeSync(fd, token); fs.closeSync(fd);
|
|
627
|
+
heldLockTokens.set(lockPath, token);
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
532
630
|
catch (e) {
|
|
533
631
|
if (e && e.code !== 'EEXIST') return false; // unexpected FS error → caller writes best-effort
|
|
534
|
-
|
|
632
|
+
const stale = lockIsStale(lockPath);
|
|
633
|
+
if (stale && breakStaleLock(lockPath, stale)) continue;
|
|
535
634
|
sleepSync(waitMs);
|
|
536
635
|
}
|
|
537
636
|
}
|
|
538
637
|
return false; // contended past ~3.6s → write best-effort (rare; captures are sub-second)
|
|
539
638
|
}
|
|
540
|
-
function releaseLock(lockPath) {
|
|
639
|
+
function releaseLock(lockPath) {
|
|
640
|
+
const token = heldLockTokens.get(lockPath);
|
|
641
|
+
heldLockTokens.delete(lockPath);
|
|
642
|
+
try {
|
|
643
|
+
// Never remove a lock this process no longer owns (it was broken as
|
|
644
|
+
// stale and re-taken by another writer).
|
|
645
|
+
if (token !== undefined && fs.readFileSync(lockPath, 'utf8') !== token) return;
|
|
646
|
+
fs.unlinkSync(lockPath);
|
|
647
|
+
} catch { /* */ }
|
|
648
|
+
}
|
|
541
649
|
|
|
542
650
|
// ── Live cross-session coordination (brain.sessions heartbeat) ───────────────
|
|
543
651
|
// The brain is ASYNC memory — capture on Stop, recall on the next Start — so two
|
|
@@ -702,6 +810,43 @@ function queuePendingCapture(batch) {
|
|
|
702
810
|
function clearDrainedOrphans(paths) {
|
|
703
811
|
for (const f of (paths || [])) { try { fs.unlinkSync(f); } catch { /* re-drained next time; landing twice is superseded away */ } }
|
|
704
812
|
}
|
|
813
|
+
// A queued batch the engine throws on (review 2026-09-18, third round) is
|
|
814
|
+
// blamed each time a capture has to land without it; after
|
|
815
|
+
// DRAIN_FAILURES_MAX such drains it is moved to its own
|
|
816
|
+
// "<queue>.poison-<id>.json" — kept, never drained again, and reported —
|
|
817
|
+
// instead of costing every later capture in the project a failed attempt.
|
|
818
|
+
// Returns the batches set aside by this call.
|
|
819
|
+
const DRAIN_FAILURES_MAX = 3;
|
|
820
|
+
function recordDrainFailure(batches, error) {
|
|
821
|
+
const quarantined = [];
|
|
822
|
+
try {
|
|
823
|
+
const reason = String((error && error.message) || error || 'unknown').slice(0, 200);
|
|
824
|
+
const bump = (b) => ({ ...b, drainFailures: (Number(b.drainFailures) || 0) + 1, lastDrainError: reason });
|
|
825
|
+
const setAside = (b) => {
|
|
826
|
+
try {
|
|
827
|
+
const { __orphanPath, ...clean } = b;
|
|
828
|
+
const file = `${PENDING_CAPTURES_FILE}.poison-${String(b.id || Date.now()).replace(/[^A-Za-z0-9_-]+/g, '-')}.json`;
|
|
829
|
+
fs.writeFileSync(file, JSON.stringify({ ...clean, quarantinedAt: nowIso() }));
|
|
830
|
+
quarantined.push({ id: b.id || null, file, cards: (b.cards || []).length, updates: (b.updates || []).length, resolutions: (b.resolutions || []).length, first: String((b.cards?.[0]?.text) || (b.updates?.[0]?.text) || (b.resolutions?.[0]?.text) || '').replace(/\s+/g, ' ').slice(0, 90) });
|
|
831
|
+
return true;
|
|
832
|
+
} catch { return false; }
|
|
833
|
+
};
|
|
834
|
+
const mainIds = new Set(batches.filter(b => b && !b.__orphanPath && b.id).map(b => b.id));
|
|
835
|
+
if (mainIds.size) {
|
|
836
|
+
updatePendingCaptures((current) => current.flatMap((b) => {
|
|
837
|
+
if (!b || !mainIds.has(b.id)) return [b];
|
|
838
|
+
const next = bump(b);
|
|
839
|
+
return next.drainFailures >= DRAIN_FAILURES_MAX && setAside(next) ? [] : [next];
|
|
840
|
+
}));
|
|
841
|
+
}
|
|
842
|
+
for (const b of batches.filter(x => x && x.__orphanPath)) {
|
|
843
|
+
const next = bump(b);
|
|
844
|
+
if (next.drainFailures >= DRAIN_FAILURES_MAX && setAside(next)) { try { fs.unlinkSync(b.__orphanPath); } catch { /* re-drained, then set aside again */ } continue; }
|
|
845
|
+
try { const { __orphanPath, ...clean } = next; const tmp = `${b.__orphanPath}.tmp-${process.pid}`; fs.writeFileSync(tmp, JSON.stringify(clean)); fs.renameSync(tmp, b.__orphanPath); } catch { /* the count is best-effort */ }
|
|
846
|
+
}
|
|
847
|
+
} catch { /* best-effort: an unrecorded failure only delays the set-aside */ }
|
|
848
|
+
return quarantined;
|
|
849
|
+
}
|
|
705
850
|
const SESSION_FRESH_MS = 10 * 60 * 1000; // a lane unseen for 10min is treated as ended
|
|
706
851
|
const MCP_SESSION_FRESH_MS = 3 * 60 * 1000; // an mcp-channel heartbeat is dead after 3min (matches agent-presence)
|
|
707
852
|
// ── Dead-host sweep (BEHAVIOR PARITY with agent-presence.mjs isDeadHostRow —
|
|
@@ -2462,16 +2607,65 @@ const readSidecar = () => { try { const d = JSON.parse(fs.readFileSync(RULE_DRAF
|
|
|
2462
2607
|
// backoff and the tmp is removed if it never lands. Returns whether the write
|
|
2463
2608
|
// landed — a caller that marks something "shown" must know.
|
|
2464
2609
|
const SIDECAR_RENAME_BACKOFF_MS = [20, 50, 120, 250, 500];
|
|
2610
|
+
// A sidecar that CANNOT be written (a read-only attribute, a deny-delete ACL, a
|
|
2611
|
+
// handle held without FILE_SHARE_DELETE) used to cost the whole ~940 ms backoff
|
|
2612
|
+
// inside the lock on every prompt with a pending receipt, for up to three days
|
|
2613
|
+
// (review 2026-09-18, third round: three prompts at ~1.33 s each against a
|
|
2614
|
+
// ~0.4 s baseline). A read-only destination now fails at once; a final
|
|
2615
|
+
// permission failure stamps the per-project health dir, and for
|
|
2616
|
+
// SIDECAR_FAIL_BACKOFF_MS every writer skips straight to "not written" (the
|
|
2617
|
+
// callers already treat that as "leave it pending"). Only EBUSY, or EPERM /
|
|
2618
|
+
// EACCES on a writable destination (a reader holding it for a moment), is
|
|
2619
|
+
// retried with backoff.
|
|
2620
|
+
const SIDECAR_FAIL_BACKOFF_MS = 10 * 60 * 1000;
|
|
2621
|
+
const SIDECAR_FAIL_STAMP = HEALTH.replace(/\.jsonl$/, '') + '.sidecar-unwritable';
|
|
2622
|
+
const stampSidecarFailure = (code) => { try { fs.mkdirSync(path.dirname(SIDECAR_FAIL_STAMP), { recursive: true }); fs.writeFileSync(SIDECAR_FAIL_STAMP, `${nowIso()} ${code || 'unwritable'} ${RULE_DRAFTS}\n`); } catch { /* best-effort */ } };
|
|
2623
|
+
// The FILE's own writability, deliberately: on Windows the read-only attribute
|
|
2624
|
+
// is exactly what makes MoveFileEx(REPLACE_EXISTING) fail, which is the field
|
|
2625
|
+
// case. On POSIX a rename-over only needs the DIRECTORY, so this also refuses
|
|
2626
|
+
// to replace a sidecar someone marked read-only there — a small, intentional
|
|
2627
|
+
// change of behaviour in the direction of not clobbering it, and one the stamp
|
|
2628
|
+
// undoes the instant the attribute is cleared.
|
|
2629
|
+
const sidecarWritable = () => {
|
|
2630
|
+
try { fs.accessSync(RULE_DRAFTS, fs.constants.W_OK); return true; }
|
|
2631
|
+
catch (error) { return error?.code === 'ENOENT'; } // absent → the rename creates it
|
|
2632
|
+
};
|
|
2633
|
+
// The backoff must not outlive its cause: the moment the destination is
|
|
2634
|
+
// writable again (the common case — someone cleared a read-only attribute) the
|
|
2635
|
+
// stamp is dropped and the next write goes through. It only holds the skip
|
|
2636
|
+
// while the destination still refuses, or while it is writable-but-locked
|
|
2637
|
+
// (an ACL or a handle held without FILE_SHARE_DELETE, which accessSync cannot
|
|
2638
|
+
// see) — then the 10 minutes are what keeps a doomed ~940 ms backoff off every
|
|
2639
|
+
// single prompt for three days.
|
|
2640
|
+
const sidecarRecentlyFailed = () => {
|
|
2641
|
+
let raw = '', fresh = false;
|
|
2642
|
+
try { fresh = Date.now() - fs.statSync(SIDECAR_FAIL_STAMP).mtimeMs < SIDECAR_FAIL_BACKOFF_MS; raw = fs.readFileSync(SIDECAR_FAIL_STAMP, 'utf8'); } catch { return false; }
|
|
2643
|
+
if (!fresh) return false;
|
|
2644
|
+
// A read-only destination is the one cause someone can clear in a second,
|
|
2645
|
+
// and the one accessSync can see: the moment it is gone, so is the backoff.
|
|
2646
|
+
if (/\bread-only\b/.test(raw) && sidecarWritable()) { try { fs.unlinkSync(SIDECAR_FAIL_STAMP); } catch { /* raced with another writer */ } return false; }
|
|
2647
|
+
return true;
|
|
2648
|
+
};
|
|
2465
2649
|
const writeSidecar = (patch) => {
|
|
2466
2650
|
let tmp = null;
|
|
2467
2651
|
try {
|
|
2652
|
+
if (sidecarRecentlyFailed()) return false;
|
|
2653
|
+
if (!sidecarWritable()) { stampSidecarFailure('read-only'); return false; }
|
|
2468
2654
|
fs.mkdirSync(path.dirname(RULE_DRAFTS), { recursive: true });
|
|
2469
2655
|
tmp = `${RULE_DRAFTS}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2470
2656
|
fs.writeFileSync(tmp, JSON.stringify({ ...readSidecar(), ...patch }, null, 2));
|
|
2471
2657
|
for (let attempt = 0; ; attempt++) {
|
|
2472
|
-
try {
|
|
2473
|
-
|
|
2474
|
-
if (attempt
|
|
2658
|
+
try {
|
|
2659
|
+
fs.renameSync(tmp, RULE_DRAFTS);
|
|
2660
|
+
if (attempt > 0 || fs.existsSync(SIDECAR_FAIL_STAMP)) { try { fs.unlinkSync(SIDECAR_FAIL_STAMP); } catch { /* none */ } }
|
|
2661
|
+
return true;
|
|
2662
|
+
} catch (error) {
|
|
2663
|
+
const code = error?.code;
|
|
2664
|
+
if (attempt >= SIDECAR_RENAME_BACKOFF_MS.length || !['EPERM', 'EACCES', 'EBUSY'].includes(code)) {
|
|
2665
|
+
if (code === 'EPERM' || code === 'EACCES') stampSidecarFailure(code);
|
|
2666
|
+
throw error;
|
|
2667
|
+
}
|
|
2668
|
+
if (code !== 'EBUSY' && !sidecarWritable()) { stampSidecarFailure('read-only'); throw error; }
|
|
2475
2669
|
sleepSync(SIDECAR_RENAME_BACKOFF_MS[attempt]);
|
|
2476
2670
|
}
|
|
2477
2671
|
}
|
|
@@ -2739,28 +2933,58 @@ async function findingDraftsFooter(sid, { markShown = true } = {}) {
|
|
|
2739
2933
|
// stdout is model context), three at a time. Deduped by key; TTL-pruned;
|
|
2740
2934
|
// never throws.
|
|
2741
2935
|
//
|
|
2742
|
-
// A receipt from a session's FINAL Stop would never be seen, so
|
|
2743
|
-
// ADOPTS
|
|
2744
|
-
//
|
|
2745
|
-
//
|
|
2746
|
-
//
|
|
2747
|
-
//
|
|
2748
|
-
//
|
|
2749
|
-
//
|
|
2750
|
-
//
|
|
2751
|
-
//
|
|
2752
|
-
//
|
|
2753
|
-
//
|
|
2754
|
-
//
|
|
2755
|
-
//
|
|
2756
|
-
//
|
|
2936
|
+
// A receipt from a session's FINAL Stop would never be seen, so another
|
|
2937
|
+
// session ADOPTS it: it reassigns the receipt to itself, and its own next
|
|
2938
|
+
// prompt prints it — nothing is printed at SessionStart, where the block
|
|
2939
|
+
// landed past the ~2 KB preview the harness shows (review 2026-09-18).
|
|
2940
|
+
//
|
|
2941
|
+
// Who may adopt (third review, 2026-09-18). Every receipt records the host
|
|
2942
|
+
// process (CLAUDE_PID) and the machine of the session that wrote it.
|
|
2943
|
+
// • The same host process — a /clear or an in-app /resume replaced that
|
|
2944
|
+
// conversation: the new session's next PROMPT adopts it. The prompt hook
|
|
2945
|
+
// always fires; SessionStart on /clear only runs where the installed
|
|
2946
|
+
// matcher lists "clear" (1.86.1's did not, and a runtime update never
|
|
2947
|
+
// rewrites settings.json), so a handoff that lived only at SessionStart
|
|
2948
|
+
// never ran after a real /clear, and an unrelated session printed the
|
|
2949
|
+
// receipt half an hour later. Capped at CAPTURE_RECEIPT_PREDECESSOR_MS, so
|
|
2950
|
+
// an OS-reused pid never claims an old receipt.
|
|
2951
|
+
// • Another host process on this machine: only once that process is
|
|
2952
|
+
// PROVABLY gone (isProcessAlive === false), at the next SessionStart. An
|
|
2953
|
+
// author that is idle but alive keeps its receipts however long it idles
|
|
2954
|
+
// ("no live lane row for 30 minutes" only ever meant "idle for 30
|
|
2955
|
+
// minutes"), and a receipt from ANOTHER machine is never adopted — its
|
|
2956
|
+
// TTL expires it.
|
|
2957
|
+
// • A receipt with no host pid (a session without CLAUDE_PID, or one 1.86.1
|
|
2958
|
+
// wrote) keeps the old rule: no live lane row, unshown for
|
|
2959
|
+
// CAPTURE_RECEIPT_HANDOFF_MS — and its heading does not claim the author
|
|
2960
|
+
// has ended.
|
|
2961
|
+
// Adoption re-stamps the receipt with the adopter's host, machine and time, so
|
|
2962
|
+
// it does not hop again. Adoption and every shown-mark happen under
|
|
2963
|
+
// RULE_DRAFTS_LOCK in ONE read-modify-write, so two sessions can never both
|
|
2964
|
+
// take the same receipt, and a receipt is printed only when its shown-mark was
|
|
2965
|
+
// actually persisted (an unwritable sidecar printed the same receipt on every
|
|
2966
|
+
// prompt).
|
|
2757
2967
|
const CAPTURE_RECEIPT_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
|
2758
2968
|
const CAPTURE_RECEIPTS_MAX = 40;
|
|
2759
2969
|
const CAPTURE_RECEIPTS_SHOWN = 3;
|
|
2760
|
-
const CAPTURE_RECEIPT_HANDOFF_MS = 30 * 60 * 1000;
|
|
2970
|
+
const CAPTURE_RECEIPT_HANDOFF_MS = 30 * 60 * 1000; // legacy rule: no live lane row + unshown this long
|
|
2971
|
+
const CAPTURE_RECEIPT_PREDECESSOR_MS = 24 * 60 * 60 * 1000; // same-host handoff window (pid reuse guard)
|
|
2761
2972
|
const readCaptureReceipts = () => { const d = readSidecar(); return Array.isArray(d.captureReceipts) ? d.captureReceipts : []; };
|
|
2973
|
+
// The cap drops SHOWN receipts first, oldest first, and an unshown one only
|
|
2974
|
+
// when more than CAPTURE_RECEIPTS_MAX are unshown: trimming by position used
|
|
2975
|
+
// to push an idle author's unshown receipt out while shown ones sat waiting
|
|
2976
|
+
// for their TTL (review 2026-09-18, third round).
|
|
2977
|
+
function capCaptureReceipts(list) {
|
|
2978
|
+
if (list.length <= CAPTURE_RECEIPTS_MAX) return list;
|
|
2979
|
+
let excess = list.length - CAPTURE_RECEIPTS_MAX;
|
|
2980
|
+
const drop = new Set();
|
|
2981
|
+
for (const r of list) { if (excess <= 0) break; if (r.shown) { drop.add(r); excess--; } }
|
|
2982
|
+
for (const r of list) { if (excess <= 0) break; if (!drop.has(r)) { drop.add(r); excess--; } }
|
|
2983
|
+
return list.filter(r => !drop.has(r));
|
|
2984
|
+
}
|
|
2762
2985
|
// → 'written' | 'unchanged' | false (lock timeout or a failed write).
|
|
2763
2986
|
function persistCaptureReceipts(mutate) {
|
|
2987
|
+
if (sidecarRecentlyFailed()) return false; // known unwritable: never pay the lock + backoff again
|
|
2764
2988
|
const got = acquireLock(RULE_DRAFTS_LOCK, { tries: 80, waitMs: 25 });
|
|
2765
2989
|
if (!got) return false;
|
|
2766
2990
|
try {
|
|
@@ -2768,11 +2992,12 @@ function persistCaptureReceipts(mutate) {
|
|
|
2768
2992
|
const raw = readCaptureReceipts();
|
|
2769
2993
|
const rawJson = JSON.stringify(raw);
|
|
2770
2994
|
const live = raw.filter(r => r && r.key && r.line && (now - (r.ts || 0)) < CAPTURE_RECEIPT_TTL_MS);
|
|
2771
|
-
const next = (mutate(live, now) || live)
|
|
2995
|
+
const next = capCaptureReceipts(mutate(live, now) || live);
|
|
2772
2996
|
if (JSON.stringify(next) === rawJson) return 'unchanged';
|
|
2773
2997
|
return writeSidecar({ captureReceipts: next }) ? 'written' : false;
|
|
2774
2998
|
} catch { return false; } finally { releaseLock(RULE_DRAFTS_LOCK); }
|
|
2775
2999
|
}
|
|
3000
|
+
const receiptHost = () => ({ ...(HOST_PID ? { hostPid: HOST_PID } : {}), machine: MACHINE_ID });
|
|
2776
3001
|
// Record [{ key, line }] for this session (a key already recorded is skipped).
|
|
2777
3002
|
function recordCaptureReceipts(sid, items) {
|
|
2778
3003
|
try {
|
|
@@ -2782,13 +3007,26 @@ function recordCaptureReceipts(sid, items) {
|
|
|
2782
3007
|
for (const it of items) {
|
|
2783
3008
|
if (!it || !it.key || !it.line || have.has(it.key)) continue;
|
|
2784
3009
|
have.add(it.key);
|
|
2785
|
-
list.push({ key: it.key, sid: String(sid), ts: now, line: String(it.line).slice(0, 700), shown: false });
|
|
3010
|
+
list.push({ key: it.key, sid: String(sid), ts: now, line: String(it.line).slice(0, 700), shown: false, ...receiptHost() });
|
|
2786
3011
|
}
|
|
2787
3012
|
return list;
|
|
2788
3013
|
});
|
|
2789
3014
|
} catch { return false; }
|
|
2790
3015
|
}
|
|
2791
|
-
//
|
|
3016
|
+
// An unshown receipt from ANOTHER session of this same host process: the
|
|
3017
|
+
// conversation this one replaced (/clear, in-app /resume).
|
|
3018
|
+
const isPredecessorReceipt = (r, sid, now) => Boolean(HOST_PID && r && !r.shown && r.sid !== String(sid)
|
|
3019
|
+
&& Number(r.hostPid) === HOST_PID && r.machine === MACHINE_ID
|
|
3020
|
+
&& now - (r.ts || 0) < CAPTURE_RECEIPT_PREDECESSOR_MS);
|
|
3021
|
+
function adoptReceipt(r, sid, now, why) {
|
|
3022
|
+
r.adoptedFrom = r.adoptedFrom || r.sid;
|
|
3023
|
+
r.adoptedWhy = why;
|
|
3024
|
+
r.sid = String(sid);
|
|
3025
|
+
r.ts = now;
|
|
3026
|
+
if (HOST_PID) r.hostPid = HOST_PID; else delete r.hostPid;
|
|
3027
|
+
r.machine = MACHINE_ID;
|
|
3028
|
+
}
|
|
3029
|
+
// SessionStart: take over receipts whose author is provably gone (see above).
|
|
2792
3030
|
// Returns how many were adopted; prints nothing.
|
|
2793
3031
|
function adoptCaptureReceipts(sid) {
|
|
2794
3032
|
try {
|
|
@@ -2799,15 +3037,23 @@ function adoptCaptureReceipts(sid) {
|
|
|
2799
3037
|
const hostOf = new Map(rows.filter(r => r && r.id).map(r => [String(r.id), r.hostPid]));
|
|
2800
3038
|
const liveIds = new Set(pruneSessions(rows, now).map(r => String(r.id)));
|
|
2801
3039
|
let adopted = 0;
|
|
2802
|
-
const res = persistCaptureReceipts((list) => {
|
|
3040
|
+
const res = persistCaptureReceipts((list, at) => {
|
|
2803
3041
|
adopted = 0;
|
|
2804
3042
|
for (const r of list) {
|
|
2805
3043
|
if (r.shown || r.sid === String(sid)) continue;
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
if (
|
|
2809
|
-
|
|
2810
|
-
r.
|
|
3044
|
+
let why = null;
|
|
3045
|
+
if (isPredecessorReceipt(r, sid, at)) why = 'predecessor';
|
|
3046
|
+
else if (r.hostPid !== undefined && r.hostPid !== null) {
|
|
3047
|
+
if (r.machine === MACHINE_ID && isProcessAlive(r.hostPid) === false) why = 'ended';
|
|
3048
|
+
} else if (r.machine && r.machine !== MACHINE_ID) {
|
|
3049
|
+
why = null; // another machine's session: never ours to take
|
|
3050
|
+
} else if (HOST_PID && hostOf.get(String(r.sid)) === HOST_PID) {
|
|
3051
|
+
why = 'predecessor'; // a 1.86.1 receipt: the lane knows its host
|
|
3052
|
+
} else if (!liveIds.has(String(r.sid)) && (at - (r.ts || 0)) > CAPTURE_RECEIPT_HANDOFF_MS) {
|
|
3053
|
+
why = 'idle';
|
|
3054
|
+
}
|
|
3055
|
+
if (!why) continue;
|
|
3056
|
+
adoptReceipt(r, sid, at, why);
|
|
2811
3057
|
adopted++;
|
|
2812
3058
|
}
|
|
2813
3059
|
return list;
|
|
@@ -2815,15 +3061,18 @@ function adoptCaptureReceipts(sid) {
|
|
|
2815
3061
|
return res ? adopted : 0;
|
|
2816
3062
|
} catch { return 0; }
|
|
2817
3063
|
}
|
|
2818
|
-
// The block the next prompt prints for THIS session
|
|
2819
|
-
// ones it adopted
|
|
2820
|
-
//
|
|
3064
|
+
// The block the next prompt prints for THIS session: its own receipts, the
|
|
3065
|
+
// ones it adopted, and — adopted right here, in the same locked write that
|
|
3066
|
+
// marks what it shows — a replaced conversation's in this host process.
|
|
3067
|
+
// Empty string when there is nothing — the zero-cost contract.
|
|
2821
3068
|
function captureReceiptsFooter(sid) {
|
|
2822
3069
|
try {
|
|
2823
3070
|
if (!sid) return '';
|
|
2824
|
-
|
|
3071
|
+
const probeAt = Date.now();
|
|
3072
|
+
if (!readCaptureReceipts().some(r => r && !r.shown && (r.sid === String(sid) || isPredecessorReceipt(r, sid, probeAt)))) return ''; // lock-free fast path
|
|
2825
3073
|
let display = [], remaining = 0;
|
|
2826
|
-
const res = persistCaptureReceipts((list) => {
|
|
3074
|
+
const res = persistCaptureReceipts((list, now) => {
|
|
3075
|
+
for (const r of list) if (isPredecessorReceipt(r, sid, now)) adoptReceipt(r, sid, now, 'predecessor');
|
|
2827
3076
|
const pending = list.filter(r => !r.shown && r.sid === String(sid));
|
|
2828
3077
|
display = pending.slice(0, CAPTURE_RECEIPTS_SHOWN).map(r => ({ ...r }));
|
|
2829
3078
|
remaining = pending.length - display.length;
|
|
@@ -2832,14 +3081,18 @@ function captureReceiptsFooter(sid) {
|
|
|
2832
3081
|
return list;
|
|
2833
3082
|
});
|
|
2834
3083
|
if (res !== 'written' || !display.length) return '';
|
|
2835
|
-
const adopted = display.filter(r => r.adoptedFrom)
|
|
3084
|
+
const adopted = display.filter(r => r.adoptedFrom);
|
|
3085
|
+
const from = [...new Set(adopted.map(r => r.adoptedWhy || 'idle'))].map(why => why === 'predecessor'
|
|
3086
|
+
? 'the conversation this one replaced in this terminal (/clear or /resume)'
|
|
3087
|
+
: why === 'ended' ? 'a session that is no longer running'
|
|
3088
|
+
: 'a session with no activity for 30+ minutes (it may have ended)');
|
|
2836
3089
|
const lines = ['', '---',
|
|
2837
|
-
adopted === 0 ? '## ⚠️ Brain capture — what your last 🧠 BRAIN markers actually did'
|
|
2838
|
-
: adopted === display.length ? '## ⚠️ Brain capture — markers from an earlier
|
|
3090
|
+
adopted.length === 0 ? '## ⚠️ Brain capture — what your last 🧠 BRAIN markers actually did'
|
|
3091
|
+
: adopted.length === display.length ? '## ⚠️ Brain capture — markers from an earlier session that did not do what they said'
|
|
2839
3092
|
: '## ⚠️ Brain capture — markers that did not do what they said',
|
|
2840
|
-
adopted === 0
|
|
3093
|
+
adopted.length === 0
|
|
2841
3094
|
? 'The Stop hook captured these, but not the way the marker reads. If it matters, re-emit a corrected marker; nothing else is needed.'
|
|
2842
|
-
:
|
|
3095
|
+
: `The Stop hook captured these, but not the way the marker reads. Lines marked (earlier session) came from ${from.join(' or ')}: check the card before acting, and re-emit a corrected marker only when you know what it meant — never a closes: you cannot verify.`];
|
|
2843
3096
|
for (const r of display) lines.push(`- ${r.adoptedFrom ? '(earlier session) ' : ''}${r.line}`);
|
|
2844
3097
|
if (remaining > 0) lines.push(`- …and ${remaining} more, shown on the next prompt.`);
|
|
2845
3098
|
return '\n' + lines.join('\n') + '\n';
|
|
@@ -2917,6 +3170,20 @@ async function capture(lib) {
|
|
|
2917
3170
|
const gapPaths = new Set(), gapShellCmds = [], gapErrorIds = new Set();
|
|
2918
3171
|
let gapAuthored = 0, gapLatestArtifactAt = 0;
|
|
2919
3172
|
const seen = readState();
|
|
3173
|
+
// Keys this capture found already seen: they move to the young end of the
|
|
3174
|
+
// FIFO so a transcript still being re-read never ages out of it — in
|
|
3175
|
+
// doCapture on a real capture, and here on a Stop with nothing new.
|
|
3176
|
+
const hitKeys = new Set();
|
|
3177
|
+
const refreshHitKeys = () => {
|
|
3178
|
+
if (!hitKeys.size) return;
|
|
3179
|
+
const before = [...readState()];
|
|
3180
|
+
const merged = new Set(before);
|
|
3181
|
+
for (const k of hitKeys) if (merged.delete(k)) merged.add(k); // only keys the state already holds
|
|
3182
|
+
const after = [...merged];
|
|
3183
|
+
if (after.length !== before.length || after.some((k, i) => k !== before[i])) writeState(merged);
|
|
3184
|
+
};
|
|
3185
|
+
// See captureLegacyUntil: older hooks' keys only count for older events.
|
|
3186
|
+
const legacyUntil = captureLegacyUntil();
|
|
2920
3187
|
const cards = [];
|
|
2921
3188
|
const resolutions = [];
|
|
2922
3189
|
const updates = [];
|
|
@@ -3023,7 +3290,7 @@ async function capture(lib) {
|
|
|
3023
3290
|
const sourceEvent = String(e.uuid || e.id || e.timestamp || e.ts || transcriptIndex);
|
|
3024
3291
|
const messageId = sha(`msg|${sid}|${sourceEvent}|${target}|${txt}`);
|
|
3025
3292
|
const handledKey = messageHandledKey(messageId);
|
|
3026
|
-
if (seen.has(handledKey)) continue;
|
|
3293
|
+
if (seen.has(handledKey)) { hitKeys.add(handledKey); continue; }
|
|
3027
3294
|
messages.push({
|
|
3028
3295
|
id: messageId,
|
|
3029
3296
|
from: sid,
|
|
@@ -3111,6 +3378,14 @@ async function capture(lib) {
|
|
|
3111
3378
|
// repair of THAT card (engine STUB REPAIR, rewritten in place, never
|
|
3112
3379
|
// a sibling); any other old key simply means "already captured".
|
|
3113
3380
|
//
|
|
3381
|
+
// …but ONLY for a transcript event older than `legacyUntil`, the
|
|
3382
|
+
// last moment an older hook could have keyed it (third review: the
|
|
3383
|
+
// old-cut key "X" of "X Q: and A: …" is byte-for-byte this grammar's
|
|
3384
|
+
// key for an earlier "X ev: README.md", so a new note was folded
|
|
3385
|
+
// into an unrelated card, dropped, or had its closes: skipped — long
|
|
3386
|
+
// after any upgrade). A project that never ran an older hook has
|
|
3387
|
+
// legacyUntil 0 and never looks.
|
|
3388
|
+
//
|
|
3114
3389
|
// ~ and ✓ keep their text bypass (a self-heal re-stamp of unchanged
|
|
3115
3390
|
// text is a NEW marker and must apply), but ONE transcript line
|
|
3116
3391
|
// applies once: Stop re-reads the whole transcript, and re-applying
|
|
@@ -3118,34 +3393,51 @@ async function capture(lib) {
|
|
|
3118
3393
|
// another session's newer card, and landed a thin ~ on a different
|
|
3119
3394
|
// card once its own was resolved (review 2026-09-18). The key is the
|
|
3120
3395
|
// transcript event + the line, recorded with the rest of the seen
|
|
3121
|
-
// set only when the batch is durably written or queued.
|
|
3396
|
+
// set only when the batch is durably written or queued. An older
|
|
3397
|
+
// hook wrote no such key, so a ~ / ✓ line from an event older than
|
|
3398
|
+
// `legacyUntil` counts as applied: every Stop of those hooks applied
|
|
3399
|
+
// it (third review: at the first 1.86.1 Stop, old lines re-applied
|
|
3400
|
+
// over newer cards — a milestone wholesale-replaced, two unrelated
|
|
3401
|
+
// cards archived by a re-read ✓).
|
|
3402
|
+
// An event with no readable timestamp counts as NEW: not looking at
|
|
3403
|
+
// an old key costs at most a stub that stays as 1.86.0 cut it,
|
|
3404
|
+
// while looking wrongly folds a real note into someone else's card.
|
|
3405
|
+
const beforeUpgrade = legacyUntil > 0 && Number.isFinite(entryAt) && entryAt < legacyUntil;
|
|
3122
3406
|
const additive = type !== '✓' && type !== '~';
|
|
3123
3407
|
const key = sha((type + '|' + area + '|' + (additive ? bodyWithCloses : body)).toLowerCase());
|
|
3124
|
-
const legacyKey = additive && closes ? sha((type + '|' + area + '|' + body).toLowerCase()) : null;
|
|
3408
|
+
const legacyKey = beforeUpgrade && additive && closes ? sha((type + '|' + area + '|' + body).toLowerCase()) : null;
|
|
3125
3409
|
let repairStub = '';
|
|
3126
3410
|
if (additive) {
|
|
3127
|
-
if (seen.has(key)) { ledger.push({ action: 'skipped-seen', area, preview }); continue; }
|
|
3411
|
+
if (seen.has(key)) { hitKeys.add(key); legacyClaimed.add(key); ledger.push({ action: 'skipped-seen', area, preview }); continue; }
|
|
3128
3412
|
// Two DIFFERENT notes that share the words before a key had ONE
|
|
3129
3413
|
// key under any cut-body rule: the first landed and the second
|
|
3130
3414
|
// was skipped as "seen" — never captured. So an old key is
|
|
3131
3415
|
// claimed by the FIRST marker of a capture that carries it; a
|
|
3132
3416
|
// later one with the same old key is a note that never landed.
|
|
3417
|
+
// Every additive marker claims its OWN key too: a later marker
|
|
3418
|
+
// whose old cut equals it is a different note of this batch.
|
|
3133
3419
|
if (legacyKey && seen.has(legacyKey) && !legacyClaimed.has(legacyKey)) {
|
|
3134
|
-
legacyClaimed.add(legacyKey); seen.add(key);
|
|
3135
|
-
ledger.push({ action: 'skipped-seen', area, preview }); continue;
|
|
3420
|
+
legacyClaimed.add(legacyKey); legacyClaimed.add(key); hitKeys.add(legacyKey); seen.add(key);
|
|
3421
|
+
ledger.push({ action: 'skipped-seen', area, preview, olderHook: true }); continue;
|
|
3136
3422
|
}
|
|
3137
|
-
const olderCut = publishedHookCuts(markerBody).find(cut => seen.has(sha((type + '|' + area + '|' + cut).toLowerCase())));
|
|
3138
|
-
seen.add(key);
|
|
3423
|
+
const olderCut = beforeUpgrade ? publishedHookCuts(markerBody).find(cut => seen.has(sha((type + '|' + area + '|' + cut).toLowerCase()))) : undefined;
|
|
3424
|
+
seen.add(key); legacyClaimed.add(key);
|
|
3139
3425
|
if (olderCut !== undefined) {
|
|
3140
3426
|
const olderKey = sha((type + '|' + area + '|' + olderCut).toLowerCase());
|
|
3141
3427
|
const landing = closes && !closesAnchored ? bodyWithCloses : body;
|
|
3142
3428
|
const squash = (s) => String(s).replace(/\s+/g, '').toLowerCase();
|
|
3143
|
-
|
|
3429
|
+
// Judged on the text BEFORE any closes: — the published hooks
|
|
3430
|
+
// cut there and ACTED on it, so a card they landed that way
|
|
3431
|
+
// was complete, and its close is final. (A "closes:txt_…"
|
|
3432
|
+
// with no space is prose to this grammar; "repairing" the
|
|
3433
|
+
// card only appended that junk to it.)
|
|
3434
|
+
const beforeCloses = landing.replace(/\s+closes:[\s\S]*$/i, '');
|
|
3435
|
+
const longer = squash(beforeCloses).length > squash(olderCut).length && squash(beforeCloses).startsWith(squash(olderCut));
|
|
3144
3436
|
// Claimed per capture, as above: only the first marker
|
|
3145
3437
|
// carrying an old key repairs its stub (or is skipped); a
|
|
3146
3438
|
// later one with the same old key lands as the note it is.
|
|
3147
3439
|
if (!legacyClaimed.has(olderKey)) {
|
|
3148
|
-
legacyClaimed.add(olderKey);
|
|
3440
|
+
legacyClaimed.add(olderKey); hitKeys.add(olderKey);
|
|
3149
3441
|
if (longer) repairStub = olderCut;
|
|
3150
3442
|
else { ledger.push({ action: 'skipped-seen', area, preview, olderHook: true }); continue; }
|
|
3151
3443
|
}
|
|
@@ -3153,7 +3445,8 @@ async function capture(lib) {
|
|
|
3153
3445
|
} else {
|
|
3154
3446
|
const sourceEvent = String(e.uuid || e.id || e.timestamp || e.ts || transcriptIndex);
|
|
3155
3447
|
const appliedKey = sha(('applied|' + sourceEvent + '|' + type + '|' + area + '|' + body).toLowerCase());
|
|
3156
|
-
if (seen.has(appliedKey)) { ledger.push({ action: 'skipped-applied', area, preview }); continue; }
|
|
3448
|
+
if (seen.has(appliedKey)) { hitKeys.add(appliedKey); ledger.push({ action: 'skipped-applied', area, preview }); continue; }
|
|
3449
|
+
if (beforeUpgrade) { ledger.push({ action: 'skipped-applied', area, preview, olderHook: true }); continue; }
|
|
3157
3450
|
seen.add(appliedKey);
|
|
3158
3451
|
}
|
|
3159
3452
|
// A malformed suffix segment went back into the card text — say so
|
|
@@ -3254,7 +3547,7 @@ async function capture(lib) {
|
|
|
3254
3547
|
if (!detail && p.num) { const nm = p.num.exec(cmd); if (nm) detail = '#' + nm[1]; } // pull the PR/issue number out separately
|
|
3255
3548
|
const summary = `${p.kind}${detail ? ' ' + detail : ''}`;
|
|
3256
3549
|
const key = sha(('ship|' + p.area + '|' + summary).toLowerCase());
|
|
3257
|
-
if (seen.has(key)) break;
|
|
3550
|
+
if (seen.has(key)) { hitKeys.add(key); break; } // already captured this ship
|
|
3258
3551
|
seen.add(key);
|
|
3259
3552
|
// #auto marks machine-harvested provenance: the repeat-detector demands an
|
|
3260
3553
|
// entity-token match on these (they're dense with generic ship verbs) and
|
|
@@ -3413,6 +3706,12 @@ async function capture(lib) {
|
|
|
3413
3706
|
// the authoritative drain happens INSIDE the brain lock (doCapture), so two
|
|
3414
3707
|
// concurrent sessions can never both land the same queued batch.
|
|
3415
3708
|
if (!cards.length && !resolutions.length && !updates.length && !readPendingCaptures().length) {
|
|
3709
|
+
// A Stop with nothing new is the COMMON case for a long session, and it
|
|
3710
|
+
// is exactly when the keys of a still-live transcript must not age out
|
|
3711
|
+
// of the capped FIFO — so the keys this run HIT move to the young end
|
|
3712
|
+
// here too (third review, R5). Only keys the file already holds, and
|
|
3713
|
+
// only when the order really changes: a no-op Stop stays a no-op.
|
|
3714
|
+
refreshHitKeys();
|
|
3416
3715
|
// Record the commit baseline / advance even with nothing to capture, so
|
|
3417
3716
|
// the next run doesn't re-scan the same commits.
|
|
3418
3717
|
if (newLastCommit && newLastCommit !== prevCommit) writeLastCommit(newLastCommit);
|
|
@@ -3432,6 +3731,10 @@ async function capture(lib) {
|
|
|
3432
3731
|
// cards into [Area] containers, and wires [[wikilink]] connections.
|
|
3433
3732
|
const doCapture = async (locked) => {
|
|
3434
3733
|
const merged = readState(); for (const k of seen) merged.add(k);
|
|
3734
|
+
// Every key this capture hit moves to the young end of the FIFO (see
|
|
3735
|
+
// STATE_SEEN_MAX): Set order is first insertion, and a key re-read at
|
|
3736
|
+
// every Stop never refreshed, so it aged out while its line was live.
|
|
3737
|
+
for (const k of hitKeys) { merged.delete(k); merged.add(k); }
|
|
3435
3738
|
// Own-batch snapshot BEFORE the drain merges queued peers' batches in:
|
|
3436
3739
|
// if the brain write fails we re-queue only OUR contribution (drained
|
|
3437
3740
|
// batches stay queued — ids/paths clear only after a durable write),
|
|
@@ -3458,14 +3761,14 @@ async function capture(lib) {
|
|
|
3458
3761
|
}
|
|
3459
3762
|
// Drain the queue UNDER the lock: read, land, clear-by-id — a peer's
|
|
3460
3763
|
// batch queued after this read survives, and no batch lands twice.
|
|
3461
|
-
const pendingBatches = readPendingCaptures();
|
|
3462
|
-
const drainedPendingIds = new Set(pendingBatches.map(b => b
|
|
3463
|
-
const drainedOrphanPaths = pendingBatches.map(b => b
|
|
3764
|
+
const pendingBatches = readPendingCaptures().filter(b => b && typeof b === 'object');
|
|
3765
|
+
const drainedPendingIds = new Set(pendingBatches.map(b => b.id).filter(Boolean));
|
|
3766
|
+
const drainedOrphanPaths = pendingBatches.map(b => b.__orphanPath).filter(Boolean);
|
|
3767
|
+
const drainedCards = [], drainedResolutions = [], drainedUpdates = [];
|
|
3464
3768
|
for (const b of pendingBatches) {
|
|
3465
|
-
|
|
3466
|
-
for (const
|
|
3467
|
-
for (const
|
|
3468
|
-
for (const u of (Array.isArray(b.updates) ? b.updates : [])) updates.push(u);
|
|
3769
|
+
for (const c of (Array.isArray(b.cards) ? b.cards : [])) { cards.push(c); drainedCards.push(c); }
|
|
3770
|
+
for (const r of (Array.isArray(b.resolutions) ? b.resolutions : [])) { resolutions.push(r); drainedResolutions.push(r); }
|
|
3771
|
+
for (const u of (Array.isArray(b.updates) ? b.updates : [])) { updates.push(u); drainedUpdates.push(u); }
|
|
3469
3772
|
}
|
|
3470
3773
|
if (!cards.length && !resolutions.length && !updates.length) return null;
|
|
3471
3774
|
const brainBuf = fs.readFileSync(BRAIN);
|
|
@@ -3481,37 +3784,84 @@ async function capture(lib) {
|
|
|
3481
3784
|
// filtering on the tag alone is a silent-loss bug, and silent loss is
|
|
3482
3785
|
// the one thing the brain may never do).
|
|
3483
3786
|
const isCommitCard = (c) => c && c.createdVia === 'commit' && /#commit-[0-9a-f]{7}/i.test(String(c.text || ''));
|
|
3484
|
-
let
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3787
|
+
let carded = null;
|
|
3788
|
+
const withoutCarded = async (list) => {
|
|
3789
|
+
try {
|
|
3790
|
+
if (!list.some(isCommitCard)) return list;
|
|
3791
|
+
if (!carded) {
|
|
3792
|
+
const { struct: cur } = await lib.parseKlypix(brainBuf);
|
|
3793
|
+
const found = new Set();
|
|
3794
|
+
for (const c of (cur.cards || []))
|
|
3795
|
+
for (const m of String(c.text || '').matchAll(/#commit-([0-9a-f]{7})/gi)) found.add(m[1].toLowerCase());
|
|
3796
|
+
carded = found;
|
|
3797
|
+
}
|
|
3798
|
+
return list.filter(c => {
|
|
3492
3799
|
if (!isCommitCard(c)) return true;
|
|
3493
3800
|
const m = /#commit-([0-9a-f]{7})/i.exec(String(c.text || ''));
|
|
3494
|
-
return !
|
|
3801
|
+
return !carded.has(m[1].toLowerCase());
|
|
3495
3802
|
});
|
|
3803
|
+
} catch { return list; /* parse failed → land unfiltered; the supersede pass copes */ }
|
|
3804
|
+
};
|
|
3805
|
+
const toEngine = (c) => ({ text: c.text, color: '#e8e8ed', borderColor: c.borderColor, area: c.area, createdVia: c.createdVia || 'claude-code', ...(c.closes ? { closes: c.closes } : {}), ...(c.closes && typeof c.closesFallbackText === 'string' ? { closesFallbackText: c.closesFallbackText, closesAnchored: c.closesAnchored === true } : {}), ...(typeof c.repairStub === 'string' ? { repairStub: c.repairStub } : {}), ...(c.evidence ? { evidence: c.evidence } : {}), ...(c.verify ? { verify: c.verify } : {}) });
|
|
3806
|
+
// → null when this subset has nothing left to land (every card was
|
|
3807
|
+
// already carded by the git hook), else the engine's result.
|
|
3808
|
+
const land = async (cs, rs, us) => {
|
|
3809
|
+
const landedCards = await withoutCarded(cs);
|
|
3810
|
+
if (!landedCards.length && !rs.length && !us.length) return null;
|
|
3811
|
+
return lib.captureIntoBrain(brainBuf, { cards: landedCards.map(toEngine), resolutions: rs, updates: us });
|
|
3812
|
+
};
|
|
3813
|
+
// A throw in the engine used to lose the WHOLE batch — and a queued
|
|
3814
|
+
// batch that throws was re-drained, and threw, at every later Stop of
|
|
3815
|
+
// every session, so capture was dead project-wide until someone deleted
|
|
3816
|
+
// the queue file (review 2026-09-18, third round). With drained
|
|
3817
|
+
// batches merged in, a failure is retried with this session's own
|
|
3818
|
+
// batch alone (the drained ones are blamed, and set aside once they
|
|
3819
|
+
// have failed DRAIN_FAILURES_MAX times), then with the drained batches
|
|
3820
|
+
// alone (this session's markers stay in its transcript for the next
|
|
3821
|
+
// Stop). Only when both halves fail does nothing land, as before.
|
|
3822
|
+
let res, scope = 'all', batchError = null;
|
|
3823
|
+
try { res = await land(cards, resolutions, updates); }
|
|
3824
|
+
catch (error) {
|
|
3825
|
+
if (!pendingBatches.length) throw error;
|
|
3826
|
+
batchError = error;
|
|
3827
|
+
try { res = await land(ownCards, ownResolutions, ownUpdates); scope = 'own'; }
|
|
3828
|
+
catch (ownError) {
|
|
3829
|
+
res = await land(drainedCards, drainedResolutions, drainedUpdates); // a throw here: both halves fail
|
|
3830
|
+
scope = 'drained'; batchError = ownError;
|
|
3496
3831
|
}
|
|
3497
|
-
}
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
// the commits ARE durable — so the same range never re-scans.
|
|
3502
|
-
writeState(merged);
|
|
3503
|
-
writeLastCommit(newLastCommit);
|
|
3832
|
+
}
|
|
3833
|
+
const ownLanded = scope !== 'drained', drainedLanded = scope !== 'own';
|
|
3834
|
+
const quarantined = scope === 'own' ? recordDrainFailure(pendingBatches, batchError) : [];
|
|
3835
|
+
const clearDrained = () => {
|
|
3504
3836
|
if (drainedPendingIds.size) updatePendingCaptures((current) => current.filter(b => b && !drainedPendingIds.has(b.id)));
|
|
3505
3837
|
clearDrainedOrphans(drainedOrphanPaths);
|
|
3838
|
+
};
|
|
3839
|
+
const advanceOwn = () => {
|
|
3840
|
+
writeState(merged);
|
|
3841
|
+
writeLastCommit(newLastCommit); // advance the commit baseline only after a durable write
|
|
3842
|
+
// Same discipline for the two ship channels: the queue is consumed and
|
|
3843
|
+
// the observation baseline advances ONLY now that the cards are durable.
|
|
3506
3844
|
if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
|
|
3507
3845
|
advanceShipBaseline(lib);
|
|
3846
|
+
};
|
|
3847
|
+
const reportBatch = () => {
|
|
3848
|
+
if (scope === 'all') return;
|
|
3849
|
+
const msg = String((batchError && batchError.message) || batchError).slice(0, 160);
|
|
3850
|
+
const what = scope === 'own'
|
|
3851
|
+
? `a queued batch from an earlier capture threw (${msg}); this session's own batch landed without it${quarantined.length ? `, and ${quarantined.length} batch(es) that failed ${DRAIN_FAILURES_MAX} times were set aside as ${path.basename(PENDING_CAPTURES_FILE)}.poison-*` : ' — it stays queued'}`
|
|
3852
|
+
: `this session's batch threw (${msg}); the queued batches landed without it, and its markers stay in the transcript for the next Stop`;
|
|
3853
|
+
appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'capture', ok: false, err: `batch-isolated:${scope} — ${what}`.slice(0, 400) }, 500);
|
|
3854
|
+
process.stderr.write(`[brain] capture: ${what}\n`);
|
|
3855
|
+
};
|
|
3856
|
+
if (!res) {
|
|
3857
|
+
// Everything gathered was already in the brain (the git hook carded
|
|
3858
|
+
// it first). Advance every baseline exactly like a successful write —
|
|
3859
|
+
// the commits ARE durable — so the same range never re-scans.
|
|
3860
|
+
if (ownLanded) advanceOwn();
|
|
3861
|
+
if (drainedLanded) clearDrained();
|
|
3862
|
+
reportBatch();
|
|
3508
3863
|
return null;
|
|
3509
3864
|
}
|
|
3510
|
-
const res = await lib.captureIntoBrain(brainBuf, {
|
|
3511
|
-
cards: landedCards.map(c => ({ text: c.text, color: '#e8e8ed', borderColor: c.borderColor, area: c.area, createdVia: c.createdVia || 'claude-code', ...(c.closes ? { closes: c.closes } : {}), ...(c.closes && typeof c.closesFallbackText === 'string' ? { closesFallbackText: c.closesFallbackText, closesAnchored: c.closesAnchored === true } : {}), ...(typeof c.repairStub === 'string' ? { repairStub: c.repairStub } : {}), ...(c.evidence ? { evidence: c.evidence } : {}), ...(c.verify ? { verify: c.verify } : {}) })),
|
|
3512
|
-
resolutions,
|
|
3513
|
-
updates,
|
|
3514
|
-
});
|
|
3515
3865
|
// Re-pack the whole grid so a container that grew never overlaps its neighbor.
|
|
3516
3866
|
let out = res.buffer; try { out = (await lib.tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
|
|
3517
3867
|
try {
|
|
@@ -3523,25 +3873,22 @@ async function capture(lib) {
|
|
|
3523
3873
|
// durably (drained batches stay queued — their ids/paths were never
|
|
3524
3874
|
// cleared), advance baselines only if that queue write succeeded,
|
|
3525
3875
|
// and exit 0 by contract. Field case: the 2026-08-12 EPERM landed
|
|
3526
|
-
// on a session's FINAL Stop and its markers had nowhere to go.
|
|
3527
|
-
|
|
3876
|
+
// on a session's FINAL Stop and its markers had nowhere to go. An
|
|
3877
|
+
// own batch the engine could not apply is not queued: its markers
|
|
3878
|
+
// stay in the transcript.
|
|
3879
|
+
const queued = ownLanded && (ownCards.length || ownResolutions.length || ownUpdates.length)
|
|
3528
3880
|
? queuePendingCapture({ id: `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`, ts: nowIso(), cards: ownCards, resolutions: ownResolutions, updates: ownUpdates })
|
|
3529
|
-
:
|
|
3881
|
+
: ownLanded;
|
|
3530
3882
|
if (queued) { writeState(merged); writeLastCommit(newLastCommit); }
|
|
3531
3883
|
appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'capture', ok: false, err: `write failed (${e?.code || String(e?.message || e).slice(0, 80)}) — own batch ${queued ? 'QUEUED durably' : 'NOT queued; markers remain re-gatherable'}; drained batches remain queued` }, 500);
|
|
3532
3884
|
process.stderr.write(`[brain] capture write failed (${e?.code || 'error'}) — ${queued ? 'batch queued durably, nothing lost' : 'queueing ALSO failed; markers stay in the transcript for the next Stop'}\n`);
|
|
3533
3885
|
return null;
|
|
3534
3886
|
}
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
clearDrainedOrphans(drainedOrphanPaths);
|
|
3539
|
-
// Same discipline for the two ship channels: the queue is consumed and
|
|
3540
|
-
// the observation baseline advances ONLY now that the cards are durable.
|
|
3541
|
-
if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
|
|
3542
|
-
advanceShipBaseline(lib);
|
|
3887
|
+
if (ownLanded) advanceOwn();
|
|
3888
|
+
if (drainedLanded) clearDrained();
|
|
3889
|
+
reportBatch();
|
|
3543
3890
|
try { await refreshAgentsBrief(lib, out); } catch { /* AGENTS.md refresh is best-effort */ }
|
|
3544
|
-
return res.stats;
|
|
3891
|
+
return { ...res.stats, batchScope: scope, ...(batchError ? { batchError: String(batchError.message || batchError).slice(0, 160) } : {}), ...(quarantined.length ? { quarantined } : {}) };
|
|
3545
3892
|
};
|
|
3546
3893
|
// Canonical cross-process lock (heartbeat + token-checked release, shared
|
|
3547
3894
|
// with the MCP engine and the desktop app). Stale bundles missing the module
|
|
@@ -3561,7 +3908,8 @@ async function capture(lib) {
|
|
|
3561
3908
|
// without enrichment.mjs just skips, costing recall, never correctness.
|
|
3562
3909
|
// An update counts (1.86.1): a ~ that rewrote or amended its card carries a
|
|
3563
3910
|
// q: too, and the sidecar joins a question to whichever card holds its body.
|
|
3564
|
-
|
|
3911
|
+
// Not when only queued batches landed (this session's own batch failed).
|
|
3912
|
+
if (enrichmentPairs.length && stats.batchScope !== 'drained' && (stats.added > 0 || stats.updated > 0 || stats.repaired > 0)) {
|
|
3565
3913
|
try {
|
|
3566
3914
|
const enrich = await import(new URL('./enrichment.mjs', import.meta.url).href);
|
|
3567
3915
|
enrich.recordEnrichment(BRAIN, enrichmentPairs.map(pair => ({ body: pair.body, question: pair.question })));
|
|
@@ -3644,16 +3992,23 @@ async function capture(lib) {
|
|
|
3644
3992
|
// replaced, and a closes: that named no live card — say so, and how to
|
|
3645
3993
|
// finish the job.
|
|
3646
3994
|
if (typeof lib.formatCaptureReceipts === 'function') {
|
|
3647
|
-
for (const line of lib.formatCaptureReceipts({ closedCards: stats.closedCards, updateAmended, closesKept: stats.closesKept, stubRepairs: stats.stubRepairs }, { maxEach: 3 })) process.stderr.write(`[brain] ${line}\n`);
|
|
3648
|
-
if (Array.isArray(stats.stubRepairMissing) && stats.stubRepairMissing.length) {
|
|
3649
|
-
process.stderr.write(`[brain] ${stats.stubRepairMissing.length} marker(s) an older hook already landed cut short were NOT re-added: that card is no longer live (archived, superseded or edited since) — e.g. "${stats.stubRepairMissing[0].stub}"\n`);
|
|
3650
|
-
}
|
|
3995
|
+
for (const line of lib.formatCaptureReceipts({ closedCards: stats.closedCards, updateAmended, closesKept: stats.closesKept, stubRepairs: stats.stubRepairs, stubRepairMissing: stats.stubRepairMissing, captureErrors: stats.captureErrors }, { maxEach: 3 })) process.stderr.write(`[brain] ${line}\n`);
|
|
3651
3996
|
// …and the MODEL has to hear the ones that need a re-emit: a Stop
|
|
3652
3997
|
// hook's exit-0 stderr never reaches it, so these go to a sidecar the
|
|
3653
|
-
// next prompt prints once (captureReceiptsFooter).
|
|
3654
|
-
|
|
3998
|
+
// next prompt prints once (captureReceiptsFooter). That includes a
|
|
3999
|
+
// note folded into an existing card as a stub repair, one NOT re-added
|
|
4000
|
+
// because an older hook captured it, and a marker the engine could
|
|
4001
|
+
// not apply (third review: those went to stderr only).
|
|
4002
|
+
const forModel = lib.formatCaptureReceipts({ updateAmended, closesKept: stats.closesKept, closeRefused: stats.closeRefused, stubRepairs: stats.stubRepairs, stubRepairMissing: stats.stubRepairMissing, captureErrors: stats.captureErrors }, { maxEach: 3 });
|
|
3655
4003
|
if (forModel.length) recordCaptureReceipts(sid, forModel.map(line => ({ key: sha('capture|' + line), line })));
|
|
3656
4004
|
}
|
|
4005
|
+
// A capture that had to land around a failing batch (see doCapture).
|
|
4006
|
+
if (stats.batchScope === 'drained' || (Array.isArray(stats.quarantined) && stats.quarantined.length)) {
|
|
4007
|
+
const lines = [];
|
|
4008
|
+
if (stats.batchScope === 'drained') lines.push(`⚠️ capture failed for this session's markers (${stats.batchError || 'engine error'}); nothing of this turn landed yet. They stay in the transcript and are retried at the next Stop — if this repeats, re-emit the important ones one per turn.`);
|
|
4009
|
+
for (const q of (stats.quarantined || []).slice(0, 3)) lines.push(`⚠️ a queued capture batch failed ${DRAIN_FAILURES_MAX} times and was set aside, not landed (${q.cards} card(s), ${q.updates} update(s), ${q.resolutions} resolve(s)${q.first ? `, e.g. "${q.first}"` : ''}): ${q.file}`);
|
|
4010
|
+
recordCaptureReceipts(sid, lines.map(line => ({ key: sha('capture|' + line), line })));
|
|
4011
|
+
}
|
|
3657
4012
|
if (partialSkipped || stats.partialSkipped) {
|
|
3658
4013
|
// `partialSkipped` counts MARKERS whose strongest outcome was a skip;
|
|
3659
4014
|
// the engine's stat counts CARDS. They differ when one ✓ hit near-tie
|
|
@@ -3694,7 +4049,7 @@ async function capture(lib) {
|
|
|
3694
4049
|
// whose only cards were
|
|
3695
4050
|
// machine-harvested ships/commits has still recorded no reasoning, and must
|
|
3696
4051
|
// stay nudgeable. brain_note (MCP) writes the same receipt for the same id.
|
|
3697
|
-
if (gapAuthored > 0 && stats.added + (stats.resolved || 0) + (stats.updated || 0) > 0) {
|
|
4052
|
+
if (gapAuthored > 0 && stats.batchScope !== 'drained' && stats.added + (stats.resolved || 0) + (stats.updated || 0) > 0) {
|
|
3698
4053
|
try {
|
|
3699
4054
|
const gapLib = await import(new URL('./capture-gap.mjs', import.meta.url).href);
|
|
3700
4055
|
gapLib.recordSessionCapture?.(String(input.session_id || ''), undefined, Date.now(), { project: CWD, head: newLastCommit || '' });
|
package/src/klypix-core.mjs
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
splitQueryTokens, scoreCardsAgainstQuery, correctionOverlaysFor, currentGuidanceFor, currentGuidancePrefix,
|
|
38
38
|
isFastDecayCard, isUnresolvedOpenCard, isSkillCard, validateGuard, guardSidecarPathFor, ensureGuardSidecar, DECAY_STALE_MS, formatDecayAge,
|
|
39
39
|
isPlanCard, planFulfillmentFor, PLAN_PAIR_SIM_BRAIN, isAgconfTwinId,
|
|
40
|
-
readPendingShips, clearPendingShips, pendingShipCards, formatCaptureReceipts, parseVerifySuffix,
|
|
40
|
+
readPendingShips, clearPendingShips, pendingShipCards, formatCaptureReceipts, parseVerifySuffix, amendmentFirst,
|
|
41
41
|
} from './klypix-format.mjs';
|
|
42
42
|
import { findProjectBrain, postPresenceMessage, readReleaseLease } from './agent-presence.mjs';
|
|
43
43
|
import { collectRepoState, commitsInRange, makeContainmentProbe } from './repo-state.mjs';
|
|
@@ -514,7 +514,10 @@ export async function opBrainTaskContext({
|
|
|
514
514
|
id: hit.card.id,
|
|
515
515
|
evidence: evidenceFor(hit.card),
|
|
516
516
|
area: flat(hit.card.area) || 'Notes',
|
|
517
|
-
|
|
517
|
+
// Newest amendment first, as in the hook's previews: a thin ~ is appended
|
|
518
|
+
// UNDER the claim it corrects, and 420 characters of a long card never
|
|
519
|
+
// reached it (review 2026-09-18, third round).
|
|
520
|
+
text: clip(amendmentFirst(hit.card.text), 420),
|
|
518
521
|
score: Number(hit.score.toFixed(2)),
|
|
519
522
|
correctedBy: correction ? clip(correction.text, 420) : null,
|
|
520
523
|
...(correction ? { correctedById: correction.id } : {}),
|
package/src/klypix-format.mjs
CHANGED
|
@@ -4250,36 +4250,92 @@ export const isThinUpdate = (words, targetWords) => words < UPDATE_MIN_WORDS &&
|
|
|
4250
4250
|
// thin ~ naming a long path, URL or 40-char SHA was never recognised as
|
|
4251
4251
|
// already said, and re-appended itself at every Stop (review 2026-09-18, the
|
|
4252
4252
|
// same unbounded stacking as the 85-line ✔ partial incident). The body is now
|
|
4253
|
-
// matched
|
|
4254
|
-
//
|
|
4255
|
-
//
|
|
4256
|
-
//
|
|
4257
|
-
// long token is
|
|
4258
|
-
|
|
4259
|
-
|
|
4253
|
+
// matched with the card's whitespace taken out of the way instead: a space in
|
|
4254
|
+
// the body matches any run of whitespace in the card, and between two adjacent
|
|
4255
|
+
// non-space characters the card may carry one line break — the only thing a
|
|
4256
|
+
// mid-word wrap inserts. A match that starts or ends at a mid-word break is not
|
|
4257
|
+
// on a word boundary (a body that is only the head or tail of a long token is
|
|
4258
|
+
// not "said").
|
|
4259
|
+
//
|
|
4260
|
+
// No regular expression (review 2026-09-18, third round): the first version
|
|
4261
|
+
// compiled the body into a pattern, and V8 threw "Stack overflow" from exec on
|
|
4262
|
+
// a ~5,000-character body — outside its try, so one long thin ~ threw
|
|
4263
|
+
// captureIntoBrain and, once queued, wedged capture for the whole project. It
|
|
4264
|
+
// also lower-cased the body before an /iu match, and "İ".toLowerCase() is two
|
|
4265
|
+
// code units that simple case folding never maps back, so "İstanbul" did not
|
|
4266
|
+
// match itself. Both sides are now lower-cased the SAME way, one code unit at a
|
|
4267
|
+
// time, into a whitespace-free string with a map back to the card's offsets;
|
|
4268
|
+
// matching is a plain indexOf, linear in the card and the body.
|
|
4260
4269
|
function isMidWordBreak(raw, nl) {
|
|
4261
4270
|
if (raw[nl] !== '\n') return false;
|
|
4262
4271
|
const start = raw.lastIndexOf('\n', nl - 1) + 1;
|
|
4263
4272
|
const line = raw.slice(start, nl);
|
|
4264
4273
|
return line.length === brainCPL() && !/\s/.test(line) && /\S/.test(raw[nl + 1] || '');
|
|
4265
4274
|
}
|
|
4275
|
+
// A catch handler that itself throws would defeat the containment it is there
|
|
4276
|
+
// for, so the two fields a failure is reported with are read defensively: the
|
|
4277
|
+
// thing that just failed may be exactly the thing whose property throws.
|
|
4278
|
+
const errText = (error) => { try { return String((error && error.message) || error).slice(0, 160); } catch { return 'unknown error'; } };
|
|
4279
|
+
function describeFailed(item, textKey) {
|
|
4280
|
+
let area = null, text = '';
|
|
4281
|
+
try { area = (item && item.area) || null; if (area !== null) area = String(area).slice(0, 60); } catch { area = null; }
|
|
4282
|
+
try { text = String((item && item[textKey]) || '').slice(0, 90); } catch { text = ''; }
|
|
4283
|
+
return { area, text };
|
|
4284
|
+
}
|
|
4285
|
+
// Whitespace-free, unit-by-unit lower-cased copy of s, with, per unit, the
|
|
4286
|
+
// offset in s it came from and whether whitespace preceded it.
|
|
4287
|
+
function squashForSays(s) {
|
|
4288
|
+
let out = '';
|
|
4289
|
+
const at = [], spaced = [];
|
|
4290
|
+
let sawSpace = false;
|
|
4291
|
+
for (let i = 0; i < s.length; i++) {
|
|
4292
|
+
const c = s[i];
|
|
4293
|
+
if (/\s/.test(c)) { sawSpace = true; continue; }
|
|
4294
|
+
const lc = c.toLowerCase();
|
|
4295
|
+
for (let k = 0; k < lc.length; k++) { out += lc[k]; at.push(i); spaced.push(sawSpace && k === 0); }
|
|
4296
|
+
sawSpace = false;
|
|
4297
|
+
}
|
|
4298
|
+
return { out, at, spaced };
|
|
4299
|
+
}
|
|
4300
|
+
const SAY_WORD_CHAR = /[\p{L}\p{N}]/u;
|
|
4301
|
+
const codePointBefore = (s, i) => { if (i <= 0) return ''; const lo = s.charCodeAt(i - 1); return (lo >= 0xdc00 && lo <= 0xdfff && i >= 2) ? s.slice(i - 2, i) : s[i - 1]; };
|
|
4302
|
+
const codePointAt = (s, i) => (i >= s.length ? '' : String.fromCodePoint(s.codePointAt(i)));
|
|
4266
4303
|
export function cardAlreadySays(cardText, body) {
|
|
4267
|
-
const want = sayNorm(body);
|
|
4268
|
-
if (!want) return false;
|
|
4269
4304
|
const raw = String(cardText || '');
|
|
4270
|
-
const
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4305
|
+
const want = squashForSays(String(body || '').trim());
|
|
4306
|
+
if (!want.out) return false;
|
|
4307
|
+
const hay = squashForSays(raw);
|
|
4308
|
+
const n = want.out.length;
|
|
4309
|
+
// Only offsets that could START a word-bounded match are tried. Restarting
|
|
4310
|
+
// indexOf at pos+1 re-scanned from every position a long repetitive card
|
|
4311
|
+
// matched at and then rejected on the boundary check — 2.4 s on a 60k card
|
|
4312
|
+
// of one repeated letter. The leading-boundary rule is the same one applied
|
|
4313
|
+
// below; it just runs first, so a card with one word head has one candidate.
|
|
4314
|
+
const starts = [];
|
|
4315
|
+
for (let k = 0; k < hay.out.length; k++) {
|
|
4316
|
+
const at = hay.at[k];
|
|
4317
|
+
if (k > 0 && hay.at[k - 1] === at) continue; // second unit of one expanded char
|
|
4318
|
+
if (at === 0 || !SAY_WORD_CHAR.test(codePointBefore(raw, at))) starts.push(k);
|
|
4319
|
+
}
|
|
4320
|
+
for (const pos of starts) {
|
|
4321
|
+
if (!hay.out.startsWith(want.out, pos)) continue;
|
|
4322
|
+
// The card's whitespace between two matched units must be what the
|
|
4323
|
+
// body has there: some whitespace where the body has a space, and at
|
|
4324
|
+
// most one line break where it has none.
|
|
4325
|
+
let fits = true;
|
|
4326
|
+
for (let k = 1; k < n && fits; k++) {
|
|
4327
|
+
const a = hay.at[pos + k - 1], b = hay.at[pos + k];
|
|
4328
|
+
if (a === b) { fits = !want.spaced[k]; continue; } // one card char lower-cased to two units
|
|
4329
|
+
const gap = raw.slice(a + 1, b);
|
|
4330
|
+
fits = want.spaced[k] ? gap.length > 0 : (gap === '' || gap === '\n');
|
|
4331
|
+
}
|
|
4332
|
+
if (!fits) continue;
|
|
4333
|
+
const s0 = hay.at[pos];
|
|
4334
|
+
const e0 = hay.at[pos + n - 1] + 1;
|
|
4335
|
+
const end = (e0 < raw.length && raw.charCodeAt(e0) >= 0xdc00 && raw.charCodeAt(e0) <= 0xdfff) ? e0 + 1 : e0;
|
|
4336
|
+
if (SAY_WORD_CHAR.test(codePointBefore(raw, s0)) || SAY_WORD_CHAR.test(codePointAt(raw, end))) continue;
|
|
4337
|
+
if ((s0 > 0 && isMidWordBreak(raw, s0 - 1)) || isMidWordBreak(raw, end)) continue;
|
|
4338
|
+
return true;
|
|
4283
4339
|
}
|
|
4284
4340
|
return false;
|
|
4285
4341
|
}
|
|
@@ -4289,27 +4345,56 @@ export function cardAlreadySays(cardText, body) {
|
|
|
4289
4345
|
// server port changed to 5174 now" on a 190-character card never appeared
|
|
4290
4346
|
// there (review 2026-09-18). Previews render the NEWEST amendment first; the
|
|
4291
4347
|
// stored card is untouched (its title still names the claim, which is what
|
|
4292
|
-
// closes: and ✓ match against).
|
|
4293
|
-
//
|
|
4294
|
-
//
|
|
4348
|
+
// closes: and ✓ match against).
|
|
4349
|
+
//
|
|
4350
|
+
// Third review (2026-09-18) — what a preview may NOT reorder:
|
|
4351
|
+
// • a leading lifecycle stamp ("↩︎ superseded …", "⤵ consolidated …") stays
|
|
4352
|
+
// first, and the amendment goes right after it: the repeat nudge shows
|
|
4353
|
+
// superseded cards, and one that opened with the amendment read as live;
|
|
4354
|
+
// • a newer "(re-affirmed …)", "✔ …" or "✅ …" line after the amendment
|
|
4355
|
+
// means the amendment is not the newest word, so the text is left as is;
|
|
4356
|
+
// • the run is the amendment's own lines only: it continues across a wrap
|
|
4357
|
+
// join (the previous line is a full mid-word chunk, or the next line's
|
|
4358
|
+
// first word would not have fitted on it) while its "(" is still open —
|
|
4359
|
+
// a human line added under a one-line amendment is not absorbed into it.
|
|
4360
|
+
const parenBalance = (l) => (l.match(/\(/g) || []).length - (l.match(/\)/g) || []).length;
|
|
4295
4361
|
export function amendmentFirst(text) {
|
|
4296
4362
|
const raw = String(text || '');
|
|
4297
4363
|
if (!raw.includes('(~ amended ')) return raw;
|
|
4298
4364
|
const lines = raw.split('\n');
|
|
4299
4365
|
const isRunStart = (l) => /^\(~ amended \d{4}-\d{2}-\d{2}:/.test(l.trim());
|
|
4366
|
+
const isStamp = (l) => /^(?:↩|⤵)/u.test(l.trim());
|
|
4300
4367
|
const isBlockStart = (l) => /^\s*(?:#[\p{L}\p{N}_-]+\s*)+$/u.test(l) || /^(?:✔|✅|↩|⤵|\(re-affirmed|\(~ amended)/u.test(l.trim());
|
|
4368
|
+
const isNewerWord = (l) => /^(?:\(re-affirmed|✔|✅)/u.test(l.trim());
|
|
4369
|
+
// A claim line that itself carries a ✅ / ✔ is a card that has been
|
|
4370
|
+
// RESOLVED: that is its newest word, so the preview is left as stored. The
|
|
4371
|
+
// glyph is prefixed inline rather than on a line of its own, so it cannot
|
|
4372
|
+
// be kept on top the way a ↩︎ / ⤵ stamp line is — hoisting past it made a
|
|
4373
|
+
// resolved card read live in the repeat nudge (third review, R6).
|
|
4374
|
+
const isResolvedHead = (l) => /^(?:✅|✔)/u.test(l.trim());
|
|
4301
4375
|
let start = -1;
|
|
4302
4376
|
for (let i = lines.length - 1; i >= 0; i--) if (isRunStart(lines[i])) { start = i; break; }
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4377
|
+
let lead = 0;
|
|
4378
|
+
while (lead < lines.length && isStamp(lines[lead])) lead++;
|
|
4379
|
+
if (start <= lead) return raw;
|
|
4306
4380
|
const cpl = brainCPL();
|
|
4381
|
+
let end = start, depth = parenBalance(lines[start]);
|
|
4382
|
+
while (depth > 0 && end + 1 < lines.length) {
|
|
4383
|
+
const prev = lines[end], next = lines[end + 1];
|
|
4384
|
+
if (!next.trim() || isBlockStart(next)) break;
|
|
4385
|
+
const hardWrap = prev.length === cpl && !/\s/.test(prev);
|
|
4386
|
+
const softWrap = prev.trimEnd().length + 1 + (next.trim().split(/\s+/)[0] || '').length > cpl;
|
|
4387
|
+
if (!hardWrap && !softWrap) break;
|
|
4388
|
+
end++;
|
|
4389
|
+
depth += parenBalance(next);
|
|
4390
|
+
}
|
|
4391
|
+
if (lines.slice(end + 1).some(isNewerWord) || isResolvedHead(lines[lead] || '')) return raw;
|
|
4307
4392
|
let run = lines[start].trim();
|
|
4308
4393
|
for (let i = start + 1; i <= end; i++) {
|
|
4309
4394
|
const prev = lines[i - 1];
|
|
4310
4395
|
run += (prev.length === cpl && !/\s/.test(prev.trim()) ? '' : ' ') + lines[i].trim();
|
|
4311
4396
|
}
|
|
4312
|
-
return [run, ...lines.slice(
|
|
4397
|
+
return [...lines.slice(0, lead), run, ...lines.slice(lead, start), ...lines.slice(end + 1)].join('\n');
|
|
4313
4398
|
}
|
|
4314
4399
|
// Cue META words describe the act of correcting, not the subject — left in, they
|
|
4315
4400
|
// dilute the overlap denominator and push real correction pairs just under the
|
|
@@ -5301,7 +5386,11 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
5301
5386
|
// Too thin to stand in for the card it matched (UPDATE FLOOR below) →
|
|
5302
5387
|
// the card is kept whole and the text is appended to it as a dated
|
|
5303
5388
|
// `(~ amended …)` line; nothing separate is ever minted for it.
|
|
5304
|
-
|
|
5389
|
+
// Each update is CONTAINED (review 2026-09-18, third round): one that
|
|
5390
|
+
// throws is reported in stats.captureErrors and the rest of the batch
|
|
5391
|
+
// still lands — a single bad marker used to throw captureIntoBrain,
|
|
5392
|
+
// lose every other card of its Stop and, once queued, wedge the queue.
|
|
5393
|
+
for (const [uIndex, u] of updates.entries()) try {
|
|
5305
5394
|
const uTok = tokenSet(u.text);
|
|
5306
5395
|
let best = null, bestScore = 0;
|
|
5307
5396
|
for (const c of liveTextCards()) {
|
|
@@ -5425,6 +5514,8 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
5425
5514
|
// glyph and no field: an unmatched disarm is inert by design.
|
|
5426
5515
|
cards.push({ text: (u.area ? `${u.area}: ` : '') + (u.guard && u.guard.remove !== true && !/🛠/.test(u.text) ? '🛠️ ' : '') + u.text + (u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : ''), area: u.area, createdVia: u.createdVia, ...(Array.isArray(u.evidence) && u.evidence.length ? { evidence: u.evidence } : {}), ...(typeof u.verify === 'string' && u.verify.trim() ? { verify: u.verify.trim() } : {}), ...(u.guard && typeof u.guard === 'object' && u.guard.remove !== true ? { guard: u.guard } : {}) });
|
|
5427
5516
|
}
|
|
5517
|
+
} catch (error) {
|
|
5518
|
+
(stats.captureErrors ||= []).push({ kind: 'update', i: uIndex, ...describeFailed(u, 'text'), error: errText(error) });
|
|
5428
5519
|
}
|
|
5429
5520
|
// Report only amendments that are still ON their card after the whole
|
|
5430
5521
|
// batch: a thin ~ appended and then replaced by a full ~ for the same
|
|
@@ -5455,8 +5546,9 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
5455
5546
|
if (area && t.toLowerCase().startsWith(`${String(area).toLowerCase()}:`)) t = t.slice(String(area).length + 1);
|
|
5456
5547
|
return stripLifecycleGlyphs(t).replace(/\s+/g, '').toLowerCase();
|
|
5457
5548
|
};
|
|
5458
|
-
|
|
5459
|
-
|
|
5549
|
+
let repairing = null;
|
|
5550
|
+
for (let i = cards.length - 1; i >= 0; i--) try {
|
|
5551
|
+
const card = repairing = cards[i];
|
|
5460
5552
|
if (!card || typeof card.repairStub !== 'string') continue;
|
|
5461
5553
|
cards.splice(i, 1);
|
|
5462
5554
|
const want = stubCore(card.repairStub, card.area);
|
|
@@ -5469,8 +5561,21 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
5469
5561
|
// on the card (a ref no part of this text names) is kept.
|
|
5470
5562
|
const fullText = String(card.text).replace(/\s+/g, ' ').toLowerCase();
|
|
5471
5563
|
const fromThisProse = (v) => { const t = String(v || '').replace(/\s+/g, ' ').trim().toLowerCase(); return Boolean(t) && fullText.includes(t); };
|
|
5564
|
+
// Only the TEXT is repaired. The stub's tag lines — a user's own
|
|
5565
|
+
// tags, the #file-/#dir- anchors it was captured with — are what
|
|
5566
|
+
// the match above ignored, so they are kept and merged with the
|
|
5567
|
+
// marker's tag line (review 2026-09-18, third round: a repair
|
|
5568
|
+
// rebuilt the tag line from the current transcript scan, which
|
|
5569
|
+
// after an upgrade is often empty, and dropped every one).
|
|
5570
|
+
const isTagLine = (l) => /^\s*(?:#[\p{L}\p{N}_-]+\s*)+$/u.test(l);
|
|
5571
|
+
const tagsOf = (t) => String(t || '').split('\n').filter(isTagLine).flatMap(l => l.trim().split(/\s+/));
|
|
5572
|
+
let repaired = String(card.text);
|
|
5472
5573
|
await rewriteCard(stub.id, j => {
|
|
5473
|
-
|
|
5574
|
+
const tags = new Map();
|
|
5575
|
+
for (const tag of [...tagsOf(j.content), ...tagsOf(card.text)]) if (!tags.has(tag.toLowerCase())) tags.set(tag.toLowerCase(), tag);
|
|
5576
|
+
const prose = String(card.text).split('\n').filter(l => !isTagLine(l)).join('\n');
|
|
5577
|
+
repaired = tags.size ? `${prose}\n${[...tags.values()].join(' ')}` : prose;
|
|
5578
|
+
j.content = repaired;
|
|
5474
5579
|
if (card.borderColor) j.borderColor = card.borderColor;
|
|
5475
5580
|
const byRef = new Map();
|
|
5476
5581
|
for (const ev of (Array.isArray(card.evidence) ? card.evidence : [])) byRef.set(ev && ev.ref, ev);
|
|
@@ -5480,8 +5585,11 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
5480
5585
|
else if (typeof j.verify === 'string' && fromThisProse(j.verify)) delete j.verify;
|
|
5481
5586
|
});
|
|
5482
5587
|
(stats.stubRepairs ||= []).push({ id: stub.id, area: stub.area || null, from: String(card.repairStub).slice(0, 90), to: String(card.text).replace(/\s+/g, ' ').trim().slice(0, 120) });
|
|
5483
|
-
stub.text =
|
|
5588
|
+
stub.text = repaired;
|
|
5484
5589
|
stats.repaired = (stats.repaired || 0) + 1;
|
|
5590
|
+
} catch (error) {
|
|
5591
|
+
// The stub stays as it is; the marker was captured once already.
|
|
5592
|
+
(stats.captureErrors ||= []).push({ kind: 'repair', ...describeFailed(repairing, 'repairStub'), error: errText(error) });
|
|
5485
5593
|
}
|
|
5486
5594
|
|
|
5487
5595
|
// Which live cards a close-target names — shared by the unmatched-closes
|
|
@@ -5677,8 +5785,9 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
5677
5785
|
// wording + createdAt) instead of stacking a twin the close-pass would
|
|
5678
5786
|
// later miss. (Supersede deliberately skips ? cards, so without this
|
|
5679
5787
|
// twins could never merge at capture at all.)
|
|
5680
|
-
|
|
5681
|
-
|
|
5788
|
+
let merging = null;
|
|
5789
|
+
for (let i = cards.length - 1; i >= 0; i--) try {
|
|
5790
|
+
const card = merging = cards[i];
|
|
5682
5791
|
if (!/❓/.test(card.text) || /🏁|🛠/.test(card.text)) continue;
|
|
5683
5792
|
const nTok = tokenSet(card.text);
|
|
5684
5793
|
let best = null, bestScore = 0;
|
|
@@ -5713,6 +5822,10 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
5713
5822
|
cards.splice(i, 1);
|
|
5714
5823
|
stats.merged++;
|
|
5715
5824
|
}
|
|
5825
|
+
} catch (error) {
|
|
5826
|
+
// Contained like an update: the question stays in `cards` and
|
|
5827
|
+
// lands as its own card.
|
|
5828
|
+
(stats.captureErrors ||= []).push({ kind: 'question-merge', ...describeFailed(merging, 'text'), error: errText(error) });
|
|
5716
5829
|
}
|
|
5717
5830
|
|
|
5718
5831
|
// SUPERSEDE — pre-mark old cards that a NEW decision replaces. The arrow
|
|
@@ -6815,7 +6928,20 @@ export function formatCaptureReceipts(stats, { maxEach = 3 } = {}) {
|
|
|
6815
6928
|
}
|
|
6816
6929
|
// A 1.86.0 stub restored to the full marker text, in place (stub repair).
|
|
6817
6930
|
for (const f of (Array.isArray(s.stubRepairs) ? s.stubRepairs : []).slice(0, maxEach)) {
|
|
6818
|
-
lines.push(`🔧 repaired a note 1.86.0 cut short: (id ${f.id}) "${f.from}" now reads "${f.to}".`);
|
|
6931
|
+
lines.push(`🔧 repaired a note 1.86.0 cut short: (id ${f.id}) "${f.from}" now reads "${f.to}". If that card was a different note, restore its text and re-emit this marker.`);
|
|
6932
|
+
}
|
|
6933
|
+
// …and one that was NOT re-added: the stub it would repair is not live any
|
|
6934
|
+
// more. Never silent — the author is the one who can tell whether the note
|
|
6935
|
+
// is already in the brain in another form.
|
|
6936
|
+
for (const f of (Array.isArray(s.stubRepairMissing) ? s.stubRepairMissing : []).slice(0, maxEach)) {
|
|
6937
|
+
lines.push(`↩ not re-added: a marker an older hook already captured as "${f.stub}"${f.area ? ` [${f.area}]` : ''} names no live card of that text any more (archived, superseded or edited since). If the note is not in the brain, re-emit it.`);
|
|
6938
|
+
}
|
|
6939
|
+
// A marker the engine could not apply (contained, so the rest landed).
|
|
6940
|
+
for (const f of (Array.isArray(s.captureErrors) ? s.captureErrors : []).slice(0, maxEach)) {
|
|
6941
|
+
const what = f.kind === 'update' ? '~ update' : f.kind === 'repair' ? 'stub repair' : 'open-question merge';
|
|
6942
|
+
const after = f.kind === 'question-merge' ? 'The question landed as its own card, and the rest of the capture landed too.'
|
|
6943
|
+
: 'The rest of the capture landed; re-emit this one if it matters.';
|
|
6944
|
+
lines.push(`⚠️ ${what} failed${f.text ? `: "${f.text}"` : ''}${f.area ? ` [${f.area}]` : ''} (${f.error}). ${after}`);
|
|
6819
6945
|
}
|
|
6820
6946
|
// A closes: target that names no live card closed nothing; the phrase went
|
|
6821
6947
|
// back into the note's text rather than vanishing.
|