@mindrian_os/install 1.13.0-beta.11 → 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 +68 -3
- package/bin/cli.js +114 -57
- package/commands/act.md +16 -2
- package/commands/auto-explore.md +1 -0
- package/commands/doctor.md +1 -1
- package/commands/operator.md +1 -1
- package/commands/pipeline.md +16 -1
- package/commands/setup.md +7 -3
- package/commands/suggest-next.md +17 -3
- package/lib/core/active-plugin-root.cjs +207 -0
- package/lib/core/brain-client.cjs +451 -36
- package/lib/core/cache-prune.cjs +208 -0
- package/lib/core/framework-chain-composer.cjs +156 -43
- package/lib/core/migrations/phase-109-nodes-provenance.cjs +47 -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/hmi/jtbd-taxonomy.json +2 -1
- package/lib/memory/framework-chain-composer.test.cjs +54 -20
- package/lib/memory/navigation-hook-resolver.test.cjs +177 -0
- package/lib/memory/run-feynman-tests.cjs +102 -0
- package/lib/memory/security-trifecta.test.cjs +23 -6
- package/lib/memory/suggest-next-workflow.test.cjs +176 -0
- package/lib/memory/workflow-layer-e2e.test.cjs +262 -0
- package/lib/workflow/ROOM.md +1 -1
- package/package.json +4 -1
- package/references/brain/command-triggers-schema.md +10 -221
- package/references/methodology/index.md +11 -74
- package/skills/brain-connector/SKILL.md +12 -8
- package/skills/pws-methodology/SKILL.md +7 -5
|
@@ -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
|
+
};
|
|
@@ -12,6 +12,31 @@
|
|
|
12
12
|
* an engine.offer_next_step candidate consumed by the navigation engine
|
|
13
13
|
* (Plan 91-00) and rendered through the offer presenter (Plan 91-04).
|
|
14
14
|
*
|
|
15
|
+
* Phase 122-04 -- routed through the resolver (the only door)
|
|
16
|
+
* ==========================================================
|
|
17
|
+
* proposeNextFramework() now resolves the next framework's /mos: command
|
|
18
|
+
* via lib/workflow/command-resolver.cjs commandsForFramework() -- the SOLE
|
|
19
|
+
* deterministic framework -> command path, reading only the generated
|
|
20
|
+
* data/command-registry.json. When the registry has no command for that
|
|
21
|
+
* framework, command degrades to null ("no /mos: for [framework] yet" --
|
|
22
|
+
* degrade, not fabricate per WORKFLOW-LAYER-SPEC reliability rule 5); the
|
|
23
|
+
* offer presenter already treats a null/empty command as not-an-offer.
|
|
24
|
+
* proposeNextFramework also returns a workflow field -- the resolver's
|
|
25
|
+
* composeWorkflow([completed, next, ...successors]) array -- so a multi-hop
|
|
26
|
+
* FEEDS_INTO chain is available as data on the proposal (a future plan can
|
|
27
|
+
* carry it into offer_next_step / shape-f1-renderer; this plan only puts
|
|
28
|
+
* the data there). mapFrameworkToCommandSlug() relies solely on the
|
|
29
|
+
* resolver (then FALLBACK_COMMAND_SLUG) so any remaining caller also gets
|
|
30
|
+
* the resolver answer.
|
|
31
|
+
*
|
|
32
|
+
* Phase 122-05 -- the residual map pruned
|
|
33
|
+
* =======================================
|
|
34
|
+
* FRAMEWORK_TO_COMMAND_SLUG is now Object.freeze({}) -- the resolver is the
|
|
35
|
+
* ONLY framework-to-command door (data/command-registry.json, generated from
|
|
36
|
+
* frontmatter; WORKFLOW-LAYER-SPEC reliability rule 1). The empty table is
|
|
37
|
+
* kept only as a back-compat export. KNOWN_FRAMEWORKS stays exported as a
|
|
38
|
+
* name-recognition bootstrap (it is NOT the framework-to-command source).
|
|
39
|
+
*
|
|
15
40
|
* Canon Part 2 Engine 1 + Appendix E:
|
|
16
41
|
* Framework chains power Act 1 -> BONO Orchestration handoffs.
|
|
17
42
|
* FEEDS_INTO is the Brain-flagged graph infrastructure (~40 edges in
|
|
@@ -49,6 +74,11 @@
|
|
|
49
74
|
const fs = require('node:fs');
|
|
50
75
|
const path = require('node:path');
|
|
51
76
|
|
|
77
|
+
// The resolver (the only door). Required lazily inside the functions that
|
|
78
|
+
// need it so a missing module never crashes a caller that does not use the
|
|
79
|
+
// resolver path -- but it is an in-repo sibling so this never fails in
|
|
80
|
+
// practice. Reads only data/command-registry.json; never touches the Brain.
|
|
81
|
+
|
|
52
82
|
// ---------- Frozen constants ----------
|
|
53
83
|
|
|
54
84
|
// Confidence gates per locked decision in PLAN frontmatter:
|
|
@@ -63,12 +93,14 @@ const RECOMMENDED_FLOOR = 0.7;
|
|
|
63
93
|
// plans may layer richer signal on top).
|
|
64
94
|
const RECENT_WRITE_WINDOW_MS = 5 * 60 * 1000;
|
|
65
95
|
|
|
66
|
-
// Bootstrap KNOWN_FRAMEWORKS list.
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
96
|
+
// Bootstrap KNOWN_FRAMEWORKS list. NAME-RECOGNITION BOOTSTRAP ONLY -- this is
|
|
97
|
+
// NOT the framework-to-command source (that is OWNED by lib/workflow/
|
|
98
|
+
// command-resolver.cjs, reading the generated data/command-registry.json).
|
|
99
|
+
// detectCompletedFramework() uses this list to recognize a framework name in a
|
|
100
|
+
// governing thought / a filed-artifact slug; the list is conservative; a
|
|
101
|
+
// Brain-derived FEEDS_INTO edge may reference a framework outside this set, in
|
|
102
|
+
// which case completion detection falls through to the mtime slug fallback.
|
|
103
|
+
// The list is extensible; future plans may pull from the Brain frameworks
|
|
72
104
|
// catalog directly.
|
|
73
105
|
const KNOWN_FRAMEWORKS = Object.freeze([
|
|
74
106
|
'SWOT Analysis',
|
|
@@ -91,31 +123,14 @@ const KNOWN_FRAMEWORKS = Object.freeze([
|
|
|
91
123
|
'Rich Pictures',
|
|
92
124
|
]);
|
|
93
125
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
'value chain analysis': 'beautiful-question',
|
|
103
|
-
'business model canvas': 'beautiful-question',
|
|
104
|
-
'lean canvas': 'lean-canvas',
|
|
105
|
-
'jobs-to-be-done': 'beautiful-question',
|
|
106
|
-
'value proposition canvas': 'beautiful-question',
|
|
107
|
-
'5 whys': 'beautiful-question',
|
|
108
|
-
'first principles': 'beautiful-question',
|
|
109
|
-
'design thinking': 'beautiful-question',
|
|
110
|
-
'blue ocean strategy': 'beautiful-question',
|
|
111
|
-
"innovator's dilemma": 'beautiful-question',
|
|
112
|
-
'7 s framework': 'beautiful-question',
|
|
113
|
-
'balanced scorecard': 'beautiful-question',
|
|
114
|
-
'mullins': 'mullins',
|
|
115
|
-
'beautiful question': 'beautiful-question',
|
|
116
|
-
'soft systems': 'beautiful-question',
|
|
117
|
-
'rich pictures': 'beautiful-question',
|
|
118
|
-
});
|
|
126
|
+
// Framework-to-command mapping is OWNED by lib/workflow/command-resolver.cjs
|
|
127
|
+
// (the generated data/command-registry.json, built from each command's
|
|
128
|
+
// frontmatter -- WORKFLOW-LAYER-SPEC reliability rule 1: a single source of
|
|
129
|
+
// truth, nothing else asserts the mapping). Phase 122-05 pruned this table to
|
|
130
|
+
// an empty Object.freeze({}); it is kept ONLY as an empty back-compat export
|
|
131
|
+
// so any caller that still imports FRAMEWORK_TO_COMMAND_SLUG does not crash.
|
|
132
|
+
// Do NOT add entries here -- declare `frameworks:` in the command frontmatter.
|
|
133
|
+
const FRAMEWORK_TO_COMMAND_SLUG = Object.freeze({});
|
|
119
134
|
|
|
120
135
|
// Default fallback command when the next framework has no known mapping.
|
|
121
136
|
const FALLBACK_COMMAND_SLUG = 'beautiful-question';
|
|
@@ -304,7 +319,12 @@ function detectCompletedFramework(roomDir, sectionPath, reasoning) {
|
|
|
304
319
|
* confidence: number,
|
|
305
320
|
* source: 'FEEDS_INTO',
|
|
306
321
|
* phase_indicator: string|null,
|
|
307
|
-
* command: string,
|
|
322
|
+
* command: string|null, // '/mos:<slug>' from the resolver, or null
|
|
323
|
+
* // when the registry has no command for
|
|
324
|
+
* // `next` yet (degrade, do not fabricate)
|
|
325
|
+
* workflow: Array|null, // resolver.composeWorkflow([completed, next, ...])
|
|
326
|
+
* // -- the multi-hop chain as data; the engine
|
|
327
|
+
* // does not propagate it yet (future plan)
|
|
308
328
|
* reason: string, // grounding text (FEEDS_INTO + Brain + confidence)
|
|
309
329
|
* recommended_eligible: boolean, // true when confidence >= 0.7
|
|
310
330
|
* }
|
|
@@ -353,12 +373,44 @@ function proposeNextFramework(completedFramework, edges) {
|
|
|
353
373
|
// the noise gate (we cannot certify a confidenceless edge).
|
|
354
374
|
if (conf === null || conf < NOISE_FLOOR) return null;
|
|
355
375
|
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
376
|
+
// Resolve next -> /mos: command via the resolver (the only door). The
|
|
377
|
+
// resolver reads only data/command-registry.json; it never touches the
|
|
378
|
+
// Brain. When the registry has no command for `next`, command degrades
|
|
379
|
+
// to null -- "no /mos: for [framework] yet" -- never a fabricated one
|
|
380
|
+
// (WORKFLOW-LAYER-SPEC reliability rule 5). The offer presenter already
|
|
381
|
+
// treats a null/empty command as not-an-offer.
|
|
382
|
+
let resolver = null;
|
|
383
|
+
try {
|
|
384
|
+
resolver = require('../workflow/command-resolver.cjs');
|
|
385
|
+
} catch (_e) {
|
|
386
|
+
resolver = null;
|
|
387
|
+
}
|
|
388
|
+
let command = null;
|
|
389
|
+
let workflow = null;
|
|
390
|
+
if (resolver) {
|
|
391
|
+
try {
|
|
392
|
+
const cmds = resolver.commandsForFramework(top.to);
|
|
393
|
+
command = (Array.isArray(cmds) && cmds.length > 0) ? cmds[0] : null;
|
|
394
|
+
} catch (_e) {
|
|
395
|
+
command = null;
|
|
396
|
+
}
|
|
397
|
+
// Multi-step path: build the resolver's composeWorkflow for the chain
|
|
398
|
+
// [completedFramework, next, ...further FEEDS_INTO successors up to ~3].
|
|
399
|
+
// This puts the multi-hop chain on the proposal as data; the navigation
|
|
400
|
+
// engine does not propagate `workflow` into offer_next_step in this plan
|
|
401
|
+
// (the presenter / shape-f1-renderer wiring is a future plan's job).
|
|
402
|
+
try {
|
|
403
|
+
const chain = collectForwardChain(completedFramework, edges, 3);
|
|
404
|
+
workflow = resolver.composeWorkflow(chain);
|
|
405
|
+
} catch (_e) {
|
|
406
|
+
workflow = null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
359
409
|
|
|
360
410
|
// Grounding-rule reason: must contain FEEDS_INTO + Brain + the
|
|
361
|
-
// confidence number per Plan 91-04 presenter contract + Test 13.
|
|
411
|
+
// confidence number per Plan 91-04 presenter contract + Test 13. When
|
|
412
|
+
// there is no command for `next`, the reason still names the framework
|
|
413
|
+
// (the consumer prints "run [framework] manually" rather than a command).
|
|
362
414
|
const confStr = conf.toFixed(2);
|
|
363
415
|
const reason =
|
|
364
416
|
completedFramework + ' FEEDS_INTO ' + top.to +
|
|
@@ -370,26 +422,81 @@ function proposeNextFramework(completedFramework, edges) {
|
|
|
370
422
|
source: 'FEEDS_INTO',
|
|
371
423
|
phase_indicator: (typeof top.phase_indicator === 'string') ? top.phase_indicator : null,
|
|
372
424
|
command: command,
|
|
425
|
+
workflow: workflow,
|
|
373
426
|
reason: reason,
|
|
374
427
|
recommended_eligible: conf >= RECOMMENDED_FLOOR,
|
|
375
428
|
};
|
|
376
429
|
}
|
|
377
430
|
|
|
431
|
+
/**
|
|
432
|
+
* collectForwardChain(start, edges, maxHops) -> [start, next, ...]
|
|
433
|
+
*
|
|
434
|
+
* Walks the highest-confidence FEEDS_INTO edge from `start`, then from that
|
|
435
|
+
* successor, etc., up to `maxHops` hops. Cycle-safe (a framework already in
|
|
436
|
+
* the chain stops the walk). Only follows edges that pass the noise floor.
|
|
437
|
+
* Returns at least [start]. Pure: no I/O.
|
|
438
|
+
*
|
|
439
|
+
* @param {string} start
|
|
440
|
+
* @param {Array} edges
|
|
441
|
+
* @param {number} maxHops
|
|
442
|
+
* @returns {string[]}
|
|
443
|
+
*/
|
|
444
|
+
function collectForwardChain(start, edges, maxHops) {
|
|
445
|
+
const chain = [start];
|
|
446
|
+
if (!Array.isArray(edges) || edges.length === 0) return chain;
|
|
447
|
+
const hops = (typeof maxHops === 'number' && maxHops > 0) ? maxHops : 3;
|
|
448
|
+
const seen = new Set([String(start).toLowerCase()]);
|
|
449
|
+
let current = start;
|
|
450
|
+
for (let i = 0; i < hops; i += 1) {
|
|
451
|
+
const cur = String(current).toLowerCase();
|
|
452
|
+
let best = null;
|
|
453
|
+
for (const e of edges) {
|
|
454
|
+
if (!e || typeof e !== 'object') continue;
|
|
455
|
+
if (typeof e.from !== 'string' || typeof e.to !== 'string') continue;
|
|
456
|
+
if (e.from.toLowerCase() !== cur) continue;
|
|
457
|
+
const c = (typeof e.confidence === 'number') ? e.confidence : null;
|
|
458
|
+
if (c === null || c < NOISE_FLOOR) continue;
|
|
459
|
+
if (best === null || c > best.confidence) best = { to: e.to, confidence: c };
|
|
460
|
+
}
|
|
461
|
+
if (best === null) break;
|
|
462
|
+
const nextLc = best.to.toLowerCase();
|
|
463
|
+
if (seen.has(nextLc)) break;
|
|
464
|
+
chain.push(best.to);
|
|
465
|
+
seen.add(nextLc);
|
|
466
|
+
current = best.to;
|
|
467
|
+
}
|
|
468
|
+
return chain;
|
|
469
|
+
}
|
|
470
|
+
|
|
378
471
|
/**
|
|
379
472
|
* mapFrameworkToCommandSlug(name) -> slug
|
|
380
473
|
*
|
|
381
|
-
* Maps a framework name (in any case) to a /mos: command slug via the
|
|
382
|
-
*
|
|
383
|
-
*
|
|
474
|
+
* Maps a framework name (in any case) to a /mos: command slug via the resolver
|
|
475
|
+
* (lib/workflow/command-resolver.cjs commandsForFramework -- the ONLY door,
|
|
476
|
+
* reads only data/command-registry.json, never touches the Brain), falling
|
|
477
|
+
* back to FALLBACK_COMMAND_SLUG ('beautiful-question') when the registry has no
|
|
478
|
+
* command for `name` or the resolver is unavailable. Phase 122-05 removed the
|
|
479
|
+
* legacy in-module table (FRAMEWORK_TO_COMMAND_SLUG is now empty) -- the
|
|
480
|
+
* resolver is authoritative.
|
|
481
|
+
*
|
|
482
|
+
* Note: proposeNextFramework() does NOT use this helper -- it calls
|
|
483
|
+
* commandsForFramework() directly so it can degrade to command:null (the
|
|
484
|
+
* helper keeps a non-null fallback for back-compat with callers that expect
|
|
485
|
+
* a slug string).
|
|
384
486
|
*
|
|
385
487
|
* @param {string} name
|
|
386
488
|
* @returns {string}
|
|
387
489
|
*/
|
|
388
490
|
function mapFrameworkToCommandSlug(name) {
|
|
389
491
|
if (typeof name !== 'string' || name.length === 0) return FALLBACK_COMMAND_SLUG;
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
492
|
+
// Resolver -- the only door.
|
|
493
|
+
try {
|
|
494
|
+
const cmds = require('../workflow/command-resolver.cjs').commandsForFramework(name);
|
|
495
|
+
if (Array.isArray(cmds) && cmds.length > 0) {
|
|
496
|
+
return cmds[0].replace(/^\/mos:/, '');
|
|
497
|
+
}
|
|
498
|
+
} catch (_e) {
|
|
499
|
+
// resolver unavailable -> fall through to the fallback slug
|
|
393
500
|
}
|
|
394
501
|
return FALLBACK_COMMAND_SLUG;
|
|
395
502
|
}
|
|
@@ -400,7 +507,13 @@ module.exports = {
|
|
|
400
507
|
parseFrameworkChainSection: parseFrameworkChainSection,
|
|
401
508
|
detectCompletedFramework: detectCompletedFramework,
|
|
402
509
|
proposeNextFramework: proposeNextFramework,
|
|
403
|
-
|
|
510
|
+
collectForwardChain: collectForwardChain,
|
|
511
|
+
// Exported for back-compat: relies solely on the resolver, then the
|
|
512
|
+
// fallback slug. Phase 122-05 removed the legacy in-module table.
|
|
513
|
+
mapFrameworkToCommandSlug: mapFrameworkToCommandSlug,
|
|
514
|
+
// KNOWN_FRAMEWORKS is a name-recognition bootstrap (NOT the framework-to-
|
|
515
|
+
// command source). FRAMEWORK_TO_COMMAND_SLUG is an EMPTY back-compat export
|
|
516
|
+
// (Phase 122-05) -- the resolver (data/command-registry.json) is the only door.
|
|
404
517
|
KNOWN_FRAMEWORKS: KNOWN_FRAMEWORKS,
|
|
405
518
|
FRAMEWORK_TO_COMMAND_SLUG: FRAMEWORK_TO_COMMAND_SLUG,
|
|
406
519
|
// Constants exposed for invariant tests + downstream callers.
|
|
@@ -242,6 +242,31 @@ function backfillAssumptionsAsGraphNodes(db) {
|
|
|
242
242
|
return db.prepare("SELECT COUNT(*) AS n FROM assumptions").get().n;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
function dependentSchemaObjects(db) {
|
|
246
|
+
// Enumerate every view and trigger that mentions the legacy `nodes` table in
|
|
247
|
+
// its definition. The SQLite "making other kinds of table schema changes"
|
|
248
|
+
// recipe (the canonical 12-step procedure) requires these to be dropped
|
|
249
|
+
// BEFORE the rename-out-of-existence rebuild and recreated AFTER -- otherwise
|
|
250
|
+
// SQLite re-validates the schema during ALTER TABLE ... RENAME TO, finds the
|
|
251
|
+
// now-dangling view, and throws "error in view <name>: no such table:
|
|
252
|
+
// main.nodes". We do not hardcode rs_discoveries; any future view/trigger on
|
|
253
|
+
// `nodes` is picked up automatically. Drop-then-recreate is idempotent: views
|
|
254
|
+
// whose sql carries IF NOT EXISTS re-exec cleanly; ones without it are simply
|
|
255
|
+
// recreated fresh since we dropped them first.
|
|
256
|
+
const rows = db.prepare(
|
|
257
|
+
"SELECT type, name, sql FROM sqlite_master " +
|
|
258
|
+
"WHERE type IN ('view','trigger') AND sql IS NOT NULL " +
|
|
259
|
+
// \bnodes\b style match: the token "nodes" not immediately followed by an
|
|
260
|
+
// identifier char (so we do not mistakenly catch nodes_new). SQLite LIKE
|
|
261
|
+
// has no word boundaries, so over-match a little and trust the recreate to
|
|
262
|
+
// be a no-op for anything unrelated -- but exclude the obvious nodes_new.
|
|
263
|
+
"AND sql LIKE '%nodes%' AND sql NOT LIKE '%nodes_new%'"
|
|
264
|
+
).all();
|
|
265
|
+
// Defensive: drop NULL/empty sql rows (autogenerated indexes never appear
|
|
266
|
+
// here because we filtered type, but be safe).
|
|
267
|
+
return rows.filter((r) => r && r.name && typeof r.sql === 'string' && r.sql.trim());
|
|
268
|
+
}
|
|
269
|
+
|
|
245
270
|
function tightenSchemaWithCheckConstraints(db) {
|
|
246
271
|
// Step 2: re-create-table-with-NOT-NULL plus CHECK constraints.
|
|
247
272
|
// Canonical SQLite 12-step recipe (foreign_keys disabled for the duration;
|
|
@@ -251,6 +276,19 @@ function tightenSchemaWithCheckConstraints(db) {
|
|
|
251
276
|
// do not flip it here; the BEGIN/COMMIT wrapper guarantees atomicity, and
|
|
252
277
|
// FK behavior is unchanged because the only FK targeting nodes is from edges
|
|
253
278
|
// which we do not drop.
|
|
279
|
+
|
|
280
|
+
// Step 2a: capture and drop every view/trigger that depends on `nodes`. Must
|
|
281
|
+
// happen before DROP TABLE nodes so the schema stays internally consistent
|
|
282
|
+
// through the rename. Recreated verbatim at the end of this function.
|
|
283
|
+
const dependents = dependentSchemaObjects(db);
|
|
284
|
+
for (const obj of dependents) {
|
|
285
|
+
if (obj.type === 'view') {
|
|
286
|
+
db.exec('DROP VIEW IF EXISTS "' + obj.name.replace(/"/g, '""') + '"');
|
|
287
|
+
} else {
|
|
288
|
+
db.exec('DROP TRIGGER IF EXISTS "' + obj.name.replace(/"/g, '""') + '"');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
254
292
|
db.exec(
|
|
255
293
|
"CREATE TABLE nodes_new (" +
|
|
256
294
|
" id TEXT PRIMARY KEY, " +
|
|
@@ -291,6 +329,15 @@ function tightenSchemaWithCheckConstraints(db) {
|
|
|
291
329
|
'CREATE INDEX IF NOT EXISTS idx_nodes_confirmed_by ON nodes(confirmed_by) ' +
|
|
292
330
|
'WHERE confirmed_by IS NOT NULL'
|
|
293
331
|
);
|
|
332
|
+
|
|
333
|
+
// Step 2b: recreate the views/triggers we dropped in Step 2a, now that
|
|
334
|
+
// `nodes` exists again with the tightened schema. The captured `sql` is the
|
|
335
|
+
// exact CREATE statement from sqlite_master; many carry IF NOT EXISTS which
|
|
336
|
+
// keeps the recreate idempotent, and any that do not were dropped above so
|
|
337
|
+
// re-exec is still safe.
|
|
338
|
+
for (const obj of dependents) {
|
|
339
|
+
db.exec(obj.sql);
|
|
340
|
+
}
|
|
294
341
|
}
|
|
295
342
|
|
|
296
343
|
function insertSentinel(db) {
|
|
@@ -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
|
}));
|