klypix-mcp 1.102.0 → 1.104.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
@@ -515,6 +515,33 @@ so fresh relevant findings are not crowded out by older area vocabulary.
515
515
  Briefs are **not** injected automatically on Cursor, Cline, Copilot, Gemini CLI or Antigravity —
516
516
  there are no lifecycle hooks on those hosts.
517
517
 
518
+ ### The live projection — the file the agent obeys
519
+
520
+ The coordination bench showed that an agent trusts the instruction file it was launched with
521
+ over anything a tool or hook tells it, and that each host reads a different surface. So the
522
+ brain's state is projected into those files, in two halves:
523
+
524
+ - **Committed, from the brain:** the compact brief block in `AGENTS.md` (standing rules,
525
+ corrections, open handoff cards), refreshed by the Claude Code Stop hook only when its content
526
+ changed, so diffs stay quiet.
527
+ - **Machine-local, from the lane (klypix-mcp 1.104.0):** which sessions are live on this PC and
528
+ which files they hold, the work a stopped session left, and the evidence beside every
529
+ "keep off this file" note — written at session start and at every Stop by the Claude Code and
530
+ Codex hooks into `CLAUDE.local.md` at the project root (Claude Code loads it beside
531
+ `CLAUDE.md`; KLYPIX keeps it out of git through `.git/info/exclude`, never a committed file)
532
+ and into one section per project of the Codex global instructions file
533
+ (`~/.codex/AGENTS.override.md` when you have one, else `~/.codex/AGENTS.md`). A project-level
534
+ `AGENTS.override.md` is never written: Codex loads one file per directory, so it would hide
535
+ your `AGENTS.md`.
536
+
537
+ The block is a snapshot and says so: bounded to about 2.4KB, rewritten only when its content
538
+ changed (the time stamp alone never causes a write), with the keep-off evidence kept first when
539
+ the budget bites, and "nothing live" written in words so an older snapshot never stands. It
540
+ holds the same strings the hooks print, so the file and the session context cannot disagree.
541
+ Sections for projects not touched in a day are pruned from the Codex file, at most eight are
542
+ kept, and `uninstall unlink` strips the block (deleting `CLAUDE.local.md` only when nothing else
543
+ is in it). `brain_sync` remains the live source; the block tells the agent to call it.
544
+
518
545
  ## Capture and corrections
519
546
 
520
547
  `brain_note` accepts structured supporting references and inert verification text:
@@ -702,6 +729,18 @@ until a session takes it (`{action:"take", id}`) and finishes it (`{action:"done
702
729
  archives it). A later handoff can name the earlier one it `continues`. The per-prompt hook nudges
703
730
  the agent to write one when the person says *handoff* or names the limit.
704
731
 
732
+ **Drafted by KLYPIX — when the session never got to write one.** A usage limit, a crash or a
733
+ closed window leaves no time for `brain_handoff`. Since 1.103.0 the brain writes the card itself:
734
+ at the next `brain_sync` start or `brain_handoff list` of *any* session, a record that qualifies —
735
+ the session is closed or quiet for 30 minutes, left real work (its own commits, or touched files
736
+ and a reply tail), has no card and no taker, and its task was a human's, not an app's — becomes a
737
+ 🤝 HANDOFF card on that session's behalf, reason *interrupted*. The card says `Drafted: by KLYPIX`
738
+ on its own line, lists only what git proved under *Done*, tells the reader to confirm the state
739
+ with `git status` before touching anything, and carries the session's last words verbatim. The
740
+ record and every list say the session wrote none. If the session resumes and completes its task,
741
+ the drafted card is archived at the next pass. Three drafts per pass at most; a draft can never
742
+ fail the sync that triggered it.
743
+
705
744
  Durable decisions still go in the brain as cards — a handoff is the one card about *work in
706
745
  flight*, and it closes when the work lands.
707
746
 
@@ -1017,7 +1056,7 @@ The MCP verbs below are what agents call. These are what **you** call:
1017
1056
  | `brain_doctor` | Self-diagnosis: version, core/enhanced host adapters, active sessions, tool count, projection drift |
1018
1057
  | `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
1058
  | `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 |
1059
+ | `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
1060
  | `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
1061
  | `brain_sync` | Context Gateway: task capsule, active-task peers, exact-file overlap, one-time alerts, timing, and optional result-manifest reconciliation |
1023
1062
  | `brain_connect` | Find and draw related-but-unlinked cards |
@@ -1338,7 +1377,10 @@ It strips only KLYPIX's own entries — every other hook and setting in
1338
1377
  `~/.claude/settings.json` stays — backs up each file it edits, and **never deletes a `.klypix`**.
1339
1378
  Removal requires `--yes`. Without it, the command prints the plan and changes nothing;
1340
1379
  there is no interactive confirmation prompt. Run the project-local form in each project you
1341
- want to unlink. A preview exits non-zero while managed files remain.
1380
+ want to unlink. A preview exits non-zero while managed files remain. The project-local form also
1381
+ strips the live-projection block from `CLAUDE.local.md` (the file goes only when nothing else is
1382
+ in it) and this project's section from the Codex global file; the machine-global form strips
1383
+ every live section there.
1342
1384
 
1343
1385
  Your `brain.klypix` is yours — it is a plain ZIP and stays readable with or without this package.
1344
1386
 
@@ -495,7 +495,7 @@ try {
495
495
  // a newer klypix-format cannot. merge-brains and the driver ship here so
496
496
  // brain-history's restore merge, the KLYPIX core and the git driver all
497
497
  // find one engine in this directory.
498
- for (const f of ['global-brain-hook.mjs', 'klypix-format.mjs', 'brain-graveyard.mjs', 'merge-brains.mjs', 'klypix-merge-driver.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'provenance.mjs', 'brain-note.mjs', 'brain-evidence.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'app-lease.mjs', 'klypix-core.mjs', 'app-bridge-protocol.mjs', 'app-bridge-client.mjs', 'app-tools.mjs', 'card-files.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'editor-detect.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'session-handoff.mjs', 'canvas-view-app.html']) {
498
+ for (const f of ['global-brain-hook.mjs', 'klypix-format.mjs', 'brain-graveyard.mjs', 'merge-brains.mjs', 'klypix-merge-driver.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'provenance.mjs', 'brain-note.mjs', 'brain-evidence.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'app-lease.mjs', 'klypix-core.mjs', 'app-bridge-protocol.mjs', 'app-bridge-client.mjs', 'app-tools.mjs', 'card-files.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'editor-detect.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'session-handoff.mjs', 'live-projection.mjs', 'canvas-view-app.html']) {
499
499
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
500
500
  }
501
501
  for (const [src, dst] of [
@@ -1409,7 +1409,13 @@ server.registerTool('brain_sync', {
1409
1409
  let handoffData = null;
1410
1410
  if (phase === 'start' && include_context !== false && report.structured?.brain) {
1411
1411
  try {
1412
- 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();
1413
1419
  const cards = h?.structured?.handoffs || [];
1414
1420
  // 1.99.1: the AUTOMATIC lane records are history, not a lock (bench S5:
1415
1421
  // an ended session's intent on a file made Claude and Codex defer, 0/5).
@@ -1438,14 +1444,19 @@ server.registerTool('brain_sync', {
1438
1444
  .map(({ r }) => r);
1439
1445
  if (!h?.isError && (cards.length || lane.length)) {
1440
1446
  const now = Date.now();
1447
+ const drafted = Array.isArray(h?.structured?.autoDrafted) ? h.structured.autoDrafted : [];
1441
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.` : '',
1442
1452
  formatHandoffCards(cards, { now, max: 5 }),
1443
1453
  formatUnfinishedWork(lane, { now, max: 4, ranked: wantsContinue && words.size > 0 }),
1444
1454
  ].filter(Boolean).join('\n\n');
1445
1455
  handoffData = {
1446
1456
  cards: cards.length,
1447
1457
  unfinished: lane.length,
1448
- 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 })),
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 } : {}),
1449
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 })),
1450
1461
  };
1451
1462
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.102.0",
3
+ "version": "1.104.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/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",
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/live-projection.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';
@@ -3010,7 +3088,11 @@ export function listUnfinishedWork({ brainPath, home, now = Date.now(), selfId =
3010
3088
  lastWords: work.lastWords || '',
3011
3089
  lastWordsAt: work.lastWordsAt || null,
3012
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 : [],
3013
3094
  handoffCardId: work.handoffCardId || null,
3095
+ handoffAuto: work.handoffAuto === true,
3014
3096
  takenBy: work.takenBy
3015
3097
  ? { ...work.takenBy, live: Boolean(taker), status: taker ? (sessionStatusLabel(taker, now) || 'live') : 'not running' }
3016
3098
  : null,
@@ -3068,10 +3150,13 @@ export function formatUnfinishedWork(records, { now = Date.now(), max = 5, cardT
3068
3150
  lines.push(`- ${who} · “${flat(r.intent).slice(0, 120)}” · ${bits.join(' · ')}`);
3069
3151
  if (r.commits.length) lines.push(` commits: ${r.commits.map((c) => `${c.sha} ${c.subject}`).join('; ').slice(0, 320)}`);
3070
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 }));
3071
3154
  if (r.lastWords) lines.push(` its last words: “${flat(r.lastWords).slice(0, 420)}”`);
3072
3155
  if (r.handoffCardId) {
3073
3156
  const title = cardTitles && cardTitles.get ? cardTitles.get(r.handoffCardId) : null;
3074
- 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)`);
3075
3160
  }
3076
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`);
3077
3162
  if (r.resumeCommand) lines.push(` its own transcript, should the human want it: ${r.resumeCommand}`);
@@ -510,19 +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
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
517
518
  files. Never ask the human to paste a summary into the next session.
518
519
 
519
520
  **A note that asks agents to keep off a file**, whatever its wording (here, in AGENTS.md / CLAUDE.md or any
520
521
  notes file) can outlive the work it protected. Before you obey one, or before you edit the file
521
522
  anyway, call \`brain_sync\` with that file in \`files\`: KLYPIX checks the note against live sessions,
522
- unmerged local branches and registered worktrees. The result is evidence, not permission:
523
- standing rules, generated-file policies and explicit human restrictions still apply. An incomplete
524
- check says UNKNOWN. Only an expired temporary reservation may be stale; if your task authorizes
525
- editing, continue and tell the human so they can remove the note — never delete the note yourself.
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.
526
528
 
527
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.`;
528
530
 
@@ -385,7 +385,7 @@ function unfinishedWorkBlock(brainPath, sessionId) {
385
385
  // a "do not touch" / hands-off / WIP line in AGENTS.md checked against live
386
386
  // sessions, local branches and uncommitted changes. Dynamic import so a stale
387
387
  // deployment without it no-ops; never throws.
388
- async function lockNotesBlock(projectDir, brainPath, sessionId, sessions) {
388
+ async function lockNotesBlock(projectDir, brainPath, sessionId, sessions, { budgetMs } = {}) {
389
389
  try {
390
390
  const lib = await import(new URL('./session-handoff.mjs', import.meta.url).href);
391
391
  if (typeof lib.readLockNotes !== 'function') return '';
@@ -393,7 +393,7 @@ async function lockNotesBlock(projectDir, brainPath, sessionId, sessions) {
393
393
  try { stopped = listUnfinishedWork({ brainPath, selfId: sessionId }); } catch { /* git still answers */ }
394
394
  // Lines naming a file a stopped session worked on ride too, whatever their wording.
395
395
  const mentions = [...new Set(stopped.flatMap((r) => (Array.isArray(r.files) ? r.files : [])))].slice(0, 20);
396
- const probe = lib.createLockNoteProbe(projectDir);
396
+ const probe = lib.createLockNoteProbe(projectDir, budgetMs ? { budgetMs } : {});
397
397
  const notes = lib.readLockNotes(projectDir, { mentions, probe });
398
398
  if (!notes.length) return '';
399
399
  const assessed = notes.slice(0, 6).map((n) => lib.assessLockNote(projectDir, n, { live: sessions || [], stopped, selfId: sessionId ? [sessionId] : [], probe }));
@@ -403,6 +403,22 @@ async function lockNotesBlock(projectDir, brainPath, sessionId, sessions) {
403
403
  }
404
404
  }
405
405
 
406
+ // Live projection (1.104.0): the same presence / unfinished / keep-off strings
407
+ // this hook emits, written into CLAUDE.local.md and the Codex global
408
+ // instructions file for the NEXT session of any host (session start and Stop).
409
+ // Dynamic import so a stale deployment no-ops; never throws.
410
+ async function projectLiveSafe({ projectDir, sessions, sessionId, lockNotes = '', unfinished = '' }) {
411
+ try {
412
+ const lib = await import(new URL('./live-projection.mjs', import.meta.url).href);
413
+ if (typeof lib.projectLive !== 'function') return null;
414
+ const peers = (Array.isArray(sessions) ? sessions : []).filter((s) => s && s.id && s.id !== sessionId)
415
+ .map((s) => ({ id: s.id, client: s.client, intent: s.intent, files: s.files, observedFiles: s.observedFiles }));
416
+ return lib.projectLive({ projectDir, peers, lockNotesText: String(lockNotes || '').trim(), unfinishedText: String(unfinished || '').trim() });
417
+ } catch {
418
+ return null;
419
+ }
420
+ }
421
+
406
422
  // Unfinished-work patch for one Codex lifecycle event (session handoff). A
407
423
  // human prompt records what was asked; a Stop records the reply tail only if
408
424
  // the host put it in the payload. Null when the event carries nothing.
@@ -495,6 +511,20 @@ async function main() {
495
511
  ? touchedFiles(input, projectDir)
496
512
  : undefined;
497
513
  const files = humanTurn ? [] : undefined;
514
+ const workPatch = codexWorkPatch({ event, humanTurn, prompt, input }) || {};
515
+ // Where it works (1.103.0): the directory a tool call ran in (Codex's exec
516
+ // carries `workdir`; a command may `cd` or `git -C` elsewhere). Dynamic +
517
+ // guarded so a stale bundle without the helper still records the rest.
518
+ let whereLib = null;
519
+ if (event === 'PostToolUse' || event === 'Stop') {
520
+ try { whereLib = await import(new URL('./session-handoff.mjs', import.meta.url).href); } catch { whereLib = null; }
521
+ }
522
+ if (event === 'PostToolUse' && typeof whereLib?.workdirsFromToolInput === 'function') {
523
+ try {
524
+ const dirs = whereLib.workdirsFromToolInput(toolInput(input));
525
+ if (dirs.length) workPatch.addWorkdirs = dirs;
526
+ } catch { /* best-effort */ }
527
+ }
498
528
  const sessions = upsertSession({
499
529
  brainPath,
500
530
  id: sessionId,
@@ -517,8 +547,29 @@ async function main() {
517
547
  // human prompt on a human turn, and at Stop the reply tail WHEN the host
518
548
  // hands it over (Codex's Stop payload may carry `last_assistant_message`;
519
549
  // this adapter never reads Codex's transcript). Absent = keep the known.
520
- ...(codexWorkPatch({ event, humanTurn, prompt, input }) ? { work: codexWorkPatch({ event, humanTurn, prompt, input }) } : {}),
550
+ ...(Object.keys(workPatch).length ? { work: workPatch } : {}),
521
551
  });
552
+ // At Stop, a bounded git status of every checkout this session worked in
553
+ // (cwd first, then the workdirs its tool calls recorded) — the fix left
554
+ // uncommitted in a side checkout is what the 2026-10-15 field case lost.
555
+ // A second touch, so it sees the workdirs the first one merged.
556
+ if (event === 'Stop' && typeof whereLib?.snapshotCheckouts === 'function') {
557
+ try {
558
+ const me = sessions.find((session) => session.id === sessionId);
559
+ const checkouts = whereLib.snapshotCheckouts({
560
+ dirs: [cwd, ...(Array.isArray(me?.work?.workdirs) ? me.work.workdirs : [])],
561
+ projectDir,
562
+ sessionFiles: [...new Set([...(me?.files || []), ...(me?.observedFiles || [])])],
563
+ });
564
+ if (checkouts.length) {
565
+ upsertSession({
566
+ brainPath, id: sessionId, client: 'codex', event, channel: 'lifecycle', cwd,
567
+ ...(HOST_PID.pid ? { hostPid: HOST_PID.pid, hostPidSource: HOST_PID.source } : {}),
568
+ logicalSessionId: sessionId, identitySource: 'codex-lifecycle', work: { checkouts },
569
+ });
570
+ }
571
+ } catch { /* best-effort */ }
572
+ }
522
573
 
523
574
  const ignored = event === 'PostToolUse' ? ownBrainMessageText(input) : [];
524
575
  // Stop can show a UI warning but cannot add model context. Never advance a
@@ -552,6 +603,8 @@ async function main() {
552
603
  } catch { return ''; }
553
604
  })();
554
605
  const lockNotes = await lockNotesBlock(projectDir, brainPath, sessionId, sessions);
606
+ const unfinished = unfinishedWorkBlock(brainPath, sessionId);
607
+ await projectLiveSafe({ projectDir, sessions, sessionId, lockNotes, unfinished });
555
608
  emitSystemMessage([
556
609
  formatPresenceMessage(sessions, sessionId, { includeSolo: true }),
557
610
  lockNotes,
@@ -565,10 +618,16 @@ async function main() {
565
618
  // ⏸️ Unfinished work other sessions left (session handoff, 1.99.0): the
566
619
  // same engine block brain_sync prints, so a Codex session asked to
567
620
  // "continue" what a Claude session stopped in starts from its record.
568
- unfinishedWorkBlock(brainPath, sessionId),
621
+ unfinished,
569
622
  ], event);
570
623
  return;
571
624
  }
625
+ if (event === 'Stop') {
626
+ // The lane now carries this turn's files and reply tail: refresh the live
627
+ // projection for the next session, with a short git budget (a Stop must not wait).
628
+ const lockNotes = await lockNotesBlock(projectDir, brainPath, sessionId, sessions, { budgetMs: 1000 });
629
+ await projectLiveSafe({ projectDir, sessions, sessionId, lockNotes, unfinished: unfinishedWorkBlock(brainPath, sessionId) });
630
+ }
572
631
  if (event === 'UserPromptSubmit') {
573
632
  const me = sessions.find((session) => session.id === sessionId);
574
633
  // A STRONG status question is answered from computed state and REPLACES