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 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 own commits), which can be taken over by session id |
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 |
@@ -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
- 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, files: r.files, commits: r.commits, handoffCardId: r.handoffCardId, takenBy: r.takenBy })),
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.101.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",
@@ -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
- export const hasWorkEvidence = (work) => Boolean(work && (work.lastWords || (Array.isArray(work.commits) && work.commits.length) || work.handoffCardId || work.declaredAt));
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; a "do not touch" note it left no longer applies. Continue its work only if the human asks.',
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, and a "do not touch" or WIP note one of them left no longer applies. Use this only if the human asks you to continue their work:`];
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(` 🤝 it left a handoff card in the brain: ${r.handoffCardId}${title ? ` — “${flat(title).slice(0, 100)}”` : ''} (brain_handoff {action:"list"} prints it in full)`);
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}`);
@@ -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 and
514
- commits automatically, so \`brain_sync\` may list **unfinished work** another session left without
515
- a card: if the human asks you to continue it, take it with \`brain_handoff\` {action:"take",
516
- session:"<id>"} first. That list is history, not a lock — a stopped session holds no claim on its
517
- files, and a "do not touch" note it left no longer applies. Never ask the human to paste a summary
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
 
@@ -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
- ...(codexWorkPatch({ event, humanTurn, prompt, input }) ? { work: codexWorkPatch({ event, humanTurn, prompt, input }) } : {}),
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).