klypix-mcp 1.99.0 → 1.101.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
@@ -597,16 +597,29 @@ and the person opens Codex (or a fresh Claude Code) and says *continue*. Until 1
597
597
  handoff was whatever the person pasted. Now there are two layers, and the person does not have
598
598
  to ask for either.
599
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.
600
+ **Automatic — whichever agent stopped.** Each session's last state is recorded on the lane that
601
+ already remembers sessions for two weeks, by whatever that host offers:
602
+
603
+ | Host | Recorded automatically |
604
+ |---|---|
605
+ | Claude Code (hooks) | last prompt, the tail of the last reply, files edited, its *own* commits |
606
+ | Codex (hooks) | last prompt, the tail of the last reply, files edited, its *own* commits |
607
+ | Any MCP host — Cursor, VS Code, Windsurf, Gemini CLI, … | the task and files it declared with `brain_sync` |
608
+
609
+ *Own* commits are the shas that session's `git commit` printed, never a branch range, because
610
+ twenty sessions can share one checkout. A session is listed when it stopped in the last 24 hours
611
+ without completing its task — quiet for ten minutes, or closed — and left a record. A session that
612
+ recorded nothing is never listed, so finished chats do not pile up.
613
+
614
+ The next session on the project, in any agent, sees **⏸️ Work other sessions may have left
615
+ unfinished** at its first `brain_sync` when its task is to continue something ("continue what Codex
616
+ was doing on the invoice" puts that session first) or touches a file one of them touched. It sees the
617
+ same list at session start. Each record carries its status, files, commits, last words and resume
618
+ command. It says, in the text and in the JSON (`state`, `working:false`, `holdsFiles:false`), that
619
+ the session is not running, holds no claim on any file, and that a "do not touch" note it left no
620
+ longer applies: the list is history, not a lock. The agent takes a record over with
621
+ `brain_handoff {action:"take", session:"<id>"}` so peers see it is taken, then marks it `done`. This
622
+ layer is machine-local, like the rest of the lane.
610
623
 
611
624
  **Deliberate.** When a session knows it is stopping — the person asks for a handoff, the context or
612
625
  usage limit is near, the day ends, it is blocked, it is switching tool — it calls
@@ -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,43 @@ 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 overlaps = (r) => Array.isArray(r.files) && r.files.some((p) => mine.has(normPath(p)));
1422
+ // "continue what codex was doing on the invoice": rank by what the
1423
+ // request names — the agent, words of the task, last words, files,
1424
+ // commit subjects — then by recency, so the one meant leads the list.
1425
+ const words = new Set(String(intent || '').toLowerCase().match(/[\p{L}\p{N}_-]{3,}/gu) || []);
1426
+ for (const w of ['continue', 'resume', 'what', 'was', 'were', 'doing', 'the', 'and', 'left', 'off', 'where', 'session', 'other', 'work', 'pick', 'carry', 'keep', 'going', 'finish', 'take', 'over', 'handoff', 'hand']) words.delete(w);
1427
+ const relevance = (r) => {
1428
+ const hay = [r.client === 'claude-code' ? 'claude' : r.client, r.intent, r.lastPrompt, r.lastWords, ...(r.files || []), ...(r.commits || []).map((c) => c.subject)].join(' ').toLowerCase();
1429
+ let n = 0;
1430
+ for (const w of words) if (hay.includes(w)) n++;
1431
+ return n + (overlaps(r) ? 3 : 0);
1432
+ };
1433
+ const lane = (h?.structured?.unfinished || [])
1434
+ .filter((r) => wantsContinue || overlaps(r))
1435
+ .map((r) => ({ r, score: relevance(r) }))
1436
+ .sort((a, b) => b.score - a.score || Number(b.r.lastSeen || 0) - Number(a.r.lastSeen || 0))
1437
+ .map(({ r }) => r);
1412
1438
  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 })) };
1439
+ const now = Date.now();
1440
+ handoffText = [
1441
+ formatHandoffCards(cards, { now, max: 5 }),
1442
+ formatUnfinishedWork(lane, { now, max: 4, ranked: wantsContinue && words.size > 0 }),
1443
+ ].filter(Boolean).join('\n\n');
1444
+ handoffData = {
1445
+ cards: cards.length,
1446
+ unfinished: lane.length,
1447
+ handoffs: cards.map((c) => ({ id: c.id, title: c.title, from: c.from, at: c.at, reason: c.reason, state: c.state, next: c.next, files: c.files })),
1448
+ sessions: lane.slice(0, 4).map((r) => ({ id: r.id, client: r.client, intent: r.intent, state: r.state, working: false, holdsFiles: false, note: r.note, status: r.status, lastSeen: r.lastSeen, files: r.files, commits: r.commits, handoffCardId: r.handoffCardId, takenBy: r.takenBy })),
1449
+ };
1415
1450
  }
1416
1451
  } catch { /* never fails a sync */ }
1417
1452
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.99.0",
3
+ "version": "1.101.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",
@@ -535,12 +535,28 @@ export function normalizeWork(raw) {
535
535
  out.takenBy = { id: str(raw.takenBy.id, 160), client: str(raw.takenBy.client, 40) || 'unknown', at: ms(raw.takenBy.at) };
536
536
  }
537
537
  if (ms(raw.doneAt)) { out.doneAt = ms(raw.doneAt); if (raw.doneBy) out.doneBy = str(raw.doneBy, 160); }
538
+ // 1.99.1 (universal): a task DECLARED through brain_sync — any MCP host,
539
+ // hooks or not — stamps when it was declared, so a Cursor / Gemini / VS Code
540
+ // session that stopped mid-task is still found by the next session.
541
+ if (ms(raw.declaredAt)) out.declaredAt = ms(raw.declaredAt);
538
542
  return Object.keys(out).length ? out : null;
539
543
  }
540
544
  export function mergeWork(previous, patch) {
541
545
  if (patch === undefined) return normalizeWork(previous);
542
546
  if (patch === null) return null;
543
- return normalizeWork({ ...(normalizeWork(previous) || {}), ...(patch && typeof patch === 'object' ? patch : {}) });
547
+ const prev = normalizeWork(previous) || {};
548
+ const next = patch && typeof patch === 'object' ? { ...patch } : {};
549
+ // `addCommits` APPENDS (Codex records one commit per tool result; the
550
+ // Claude Stop hook replaces the full list with `commits`), newest last,
551
+ // de-duplicated by sha, capped like `commits`.
552
+ if (Array.isArray(next.addCommits)) {
553
+ const seen = new Set();
554
+ next.commits = [...(prev.commits || []), ...next.addCommits]
555
+ .filter((c) => c && c.sha && !seen.has(String(c.sha).slice(0, 7)) && seen.add(String(c.sha).slice(0, 7)))
556
+ .slice(-WORK_COMMITS_MAX);
557
+ delete next.addCommits;
558
+ }
559
+ return normalizeWork({ ...prev, ...next });
544
560
  }
545
561
  const pruneDirectory = (rows, now) => (Array.isArray(rows) ? rows : [])
546
562
  .filter((row) => row && recipientKey(row.id) && now - Number(row.lastSeen || 0) < DIRECTORY_FRESH_MS)
@@ -1186,7 +1202,14 @@ export function upsertSession({
1186
1202
  ...(statusDigestHash !== undefined ? { statusDigestHash: String(statusDigestHash || '').slice(0, 16) } : {}),
1187
1203
  // ADDITIVE unfinished-work state: merged only when this touch carries a
1188
1204
  // patch; the `...previous` spread keeps it verbatim otherwise.
1189
- ...(work !== undefined ? (mergeWork(previous.work, work) ? { work: mergeWork(previous.work, work) } : {}) : {}),
1205
+ ...(() => {
1206
+ // A brain_sync-DECLARED task (any MCP host) is itself a work record:
1207
+ // stamp declaredAt beside whatever patch this touch carries.
1208
+ const declared = intentSource === 'declared' && intentChanged && nextIntent ? { declaredAt: now } : null;
1209
+ if (work === undefined && !declared) return {};
1210
+ const merged = mergeWork(previous.work, { ...(work && typeof work === 'object' ? work : {}), ...(declared || {}) });
1211
+ return merged ? { work: merged } : {};
1212
+ })(),
1190
1213
  // ADDITIVE host status: stamped only when this touch carries one, kept
1191
1214
  // verbatim otherwise, so a heartbeat never turns "working" into unknown.
1192
1215
  ...(nextHostStatus
@@ -2903,6 +2926,21 @@ export function listKnownSessions({ brainPath, home, now = Date.now() } = {}) {
2903
2926
  // first, with its last words, commits and files, and whether another session
2904
2927
  // already took it over. Nothing here starts, wakes or routes a session.
2905
2928
  export const UNFINISHED_QUIET_MS = 10 * 60 * 1000;
2929
+ // 1.99.1 — signal, not history (field + bench, 2026-10-12). 1.99.0 listed every
2930
+ // remembered session with an uncompleted intent: 34 on the founder's PC, most
2931
+ // of them finished chats whose last prompt was "i can close?" (a hook-only
2932
+ // Claude session never records completion), and the coordination bench (S5)
2933
+ // showed an ended session's intent on a file read to Claude AND Codex as a live
2934
+ // claim (0/5). So a record is listed only when (a) the session stopped within
2935
+ // UNFINISHED_WINDOW_MS, and (b) it carries a 1.99+ work record — a reply tail,
2936
+ // own commits or a handoff card — so legacy and empty sessions never appear;
2937
+ // and every record says, in text and JSON, that it is not running and holds
2938
+ // no claim on any file.
2939
+ export const UNFINISHED_WINDOW_MS = 24 * 60 * 60 * 1000;
2940
+ // Evidence of real work, from ANY host: a reply tail (Claude and Codex Stop
2941
+ // hooks), own commits (Claude transcript, Codex tool results), a handoff card
2942
+ // (brain_handoff), or a task declared through brain_sync (every MCP host).
2943
+ export const hasWorkEvidence = (work) => Boolean(work && (work.lastWords || (Array.isArray(work.commits) && work.commits.length) || work.handoffCardId || work.declaredAt));
2906
2944
  export function clientWord(client) {
2907
2945
  const key = String(client || '').toLowerCase();
2908
2946
  if (key === 'claude-code' || key === 'claude') return 'Claude Code';
@@ -2937,7 +2975,9 @@ export function listUnfinishedWork({ brainPath, home, now = Date.now(), selfId =
2937
2975
  const quietMs = quietFor(row, now);
2938
2976
  return { entry, row, work, completed, done, taker, quietMs };
2939
2977
  })
2940
- .filter(({ completed, done, quietMs }) => !completed && !done && (includeWorking || quietMs >= UNFINISHED_QUIET_MS))
2978
+ .filter(({ entry, work, completed, done, quietMs }) => !completed && !done
2979
+ && (includeWorking || quietMs >= UNFINISHED_QUIET_MS)
2980
+ && (includeWorking || (hasWorkEvidence(work) && now - Number(entry.lastSeen || 0) < UNFINISHED_WINDOW_MS)))
2941
2981
  .map(({ entry, row, work, taker, quietMs }) => ({
2942
2982
  id: entry.id,
2943
2983
  client: entry.client,
@@ -2955,6 +2995,13 @@ export function listUnfinishedWork({ brainPath, home, now = Date.now(), selfId =
2955
2995
  ? (sessionStatusLabel(row, now) || 'live')
2956
2996
  : (entry.endedAt ? 'closed' : `not seen for ${agoLabel(now - entry.lastSeen).replace(/ ago$/, '')}`),
2957
2997
  resumeCommand: row ? '' : resumeCommandFor(entry.client, entry.id),
2998
+ // Machine-readable state — hosts that hand the model the JSON (Claude
2999
+ // Code reads structuredContent) must see what the text says: stopped,
3000
+ // not working, no file reserved.
3001
+ state: row ? 'stopped' : 'ended',
3002
+ working: false,
3003
+ holdsFiles: false,
3004
+ note: 'This session is not running. It holds no claim on these files; a "do not touch" note it left no longer applies. Continue its work only if the human asks.',
2958
3005
  lastPrompt: work.lastPrompt || '',
2959
3006
  lastWords: work.lastWords || '',
2960
3007
  lastWordsAt: work.lastWordsAt || null,
@@ -3005,18 +3052,18 @@ export function stampSessionWork({ brainPath, sessionId, patch, home, now = Date
3005
3052
  // one record per unfinished session, newest first, then how to take it over.
3006
3053
  // Quoted text is another session's record and is labelled as such — never as
3007
3054
  // the human's instructions; markers inside it were neutralized at store time.
3008
- export function formatUnfinishedWork(records, { now = Date.now(), max = 5, cardTitles = null } = {}) {
3055
+ export function formatUnfinishedWork(records, { now = Date.now(), max = 5, cardTitles = null, ranked = false } = {}) {
3009
3056
  const list = Array.isArray(records) ? records : [];
3010
3057
  if (!list.length) return '';
3011
3058
  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:`];
3059
+ const lines = [`⏸️ Work other sessions may have left unfinished (${list.length}, stopped in the last 24h, ${ranked ? 'most relevant first' : 'newest first'}). None of them is running: they hold NO claim on any file, and a "do not touch" or WIP note one of them left no longer applies. Use this only if the human asks you to continue their work:`];
3013
3060
  for (const r of list.slice(0, max)) {
3014
3061
  const who = `${clientWord(r.client)} ${String(r.id).slice(0, 8)}`;
3015
- const bits = [r.status, `last active ${agoLabel(r.ageMs)}`];
3062
+ const bits = [r.state === 'ended' ? 'ended' : `stopped (${r.status})`, `last active ${agoLabel(r.ageMs)}`];
3016
3063
  if (r.branch) bits.push(`branch ${r.branch}`);
3017
3064
  lines.push(`- ${who} · “${flat(r.intent).slice(0, 120)}” · ${bits.join(' · ')}`);
3018
3065
  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(', ')}`);
3066
+ if (r.files.length) lines.push(` files it touched (free — not reserved): ${r.files.slice(-8).join(', ')}`);
3020
3067
  if (r.lastWords) lines.push(` its last words: “${flat(r.lastWords).slice(0, 420)}”`);
3021
3068
  if (r.handoffCardId) {
3022
3069
  const title = cardTitles && cardTitles.get ? cardTitles.get(r.handoffCardId) : null;
@@ -3026,7 +3073,7 @@ export function formatUnfinishedWork(records, { now = Date.now(), max = 5, cardT
3026
3073
  if (r.resumeCommand) lines.push(` its own transcript, should the human want it: ${r.resumeCommand}`);
3027
3074
  }
3028
3075
  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.');
3076
+ 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
3077
  return lines.join('\n');
3031
3078
  }
3032
3079
 
@@ -3443,10 +3490,11 @@ export function reopenQuestion(candidate, { senderLabel = 'another session', sta
3443
3490
  const entry = candidate?.entry || {};
3444
3491
  const who = `${candidate?.hostLabel || 'The'} session ${String(entry.id || '').slice(0, 8)}${entry.intent ? ` ("${neutralizeMarkers(String(entry.intent).slice(0, 70))}")` : ''}`;
3445
3492
  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))})`;
3493
+ // "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")
3494
+ const state = candidate?.endedAt ? `was closed ${agoLabel(now - candidate.endedAt)}` : `is not running (last seen ${agoLabel(now - Number(candidate?.lastSeen || now))})`;
3447
3495
  const days = candidate?.expiresAt ? Math.max(1, Math.round((candidate.expiresAt - now) / 86_400_000)) : 7;
3448
3496
  return [
3449
- `${who} is ${state}, and ${notes === 1 ? 'a note' : `${notes} notes`} from ${senderLabel} ${notes === 1 ? 'is' : 'are'} waiting for it.`,
3497
+ `${who} ${state}, and ${notes === 1 ? 'a note' : `${notes} notes`} from ${senderLabel} ${notes === 1 ? 'is' : 'are'} waiting for it.`,
3450
3498
  `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
3499
  `If it waits, the note stays queued for ${days} more day${days === 1 ? '' : 's'} and is delivered when that session is next used.`,
3452
3500
  ].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
 
@@ -371,6 +371,8 @@ async function statusDigestContext({ brainPath, sessionId, prompt, sessions, cwd
371
371
  // ⏸️ Unfinished work other sessions left — the engine's block (agent-presence
372
372
  // listUnfinishedWork + formatUnfinishedWork), read-only, never throws.
373
373
  const CONTINUE_PROMPT_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;
374
+ const HANDOFF_ASK_PROMPT_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|rate) (limit|window|quota)|out of context|switch(ing)? to (claude|another (agent|session|tool)))\b/i;
375
+ const CODEX_HANDOFF_HINT = '🤝 KLYPIX: to hand this session\'s work to the next one (any agent — Claude Code or Codex), call brain_handoff with the task, what is done, what is next, the files and how to verify; the next session on this project sees it at its first brain_sync and takes it with {action:"take"}. Your last reply and commits are recorded automatically at each turn end regardless — but a deliberate handoff says what you know. Never make the human paste a summary into the next session.';
374
376
  function unfinishedWorkBlock(brainPath, sessionId) {
375
377
  try {
376
378
  return formatUnfinishedWork(listUnfinishedWork({ brainPath, selfId: sessionId }));
@@ -390,8 +392,31 @@ function codexWorkPatch({ event, humanTurn, prompt, input }) {
390
392
  const text = typeof words === 'string' ? words.trim() : '';
391
393
  if (text) { patch.lastWords = text.length > 700 ? '…' + text.slice(-699) : text; patch.lastWordsAt = Date.now(); }
392
394
  }
395
+ // 1.99.1 (universal): this session's OWN commits, from the output git
396
+ // printed for a commit it ran ("[branch abc1234] subject") — never a branch
397
+ // range, which would credit a peer's commits in a shared checkout.
398
+ if (event === 'PostToolUse') {
399
+ const commits = commitsFromToolResult(input);
400
+ if (commits.length) patch.addCommits = commits;
401
+ }
393
402
  return Object.keys(patch).length ? patch : null;
394
403
  }
404
+ function toolOutputText(input) {
405
+ const r = input.tool_response ?? input.toolResponse ?? input.tool_output ?? input.toolOutput;
406
+ if (typeof r === 'string') return r;
407
+ if (!r || typeof r !== 'object') return '';
408
+ const parts = [r.output, r.stdout, r.aggregated_output, r.formatted_output, r.text, r.content];
409
+ return parts.map((p) => (typeof p === 'string' ? p : Array.isArray(p) ? p.map((x) => (typeof x === 'string' ? x : x?.text || '')).join('\n') : '')).filter(Boolean).join('\n');
410
+ }
411
+ export function commitsFromToolResult(input) {
412
+ const ti = input.tool_input || input.toolInput || {};
413
+ const cmd = typeof ti === 'string' ? ti : [ti.command, ti.cmd, Array.isArray(ti.command) ? ti.command.join(' ') : ''].filter((v) => typeof v === 'string').join(' ');
414
+ if (!/\bgit\b[^\n]*\bcommit\b/.test(cmd)) return [];
415
+ const out = toolOutputText(input);
416
+ const commits = [];
417
+ for (const m of out.matchAll(/^\[[^\]\n]{1,80}?\s([0-9a-f]{7,40})\]\s+(.+)$/gm)) commits.push({ sha: m[1].slice(0, 12), subject: m[2].trim().slice(0, 120) });
418
+ return commits.slice(0, 6);
419
+ }
395
420
 
396
421
  async function compactTaskContext(projectDir, prompt, files) {
397
422
  if (!prompt) return '';
@@ -533,6 +558,8 @@ async function main() {
533
558
  stampReceivedMessages(messages, Date.now(), undefined, sessionId),
534
559
  // Only on a continue-shaped human prompt — SessionStart printed it once.
535
560
  ...(prompt && CONTINUE_PROMPT_RE.test(prompt) ? [unfinishedWorkBlock(brainPath, sessionId)] : []),
561
+ // The human asked for a handoff or named the limit (Codex has one too).
562
+ ...(prompt && HANDOFF_ASK_PROMPT_RE.test(prompt) ? [CODEX_HANDOFF_HINT] : []),
536
563
  ], event);
537
564
  return;
538
565
  }
@@ -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.
@@ -1075,6 +1086,7 @@ function workNorm(raw) {
1075
1086
  if (raw.handoffCardId) out.handoffCardId = str(raw.handoffCardId, 64);
1076
1087
  if (raw.takenBy && typeof raw.takenBy === 'object' && raw.takenBy.id) out.takenBy = { id: str(raw.takenBy.id, 160), client: str(raw.takenBy.client, 40) || 'unknown', at: ms(raw.takenBy.at) };
1077
1088
  if (ms(raw.doneAt)) { out.doneAt = ms(raw.doneAt); if (raw.doneBy) out.doneBy = str(raw.doneBy, 160); }
1089
+ if (ms(raw.declaredAt)) out.declaredAt = ms(raw.declaredAt); // 1.99.1 twin: a brain_sync-declared task (any MCP host)
1078
1090
  return Object.keys(out).length ? out : null;
1079
1091
  }
1080
1092
  const workMerge = (prev, patch) => (patch === undefined ? workNorm(prev) : patch === null ? null : workNorm({ ...(workNorm(prev) || {}), ...(patch && typeof patch === 'object' ? patch : {}) }));
@@ -1456,6 +1468,10 @@ function releaseLeaseFooterLine(list, now) {
1456
1468
  // left off"). Read-only. Quoted text is another session's record, never the
1457
1469
  // human's instructions; markers in it were neutralized at store time.
1458
1470
  const UNFINISHED_QUIET_MS = 10 * 60 * 1000;
1471
+ // 1.99.1 twin of agent-presence UNFINISHED_WINDOW_MS / hasWorkEvidence: only a
1472
+ // session that stopped in the last 24h AND left a 1.99+ work record is listed.
1473
+ const UNFINISHED_WINDOW_MS = 24 * 60 * 60 * 1000;
1474
+ const hasWorkEvidence = (w) => Boolean(w && (w.lastWords || (Array.isArray(w.commits) && w.commits.length) || w.handoffCardId || w.declaredAt));
1459
1475
  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
1476
  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
1477
  function unfinishedFooter(sid, { max = 4 } = {}) {
@@ -1474,18 +1490,19 @@ function unfinishedFooter(sid, { max = 4 } = {}) {
1474
1490
  const intentAt = Number(e.intentAt) || Number(e.firstSeen) || 0;
1475
1491
  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
1492
  })
1477
- .filter(r => !r.completed && !r.done && r.quietMs >= UNFINISHED_QUIET_MS)
1493
+ .filter(r => !r.completed && !r.done && r.quietMs >= UNFINISHED_QUIET_MS
1494
+ && hasWorkEvidence(r.w) && now - Number(r.e.lastSeen || 0) < UNFINISHED_WINDOW_MS)
1478
1495
  .sort((a, b) => Number(b.e.lastSeen) - Number(a.e.lastSeen));
1479
1496
  if (!recs.length) return '';
1480
1497
  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:`];
1498
+ 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
1499
  for (const { e, row, w } of recs.slice(0, max)) {
1483
1500
  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)}`];
1501
+ const bits = [row ? `stopped (${status})` : 'ended', `last active ${agoWord(now - e.lastSeen)}`];
1485
1502
  if (e.branch) bits.push(`branch ${e.branch}`);
1486
1503
  lines.push(`- ${clientWordOf(e.client)} ${e.id.slice(0, 8)} · “${flat(e.intent).slice(0, 120)}” · ${bits.join(' · ')}`);
1487
1504
  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(', ')}`);
1505
+ if (Array.isArray(e.files) && e.files.length) lines.push(` files it touched (free — not reserved): ${e.files.slice(-8).join(', ')}`);
1489
1506
  if (w.lastWords) lines.push(` its last words: “${flat(w.lastWords).slice(0, 420)}”`);
1490
1507
  if (w.handoffCardId) lines.push(` 🤝 it left a handoff card in the brain: ${w.handoffCardId} (brain_handoff {action:"list"} prints it in full)`);
1491
1508
  if (w.takenBy) {
@@ -1495,7 +1512,7 @@ function unfinishedFooter(sid, { max = 4 } = {}) {
1495
1512
  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
1513
  }
1497
1514
  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.');
1515
+ 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
1516
  return '\n\n' + lines.join('\n');
1500
1517
  } catch { return ''; }
1501
1518
  }
@@ -2664,8 +2681,7 @@ function liveMilestoneFromTranscript(tp) {
2664
2681
  for (const raw of text.split('\n')) {
2665
2682
  const line = raw.trim();
2666
2683
  const m = MARKER.exec(line); if (!m) continue;
2667
- const type = m[2] || '';
2668
- const body0 = m[3].trim(); if (!body0) continue;
2684
+ const { type, body: body0 } = markerParts(m); if (!body0) continue;
2669
2685
  const isMilestone = type === '!' || type === '+' || /🏁|🛠/.test(line);
2670
2686
  if (!isMilestone) continue;
2671
2687
  if (/<(open question|one-line decision|decision|area|placeholder|open|milestone)[^>]*>/i.test(body0) || /`[^`]*🧠/.test(raw)) continue; // example/doc guard
@@ -3676,9 +3692,10 @@ async function capture(lib) {
3676
3692
  continue;
3677
3693
  }
3678
3694
  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
3695
+ const area = (m[1] || '').trim();
3696
+ const { type, body: liftedBody } = markerParts(m);
3697
+ let body = liftedBody; if (!body) continue;
3698
+ const markerBody = m[3].trim(); // as written — what the published hooks cut and keyed
3682
3699
  // Strip optional `closes:` / `ev:` / `verify:` / `q:` suffixes off the
3683
3700
  // body so they don't leak into the card text; they drive the
3684
3701
  // close-link, evidence, live-probe and enrichment below. Only a
@@ -3776,6 +3793,14 @@ async function capture(lib) {
3776
3793
  const additive = type !== '✓' && type !== '~';
3777
3794
  const key = sha((type + '|' + area + '|' + (additive ? bodyWithCloses : body)).toLowerCase());
3778
3795
  const legacyKey = beforeUpgrade && additive && closes ? sha((type + '|' + area + '|' + body).toLowerCase()) : null;
3796
+ // A marker written after the colon was keyed by earlier hooks as a
3797
+ // plain decision whose body kept the glyph ("|Drive|! text"); a
3798
+ // line they already landed is seen, never captured a second time.
3799
+ if (!m[2] && type) {
3800
+ const pre = splitMarkerSuffixes(markerBody);
3801
+ const preKey = sha(('|' + area + '|' + (pre.bodyWithCloses || pre.body || markerBody)).toLowerCase());
3802
+ if (seen.has(preKey)) { hitKeys.add(preKey); seen.add(key); ledger.push({ action: 'skipped-seen', area, preview, olderHook: true }); continue; }
3803
+ }
3779
3804
  let repairStub = '';
3780
3805
  if (additive) {
3781
3806
  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.',