klypix-mcp 1.98.0 → 1.100.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
@@ -590,8 +590,36 @@ receives the waiting note at its first action and the sender sees the receipt as
590
590
  waiting for them; `npx klypix-mcp sessions reopen <id>` reopens one (`--quiet` lets it wait for
591
591
  you instead of starting on the note).
592
592
 
593
- Durable handoffs go in the brain itself — decisions, findings, open questions and skills captured
594
- as cards, each stamped with the agent that wrote it.
593
+ ### Session handoff — continue in the next session, with any agent
594
+
595
+ The case this exists for: a Claude Code session stops at its usage limit in the middle of a task,
596
+ and the person opens Codex (or a fresh Claude Code) and says *continue*. Until 1.99.0 the only
597
+ handoff was whatever the person pasted. Now there are two layers, and the person does not have
598
+ to ask for either.
599
+
600
+ **Automatic.** At every turn end the hooks record the session's last state on the lane that already
601
+ remembers sessions for two weeks: the last human prompt, the tail of the last reply (where "done /
602
+ next" usually is), the files it touched, and its *own* commits (the shas its `git commit` printed —
603
+ never a branch range, because twenty sessions can share one checkout). When that session goes quiet
604
+ for ten minutes or closes with its task uncompleted, every new session on the project sees
605
+ **⏸️ Unfinished work left by other sessions** at its first `brain_sync` and at session start — in
606
+ Claude Code, Codex, and any MCP host — with a status (*closed*, *idle 83m*), the files, the commits,
607
+ the last words, and the resume command should the person want the old transcript. The agent takes it
608
+ over with `brain_handoff {action:"take", session:"<id>"}` so peers see it is taken, continues from
609
+ the record, and marks it `done`. Machine-local, like the rest of the lane.
610
+
611
+ **Deliberate.** When a session knows it is stopping — the person asks for a handoff, the context or
612
+ usage limit is near, the day ends, it is blocked, it is switching tool — it calls
613
+ `brain_handoff {action:"write"}` with the task, what is done, what is **next in order**, the files
614
+ and how to verify. That becomes a 🤝 HANDOFF card in the project brain: an open card the person can
615
+ see and correct on the canvas, with the files as evidence anchors, that travels with the repo and
616
+ syncs like every other card. It leads the session-start brief and the `brain_sync` start response
617
+ until a session takes it (`{action:"take", id}`) and finishes it (`{action:"done", id}`, which
618
+ archives it). A later handoff can name the earlier one it `continues`. The per-prompt hook nudges
619
+ the agent to write one when the person says *handoff* or names the limit.
620
+
621
+ Durable decisions still go in the brain as cards — a handoff is the one card about *work in
622
+ flight*, and it closes when the work lands.
595
623
 
596
624
  ## Evidence-gated completion
597
625
 
@@ -891,7 +919,7 @@ The MCP verbs below are what agents call. These are what **you** call:
891
919
 
892
920
  ---
893
921
 
894
- ## The verbs: 25, and 26 on Windows
922
+ ## The verbs: 26, and 27 on Windows
895
923
 
896
924
  | Tool | What it does |
897
925
  |---|---|
@@ -905,6 +933,7 @@ The MCP verbs below are what agents call. These are what **you** call:
905
933
  | `brain_doctor` | Self-diagnosis: version, core/enhanced host adapters, active sessions, tool count, projection drift |
906
934
  | `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) |
907
935
  | `brain_message_receipt` | Explicitly record model-side consumption using the exact message id and per-recipient offer token; acknowledgement alone never consumes a note |
936
+ | `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 |
908
937
  | `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 |
909
938
  | `brain_sync` | Context Gateway: task capsule, active-task peers, exact-file overlap, one-time alerts, timing, and optional result-manifest reconciliation |
910
939
  | `brain_connect` | Find and draw related-but-unlinked cards |
@@ -922,7 +951,7 @@ The MCP verbs below are what agents call. These are what **you** call:
922
951
  | `add_to_canvas` | Append cards/connections (positions preserved), bordered and readable on KLYPIX's dark and Paper themes; a card's `group` puts it in that titled box. On a canvas open in KLYPIX (Windows) the cards go to KLYPIX itself: live, attributed, one undo. When KLYPIX cannot take them it refuses (`OPEN_IN_APP`, `ACCESS_OFF`, `BLOCKED`), a box locked from AI tools (`SCOPE_LOCKED`) or a frozen box (`FROZEN`), and writes nothing; project brains are the exception to the first. Returns the new card ids |
923
952
  | `list_canvases` | List every `.klypix` in the vault |
924
953
 
925
- Exactly 25 on Linux and macOS and 26 on Windows, where `show_in_klypix` is registered; machine-verifiable with `npx klypix-mcp doctor`, whose TOOLS line applies the same rule and whose App bridge line says whether KLYPIX is running, with access on or off.
954
+ Exactly 26 on Linux and macOS and 27 on Windows, where `show_in_klypix` is registered; machine-verifiable with `npx klypix-mcp doctor`, whose TOOLS line applies the same rule and whose App bridge line says whether KLYPIX is running, with access on or off.
926
955
 
927
956
  > **`canvas_view`:** no MCP Apps host has been observed rendering the UI resource yet — there is no
928
957
  > screenshot and no host-level test. Hosts without the extension get clean text, which is the path
@@ -243,6 +243,7 @@ const DELIBERATELY_UNWIRED = Object.freeze({
243
243
  opBrainReconcile: 'wire-supplied roots can traverse host directories; keep MCP-only',
244
244
  opBrainGarden: 'apply requires an out-of-band human approval code and must never be weakened for A2A',
245
245
  opBrainMessage: 'send-only machine-local messaging has no receivable A2A half',
246
+ opBrainHandoff: 'take/done stamp the calling session identity on the lane; A2A has no authenticated session identity (MCP-only)',
246
247
  });
247
248
 
248
249
  const canonicalPath = (p) => {
@@ -347,7 +347,9 @@ const flatten = (code) => code
347
347
  // tools import the lease reader. app-bridge-protocol / app-bridge-client
348
348
  // (P1 agent parity): the app tools lazily import the client of the KLYPIX
349
349
  // app bridge, which imports the protocol.
350
- .replace(/\.\.\/src\/(bench|brain-doctor|agent-presence|agent-rules|capture-gap|enrichment|finding-routing|mcp-presence|mcp-supervisor|mcp-auto-update|presence-relay|repo-state|semantic-memory|runtime-inspector|project-graph|git-capture-install|app-lease|app-tools|app-bridge-protocol|app-bridge-client)\.mjs/g, './$1.mjs')
350
+ // session-handoff (1.99.1): the worker's brain_sync imports CONTINUE_RE and
351
+ // formatHandoffCards from it directly (1.99.0 only reached it via core).
352
+ .replace(/\.\.\/src\/(bench|brain-doctor|agent-presence|agent-rules|capture-gap|enrichment|finding-routing|mcp-presence|mcp-supervisor|mcp-auto-update|presence-relay|repo-state|semantic-memory|runtime-inspector|project-graph|git-capture-install|app-lease|app-tools|app-bridge-protocol|app-bridge-client|session-handoff)\.mjs/g, './$1.mjs')
351
353
  .replace(/klypix-worker\.mjs/g, 'klypix-mcp-worker.mjs')
352
354
  .replace(/const PKG_VERSION = \(\(\) => \{[\s\S]*?\}\)\(\);/, `const PKG_VERSION = '${VERSION}'; // baked at install (flat layout has no package.json)`);
353
355
 
@@ -493,7 +495,7 @@ try {
493
495
  // a newer klypix-format cannot. merge-brains and the driver ship here so
494
496
  // brain-history's restore merge, the KLYPIX core and the git driver all
495
497
  // find one engine in this directory.
496
- 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', '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', 'canvas-view-app.html']) {
497
499
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
498
500
  }
499
501
  for (const [src, dst] of [
@@ -28,7 +28,7 @@ import {
28
28
  resolveVault, getEmbedder, shouldPrewarmSemantic, buildKlypixMap, cardSchema, connSchema, groupSchema,
29
29
  opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
30
30
  opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage, opBrainAsk, opBrainChallenge, opCanvasView, opBrainLens,
31
- opBrainTaskContext,
31
+ opBrainTaskContext, opBrainHandoff,
32
32
  } from '../src/klypix-core.mjs';
33
33
  import { compareProjectGraphResults, projectGraphContextMarkdown, queryProjectGraph, suggestProjectGraphBrainLinks, scanNativeProjectMap, checkBrainDrift, brainDriftMarkdown } from '../src/project-graph.mjs';
34
34
  import { auditProject, compactAgentsBrief, linkProject, mcpServerEntry } from '../src/agent-rules.mjs';
@@ -44,8 +44,10 @@ import {
44
44
  reopenCandidate,
45
45
  reopenQuestion,
46
46
  REOPEN_NUDGE,
47
+ formatUnfinishedWork,
47
48
  } from '../src/agent-presence.mjs';
48
49
  import { collectRepoState, commitsInRange, makeContainmentProbe } from '../src/repo-state.mjs';
50
+ import { CONTINUE_RE as HANDOFF_CONTINUE_RE, formatHandoffCards } from '../src/session-handoff.mjs';
49
51
  import {
50
52
  reconcileRegisteredProjects,
51
53
  registerProjectBrain,
@@ -1064,6 +1066,61 @@ server.registerTool('brain_reopen', {
1064
1066
  return reply('reopened', `🔓 Reopened ${label} — the human said yes${consent.via === 'dialog' ? ' in the KLYPIX dialog' : ''}. It opened in a new ${plan.method === 'windows-terminal' ? 'Windows Terminal window' : plan.method === 'macos-terminal' ? 'Terminal window' : 'terminal window'} in ${candidate.cwd} with ${candidate.command}${consent.start ? ', and starts on the note right away' : ', waiting for the human to prompt it'}. ${notes === 1 ? 'The waiting note is' : `The ${notes} waiting notes are`} delivered at its first action; your next prompt shows the receipt. If the window shows an error (for example the command is not found), the note stays queued — ${manualLine}.`, { via: consent.via, method: plan.method, cwd: candidate.cwd, command: candidate.command, start: Boolean(consent.start), notes });
1065
1067
  });
1066
1068
 
1069
+ // ── brain_handoff (1.99.0): a session hands its unfinished work to the next ─
1070
+ // Two layers. AUTOMATIC: every Stop hook records the session's last state on
1071
+ // the lane (intent, files, own commits, the tail of its last reply); when the
1072
+ // session goes quiet or closes with its task uncompleted, every new session of
1073
+ // any agent sees "unfinished work" at its first brain_sync. DELIBERATE: this
1074
+ // verb's `write` puts a 🤝 HANDOFF card into the brain — what is done, what is
1075
+ // next, files, how to verify — which the next session takes over by id.
1076
+ server.registerTool('brain_handoff', {
1077
+ title: 'Hand unfinished work to the next session — or take over what another session left',
1078
+ description: 'The session handoff. Call {action:"write"} when you are stopping before the work is done — the human asked for a handoff, your context or usage limit is near, the day ends, you are blocked or switching tool — with the task, what is done, what is NEXT in order, the files and how to verify: it becomes a 🤝 card in the project brain that the human can see and correct, and the next session on this project (any agent — Codex, Claude Code, Cursor) sees it at its first brain_sync / session start. Call {action:"take", id} (a card) or {action:"take", session} (unfinished work a session left without a card — KLYPIX records every session\'s last reply, files and commits automatically) before continuing it, so peers see it is taken; {action:"done"} when it is finished (the card is archived); {action:"list"} shows every open handoff and every unfinished session. Never ask the human to paste a summary into the next session — write a handoff. Machine-local for the automatic layer; the card travels with the brain.',
1079
+ annotations: {
1080
+ destructiveHint: false,
1081
+ idempotentHint: false,
1082
+ openWorldHint: false,
1083
+ },
1084
+ inputSchema: {
1085
+ action: z.enum(['write', 'take', 'done', 'list']).optional().describe('write (default): record a handoff card. take: claim a handoff card (id) or unfinished work (session) before continuing it. done: mark it finished. list: every open handoff card and every unfinished session on this project.'),
1086
+ project: z.string().optional().describe('Absolute project root containing brain.klypix. Defaults to this connection\'s project.'),
1087
+ canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
1088
+ title: z.string().max(120).optional().describe('write: the work, in a few words ("Drive upload: the Place-it-now chooser").'),
1089
+ task: z.string().max(400).optional().describe('write: one or two sentences — what the work is and why.'),
1090
+ done: z.array(z.string().max(240)).max(12).optional().describe('write: what is already finished (so it is not redone).'),
1091
+ next: z.array(z.string().max(240)).max(12).optional().describe('write: the remaining steps IN ORDER, as the next session should do them. Required.'),
1092
+ files: z.array(z.string().max(200)).max(20).optional().describe('write: project-relative files the work touches (recorded as evidence anchors).'),
1093
+ verify: z.string().max(400).optional().describe('write: how to check the state — a command or a visible result.'),
1094
+ open: z.array(z.string().max(240)).max(8).optional().describe('write: questions still open, or decisions the human has to make.'),
1095
+ reason: z.enum(['context_limit', 'usage_limit', 'end_of_session', 'blocked', 'switching_tool', 'other']).optional().describe('write: why the work stops here.'),
1096
+ area: z.string().max(40).optional().describe('write: the brain area (titled box) the card goes into. Defaults to "Handoff".'),
1097
+ continues: z.string().max(64).optional().describe('write: the id of an earlier handoff card this one continues — it is closed as "continued by" this one.'),
1098
+ id: z.string().max(64).optional().describe('take / done: the handoff card id (from brain_sync, the session-start brief, or list).'),
1099
+ session: z.string().max(160).optional().describe('take / done: the session id (or a unique prefix of 8+ characters) from the unfinished-work list, when the work has no card.'),
1100
+ summary: z.string().max(400).optional().describe('done: what was finished, in one or two sentences. take: an optional note.'),
1101
+ },
1102
+ }, async ({ action, project, canvas, ...rest }, extra) => {
1103
+ const identity = extra?.klypixRequestIdentity || {};
1104
+ const result = await opBrainHandoff({
1105
+ vault: project ? path.resolve(project) : mcpPresence.vault,
1106
+ canvas: canvas || boundBrainCanvas(undefined),
1107
+ action: action || 'write',
1108
+ from: { sessionId: identity.sessionId || mcpPresence.id, client: identity.clientInfo?.client || extra?.klypixClientName || 'unknown' },
1109
+ via: extra?.klypixClientName,
1110
+ ...rest,
1111
+ });
1112
+ // Capture receipt, as for brain_note: a written handoff IS this session's
1113
+ // reasoning recorded through MCP — the uncaptured-work nudge must not fire.
1114
+ if ((action || 'write') === 'write' && !result.isError) {
1115
+ try {
1116
+ const { recordSessionCapture } = await import('../src/capture-gap.mjs');
1117
+ recordSessionCapture(identity.sessionId || mcpPresence.id, undefined, Date.now(), { project: mcpPresence.vault });
1118
+ } catch { /* receipt is best-effort */ }
1119
+ }
1120
+ const out = toContent(result);
1121
+ return result.structured ? { ...out, structuredContent: result.structured } : out;
1122
+ });
1123
+
1067
1124
  server.registerTool('brain_sync', {
1068
1125
  title: 'KLYPIX Context Gateway — synchronize task, peers, conflicts, and relevant memory',
1069
1126
  description: 'APPROVAL-FREE task gateway over the authorized MCP connection. Call FIRST with a concise intent and expected files, again when scope changes, and with phase:"complete" before the final response. One bounded response returns compact task-relevant brain context, active TASK peers (idle connections hidden), one-time messages, structured exact-file conflicts, and late-arrival overlap alerts. A completion that supplies machine-checkable result manifests is fail-closed: invalid, conflicting, or incomparable evidence returns needs-reconciliation and retains task scope. `results` is ONLY for such manifests, never a summary of the work: with nothing machine-checkable to claim, complete without it. Works on any MCP host — it needs only the authorized MCP connection, so native lifecycle hooks are optional. LIMITS: blocking overlap is EXACT-PATH over the declared files of each session plus the edits its host observed; sessions on different branches are also test-merged with git merge-tree (committed work only); a same-folder overlap is an advisory notice; coordination/result reconciliation is machine-local and OS-user-local (a teammate on another machine is invisible), and file overlap remains ADVISORY.',
@@ -1343,6 +1400,41 @@ server.registerTool('brain_sync', {
1343
1400
  .map((block) => block.text)
1344
1401
  .filter(Boolean)
1345
1402
  .join('\n\n') || '';
1403
+ // ── Handoffs (1.99.0): on task START only — the one moment a session asked
1404
+ // to "continue" needs them. Open 🤝 cards + unfinished work other sessions
1405
+ // left on the lane, as one block; a second brain parse, bounded, never
1406
+ // fails a sync. Checkpoints and completions print nothing here.
1407
+ let handoffText = '';
1408
+ let handoffData = null;
1409
+ if (phase === 'start' && include_context !== false && report.structured?.brain) {
1410
+ try {
1411
+ const h = await opBrainHandoff({ vault: mcpPresence.vault, canvas: report.structured.brain, action: 'list', from: { sessionId: extra?.klypixRequestIdentity?.sessionId || mcpPresence.id } });
1412
+ const cards = h?.structured?.handoffs || [];
1413
+ // 1.99.1: the AUTOMATIC lane records are history, not a lock (bench S5:
1414
+ // an ended session's intent on a file made Claude and Codex defer, 0/5).
1415
+ // They ride the start response only when this task asks to continue
1416
+ // something, or touches a file one of them touched — and then as
1417
+ // "stopped, holds no files". Deliberate 🤝 cards always ride.
1418
+ const wantsContinue = HANDOFF_CONTINUE_RE.test(String(intent || ''));
1419
+ const normPath = (p) => String(p || '').replace(/\\/g, '/').toLowerCase();
1420
+ const mine = new Set((Array.isArray(files) ? files : []).map(normPath));
1421
+ const lane = (h?.structured?.unfinished || []).filter((r) => wantsContinue
1422
+ || (Array.isArray(r.files) && r.files.some((p) => mine.has(normPath(p)))));
1423
+ if (!h?.isError && (cards.length || lane.length)) {
1424
+ const now = Date.now();
1425
+ handoffText = [
1426
+ formatHandoffCards(cards, { now, max: 5 }),
1427
+ formatUnfinishedWork(lane, { now, max: 4 }),
1428
+ ].filter(Boolean).join('\n\n');
1429
+ handoffData = {
1430
+ cards: cards.length,
1431
+ unfinished: lane.length,
1432
+ 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 })),
1433
+ 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 })),
1434
+ };
1435
+ }
1436
+ } catch { /* never fails a sync */ }
1437
+ }
1346
1438
  const totalMs = Math.max(0, Date.now() - totalStartedAt);
1347
1439
  const structuredContent = {
1348
1440
  ...(report.structured || {
@@ -1363,6 +1455,7 @@ server.registerTool('brain_sync', {
1363
1455
  },
1364
1456
  ...(registration ? { registration } : {}),
1365
1457
  ...(harness ? { harness } : {}),
1458
+ ...(handoffData ? { handoffs: handoffData } : {}),
1366
1459
  };
1367
1460
  const harnessText = harness?.failed
1368
1461
  ? `KLYPIX harness self-heal needs attention: ${harness.failed} project(s) remain partially aligned; brain_doctor has the exact files.`
@@ -1374,7 +1467,7 @@ server.registerTool('brain_sync', {
1374
1467
  return {
1375
1468
  content: [{
1376
1469
  type: 'text',
1377
- text: [report.text, harnessText, shipNotice, releaseReconcileText, captureGapText, contextText, timingText].filter(Boolean).join('\n\n'),
1470
+ text: [report.text, harnessText, shipNotice, releaseReconcileText, captureGapText, handoffText, contextText, timingText].filter(Boolean).join('\n\n'),
1378
1471
  }],
1379
1472
  structuredContent,
1380
1473
  ...(report.isError ? { isError: true } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.98.0",
3
+ "version": "1.100.0",
4
4
  "mcpName": "io.github.dahshanlabs/klypix-mcp",
5
5
  "description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
6
6
  "type": "module",
@@ -86,7 +86,7 @@
86
86
  "bench": "node bin/klypix-mcp.mjs bench",
87
87
  "test:bench": "node test/bench.mjs",
88
88
  "pretest": "node test/publish-workflow.mjs",
89
- "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/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/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",
89
+ "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",
90
90
  "test:memory": "node test/memory-runtime.mjs",
91
91
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
92
92
  "runtime": "node bin/klypix-runtime.mjs",
@@ -493,6 +493,55 @@ const endedMatches = (row, id) => {
493
493
  // ── Session directory upkeep (mailbox) ───────────────────────────────────────
494
494
  // PARITY: global-brain-hook.mjs keeps a verbatim twin (dirPrune/dirRemember)
495
495
  // because that hook stays free of sibling imports — change both.
496
+ //
497
+ // ── Unfinished work (session handoff, 1.99.0) ────────────────────────────────
498
+ // A session's last known STATE rides its directory entry as `work`, so the
499
+ // work a session stopped in the middle of — at a usage or context limit, a
500
+ // closed window, a crash — is visible to the next session of ANY agent, with
501
+ // no one having to write or paste a summary:
502
+ // lastPrompt the last human prompt the session answered
503
+ // lastWords the tail of its last reply (where "done / next" usually is)
504
+ // commits commits made since the session started (sha + subject)
505
+ // baseHead the HEAD the session started from (what `commits` counts from)
506
+ // handoffCardId a deliberate 🤝 handoff card it wrote into the brain
507
+ // takenBy the session that took the work over (brain_handoff take)
508
+ // doneAt/doneBy marked finished (brain_handoff done)
509
+ // `intentAt` and `completedAt` are copied from the lane row for the same
510
+ // reason: a session whose intent was never completed is the one to show.
511
+ // Every field is capped here, and an empty object is dropped, so an old entry
512
+ // without `work` is byte-stable through a touch that changes nothing.
513
+ export const WORK_LAST_WORDS_MAX = 700;
514
+ export const WORK_COMMITS_MAX = 6;
515
+ export function normalizeWork(raw) {
516
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
517
+ const out = {};
518
+ const str = (value, max) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, max);
519
+ const ms = (value) => (Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : null);
520
+ // Both texts are rendered into OTHER sessions' prompts: a capture marker
521
+ // inside them must never be harvestable there (see neutralizeMarkers).
522
+ if (raw.lastPrompt) out.lastPrompt = neutralizeMarkers(str(raw.lastPrompt, 240));
523
+ if (raw.lastWords) out.lastWords = neutralizeMarkers(String(raw.lastWords).replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim().slice(0, WORK_LAST_WORDS_MAX));
524
+ if (ms(raw.lastWordsAt)) out.lastWordsAt = ms(raw.lastWordsAt);
525
+ if (raw.baseHead) out.baseHead = String(raw.baseHead).trim().slice(0, 40);
526
+ if (Array.isArray(raw.commits)) {
527
+ const commits = raw.commits
528
+ .map((c) => (c && typeof c === 'object' ? { sha: str(c.sha, 12), subject: str(c.subject, 120) } : null))
529
+ .filter((c) => c && c.sha)
530
+ .slice(0, WORK_COMMITS_MAX);
531
+ if (commits.length) out.commits = commits;
532
+ }
533
+ if (raw.handoffCardId) out.handoffCardId = str(raw.handoffCardId, 64);
534
+ if (raw.takenBy && typeof raw.takenBy === 'object' && raw.takenBy.id) {
535
+ out.takenBy = { id: str(raw.takenBy.id, 160), client: str(raw.takenBy.client, 40) || 'unknown', at: ms(raw.takenBy.at) };
536
+ }
537
+ if (ms(raw.doneAt)) { out.doneAt = ms(raw.doneAt); if (raw.doneBy) out.doneBy = str(raw.doneBy, 160); }
538
+ return Object.keys(out).length ? out : null;
539
+ }
540
+ export function mergeWork(previous, patch) {
541
+ if (patch === undefined) return normalizeWork(previous);
542
+ if (patch === null) return null;
543
+ return normalizeWork({ ...(normalizeWork(previous) || {}), ...(patch && typeof patch === 'object' ? patch : {}) });
544
+ }
496
545
  const pruneDirectory = (rows, now) => (Array.isArray(rows) ? rows : [])
497
546
  .filter((row) => row && recipientKey(row.id) && now - Number(row.lastSeen || 0) < DIRECTORY_FRESH_MS)
498
547
  .map((row) => ({
@@ -508,6 +557,9 @@ const pruneDirectory = (rows, now) => (Array.isArray(rows) ? rows : [])
508
557
  lastSeen: Number(row.lastSeen) || now,
509
558
  endedAt: Number(row.endedAt) || null,
510
559
  hostStatus: row.hostStatus ? String(row.hostStatus).slice(0, 16) : null,
560
+ ...(Number(row.intentAt) > 0 ? { intentAt: Number(row.intentAt) } : {}),
561
+ ...(Number(row.completedAt) > 0 ? { completedAt: Number(row.completedAt) } : {}),
562
+ ...(normalizeWork(row.work) ? { work: normalizeWork(row.work) } : {}),
511
563
  }))
512
564
  .sort((left, right) => Number(left.lastSeen) - Number(right.lastSeen))
513
565
  .slice(-DIRECTORY_CAP);
@@ -524,6 +576,12 @@ function rememberSession(directory, row, now, { ended = false } = {}) {
524
576
  const prev = index >= 0 ? rows[index] : null;
525
577
  const scope = [...new Set([...normalizeFiles(row.files), ...normalizeFiles(row.observedFiles)])].slice(-8);
526
578
  const intent = String(row.intent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
579
+ // The lane row's own timestamps decide "unfinished": an intent newer than
580
+ // its completion is work nobody finished. A row that carries neither keeps
581
+ // what the directory already knew (a hook twin predating 1.99.0).
582
+ const intentAt = Number(row.intentAt) > 0 ? Number(row.intentAt) : (Number(prev?.intentAt) > 0 ? Number(prev.intentAt) : null);
583
+ const completedAt = Number(row.completedAt) > 0 ? Number(row.completedAt) : (row.completedAt === null && intent ? null : (Number(prev?.completedAt) > 0 ? Number(prev.completedAt) : null));
584
+ const work = mergeWork(prev?.work, row.work);
527
585
  const entry = {
528
586
  id,
529
587
  client: String(row.client || prev?.client || 'unknown').slice(0, 40),
@@ -537,6 +595,9 @@ function rememberSession(directory, row, now, { ended = false } = {}) {
537
595
  lastSeen: now,
538
596
  endedAt: ended ? now : null,
539
597
  hostStatus: row.hostStatus ? String(row.hostStatus).slice(0, 16) : (prev?.hostStatus || null),
598
+ ...(intentAt ? { intentAt } : {}),
599
+ ...(completedAt ? { completedAt } : {}),
600
+ ...(work ? { work } : {}),
540
601
  };
541
602
  if (index >= 0) rows[index] = entry; else rows.push(entry);
542
603
  return rows
@@ -957,6 +1018,10 @@ export function upsertSession({
957
1018
  // Host activity status (mailbox, 1.88.0): 'busy' | 'idle'. Derived from
958
1019
  // `event` when a writer does not pass it; heartbeats keep the previous value.
959
1020
  hostStatus = null,
1021
+ // Unfinished-work state (session handoff, 1.99.0): a PATCH merged over the
1022
+ // row's `work` (see normalizeWork) — last reply tail, commits, base head,
1023
+ // handoff card, taken/done stamps. Absent = keep what the row has.
1024
+ work,
960
1025
  now = Date.now(),
961
1026
  }) {
962
1027
  if (!brainPath || !id) return withWriteVerdict([], false, 'no-brain-or-id');
@@ -1119,6 +1184,9 @@ export function upsertSession({
1119
1184
  ...(taskCompleted ? { completedAt: now } : (previous.completedAt ? { completedAt: previous.completedAt } : {})),
1120
1185
  }),
1121
1186
  ...(statusDigestHash !== undefined ? { statusDigestHash: String(statusDigestHash || '').slice(0, 16) } : {}),
1187
+ // ADDITIVE unfinished-work state: merged only when this touch carries a
1188
+ // patch; the `...previous` spread keeps it verbatim otherwise.
1189
+ ...(work !== undefined ? (mergeWork(previous.work, work) ? { work: mergeWork(previous.work, work) } : {}) : {}),
1122
1190
  // ADDITIVE host status: stamped only when this touch carries one, kept
1123
1191
  // verbatim otherwise, so a heartbeat never turns "working" into unknown.
1124
1192
  ...(nextHostStatus
@@ -2824,6 +2892,165 @@ export function listKnownSessions({ brainPath, home, now = Date.now() } = {}) {
2824
2892
  }).sort((left, right) => Number(right.lastSeen) - Number(left.lastSeen));
2825
2893
  }
2826
2894
 
2895
+ // ── Unfinished work — the automatic session handoff (1.99.0) ────────────────
2896
+ // The field case (2026-10-11): the founder's Claude Code session stops at a
2897
+ // usage limit mid-task, and Codex is asked to "continue" — with nothing to
2898
+ // continue from but whatever the human pastes. The lane already remembers
2899
+ // every exactly-identified session for two weeks; `work` (see normalizeWork)
2900
+ // adds the state a Stop hook can record without anyone writing a summary.
2901
+ // This is the READ side: every session whose intent was never completed and
2902
+ // that nobody is working on right now — closed, or live but quiet — newest
2903
+ // first, with its last words, commits and files, and whether another session
2904
+ // already took it over. Nothing here starts, wakes or routes a session.
2905
+ export const UNFINISHED_QUIET_MS = 10 * 60 * 1000;
2906
+ // 1.99.1 — signal, not history (field + bench, 2026-10-12). 1.99.0 listed every
2907
+ // remembered session with an uncompleted intent: 34 on the founder's PC, most
2908
+ // of them finished chats whose last prompt was "i can close?" (a hook-only
2909
+ // Claude session never records completion), and the coordination bench (S5)
2910
+ // showed an ended session's intent on a file read to Claude AND Codex as a live
2911
+ // claim (0/5). So a record is listed only when (a) the session stopped within
2912
+ // UNFINISHED_WINDOW_MS, and (b) it carries a 1.99+ work record — a reply tail,
2913
+ // own commits or a handoff card — so legacy and empty sessions never appear;
2914
+ // and every record says, in text and JSON, that it is not running and holds
2915
+ // no claim on any file.
2916
+ export const UNFINISHED_WINDOW_MS = 24 * 60 * 60 * 1000;
2917
+ export const hasWorkEvidence = (work) => Boolean(work && (work.lastWords || (Array.isArray(work.commits) && work.commits.length) || work.handoffCardId));
2918
+ export function clientWord(client) {
2919
+ const key = String(client || '').toLowerCase();
2920
+ if (key === 'claude-code' || key === 'claude') return 'Claude Code';
2921
+ if (key === 'codex') return 'Codex';
2922
+ return key ? key.replace(/(^|[-_ ])([a-z])/g, (_m, p, c) => `${p}${c.toUpperCase()}`) : 'Unknown';
2923
+ }
2924
+ // How long since the row's host last did WORK: a heartbeat refreshes lastSeen
2925
+ // without touching it, so a connected-but-idle MCP session reads as quiet, not
2926
+ // as busy. A row with no host status falls back to its last activity/intent.
2927
+ const quietFor = (row, now) => {
2928
+ if (!row) return Infinity;
2929
+ const status = String(row.hostStatus || '');
2930
+ if (status === 'busy' && now - Number(row.hostStatusAt || row.lastSeen || now) < HOST_STATUS_BUSY_FRESH_MS) return 0;
2931
+ const at = Number(row.hostStatusAt || row.activityAt || row.intentAt || row.startedAt || row.lastSeen || now);
2932
+ return Math.max(0, now - at);
2933
+ };
2934
+ export function listUnfinishedWork({ brainPath, home, now = Date.now(), selfId = '', includeWorking = false } = {}) {
2935
+ if (!brainPath) return [];
2936
+ const lane = readLane(laneFileFor(brainPath, home));
2937
+ const live = pruneSessions(lane.sessions, now);
2938
+ const liveRow = (id) => live.find((session) => sessionIdentityKeys(session).includes(String(id || '').toLowerCase())) || null;
2939
+ const self = recipientKey(selfId).toLowerCase();
2940
+ return pruneDirectory(lane.directory, now)
2941
+ .filter((entry) => entry.intent && entry.id.toLowerCase() !== self)
2942
+ .map((entry) => {
2943
+ const row = liveRow(entry.id);
2944
+ const work = entry.work || {};
2945
+ const intentAt = Number(entry.intentAt) || Number(entry.firstSeen) || 0;
2946
+ const completed = Number(entry.completedAt) > 0 && Number(entry.completedAt) >= intentAt;
2947
+ const done = Number(work.doneAt) > 0 && Number(work.doneAt) >= intentAt;
2948
+ const taker = work.takenBy ? liveRow(work.takenBy.id) : null;
2949
+ const quietMs = quietFor(row, now);
2950
+ return { entry, row, work, completed, done, taker, quietMs };
2951
+ })
2952
+ .filter(({ entry, work, completed, done, quietMs }) => !completed && !done
2953
+ && (includeWorking || quietMs >= UNFINISHED_QUIET_MS)
2954
+ && (includeWorking || (hasWorkEvidence(work) && now - Number(entry.lastSeen || 0) < UNFINISHED_WINDOW_MS)))
2955
+ .map(({ entry, row, work, taker, quietMs }) => ({
2956
+ id: entry.id,
2957
+ client: entry.client,
2958
+ intent: entry.intent,
2959
+ intentAt: entry.intentAt || null,
2960
+ branch: entry.branch || null,
2961
+ cwd: entry.cwd || null,
2962
+ files: Array.isArray(entry.files) ? entry.files : [],
2963
+ lastSeen: entry.lastSeen,
2964
+ endedAt: entry.endedAt || null,
2965
+ ageMs: Math.max(0, now - Number(entry.lastSeen || now)),
2966
+ quietMs: Number.isFinite(quietMs) ? quietMs : null,
2967
+ live: Boolean(row),
2968
+ status: row
2969
+ ? (sessionStatusLabel(row, now) || 'live')
2970
+ : (entry.endedAt ? 'closed' : `not seen for ${agoLabel(now - entry.lastSeen).replace(/ ago$/, '')}`),
2971
+ resumeCommand: row ? '' : resumeCommandFor(entry.client, entry.id),
2972
+ // Machine-readable state — hosts that hand the model the JSON (Claude
2973
+ // Code reads structuredContent) must see what the text says: stopped,
2974
+ // not working, no file reserved.
2975
+ state: row ? 'stopped' : 'ended',
2976
+ working: false,
2977
+ holdsFiles: false,
2978
+ 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.',
2979
+ lastPrompt: work.lastPrompt || '',
2980
+ lastWords: work.lastWords || '',
2981
+ lastWordsAt: work.lastWordsAt || null,
2982
+ commits: Array.isArray(work.commits) ? work.commits : [],
2983
+ handoffCardId: work.handoffCardId || null,
2984
+ takenBy: work.takenBy
2985
+ ? { ...work.takenBy, live: Boolean(taker), status: taker ? (sessionStatusLabel(taker, now) || 'live') : 'not running' }
2986
+ : null,
2987
+ }))
2988
+ .sort((left, right) => Number(right.lastSeen) - Number(left.lastSeen));
2989
+ }
2990
+
2991
+ // Stamp unfinished-work state onto one remembered session (directory entry
2992
+ // AND its live row when there is one): the writer of a handoff card, the
2993
+ // session that takes work over, the one that marks it done. Exact id or a
2994
+ // unique prefix; ambiguity and unknown ids fail closed. Machine-local.
2995
+ export function stampSessionWork({ brainPath, sessionId, patch, home, now = Date.now() }) {
2996
+ if (!brainPath || !sessionId || !patch || typeof patch !== 'object') return { ok: false, reason: 'invalid-stamp' };
2997
+ const laneFile = laneFileFor(brainPath, home);
2998
+ const lockFile = laneFile + '.lock';
2999
+ if (!acquireLock(lockFile)) return { ok: false, reason: 'lane-locked' };
3000
+ try {
3001
+ const laneRead = readMutableLane(laneFile);
3002
+ if (!laneRead.ok) return { ok: false, reason: laneRead.reason };
3003
+ const data = laneRead.data;
3004
+ const sessions = pruneSessions(data.sessions, now);
3005
+ const directory = pruneDirectory(data.directory, now);
3006
+ const wanted = String(sessionId).trim();
3007
+ const known = resolveDirectoryTarget(wanted, directory);
3008
+ if (known.ambiguous) return { ok: false, reason: 'target-not-unique' };
3009
+ const entry = known.entry;
3010
+ const keys = [wanted.toLowerCase(), ...(entry ? [entry.id.toLowerCase()] : [])];
3011
+ const row = sessions.find((session) => sessionIdentityKeys(session).some((key) => keys.includes(key))) || null;
3012
+ if (!entry && !row) return { ok: false, reason: 'target-unknown' };
3013
+ if (entry) { const merged = mergeWork(entry.work, patch); if (merged) entry.work = merged; else delete entry.work; }
3014
+ if (row) { const merged = mergeWork(row.work, patch); if (merged) row.work = merged; else delete row.work; }
3015
+ fs.mkdirSync(path.dirname(laneFile), { recursive: true });
3016
+ writeLaneFileAtomic(laneFile, JSON.stringify({ ...data, sessions, directory }));
3017
+ return { ok: true, id: entry?.id || row?.logicalSessionId || row?.id, entry: entry || null };
3018
+ } catch (error) {
3019
+ return { ok: false, reason: writeFailureReason(error) };
3020
+ } finally {
3021
+ releaseLock(lockFile);
3022
+ }
3023
+ }
3024
+
3025
+ // The block every surface prints (brain_sync, the Claude and Codex hooks):
3026
+ // one record per unfinished session, newest first, then how to take it over.
3027
+ // Quoted text is another session's record and is labelled as such — never as
3028
+ // the human's instructions; markers inside it were neutralized at store time.
3029
+ export function formatUnfinishedWork(records, { now = Date.now(), max = 5, cardTitles = null } = {}) {
3030
+ const list = Array.isArray(records) ? records : [];
3031
+ if (!list.length) return '';
3032
+ const flat = (value) => String(value || '').replace(/\s+/g, ' ').trim();
3033
+ const lines = [`⏸️ Work other sessions may have left unfinished (${list.length}, stopped in the last 24h, 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:`];
3034
+ for (const r of list.slice(0, max)) {
3035
+ const who = `${clientWord(r.client)} ${String(r.id).slice(0, 8)}`;
3036
+ const bits = [r.state === 'ended' ? 'ended' : `stopped (${r.status})`, `last active ${agoLabel(r.ageMs)}`];
3037
+ if (r.branch) bits.push(`branch ${r.branch}`);
3038
+ lines.push(`- ${who} · “${flat(r.intent).slice(0, 120)}” · ${bits.join(' · ')}`);
3039
+ if (r.commits.length) lines.push(` commits: ${r.commits.map((c) => `${c.sha} ${c.subject}`).join('; ').slice(0, 320)}`);
3040
+ if (r.files.length) lines.push(` files it touched (free — not reserved): ${r.files.slice(-8).join(', ')}`);
3041
+ if (r.lastWords) lines.push(` its last words: “${flat(r.lastWords).slice(0, 420)}”`);
3042
+ if (r.handoffCardId) {
3043
+ const title = cardTitles && cardTitles.get ? cardTitles.get(r.handoffCardId) : null;
3044
+ 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)`);
3045
+ }
3046
+ 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`);
3047
+ if (r.resumeCommand) lines.push(` its own transcript, should the human want it: ${r.resumeCommand}`);
3048
+ }
3049
+ if (list.length > max) lines.push(`- …and ${list.length - max} more — brain_handoff {action:"list"} shows all.`);
3050
+ lines.push('If the human asks you to continue one: brain_handoff {action:"take", session:"<its id>"} (peers then see it is taken), then brain_sync with that intent and files; when finished, brain_handoff {action:"done", session:"<its id>"}. Otherwise ignore this list — it is history, not a lock.');
3051
+ return lines.join('\n');
3052
+ }
3053
+
2827
3054
  // ── Reopen on the human's OK (1.91.0) ───────────────────────────────────────
2828
3055
  // A note to a session that has closed waits up to a week and is delivered the
2829
3056
  // moment that session next acts — but only if a human happens to reopen it.
@@ -3237,10 +3464,11 @@ export function reopenQuestion(candidate, { senderLabel = 'another session', sta
3237
3464
  const entry = candidate?.entry || {};
3238
3465
  const who = `${candidate?.hostLabel || 'The'} session ${String(entry.id || '').slice(0, 8)}${entry.intent ? ` ("${neutralizeMarkers(String(entry.intent).slice(0, 70))}")` : ''}`;
3239
3466
  const notes = candidate?.waitingNotes?.length || 0;
3240
- const state = candidate?.endedAt ? `closed ${agoLabel(now - candidate.endedAt)}` : `not running (last seen ${agoLabel(now - Number(candidate?.lastSeen || now))})`;
3467
+ // "was closed 2m ago" / "is not running (last seen 2m ago)": the verb belongs to each state (1.91.0 read "is closed 2m ago")
3468
+ const state = candidate?.endedAt ? `was closed ${agoLabel(now - candidate.endedAt)}` : `is not running (last seen ${agoLabel(now - Number(candidate?.lastSeen || now))})`;
3241
3469
  const days = candidate?.expiresAt ? Math.max(1, Math.round((candidate.expiresAt - now) / 86_400_000)) : 7;
3242
3470
  return [
3243
- `${who} is ${state}, and ${notes === 1 ? 'a note' : `${notes} notes`} from ${senderLabel} ${notes === 1 ? 'is' : 'are'} waiting for it.`,
3471
+ `${who} ${state}, and ${notes === 1 ? 'a note' : `${notes} notes`} from ${senderLabel} ${notes === 1 ? 'is' : 'are'} waiting for it.`,
3244
3472
  `Reopen it now in a new terminal${candidate?.cwd ? ` (in ${candidate.cwd})` : ''} so it gets ${notes === 1 ? 'the note' : 'them'}?${start ? ' It starts on the note right away.' : ''}`,
3245
3473
  `If it waits, the note stays queued for ${days} more day${days === 1 ? '' : 's'} and is delivered when that session is next used.`,
3246
3474
  ].join('\n');
@@ -86,6 +86,8 @@ const CODEX_GLOBAL_BODY = [
86
86
  'To reach another agent session on this project, call `brain_message` with its session id (from',
87
87
  '`brain_sync` or `brain_doctor`); a session that has closed receives the note when it next starts.',
88
88
  'Never ask the human to carry a message between agents — send it and say it was sent or queued.',
89
+ 'Stopping before the work is done (limit, end of day, blocked)? Call `brain_handoff` {action:"write"} with what is done, what is next, files and how to verify;',
90
+ 'the next session of any agent sees it at its first `brain_sync` and takes it with {action:"take"}. Asked to continue what another session left? Take it there first.',
89
91
  'Capture durable decisions and milestones with `brain_note`. Never hand-edit `brain.klypix`.',
90
92
  'If the project has no `brain.klypix`, ignore this section.',
91
93
  CODEX_GLOBAL_END,
@@ -503,6 +505,18 @@ otherwise ask the human to relay or paste something to another agent session, se
503
505
  \`brain_message\` refuses. Notes are ephemeral, NOT brain cards — durable decisions still go
504
506
  through \`brain_note\`.
505
507
 
508
+ **Stopping before the work is done?** (the human asks for a handoff, your context or usage limit
509
+ is near, the day ends, you are blocked, you are switching tool) Call \`brain_handoff\`
510
+ {action:"write"} with the task, what is done, what is NEXT in order, the files and how to verify.
511
+ It is a 🤝 card in this brain the human can see and correct, and the next session on this project —
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.
519
+
506
520
  **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.`;
507
521
 
508
522
  // Content fingerprint of the canonical instructions — stamped into the fence so a