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 +23 -10
- package/bin/klypix-install.mjs +3 -1
- package/bin/klypix-worker.mjs +38 -3
- package/package.json +2 -2
- package/src/agent-presence.mjs +58 -10
- package/src/agent-rules.mjs +3 -1
- package/src/codex-brain-hook.mjs +27 -0
- package/src/global-brain-hook.mjs +35 -10
- package/src/klypix-core.mjs +2 -1
- package/src/klypix-format.mjs +48 -1
- package/src/mcp-presence.mjs +1 -1
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
|
|
601
|
-
remembers sessions for two weeks
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
the
|
|
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
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1414
|
-
|
|
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.
|
|
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",
|
package/src/agent-presence.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...(
|
|
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
|
|
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 = [`⏸️
|
|
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
|
|
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('
|
|
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
|
-
|
|
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}
|
|
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');
|
package/src/agent-rules.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
package/src/codex-brain-hook.mjs
CHANGED
|
@@ -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 = [`⏸️
|
|
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('
|
|
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
|
|
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()
|
|
3680
|
-
|
|
3681
|
-
|
|
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; }
|
package/src/klypix-core.mjs
CHANGED
|
@@ -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
|
package/src/klypix-format.mjs
CHANGED
|
@@ -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();
|
package/src/mcp-presence.mjs
CHANGED
|
@@ -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.',
|