klypix-mcp 1.103.0 → 1.104.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 +31 -1
- package/bin/klypix-install.mjs +1 -1
- package/package.json +2 -2
- package/src/codex-brain-hook.mjs +27 -3
- package/src/global-brain-hook.mjs +32 -3
- package/src/live-projection.mjs +286 -0
- package/src/uninstall.mjs +54 -1
package/README.md
CHANGED
|
@@ -515,6 +515,33 @@ so fresh relevant findings are not crowded out by older area vocabulary.
|
|
|
515
515
|
Briefs are **not** injected automatically on Cursor, Cline, Copilot, Gemini CLI or Antigravity —
|
|
516
516
|
there are no lifecycle hooks on those hosts.
|
|
517
517
|
|
|
518
|
+
### The live projection — the file the agent obeys
|
|
519
|
+
|
|
520
|
+
The coordination bench showed that an agent trusts the instruction file it was launched with
|
|
521
|
+
over anything a tool or hook tells it, and that each host reads a different surface. So the
|
|
522
|
+
brain's state is projected into those files, in two halves:
|
|
523
|
+
|
|
524
|
+
- **Committed, from the brain:** the compact brief block in `AGENTS.md` (standing rules,
|
|
525
|
+
corrections, open handoff cards), refreshed by the Claude Code Stop hook only when its content
|
|
526
|
+
changed, so diffs stay quiet.
|
|
527
|
+
- **Machine-local, from the lane (klypix-mcp 1.104.0):** which sessions are live on this PC and
|
|
528
|
+
which files they hold, the work a stopped session left, and the evidence beside every
|
|
529
|
+
"keep off this file" note — written at session start and at every Stop by the Claude Code and
|
|
530
|
+
Codex hooks into `CLAUDE.local.md` at the project root (Claude Code loads it beside
|
|
531
|
+
`CLAUDE.md`; KLYPIX keeps it out of git through `.git/info/exclude`, never a committed file)
|
|
532
|
+
and into one section per project of the Codex global instructions file
|
|
533
|
+
(`~/.codex/AGENTS.override.md` when you have one, else `~/.codex/AGENTS.md`). A project-level
|
|
534
|
+
`AGENTS.override.md` is never written: Codex loads one file per directory, so it would hide
|
|
535
|
+
your `AGENTS.md`.
|
|
536
|
+
|
|
537
|
+
The block is a snapshot and says so: bounded to about 2.4KB, rewritten only when its content
|
|
538
|
+
changed (the time stamp alone never causes a write), with the keep-off evidence kept first when
|
|
539
|
+
the budget bites, and "nothing live" written in words so an older snapshot never stands. It
|
|
540
|
+
holds the same strings the hooks print, so the file and the session context cannot disagree.
|
|
541
|
+
Sections for projects not touched in a day are pruned from the Codex file, at most eight are
|
|
542
|
+
kept, and `uninstall unlink` strips the block (deleting `CLAUDE.local.md` only when nothing else
|
|
543
|
+
is in it). `brain_sync` remains the live source; the block tells the agent to call it.
|
|
544
|
+
|
|
518
545
|
## Capture and corrections
|
|
519
546
|
|
|
520
547
|
`brain_note` accepts structured supporting references and inert verification text:
|
|
@@ -1350,7 +1377,10 @@ It strips only KLYPIX's own entries — every other hook and setting in
|
|
|
1350
1377
|
`~/.claude/settings.json` stays — backs up each file it edits, and **never deletes a `.klypix`**.
|
|
1351
1378
|
Removal requires `--yes`. Without it, the command prints the plan and changes nothing;
|
|
1352
1379
|
there is no interactive confirmation prompt. Run the project-local form in each project you
|
|
1353
|
-
want to unlink. A preview exits non-zero while managed files remain.
|
|
1380
|
+
want to unlink. A preview exits non-zero while managed files remain. The project-local form also
|
|
1381
|
+
strips the live-projection block from `CLAUDE.local.md` (the file goes only when nothing else is
|
|
1382
|
+
in it) and this project's section from the Codex global file; the machine-global form strips
|
|
1383
|
+
every live section there.
|
|
1354
1384
|
|
|
1355
1385
|
Your `brain.klypix` is yours — it is a plain ZIP and stays readable with or without this package.
|
|
1356
1386
|
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -495,7 +495,7 @@ try {
|
|
|
495
495
|
// a newer klypix-format cannot. merge-brains and the driver ship here so
|
|
496
496
|
// brain-history's restore merge, the KLYPIX core and the git driver all
|
|
497
497
|
// find one engine in this directory.
|
|
498
|
-
for (const f of ['global-brain-hook.mjs', 'klypix-format.mjs', 'brain-graveyard.mjs', 'merge-brains.mjs', 'klypix-merge-driver.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'provenance.mjs', 'brain-note.mjs', 'brain-evidence.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'app-lease.mjs', 'klypix-core.mjs', 'app-bridge-protocol.mjs', 'app-bridge-client.mjs', 'app-tools.mjs', 'card-files.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'editor-detect.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'session-handoff.mjs', 'canvas-view-app.html']) {
|
|
498
|
+
for (const f of ['global-brain-hook.mjs', 'klypix-format.mjs', 'brain-graveyard.mjs', 'merge-brains.mjs', 'klypix-merge-driver.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'provenance.mjs', 'brain-note.mjs', 'brain-evidence.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'app-lease.mjs', 'klypix-core.mjs', 'app-bridge-protocol.mjs', 'app-bridge-client.mjs', 'app-tools.mjs', 'card-files.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'editor-detect.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'session-handoff.mjs', 'live-projection.mjs', 'canvas-view-app.html']) {
|
|
499
499
|
const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
|
|
500
500
|
}
|
|
501
501
|
for (const [src, dst] of [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.104.0",
|
|
4
4
|
"mcpName": "io.github.dahshanlabs/klypix-mcp",
|
|
5
5
|
"description": "Shared project memory for Claude Code, Codex and Cursor: decisions and corrections through MCP.",
|
|
6
6
|
"type": "module",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"bench": "node bin/klypix-mcp.mjs bench",
|
|
92
92
|
"test:bench": "node test/bench.mjs",
|
|
93
93
|
"pretest": "node test/publish-workflow.mjs",
|
|
94
|
-
"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/lock-notes.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",
|
|
94
|
+
"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/lock-notes.mjs && node test/live-projection.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",
|
|
95
95
|
"test:memory": "node test/memory-runtime.mjs",
|
|
96
96
|
"test:memory:soak": "node --expose-gc test/memory-soak.mjs",
|
|
97
97
|
"runtime": "node bin/klypix-runtime.mjs",
|
package/src/codex-brain-hook.mjs
CHANGED
|
@@ -385,7 +385,7 @@ function unfinishedWorkBlock(brainPath, sessionId) {
|
|
|
385
385
|
// a "do not touch" / hands-off / WIP line in AGENTS.md checked against live
|
|
386
386
|
// sessions, local branches and uncommitted changes. Dynamic import so a stale
|
|
387
387
|
// deployment without it no-ops; never throws.
|
|
388
|
-
async function lockNotesBlock(projectDir, brainPath, sessionId, sessions) {
|
|
388
|
+
async function lockNotesBlock(projectDir, brainPath, sessionId, sessions, { budgetMs } = {}) {
|
|
389
389
|
try {
|
|
390
390
|
const lib = await import(new URL('./session-handoff.mjs', import.meta.url).href);
|
|
391
391
|
if (typeof lib.readLockNotes !== 'function') return '';
|
|
@@ -393,7 +393,7 @@ async function lockNotesBlock(projectDir, brainPath, sessionId, sessions) {
|
|
|
393
393
|
try { stopped = listUnfinishedWork({ brainPath, selfId: sessionId }); } catch { /* git still answers */ }
|
|
394
394
|
// Lines naming a file a stopped session worked on ride too, whatever their wording.
|
|
395
395
|
const mentions = [...new Set(stopped.flatMap((r) => (Array.isArray(r.files) ? r.files : [])))].slice(0, 20);
|
|
396
|
-
const probe = lib.createLockNoteProbe(projectDir);
|
|
396
|
+
const probe = lib.createLockNoteProbe(projectDir, budgetMs ? { budgetMs } : {});
|
|
397
397
|
const notes = lib.readLockNotes(projectDir, { mentions, probe });
|
|
398
398
|
if (!notes.length) return '';
|
|
399
399
|
const assessed = notes.slice(0, 6).map((n) => lib.assessLockNote(projectDir, n, { live: sessions || [], stopped, selfId: sessionId ? [sessionId] : [], probe }));
|
|
@@ -403,6 +403,22 @@ async function lockNotesBlock(projectDir, brainPath, sessionId, sessions) {
|
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
// Live projection (1.104.0): the same presence / unfinished / keep-off strings
|
|
407
|
+
// this hook emits, written into CLAUDE.local.md and the Codex global
|
|
408
|
+
// instructions file for the NEXT session of any host (session start and Stop).
|
|
409
|
+
// Dynamic import so a stale deployment no-ops; never throws.
|
|
410
|
+
async function projectLiveSafe({ projectDir, sessions, sessionId, lockNotes = '', unfinished = '' }) {
|
|
411
|
+
try {
|
|
412
|
+
const lib = await import(new URL('./live-projection.mjs', import.meta.url).href);
|
|
413
|
+
if (typeof lib.projectLive !== 'function') return null;
|
|
414
|
+
const peers = (Array.isArray(sessions) ? sessions : []).filter((s) => s && s.id && s.id !== sessionId)
|
|
415
|
+
.map((s) => ({ id: s.id, client: s.client, intent: s.intent, files: s.files, observedFiles: s.observedFiles }));
|
|
416
|
+
return lib.projectLive({ projectDir, peers, lockNotesText: String(lockNotes || '').trim(), unfinishedText: String(unfinished || '').trim() });
|
|
417
|
+
} catch {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
406
422
|
// Unfinished-work patch for one Codex lifecycle event (session handoff). A
|
|
407
423
|
// human prompt records what was asked; a Stop records the reply tail only if
|
|
408
424
|
// the host put it in the payload. Null when the event carries nothing.
|
|
@@ -587,6 +603,8 @@ async function main() {
|
|
|
587
603
|
} catch { return ''; }
|
|
588
604
|
})();
|
|
589
605
|
const lockNotes = await lockNotesBlock(projectDir, brainPath, sessionId, sessions);
|
|
606
|
+
const unfinished = unfinishedWorkBlock(brainPath, sessionId);
|
|
607
|
+
await projectLiveSafe({ projectDir, sessions, sessionId, lockNotes, unfinished });
|
|
590
608
|
emitSystemMessage([
|
|
591
609
|
formatPresenceMessage(sessions, sessionId, { includeSolo: true }),
|
|
592
610
|
lockNotes,
|
|
@@ -600,10 +618,16 @@ async function main() {
|
|
|
600
618
|
// ⏸️ Unfinished work other sessions left (session handoff, 1.99.0): the
|
|
601
619
|
// same engine block brain_sync prints, so a Codex session asked to
|
|
602
620
|
// "continue" what a Claude session stopped in starts from its record.
|
|
603
|
-
|
|
621
|
+
unfinished,
|
|
604
622
|
], event);
|
|
605
623
|
return;
|
|
606
624
|
}
|
|
625
|
+
if (event === 'Stop') {
|
|
626
|
+
// The lane now carries this turn's files and reply tail: refresh the live
|
|
627
|
+
// projection for the next session, with a short git budget (a Stop must not wait).
|
|
628
|
+
const lockNotes = await lockNotesBlock(projectDir, brainPath, sessionId, sessions, { budgetMs: 1000 });
|
|
629
|
+
await projectLiveSafe({ projectDir, sessions, sessionId, lockNotes, unfinished: unfinishedWorkBlock(brainPath, sessionId) });
|
|
630
|
+
}
|
|
607
631
|
if (event === 'UserPromptSubmit') {
|
|
608
632
|
const me = sessions.find((session) => session.id === sessionId);
|
|
609
633
|
// A STRONG status question is answered from computed state and REPLACES
|
|
@@ -1571,7 +1571,7 @@ function unfinishedFooter(sid, { max = 4 } = {}) {
|
|
|
1571
1571
|
// live sessions, local branches and uncommitted changes — at session start,
|
|
1572
1572
|
// before the agent decides to obey or ignore it. Same code as brain_sync
|
|
1573
1573
|
// (session-handoff.mjs). Never edits the note; '' on any failure.
|
|
1574
|
-
async function lockNotesFooter(sid) {
|
|
1574
|
+
async function lockNotesFooter(sid, { budgetMs } = {}) {
|
|
1575
1575
|
try {
|
|
1576
1576
|
const lib = await import(new URL('./session-handoff.mjs', import.meta.url).href);
|
|
1577
1577
|
if (typeof lib.readLockNotes !== 'function') return '';
|
|
@@ -1586,7 +1586,7 @@ async function lockNotesFooter(sid) {
|
|
|
1586
1586
|
// Lines naming a file a stopped session worked on ride too, whatever
|
|
1587
1587
|
// their wording (bench S5d/S5f: the agent with hooks never asked).
|
|
1588
1588
|
const mentions = [...new Set(stopped.flatMap(r => Array.isArray(r.files) ? r.files : []))].slice(0, 20);
|
|
1589
|
-
const probe = lib.createLockNoteProbe(CWD);
|
|
1589
|
+
const probe = lib.createLockNoteProbe(CWD, budgetMs ? { budgetMs } : {});
|
|
1590
1590
|
const notes = lib.readLockNotes(CWD, { mentions, probe });
|
|
1591
1591
|
if (!notes.length) return '';
|
|
1592
1592
|
const assessed = notes.slice(0, 6).map(n => lib.assessLockNote(CWD, n, { live, stopped, selfId: sid ? [sid] : [], now, probe }));
|
|
@@ -1595,6 +1595,28 @@ async function lockNotesFooter(sid) {
|
|
|
1595
1595
|
} catch { return ''; }
|
|
1596
1596
|
}
|
|
1597
1597
|
|
|
1598
|
+
// Live projection (1.104.0, bench rounds 2–3): write the machine-local half of
|
|
1599
|
+
// the brain's state — who is live on this PC and what they hold, the work a
|
|
1600
|
+
// stopped session left, the evidence beside every keep-off note — into the
|
|
1601
|
+
// files the hosts load by themselves and never commit (CLAUDE.local.md and the
|
|
1602
|
+
// Codex global instructions file), at session start and at every Stop. The
|
|
1603
|
+
// inputs are the SAME strings this hook prints, so the file and the context
|
|
1604
|
+
// cannot disagree. Dynamic import so a stale flat deployment no-ops; never throws.
|
|
1605
|
+
async function writeLiveProjection(sid, { lockText = null, unfinishedText = null, budgetMs } = {}) {
|
|
1606
|
+
try {
|
|
1607
|
+
const lib = await import(new URL('./live-projection.mjs', import.meta.url).href);
|
|
1608
|
+
if (typeof lib.projectLive !== 'function') return null;
|
|
1609
|
+
const lock = lockText === null ? await lockNotesFooter(sid, budgetMs ? { budgetMs } : {}) : lockText;
|
|
1610
|
+
const unfinished = unfinishedText === null ? unfinishedFooter(sid) : unfinishedText;
|
|
1611
|
+
return lib.projectLive({
|
|
1612
|
+
projectDir: CWD,
|
|
1613
|
+
peers: livePeers(sid).map(p => ({ id: p.id, client: p.client, intent: p.intent, files: p.files, observedFiles: p.observedFiles })),
|
|
1614
|
+
lockNotesText: String(lock || '').trim(),
|
|
1615
|
+
unfinishedText: String(unfinished || '').trim(),
|
|
1616
|
+
});
|
|
1617
|
+
} catch { return null; }
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1598
1620
|
// The nudge a session gets when the human asks for a handoff or says the
|
|
1599
1621
|
// limit is near: the deliberate layer (a 🤝 card in the brain) on top of the
|
|
1600
1622
|
// automatic state above. One line; the tool's own description carries the rest.
|
|
@@ -6185,12 +6207,16 @@ async function read(lib) {
|
|
|
6185
6207
|
// The full brief keeps its order.
|
|
6186
6208
|
const messages = messageFooter(input.session_id || '', input.transcript_path, lib);
|
|
6187
6209
|
const lockLine = await lockNotesFooter(input.session_id);
|
|
6210
|
+
const unfinishedLine = unfinishedFooter(input.session_id);
|
|
6188
6211
|
const out = ultra + messages + lockLine + versionLine + presenceLine + shipObsLine + gitHookNotice + laneWarning + healLine + draftLine
|
|
6189
6212
|
+ receiptLine
|
|
6190
6213
|
+ inflightFooter(input.session_id, struct)
|
|
6191
|
-
+
|
|
6214
|
+
+ unfinishedLine
|
|
6192
6215
|
+ selfCheckFooter() + doctorFooter();
|
|
6193
6216
|
process.stdout.write(out);
|
|
6217
|
+
// The same lock / unfinished strings go into CLAUDE.local.md and the Codex
|
|
6218
|
+
// global file for the NEXT session of any host (live projection).
|
|
6219
|
+
await writeLiveProjection(input.session_id, { lockText: lockLine, unfinishedText: unfinishedLine });
|
|
6194
6220
|
// Heartbeat: prove the brief actually injected (and how big) so a dead or
|
|
6195
6221
|
// stale live-copy of the hook stops being a silent no-op.
|
|
6196
6222
|
appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(out), fullBriefBytes: Buffer.byteLength(full), cards: struct?.counts?.cards ?? null }, 500);
|
|
@@ -6262,6 +6288,9 @@ async function main() {
|
|
|
6262
6288
|
// without paying the rebuild itself. Best-effort; guardCheck's currency
|
|
6263
6289
|
// stat is the backstop.
|
|
6264
6290
|
try { if (typeof lib.ensureGuardSidecar === 'function') await lib.ensureGuardSidecar(BRAIN); } catch { /* backstopped */ }
|
|
6291
|
+
// Refresh the live projection after the turn: the lane now carries this
|
|
6292
|
+
// session's files and reply tail. A short git budget — a Stop must not wait.
|
|
6293
|
+
await writeLiveProjection(readHookInput().session_id, { budgetMs: 1000 });
|
|
6265
6294
|
// Ambient version-currency: piggyback the post-session Stop hook to refresh the
|
|
6266
6295
|
// npm-latest cache (≤ once/day, best-effort, failure-silent) so the next
|
|
6267
6296
|
// SessionStart footer can surface a stale install with ZERO network. Awaited so
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Live projection (1.104.0) — the machine-local half of "project the brain
|
|
2
|
+
// into the file the agent obeys".
|
|
3
|
+
//
|
|
4
|
+
// WHY. The coordination bench (rounds 2 and 3, 2026-10-12/13) showed that an
|
|
5
|
+
// agent trusts the instruction file it was launched with more than anything a
|
|
6
|
+
// tool or hook tells it: a two-day-old "keep off this file" line in AGENTS.md
|
|
7
|
+
// beat KLYPIX's lane in 14 of 15 runs, and the hosts read different surfaces
|
|
8
|
+
// (Claude the JSON, Codex the text, both the files). The brain-derived half —
|
|
9
|
+
// standing rules, corrections, handoff cards — already lives in the committed
|
|
10
|
+
// `<!-- klypix-brain-brief -->` block of AGENTS.md (refreshAgentsBrief). The
|
|
11
|
+
// LIVE half — which sessions are on this PC right now and what files they
|
|
12
|
+
// hold, what work a stopped session left, and the evidence beside every
|
|
13
|
+
// keep-off note — reached the agent only through hook output and brain_sync,
|
|
14
|
+
// never through a file. This module writes that half into the two files the
|
|
15
|
+
// hosts load by themselves and never commit:
|
|
16
|
+
//
|
|
17
|
+
// • `CLAUDE.local.md` at the project root — Claude Code loads it beside
|
|
18
|
+
// CLAUDE.md ("personal project-specific preferences; add to .gitignore");
|
|
19
|
+
// its HTML comments are stripped before injection, so the markers cost
|
|
20
|
+
// nothing. KLYPIX keeps it out of git through `.git/info/exclude` (local
|
|
21
|
+
// to this clone, never a committed file).
|
|
22
|
+
// • the Codex global instructions file — `~/.codex/AGENTS.override.md` when
|
|
23
|
+
// the person has one, else `~/.codex/AGENTS.md`, the file klypix-mcp
|
|
24
|
+
// already manages. Codex loads the global file first, then one file per
|
|
25
|
+
// directory from the project root down, so a project-level override would
|
|
26
|
+
// HIDE the project's AGENTS.md: never written. One section per project,
|
|
27
|
+
// keyed by the project's path, pruned when stale.
|
|
28
|
+
//
|
|
29
|
+
// CONTRACT. Snapshot semantics, said in the text: written at session start and
|
|
30
|
+
// stop, not live, not shared, brain_sync is the live source. Bounded (budget
|
|
31
|
+
// below), deterministic for one input, rewritten only when the non-volatile
|
|
32
|
+
// content changed (the time stamp alone never causes a write). Never throws:
|
|
33
|
+
// every writer returns an action word and swallows its own errors, because it
|
|
34
|
+
// runs inside hooks that must exit 0.
|
|
35
|
+
import crypto from 'node:crypto';
|
|
36
|
+
import { execFileSync } from 'node:child_process';
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import os from 'node:os';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
|
|
41
|
+
export const LIVE_PROJECTION_VERSION = 1;
|
|
42
|
+
export const LIVE_BUDGET_CHARS = 2400;
|
|
43
|
+
export const LIVE_STALE_MS = 24 * 60 * 60 * 1000;
|
|
44
|
+
export const LIVE_PROJECT_CAP = 8;
|
|
45
|
+
export const CLAUDE_LOCAL_FILE = 'CLAUDE.local.md';
|
|
46
|
+
export const LIVE_END = '<!-- klypix-live:end -->';
|
|
47
|
+
|
|
48
|
+
const flat = (v) => String(v || '').replace(/\s+/g, ' ').trim();
|
|
49
|
+
const minuteIso = (ms) => new Date(ms).toISOString().slice(0, 16) + 'Z';
|
|
50
|
+
const CLIENT_WORDS = { 'claude-code': 'Claude Code', codex: 'Codex', cursor: 'Cursor', cline: 'Cline', windsurf: 'Windsurf', copilot: 'Copilot', gemini: 'Gemini CLI', aider: 'Aider', antigravity: 'Antigravity' };
|
|
51
|
+
const clientWord = (c) => CLIENT_WORDS[String(c || '').toLowerCase()] || (c ? String(c) : 'agent');
|
|
52
|
+
|
|
53
|
+
/** Stable key for a project path: same spelling rules as the presence lane. */
|
|
54
|
+
export function liveKey(projectDir) {
|
|
55
|
+
const norm = path.resolve(String(projectDir || '.')).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
56
|
+
return crypto.createHash('sha1').update(norm).digest('hex').slice(0, 16);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function liveStart(key, { at = Date.now(), projectDir = '' } = {}) {
|
|
60
|
+
const where = projectDir ? ` project=${JSON.stringify(path.resolve(projectDir).replace(/\\/g, '/'))}` : '';
|
|
61
|
+
return `<!-- klypix-live:start key=${key} at=${new Date(at).toISOString()}${where} (machine-local; written by klypix-mcp at session start and stop; do not edit; never commit) -->`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// One regex per key; the marker's attributes are free text up to `-->`.
|
|
65
|
+
const blockRe = (key) => new RegExp(`<!-- klypix-live:start key=${key}[^\\n]*-->[\\s\\S]*?<!-- klypix-live:end -->\\r?\\n?`);
|
|
66
|
+
const ANY_BLOCK_RE = /<!-- klypix-live:start key=([0-9a-f]{16}) at=([^ \n]+)[^\n]*-->[\s\S]*?<!-- klypix-live:end -->\r?\n?/g;
|
|
67
|
+
|
|
68
|
+
/** Everything in a block that may change without the content changing. */
|
|
69
|
+
function stable(text) {
|
|
70
|
+
return String(text || '')
|
|
71
|
+
.replace(/<!-- klypix-live:start key=[0-9a-f]{16} at=[^ \n]+/g, '<!-- klypix-live:start')
|
|
72
|
+
.replace(/· as of [0-9T:-]+Z/g, '· as of')
|
|
73
|
+
.replace(/\r\n/g, '\n').trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function peerLine(p) {
|
|
77
|
+
const files = [...new Set([...(Array.isArray(p.files) ? p.files : []), ...(Array.isArray(p.observedFiles) ? p.observedFiles : [])])].filter(Boolean);
|
|
78
|
+
const bits = [`${clientWord(p.client)} ${String(p.id || '').slice(0, 8)}`];
|
|
79
|
+
if (p.intent) bits.push(`“${flat(p.intent).slice(0, 70)}”`);
|
|
80
|
+
if (files.length) bits.push(`files: ${files.slice(0, 4).join(', ')}${files.length > 4 ? ` +${files.length - 4}` : ''}`);
|
|
81
|
+
return bits.join(' · ');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function clip(text, max) {
|
|
85
|
+
const t = String(text || '').trim();
|
|
86
|
+
if (t.length <= max) return t;
|
|
87
|
+
return t.slice(0, Math.max(0, max - 60)).replace(/\s+\S*$/, '') + '\n … (cut for size — brain_sync has the rest)';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The block body. Pure: same inputs, same text (the "as of" minute aside).
|
|
92
|
+
* `peers` are presence-lane rows OTHER than the writing session; `unfinishedText`
|
|
93
|
+
* and `lockNotesText` are the engine's own blocks (formatUnfinishedWork,
|
|
94
|
+
* formatLockNotes), passed through so this file never re-derives a verdict.
|
|
95
|
+
*/
|
|
96
|
+
export function renderLiveProjection({ projectDir, now = Date.now(), version = '', peers = [], unfinishedText = '', lockNotesText = '', budgetChars = LIVE_BUDGET_CHARS } = {}) {
|
|
97
|
+
const name = path.basename(path.resolve(projectDir || '.')) || 'project';
|
|
98
|
+
const budget = Math.max(800, Math.min(8000, Number(budgetChars) || LIVE_BUDGET_CHARS));
|
|
99
|
+
const head = [
|
|
100
|
+
`## KLYPIX live — ${name} on this PC · as of ${minuteIso(now)}`,
|
|
101
|
+
`Snapshot written by klypix-mcp${version ? ` ${version}` : ''} at session start and stop: this PC only, not committed, not shared. The decisions and corrections are in the AGENTS.md brain block; \`brain_sync\` for this project is the live source and must be called before acting on anything below.`,
|
|
102
|
+
];
|
|
103
|
+
const rows = (Array.isArray(peers) ? peers : []).filter((p) => p && p.id);
|
|
104
|
+
const peersText = rows.length
|
|
105
|
+
? `👥 ${rows.length} other live session(s) on this PC right now (a snapshot — re-query brain_sync before reporting them): ${rows.slice(0, 6).map(peerLine).join(' | ')}${rows.length > 6 ? ` | …and ${rows.length - 6} more` : ''}. A file two sessions both hold is a warning, not a lock: coordinate with brain_message.`
|
|
106
|
+
: '👥 No other live session on this PC at the time of writing.';
|
|
107
|
+
const lock = String(lockNotesText || '').trim();
|
|
108
|
+
const unfinished = String(unfinishedText || '').trim();
|
|
109
|
+
// Priority when the budget bites: the keep-off evidence first (it is what the
|
|
110
|
+
// bench lost on), then who is live, then the unfinished work.
|
|
111
|
+
const fixed = head.join('\n').length + 2;
|
|
112
|
+
let left = budget - fixed;
|
|
113
|
+
const lockPart = lock ? clip(lock, Math.max(600, Math.floor(left * 0.6))) : '';
|
|
114
|
+
left -= lockPart.length + 2;
|
|
115
|
+
const peersPart = clip(peersText, Math.max(200, Math.min(700, left)));
|
|
116
|
+
left -= peersPart.length + 2;
|
|
117
|
+
const unfinishedPart = unfinished ? clip(unfinished, Math.max(240, left)) : '⏸️ No unfinished work recorded from a stopped session.';
|
|
118
|
+
return [...head, '', peersPart, '', lockPart || '📌 No keep-off note in this project\'s instruction files named a file with a verdict to report.', '', unfinishedPart].join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function wrapLiveBlock(key, body, opts = {}) {
|
|
122
|
+
return `${liveStart(key, opts)}\n${body}\n${LIVE_END}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Replace the block with this key in `raw`, or append it. */
|
|
126
|
+
export function upsertLiveBlock(raw, key, block) {
|
|
127
|
+
const text = String(raw || '');
|
|
128
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
129
|
+
const b = block.replace(/\r?\n/g, eol);
|
|
130
|
+
const re = blockRe(key);
|
|
131
|
+
if (re.test(text)) return text.replace(re, b + eol);
|
|
132
|
+
const base = text.trim() ? text.replace(/\s*$/, '') + eol + eol : '';
|
|
133
|
+
return base + b + eol;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function stripLiveBlocks(raw, key = null) {
|
|
137
|
+
const text = String(raw || '');
|
|
138
|
+
const next = key ? text.replace(blockRe(key), '') : text.replace(ANY_BLOCK_RE, '');
|
|
139
|
+
return next.replace(/^\s+/, '').replace(/\s+$/, (m) => (m.includes('\n') ? '\n' : ''));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function atomicWrite(file, content) {
|
|
143
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
144
|
+
const tmp = `${file}.klypix-tmp-${process.pid}`;
|
|
145
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
146
|
+
fs.renameSync(tmp, file);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function git(cwd, args, timeout = 1500) {
|
|
150
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Keep CLAUDE.local.md out of this clone's git status without touching a
|
|
155
|
+
* committed file: `.git/info/exclude` (the worktree-aware git-path form). A
|
|
156
|
+
* project that already ignores it, or is not a repository, is left alone.
|
|
157
|
+
*/
|
|
158
|
+
export function ensureLocalExcluded(projectDir, { rel = CLAUDE_LOCAL_FILE } = {}) {
|
|
159
|
+
const root = path.resolve(projectDir);
|
|
160
|
+
try {
|
|
161
|
+
execFileSync('git', ['check-ignore', '-q', rel], { cwd: root, timeout: 1500, stdio: 'ignore', windowsHide: true });
|
|
162
|
+
return 'already-ignored';
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (e && e.status !== 1) return 'not-a-repo';
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const excludeRel = git(root, ['rev-parse', '--git-path', 'info/exclude']);
|
|
168
|
+
const exclude = path.isAbsolute(excludeRel) ? excludeRel : path.join(root, excludeRel);
|
|
169
|
+
let raw = '';
|
|
170
|
+
try { raw = fs.readFileSync(exclude, 'utf8'); } catch { /* fresh */ }
|
|
171
|
+
if (raw.split(/\r?\n/).some((l) => l.trim() === rel || l.trim() === `/${rel}`)) return 'already-excluded';
|
|
172
|
+
fs.mkdirSync(path.dirname(exclude), { recursive: true });
|
|
173
|
+
fs.appendFileSync(exclude, `${raw && !raw.endsWith('\n') ? '\n' : ''}# KLYPIX live projection (machine-local; written by klypix-mcp)\n${rel}\n`, 'utf8');
|
|
174
|
+
return 'excluded';
|
|
175
|
+
} catch {
|
|
176
|
+
return 'exclude-failed';
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function writeClaudeLocal(projectDir, body, { now = Date.now(), file = null } = {}) {
|
|
181
|
+
const root = path.resolve(projectDir);
|
|
182
|
+
const target = file || path.join(root, CLAUDE_LOCAL_FILE);
|
|
183
|
+
try {
|
|
184
|
+
const key = liveKey(root);
|
|
185
|
+
let raw = '';
|
|
186
|
+
try { raw = fs.readFileSync(target, 'utf8'); } catch { /* fresh */ }
|
|
187
|
+
const block = wrapLiveBlock(key, body, { at: now, projectDir: root });
|
|
188
|
+
const next = upsertLiveBlock(raw, key, block);
|
|
189
|
+
if (stable(next) === stable(raw)) return { path: target, action: 'unchanged', excluded: ensureLocalExcluded(root) };
|
|
190
|
+
atomicWrite(target, next);
|
|
191
|
+
return { path: target, action: raw ? 'updated' : 'created', excluded: ensureLocalExcluded(root) };
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return { path: target, action: 'failed', error: e?.message || String(e) };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The one global instruction file Codex actually loads. */
|
|
198
|
+
export function codexGlobalInstructionFile(home = os.homedir()) {
|
|
199
|
+
const dir = path.join(process.env.CODEX_HOME || path.join(home, '.codex'));
|
|
200
|
+
const override = path.join(dir, 'AGENTS.override.md');
|
|
201
|
+
try { if (fs.statSync(override).isFile()) return override; } catch { /* no override */ }
|
|
202
|
+
return path.join(dir, 'AGENTS.md');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Drop other projects' sections that are stale or beyond the cap (oldest first). */
|
|
206
|
+
export function pruneLiveSections(raw, { keep = null, now = Date.now(), staleMs = LIVE_STALE_MS, cap = LIVE_PROJECT_CAP } = {}) {
|
|
207
|
+
const text = String(raw || '');
|
|
208
|
+
const found = [];
|
|
209
|
+
for (const m of text.matchAll(ANY_BLOCK_RE)) found.push({ key: m[1], at: Date.parse(m[2]) || 0, text: m[0] });
|
|
210
|
+
const others = found.filter((f) => f.key !== keep).sort((a, b) => a.at - b.at);
|
|
211
|
+
const drop = new Set();
|
|
212
|
+
for (const f of others) if (now - f.at > staleMs) drop.add(f.text);
|
|
213
|
+
const fresh = others.filter((f) => !drop.has(f.text));
|
|
214
|
+
const room = Math.max(0, cap - (keep ? 1 : 0));
|
|
215
|
+
for (const f of fresh.slice(0, Math.max(0, fresh.length - room))) drop.add(f.text);
|
|
216
|
+
let next = text;
|
|
217
|
+
for (const t of drop) next = next.replace(t, '');
|
|
218
|
+
return { text: next, dropped: drop.size };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function writeCodexGlobal(projectDir, body, { now = Date.now(), home = os.homedir(), file = null } = {}) {
|
|
222
|
+
const root = path.resolve(projectDir);
|
|
223
|
+
const target = file || codexGlobalInstructionFile(home);
|
|
224
|
+
try {
|
|
225
|
+
const key = liveKey(root);
|
|
226
|
+
let raw = '';
|
|
227
|
+
try { raw = fs.readFileSync(target, 'utf8'); } catch { /* fresh */ }
|
|
228
|
+
const pruned = pruneLiveSections(raw, { keep: key, now });
|
|
229
|
+
const block = wrapLiveBlock(key, body, { at: now, projectDir: root });
|
|
230
|
+
const next = upsertLiveBlock(pruned.text, key, block);
|
|
231
|
+
if (stable(next) === stable(raw)) return { path: target, action: 'unchanged', dropped: pruned.dropped };
|
|
232
|
+
atomicWrite(target, next);
|
|
233
|
+
return { path: target, action: raw ? 'updated' : 'created', dropped: pruned.dropped };
|
|
234
|
+
} catch (e) {
|
|
235
|
+
return { path: target, action: 'failed', error: e?.message || String(e) };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Both files, never throws. The caller supplies what the hook already computed. */
|
|
240
|
+
export function projectLive({ projectDir, now = Date.now(), home = os.homedir(), version = '', peers = [], unfinishedText = '', lockNotesText = '', claudeFile = null, codexFile = null } = {}) {
|
|
241
|
+
const body = renderLiveProjection({ projectDir, now, version, peers, unfinishedText, lockNotesText });
|
|
242
|
+
return {
|
|
243
|
+
claude: writeClaudeLocal(projectDir, body, { now, file: claudeFile }),
|
|
244
|
+
codex: writeCodexGlobal(projectDir, body, { now, home, file: codexFile }),
|
|
245
|
+
bytes: Buffer.byteLength(body),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Uninstall / unlink: remove this project's block from both files; delete CLAUDE.local.md only if nothing else is in it. */
|
|
250
|
+
export function removeLiveProjection(projectDir, { home = os.homedir(), claudeFile = null } = {}) {
|
|
251
|
+
const root = path.resolve(projectDir);
|
|
252
|
+
const key = liveKey(root);
|
|
253
|
+
const out = { claude: 'unchanged', codex: 'unchanged' };
|
|
254
|
+
const local = claudeFile || path.join(root, CLAUDE_LOCAL_FILE);
|
|
255
|
+
try {
|
|
256
|
+
const raw = fs.readFileSync(local, 'utf8');
|
|
257
|
+
if (blockRe(key).test(raw)) {
|
|
258
|
+
const rest = stripLiveBlocks(raw, key);
|
|
259
|
+
if (rest.trim()) { atomicWrite(local, rest); out.claude = 'block-removed'; } else { fs.unlinkSync(local); out.claude = 'file-removed'; }
|
|
260
|
+
}
|
|
261
|
+
} catch { /* absent */ }
|
|
262
|
+
const dir = path.join(process.env.CODEX_HOME || path.join(home, '.codex'));
|
|
263
|
+
for (const f of [path.join(dir, 'AGENTS.override.md'), path.join(dir, 'AGENTS.md')]) {
|
|
264
|
+
try {
|
|
265
|
+
const raw = fs.readFileSync(f, 'utf8');
|
|
266
|
+
if (!blockRe(key).test(raw)) continue;
|
|
267
|
+
atomicWrite(f, stripLiveBlocks(raw, key));
|
|
268
|
+
out.codex = 'section-removed';
|
|
269
|
+
} catch { /* absent */ }
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Does `raw` carry this project's live block? (uninstall planning, read-only) */
|
|
275
|
+
export function hasLiveBlock(raw, projectDir) {
|
|
276
|
+
return blockRe(liveKey(projectDir)).test(String(raw || ''));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Every live section in the Codex global file (for doctor and uninstall). */
|
|
280
|
+
export function listLiveSections(raw) {
|
|
281
|
+
const text = String(raw || '');
|
|
282
|
+
return [...text.matchAll(ANY_BLOCK_RE)].map((m) => {
|
|
283
|
+
const project = /project="([^"]*)"/.exec(m[0]);
|
|
284
|
+
return { key: m[1], at: Date.parse(m[2]) || 0, project: project ? project[1] : '' };
|
|
285
|
+
});
|
|
286
|
+
}
|
package/src/uninstall.mjs
CHANGED
|
@@ -43,6 +43,13 @@ import {
|
|
|
43
43
|
removeCodexPresenceHooks,
|
|
44
44
|
resolveCodexHooksPath,
|
|
45
45
|
} from './codex-hooks.mjs';
|
|
46
|
+
import {
|
|
47
|
+
CLAUDE_LOCAL_FILE,
|
|
48
|
+
hasLiveBlock,
|
|
49
|
+
removeLiveProjection,
|
|
50
|
+
listLiveSections,
|
|
51
|
+
stripLiveBlocks,
|
|
52
|
+
} from './live-projection.mjs';
|
|
46
53
|
|
|
47
54
|
// ── the one absolute rule ────────────────────────────────────────────────────
|
|
48
55
|
// A canvas is USER DATA. install/link never created one and uninstall must never
|
|
@@ -290,6 +297,20 @@ export function planUnlink(projectDir, { sidecars = false } = {}) {
|
|
|
290
297
|
const target = { file, kind: meta.kind, wrapKey: meta.wrapKey, frontmatter: meta.frontmatter };
|
|
291
298
|
items.push({ label: r.tool, rel, file, ...target, ...planOne(target) });
|
|
292
299
|
}
|
|
300
|
+
// Live projection block (1.104.0). CLAUDE.local.md is not a managed install
|
|
301
|
+
// target — the person may own the rest of the file — so only KLYPIX's own
|
|
302
|
+
// marked block goes, and the file only when nothing else is in it. The
|
|
303
|
+
// project's section in the Codex global file goes with it.
|
|
304
|
+
{
|
|
305
|
+
const file = path.join(root, CLAUDE_LOCAL_FILE);
|
|
306
|
+
const raw = readText(file);
|
|
307
|
+
items.push({
|
|
308
|
+
label: 'Live projection (machine-local snapshot)', rel: CLAUDE_LOCAL_FILE, file, kind: 'live-block',
|
|
309
|
+
...(raw !== null && hasLiveBlock(raw, root)
|
|
310
|
+
? { action: 'strip-block', why: 'remove the KLYPIX live block (and this project\'s section in the Codex global file); your own lines stay' }
|
|
311
|
+
: { action: 'none', why: 'not present' }),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
293
314
|
if (sidecars) {
|
|
294
315
|
for (const rel of PROJECT_SIDECARS) {
|
|
295
316
|
const file = path.join(root, rel);
|
|
@@ -332,6 +353,18 @@ export function planUninstall({ home = os.homedir(), installDir, keepRegistry =
|
|
|
332
353
|
? { action: 'strip-block', why: 'remove the fenced KLYPIX block; your own guidance stays' }
|
|
333
354
|
: { action: 'none', why: 'no managed block' }),
|
|
334
355
|
});
|
|
356
|
+
// 2b) Live projection sections (1.104.0): one per project, in whichever
|
|
357
|
+
// global file Codex loads. Stripped whole; the person's own text stays.
|
|
358
|
+
for (const name of ['AGENTS.override.md', 'AGENTS.md']) {
|
|
359
|
+
const file = path.join(HOME, '.codex', name);
|
|
360
|
+
const raw = readText(file);
|
|
361
|
+
const count = raw === null ? 0 : listLiveSections(raw).length;
|
|
362
|
+
if (!count && name === 'AGENTS.override.md') continue;
|
|
363
|
+
items.push({
|
|
364
|
+
label: 'Live projection sections (Codex global)', rel: `.codex/${name}`, file, kind: 'live-sections',
|
|
365
|
+
...(count ? { action: 'strip-block', why: `remove ${count} machine-local KLYPIX live section(s); your own guidance stays` } : { action: 'none', why: 'no live section' }),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
335
368
|
if (codexGlobalInstructionsInstalled(HOME)) {
|
|
336
369
|
notes.push('~/.codex/AGENTS.md carries no version/hash stamp, so an edit you made INSIDE the KLYPIX block cannot be detected — the .klypix-bak backup is your only copy of it.');
|
|
337
370
|
}
|
|
@@ -432,8 +465,28 @@ function applyOne(item, { root } = {}) {
|
|
|
432
465
|
: item.kind === 'codex-hooks' ? null
|
|
433
466
|
: item.kind === 'codex-agents' ? null
|
|
434
467
|
: item.kind === 'bundle' ? null
|
|
435
|
-
:
|
|
468
|
+
: item.kind === 'live-block' || item.kind === 'live-sections' ? null
|
|
469
|
+
: planOne(item);
|
|
436
470
|
try {
|
|
471
|
+
if (item.kind === 'live-block') {
|
|
472
|
+
// Both files at once: the project's CLAUDE.local.md block and its section
|
|
473
|
+
// in the Codex global file (the module owns the markers; never a blind delete).
|
|
474
|
+
const projectDir = root || path.dirname(item.file);
|
|
475
|
+
const raw = readText(item.file);
|
|
476
|
+
if (raw === null || !hasLiveBlock(raw, projectDir)) return { ok: true, action: 'none' };
|
|
477
|
+
const backup = backupFile(item.file);
|
|
478
|
+
const res = removeLiveProjection(projectDir, { claudeFile: item.file });
|
|
479
|
+
return { ok: true, action: res.claude === 'file-removed' ? 'delete-file' : res.claude === 'block-removed' ? 'strip-block' : 'none', backup };
|
|
480
|
+
}
|
|
481
|
+
if (item.kind === 'live-sections') {
|
|
482
|
+
const raw = readText(item.file);
|
|
483
|
+
if (raw === null || !listLiveSections(raw).length) return { ok: true, action: 'none' };
|
|
484
|
+
const backup = backupFile(item.file);
|
|
485
|
+
const next = stripLiveBlocks(raw);
|
|
486
|
+
if (next.trim() === '') { deleteFile(item.file); return { ok: true, action: 'delete-file', backup }; }
|
|
487
|
+
writeAtomic(item.file, next);
|
|
488
|
+
return { ok: true, action: 'strip-block', backup };
|
|
489
|
+
}
|
|
437
490
|
if (item.kind === 'codex-agents') {
|
|
438
491
|
if (!codexGlobalInstructionsInstalled(path.resolve(item.file, '..', '..'))) return { ok: true, action: 'none' };
|
|
439
492
|
const res = removeCodexGlobalInstructions(path.resolve(item.file, '..', '..'));
|