klypix-mcp 1.101.1 → 1.103.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/bin/klypix-worker.mjs +50 -5
- package/package.json +2 -2
- package/src/agent-presence.mjs +93 -4
- package/src/agent-rules.mjs +15 -6
- package/src/codex-brain-hook.mjs +60 -1
- package/src/global-brain-hook.mjs +104 -6
- package/src/klypix-core.mjs +89 -10
- package/src/klypix-format.mjs +10 -3
- package/src/mcp-presence.mjs +1 -1
- package/src/session-handoff.mjs +565 -14
package/README.md
CHANGED
|
@@ -702,6 +702,18 @@ until a session takes it (`{action:"take", id}`) and finishes it (`{action:"done
|
|
|
702
702
|
archives it). A later handoff can name the earlier one it `continues`. The per-prompt hook nudges
|
|
703
703
|
the agent to write one when the person says *handoff* or names the limit.
|
|
704
704
|
|
|
705
|
+
**Drafted by KLYPIX — when the session never got to write one.** A usage limit, a crash or a
|
|
706
|
+
closed window leaves no time for `brain_handoff`. Since 1.103.0 the brain writes the card itself:
|
|
707
|
+
at the next `brain_sync` start or `brain_handoff list` of *any* session, a record that qualifies —
|
|
708
|
+
the session is closed or quiet for 30 minutes, left real work (its own commits, or touched files
|
|
709
|
+
and a reply tail), has no card and no taker, and its task was a human's, not an app's — becomes a
|
|
710
|
+
🤝 HANDOFF card on that session's behalf, reason *interrupted*. The card says `Drafted: by KLYPIX`
|
|
711
|
+
on its own line, lists only what git proved under *Done*, tells the reader to confirm the state
|
|
712
|
+
with `git status` before touching anything, and carries the session's last words verbatim. The
|
|
713
|
+
record and every list say the session wrote none. If the session resumes and completes its task,
|
|
714
|
+
the drafted card is archived at the next pass. Three drafts per pass at most; a draft can never
|
|
715
|
+
fail the sync that triggered it.
|
|
716
|
+
|
|
705
717
|
Durable decisions still go in the brain as cards — a handoff is the one card about *work in
|
|
706
718
|
flight*, and it closes when the work lands.
|
|
707
719
|
|
|
@@ -1017,7 +1029,7 @@ The MCP verbs below are what agents call. These are what **you** call:
|
|
|
1017
1029
|
| `brain_doctor` | Self-diagnosis: version, core/enhanced host adapters, active sessions, tool count, projection drift |
|
|
1018
1030
|
| `brain_message` | Session-to-session coordination notes — to a live session, or queued for one that is not running until it next starts — with a fixed send-time audience and per-recipient pending / offer / acknowledgement / consumption / failure receipts (a directed note is kept 7 days, a broadcast 24h; never written into the brain) |
|
|
1019
1031
|
| `brain_message_receipt` | Explicitly record model-side consumption using the exact message id and per-recipient offer token; acknowledgement alone never consumes a note |
|
|
1020
|
-
| `brain_handoff` | The session handoff. `write` puts a 🤝 card in the brain — task, done, next in order, files, verify — when a session stops before the work is done; the next session of any agent sees it at its first `brain_sync` and `take`s it, then marks it `done`. `list` also shows the **unfinished work** KLYPIX recorded automatically for every session (its last reply, files and
|
|
1032
|
+
| `brain_handoff` | The session handoff. `write` puts a 🤝 card in the brain — task, done, next in order, files, verify — when a session stops before the work is done; the next session of any agent sees it at its first `brain_sync` and `take`s it, then marks it `done`. `list` also shows the **unfinished work** KLYPIX recorded automatically for every session (its last reply, files, own commits, and since 1.103.0 the directories it worked in with the uncommitted state of each checkout, so a fix left in a side checkout is named, not searched for), which can be taken over by session id. Since 1.103.0 a session that stopped without writing a card (a usage limit, a crash) gets one **drafted by KLYPIX** from that record at the next `brain_sync` start or `list` — marked `Drafted:`, reason *interrupted*, archived if the session later completes its task |
|
|
1021
1033
|
| `brain_reopen` | Reopen a closed Claude Code or Codex session so a note waiting for it gets there now — only after the person answers *Reopen* in an in-chat prompt or KLYPIX's own dialog; opens a new, visible terminal in that session's folder with the host's resume command |
|
|
1022
1034
|
| `brain_sync` | Context Gateway: task capsule, active-task peers, exact-file overlap, one-time alerts, timing, and optional result-manifest reconciliation |
|
|
1023
1035
|
| `brain_connect` | Find and draw related-but-unlinked cards |
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -45,9 +45,10 @@ import {
|
|
|
45
45
|
reopenQuestion,
|
|
46
46
|
REOPEN_NUDGE,
|
|
47
47
|
formatUnfinishedWork,
|
|
48
|
+
listUnfinishedWork,
|
|
48
49
|
} from '../src/agent-presence.mjs';
|
|
49
50
|
import { collectRepoState, commitsInRange, makeContainmentProbe } from '../src/repo-state.mjs';
|
|
50
|
-
import { CONTINUE_RE as HANDOFF_CONTINUE_RE, formatHandoffCards } from '../src/session-handoff.mjs';
|
|
51
|
+
import { CONTINUE_RE as HANDOFF_CONTINUE_RE, formatHandoffCards, readLockNotes, assessLockNote, formatLockNotes, lockNotesData, createLockNoteProbe } from '../src/session-handoff.mjs';
|
|
51
52
|
import {
|
|
52
53
|
reconcileRegisteredProjects,
|
|
53
54
|
registerProjectBrain,
|
|
@@ -1408,7 +1409,13 @@ server.registerTool('brain_sync', {
|
|
|
1408
1409
|
let handoffData = null;
|
|
1409
1410
|
if (phase === 'start' && include_context !== false && report.structured?.brain) {
|
|
1410
1411
|
try {
|
|
1411
|
-
const h = await opBrainHandoff({ vault: mcpPresence.vault, canvas: report.structured.brain, action: 'list', from: { sessionId: extra?.klypixRequestIdentity?.sessionId || mcpPresence.id } });
|
|
1412
|
+
const h = await opBrainHandoff({ vault: mcpPresence.vault, canvas: report.structured.brain, action: 'list', from: { sessionId: extra?.klypixRequestIdentity?.sessionId || mcpPresence.id, client: extra?.klypixRequestIdentity?.clientInfo?.client || extra?.klypixClientName || 'unknown' } });
|
|
1413
|
+
// 1.103.0: the list may have WRITTEN the brain (a card drafted for a dead
|
|
1414
|
+
// session, or a drafted card archived) — an atomic replace that changes
|
|
1415
|
+
// the brain inode captured at sync. Refresh the binding from the
|
|
1416
|
+
// unchanged canonical root, as the tool wrapper does after any managed
|
|
1417
|
+
// write, or the session's very next tool is rejected as project-changed.
|
|
1418
|
+
if ((h?.structured?.autoDrafted || []).length || (h?.structured?.autoClosed || []).length) mcpPresence.refreshCurrentProjectBinding();
|
|
1412
1419
|
const cards = h?.structured?.handoffs || [];
|
|
1413
1420
|
// 1.99.1: the AUTOMATIC lane records are history, not a lock (bench S5:
|
|
1414
1421
|
// an ended session's intent on a file made Claude and Codex defer, 0/5).
|
|
@@ -1437,19 +1444,56 @@ server.registerTool('brain_sync', {
|
|
|
1437
1444
|
.map(({ r }) => r);
|
|
1438
1445
|
if (!h?.isError && (cards.length || lane.length)) {
|
|
1439
1446
|
const now = Date.now();
|
|
1447
|
+
const drafted = Array.isArray(h?.structured?.autoDrafted) ? h.structured.autoDrafted : [];
|
|
1440
1448
|
handoffText = [
|
|
1449
|
+
// 1.103.0: a card KLYPIX drafted in THIS pass from a dead session's
|
|
1450
|
+
// record is announced as such — a lead to verify, not the session's words.
|
|
1451
|
+
drafted.length ? `🤝 KLYPIX drafted ${drafted.length} handoff card${drafted.length > 1 ? 's' : ''} just now from the record of a session that stopped without writing one: ${drafted.map((d) => `[${d.id}] “${d.title}”`).join('; ')}. A drafted card is a lead, not a verified state — confirm it with git before relying on it.` : '',
|
|
1441
1452
|
formatHandoffCards(cards, { now, max: 5 }),
|
|
1442
1453
|
formatUnfinishedWork(lane, { now, max: 4, ranked: wantsContinue && words.size > 0 }),
|
|
1443
1454
|
].filter(Boolean).join('\n\n');
|
|
1444
1455
|
handoffData = {
|
|
1445
1456
|
cards: cards.length,
|
|
1446
1457
|
unfinished: lane.length,
|
|
1447
|
-
handoffs: cards.map((c) => ({ id: c.id, title: c.title, from: c.from, at: c.at, reason: c.reason, state: c.state, next: c.next, files: c.files })),
|
|
1448
|
-
|
|
1458
|
+
handoffs: cards.map((c) => ({ id: c.id, title: c.title, from: c.from, at: c.at, reason: c.reason, state: c.state, next: c.next, files: c.files, auto: c.auto === true })),
|
|
1459
|
+
...(Array.isArray(h?.structured?.autoDrafted) && h.structured.autoDrafted.length ? { autoDrafted: h.structured.autoDrafted } : {}),
|
|
1460
|
+
sessions: lane.slice(0, 4).map((r) => ({ id: r.id, client: r.client, intent: r.intent, state: r.state, working: false, holdsFiles: false, note: r.note, status: r.status, lastSeen: r.lastSeen, lastPrompt: r.lastPrompt || null, lastWords: r.lastWords || null, files: r.files, commits: r.commits, handoffCardId: r.handoffCardId, takenBy: r.takenBy })),
|
|
1449
1461
|
};
|
|
1450
1462
|
}
|
|
1451
1463
|
} catch { /* never fails a sync */ }
|
|
1452
1464
|
}
|
|
1465
|
+
// ── Lock notes (1.102.0, bench S5/S5b): a "do not touch" / hands-off / WIP
|
|
1466
|
+
// line in AGENTS.md / CLAUDE.md is checked against live sessions, unmerged
|
|
1467
|
+
// branches and uncommitted changes, and the verdict rides start (and any
|
|
1468
|
+
// sync that declares a file a note names). Never edits the note; never
|
|
1469
|
+
// fails a sync.
|
|
1470
|
+
let lockText = '';
|
|
1471
|
+
let lockData = null;
|
|
1472
|
+
if (include_context !== false && phase !== 'complete' && report.structured?.brain && (phase === 'start' || (Array.isArray(files) && files.length))) {
|
|
1473
|
+
try {
|
|
1474
|
+
const projectDir = path.dirname(report.structured.brain);
|
|
1475
|
+
const normPath = (p) => { const value = String(p || '').replace(/\\/g, '/').replace(/^\.\//, ''); return process.platform === 'win32' ? value.toLowerCase() : value; };
|
|
1476
|
+
const declared = new Set((Array.isArray(files) ? files : []).map(normPath));
|
|
1477
|
+
// Keyword-matched notes on start; on any sync, every instruction line that
|
|
1478
|
+
// names a declared file, whatever its wording (the agent judges it).
|
|
1479
|
+
const brainPath = report.structured.brain;
|
|
1480
|
+
const selfId = [extra?.klypixRequestIdentity?.sessionId, mcpPresence.id].filter(Boolean);
|
|
1481
|
+
let stopped = [];
|
|
1482
|
+
try { stopped = listUnfinishedWork({ brainPath, selfId: selfId[0] || '', includeWorking: false }); } catch { /* lane unreadable: git still answers */ }
|
|
1483
|
+
// At start, lines naming a file a stopped session worked on ride too,
|
|
1484
|
+
// whatever their wording (bench S5d/S5f): the agent need not ask.
|
|
1485
|
+
const stoppedFiles = new Set(phase === 'start' ? stopped.flatMap((r) => (Array.isArray(r.files) ? r.files : []).map(normPath)) : []);
|
|
1486
|
+
const probe = createLockNoteProbe(projectDir);
|
|
1487
|
+
const notes = readLockNotes(projectDir, { probe, mentions: [...new Set([...declared, ...stoppedFiles])].slice(0, 40) })
|
|
1488
|
+
.filter((n) => (phase === 'start' && n.kind === 'lock') || n.files.some((f) => declared.has(normPath(f)) || stoppedFiles.has(normPath(f))));
|
|
1489
|
+
if (notes.length) {
|
|
1490
|
+
const live = listActiveSessions({ brainPath });
|
|
1491
|
+
const assessed = notes.slice(0, 6).map((n) => assessLockNote(projectDir, n, { live, stopped, selfId, probe }));
|
|
1492
|
+
lockText = formatLockNotes(assessed);
|
|
1493
|
+
lockData = lockNotesData(assessed);
|
|
1494
|
+
}
|
|
1495
|
+
} catch { /* never fails a sync */ }
|
|
1496
|
+
}
|
|
1453
1497
|
const totalMs = Math.max(0, Date.now() - totalStartedAt);
|
|
1454
1498
|
const structuredContent = {
|
|
1455
1499
|
...(report.structured || {
|
|
@@ -1471,6 +1515,7 @@ server.registerTool('brain_sync', {
|
|
|
1471
1515
|
...(registration ? { registration } : {}),
|
|
1472
1516
|
...(harness ? { harness } : {}),
|
|
1473
1517
|
...(handoffData ? { handoffs: handoffData } : {}),
|
|
1518
|
+
...(lockData ? { lockNotes: lockData } : {}),
|
|
1474
1519
|
};
|
|
1475
1520
|
const harnessText = harness?.failed
|
|
1476
1521
|
? `KLYPIX harness self-heal needs attention: ${harness.failed} project(s) remain partially aligned; brain_doctor has the exact files.`
|
|
@@ -1482,7 +1527,7 @@ server.registerTool('brain_sync', {
|
|
|
1482
1527
|
return {
|
|
1483
1528
|
content: [{
|
|
1484
1529
|
type: 'text',
|
|
1485
|
-
text: [report.text, harnessText, shipNotice, releaseReconcileText, captureGapText, handoffText, contextText, timingText].filter(Boolean).join('\n\n'),
|
|
1530
|
+
text: [report.text, harnessText, shipNotice, releaseReconcileText, captureGapText, lockText, handoffText, contextText, timingText].filter(Boolean).join('\n\n'),
|
|
1486
1531
|
}],
|
|
1487
1532
|
structuredContent,
|
|
1488
1533
|
...(report.isError ? { isError: true } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.103.0",
|
|
4
4
|
"mcpName": "io.github.dahshanlabs/klypix-mcp",
|
|
5
5
|
"description": "Shared project memory for Claude Code, Codex and Cursor: decisions and corrections through MCP.",
|
|
6
6
|
"type": "module",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"bench": "node bin/klypix-mcp.mjs bench",
|
|
92
92
|
"test:bench": "node test/bench.mjs",
|
|
93
93
|
"pretest": "node test/publish-workflow.mjs",
|
|
94
|
-
"test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/session-mailbox.mjs && node test/session-reopen.mjs && node test/session-handoff.mjs && node test/lane-write-retry.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/brain-ask-words.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-preparation.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/marker-after-colon.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/overlap-tiers.mjs && node test/coordination-bench.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/results-not-manifests.mjs && node test/merge-brains.mjs && node test/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/current-first.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
|
|
94
|
+
"test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/session-mailbox.mjs && node test/session-reopen.mjs && node test/session-handoff.mjs && node test/lock-notes.mjs && node test/lane-write-retry.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/brain-ask-words.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-preparation.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/marker-after-colon.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/overlap-tiers.mjs && node test/coordination-bench.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/results-not-manifests.mjs && node test/merge-brains.mjs && node test/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/current-first.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
|
|
95
95
|
"test:memory": "node test/memory-runtime.mjs",
|
|
96
96
|
"test:memory:soak": "node --expose-gc test/memory-soak.mjs",
|
|
97
97
|
"runtime": "node bin/klypix-runtime.mjs",
|
package/src/agent-presence.mjs
CHANGED
|
@@ -512,6 +512,37 @@ const endedMatches = (row, id) => {
|
|
|
512
512
|
// without `work` is byte-stable through a touch that changes nothing.
|
|
513
513
|
export const WORK_LAST_WORDS_MAX = 700;
|
|
514
514
|
export const WORK_COMMITS_MAX = 6;
|
|
515
|
+
// Where the session actually worked (1.103.0, field case 2026-10-15): the
|
|
516
|
+
// directories its shell commands moved into (`workdirs`) and a bounded `git
|
|
517
|
+
// status` of each checkout at Stop (`checkouts`). Codex had left a fix
|
|
518
|
+
// UNCOMMITTED on a branch in a side checkout (C:\w\facts) that `git worktree
|
|
519
|
+
// list` in the project does not show; its last words, files and commits were
|
|
520
|
+
// all recorded and still nobody could find the work.
|
|
521
|
+
export const WORK_WORKDIRS_MAX = 4;
|
|
522
|
+
export const WORK_CHECKOUTS_MAX = 4;
|
|
523
|
+
export const WORK_CHECKOUT_PATHS_MAX = 12;
|
|
524
|
+
// Newest last; a directory used again moves to the tail (LRU, like observedFiles).
|
|
525
|
+
const normalizeDirList = (raw, max) => {
|
|
526
|
+
const cleaned = (Array.isArray(raw) ? raw : []).map((d) => String(d || '').trim().replace(/[\\/]+$/, '').slice(0, 400)).filter(Boolean);
|
|
527
|
+
const out = [];
|
|
528
|
+
const seen = new Set();
|
|
529
|
+
for (const d of cleaned.reverse()) { if (seen.has(d)) continue; seen.add(d); out.unshift(d); }
|
|
530
|
+
return out.slice(-max);
|
|
531
|
+
};
|
|
532
|
+
export function normalizeCheckout(raw) {
|
|
533
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
534
|
+
const dir = String(raw.dir || '').trim().replace(/[\\/]+$/, '').slice(0, 400);
|
|
535
|
+
if (!dir) return null;
|
|
536
|
+
const paths = [...new Set((Array.isArray(raw.paths) ? raw.paths : []).map((p) => String(p || '').trim().slice(0, 200)).filter(Boolean))].slice(0, WORK_CHECKOUT_PATHS_MAX);
|
|
537
|
+
const changed = Math.max(Number.isFinite(Number(raw.changed)) ? Math.round(Number(raw.changed)) : 0, paths.length);
|
|
538
|
+
const head = String(raw.head || '').trim().slice(0, 12);
|
|
539
|
+
const branch = String(raw.branch || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
540
|
+
const at = Number.isFinite(Number(raw.at)) && Number(raw.at) > 0 ? Number(raw.at) : null;
|
|
541
|
+
return {
|
|
542
|
+
dir, branch: branch || null, head: /^[0-9a-f]{4,12}$/i.test(head) ? head : null, changed, paths,
|
|
543
|
+
shared: Boolean(raw.shared), ...(raw.statusUnknown ? { statusUnknown: true } : {}), ...(at ? { at } : {}),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
515
546
|
export function normalizeWork(raw) {
|
|
516
547
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
517
548
|
const out = {};
|
|
@@ -539,6 +570,17 @@ export function normalizeWork(raw) {
|
|
|
539
570
|
// hooks or not — stamps when it was declared, so a Cursor / Gemini / VS Code
|
|
540
571
|
// session that stopped mid-task is still found by the next session.
|
|
541
572
|
if (ms(raw.declaredAt)) out.declaredAt = ms(raw.declaredAt);
|
|
573
|
+
// 1.103.0: where it worked — directories beyond cwd, and each checkout's
|
|
574
|
+
// uncommitted state at the last Stop (replaced whole on every Stop).
|
|
575
|
+
const workdirs = normalizeDirList(raw.workdirs, WORK_WORKDIRS_MAX);
|
|
576
|
+
if (workdirs.length) out.workdirs = workdirs;
|
|
577
|
+
if (Array.isArray(raw.checkouts)) {
|
|
578
|
+
const checkouts = raw.checkouts.map(normalizeCheckout).filter(Boolean).slice(0, WORK_CHECKOUTS_MAX);
|
|
579
|
+
if (checkouts.length) out.checkouts = checkouts;
|
|
580
|
+
}
|
|
581
|
+
// 1.103.0: the card at handoffCardId was DRAFTED BY KLYPIX from this record
|
|
582
|
+
// (the session never wrote one) — every renderer says so.
|
|
583
|
+
if (raw.handoffAuto === true) out.handoffAuto = true;
|
|
542
584
|
return Object.keys(out).length ? out : null;
|
|
543
585
|
}
|
|
544
586
|
export function mergeWork(previous, patch) {
|
|
@@ -546,6 +588,12 @@ export function mergeWork(previous, patch) {
|
|
|
546
588
|
if (patch === null) return null;
|
|
547
589
|
const prev = normalizeWork(previous) || {};
|
|
548
590
|
const next = patch && typeof patch === 'object' ? { ...patch } : {};
|
|
591
|
+
// `addWorkdirs` APPENDS (one tool call at a time on Codex; a transcript walk
|
|
592
|
+
// on Claude), newest last, de-duplicated, capped like `workdirs`.
|
|
593
|
+
if (Array.isArray(next.addWorkdirs)) {
|
|
594
|
+
next.workdirs = normalizeDirList([...(prev.workdirs || []), ...next.addWorkdirs], WORK_WORKDIRS_MAX);
|
|
595
|
+
delete next.addWorkdirs;
|
|
596
|
+
}
|
|
549
597
|
// `addCommits` APPENDS (Codex records one commit per tool result; the
|
|
550
598
|
// Claude Stop hook replaces the full list with `commits`), newest last,
|
|
551
599
|
// de-duplicated by sha, capped like `commits`.
|
|
@@ -2940,7 +2988,37 @@ export const UNFINISHED_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
|
2940
2988
|
// Evidence of real work, from ANY host: a reply tail (Claude and Codex Stop
|
|
2941
2989
|
// hooks), own commits (Claude transcript, Codex tool results), a handoff card
|
|
2942
2990
|
// (brain_handoff), or a task declared through brain_sync (every MCP host).
|
|
2943
|
-
|
|
2991
|
+
// 1.103.0: uncommitted changes in a checkout it worked in are evidence too — a
|
|
2992
|
+
// session cut off before its first reply tail still left work on disk.
|
|
2993
|
+
export const hasUncommittedWork = (work) => Boolean(work && Array.isArray(work.checkouts) && work.checkouts.some((c) => c && Number(c.changed) > 0));
|
|
2994
|
+
export const hasWorkEvidence = (work) => Boolean(work && (work.lastWords || (Array.isArray(work.commits) && work.commits.length) || work.handoffCardId || work.declaredAt || hasUncommittedWork(work)));
|
|
2995
|
+
// The "where it worked" lines of one record — shared by formatUnfinishedWork,
|
|
2996
|
+
// the brain_handoff take reply and (as a twin) the Claude hook's footer. A
|
|
2997
|
+
// separate checkout is named in full with its branch, head and uncommitted
|
|
2998
|
+
// paths: the next session must not have to search for it. The project
|
|
2999
|
+
// checkout is shared by every session, so only the files THIS session touched
|
|
3000
|
+
// are reported there.
|
|
3001
|
+
export function formatCheckoutLines(work, { cwd = null } = {}) {
|
|
3002
|
+
const lines = [];
|
|
3003
|
+
const checkouts = Array.isArray(work?.checkouts) ? work.checkouts : [];
|
|
3004
|
+
const named = new Set();
|
|
3005
|
+
for (const c of checkouts) {
|
|
3006
|
+
if (!c || !c.dir) continue;
|
|
3007
|
+
named.add(String(c.dir).replace(/\\/g, '/').toLowerCase());
|
|
3008
|
+
const more = Number(c.changed) > c.paths.length ? ` …and ${Number(c.changed) - c.paths.length} more` : '';
|
|
3009
|
+
if (c.shared) {
|
|
3010
|
+
if (Number(c.changed) > 0) lines.push(` uncommitted in the project checkout (files this session touched): ${c.paths.join(', ')}${more}`);
|
|
3011
|
+
continue;
|
|
3012
|
+
}
|
|
3013
|
+
const where = [c.branch ? `branch ${c.branch}` : 'detached HEAD', ...(c.head ? [`@ ${c.head}`] : [])].join(' ');
|
|
3014
|
+
const state = c.statusUnknown ? 'git status unavailable' : (Number(c.changed) > 0 ? `${c.changed} uncommitted: ${c.paths.join(', ')}${more}` : 'clean');
|
|
3015
|
+
lines.push(` worked in a separate checkout: ${c.dir} (${where}; ${state}) — not the project folder; git worktree list may not show it`);
|
|
3016
|
+
}
|
|
3017
|
+
const extra = (Array.isArray(work?.workdirs) ? work.workdirs : [])
|
|
3018
|
+
.filter((d) => !named.has(String(d).replace(/\\/g, '/').toLowerCase()) && (!cwd || String(d).replace(/\\/g, '/').toLowerCase() !== String(cwd).replace(/\\/g, '/').toLowerCase()));
|
|
3019
|
+
if (extra.length) lines.push(` also ran commands in: ${extra.join(', ')}`);
|
|
3020
|
+
return lines;
|
|
3021
|
+
}
|
|
2944
3022
|
export function clientWord(client) {
|
|
2945
3023
|
const key = String(client || '').toLowerCase();
|
|
2946
3024
|
if (key === 'claude-code' || key === 'claude') return 'Claude Code';
|
|
@@ -2957,6 +3035,10 @@ const quietFor = (row, now) => {
|
|
|
2957
3035
|
const at = Number(row.hostStatusAt || row.activityAt || row.intentAt || row.startedAt || row.lastSeen || now);
|
|
2958
3036
|
return Math.max(0, now - at);
|
|
2959
3037
|
};
|
|
3038
|
+
// Shared with note evidence: connection heartbeats alone do not prove work.
|
|
3039
|
+
export const sessionWorkIsRecent = (row, now = Date.now()) => Boolean(row)
|
|
3040
|
+
&& row.working !== false && row.holdsFiles !== false
|
|
3041
|
+
&& quietFor(row, now) < UNFINISHED_QUIET_MS;
|
|
2960
3042
|
export function listUnfinishedWork({ brainPath, home, now = Date.now(), selfId = '', includeWorking = false } = {}) {
|
|
2961
3043
|
if (!brainPath) return [];
|
|
2962
3044
|
const lane = readLane(laneFileFor(brainPath, home));
|
|
@@ -3001,12 +3083,16 @@ export function listUnfinishedWork({ brainPath, home, now = Date.now(), selfId =
|
|
|
3001
3083
|
state: row ? 'stopped' : 'ended',
|
|
3002
3084
|
working: false,
|
|
3003
3085
|
holdsFiles: false,
|
|
3004
|
-
note: 'This session is not running. It holds no claim on these files
|
|
3086
|
+
note: 'This session is not running. It holds no claim on these files. A "do not touch" note it left may be stale or may still protect unmerged work: KLYPIX checks such notes against live sessions and branches — follow that check. Continue its work only if the human asks.',
|
|
3005
3087
|
lastPrompt: work.lastPrompt || '',
|
|
3006
3088
|
lastWords: work.lastWords || '',
|
|
3007
3089
|
lastWordsAt: work.lastWordsAt || null,
|
|
3008
3090
|
commits: Array.isArray(work.commits) ? work.commits : [],
|
|
3091
|
+
// 1.103.0: where it worked — so a fix left in a side checkout is found.
|
|
3092
|
+
workdirs: Array.isArray(work.workdirs) ? work.workdirs : [],
|
|
3093
|
+
checkouts: Array.isArray(work.checkouts) ? work.checkouts : [],
|
|
3009
3094
|
handoffCardId: work.handoffCardId || null,
|
|
3095
|
+
handoffAuto: work.handoffAuto === true,
|
|
3010
3096
|
takenBy: work.takenBy
|
|
3011
3097
|
? { ...work.takenBy, live: Boolean(taker), status: taker ? (sessionStatusLabel(taker, now) || 'live') : 'not running' }
|
|
3012
3098
|
: null,
|
|
@@ -3056,7 +3142,7 @@ export function formatUnfinishedWork(records, { now = Date.now(), max = 5, cardT
|
|
|
3056
3142
|
const list = Array.isArray(records) ? records : [];
|
|
3057
3143
|
if (!list.length) return '';
|
|
3058
3144
|
const flat = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
|
3059
|
-
const lines = [`⏸️ Work other sessions may have left unfinished (${list.length}, stopped in the last 24h, ${ranked ? 'most relevant first' : 'newest first'}). None of them is running: they hold NO claim on any file
|
|
3145
|
+
const lines = [`⏸️ Work other sessions may have left unfinished (${list.length}, stopped in the last 24h, ${ranked ? 'most relevant first' : 'newest first'}). None of them is running: they hold NO live claim on any file. Whether a note about one of their files still holds is a question of evidence, not age: KLYPIX surfaces matching lines from supported instruction files with bounded checks of local sessions, branches and worktrees (📌, when there is such a line). Use this list only if the human asks you to continue their work:`];
|
|
3060
3146
|
for (const r of list.slice(0, max)) {
|
|
3061
3147
|
const who = `${clientWord(r.client)} ${String(r.id).slice(0, 8)}`;
|
|
3062
3148
|
const bits = [r.state === 'ended' ? 'ended' : `stopped (${r.status})`, `last active ${agoLabel(r.ageMs)}`];
|
|
@@ -3064,10 +3150,13 @@ export function formatUnfinishedWork(records, { now = Date.now(), max = 5, cardT
|
|
|
3064
3150
|
lines.push(`- ${who} · “${flat(r.intent).slice(0, 120)}” · ${bits.join(' · ')}`);
|
|
3065
3151
|
if (r.commits.length) lines.push(` commits: ${r.commits.map((c) => `${c.sha} ${c.subject}`).join('; ').slice(0, 320)}`);
|
|
3066
3152
|
if (r.files.length) lines.push(` files it touched (free — not reserved): ${r.files.slice(-8).join(', ')}`);
|
|
3153
|
+
lines.push(...formatCheckoutLines(r, { cwd: r.cwd }));
|
|
3067
3154
|
if (r.lastWords) lines.push(` its last words: “${flat(r.lastWords).slice(0, 420)}”`);
|
|
3068
3155
|
if (r.handoffCardId) {
|
|
3069
3156
|
const title = cardTitles && cardTitles.get ? cardTitles.get(r.handoffCardId) : null;
|
|
3070
|
-
lines.push(
|
|
3157
|
+
lines.push(r.handoffAuto
|
|
3158
|
+
? ` 🤝 KLYPIX drafted a handoff card from this record (the session wrote none): ${r.handoffCardId}${title ? ` — “${flat(title).slice(0, 100)}”` : ''} (brain_handoff {action:"list"} prints it in full)`
|
|
3159
|
+
: ` 🤝 it left a handoff card in the brain: ${r.handoffCardId}${title ? ` — “${flat(title).slice(0, 100)}”` : ''} (brain_handoff {action:"list"} prints it in full)`);
|
|
3071
3160
|
}
|
|
3072
3161
|
if (r.takenBy) lines.push(` taken over by ${clientWord(r.takenBy.client)} ${String(r.takenBy.id).slice(0, 8)} ${r.takenBy.at ? agoLabel(now - r.takenBy.at) : ''} (${r.takenBy.status}) — coordinate with brain_message before duplicating it`);
|
|
3073
3162
|
if (r.resumeCommand) lines.push(` its own transcript, should the human want it: ${r.resumeCommand}`);
|
package/src/agent-rules.mjs
CHANGED
|
@@ -510,12 +510,21 @@ is near, the day ends, you are blocked, you are switching tool) Call \`brain_han
|
|
|
510
510
|
{action:"write"} with the task, what is done, what is NEXT in order, the files and how to verify.
|
|
511
511
|
It is a 🤝 card in this brain the human can see and correct, and the next session on this project —
|
|
512
512
|
any agent — sees it at its first \`brain_sync\` / session start and takes it with {action:"take"},
|
|
513
|
-
then {action:"done"} when finished. KLYPIX also records every session's last reply, files
|
|
514
|
-
commits
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
into the next session.
|
|
513
|
+
then {action:"done"} when finished. KLYPIX also records every session's last reply, files,
|
|
514
|
+
commits and the checkouts it worked in (with their uncommitted files) automatically, so
|
|
515
|
+
\`brain_sync\` may list **unfinished work** another session left without a card: if the human asks
|
|
516
|
+
you to continue it, take it with \`brain_handoff\` {action:"take", session:"<id>"} first, and look
|
|
517
|
+
first in the checkout the record names — it may be a side folder, not the project. That list is history, not a lock — a stopped session holds no claim on its
|
|
518
|
+
files. Never ask the human to paste a summary into the next session.
|
|
519
|
+
|
|
520
|
+
**A note that asks agents to keep off a file**, whatever its wording (here, in AGENTS.md / CLAUDE.md or any
|
|
521
|
+
notes file) can outlive the work it protected. Before you obey one, or before you edit the file
|
|
522
|
+
anyway, call \`brain_sync\` with that file in \`files\`: KLYPIX checks the note against live sessions,
|
|
523
|
+
unmerged local branches and registered worktrees. Decide which kind of note it is: a temporary
|
|
524
|
+
reservation for someone's in-flight change ends when that work ends; a permanent rule (generated
|
|
525
|
+
file, policy, legal or human restriction) always holds, whatever the evidence says. If a temporary
|
|
526
|
+
reservation's work has ended and your task needs the file, change it there and tell the human so
|
|
527
|
+
they can remove the note — never delete the note yourself. An incomplete check says UNKNOWN.
|
|
519
528
|
|
|
520
529
|
**Don't** hand-edit \`brain.klypix\` (it's a packaged canvas — use the tools) or dump file contents into it; capture the *decision*, not the file.`;
|
|
521
530
|
|
package/src/codex-brain-hook.mjs
CHANGED
|
@@ -381,6 +381,28 @@ function unfinishedWorkBlock(brainPath, sessionId) {
|
|
|
381
381
|
}
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
// 📌 Lock notes (1.102.0): the same check brain_sync and the Claude hook run —
|
|
385
|
+
// a "do not touch" / hands-off / WIP line in AGENTS.md checked against live
|
|
386
|
+
// sessions, local branches and uncommitted changes. Dynamic import so a stale
|
|
387
|
+
// deployment without it no-ops; never throws.
|
|
388
|
+
async function lockNotesBlock(projectDir, brainPath, sessionId, sessions) {
|
|
389
|
+
try {
|
|
390
|
+
const lib = await import(new URL('./session-handoff.mjs', import.meta.url).href);
|
|
391
|
+
if (typeof lib.readLockNotes !== 'function') return '';
|
|
392
|
+
let stopped = [];
|
|
393
|
+
try { stopped = listUnfinishedWork({ brainPath, selfId: sessionId }); } catch { /* git still answers */ }
|
|
394
|
+
// Lines naming a file a stopped session worked on ride too, whatever their wording.
|
|
395
|
+
const mentions = [...new Set(stopped.flatMap((r) => (Array.isArray(r.files) ? r.files : [])))].slice(0, 20);
|
|
396
|
+
const probe = lib.createLockNoteProbe(projectDir);
|
|
397
|
+
const notes = lib.readLockNotes(projectDir, { mentions, probe });
|
|
398
|
+
if (!notes.length) return '';
|
|
399
|
+
const assessed = notes.slice(0, 6).map((n) => lib.assessLockNote(projectDir, n, { live: sessions || [], stopped, selfId: sessionId ? [sessionId] : [], probe }));
|
|
400
|
+
return lib.formatLockNotes(assessed);
|
|
401
|
+
} catch {
|
|
402
|
+
return '';
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
384
406
|
// Unfinished-work patch for one Codex lifecycle event (session handoff). A
|
|
385
407
|
// human prompt records what was asked; a Stop records the reply tail only if
|
|
386
408
|
// the host put it in the payload. Null when the event carries nothing.
|
|
@@ -473,6 +495,20 @@ async function main() {
|
|
|
473
495
|
? touchedFiles(input, projectDir)
|
|
474
496
|
: undefined;
|
|
475
497
|
const files = humanTurn ? [] : undefined;
|
|
498
|
+
const workPatch = codexWorkPatch({ event, humanTurn, prompt, input }) || {};
|
|
499
|
+
// Where it works (1.103.0): the directory a tool call ran in (Codex's exec
|
|
500
|
+
// carries `workdir`; a command may `cd` or `git -C` elsewhere). Dynamic +
|
|
501
|
+
// guarded so a stale bundle without the helper still records the rest.
|
|
502
|
+
let whereLib = null;
|
|
503
|
+
if (event === 'PostToolUse' || event === 'Stop') {
|
|
504
|
+
try { whereLib = await import(new URL('./session-handoff.mjs', import.meta.url).href); } catch { whereLib = null; }
|
|
505
|
+
}
|
|
506
|
+
if (event === 'PostToolUse' && typeof whereLib?.workdirsFromToolInput === 'function') {
|
|
507
|
+
try {
|
|
508
|
+
const dirs = whereLib.workdirsFromToolInput(toolInput(input));
|
|
509
|
+
if (dirs.length) workPatch.addWorkdirs = dirs;
|
|
510
|
+
} catch { /* best-effort */ }
|
|
511
|
+
}
|
|
476
512
|
const sessions = upsertSession({
|
|
477
513
|
brainPath,
|
|
478
514
|
id: sessionId,
|
|
@@ -495,8 +531,29 @@ async function main() {
|
|
|
495
531
|
// human prompt on a human turn, and at Stop the reply tail WHEN the host
|
|
496
532
|
// hands it over (Codex's Stop payload may carry `last_assistant_message`;
|
|
497
533
|
// this adapter never reads Codex's transcript). Absent = keep the known.
|
|
498
|
-
...(
|
|
534
|
+
...(Object.keys(workPatch).length ? { work: workPatch } : {}),
|
|
499
535
|
});
|
|
536
|
+
// At Stop, a bounded git status of every checkout this session worked in
|
|
537
|
+
// (cwd first, then the workdirs its tool calls recorded) — the fix left
|
|
538
|
+
// uncommitted in a side checkout is what the 2026-10-15 field case lost.
|
|
539
|
+
// A second touch, so it sees the workdirs the first one merged.
|
|
540
|
+
if (event === 'Stop' && typeof whereLib?.snapshotCheckouts === 'function') {
|
|
541
|
+
try {
|
|
542
|
+
const me = sessions.find((session) => session.id === sessionId);
|
|
543
|
+
const checkouts = whereLib.snapshotCheckouts({
|
|
544
|
+
dirs: [cwd, ...(Array.isArray(me?.work?.workdirs) ? me.work.workdirs : [])],
|
|
545
|
+
projectDir,
|
|
546
|
+
sessionFiles: [...new Set([...(me?.files || []), ...(me?.observedFiles || [])])],
|
|
547
|
+
});
|
|
548
|
+
if (checkouts.length) {
|
|
549
|
+
upsertSession({
|
|
550
|
+
brainPath, id: sessionId, client: 'codex', event, channel: 'lifecycle', cwd,
|
|
551
|
+
...(HOST_PID.pid ? { hostPid: HOST_PID.pid, hostPidSource: HOST_PID.source } : {}),
|
|
552
|
+
logicalSessionId: sessionId, identitySource: 'codex-lifecycle', work: { checkouts },
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
} catch { /* best-effort */ }
|
|
556
|
+
}
|
|
500
557
|
|
|
501
558
|
const ignored = event === 'PostToolUse' ? ownBrainMessageText(input) : [];
|
|
502
559
|
// Stop can show a UI warning but cannot add model context. Never advance a
|
|
@@ -529,8 +586,10 @@ async function main() {
|
|
|
529
586
|
return typeof ghl.ensureGitCaptureHook === 'function' ? (ghl.ensureGitCaptureHook(projectDir).notice || '') : '';
|
|
530
587
|
} catch { return ''; }
|
|
531
588
|
})();
|
|
589
|
+
const lockNotes = await lockNotesBlock(projectDir, brainPath, sessionId, sessions);
|
|
532
590
|
emitSystemMessage([
|
|
533
591
|
formatPresenceMessage(sessions, sessionId, { includeSolo: true }),
|
|
592
|
+
lockNotes,
|
|
534
593
|
// Class-C ship observation — the engine's, not a Claude-hook-local copy.
|
|
535
594
|
// Codex-driven projects had NO out-of-session ship detection at all, which
|
|
536
595
|
// is exactly where the incident class bites hardest (2026-07-29 review).
|