klypix-mcp 1.40.2 → 1.42.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/bin/klypix-conformance.mjs +9 -0
- package/bin/klypix-worker.mjs +19 -1
- package/package.json +3 -3
- package/src/agent-presence.mjs +135 -18
- package/src/agent-rules.mjs +22 -4
- package/src/brain-doctor.mjs +155 -13
- package/src/brain-semantic.mjs +14 -3
- package/src/codex-brain-hook.mjs +86 -5
- package/src/global-brain-hook.mjs +342 -65
- package/src/klypix-core.mjs +26 -3
- package/src/klypix-format.mjs +140 -8
- package/src/mcp-presence.mjs +138 -26
- package/src/mcp-supervisor.mjs +104 -2
|
@@ -57,6 +57,15 @@ fs.writeFileSync(path.join(vault, 'brain.klypix'), await buildKlypixMap({
|
|
|
57
57
|
const baseEnv = Object.fromEntries(
|
|
58
58
|
Object.entries(process.env).filter(([, value]) => typeof value === 'string'),
|
|
59
59
|
);
|
|
60
|
+
// The harness simulates two INDEPENDENT hosts. When it itself runs inside an
|
|
61
|
+
// agent session (Claude Code exports CLAUDE_PID + CLAUDE_CODE_SESSION_ID to
|
|
62
|
+
// children), both simulated clients would inherit the SAME host identity and
|
|
63
|
+
// be correctly treated as one session's twin halves — failing every two-client
|
|
64
|
+
// check. Strip host/session identity; each client gets its own KLYPIX_SESSION_ID.
|
|
65
|
+
for (const key of [
|
|
66
|
+
'CLAUDE_PID', 'KLYPIX_HOST_PID', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_SESSION_ID',
|
|
67
|
+
'CODEX_THREAD_ID', 'CURSOR_SESSION_ID', 'CLINE_SESSION_ID', 'WINDSURF_SESSION_ID',
|
|
68
|
+
]) delete baseEnv[key];
|
|
60
69
|
|
|
61
70
|
async function connect(name, sessionId) {
|
|
62
71
|
const logs = [];
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -33,6 +33,10 @@ import {
|
|
|
33
33
|
import { mcpServerEntry } from '../src/agent-rules.mjs';
|
|
34
34
|
import { createMcpPresence, KLYPIX_MCP_INSTRUCTIONS } from '../src/mcp-presence.mjs';
|
|
35
35
|
import { spawnAutoUpdateHelper } from '../src/mcp-auto-update.mjs';
|
|
36
|
+
// Namespace import (already in-process via the klypix-core chain, so zero added
|
|
37
|
+
// load cost) so a bundle whose klypix-format predates classifyDecay degrades
|
|
38
|
+
// gracefully — a named import of a missing export would kill the whole server.
|
|
39
|
+
import * as brainFormat from '../src/klypix-format.mjs';
|
|
36
40
|
|
|
37
41
|
// Real package version for the MCP handshake (was hardcoded '1.0.0', which
|
|
38
42
|
// misled every client/version diagnosis — it could never reflect the true release).
|
|
@@ -118,7 +122,21 @@ const server = new McpServer(
|
|
|
118
122
|
capabilities: { logging: {} },
|
|
119
123
|
},
|
|
120
124
|
);
|
|
121
|
-
|
|
125
|
+
// Decay-aware LAST-KNOWN stamps for every MCP delivery surface (2026-07-28
|
|
126
|
+
// post-mortem, class B): the classifier lives ONCE in klypix-format.mjs and is
|
|
127
|
+
// INJECTED here so mcp-presence/agent-presence stay builtin-only. The typeof
|
|
128
|
+
// guards let a bundle predating the feature degrade to unstamped delivery —
|
|
129
|
+
// no crash, no stamp, never a throw.
|
|
130
|
+
const mcpPresence = createMcpPresence({
|
|
131
|
+
server,
|
|
132
|
+
initialVault: VAULT,
|
|
133
|
+
decay: typeof brainFormat.classifyDecay === 'function' ? {
|
|
134
|
+
classifyDecay: brainFormat.classifyDecay,
|
|
135
|
+
decayStaleMs: brainFormat.DECAY_STALE_MS,
|
|
136
|
+
decayMessageStamp: typeof brainFormat.decayMessageStamp === 'function' ? brainFormat.decayMessageStamp : undefined,
|
|
137
|
+
formatDecayAge: typeof brainFormat.formatDecayAge === 'function' ? brainFormat.formatDecayAge : undefined,
|
|
138
|
+
} : {},
|
|
139
|
+
});
|
|
122
140
|
|
|
123
141
|
// Map a protocol-neutral core result → an MCP tool result.
|
|
124
142
|
const toContent = (r) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Every project gets a brain
|
|
3
|
+
"version": "1.42.0",
|
|
4
|
+
"description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Codex, Cursor, Cline, any model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"keywords": [
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"node": ">=18"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
|
-
"test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.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/canvas-view.mjs && node test/status-completeness.mjs"
|
|
61
|
+
"test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.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/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/presence-visibility.mjs"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@modelcontextprotocol/ext-apps": "^1.7.4",
|
package/src/agent-presence.mjs
CHANGED
|
@@ -113,6 +113,29 @@ function pruneMessages(messages, now) {
|
|
|
113
113
|
.filter((message) => message?.id && now - Number(message.ts || 0) < MESSAGE_FRESH_MS);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// Cap the message lane WITHOUT silently destroying undelivered notes: the old
|
|
117
|
+
// flat `.slice(-30)` evicted the OLDEST rows first regardless of delivery, so a
|
|
118
|
+
// burst of >30 messages could destroy a note nobody had seen yet. Evict
|
|
119
|
+
// delivered (seen-by-someone) messages first, oldest first; only then the
|
|
120
|
+
// oldest undelivered ones. Order of survivors is preserved.
|
|
121
|
+
export function capMessages(messages, cap = 30) {
|
|
122
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
123
|
+
if (list.length <= cap) return list;
|
|
124
|
+
let excess = list.length - cap;
|
|
125
|
+
const dropped = new Set();
|
|
126
|
+
const evict = (predicate) => {
|
|
127
|
+
for (const message of list) {
|
|
128
|
+
if (!excess) return;
|
|
129
|
+
if (dropped.has(message) || !predicate(message)) continue;
|
|
130
|
+
dropped.add(message);
|
|
131
|
+
excess--;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
evict((m) => Array.isArray(m?.seen) && m.seen.length > 0); // delivered at least once
|
|
135
|
+
evict(() => true); // still over cap → oldest of the rest
|
|
136
|
+
return list.filter((message) => !dropped.has(message));
|
|
137
|
+
}
|
|
138
|
+
|
|
116
139
|
function normalizeFiles(files) {
|
|
117
140
|
const seen = new Set();
|
|
118
141
|
const out = [];
|
|
@@ -140,11 +163,13 @@ export function upsertSession({
|
|
|
140
163
|
permissionMode = null,
|
|
141
164
|
branch = null,
|
|
142
165
|
intent,
|
|
166
|
+
intentSource = null,
|
|
143
167
|
files,
|
|
144
168
|
replaceFiles = false,
|
|
145
169
|
event = null,
|
|
146
170
|
channel = null,
|
|
147
171
|
cwd = null,
|
|
172
|
+
hostPid = null,
|
|
148
173
|
home,
|
|
149
174
|
now = Date.now(),
|
|
150
175
|
}) {
|
|
@@ -162,6 +187,14 @@ export function upsertSession({
|
|
|
162
187
|
const mergedFiles = files === undefined
|
|
163
188
|
? normalizeFiles(previous.files)
|
|
164
189
|
: normalizeFiles(replaceFiles ? files : [...(previous.files || []), ...(files || [])]);
|
|
190
|
+
// Intent freshness is its OWN timestamp: lastSeen is refreshed by heartbeats
|
|
191
|
+
// that never touch intent, so without intentAt a 100-minute-old intent renders
|
|
192
|
+
// under "active just now" (2026-07-29 audit). Stamped only when the intent
|
|
193
|
+
// VALUE actually changes; additive — old rows/readers are unaffected.
|
|
194
|
+
const nextIntent = intent !== undefined
|
|
195
|
+
? String(intent || '').replace(/\s+/g, ' ').trim().slice(0, 160)
|
|
196
|
+
: (previous.intent || '');
|
|
197
|
+
const intentChanged = intent !== undefined && nextIntent !== (previous.intent || '');
|
|
165
198
|
const next = {
|
|
166
199
|
...previous,
|
|
167
200
|
id: String(id),
|
|
@@ -172,13 +205,19 @@ export function upsertSession({
|
|
|
172
205
|
model: model ?? previous.model ?? null,
|
|
173
206
|
permissionMode: permissionMode ?? previous.permissionMode ?? null,
|
|
174
207
|
branch: branch ?? previous.branch ?? null,
|
|
175
|
-
intent:
|
|
208
|
+
intent: nextIntent,
|
|
209
|
+
...(intentChanged ? { intentAt: now, intentSource: intentSource || 'declared' }
|
|
210
|
+
: (previous.intentAt ? { intentAt: previous.intentAt, intentSource: previous.intentSource || null } : {})),
|
|
176
211
|
files: mergedFiles,
|
|
177
212
|
event: event ?? previous.event ?? null,
|
|
178
213
|
...(Object.keys(channelSeen).length
|
|
179
214
|
? { channels: Object.keys(channelSeen), channelSeen }
|
|
180
215
|
: {}),
|
|
181
216
|
cwd: cwd ? path.resolve(cwd) : (previous.cwd || path.dirname(brainPath)),
|
|
217
|
+
// Host-process correlation (e.g. Claude Code exports CLAUDE_PID to every
|
|
218
|
+
// child): lets readers recognize one logical session's lifecycle row and
|
|
219
|
+
// MCP row as TWINS instead of two independent peers.
|
|
220
|
+
hostPid: Number(hostPid) || previous.hostPid || null,
|
|
182
221
|
startedAt: previous.startedAt || now,
|
|
183
222
|
lastSeen: now,
|
|
184
223
|
};
|
|
@@ -188,7 +227,7 @@ export function upsertSession({
|
|
|
188
227
|
fs.writeFileSync(laneFile, JSON.stringify({
|
|
189
228
|
...data,
|
|
190
229
|
sessions: kept.slice(-40),
|
|
191
|
-
messages: pruneMessages(data.messages, now)
|
|
230
|
+
messages: capMessages(pruneMessages(data.messages, now), 30),
|
|
192
231
|
}));
|
|
193
232
|
return kept.sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0));
|
|
194
233
|
} finally {
|
|
@@ -226,7 +265,7 @@ export function removeSession({ brainPath, id, channel = null, home, now = Date.
|
|
|
226
265
|
fs.writeFileSync(laneFile, JSON.stringify({
|
|
227
266
|
...data,
|
|
228
267
|
sessions,
|
|
229
|
-
messages: pruneMessages(data.messages, now)
|
|
268
|
+
messages: capMessages(pruneMessages(data.messages, now), 30),
|
|
230
269
|
}));
|
|
231
270
|
return sessions;
|
|
232
271
|
} finally {
|
|
@@ -271,24 +310,45 @@ export function receiveMessages({
|
|
|
271
310
|
&& messageTargetsSession(message, me, sessionId));
|
|
272
311
|
if (!unseen.length) return [];
|
|
273
312
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
313
|
+
// Deliver-then-ack, never ack-then-truncate: the old path acked EVERY unseen
|
|
314
|
+
// message under lock but returned only the first 6 — anything past the cap
|
|
315
|
+
// was marked seen without ever being shown, permanently lost under the
|
|
316
|
+
// delivered-once contract (2026-07-29 audit). Ack exactly: the delivered 6,
|
|
317
|
+
// plus self-ignored texts and text-duplicates OF a delivered message (their
|
|
318
|
+
// content was shown once via the twin). Overflow stays unacked and arrives
|
|
319
|
+
// on the next call.
|
|
320
|
+
const normText = (message) => String(message.text || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
282
321
|
const ignored = new Set(ignoreTexts.map((text) => String(text || '').replace(/\s+/g, ' ').trim().toLowerCase()));
|
|
283
322
|
const shown = [];
|
|
323
|
+
const dropped = []; // ignored (own sends) — content intentionally never shown
|
|
284
324
|
const seenText = new Set();
|
|
325
|
+
const dupesByText = new Map();
|
|
285
326
|
for (const message of unseen) {
|
|
286
|
-
const key =
|
|
287
|
-
if (!key || ignored.has(key)
|
|
327
|
+
const key = normText(message);
|
|
328
|
+
if (!key || ignored.has(key)) { dropped.push(message); continue; }
|
|
329
|
+
if (seenText.has(key)) {
|
|
330
|
+
if (!dupesByText.has(key)) dupesByText.set(key, []);
|
|
331
|
+
dupesByText.get(key).push(message);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
288
334
|
seenText.add(key);
|
|
289
335
|
shown.push(message);
|
|
290
336
|
}
|
|
291
|
-
|
|
337
|
+
const delivered = shown.slice(0, 6);
|
|
338
|
+
const ackIds = new Set(delivered.map((message) => message.id));
|
|
339
|
+
for (const message of dropped) ackIds.add(message.id);
|
|
340
|
+
for (const message of delivered) {
|
|
341
|
+
for (const dupe of dupesByText.get(normText(message)) || []) ackIds.add(dupe.id);
|
|
342
|
+
}
|
|
343
|
+
if (ackIds.size) {
|
|
344
|
+
for (const message of messages) {
|
|
345
|
+
if (!ackIds.has(message.id)) continue;
|
|
346
|
+
if (!Array.isArray(message.seen)) message.seen = [];
|
|
347
|
+
if (!message.seen.includes(sessionId)) message.seen.push(sessionId);
|
|
348
|
+
}
|
|
349
|
+
fs.writeFileSync(laneFile, JSON.stringify({ ...data, sessions, messages }));
|
|
350
|
+
}
|
|
351
|
+
return delivered;
|
|
292
352
|
} finally {
|
|
293
353
|
if (gotLock) releaseLock(lockFile);
|
|
294
354
|
}
|
|
@@ -368,7 +428,7 @@ export function postPresenceMessage({
|
|
|
368
428
|
fs.writeFileSync(laneFile, JSON.stringify({
|
|
369
429
|
...data,
|
|
370
430
|
sessions,
|
|
371
|
-
messages: messages
|
|
431
|
+
messages: capMessages(messages, 30),
|
|
372
432
|
}));
|
|
373
433
|
return { posted: true, message };
|
|
374
434
|
} finally {
|
|
@@ -405,24 +465,81 @@ export function formatPresenceMessage(sessions, selfId, { includeSolo = false, n
|
|
|
405
465
|
lines.push('Other active sessions:');
|
|
406
466
|
for (const session of others.slice(0, 8)) {
|
|
407
467
|
const ageMin = Math.max(0, Math.round((now - Number(session.lastSeen || now)) / 60_000));
|
|
468
|
+
// A heartbeat refreshes lastSeen while carrying an old intent forward — show
|
|
469
|
+
// the INTENT's own age when it meaningfully lags the heartbeat, so a
|
|
470
|
+
// 100-minute-old task line can never read as "what they're doing right now".
|
|
471
|
+
const intentAgeMin = session.intentAt ? Math.max(0, Math.round((now - Number(session.intentAt)) / 60_000)) : null;
|
|
472
|
+
const intentAge = intentAgeMin !== null && intentAgeMin - ageMin > 3 ? ` (intent set ${intentAgeMin}m ago)` : '';
|
|
408
473
|
const details = [
|
|
409
474
|
clientLabel(session),
|
|
410
475
|
session.branch ? `branch ${session.branch}` : null,
|
|
411
|
-
session.intent ? `"${String(session.intent).slice(0, 90)}"` : null,
|
|
476
|
+
session.intent ? `"${String(session.intent).slice(0, 90)}"${intentAge}` : null,
|
|
412
477
|
`${ageMin}m ago`,
|
|
413
478
|
].filter(Boolean);
|
|
414
479
|
lines.push(`- ${String(session.id).slice(0, 8)}: ${details.join(' | ')}`);
|
|
415
480
|
}
|
|
416
|
-
|
|
481
|
+
// v1.32.0 law: a truncated list must never render as a complete one.
|
|
482
|
+
if (others.length > 8) lines.push(`- …and ${others.length - 8} more live session(s) not listed — brain_doctor shows all.`);
|
|
483
|
+
lines.push(`Presence is point-in-time (as of ${new Date(now).toISOString().slice(11, 16)}Z) — re-check before coordinating; use brain_message for a targeted note.`);
|
|
417
484
|
return lines.join('\n');
|
|
418
485
|
}
|
|
419
486
|
|
|
420
|
-
|
|
487
|
+
// ── Decay-aware LAST-KNOWN stamps (2026-07-28 post-mortem, class B) ──────────
|
|
488
|
+
// A delivered inter-session message is MEMORY, not a SENSOR: one older than 6h
|
|
489
|
+
// whose text asserts fast-decay build/deploy status ("no TestFlight upload
|
|
490
|
+
// triggered yet") must never read as CURRENT state — the ENGINE stamps it,
|
|
491
|
+
// never the reading model. The classifier lives ONCE in klypix-format.mjs;
|
|
492
|
+
// this file stays builtin-only, so consumers INJECT it (the optional third
|
|
493
|
+
// `decay` argument). With no injection the output is byte-identical to the
|
|
494
|
+
// unstamped form — an old bundle degrades to no stamp, never a throw. Each
|
|
495
|
+
// stamp is its OWN line appended AFTER the 400-char render slice (the v1.32.0
|
|
496
|
+
// law: a warning is never subject to the budget/cut it warns about) and exists
|
|
497
|
+
// in render output only — the lane file is never mutated.
|
|
498
|
+
const MSG_DECAY_STAMP_MS = 6 * 60 * 60 * 1000;
|
|
499
|
+
// classifyDecay is precision-first; mirror that here — only an explicit `true`
|
|
500
|
+
// or an explicitly fast-shaped object stamps. Anything ambiguous does NOT
|
|
501
|
+
// (a false stamp erodes trust in every stamp).
|
|
502
|
+
const isFastDecayResult = (r) => r === true || r === 'fast'
|
|
503
|
+
|| (!!r && typeof r === 'object' && (r.fast === true || r.fastDecay === true || r.decay === 'fast' || r.class === 'fast' || r.kind === 'fast'));
|
|
504
|
+
const decayAgeLabel = (ms) => {
|
|
505
|
+
const h = Math.floor(Math.max(0, ms) / 3_600_000);
|
|
506
|
+
return h >= 48 ? `${Math.floor(h / 24)}d` : `${Math.max(1, h)}h`;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// Decay verdict for ONE delivered message: null, or { age, stampText } when it
|
|
510
|
+
// is stale (ts older than the engine threshold) AND its RAW text classifies
|
|
511
|
+
// fast-decay (raw, not the render slice — a claim cut out of the 400 chars
|
|
512
|
+
// must still stamp). Threshold, wording, and age format come from the injected
|
|
513
|
+
// engine surface ({ classifyDecay, decayStaleMs, decayMessageStamp,
|
|
514
|
+
// formatDecayAge }) so the renderers can never drift apart; the local
|
|
515
|
+
// fallbacks mirror klypix-format verbatim for a bundle old enough to carry the
|
|
516
|
+
// classifier but not the helpers.
|
|
517
|
+
export function messageDecayInfo(message, now = Date.now(), decay = {}) {
|
|
518
|
+
const { classifyDecay, decayStaleMs, decayMessageStamp, formatDecayAge } = decay || {};
|
|
519
|
+
if (typeof classifyDecay !== 'function') return null;
|
|
520
|
+
try {
|
|
521
|
+
const ts = Number(message?.ts) || 0;
|
|
522
|
+
const staleMs = Number(decayStaleMs) > 0 ? Number(decayStaleMs) : MSG_DECAY_STAMP_MS;
|
|
523
|
+
if (!ts || now - ts < staleMs) return null;
|
|
524
|
+
if (!isFastDecayResult(classifyDecay(String(message?.text || '')))) return null;
|
|
525
|
+
const ageMs = now - ts;
|
|
526
|
+
return {
|
|
527
|
+
age: typeof formatDecayAge === 'function' ? String(formatDecayAge(ageMs)) : decayAgeLabel(ageMs),
|
|
528
|
+
stampText: typeof decayMessageStamp === 'function'
|
|
529
|
+
? String(decayMessageStamp(ageMs))
|
|
530
|
+
: `⏱️ This message is ${decayAgeLabel(ageMs)} old and contains build/deploy status — treat as LAST KNOWN, verify live before reporting it.`,
|
|
531
|
+
};
|
|
532
|
+
} catch { return null; } // stamping is best-effort — a classifier bug must never break delivery
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function formatReceivedMessages(messages, now = Date.now(), decay = {}) {
|
|
421
536
|
if (!Array.isArray(messages) || !messages.length) return '';
|
|
422
537
|
const lines = ['KLYPIX message(s) from another active session:'];
|
|
423
538
|
for (const message of messages) {
|
|
424
539
|
const ageMin = Math.max(0, Math.round((now - Number(message.ts || now)) / 60_000));
|
|
425
540
|
lines.push(`- from ${String(message.from || '?').slice(0, 12)} (${ageMin}m ago): ${String(message.text || '').replace(/\s+/g, ' ').trim().slice(0, 400)}`);
|
|
541
|
+
const info = messageDecayInfo(message, now, decay);
|
|
542
|
+
if (info) lines.push(` ${info.stampText}`);
|
|
426
543
|
}
|
|
427
544
|
return lines.join('\n');
|
|
428
545
|
}
|
package/src/agent-rules.mjs
CHANGED
|
@@ -521,8 +521,15 @@ function classifyFenced(file, version, frontmatter = null) {
|
|
|
521
521
|
const raw = fs.readFileSync(file, 'utf8');
|
|
522
522
|
const fence = parseFence(raw);
|
|
523
523
|
if (!fence) return { status: 'missing' };
|
|
524
|
-
|
|
525
|
-
|
|
524
|
+
// Normalize CRLF before hashing (matching norm() in the frontmatter branch
|
|
525
|
+
// below): the stamped hash is always computed from the LF-only canonical
|
|
526
|
+
// instructions, so a git-autocrlf checkout or CRLF-saving editor flipped
|
|
527
|
+
// EVERY projected file to hand-edited at once — the destructive-repair class
|
|
528
|
+
// — when the true status was a losslessly healable 'stale' (2026-07-29 audit,
|
|
529
|
+
// the likely cause of "9/14 HAND-EDITED at the same stamp").
|
|
530
|
+
const bodyHash = sha8(fence.body.replace(/\r\n/g, '\n').trim());
|
|
531
|
+
if (fence.hash && bodyHash !== fence.hash) return { status: 'hand-edited', stampedVersion: fence.version };
|
|
532
|
+
const bodyCurrent = bodyHash === INSTRUCTIONS_HASH;
|
|
526
533
|
const stampConsistent = !fence.hash || fence.hash === INSTRUCTIONS_HASH;
|
|
527
534
|
if (bodyCurrent && stampConsistent) {
|
|
528
535
|
// Owned dedicated files are OURS wholesale — frontmatter included. Compare the
|
|
@@ -541,11 +548,20 @@ function classifyFenced(file, version, frontmatter = null) {
|
|
|
541
548
|
|
|
542
549
|
// Shared markdown (AGENTS.md, copilot-instructions.md, GEMINI.md): merge our fenced
|
|
543
550
|
// block in place, preserving the user's own prose outside the fence.
|
|
551
|
+
// A hand-edited file is about to be rebuilt wholesale — keep the human's version.
|
|
552
|
+
// One rolling .klypix-bak per file (repair stops being an all-or-nothing choice).
|
|
553
|
+
function backupHandEdited(file, status) {
|
|
554
|
+
if (status !== 'hand-edited' || !exists(file)) return;
|
|
555
|
+
try { fs.copyFileSync(file, file + '.klypix-bak'); } catch { /* best-effort */ }
|
|
556
|
+
}
|
|
557
|
+
|
|
544
558
|
function fenceMerge(file, version) {
|
|
545
559
|
// Zero-touch IFF the audit says 'ok' (same classifier, so check and write can
|
|
546
560
|
// never disagree): current+consistent block → byte-identical file, no stamp-only
|
|
547
561
|
// churn; a hand-edited stamp or stale body falls through and gets rebuilt.
|
|
548
|
-
|
|
562
|
+
const status = classifyFenced(file, version).status;
|
|
563
|
+
if (status === 'ok') return { action: 'unchanged' };
|
|
564
|
+
backupHandEdited(file, status);
|
|
549
565
|
let cur = '';
|
|
550
566
|
let had = false;
|
|
551
567
|
if (exists(file)) { cur = fs.readFileSync(file, 'utf8'); had = true; }
|
|
@@ -564,7 +580,9 @@ function fenceMerge(file, version) {
|
|
|
564
580
|
function writeDedicated(file, frontmatter, version) {
|
|
565
581
|
// Zero-touch IFF the audit says 'ok' — which for owned dedicated files includes
|
|
566
582
|
// the frontmatter (a stripped alwaysApply/trigger MUST be repaired, not skipped).
|
|
567
|
-
|
|
583
|
+
const status = classifyFenced(file, version, frontmatter || '').status;
|
|
584
|
+
if (status === 'ok') return { action: 'unchanged' };
|
|
585
|
+
backupHandEdited(file, status);
|
|
568
586
|
const had = exists(file);
|
|
569
587
|
const body = (frontmatter ? frontmatter + '\n' : '') + fencedBlock(version) + '\n';
|
|
570
588
|
ensureDir(file); fs.writeFileSync(file, body, 'utf8');
|