@compr/opscontext-mcp 2.10.0 → 2.12.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/CHANGELOG.md +81 -1
- package/dist/audit.d.ts +80 -3
- package/dist/audit.js +735 -96
- package/dist/cli.js +67 -5
- package/dist/firewall.js +17 -7
- package/dist/fleet-health.d.ts +23 -0
- package/dist/fleet-health.js +56 -0
- package/dist/http-server.js +12 -2
- package/dist/index.js +85 -4
- package/dist/learnings.d.ts +3 -0
- package/dist/learnings.js +100 -6
- package/dist/secret-shapes.js +7 -4
- package/dist/server-registry.d.ts +3 -0
- package/dist/server-registry.js +67 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -651,7 +651,7 @@ import { listLearnings, parseSince, learningsToChunks, learningsStats, formatLea
|
|
|
651
651
|
import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
|
|
652
652
|
import { activate, deactivate, getActivationStatus, gateCheckFresh, } from "./activation.js";
|
|
653
653
|
import { syncTierA, syncTierB, loadCommunityStore, communityRulesToChunks, mergeWithDedup, STORE_PATH as COMMUNITY_STORE_PATH, } from "./community-sync.js";
|
|
654
|
-
import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, acknowledgeRedaction, restoreSegment, scrubAuditLog, } from "./audit.js";
|
|
654
|
+
import { readAuditLog, verifyChain, filterByRange, toCsv, rotateAuditLog, planRotation, listSegments, acknowledgeRedaction, restoreSegment, scrubAuditLog, recordVerifyState, acquireVerifyLock, } from "./audit.js";
|
|
655
655
|
import { redactPayload, redactChunk } from "./secret-shapes.js";
|
|
656
656
|
import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationErrors, repoPolicyPath, } from "./policy.js";
|
|
657
657
|
import { buildCostReport } from "./cost-report.js";
|
|
@@ -1751,7 +1751,16 @@ safeAppend), visible via 'contextengine audit-verify' and consumed by the
|
|
|
1751
1751
|
console.error(`❌ ${refused}`);
|
|
1752
1752
|
process.exit(1);
|
|
1753
1753
|
}
|
|
1754
|
-
|
|
1754
|
+
// [LOCKED] [RECEIVER-SAYS-WHAT-WAS-WRITTEN] - 2026-09-27
|
|
1755
|
+
// [NEVER] report "Appended" or exit 0 for an entry the audit log refused.
|
|
1756
|
+
// WHY: on a stuck log (a record cut short by a full disk) this printed "Appended vscode.tool_call to
|
|
1757
|
+
// audit log." and exited 0 while nothing landed (E2E_REVIEW_2026-09 B1-1); the VS Code
|
|
1758
|
+
// extension emits through this command and believed it.
|
|
1759
|
+
// FIX: safeAppend() returns whether it wrote; a refusal exits 1. [LOCK] [A-REFUSED-APPEND-IS-COUNTED-AND-CHAINED]
|
|
1760
|
+
if (!safeAppend(eventKind, prepareCapturedPayload(payload, eventKind), actor)) {
|
|
1761
|
+
console.error(`❌ Not recorded: the audit log refused ${eventKind} (reason above). The refusal is counted and will be noted on the chain.`);
|
|
1762
|
+
process.exit(1);
|
|
1763
|
+
}
|
|
1755
1764
|
console.log(`✅ Appended ${eventKind} to audit log.`);
|
|
1756
1765
|
}
|
|
1757
1766
|
async function cliWatch(args) {
|
|
@@ -2129,14 +2138,50 @@ function cliAuditRedactAck(args) {
|
|
|
2129
2138
|
console.log(` audit-verify now: ${after.ok ? "OK" : "FAILED"}, ${(after.redactedIndices ?? []).length} redacted, ${(after.tamperedIndices ?? []).length} altered.`);
|
|
2130
2139
|
}
|
|
2131
2140
|
async function cliAuditVerify() {
|
|
2141
|
+
// [LOCK] [HEALTH-SEES-THE-CHAIN]: every full check leaves its result for fleet health. The
|
|
2142
|
+
// daily one (--scheduled, started by the indexing server) runs one at a time and prints nothing.
|
|
2143
|
+
const scheduled = process.argv.includes("--scheduled");
|
|
2144
|
+
let releaseVerifyLock = null;
|
|
2145
|
+
if (scheduled) {
|
|
2146
|
+
releaseVerifyLock = acquireVerifyLock();
|
|
2147
|
+
if (!releaseVerifyLock)
|
|
2148
|
+
return; // another check is running
|
|
2149
|
+
}
|
|
2150
|
+
const t0 = Date.now();
|
|
2132
2151
|
const report = verifyChain();
|
|
2152
|
+
recordVerifyState(report, Date.now() - t0, scheduled ? "scheduled" : "cli");
|
|
2153
|
+
if (scheduled) {
|
|
2154
|
+
releaseVerifyLock?.();
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2133
2157
|
const forks = report.forkIndices ?? [];
|
|
2134
2158
|
const redacted = report.redactedIndices ?? [];
|
|
2159
|
+
const dups = report.duplicateIndices ?? [];
|
|
2160
|
+
// [LOCK] [VERIFY-FORK-IS-NOT-TAMPER]: a second copy of a record is counted once and named as a copy.
|
|
2161
|
+
const copiesNote = () => {
|
|
2162
|
+
if (dups.length === 0)
|
|
2163
|
+
return;
|
|
2164
|
+
console.log(`\n⚠️ ${dups.length} record(s) appear twice (a second copy of a record already in the log).`);
|
|
2165
|
+
console.log(` Counted once. Content intact, nothing missing. Usually a log trim that was interrupted or`);
|
|
2166
|
+
console.log(` ran while entries arrived. At: ${dups.slice(0, 8).join(", ")}${dups.length > 8 ? `, … (+${dups.length - 8} more)` : ""}`);
|
|
2167
|
+
};
|
|
2168
|
+
// [LOCK] [REDACTION-IS-A-CHAINED-RECORD]: an acknowledgement is a statement by whoever ran it; show each one used.
|
|
2169
|
+
const acksNote = (log) => {
|
|
2170
|
+
const acks = report.acknowledgements ?? [];
|
|
2171
|
+
if (acks.length === 0)
|
|
2172
|
+
return;
|
|
2173
|
+
log(` Acknowledged by (a statement by whoever ran it; the chain cannot tell a removal from a rewrite):`);
|
|
2174
|
+
for (const a of acks.slice(0, 10))
|
|
2175
|
+
log(` ${a.ts.slice(0, 19).replace("T", " ")}Z ${a.actor} ${a.records} record(s) "${a.reason.slice(0, 80)}"`);
|
|
2176
|
+
if (acks.length > 10)
|
|
2177
|
+
log(` … (+${acks.length - 10} more)`);
|
|
2178
|
+
};
|
|
2135
2179
|
if (report.ok) {
|
|
2136
|
-
console.log(`✅ Audit chain verified — ${report.total} record(s).`);
|
|
2180
|
+
console.log(`✅ Audit chain verified — ${report.total - dups.length} record(s).`);
|
|
2137
2181
|
console.log(redacted.length === 0
|
|
2138
2182
|
? ` No record was altered, and no history is missing.`
|
|
2139
2183
|
: ` No history is missing. ${redacted.length} record(s) redacted and acknowledged on the chain (indices ${redacted.slice(0, 8).join(", ")}${redacted.length > 8 ? ", …" : ""}), 0 altered.`);
|
|
2184
|
+
acksNote((x) => console.log(x));
|
|
2140
2185
|
if (forks.length > 0) {
|
|
2141
2186
|
// [VERIFY-FORK-IS-NOT-TAMPER] — surface this, but do not call it tampering.
|
|
2142
2187
|
console.log(`\n⚠️ ${forks.length} concurrent-append fork(s) detected (not tampering).`);
|
|
@@ -2146,6 +2191,7 @@ async function cliAuditVerify() {
|
|
|
2146
2191
|
console.log(` property holds. Do NOT rewrite the log to linearise it — that would destroy`);
|
|
2147
2192
|
console.log(` the evidence it exists to provide.`);
|
|
2148
2193
|
}
|
|
2194
|
+
copiesNote();
|
|
2149
2195
|
return;
|
|
2150
2196
|
}
|
|
2151
2197
|
console.error(`❌ Audit chain FAILED — ${report.total} record(s) checked.`);
|
|
@@ -2155,11 +2201,24 @@ async function cliAuditVerify() {
|
|
|
2155
2201
|
console.error(`\n Altered records (content does not match its own hash):`);
|
|
2156
2202
|
console.error(` ${t.slice(0, 10).join(", ")}${t.length > 10 ? `, … (+${t.length - 10} more)` : ""}`);
|
|
2157
2203
|
console.error(` This is tampering: the record's bytes were changed after it was written.`);
|
|
2158
|
-
console.error(` If this was a deliberate redaction of a secret, acknowledge it on the chain
|
|
2159
|
-
console.error(`
|
|
2204
|
+
console.error(` If this was a deliberate redaction of a secret, acknowledge it on the chain`);
|
|
2205
|
+
console.error(` (every altered index is listed, so the command can be pasted as it is):`);
|
|
2206
|
+
console.error(` contextengine audit-redact-ack --index ${t.join(",")} --reason "<what was removed and why>"`);
|
|
2160
2207
|
}
|
|
2161
2208
|
if (redacted.length > 0) {
|
|
2162
2209
|
console.error(`\n Also ${redacted.length} redacted record(s), acknowledged on the chain, not counted above.`);
|
|
2210
|
+
acksNote((x) => console.error(x));
|
|
2211
|
+
}
|
|
2212
|
+
// [LOCK] [VERIFY-READS-PAST-AN-UNREADABLE-LINE]: say where, and that the rest was checked.
|
|
2213
|
+
const unreadable = report.unreadable ?? [];
|
|
2214
|
+
if (unreadable.length > 0) {
|
|
2215
|
+
console.error(`\n Lines that are not records (every other record was checked):`);
|
|
2216
|
+
for (const u of unreadable.slice(0, 10))
|
|
2217
|
+
console.error(` ${u.file} line ${u.line}`);
|
|
2218
|
+
if (unreadable.length > 10)
|
|
2219
|
+
console.error(` … (+${unreadable.length - 10} more)`);
|
|
2220
|
+
console.error(` A last record cut short by a full disk is set aside by the next append on its own;`);
|
|
2221
|
+
console.error(` a line like these is damage: keep a copy of the file before touching it.`);
|
|
2163
2222
|
}
|
|
2164
2223
|
if ((report.orphanIndices ?? []).length > 0) {
|
|
2165
2224
|
const o = report.orphanIndices;
|
|
@@ -2172,6 +2231,9 @@ async function cliAuditVerify() {
|
|
|
2172
2231
|
if (forks.length > 0) {
|
|
2173
2232
|
console.error(`\n (Also ${forks.length} concurrent-append fork(s) — benign, see docs.)`);
|
|
2174
2233
|
}
|
|
2234
|
+
if (dups.length > 0) {
|
|
2235
|
+
console.error(`\n (Also ${dups.length} second copies of records, counted once, benign.)`);
|
|
2236
|
+
}
|
|
2175
2237
|
console.error(`\nFor compliance-graded evidence, treat the affected records as unverified.`);
|
|
2176
2238
|
process.exit(2);
|
|
2177
2239
|
}
|
package/dist/firewall.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// This is the first MCP server that enforces agent behavior
|
|
13
13
|
// through progressive response degradation.
|
|
14
|
-
import { execSync } from "child_process";
|
|
14
|
+
import { execSync, execFileSync } from "child_process";
|
|
15
15
|
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from "fs";
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
import { homedir } from "os";
|
|
@@ -513,19 +513,29 @@ export class ProtocolFirewall {
|
|
|
513
513
|
this.gitCache.timestamp = now;
|
|
514
514
|
this.gitCache.data = [];
|
|
515
515
|
for (const dir of this.projectDirs.slice(0, 5)) {
|
|
516
|
+
// [LOCKED] [GIT-FAILURE-IS-UNKNOWN-NOT-CLEAN] - 2026-09-27 (Yan's GO for this one line, file under DO NOT RE-AUDIT)
|
|
517
|
+
// [NEVER] count dirty files through a shell pipe that turns a failed git into "0".
|
|
518
|
+
// WHY: `git status --porcelain 2>/dev/null | wc -l` printed 0 when git failed, so a repository
|
|
519
|
+
// git could not read (a corrupt index: exit 128, 8 changed files) was reported "Git: ok,
|
|
520
|
+
// clean" (E2E_REVIEW_2026-09 B6-2). [EXEC-FAILURE-IS-NOT-EMPTY] fixed the same shape in agents.ts.
|
|
521
|
+
// FIX: git by argument list, lines counted here; a folder that is not a repository is skipped as
|
|
522
|
+
// before; any other failure (unreadable repo, timeout) is reported as unknown, never clean.
|
|
516
523
|
try {
|
|
517
|
-
const out =
|
|
524
|
+
const out = execFileSync("git", ["status", "--porcelain"], {
|
|
518
525
|
cwd: dir.path,
|
|
519
526
|
encoding: "utf-8",
|
|
520
527
|
timeout: 3000,
|
|
521
|
-
stdio: ["
|
|
522
|
-
})
|
|
523
|
-
const n =
|
|
528
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
529
|
+
});
|
|
530
|
+
const n = out.split("\n").filter(Boolean).length;
|
|
524
531
|
if (n > 0)
|
|
525
532
|
this.gitCache.data.push(`${dir.name}(${n})`);
|
|
526
533
|
}
|
|
527
|
-
catch {
|
|
528
|
-
|
|
534
|
+
catch (e) {
|
|
535
|
+
const stderr = String(e.stderr ?? "");
|
|
536
|
+
if (/not a git repository/i.test(stderr))
|
|
537
|
+
continue;
|
|
538
|
+
this.gitCache.data.push(`${dir.name}(unknown: git could not read it)`);
|
|
529
539
|
}
|
|
530
540
|
}
|
|
531
541
|
}
|
package/dist/fleet-health.d.ts
CHANGED
|
@@ -45,6 +45,27 @@ export interface FleetHealth {
|
|
|
45
45
|
detail: string;
|
|
46
46
|
}>;
|
|
47
47
|
};
|
|
48
|
+
/** The last full check of the audit chain (`audit-verify`, run by hand or daily by the indexing
|
|
49
|
+
* server), or null when it never ran here. [LOCK] [HEALTH-SEES-THE-CHAIN] */
|
|
50
|
+
chain: {
|
|
51
|
+
checkedAt: string;
|
|
52
|
+
ageHours: number;
|
|
53
|
+
ok: boolean;
|
|
54
|
+
unique: number;
|
|
55
|
+
altered: number;
|
|
56
|
+
orphans: number;
|
|
57
|
+
unreadable: number;
|
|
58
|
+
duplicates: number;
|
|
59
|
+
reason: string | null;
|
|
60
|
+
} | null;
|
|
61
|
+
/** Entries the audit log refused today (chained as audit.append_failed, or still pending), and
|
|
62
|
+
* records cut short today (audit.torn_tail). */
|
|
63
|
+
auditLog: {
|
|
64
|
+
refusedToday: number;
|
|
65
|
+
lastRefusal: string | null;
|
|
66
|
+
tornToday: number;
|
|
67
|
+
lastTornKept: string | null;
|
|
68
|
+
};
|
|
48
69
|
/** The newest release for which verify-release passed on this machine, or null. */
|
|
49
70
|
lastVerifiedRelease: string | null;
|
|
50
71
|
/** Measured problems only. Empty means green. */
|
|
@@ -56,6 +77,8 @@ export declare const REINDEX_PER_HOUR_WARN = 30;
|
|
|
56
77
|
export declare const DOUBLED_HOOK_EVENTS_WARN_PCT = 5;
|
|
57
78
|
export declare const DOUBLED_MIN_EVENTS = 10;
|
|
58
79
|
export declare const DOUBLED_WINDOW_MS = 2000;
|
|
80
|
+
/** A full check older than this is reported: the indexing server runs one a day. */
|
|
81
|
+
export declare const CHAIN_CHECK_STALE_HOURS = 48;
|
|
59
82
|
export declare function fleetHealthPath(): string;
|
|
60
83
|
/** The highest version with a verify-release marker; version order, not file time (ties). */
|
|
61
84
|
export declare function lastVerifiedRelease(dir?: string): string | null;
|
package/dist/fleet-health.js
CHANGED
|
@@ -13,6 +13,7 @@ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, readdi
|
|
|
13
13
|
import { join } from "path";
|
|
14
14
|
import { homedir } from "os";
|
|
15
15
|
import { listServers } from "./server-registry.js";
|
|
16
|
+
import { readVerifyState, pendingRefusals } from "./audit.js";
|
|
16
17
|
import { claudeHookRegistrations } from "./install-claude-hook.js";
|
|
17
18
|
export const REINDEX_PER_HOUR_WARN = 30;
|
|
18
19
|
/** Doubled hook events above this share of the day's hook events raise a warning, once the day
|
|
@@ -20,6 +21,8 @@ export const REINDEX_PER_HOUR_WARN = 30;
|
|
|
20
21
|
export const DOUBLED_HOOK_EVENTS_WARN_PCT = 5;
|
|
21
22
|
export const DOUBLED_MIN_EVENTS = 10;
|
|
22
23
|
export const DOUBLED_WINDOW_MS = 2000;
|
|
24
|
+
/** A full check older than this is reported: the indexing server runs one a day. */
|
|
25
|
+
export const CHAIN_CHECK_STALE_HOURS = 48;
|
|
23
26
|
const TAIL_BYTES = 8 * 1024 * 1024;
|
|
24
27
|
function ceHome() {
|
|
25
28
|
return process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine");
|
|
@@ -109,6 +112,8 @@ export function computeFleetHealth(opts = {}) {
|
|
|
109
112
|
let lastHourWrites = 0, blocks = 0, refusals = 0, learningsSaved = 0, hookEvents = 0, doubledHookEvents = 0;
|
|
110
113
|
const lastBlocks = [];
|
|
111
114
|
let prevHook = null;
|
|
115
|
+
let refusedToday = 0, tornToday = 0;
|
|
116
|
+
let lastRefusal = null, lastTornKept = null;
|
|
112
117
|
for (const r of records) {
|
|
113
118
|
const t = Date.parse(r.ts);
|
|
114
119
|
if (Number.isNaN(t))
|
|
@@ -127,6 +132,16 @@ export function computeFleetHealth(opts = {}) {
|
|
|
127
132
|
}
|
|
128
133
|
if (t < midnight)
|
|
129
134
|
continue;
|
|
135
|
+
if (r.event === "audit.append_failed") {
|
|
136
|
+
refusedToday += Number(r.payload?.count ?? 0) || 0;
|
|
137
|
+
const errs = r.payload?.errors;
|
|
138
|
+
if (errs && Object.keys(errs).length > 0)
|
|
139
|
+
lastRefusal = Object.keys(errs)[0];
|
|
140
|
+
}
|
|
141
|
+
if (r.event === "audit.torn_tail") {
|
|
142
|
+
tornToday++;
|
|
143
|
+
lastTornKept = String(r.payload?.kept ?? "");
|
|
144
|
+
}
|
|
130
145
|
if (r.event === "hook.block") {
|
|
131
146
|
blocks++;
|
|
132
147
|
lastBlocks.push({ ts: r.ts, kind: "pre-commit", detail: blockDetail(r.payload) });
|
|
@@ -161,6 +176,42 @@ export function computeFleetHealth(opts = {}) {
|
|
|
161
176
|
warnings.push(`${lastHourWrites} shared-index writes in the last hour (ceiling ${REINDEX_PER_HOUR_WARN}): something saves in a loop`);
|
|
162
177
|
if (refusals > 0)
|
|
163
178
|
warnings.push(`${refusals} learnings-store refusal(s) today: a write looked like a wipe or a runaway import`);
|
|
179
|
+
// [LOCKED] [HEALTH-SEES-THE-CHAIN] - 2026-09-27
|
|
180
|
+
// [NEVER] report green while the audit chain failed its last check, or while the log refuses entries.
|
|
181
|
+
// WHY: fleet health read the audit log for counts only. On three broken logs (a stuck log whose
|
|
182
|
+
// appends were all refused, missing history, 100 altered records) the verifier said FAILED
|
|
183
|
+
// and health said 0 warnings (E2E_REVIEW_2026-09 B6-1). The chain is the product's claim.
|
|
184
|
+
// FIX: health never runs the check (26 s and 3.5 GB on 4.9M records, measured): it reads the
|
|
185
|
+
// result every `audit-verify` leaves, which the indexing server refreshes daily in a separate
|
|
186
|
+
// low-priority process. A failed check, a check older than CHAIN_CHECK_STALE_HOURS, refused
|
|
187
|
+
// entries (chained as audit.append_failed or still pending) and records cut short today are
|
|
188
|
+
// each a warning. [LOCK] [A-REFUSED-APPEND-IS-COUNTED-AND-CHAINED] [LOCK] [TORN-TAIL-IS-KEPT-AND-CHAINED]
|
|
189
|
+
const vs = readVerifyState();
|
|
190
|
+
const chain = vs
|
|
191
|
+
? {
|
|
192
|
+
checkedAt: vs.checkedAt,
|
|
193
|
+
ageHours: Math.max(0, Math.round((now.getTime() - Date.parse(vs.checkedAt)) / 3_600_000)),
|
|
194
|
+
ok: vs.ok,
|
|
195
|
+
unique: vs.unique,
|
|
196
|
+
altered: vs.altered,
|
|
197
|
+
orphans: vs.orphans,
|
|
198
|
+
unreadable: vs.unreadable,
|
|
199
|
+
duplicates: vs.duplicates,
|
|
200
|
+
reason: vs.reason,
|
|
201
|
+
}
|
|
202
|
+
: null;
|
|
203
|
+
if (chain && !chain.ok)
|
|
204
|
+
warnings.push(`the audit chain did not verify at its last check (${chain.checkedAt.slice(0, 16).replace("T", " ")}Z): ${chain.reason ?? "see contextengine audit-verify"}`);
|
|
205
|
+
else if (chain && chain.ageHours > CHAIN_CHECK_STALE_HOURS)
|
|
206
|
+
warnings.push(`the audit chain was last checked ${chain.ageHours} h ago: run contextengine audit-verify`);
|
|
207
|
+
const pending = pendingRefusals();
|
|
208
|
+
if (pending.count > 0 && pending.error)
|
|
209
|
+
lastRefusal = pending.error; // the newest refusal wins
|
|
210
|
+
const refusedTotal = refusedToday + pending.count;
|
|
211
|
+
if (refusedTotal > 0)
|
|
212
|
+
warnings.push(`the audit log refused ${refusedTotal} entr${refusedTotal === 1 ? "y" : "ies"} today${lastRefusal ? ` (${lastRefusal})` : ""}: they are not in the log, and the gap is noted on the chain`);
|
|
213
|
+
if (tornToday > 0)
|
|
214
|
+
warnings.push(`${tornToday} audit record(s) were cut short today (a full disk?); the bytes are kept in ${lastTornKept}`);
|
|
164
215
|
for (const w of report.warnings)
|
|
165
216
|
if (/index on their own/.test(w))
|
|
166
217
|
warnings.push(w);
|
|
@@ -172,6 +223,8 @@ export function computeFleetHealth(opts = {}) {
|
|
|
172
223
|
reindex: { lastHourWrites, perCorpus, threshold: REINDEX_PER_HOUR_WARN },
|
|
173
224
|
claudeHooks,
|
|
174
225
|
today: { hookEvents, doubledHookEvents, blocks, refusals, learningsSaved, lastBlocks: lastBlocks.slice(-3) },
|
|
226
|
+
chain,
|
|
227
|
+
auditLog: { refusedToday: refusedTotal, lastRefusal, tornToday, lastTornKept },
|
|
175
228
|
lastVerifiedRelease: lastVerifiedRelease(),
|
|
176
229
|
warnings,
|
|
177
230
|
};
|
|
@@ -191,6 +244,9 @@ export function formatFleetHealth(h) {
|
|
|
191
244
|
lines.push(` servers ${h.servers.total}: ${h.servers.indexers} indexing, ${h.servers.readers} reading, ${h.servers.stale.length} on an old build`);
|
|
192
245
|
lines.push(` shared-index writes last hour: ${h.reindex.lastHourWrites} (ceiling ${h.reindex.threshold})`);
|
|
193
246
|
lines.push(` today: ${h.today.blocks} block(s) prevented, ${h.today.refusals} store refusal(s), ${h.today.learningsSaved} learning(s) saved`);
|
|
247
|
+
lines.push(h.chain
|
|
248
|
+
? ` audit chain: ${h.chain.ok ? "verified" : "FAILED"} ${h.chain.ageHours} h ago, ${h.chain.unique} record(s)${h.chain.duplicates ? `, ${h.chain.duplicates} copies counted once` : ""}; ${h.auditLog?.refusedToday ?? 0} entr(ies) refused today`
|
|
249
|
+
: ` audit chain: not checked on this machine yet (the indexing server runs a full check daily; or run contextengine audit-verify)`);
|
|
194
250
|
lines.push(` claude code: ${h.today.hookEvents} hook event(s) today, ${h.today.doubledHookEvents} doubled; registrations ${h.claudeHooks ? Object.entries(h.claudeHooks).map(([ev, n]) => `${ev}=${n}`).join(" ") : "no settings.json"}`);
|
|
195
251
|
for (const b of h.today.lastBlocks)
|
|
196
252
|
lines.push(` ${b.ts.slice(11, 19)}Z ${b.kind}: ${b.detail}`);
|
package/dist/http-server.js
CHANGED
|
@@ -266,13 +266,23 @@ function handleEvents(req, res) {
|
|
|
266
266
|
return;
|
|
267
267
|
}
|
|
268
268
|
// All valid — write them to audit log via safeAppend.
|
|
269
|
+
// [LOCK] [RECEIVER-SAYS-WHAT-WAS-WRITTEN] (src/cli.ts emit-event): count what the log took, and
|
|
270
|
+
// answer 503 when it refused any. On 2026-09-27 a stuck log answered {"ok":true,"written":25}
|
|
271
|
+
// with 0 of 25 recorded (E2E_REVIEW_2026-09 B1-1).
|
|
269
272
|
let written = 0;
|
|
273
|
+
let failed = 0;
|
|
270
274
|
for (const ev of batch.events) {
|
|
271
275
|
const actor = typeof ev.actor === "string" ? ev.actor : "browser-ext";
|
|
272
276
|
// event/payload were validated above — cast is safe.
|
|
273
277
|
// [LOCK] [CAPTURE-IS-REDACTED-AT-THE-DOOR]: redact before the append, never after.
|
|
274
|
-
safeAppend(ev.event, prepareCapturedPayload(ev.payload, ev.event), actor)
|
|
275
|
-
|
|
278
|
+
if (safeAppend(ev.event, prepareCapturedPayload(ev.payload, ev.event), actor))
|
|
279
|
+
written++;
|
|
280
|
+
else
|
|
281
|
+
failed++;
|
|
282
|
+
}
|
|
283
|
+
if (failed > 0) {
|
|
284
|
+
sendJson(req, res, 503, { ok: false, error: "audit_append_failed", written, failed });
|
|
285
|
+
return;
|
|
276
286
|
}
|
|
277
287
|
sendJson(req, res, 200, { ok: true, written });
|
|
278
288
|
});
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { loadEmbeddingStore, compactEmbeddingStore } from "./embedding-store.js"
|
|
|
13
13
|
import { sharedIndexEnabled, corpusId, electIndexer, writeSharedIndex, readSharedIndex, sharedIndexMtime, } from "./shared-index.js";
|
|
14
14
|
import { listProjects, checkPorts, runComplianceAudit, formatProjectList, formatPortMap, formatPlan, scoreProject, formatScoreReport, runScoreCanary, } from "./agents.js";
|
|
15
15
|
import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
|
|
16
|
-
import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend } from "./audit.js";
|
|
16
|
+
import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend, readVerifyState } from "./audit.js";
|
|
17
17
|
import { registerServer, listServers, formatServers, liveDaemonPid } from "./server-registry.js";
|
|
18
18
|
import { secureCeHome } from "./ce-home.js";
|
|
19
19
|
import { QUOTED_TEXT_NOTE } from "./framing.js";
|
|
@@ -26,7 +26,8 @@ import { communityRulesToChunks, mergeWithDedup, loadCommunityStore, } from "./c
|
|
|
26
26
|
import { readFileSync, existsSync, watch, statSync, writeFileSync, mkdirSync } from "fs";
|
|
27
27
|
import { basename, join, dirname } from "path";
|
|
28
28
|
import { homedir } from "os";
|
|
29
|
-
import { execSync } from "child_process";
|
|
29
|
+
import { execSync, spawn } from "child_process";
|
|
30
|
+
import { setPriority } from "os";
|
|
30
31
|
import { scanCodeDir } from "./code-chunker.js";
|
|
31
32
|
import { fileURLToPath } from "url";
|
|
32
33
|
import { TOOL_COUNT, FREE_TOOL_COUNT, PREMIUM_TOOL_NAMES } from "./tools-manifest.js";
|
|
@@ -460,6 +461,64 @@ function evaluateRole(reason) {
|
|
|
460
461
|
}
|
|
461
462
|
let healthTick = 0;
|
|
462
463
|
let lastStaleCount = -1;
|
|
464
|
+
let stopRegistry = null;
|
|
465
|
+
let ending = false;
|
|
466
|
+
/**
|
|
467
|
+
* End this server. Everything that matters is already on disk (audit appends and store writes are
|
|
468
|
+
* synchronous); the registry record is removed first. With the embedding model loaded, a normal exit
|
|
469
|
+
* aborts in the model runtime's native teardown ("libc++abi: ... mutex lock failed", SIGABRT, 3 of 3
|
|
470
|
+
* runs, releasing the model does not help) and leaves a macOS crash report per exit, so such a server
|
|
471
|
+
* ends with SIGKILL, which skips that teardown. [LOCK] [A-CHAT-SERVER-ENDS-WITH-ITS-CHAT]
|
|
472
|
+
*/
|
|
473
|
+
function endThisServer() {
|
|
474
|
+
if (ending)
|
|
475
|
+
return;
|
|
476
|
+
ending = true;
|
|
477
|
+
try {
|
|
478
|
+
stopRegistry?.();
|
|
479
|
+
}
|
|
480
|
+
catch { /* the lister removes a dead record anyway */ }
|
|
481
|
+
if (isEmbeddingsReady())
|
|
482
|
+
process.kill(process.pid, "SIGKILL");
|
|
483
|
+
else
|
|
484
|
+
process.exit(0);
|
|
485
|
+
}
|
|
486
|
+
const SERVER_STARTED_AT = Date.now();
|
|
487
|
+
let lastChainCheckSpawn = 0;
|
|
488
|
+
/**
|
|
489
|
+
* The indexing server starts the daily full check of the audit chain, in its own process.
|
|
490
|
+
* [LOCK] [HEALTH-SEES-THE-CHAIN] (src/fleet-health.ts): the check costs 26 s and 3.5 GB on 4.9M
|
|
491
|
+
* records (measured 2026-09-27), so it never runs in this long-lived process, whose event loop
|
|
492
|
+
* serves the receiver and the tools. A separate low-priority `audit-verify --scheduled`, at most
|
|
493
|
+
* one attempt an hour, never in the first 10 minutes after a start, one at a time across processes
|
|
494
|
+
* (its own lock). CONTEXTENGINE_CHAIN_CHECK=0 turns it off.
|
|
495
|
+
*/
|
|
496
|
+
function maybeScheduleChainCheck() {
|
|
497
|
+
if (role !== "indexer" || process.env.CONTEXTENGINE_CHAIN_CHECK === "0")
|
|
498
|
+
return;
|
|
499
|
+
const now = Date.now();
|
|
500
|
+
if (now - SERVER_STARTED_AT < 10 * 60_000 || now - lastChainCheckSpawn < 60 * 60_000)
|
|
501
|
+
return;
|
|
502
|
+
const state = readVerifyState();
|
|
503
|
+
if (state && now - Date.parse(state.checkedAt) < 24 * 3_600_000)
|
|
504
|
+
return;
|
|
505
|
+
lastChainCheckSpawn = now;
|
|
506
|
+
try {
|
|
507
|
+
const cli = join(dirname(fileURLToPath(import.meta.url)), "cli.js");
|
|
508
|
+
const child = spawn(process.execPath, [cli, "audit-verify", "--scheduled"], { stdio: "ignore", detached: true, env: process.env });
|
|
509
|
+
if (child.pid) {
|
|
510
|
+
try {
|
|
511
|
+
setPriority(child.pid, 10);
|
|
512
|
+
}
|
|
513
|
+
catch { /* the check still runs, at normal priority */ }
|
|
514
|
+
}
|
|
515
|
+
child.unref();
|
|
516
|
+
console.error(`[ContextEngine] 🔎 daily audit chain check started (pid ${child.pid ?? "?"})`);
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
console.error(`[ContextEngine] ⚠ could not start the daily audit chain check: ${err.message}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
463
522
|
/**
|
|
464
523
|
* The indexer writes ~/.contextengine/fleet-health.json once a minute: version drift, reindex
|
|
465
524
|
* rate, today's blocks and refusals, the last verified release. Every surface reads that file.
|
|
@@ -475,6 +534,7 @@ function publishHealth() {
|
|
|
475
534
|
}
|
|
476
535
|
if (role === "indexer")
|
|
477
536
|
writeFleetHealth(h);
|
|
537
|
+
maybeScheduleChainCheck();
|
|
478
538
|
}
|
|
479
539
|
catch (err) {
|
|
480
540
|
console.error(`[ContextEngine] ⚠ fleet health failed: ${err.message}`);
|
|
@@ -1356,7 +1416,8 @@ async function main() {
|
|
|
1356
1416
|
}
|
|
1357
1417
|
}
|
|
1358
1418
|
try {
|
|
1359
|
-
const reg = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url), corpus, role: corpus ? "reader" : undefined, daemon: process.env.OPSCONTEXT_DAEMON === "1" });
|
|
1419
|
+
const reg = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url), corpus, role: corpus ? "reader" : undefined, daemon: process.env.OPSCONTEXT_DAEMON === "1", onSignal: endThisServer });
|
|
1420
|
+
stopRegistry = reg.stop;
|
|
1360
1421
|
setRegistryRole = reg.setRole;
|
|
1361
1422
|
setRegistryEventPort = reg.setEventPort;
|
|
1362
1423
|
const fleet = listServers();
|
|
@@ -1415,6 +1476,26 @@ async function main() {
|
|
|
1415
1476
|
const transport = new StdioServerTransport();
|
|
1416
1477
|
await server.connect(transport);
|
|
1417
1478
|
console.error("[ContextEngine] 🚀 MCP server running on stdio (keyword search ready)");
|
|
1479
|
+
// [LOCKED] [A-CHAT-SERVER-ENDS-WITH-ITS-CHAT] - 2026-09-27
|
|
1480
|
+
// [NEVER] let a server whose client closed its stdin keep running, unless it is the launchd agent.
|
|
1481
|
+
// WHY: the file watchers and the event receiver keep the event loop alive, and nothing listened for
|
|
1482
|
+
// the end of stdin. When a chat's client died without stopping its server (3 of 3 runs,
|
|
1483
|
+
// E2E_REVIEW_2026-09 B5-2), the server lived on, adopted by launchd, still the indexer, still
|
|
1484
|
+
// holding the event port, running its build for good; new servers became its readers.
|
|
1485
|
+
// FIX: stdin's end or close ends a chat server (its registry record goes with it, and a reader takes
|
|
1486
|
+
// over the index within one role poll, 15 s). The launchd agent (OPSCONTEXT_DAEMON=1) has
|
|
1487
|
+
// stdin on /dev/null by design and is exempt. [LOCK] [AUTOSTART-IS-THE-STANDING-INDEXER]
|
|
1488
|
+
// Ending goes through endThisServer(): with the embedding model loaded, a normal exit aborts in
|
|
1489
|
+
// native code and leaves a macOS crash report each time, so such a server ends with SIGKILL
|
|
1490
|
+
// after its record is removed; the stop signals (SIGTERM, SIGINT, SIGHUP) take the same path.
|
|
1491
|
+
if (process.env.OPSCONTEXT_DAEMON !== "1") {
|
|
1492
|
+
const leave = () => {
|
|
1493
|
+
console.error("[ContextEngine] 👋 the client closed the connection: this server stops");
|
|
1494
|
+
endThisServer();
|
|
1495
|
+
};
|
|
1496
|
+
process.stdin.once("end", leave);
|
|
1497
|
+
process.stdin.once("close", leave);
|
|
1498
|
+
}
|
|
1418
1499
|
// 3a. Audit log auto-rotation. Deferred so the first requests are answered before the
|
|
1419
1500
|
// synchronous verify + rewrite (a few seconds on a 500k-record chain) blocks the loop.
|
|
1420
1501
|
// [LOCK] [AUTO-ROTATE-HYSTERESIS-AND-ONE-RUNNER]
|
|
@@ -1423,7 +1504,7 @@ async function main() {
|
|
|
1423
1504
|
const runAutoRotate = () => {
|
|
1424
1505
|
try {
|
|
1425
1506
|
const o = autoRotateAuditLog();
|
|
1426
|
-
if (o.action === "rotated" || o.action === "refused" || o.action === "error" || o.action === "in_progress") {
|
|
1507
|
+
if (o.action === "rotated" || o.action === "refused" || o.action === "error" || o.action === "in_progress" || o.action === "finished") {
|
|
1427
1508
|
console.error(`[ContextEngine] 📦 audit auto-rotate (${o.action}): ${o.detail}`);
|
|
1428
1509
|
}
|
|
1429
1510
|
}
|
package/dist/learnings.d.ts
CHANGED
|
@@ -15,6 +15,9 @@ export interface LearningsStore {
|
|
|
15
15
|
version: number;
|
|
16
16
|
count: number;
|
|
17
17
|
learnings: Learning[];
|
|
18
|
+
/** Bundled default rules (lowercased, trimmed) the owner deleted: never merged back.
|
|
19
|
+
* [LOCK] [A-READ-NEVER-WRITES-THE-STORE] */
|
|
20
|
+
dismissed_defaults?: string[];
|
|
18
21
|
}
|
|
19
22
|
/** Valid categories for learnings */
|
|
20
23
|
export declare const LEARNING_CATEGORIES: readonly ["deployment", "api", "database", "frontend", "backend", "devops", "security", "performance", "testing", "debugging", "tooling", "git", "dependencies", "architecture", "data", "infrastructure", "mobile", "other"];
|
package/dist/learnings.js
CHANGED
|
@@ -76,10 +76,13 @@ function loadBundledDefaults() {
|
|
|
76
76
|
function mergeDefaults(store) {
|
|
77
77
|
const bundled = loadBundledDefaults();
|
|
78
78
|
if (bundled.length === 0)
|
|
79
|
-
return
|
|
79
|
+
return 0;
|
|
80
80
|
const existingRules = new Set(store.learnings
|
|
81
81
|
.filter((l) => typeof l.rule === "string")
|
|
82
82
|
.map((l) => l.rule.toLowerCase().trim()));
|
|
83
|
+
// A default the owner deleted stays deleted. [LOCK] [A-READ-NEVER-WRITES-THE-STORE]
|
|
84
|
+
for (const r of store.dismissed_defaults ?? [])
|
|
85
|
+
existingRules.add(r);
|
|
83
86
|
let added = 0;
|
|
84
87
|
const now = new Date().toISOString();
|
|
85
88
|
for (const def of bundled) {
|
|
@@ -97,7 +100,12 @@ function mergeDefaults(store) {
|
|
|
97
100
|
existingRules.add(def.rule.toLowerCase().trim());
|
|
98
101
|
added++;
|
|
99
102
|
}
|
|
100
|
-
return added
|
|
103
|
+
return added;
|
|
104
|
+
}
|
|
105
|
+
/** Is this rule text one of the bundled defaults? */
|
|
106
|
+
function isBundledDefault(rule) {
|
|
107
|
+
const key = rule.toLowerCase().trim();
|
|
108
|
+
return loadBundledDefaults().some((d) => d.rule.toLowerCase().trim() === key);
|
|
101
109
|
}
|
|
102
110
|
// [LOCKED] [STORE-NEVER-STARTS-FRESH-OVER-DATA] 2026-09-05
|
|
103
111
|
// [NEVER] turn an unreadable learnings.json into an empty store, write the store with a
|
|
@@ -127,6 +135,55 @@ let batchDirty = false;
|
|
|
127
135
|
function sleepMs(ms) {
|
|
128
136
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
129
137
|
}
|
|
138
|
+
// [LOCKED] [A-DEAD-STORE-HOLDER-LOSES-THE-LOCK-AT-ONCE] 2026-09-27
|
|
139
|
+
// [NEVER] make a save wait out LOCK_STALE_MS for a holder that is provably gone, or leave a dead
|
|
140
|
+
// writer's full copy of the store on disk.
|
|
141
|
+
// WHY: a saver killed inside the lock made every other save in the next ~20 s wait 10 s and fail
|
|
142
|
+
// with "locked by another process" (6 per run, 3 of 3, E2E_REVIEW_2026-09 B4-2), and left its
|
|
143
|
+
// temp copy of the whole store behind for good. The lock directory names its holder's pid;
|
|
144
|
+
// nothing read it.
|
|
145
|
+
// FIX: a lock whose pid no longer exists, or a lock directory older than a second with no pid in
|
|
146
|
+
// it, is taken over at once; a live pid keeps the 30 s rule. Inside the lock, temp copies
|
|
147
|
+
// written by a pid that no longer exists are removed.
|
|
148
|
+
function storeHolderIsGone(age) {
|
|
149
|
+
let pid = NaN;
|
|
150
|
+
try {
|
|
151
|
+
pid = parseInt(readFileSync(join(STORE_LOCK_DIR, "pid"), "utf-8"), 10);
|
|
152
|
+
}
|
|
153
|
+
catch { /* no pid yet */ }
|
|
154
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
155
|
+
return age > 1000;
|
|
156
|
+
if (pid === process.pid)
|
|
157
|
+
return false;
|
|
158
|
+
try {
|
|
159
|
+
process.kill(pid, 0);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
return e.code === "ESRCH";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function removeDeadWritersTemps() {
|
|
167
|
+
try {
|
|
168
|
+
const dir = dirname(LEARNINGS_PATH);
|
|
169
|
+
for (const f of readdirSync(dir)) {
|
|
170
|
+
const m = /^learnings\.json\.tmp-(\d+)-\d+$/.exec(f);
|
|
171
|
+
if (!m)
|
|
172
|
+
continue;
|
|
173
|
+
const pid = Number(m[1]);
|
|
174
|
+
if (pid === process.pid)
|
|
175
|
+
continue;
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, 0);
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
if (e.code === "ESRCH")
|
|
181
|
+
unlinkSync(join(dir, f));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch { /* cleanup is a courtesy; the store is untouched either way */ }
|
|
186
|
+
}
|
|
130
187
|
/** Cross-process, re-entrant (within this process) lock around the store file. */
|
|
131
188
|
export function withStoreLock(fn) {
|
|
132
189
|
if (lockDepth > 0) {
|
|
@@ -160,7 +217,8 @@ export function withStoreLock(fn) {
|
|
|
160
217
|
catch {
|
|
161
218
|
age = 0;
|
|
162
219
|
}
|
|
163
|
-
|
|
220
|
+
// [LOCK] [A-DEAD-STORE-HOLDER-LOSES-THE-LOCK-AT-ONCE]
|
|
221
|
+
if (age > LOCK_STALE_MS || storeHolderIsGone(age)) {
|
|
164
222
|
// Holder died (or hung) without releasing: take it over.
|
|
165
223
|
try {
|
|
166
224
|
rmSync(STORE_LOCK_DIR, { recursive: true, force: true });
|
|
@@ -176,6 +234,7 @@ export function withStoreLock(fn) {
|
|
|
176
234
|
}
|
|
177
235
|
lockDepth = 1;
|
|
178
236
|
try {
|
|
237
|
+
removeDeadWritersTemps();
|
|
179
238
|
return fn();
|
|
180
239
|
}
|
|
181
240
|
finally {
|
|
@@ -237,14 +296,42 @@ function readStoreFromDisk() {
|
|
|
237
296
|
else {
|
|
238
297
|
store = { version: 1, count: 0, learnings: [] };
|
|
239
298
|
}
|
|
240
|
-
//
|
|
241
|
-
|
|
299
|
+
// [LOCKED] [A-READ-NEVER-WRITES-THE-STORE] - 2026-09-27
|
|
300
|
+
// [NEVER] write the store from a read that does not hold the store lock, and never merge back a
|
|
301
|
+
// bundled default the owner deleted.
|
|
302
|
+
// WHY: a plain read (listLearnings, searchLearnings, the index build) merged a missing bundled
|
|
303
|
+
// default and wrote the whole store WITHOUT the lock. With that read held 1.5 s before its
|
|
304
|
+
// write, a concurrent saver's 5 learnings, each saved with success, were gone (3 of 3,
|
|
305
|
+
// E2E_REVIEW_2026-09 B4-1); at natural speed the window is milliseconds and opens after every
|
|
306
|
+
// release that adds a default. And a deleted default came back at the next read, with a new id
|
|
307
|
+
// and no audit record: the delete never stuck. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
|
|
308
|
+
// already said "never let two processes write it without the lock".
|
|
309
|
+
// FIX: holding the lock (a save, a delete, a batch), the merge is written as before. A plain read
|
|
310
|
+
// takes the lock for it (re-reading inside), and if the lock is busy only shows the defaults,
|
|
311
|
+
// writing nothing. A deleted default is remembered in `dismissed_defaults`; every persisted
|
|
312
|
+
// merge is recorded as a learning.import of the bundled defaults.
|
|
313
|
+
if (!hasMissingDefaults(store))
|
|
314
|
+
return store;
|
|
315
|
+
if (lockDepth > 0) {
|
|
316
|
+
const added = mergeDefaults(store);
|
|
242
317
|
if (batchStore)
|
|
243
318
|
batchDirty = true;
|
|
244
319
|
else
|
|
245
320
|
writeStoreToDisk(store);
|
|
321
|
+
safeAppend("learning.import", { source: "bundled defaults", format: "json", imported: added, updated: 0, skipped: 0 });
|
|
322
|
+
return store;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
return withStoreLock(() => readStoreFromDisk());
|
|
246
326
|
}
|
|
247
|
-
|
|
327
|
+
catch {
|
|
328
|
+
mergeDefaults(store); // the lock is busy: show them, persist at the next locked read or write
|
|
329
|
+
return store;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function hasMissingDefaults(store) {
|
|
333
|
+
const probe = { version: store.version, count: 0, learnings: store.learnings.slice(), dismissed_defaults: store.dismissed_defaults };
|
|
334
|
+
return mergeDefaults(probe) > 0;
|
|
248
335
|
}
|
|
249
336
|
function loadStore() {
|
|
250
337
|
if (batchStore)
|
|
@@ -517,12 +604,19 @@ function deleteLearningUnlocked(id) {
|
|
|
517
604
|
return false;
|
|
518
605
|
const removed = store.learnings[index];
|
|
519
606
|
store.learnings.splice(index, 1);
|
|
607
|
+
// A deleted bundled default stays deleted. [LOCK] [A-READ-NEVER-WRITES-THE-STORE]
|
|
608
|
+
const dismissed = typeof removed.rule === "string" && isBundledDefault(removed.rule);
|
|
609
|
+
if (dismissed) {
|
|
610
|
+
const key = removed.rule.toLowerCase().trim();
|
|
611
|
+
store.dismissed_defaults = [...new Set([...(store.dismissed_defaults ?? []), key])];
|
|
612
|
+
}
|
|
520
613
|
saveStore(store);
|
|
521
614
|
safeAppend("learning.delete", {
|
|
522
615
|
id: removed.id,
|
|
523
616
|
category: removed.category,
|
|
524
617
|
project: removed.project,
|
|
525
618
|
rule_length: typeof removed.rule === "string" ? removed.rule.length : 0,
|
|
619
|
+
...(dismissed ? { default_dismissed: true } : {}),
|
|
526
620
|
});
|
|
527
621
|
return true;
|
|
528
622
|
}
|