klypix-mcp 1.99.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.
@@ -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
 
@@ -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,
@@ -1408,10 +1410,28 @@ server.registerTool('brain_sync', {
1408
1410
  try {
1409
1411
  const h = await opBrainHandoff({ vault: mcpPresence.vault, canvas: report.structured.brain, action: 'list', from: { sessionId: extra?.klypixRequestIdentity?.sessionId || mcpPresence.id } });
1410
1412
  const cards = h?.structured?.handoffs || [];
1411
- const lane = h?.structured?.unfinished || [];
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)))));
1412
1423
  if (!h?.isError && (cards.length || lane.length)) {
1413
- handoffText = String(h.blocks?.[0]?.text || '').replace(/^_brain:[^\n]*_\n\n/, '');
1414
- handoffData = { cards: cards.length, unfinished: lane.length, 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 })), sessions: lane.map((r) => ({ id: r.id, client: r.client, intent: r.intent, status: r.status, lastSeen: r.lastSeen, files: r.files, commits: r.commits, handoffCardId: r.handoffCardId, takenBy: r.takenBy })) };
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
+ };
1415
1435
  }
1416
1436
  } catch { /* never fails a sync */ }
1417
1437
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.99.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/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/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",
@@ -2903,6 +2903,18 @@ export function listKnownSessions({ brainPath, home, now = Date.now() } = {}) {
2903
2903
  // first, with its last words, commits and files, and whether another session
2904
2904
  // already took it over. Nothing here starts, wakes or routes a session.
2905
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));
2906
2918
  export function clientWord(client) {
2907
2919
  const key = String(client || '').toLowerCase();
2908
2920
  if (key === 'claude-code' || key === 'claude') return 'Claude Code';
@@ -2937,7 +2949,9 @@ export function listUnfinishedWork({ brainPath, home, now = Date.now(), selfId =
2937
2949
  const quietMs = quietFor(row, now);
2938
2950
  return { entry, row, work, completed, done, taker, quietMs };
2939
2951
  })
2940
- .filter(({ completed, done, quietMs }) => !completed && !done && (includeWorking || quietMs >= UNFINISHED_QUIET_MS))
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)))
2941
2955
  .map(({ entry, row, work, taker, quietMs }) => ({
2942
2956
  id: entry.id,
2943
2957
  client: entry.client,
@@ -2955,6 +2969,13 @@ export function listUnfinishedWork({ brainPath, home, now = Date.now(), selfId =
2955
2969
  ? (sessionStatusLabel(row, now) || 'live')
2956
2970
  : (entry.endedAt ? 'closed' : `not seen for ${agoLabel(now - entry.lastSeen).replace(/ ago$/, '')}`),
2957
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.',
2958
2979
  lastPrompt: work.lastPrompt || '',
2959
2980
  lastWords: work.lastWords || '',
2960
2981
  lastWordsAt: work.lastWordsAt || null,
@@ -3009,14 +3030,14 @@ export function formatUnfinishedWork(records, { now = Date.now(), max = 5, cardT
3009
3030
  const list = Array.isArray(records) ? records : [];
3010
3031
  if (!list.length) return '';
3011
3032
  const flat = (value) => String(value || '').replace(/\s+/g, ' ').trim();
3012
- const lines = [`โธ๏ธ Unfinished work left by other sessions on this project (${list.length}, newest first) โ€” each stopped before its task was done and nobody is working on it now:`];
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:`];
3013
3034
  for (const r of list.slice(0, max)) {
3014
3035
  const who = `${clientWord(r.client)} ${String(r.id).slice(0, 8)}`;
3015
- const bits = [r.status, `last active ${agoLabel(r.ageMs)}`];
3036
+ const bits = [r.state === 'ended' ? 'ended' : `stopped (${r.status})`, `last active ${agoLabel(r.ageMs)}`];
3016
3037
  if (r.branch) bits.push(`branch ${r.branch}`);
3017
3038
  lines.push(`- ${who} ยท โ€œ${flat(r.intent).slice(0, 120)}โ€ ยท ${bits.join(' ยท ')}`);
3018
3039
  if (r.commits.length) lines.push(` commits: ${r.commits.map((c) => `${c.sha} ${c.subject}`).join('; ').slice(0, 320)}`);
3019
- if (r.files.length) lines.push(` files: ${r.files.slice(-8).join(', ')}`);
3040
+ if (r.files.length) lines.push(` files it touched (free โ€” not reserved): ${r.files.slice(-8).join(', ')}`);
3020
3041
  if (r.lastWords) lines.push(` its last words: โ€œ${flat(r.lastWords).slice(0, 420)}โ€`);
3021
3042
  if (r.handoffCardId) {
3022
3043
  const title = cardTitles && cardTitles.get ? cardTitles.get(r.handoffCardId) : null;
@@ -3026,7 +3047,7 @@ export function formatUnfinishedWork(records, { now = Date.now(), max = 5, cardT
3026
3047
  if (r.resumeCommand) lines.push(` its own transcript, should the human want it: ${r.resumeCommand}`);
3027
3048
  }
3028
3049
  if (list.length > max) lines.push(`- โ€ฆand ${list.length - max} more โ€” brain_handoff {action:"list"} shows all.`);
3029
- lines.push('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 it is finished, brain_handoff {action:"done", session:"<its id>"}. If it is not what the human asked for, leave it.');
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.');
3030
3051
  return lines.join('\n');
3031
3052
  }
3032
3053
 
@@ -3443,10 +3464,11 @@ export function reopenQuestion(candidate, { senderLabel = 'another session', sta
3443
3464
  const entry = candidate?.entry || {};
3444
3465
  const who = `${candidate?.hostLabel || 'The'} session ${String(entry.id || '').slice(0, 8)}${entry.intent ? ` ("${neutralizeMarkers(String(entry.intent).slice(0, 70))}")` : ''}`;
3445
3466
  const notes = candidate?.waitingNotes?.length || 0;
3446
- 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))})`;
3447
3469
  const days = candidate?.expiresAt ? Math.max(1, Math.round((candidate.expiresAt - now) / 86_400_000)) : 7;
3448
3470
  return [
3449
- `${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.`,
3450
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.' : ''}`,
3451
3473
  `If it waits, the note stays queued for ${days} more day${days === 1 ? '' : 's'} and is delivered when that session is next used.`,
3452
3474
  ].join('\n');
@@ -513,7 +513,9 @@ any agent โ€” sees it at its first \`brain_sync\` / session start and takes it w
513
513
  then {action:"done"} when finished. KLYPIX also records every session's last reply, files and
514
514
  commits automatically, so \`brain_sync\` may list **unfinished work** another session left without
515
515
  a card: if the human asks you to continue it, take it with \`brain_handoff\` {action:"take",
516
- session:"<id>"} first. Never ask the human to paste a summary into the next session.
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.
517
519
 
518
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.`;
519
521
 
@@ -58,6 +58,17 @@ const STATE = path.resolve(CWD, '.claude', 'brain-capture-state.json');
58
58
  // someone would ask it (a question word first, ? last);
59
59
  // retrieval enrichment only, never on the canvas.
60
60
  const MARKER = /๐Ÿง \s*BRAIN\s*(?:\[([^\]]+)\])?\s*([?!โœ“~+]?)\s*:\s*(.+)$/i;
61
+ // The marker may also be written AFTER the colon ("๐Ÿง  BRAIN [Drive]: ! text");
62
+ // that line used to become a plain decision carrying a literal "! ". Same rule
63
+ // as liftLeadingMarker in klypix-format.mjs (kept local: the hook loads the
64
+ // format module lazily).
65
+ const LEADING_MARKER = /^([?!โœ“~+])\s+(?=\S)/u;
66
+ function markerParts(m) {
67
+ const body = m[3].trim();
68
+ if (m[2]) return { type: m[2], body };
69
+ const lead = LEADING_MARKER.exec(body);
70
+ return lead ? { type: lead[1], body: body.slice(lead[0].length) } : { type: '', body };
71
+ }
61
72
  const sha = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
62
73
  // Numeric semver compare (major.minor.patch; pre-release tags ignored). <0 if
63
74
  // a<b, 0 equal, >0 if a>b. Shared by the version-currency footer below.
@@ -1456,6 +1467,10 @@ function releaseLeaseFooterLine(list, now) {
1456
1467
  // left off"). Read-only. Quoted text is another session's record, never the
1457
1468
  // human's instructions; markers in it were neutralized at store time.
1458
1469
  const UNFINISHED_QUIET_MS = 10 * 60 * 1000;
1470
+ // 1.99.1 twin of agent-presence UNFINISHED_WINDOW_MS / hasWorkEvidence: only a
1471
+ // session that stopped in the last 24h AND left a 1.99+ work record is listed.
1472
+ const UNFINISHED_WINDOW_MS = 24 * 60 * 60 * 1000;
1473
+ const hasWorkEvidence = (w) => Boolean(w && (w.lastWords || (Array.isArray(w.commits) && w.commits.length) || w.handoffCardId));
1459
1474
  const CONTINUE_RE = /\b(continue|resume|pick (it |this |that )?up|carry on|keep going|where (were|did) we|what('s| is| was) (next|left|remaining|pending)|unfinished|left off|hand[- ]?off|handed (over|off)|take over|finish (what|the work|it|this))\b/i;
1460
1475
  const HANDOFF_ASK_RE = /\b(hand[- ]?off|hand (it|this|the work) (over|off)|before (we|i|you) (hit|reach|approach|run out of) (the )?(limit|context|window|quota)|(context|usage|token) (limit|window|quota)|out of context|switch(ing)? to (codex|another (agent|session|tool)))\b/i;
1461
1476
  function unfinishedFooter(sid, { max = 4 } = {}) {
@@ -1474,18 +1489,19 @@ function unfinishedFooter(sid, { max = 4 } = {}) {
1474
1489
  const intentAt = Number(e.intentAt) || Number(e.firstSeen) || 0;
1475
1490
  return { e, row, w, completed: Number(e.completedAt) > 0 && Number(e.completedAt) >= intentAt, done: Number(w.doneAt) > 0 && Number(w.doneAt) >= intentAt, quietMs: quiet(row) };
1476
1491
  })
1477
- .filter(r => !r.completed && !r.done && r.quietMs >= UNFINISHED_QUIET_MS)
1492
+ .filter(r => !r.completed && !r.done && r.quietMs >= UNFINISHED_QUIET_MS
1493
+ && hasWorkEvidence(r.w) && now - Number(r.e.lastSeen || 0) < UNFINISHED_WINDOW_MS)
1478
1494
  .sort((a, b) => Number(b.e.lastSeen) - Number(a.e.lastSeen));
1479
1495
  if (!recs.length) return '';
1480
1496
  const flat = (v) => String(v || '').replace(/\s+/g, ' ').trim();
1481
- const lines = [`โธ๏ธ Unfinished work left by other sessions on this project (${recs.length}, newest first) โ€” each stopped before its task was done and nobody is working on it now:`];
1497
+ const lines = [`โธ๏ธ Work other sessions may have left unfinished (${recs.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:`];
1482
1498
  for (const { e, row, w } of recs.slice(0, max)) {
1483
1499
  const status = row ? (statusLabelOf(row, now) || 'live') : (e.endedAt ? 'closed' : `not seen for ${agoWord(now - e.lastSeen).replace(/ ago$/, '')}`);
1484
- const bits = [status, `last active ${agoWord(now - e.lastSeen)}`];
1500
+ const bits = [row ? `stopped (${status})` : 'ended', `last active ${agoWord(now - e.lastSeen)}`];
1485
1501
  if (e.branch) bits.push(`branch ${e.branch}`);
1486
1502
  lines.push(`- ${clientWordOf(e.client)} ${e.id.slice(0, 8)} ยท โ€œ${flat(e.intent).slice(0, 120)}โ€ ยท ${bits.join(' ยท ')}`);
1487
1503
  if (Array.isArray(w.commits) && w.commits.length) lines.push(` commits: ${w.commits.map(c => `${c.sha} ${c.subject}`).join('; ').slice(0, 320)}`);
1488
- if (Array.isArray(e.files) && e.files.length) lines.push(` files: ${e.files.slice(-8).join(', ')}`);
1504
+ if (Array.isArray(e.files) && e.files.length) lines.push(` files it touched (free โ€” not reserved): ${e.files.slice(-8).join(', ')}`);
1489
1505
  if (w.lastWords) lines.push(` its last words: โ€œ${flat(w.lastWords).slice(0, 420)}โ€`);
1490
1506
  if (w.handoffCardId) lines.push(` ๐Ÿค it left a handoff card in the brain: ${w.handoffCardId} (brain_handoff {action:"list"} prints it in full)`);
1491
1507
  if (w.takenBy) {
@@ -1495,7 +1511,7 @@ function unfinishedFooter(sid, { max = 4 } = {}) {
1495
1511
  if (!row && (e.client === 'claude-code' || e.client === 'codex') && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(e.id)) lines.push(` its own transcript, should the human want it: ${e.client === 'codex' ? `codex resume ${e.id}` : `claude --resume ${e.id}`}`);
1496
1512
  }
1497
1513
  if (recs.length > max) lines.push(`- โ€ฆand ${recs.length - max} more โ€” brain_handoff {action:"list"} shows all.`);
1498
- lines.push('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 it is finished, brain_handoff {action:"done", session:"<its id>"}. If it is not what the human asked for, leave it.');
1514
+ 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.');
1499
1515
  return '\n\n' + lines.join('\n');
1500
1516
  } catch { return ''; }
1501
1517
  }
@@ -2664,8 +2680,7 @@ function liveMilestoneFromTranscript(tp) {
2664
2680
  for (const raw of text.split('\n')) {
2665
2681
  const line = raw.trim();
2666
2682
  const m = MARKER.exec(line); if (!m) continue;
2667
- const type = m[2] || '';
2668
- const body0 = m[3].trim(); if (!body0) continue;
2683
+ const { type, body: body0 } = markerParts(m); if (!body0) continue;
2669
2684
  const isMilestone = type === '!' || type === '+' || /๐Ÿ|๐Ÿ› /.test(line);
2670
2685
  if (!isMilestone) continue;
2671
2686
  if (/<(open question|one-line decision|decision|area|placeholder|open|milestone)[^>]*>/i.test(body0) || /`[^`]*๐Ÿง /.test(raw)) continue; // example/doc guard
@@ -3676,9 +3691,10 @@ async function capture(lib) {
3676
3691
  continue;
3677
3692
  }
3678
3693
  const m = MARKER.exec(trimmed); if (!m) continue;
3679
- const area = (m[1] || '').trim(), type = m[2] || '';
3680
- let body = m[3].trim(); if (!body) continue;
3681
- const markerBody = body; // as written โ€” what the published hooks cut and keyed
3694
+ const area = (m[1] || '').trim();
3695
+ const { type, body: liftedBody } = markerParts(m);
3696
+ let body = liftedBody; if (!body) continue;
3697
+ const markerBody = m[3].trim(); // as written โ€” what the published hooks cut and keyed
3682
3698
  // Strip optional `closes:` / `ev:` / `verify:` / `q:` suffixes off the
3683
3699
  // body so they don't leak into the card text; they drive the
3684
3700
  // close-link, evidence, live-probe and enrichment below. Only a
@@ -3776,6 +3792,14 @@ async function capture(lib) {
3776
3792
  const additive = type !== 'โœ“' && type !== '~';
3777
3793
  const key = sha((type + '|' + area + '|' + (additive ? bodyWithCloses : body)).toLowerCase());
3778
3794
  const legacyKey = beforeUpgrade && additive && closes ? sha((type + '|' + area + '|' + body).toLowerCase()) : null;
3795
+ // A marker written after the colon was keyed by earlier hooks as a
3796
+ // plain decision whose body kept the glyph ("|Drive|! text"); a
3797
+ // line they already landed is seen, never captured a second time.
3798
+ if (!m[2] && type) {
3799
+ const pre = splitMarkerSuffixes(markerBody);
3800
+ const preKey = sha(('|' + area + '|' + (pre.bodyWithCloses || pre.body || markerBody)).toLowerCase());
3801
+ if (seen.has(preKey)) { hitKeys.add(preKey); seen.add(key); ledger.push({ action: 'skipped-seen', area, preview, olderHook: true }); continue; }
3802
+ }
3779
3803
  let repairStub = '';
3780
3804
  if (additive) {
3781
3805
  if (seen.has(key)) { hitKeys.add(key); legacyClaimed.add(key); ledger.push({ action: 'skipped-seen', area, preview }); continue; }
@@ -28,7 +28,7 @@ import {
28
28
  parseKlypix, buildKlypix, buildKlypixMap, appendToKlypix, structToMarkdown,
29
29
  brainInsights, insightsToMarkdown, insightsAreasToMarkdown, insightsStatusToMarkdown,
30
30
  areaStatusDigest, addBrainConnections, proposeStructuralConnections, atomicWrite,
31
- findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
31
+ findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput, liftLeadingMarker,
32
32
  selectGardenCandidates, applyGarden, detectContradictions,
33
33
  findDuplicatePartialNotes, collapseDuplicatePartialNotes,
34
34
  rankForQuestion, splitAskWords, questionContextToMarkdown, findLegacyShipCards,
@@ -1932,6 +1932,7 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
1932
1932
  if (!t.file) return err(`No brain found โ€” looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
1933
1933
  const file = t.file;
1934
1934
  if (!noteText || !String(noteText).trim()) return err('brain_note needs a non-empty text.');
1935
+ ({ marker, text: noteText } = liftLeadingMarker(marker, noteText));
1935
1936
  if (looksLikeUnfilledDraft(noteText)) return err('Complete or discard the draft rationale before capturing it; the brain cannot record an unfilled WHY THIS MATTERS placeholder.');
1936
1937
  if (!['', '?', '!', 'โœ“', '~', '+'].includes(marker)) return err(`Invalid marker "${marker}" โ€” use: (none)=decision ยท ?=open question ยท !=milestone ยท +=skill (reusable how-to) ยท โœ“=resolve a matching card ยท ~=update a matching card.`);
1937
1938
  // Guard cards (2026-08-24): a guard is authored on a '+' skill (or amended
@@ -2388,7 +2388,7 @@ export async function tidyBrain(buffer, opts = {}) {
2388
2388
  meta.set(c.id, { h: Math.max(40, Number(p.h) || 40), w: Math.max(40, Number(p.w) || G.CARD_W), keepSize: true, createdAt });
2389
2389
  continue;
2390
2390
  }
2391
- const wrapped = wrapText(String(c.text ?? ''));
2391
+ const wrapped = wrapText(repairRawMarker(String(c.text ?? '')));
2392
2392
  let createdAt = 0;
2393
2393
  try { const f = zip.file(ip); if (f) { const j = JSON.parse(await f.async('string')); createdAt = Number(j.createdAt) || 0; j.fontSize = G.FONT; j.content = wrapped; delete j.authoredInParent; zip.file(ip, JSON.stringify(j)); } } catch { /* leave as-is */ }
2394
2394
  meta.set(c.id, { h: measureCardH(wrapped), createdAt });
@@ -9404,7 +9404,54 @@ export async function ensureGuardSidecar(brainPath, { home = os.homedir(), build
9404
9404
  } catch { return { guards: null, stale: true }; }
9405
9405
  }
9406
9406
 
9407
+ // The marker written AFTER the colon โ€” "๐Ÿง  BRAIN [Drive]: ! Drive storage โ€ฆ",
9408
+ // or brain_note text "! โ€ฆ" with no marker โ€” was stored as a plain decision
9409
+ // carrying a literal "! " (166 such cards on the KLYPIX brain by 2026-10-12, so
9410
+ // milestones read as decisions and questions never counted as open). A bare
9411
+ // marker glyph followed by a space at the very start of the text IS the
9412
+ // marker; an explicit marker always wins.
9413
+ const LEADING_MARKER_RE = /^([?!โœ“~+])\s+(?=\S)/u;
9414
+ export function liftLeadingMarker(marker, text) {
9415
+ const t = String(text ?? '');
9416
+ if (marker) return { marker, text: t };
9417
+ const lead = t.trimStart();
9418
+ const m = LEADING_MARKER_RE.exec(lead);
9419
+ return m ? { marker: m[1], text: lead.slice(m[0].length) } : { marker: '', text: t };
9420
+ }
9421
+
9422
+ // Cards captured that way BEFORE the fix read "Area: ! text". Restore the glyph
9423
+ // the capture meant for the kinds whose intent is unambiguous on the card
9424
+ // itself โ€” ! โ†’ ๐Ÿ, + โ†’ ๐Ÿ› ๏ธ, ? โ†’ โ“. A โœ“ or ~ asked for an action on ANOTHER card
9425
+ // at the time; replaying a months-old resolve or amend is guesswork, so those
9426
+ // stay as written. tidyBrain runs this on every capture, so a brain heals on
9427
+ // its next write. The card is stored hard-wrapped and the glyph is longer than
9428
+ // the marker, so the first paragraph is rejoined (wrapText's own soft-break
9429
+ // rule, as in parseVerifySuffix) and wrapped again rather than left with a
9430
+ // one-word orphan line. Returns the text unchanged when there is nothing to do.
9431
+ const RAW_MARKER_CARD_RE = /^([^:\n]{1,30}):\s+([!+?])\s+(?=\S)/u;
9432
+ const RAW_MARKER_GLYPH = { '!': '๐Ÿ ', '+': '๐Ÿ› ๏ธ ', '?': 'โ“ ' };
9433
+ export function repairRawMarker(text) {
9434
+ const raw = String(text ?? '');
9435
+ const m = RAW_MARKER_CARD_RE.exec(raw);
9436
+ if (!m) return raw;
9437
+ const cpl = brainCPL();
9438
+ const lines = raw.split('\n');
9439
+ let lead = lines[0], prev = lines[0], i = 1;
9440
+ for (; i < lines.length; i++) {
9441
+ const line = lines[i], t = line.trim();
9442
+ if (!t || /^\s*(?:#[\p{L}\p{N}_-]+\s*)+$/u.test(line) || /^(?:โœ”|โœ…|โ†ฉ|โคต|\(re-affirmed|\(~ amended)/u.test(t)) break;
9443
+ const firstWord = (/^\S*/.exec(t) || [''])[0];
9444
+ const midWord = prev.length === cpl && !/\s/.test(prev.trim());
9445
+ if (!(prev.length <= cpl && line.length <= cpl && (midWord || prev.length + 1 + firstWord.length > cpl))) break;
9446
+ lead = `${lead.trimEnd()}${midWord ? '' : ' '}${t}`;
9447
+ prev = line;
9448
+ }
9449
+ lead = `${m[1]}: ${RAW_MARKER_GLYPH[m[2]]}${lead.slice(m[0].length)}`;
9450
+ return wrapText([lead, ...lines.slice(i)].join('\n'), cpl);
9451
+ }
9452
+
9407
9453
  export function noteToCaptureInput({ text = '', area = '', marker = '', closes = '', evidence = null, verify = null, guard = null, createdVia = 'mcp' } = {}) {
9454
+ ({ marker, text } = liftLeadingMarker(marker, text));
9408
9455
  const body = String(text).trim();
9409
9456
  if (!body) return { cards: [], resolutions: [], updates: [] };
9410
9457
  const a = String(area || '').trim();
@@ -69,7 +69,7 @@ export const KLYPIX_MCP_INSTRUCTIONS = [
69
69
  'Use its active-task, message, and file-overlap report to coordinate concurrent work.',
70
70
  'A coordination note is not consumed merely because it was offered: after a later independent KLYPIX action acknowledges the offer, call brain_message_receipt with its exact message id and offer token only when you actually incorporated it into your work. If you do not, a further independent action auto-consumes the note without a receipt โ€” which records activity, not uptake โ€” so the sender is told it was auto-consumed rather than acted on. Sending an explicit receipt is what makes your uptake visible to them.',
71
71
  'Never make the human carry a message between agents: if you would otherwise ask the human to relay or paste something to another agent session on this project โ€” live, idle, or recently closed โ€” send it with brain_message addressed to that session id instead, and tell the human it was sent or queued; a closed session receives a directed note the moment it next starts. If the human wants a queued note handled sooner, offer brain_reopen: KLYPIX asks the human with a Reopen / Not now prompt and reopens that session only on yes. Hand the human the text only if brain_message refuses.',
72
- 'When you stop before the work is done โ€” the human asks for a handoff, your context or usage limit is near, the day ends, you are blocked or switching tool โ€” call brain_handoff {action:"write"} with the task, what is done, what is NEXT in order, the files and how to verify: it is a card in the brain the human can see, and the next session on this project (any agent) sees it at its first brain_sync and takes it with {action:"take"}; never ask the human to paste a summary into the next session. KLYPIX also records every session\'s last reply, files and commits automatically, so brain_sync may list "unfinished work" another session left: if the human asks you to continue it, take it with brain_handoff {action:"take", session} and finish with {action:"done"}.',
72
+ 'When you stop before the work is done โ€” the human asks for a handoff, your context or usage limit is near, the day ends, you are blocked or switching tool โ€” call brain_handoff {action:"write"} with the task, what is done, what is NEXT in order, the files and how to verify: it is a card in the brain the human can see, and the next session on this project (any agent) sees it at its first brain_sync and takes it with {action:"take"}; never ask the human to paste a summary into the next session. KLYPIX also records every session\'s last reply, files and commits automatically, so brain_sync may list "unfinished work" another session left: if the human asks you to continue it, take it with brain_handoff {action:"take", session} and finish with {action:"done"}; that list is history, not a lock โ€” a stopped session holds no claim on its files, and a "do not touch" note it left no longer applies.',
73
73
  'Call brain_sync again when your file scope materially changes, and with phase "complete" before your final response.',
74
74
  'When a task publishes a quantified or otherwise machine-checkable claim, include its validated result manifest in the completion sync; conflicting or incomparable peer results retain the task scope until reconciled.',
75
75
  'A session preparing a RELEASE of the project should declare it by adding releaseIntent {version, ref} to brain_sync: the first declarer takes an exclusive lease every peer sees, and a second declarer gets a hard conflict naming the holder.',