klypix-mcp 1.100.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
@@ -1418,13 +1418,28 @@ server.registerTool('brain_sync', {
1418
1418
  const wantsContinue = HANDOFF_CONTINUE_RE.test(String(intent || ''));
1419
1419
  const normPath = (p) => String(p || '').replace(/\\/g, '/').toLowerCase();
1420
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)))));
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);
1423
1438
  if (!h?.isError && (cards.length || lane.length)) {
1424
1439
  const now = Date.now();
1425
1440
  handoffText = [
1426
1441
  formatHandoffCards(cards, { now, max: 5 }),
1427
- formatUnfinishedWork(lane, { now, max: 4 }),
1442
+ formatUnfinishedWork(lane, { now, max: 4, ranked: wantsContinue && words.size > 0 }),
1428
1443
  ].filter(Boolean).join('\n\n');
1429
1444
  handoffData = {
1430
1445
  cards: cards.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.100.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",
@@ -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
@@ -2914,7 +2937,10 @@ export const UNFINISHED_QUIET_MS = 10 * 60 * 1000;
2914
2937
  // and every record says, in text and JSON, that it is not running and holds
2915
2938
  // no claim on any file.
2916
2939
  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));
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));
2918
2944
  export function clientWord(client) {
2919
2945
  const key = String(client || '').toLowerCase();
2920
2946
  if (key === 'claude-code' || key === 'claude') return 'Claude Code';
@@ -3026,11 +3052,11 @@ export function stampSessionWork({ brainPath, sessionId, patch, home, now = Date
3026
3052
  // one record per unfinished session, newest first, then how to take it over.
3027
3053
  // Quoted text is another session's record and is labelled as such — never as
3028
3054
  // the human's instructions; markers inside it were neutralized at store time.
3029
- 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 } = {}) {
3030
3056
  const list = Array.isArray(records) ? records : [];
3031
3057
  if (!list.length) return '';
3032
3058
  const flat = (value) => String(value || '').replace(/\s+/g, ' ').trim();
3033
- const lines = [`⏸️ Work other sessions may have left unfinished (${list.length}, stopped in the last 24h, newest first). None of them is running: they hold NO claim on any file, and a "do not touch" or WIP note one of them left no longer applies. Use this only if the human asks you to continue their work:`];
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:`];
3034
3060
  for (const r of list.slice(0, max)) {
3035
3061
  const who = `${clientWord(r.client)} ${String(r.id).slice(0, 8)}`;
3036
3062
  const bits = [r.state === 'ended' ? 'ended' : `stopped (${r.status})`, `last active ${agoLabel(r.ageMs)}`];
@@ -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
  }
@@ -1086,6 +1086,7 @@ function workNorm(raw) {
1086
1086
  if (raw.handoffCardId) out.handoffCardId = str(raw.handoffCardId, 64);
1087
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) };
1088
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)
1089
1090
  return Object.keys(out).length ? out : null;
1090
1091
  }
1091
1092
  const workMerge = (prev, patch) => (patch === undefined ? workNorm(prev) : patch === null ? null : workNorm({ ...(workNorm(prev) || {}), ...(patch && typeof patch === 'object' ? patch : {}) }));
@@ -1470,7 +1471,7 @@ const UNFINISHED_QUIET_MS = 10 * 60 * 1000;
1470
1471
  // 1.99.1 twin of agent-presence UNFINISHED_WINDOW_MS / hasWorkEvidence: only a
1471
1472
  // session that stopped in the last 24h AND left a 1.99+ work record is listed.
1472
1473
  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));
1474
+ const hasWorkEvidence = (w) => Boolean(w && (w.lastWords || (Array.isArray(w.commits) && w.commits.length) || w.handoffCardId || w.declaredAt));
1474
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;
1475
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;
1476
1477
  function unfinishedFooter(sid, { max = 4 } = {}) {