@mindrian_os/install 1.13.0-beta.12 → 1.13.0-beta.13
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +42 -0
- package/commands/auto-explore.md +1 -0
- package/commands/doctor.md +1 -1
- package/commands/operator.md +1 -1
- package/commands/setup.md +7 -3
- package/lib/core/active-plugin-root.cjs +71 -6
- package/lib/core/brain-client.cjs +451 -36
- package/lib/core/cache-prune.cjs +208 -0
- package/lib/core/navigation/memory-events.cjs +17 -1
- package/lib/core/navigation/neighborhood.cjs +5 -4
- package/lib/core/navigation/packet.cjs +87 -1
- package/lib/core/navigation.cjs +6 -0
- package/lib/core/resolve-brain-key.cjs +201 -0
- package/lib/memory/run-feynman-tests.cjs +85 -0
- package/lib/memory/security-trifecta.test.cjs +23 -6
- package/package.json +4 -1
- package/skills/brain-connector/SKILL.md +9 -3
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* lib/core/cache-prune.cjs -- prune stale marketplace-cache version dirs.
|
|
4
|
+
*
|
|
5
|
+
* Phase 123 Plan-05 (HARNESS-123-13). Canon Part 8: this reads LOCAL files
|
|
6
|
+
* only (~/.claude/plugins/) and writes LOCAL files only (fs.rmSync on
|
|
7
|
+
* stale version dirs). Zero network surface.
|
|
8
|
+
*
|
|
9
|
+
* Background: Claude Code's marketplace install path is
|
|
10
|
+
* ~/.claude/plugins/cache/<marketplace>/mos/<version>/
|
|
11
|
+
* where each <version> subdir is a self-contained install of that
|
|
12
|
+
* version. On `claude plugin update`, the new version is downloaded
|
|
13
|
+
* into a new <version> dir, but the old <version> dir is NEVER removed.
|
|
14
|
+
* The Windows live test of v1.13.0-beta.12 surfaced two orphans on disk
|
|
15
|
+
* (`1.12.0/`, `1.13.0-beta.9/`) alongside the active `1.13.0-beta.12/`.
|
|
16
|
+
*
|
|
17
|
+
* This helper prunes the cache, keying off Claude Code's own
|
|
18
|
+
* installed_plugins.json (the source of truth for "which version is active"):
|
|
19
|
+
*
|
|
20
|
+
* - active version (from installed_plugins.json's mos@mindrian-marketplace
|
|
21
|
+
* entry) is ALWAYS kept, regardless of mtime.
|
|
22
|
+
* - the N most recent non-active versions (default N=2, sorted by mtime
|
|
23
|
+
* DESC) are also kept.
|
|
24
|
+
* - everything else is removed.
|
|
25
|
+
*
|
|
26
|
+
* Belt + suspenders: before every fs.rmSync call, the path's basename is
|
|
27
|
+
* cross-checked against the active version string -- if it matches, the
|
|
28
|
+
* removal is SKIPPED with a logged reason (defends against a malformed
|
|
29
|
+
* installed_plugins.json that somehow lists two different basenames as
|
|
30
|
+
* "active", or a race between this function and a concurrent install).
|
|
31
|
+
*
|
|
32
|
+
* If installed_plugins.json is unreadable (ENOENT, parse error, or no
|
|
33
|
+
* mos@mindrian-marketplace entry), the function returns
|
|
34
|
+
* { kept: [], removed: [], skipped: true, reason: '...' }
|
|
35
|
+
* with NO mutation. Never guesses; never deletes anything in the absence
|
|
36
|
+
* of an authoritative active-version answer.
|
|
37
|
+
*
|
|
38
|
+
* Signature:
|
|
39
|
+
* pruneMarketplaceCache({
|
|
40
|
+
* home = os.homedir(),
|
|
41
|
+
* marketplace = 'mindrian-marketplace',
|
|
42
|
+
* retainCount = 2,
|
|
43
|
+
* dryRun = false,
|
|
44
|
+
* } = {}) => {
|
|
45
|
+
* kept: string[], // version basenames kept on disk (incl. active)
|
|
46
|
+
* removed: string[], // version basenames removed (or would be, in dryRun)
|
|
47
|
+
* skipped: boolean, // true if we declined to act (see `reason`)
|
|
48
|
+
* reason: string|null // human-readable explanation, or null on success
|
|
49
|
+
* }
|
|
50
|
+
*
|
|
51
|
+
* Canon Part 8 sanity check (cp.6): the forbidden-network-token grep
|
|
52
|
+
* exits 1 (no match) on this source file. Test cp.6 enforces it.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const fs = require('node:fs');
|
|
56
|
+
const path = require('node:path');
|
|
57
|
+
const os = require('node:os');
|
|
58
|
+
|
|
59
|
+
// Read installed_plugins.json and pull the mos@mindrian-marketplace active
|
|
60
|
+
// version. Returns { activeVersion, activeInstallPath } on success, or
|
|
61
|
+
// { skipped: true, reason } on any failure.
|
|
62
|
+
function readActiveVersion(home) {
|
|
63
|
+
const installedPath = path.join(home, '.claude', 'plugins', 'installed_plugins.json');
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = fs.readFileSync(installedPath, 'utf8');
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { skipped: true, reason: 'installed_plugins.json unreadable (' + (e && e.code || e.message) + ') -- skipping prune' };
|
|
69
|
+
}
|
|
70
|
+
let ip;
|
|
71
|
+
try {
|
|
72
|
+
ip = JSON.parse(raw);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return { skipped: true, reason: 'installed_plugins.json parse error -- skipping prune' };
|
|
75
|
+
}
|
|
76
|
+
if (!ip || typeof ip !== 'object' || !ip.plugins || typeof ip.plugins !== 'object') {
|
|
77
|
+
return { skipped: true, reason: 'installed_plugins.json has no plugins map -- skipping prune' };
|
|
78
|
+
}
|
|
79
|
+
// Accept either key shape: 'mos@mindrian-marketplace' or 'mos'.
|
|
80
|
+
let entry = ip.plugins['mos@mindrian-marketplace'];
|
|
81
|
+
if (!entry) {
|
|
82
|
+
// Defensive: find any key whose left-of-@ is mos or mindrian-os.
|
|
83
|
+
const keys = Object.keys(ip.plugins);
|
|
84
|
+
for (const k of keys) {
|
|
85
|
+
const name = String(k).split('@')[0];
|
|
86
|
+
if (name === 'mos' || name === 'mindrian-os') { entry = ip.plugins[k]; break; }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!entry) {
|
|
90
|
+
return { skipped: true, reason: 'no active mos@mindrian-marketplace entry in installed_plugins.json -- skipping prune' };
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(entry)) entry = entry[0];
|
|
93
|
+
if (!entry || !entry.version) {
|
|
94
|
+
return { skipped: true, reason: 'mos@mindrian-marketplace entry has no version field -- skipping prune' };
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
activeVersion: String(entry.version),
|
|
98
|
+
activeInstallPath: entry.installPath || entry.path || entry.dir || null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// List child directory names under <home>/.claude/plugins/cache/<marketplace>/mos/.
|
|
103
|
+
// Returns [] if the cache dir does not exist (no-op case).
|
|
104
|
+
function listCacheVersions(home, marketplace) {
|
|
105
|
+
const cacheDir = path.join(home, '.claude', 'plugins', 'cache', marketplace, 'mos');
|
|
106
|
+
if (!fs.existsSync(cacheDir)) return { cacheDir, names: [] };
|
|
107
|
+
let names = [];
|
|
108
|
+
try {
|
|
109
|
+
names = fs.readdirSync(cacheDir).filter(function (name) {
|
|
110
|
+
try { return fs.statSync(path.join(cacheDir, name)).isDirectory(); }
|
|
111
|
+
catch (_) { return false; }
|
|
112
|
+
});
|
|
113
|
+
} catch (_) { /* unreadable -- treat as empty */ }
|
|
114
|
+
return { cacheDir, names };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Sort version names by mtime DESC (newest first). Falls back to lexicographic
|
|
118
|
+
// order if statSync fails on any path (defensive).
|
|
119
|
+
function sortByMtimeDesc(cacheDir, names) {
|
|
120
|
+
const stats = names.map(function (name) {
|
|
121
|
+
let mtimeMs = 0;
|
|
122
|
+
try { mtimeMs = fs.statSync(path.join(cacheDir, name)).mtimeMs || 0; }
|
|
123
|
+
catch (_) { mtimeMs = 0; }
|
|
124
|
+
return { name, mtimeMs };
|
|
125
|
+
});
|
|
126
|
+
stats.sort(function (a, b) {
|
|
127
|
+
if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs;
|
|
128
|
+
return a.name < b.name ? 1 : -1; // tie-break lexicographic DESC (stable)
|
|
129
|
+
});
|
|
130
|
+
return stats.map(function (s) { return s.name; });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function pruneMarketplaceCache(opts) {
|
|
134
|
+
const o = opts || {};
|
|
135
|
+
const home = o.home || os.homedir();
|
|
136
|
+
const marketplace = o.marketplace || 'mindrian-marketplace';
|
|
137
|
+
const retainCount = (typeof o.retainCount === 'number' && o.retainCount >= 0) ? o.retainCount : 2;
|
|
138
|
+
const dryRun = !!o.dryRun;
|
|
139
|
+
|
|
140
|
+
// Step 1: read active version. Skip entirely if unavailable -- never guess.
|
|
141
|
+
const active = readActiveVersion(home);
|
|
142
|
+
if (active.skipped) {
|
|
143
|
+
return { kept: [], removed: [], skipped: true, reason: active.reason };
|
|
144
|
+
}
|
|
145
|
+
const activeVersion = active.activeVersion;
|
|
146
|
+
|
|
147
|
+
// Step 2: list cache version dirs.
|
|
148
|
+
const { cacheDir, names } = listCacheVersions(home, marketplace);
|
|
149
|
+
if (names.length === 0) {
|
|
150
|
+
// No cache dir to prune. The active version is still "kept" conceptually
|
|
151
|
+
// (it lives in the cache when it exists; absent cache = nothing to do).
|
|
152
|
+
return { kept: [activeVersion], removed: [], skipped: false, reason: 'no cache dir to prune' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Step 3: build the keep-set.
|
|
156
|
+
// - the active version is ALWAYS kept (regardless of mtime).
|
|
157
|
+
// - then the N most recent non-active versions (by mtime DESC).
|
|
158
|
+
const sorted = sortByMtimeDesc(cacheDir, names);
|
|
159
|
+
const keep = new Set();
|
|
160
|
+
keep.add(activeVersion);
|
|
161
|
+
for (const name of sorted) {
|
|
162
|
+
if (name === activeVersion) continue;
|
|
163
|
+
if (keep.size >= retainCount + 1) break; // +1 reserves the slot for active
|
|
164
|
+
keep.add(name);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Step 4: compute the prune set and act on it.
|
|
168
|
+
const removed = [];
|
|
169
|
+
for (const name of names) {
|
|
170
|
+
if (keep.has(name)) continue;
|
|
171
|
+
// Belt + suspenders: NEVER rm the active version's dir, no matter what.
|
|
172
|
+
if (name === activeVersion) {
|
|
173
|
+
// (Cannot reach here under normal flow -- the keep-set above always
|
|
174
|
+
// contains activeVersion. Defensive sentinel for malformed inputs.)
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const target = path.join(cacheDir, name);
|
|
178
|
+
if (dryRun) {
|
|
179
|
+
removed.push(name);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
184
|
+
removed.push(name);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
// Best-effort: a failed removal does not abort the whole prune; we
|
|
187
|
+
// record it in the reason field at the end if any failures occurred.
|
|
188
|
+
// The caller (session-start with `|| true`; doctor with try/catch +
|
|
189
|
+
// report.recoveries) handles the partial state.
|
|
190
|
+
removed.push(name + ' (rm-failed: ' + (e && e.code || e.message) + ')');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
kept: Array.from(keep),
|
|
196
|
+
removed,
|
|
197
|
+
skipped: false,
|
|
198
|
+
reason: null,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
pruneMarketplaceCache,
|
|
204
|
+
// Internal helpers exposed for testability + future reuse.
|
|
205
|
+
readActiveVersion,
|
|
206
|
+
listCacheVersions,
|
|
207
|
+
sortByMtimeDesc,
|
|
208
|
+
};
|
|
@@ -47,13 +47,29 @@ const EVENT_TYPES = Object.freeze(new Set([
|
|
|
47
47
|
// fired (fingerprint detection -> spawn) -> finding_surfaced (drain -> F.1)
|
|
48
48
|
// -> user_response (Explore/Skip/Later/Free-text) OR skipped (suppression).
|
|
49
49
|
// brain_canon_drift_observed (FourLenses Brain vs FiveLenses Canon; emitted once per
|
|
50
|
-
// session by 117-05 emitBrainCanonDrift via this EVENT_TYPES string
|
|
50
|
+
// session by 117-05 emitBrainCanonDrift via this EVENT_TYPES string).
|
|
51
|
+
// Size-invariant note: additive set; downstream phases extend (see the Phase 88.2-00,
|
|
52
|
+
// 89-07-00, 116-00, 117-00, 110-02 blocks). Tests assert a FLOOR + named membership,
|
|
53
|
+
// not an exact count -- so a future phase adding an event type cannot regress baseline.
|
|
51
54
|
'auto_explore_fired',
|
|
52
55
|
'auto_explore_finding_surfaced',
|
|
53
56
|
'auto_explore_user_response',
|
|
54
57
|
'auto_explore_skipped',
|
|
55
58
|
'auto_explore_sanitizer_hit',
|
|
56
59
|
'brain_canon_drift_observed',
|
|
60
|
+
// Phase 110-02 extension (Brain Context Packet Contract; D-07 + D-10 telemetry mirror):
|
|
61
|
+
// brain_packet_rejected -> an outbound packet failed in-schema validation in
|
|
62
|
+
// brain-client.sendPacket (reject hard -- thrown error).
|
|
63
|
+
// brain_response_rejected -> a Brain response failed out-schema validation -> degraded
|
|
64
|
+
// soft, NOT ingested, no partial-ingest.
|
|
65
|
+
// brain_legacy_path_used -> the forward-looking deprecation guard fired (no current
|
|
66
|
+
// call site shipped in 110-02; see brain-client.cjs).
|
|
67
|
+
// Additive extension only; mirrors the Phase 116-00 5-tension-strings idiom and the
|
|
68
|
+
// 117-00 6-auto_explore-strings idiom. logEvent already rejects event_type values
|
|
69
|
+
// outside EVENT_TYPES -- so these are accepted only because they are now IN the Set.
|
|
70
|
+
'brain_packet_rejected',
|
|
71
|
+
'brain_response_rejected',
|
|
72
|
+
'brain_legacy_path_used',
|
|
57
73
|
]));
|
|
58
74
|
|
|
59
75
|
function isPlainObject(v) {
|
|
@@ -11,20 +11,20 @@
|
|
|
11
11
|
// Canon Part 9: SELECT supersedes folder scanning; this is the load-bearing
|
|
12
12
|
// query for the acceptance test.
|
|
13
13
|
|
|
14
|
-
const NEIGHBORHOOD_SQL = "WITH RECURSIVE neighborhood(id, type, edge_path, depth, edge_type_in, last_seen_at, confidence, source_section, review_status, created_by, source_path) AS ( "
|
|
14
|
+
const NEIGHBORHOOD_SQL = "WITH RECURSIVE neighborhood(id, type, edge_path, depth, edge_type_in, last_seen_at, created_at, confidence, source_section, review_status, created_by, source_path) AS ( "
|
|
15
15
|
+ "SELECT n.id, n.type, json_array(n.id) AS edge_path, 0 AS depth, NULL AS edge_type_in, "
|
|
16
|
-
+ "n.last_seen_at, n.confidence, n.source_section, n.review_status, n.created_by, n.source_path "
|
|
16
|
+
+ "n.last_seen_at, n.created_at, n.confidence, n.source_section, n.review_status, n.created_by, n.source_path "
|
|
17
17
|
+ "FROM nodes n WHERE n.id = :focus_node_id "
|
|
18
18
|
+ "UNION ALL "
|
|
19
19
|
+ "SELECT next_n.id, next_n.type, json_insert(nh.edge_path, '$[#]', next_n.id) AS edge_path, "
|
|
20
20
|
+ "nh.depth + 1 AS depth, e.type AS edge_type_in, "
|
|
21
|
-
+ "next_n.last_seen_at, next_n.confidence, next_n.source_section, next_n.review_status, next_n.created_by, next_n.source_path "
|
|
21
|
+
+ "next_n.last_seen_at, next_n.created_at, next_n.confidence, next_n.source_section, next_n.review_status, next_n.created_by, next_n.source_path "
|
|
22
22
|
+ "FROM neighborhood nh JOIN edges e ON e.source = nh.id JOIN nodes next_n ON next_n.id = e.target "
|
|
23
23
|
+ "WHERE nh.depth < :max_depth "
|
|
24
24
|
+ "AND json_array_length(nh.edge_path) < (:max_depth + 1) "
|
|
25
25
|
+ "AND nh.id != next_n.id "
|
|
26
26
|
+ ") "
|
|
27
|
-
+ "SELECT id, type, edge_path, depth, edge_type_in, source_path, review_status, created_by, confidence, last_seen_at, "
|
|
27
|
+
+ "SELECT id, type, edge_path, depth, edge_type_in, source_path, review_status, created_by, created_at, confidence, last_seen_at, "
|
|
28
28
|
+ "( "
|
|
29
29
|
+ "CASE edge_type_in "
|
|
30
30
|
+ "WHEN 'CONTRADICTS' THEN 1.0 "
|
|
@@ -70,6 +70,7 @@ function getNeighborhood(db, focusNodeId, opts) {
|
|
|
70
70
|
sourcePath: r.source_path,
|
|
71
71
|
reviewStatus: r.review_status,
|
|
72
72
|
createdBy: r.created_by,
|
|
73
|
+
createdAt: r.created_at,
|
|
73
74
|
confidence: r.confidence,
|
|
74
75
|
lastSeenAt: r.last_seen_at,
|
|
75
76
|
}));
|
|
@@ -9,12 +9,73 @@
|
|
|
9
9
|
// Canon Part 9: builder produces the JS object; Phase 110 wraps with validateAndSendBrainPacket;
|
|
10
10
|
// Phase 109 honors the privacy: 'no_raw_artifact_text' field by default in constraints.
|
|
11
11
|
|
|
12
|
+
const fs = require('node:fs');
|
|
12
13
|
const path = require('node:path');
|
|
13
14
|
const crypto = require('node:crypto');
|
|
14
15
|
const { getNeighborhood } = require('./neighborhood.cjs');
|
|
15
16
|
const { findContradictions, findUnsupportedClaims, findRelevantOpportunities } = require('./insights.cjs');
|
|
16
17
|
const { findRecentChanges } = require('./memory-events.cjs');
|
|
17
18
|
|
|
19
|
+
// Phase 110-02 (D-03 + D-09): privacy-mode opt-up. local_summary_only is the default and
|
|
20
|
+
// the only mode any of the 12 shipped Brain jobs ever requests (every $def.in.properties.privacy_mode
|
|
21
|
+
// in data/brain-packet-schema.json is { const: 'local_summary_only' }). allow_filenames opts
|
|
22
|
+
// up via .config.json preferences.brain_privacy_mode (project-level) or opts.privacyMode
|
|
23
|
+
// (per-call); allow_excerpts ADDITIONALLY requires a Part-3 Decision Gate APPROVE-with-reason
|
|
24
|
+
// row on the room graph tagged brain_excerpts -- there is NO shipped consumer of allow_excerpts
|
|
25
|
+
// as of v1.13.0-beta.3, so it is a defined-but-unconsumed escape hatch. Config can only CAP
|
|
26
|
+
// the mode, never RAISE what a job sends -- the schema's per-job const enforces this for free
|
|
27
|
+
// in Phase 110-03 sendPacket.
|
|
28
|
+
const PRIVACY_MODES = ['local_summary_only', 'allow_filenames', 'allow_excerpts'];
|
|
29
|
+
|
|
30
|
+
function readRoomConfigPrivacyMode(roomDir) {
|
|
31
|
+
if (!roomDir) return null;
|
|
32
|
+
try {
|
|
33
|
+
const p = path.join(roomDir, '.config.json');
|
|
34
|
+
if (!fs.existsSync(p)) return null;
|
|
35
|
+
const cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
36
|
+
const v = cfg && cfg.preferences && cfg.preferences.brain_privacy_mode;
|
|
37
|
+
return PRIVACY_MODES.includes(v) ? v : null;
|
|
38
|
+
} catch (_) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The Part-3 Decision Gate APPROVE row (Canon Part 3 + Part 4). The F.0 selector at
|
|
44
|
+
// lib/hmi/shape-f0-renderer.cjs is the canonical render path that, when wired in a future
|
|
45
|
+
// plan, writes the brain_excerpts-tagged APPROVE decision row that this helper looks for.
|
|
46
|
+
// As of v1.13.0-beta.3 there is NO shipped consumer of allow_excerpts -- so this returns
|
|
47
|
+
// false until a Part-3 gate is wired and the user approves. The query is a single guarded
|
|
48
|
+
// SELECT against the local room graph (the same db handle buildBrainPacket already takes);
|
|
49
|
+
// any error or absent row returns false so the resolver caps down safely.
|
|
50
|
+
function roomHasExcerptApproval(db, roomDir) {
|
|
51
|
+
try {
|
|
52
|
+
if (!db) return false;
|
|
53
|
+
const row = db.prepare(
|
|
54
|
+
"SELECT 1 FROM nodes WHERE type IN ('decision','memory_event') " +
|
|
55
|
+
"AND review_status = 'confirmed' AND created_by = 'user' " +
|
|
56
|
+
"AND instr(properties, 'brain_excerpts') > 0 LIMIT 1"
|
|
57
|
+
).get();
|
|
58
|
+
return !!row;
|
|
59
|
+
} catch (_) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolvePrivacyMode(db, roomDir, opts) {
|
|
65
|
+
const options = opts || {};
|
|
66
|
+
const perCall = options.privacyMode;
|
|
67
|
+
const fromConfig = readRoomConfigPrivacyMode(roomDir);
|
|
68
|
+
const requested = (typeof perCall === 'string' && PRIVACY_MODES.includes(perCall))
|
|
69
|
+
? perCall
|
|
70
|
+
: (fromConfig || 'local_summary_only');
|
|
71
|
+
if (requested === 'allow_excerpts' && !roomHasExcerptApproval(db, roomDir)) {
|
|
72
|
+
// Cap down per "config caps, never raises". If config said allow_filenames, keep it;
|
|
73
|
+
// otherwise drop to local_summary_only.
|
|
74
|
+
return fromConfig === 'allow_filenames' ? 'allow_filenames' : 'local_summary_only';
|
|
75
|
+
}
|
|
76
|
+
return requested;
|
|
77
|
+
}
|
|
78
|
+
|
|
18
79
|
function loadJtbd(roomDir, mocks) {
|
|
19
80
|
if (mocks && mocks.jtbd) return mocks.jtbd;
|
|
20
81
|
try { return require(path.resolve(__dirname, '..', '..', 'hmi', 'jtbd-state.cjs')); } catch (_) { return null; }
|
|
@@ -121,6 +182,11 @@ function buildBrainPacket(db, job, focusNodeId, opts) {
|
|
|
121
182
|
const options = opts || {};
|
|
122
183
|
const mocks = options._mocks;
|
|
123
184
|
const roomId = options.roomId || null;
|
|
185
|
+
const roomDir = options.roomDir || null;
|
|
186
|
+
|
|
187
|
+
// Phase 110-02: resolve the privacy mode BEFORE building the packet body so the resolved
|
|
188
|
+
// value is available on the top-level return object alongside packet_version and origin.
|
|
189
|
+
const privacyMode = resolvePrivacyMode(db, roomDir, options);
|
|
124
190
|
|
|
125
191
|
// Active context. Mocks override the require() calls for hermetic tests.
|
|
126
192
|
const jtbdMod = loadJtbd(null, mocks);
|
|
@@ -155,6 +221,16 @@ function buildBrainPacket(db, job, focusNodeId, opts) {
|
|
|
155
221
|
packet_version: '1.0',
|
|
156
222
|
job,
|
|
157
223
|
room_stage: getRoomStage(db),
|
|
224
|
+
// Phase 110-02 D-08 layer 1: stamp the navigation-API provenance origin. The schema's
|
|
225
|
+
// $defs.Origin enum constrains this to the closed set; brain-client.sendPacket (Phase
|
|
226
|
+
// 110-03) refuses any other value at wire time. defense-in-depth -- three layers (schema
|
|
227
|
+
// enum + pre-commit hook + sendPacket allowlist), no in-process nonce.
|
|
228
|
+
origin: 'navigation_api',
|
|
229
|
+
// Phase 110-02 D-09: top-level privacy_mode (one of D-03's 3 enum values; default
|
|
230
|
+
// local_summary_only). Separate from constraints.privacy (human-readable note) -- the
|
|
231
|
+
// schema's $defs.PrivacyMode enum + each job's $def.in.properties.privacy_mode const
|
|
232
|
+
// enforces "config caps, never raises" at the wire level in Phase 110-03 sendPacket.
|
|
233
|
+
privacy_mode: privacyMode,
|
|
158
234
|
active_context: {
|
|
159
235
|
jtbd: jtbdId,
|
|
160
236
|
operator,
|
|
@@ -179,4 +255,14 @@ function buildBrainPacket(db, job, focusNodeId, opts) {
|
|
|
179
255
|
};
|
|
180
256
|
}
|
|
181
257
|
|
|
182
|
-
module.exports = {
|
|
258
|
+
module.exports = {
|
|
259
|
+
buildBrainPacket,
|
|
260
|
+
surface_banked_opportunities,
|
|
261
|
+
shortText,
|
|
262
|
+
// Phase 110-02 (D-09): exported so Phase 110-05 round-trip tests, future callers, and
|
|
263
|
+
// brain-client.sendPacket (110-03) can introspect / reuse the resolution order.
|
|
264
|
+
resolvePrivacyMode,
|
|
265
|
+
readRoomConfigPrivacyMode,
|
|
266
|
+
roomHasExcerptApproval,
|
|
267
|
+
PRIVACY_MODES,
|
|
268
|
+
};
|
package/lib/core/navigation.cjs
CHANGED
|
@@ -57,4 +57,10 @@ module.exports = {
|
|
|
57
57
|
|
|
58
58
|
// Truth-state chokepoint (Plan 109-04 LIVE).
|
|
59
59
|
promoteNodeStatus: transitions.promoteNodeStatus,
|
|
60
|
+
|
|
61
|
+
// Memory-event logging (Phase 110-03 -- a thin re-export so brain-client.cjs can log the
|
|
62
|
+
// brain_packet_rejected / brain_response_rejected / brain_legacy_path_used events without
|
|
63
|
+
// reaching into the internal navigation/memory-events.cjs. The closed navigation surface is
|
|
64
|
+
// the DOCUMENTED 13-function API; the implementation re-exports internal helpers as needed.)
|
|
65
|
+
logMemoryEvent: memoryEvents.logEvent,
|
|
60
66
|
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* lib/core/resolve-brain-key.cjs -- the ONE resolver for "where is the Brain
|
|
4
|
+
* API key on this machine?" (Phase 123 Plan-07).
|
|
5
|
+
*
|
|
6
|
+
* Background: before this module, three different places guessed independently
|
|
7
|
+
* how to discover the Brain key (brain-client.cjs::getApiKey, session-start's
|
|
8
|
+
* shell test of MINDRIAN_BRAIN_KEY, brain-connector's skill detection). Three
|
|
9
|
+
* guessers, three failure modes -- a standard install with the key in
|
|
10
|
+
* ~/.mindrian.env was visible to brain-client but invisible to session-start,
|
|
11
|
+
* so a working HTTP-path Brain still printed a Tier-0 MCP warning. This
|
|
12
|
+
* collapses them into one source of truth. Mirrors active-plugin-root.cjs
|
|
13
|
+
* (the resolver this one is patterned on).
|
|
14
|
+
*
|
|
15
|
+
* Precedence (first hit wins) per D-31:
|
|
16
|
+
* 1. MINDRIAN_BRAIN_KEY env var -- explicit operator intent, highest.
|
|
17
|
+
* 2. <home>/.mindrian.env -- global backup, persists across CWDs.
|
|
18
|
+
* 3. <cwd>/.env -- project-local override.
|
|
19
|
+
* 4. not-found.
|
|
20
|
+
*
|
|
21
|
+
* IMPORTANT (FLAG-3 fix). The default for `home` is
|
|
22
|
+
* process.env.HOME || process.env.USERPROFILE || os.homedir()
|
|
23
|
+
* -- NOT bare os.homedir(). On Linux/POSIX, os.homedir() reads /etc/passwd and
|
|
24
|
+
* IGNORES process.env.HOME, which breaks hermetic tests that override HOME to
|
|
25
|
+
* a scratch directory. The env-aware default matches the precedent in
|
|
26
|
+
* scripts/doctor.cjs, scripts/session-start, and lib/core/active-plugin-root.cjs.
|
|
27
|
+
*
|
|
28
|
+
* Returns { key, source, available, reason }:
|
|
29
|
+
* - available=true: key is the trimmed value, reason is null.
|
|
30
|
+
* - available=false: key is null, reason is the explicit cause string
|
|
31
|
+
* (never a silent null -- SEC-02 rejections route here too).
|
|
32
|
+
*
|
|
33
|
+
* SEC-02 permission check (POSIX-only, no-op on Windows). When the resolved
|
|
34
|
+
* source is a key file (~/.mindrian.env or CWD .env), stat the mode. If any
|
|
35
|
+
* group or world bit is set (mode & 0o077), return available:false with the
|
|
36
|
+
* explicit reason "permissions too open: <path> is mode 0NNN, must be 0600".
|
|
37
|
+
*
|
|
38
|
+
* Use as a module:
|
|
39
|
+
* const { resolveBrainKey } = require('<...>/lib/core/resolve-brain-key.cjs');
|
|
40
|
+
* const r = resolveBrainKey(); // r.key, r.source, r.available, r.reason
|
|
41
|
+
*
|
|
42
|
+
* Use as a CLI (so scripts/session-start can shell out without a JSON parser
|
|
43
|
+
* in bash):
|
|
44
|
+
* node lib/core/resolve-brain-key.cjs -> prints JSON, exits 0
|
|
45
|
+
*
|
|
46
|
+
* Canon Part 8: this reads LOCAL files and env only. Zero network surface.
|
|
47
|
+
* The plan's verification grep against this file's source returns nothing
|
|
48
|
+
* -- no network markers in this resolver, ever. The actual Brain call is
|
|
49
|
+
* brain-client.cjs's job; this module only DISCOVERS whether a key exists.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const fs = require('node:fs');
|
|
53
|
+
const path = require('node:path');
|
|
54
|
+
const os = require('node:os');
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* SEC-02 permission gate. POSIX-only -- on Windows POSIX mode bits do not
|
|
58
|
+
* translate to NTFS ACLs, so we return ok unconditionally there (a single
|
|
59
|
+
* stderr note would couple this resolver to a side-effect; the calling layer
|
|
60
|
+
* is the right place to surface that one-time note).
|
|
61
|
+
*
|
|
62
|
+
* mode & 0o077 !== 0 => any group or world bit set => reject.
|
|
63
|
+
* 0o600 / 0o400 pass; 0o644 / 0o664 / 0o666 fail.
|
|
64
|
+
*
|
|
65
|
+
* On stat failure (file vanished between exists() and stat() -- rare but
|
|
66
|
+
* possible) we treat it as not-ok with an explanatory reason rather than
|
|
67
|
+
* pretending the file is fine.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} p the file path
|
|
70
|
+
* @returns {{ok: boolean, reason: string|null}}
|
|
71
|
+
*/
|
|
72
|
+
function checkFilePermissions(p) {
|
|
73
|
+
if (process.platform === 'win32') {
|
|
74
|
+
return { ok: true, reason: null };
|
|
75
|
+
}
|
|
76
|
+
let stat;
|
|
77
|
+
try {
|
|
78
|
+
stat = fs.statSync(p);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { ok: false, reason: 'unable to stat ' + p + ': ' + (e && e.code || String(e)) };
|
|
81
|
+
}
|
|
82
|
+
const mode = stat.mode & 0o777;
|
|
83
|
+
if ((mode & 0o077) !== 0) {
|
|
84
|
+
const modeStr = mode.toString(8).padStart(3, '0');
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: 'permissions too open: ' + p + ' is mode 0' + modeStr + ', must be 0600',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, reason: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse a single MINDRIAN_BRAIN_KEY=... line out of a (possibly multi-line)
|
|
95
|
+
* env-file body. Returns the trimmed value, or null if the line is absent or
|
|
96
|
+
* the value is empty. We deliberately do NOT use a full dotenv parser -- one
|
|
97
|
+
* variable, one regex, no transitive dependency.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} body
|
|
100
|
+
* @returns {string|null}
|
|
101
|
+
*/
|
|
102
|
+
function _parseKey(body) {
|
|
103
|
+
const m = body.match(/^MINDRIAN_BRAIN_KEY\s*=\s*(.+?)\s*$/m);
|
|
104
|
+
if (!m) return null;
|
|
105
|
+
const v = m[1].trim();
|
|
106
|
+
return v.length > 0 ? v : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the Brain API key from the standard precedence chain.
|
|
111
|
+
*
|
|
112
|
+
* The `home` and `cwd` parameters exist as test seams. Their defaults match
|
|
113
|
+
* the precedent across the rest of the plugin (scripts/doctor.cjs, scripts/
|
|
114
|
+
* session-start, lib/core/active-plugin-root.cjs): env-aware home resolution
|
|
115
|
+
* so hermetic tests overriding process.env.HOME actually work on Linux/POSIX,
|
|
116
|
+
* where os.homedir() reads /etc/passwd and ignores the env override.
|
|
117
|
+
*
|
|
118
|
+
* @param {{home?: string, cwd?: string}} [opts]
|
|
119
|
+
* @returns {{key: string|null, source: 'env'|'mindrian-env-file'|'cwd-env-file'|'not-found', available: boolean, reason: string|null}}
|
|
120
|
+
*/
|
|
121
|
+
function resolveBrainKey(opts) {
|
|
122
|
+
const o = opts || {};
|
|
123
|
+
const home = o.home || process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
124
|
+
const cwd = o.cwd || process.cwd();
|
|
125
|
+
|
|
126
|
+
// (1) Env var wins.
|
|
127
|
+
if (process.env.MINDRIAN_BRAIN_KEY) {
|
|
128
|
+
const v = String(process.env.MINDRIAN_BRAIN_KEY).trim();
|
|
129
|
+
if (v.length > 0) {
|
|
130
|
+
return { key: v, source: 'env', available: true, reason: null };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// (2) <home>/.mindrian.env
|
|
135
|
+
const mindrianEnvPath = path.join(home, '.mindrian.env');
|
|
136
|
+
if (fs.existsSync(mindrianEnvPath)) {
|
|
137
|
+
const perms = checkFilePermissions(mindrianEnvPath);
|
|
138
|
+
if (!perms.ok) {
|
|
139
|
+
return { key: null, source: 'mindrian-env-file', available: false, reason: perms.reason };
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const body = fs.readFileSync(mindrianEnvPath, 'utf8');
|
|
143
|
+
const v = _parseKey(body);
|
|
144
|
+
if (v) {
|
|
145
|
+
return { key: v, source: 'mindrian-env-file', available: true, reason: null };
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return {
|
|
149
|
+
key: null,
|
|
150
|
+
source: 'mindrian-env-file',
|
|
151
|
+
available: false,
|
|
152
|
+
reason: 'unable to read ' + mindrianEnvPath + ': ' + (e && e.code || String(e)),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// (3) <cwd>/.env
|
|
158
|
+
const cwdEnvPath = path.join(cwd, '.env');
|
|
159
|
+
if (fs.existsSync(cwdEnvPath)) {
|
|
160
|
+
const perms = checkFilePermissions(cwdEnvPath);
|
|
161
|
+
if (!perms.ok) {
|
|
162
|
+
return { key: null, source: 'cwd-env-file', available: false, reason: perms.reason };
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const body = fs.readFileSync(cwdEnvPath, 'utf8');
|
|
166
|
+
const v = _parseKey(body);
|
|
167
|
+
if (v) {
|
|
168
|
+
return { key: v, source: 'cwd-env-file', available: true, reason: null };
|
|
169
|
+
}
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return {
|
|
172
|
+
key: null,
|
|
173
|
+
source: 'cwd-env-file',
|
|
174
|
+
available: false,
|
|
175
|
+
reason: 'unable to read ' + cwdEnvPath + ': ' + (e && e.code || String(e)),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// (4) not-found.
|
|
181
|
+
return {
|
|
182
|
+
key: null,
|
|
183
|
+
source: 'not-found',
|
|
184
|
+
available: false,
|
|
185
|
+
reason: 'MINDRIAN_BRAIN_KEY not set (env), and neither ' + mindrianEnvPath
|
|
186
|
+
+ ' nor ' + cwdEnvPath + ' contains a MINDRIAN_BRAIN_KEY line',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { resolveBrainKey, checkFilePermissions };
|
|
191
|
+
|
|
192
|
+
// CLI entry point. session-start shells out via:
|
|
193
|
+
// node lib/core/resolve-brain-key.cjs
|
|
194
|
+
// and parses the JSON. Exit 0 always (the consumer reads the JSON to learn
|
|
195
|
+
// available/reason); a non-zero exit here would surface as "resolver failed"
|
|
196
|
+
// rather than the clearer not-found / permissions-too-open branches.
|
|
197
|
+
if (require.main === module) {
|
|
198
|
+
const r = resolveBrainKey();
|
|
199
|
+
process.stdout.write(JSON.stringify(r) + '\n');
|
|
200
|
+
process.exit(0);
|
|
201
|
+
}
|