futura-scion 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -592,6 +592,40 @@ with the violation evidence attached — the human chooses refactor, move,
592
592
  or amend the declaration, via the same `scion review` loop as every other
593
593
  escalation.
594
594
 
595
+ #### Hub rules — shape, not just edges (`architecture.hubs`)
596
+
597
+ Edges police direction; HUBS police concentration, checked against the
598
+ schematic's measured fan-in/fan-out (`scion map` design data — curator
599
+ models derive the same numbers from edges, so no map build is required):
600
+
601
+ ```yaml
602
+ architecture:
603
+ hubs:
604
+ max_fan_in: 100 # no file imported by more than N (change magnets)
605
+ max_fan_out: 20 # no file importing more than N (God modules)
606
+ max_layer_fan_in: 300 # no layer funneling more inbound edges than N
607
+ ignore: [vendor, gen] # path prefixes exempt from hub accounting
608
+ ```
609
+
610
+ Hub violations are analyzer-shaped **warnings** (`arch-hub-fan-in`,
611
+ `arch-hub-fan-out`, `arch-hub-layer-fan-in`) carrying the measured number,
612
+ the declared ceiling, and the file — a design smell escalates to the human
613
+ arch lane, never machine-patched. All thresholds are opt-in (empty = not
614
+ checked); malformed or typo'd keys fail loud. Live on FS's own source at
615
+ diagnostic thresholds: `kernel/trail.js` flagged at 173 inbound edges
616
+ (change magnet), `daemon.js` at 22 imports (God module).
617
+
618
+ #### Map-first THINK (`schematic.enabled`)
619
+
620
+ Every ladder decision attaches the schematic digest — layers, entries,
621
+ hubs, exported surface — as context for all downstream rungs (generator
622
+ prompts, analyzer reasoning, research queries). Served from a warm cache
623
+ (`schematic.digest_ttl_ms`, default 30000) so latency-bounded scans are
624
+ never starved: one refresh per TTL window, amortized across every worker
625
+ and daemon scan. `taskId` and `mapContext` are ambient keys — stripped
626
+ before signature hashing, so identical problems replay identically across
627
+ task ids and map refreshes.
628
+
595
629
  ### Command over tools — the research rung (`scion tools`)
596
630
 
597
631
  FS's rungs answer from what it knows. The RESEARCH rung gives it hands:
@@ -11,8 +11,12 @@ ladder:
11
11
  min_insight_confidence: 0.55 # reasoner rung admission floor
12
12
 
13
13
  llm:
14
- daily_tokens: 0 # 0 = LLM rung off (zero-LLM doctrine)
14
+ daily_tokens: 500
15
15
 
16
+ apiKey: null
17
+ model: "qwen2.5-coder:7b"
18
+ baseUrl: "http://localhost:11434/v1"
19
+ provider: "ollama"
16
20
  bounds:
17
21
  max_turns: 25
18
22
  timeout_ms: 300000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "futura-scion",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "The fused scion of cortex-os-agent + persona: one zero-LLM-dependent agent stack — Mind proposes, Muscle executes, Gate disposes.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/config.js CHANGED
@@ -27,7 +27,8 @@ export const DEFAULTS = Object.freeze({
27
27
  forge: { min_support: 3, min_precision: 0.7, max_trigger_terms: 3, max_seeds_per_run: 5 },
28
28
  queue: { max_attempts: 3 },
29
29
  analyzer: { enabled: true },
30
- architecture: { rules: [] }, // declared layer/dependency-direction rules (mind/architecture.js)
30
+ schematic: { enabled: true, digest_ttl_ms: 30000 }, // map-first THINK: inject the codebase digest into every ladder decide (warm-cache TTL)
31
+ architecture: { rules: [], hubs: { max_fan_in: null, max_fan_out: null, max_layer_fan_in: null, ignore: [] } }, // declared layer rules (mind/architecture.js); hubs thresholds are the schematic's measured shape (null = not checked)
31
32
  daemon: { auto_fix: false, max_fixes_per_scan: 10 },
32
33
  muscle: { path_deny: [], path_allow: [] }, // empty deny = shipped defaults
33
34
  api: { token: null, port: 5107, host: '127.0.0.1' },
package/src/ladder.js CHANGED
@@ -27,7 +27,12 @@ import * as replay from './mind/replay.js';
27
27
  import * as seeds from './mind/seeds.js';
28
28
  import * as brainReasoner from './brain/reasoner.js';
29
29
  import * as memory from './brain/memory.js';
30
- import * as trail from './kernel/trail.js';
30
+
31
+ // The warm map: one digest, refreshed at most once per TTL window (see the
32
+ // MAP-FIRST THINK block in decide()). Module-level so a hot daemon's scans
33
+ // and every worker's THINK share the same amortized cost.
34
+ let warmMap = null;
35
+ const MAP_CONTEXT_TTL_MS = 30_000;import * as trail from './kernel/trail.js';
31
36
  import { forge } from './mind/forge.js';
32
37
  import { load as loadConfig } from './config.js';
33
38
  import { analyzeSource, readTargetSource } from './mind/analyzer-rung.js';
@@ -154,6 +159,44 @@ export async function decide(problem, opts = {}) {
154
159
  const memoryScope = opts.memoryScope ?? cfg.memory.scope ?? 'auto';
155
160
  const minInsightConfidence = opts.minInsightConfidence ?? cfg.ladder.min_insight_confidence;
156
161
 
162
+ // MAP-FIRST THINK (the schematics contract): every task starts from the
163
+ // codebase map, not from re-reading source. The digest is the one-screen
164
+ // briefing — layers, entries, hubs, surface — attached to the input as
165
+ // `mapContext` for every downstream rung (analyzer reasoning, generator
166
+ // prompts, research queries).
167
+ // COST CONTRACT: mapFor() re-parses only changed files, but even its
168
+ // hash-only pass reads+hashes every file — per-decide that is real money
169
+ // on large trees and it starves latency-bounded scans. THINK context
170
+ // tolerates seconds of staleness, so the digest is served from a WARM
171
+ // cache (default 30s TTL, config schematic.digest_ttl_ms): at most one
172
+ // refresh per TTL window. Anything that needs fresh truth (the analyzer
173
+ // rung, the Gate) reads the real source as always — context never
174
+ // misleads: a map built for a different root, or disabled in config,
175
+ // is skipped silently.
176
+ if (cfg.schematic?.enabled !== false && !input.mapContext) {
177
+ try {
178
+ const ttl = Number(cfg.schematic?.digest_ttl_ms ?? MAP_CONTEXT_TTL_MS);
179
+ const stale = !warmMap || warmMap.root !== project || (Date.now() - warmMap.at > ttl);
180
+ if (stale) {
181
+ const sch = await import('./mind/schematic.js');
182
+ const prev = sch.loadModel();
183
+ const mapRoot = prev?.root ?? project;
184
+ const { model } = sch.mapFor(mapRoot);
185
+ warmMap = model && model.totalSymbols > 0
186
+ ? { root: model.root, at: Date.now(), digest: sch.digest(model) }
187
+ : null;
188
+ }
189
+ if (warmMap?.digest) {
190
+ input.mapContext = warmMap.digest;
191
+ trail.journal('ladder.map-context', {
192
+ root: warmMap.root, cachedMs: Date.now() - warmMap.at,
193
+ });
194
+ }
195
+ } catch (err) {
196
+ trail.journal('ladder.map-context-skipped', { error: String(err?.message || err).slice(0, 120) });
197
+ }
198
+ }
199
+
157
200
  // Rung 1 — replay (TEXT-ONLY problems): the zero-generation fast path.
158
201
  // For problems carrying a concrete file the analyzer (below) runs FIRST:
159
202
  // a replay verdict for a file task is an OUTCOME RECORD ("this was fixed
@@ -127,26 +127,183 @@ export function normalizeRules(archCfg) {
127
127
  });
128
128
  }
129
129
 
130
+ /**
131
+ * Validate the `architecture.hubs` config section — hub-based layer rules
132
+ * checked against the SCHEMATIC's measured fan-in/fan-out (mind/schematic
133
+ * design data). Where `rules` police EDGES (who may import whom), hubs
134
+ * police SHAPE (how concentrated the dependency graph has become):
135
+ *
136
+ * architecture:
137
+ * hubs:
138
+ * max_fan_in: 60 # no file imported by more than 60 (change magnet)
139
+ * max_fan_out: 20 # no file importing more than 20 (God module)
140
+ * max_layer_fan_in: 300 # no LAYER gathering more inbound edges than this
141
+ * ignore: [vendor] # path prefixes excluded from hub accounting
142
+ *
143
+ * Every threshold is opt-in (absent = not checked); numbers must be > 0.
144
+ * Fail loud on malformed config — a typo must not silently disable it.
145
+ * @param {object} archCfg — config.architecture (may be undefined)
146
+ * @returns {object|null} normalized hubs or null when unconfigured
147
+ */
148
+ export function normalizeHubs(archCfg) {
149
+ const hubs = archCfg?.hubs;
150
+ if (hubs === undefined || hubs === null) return null;
151
+ if (typeof hubs !== 'object' || Array.isArray(hubs)) {
152
+ throw new Error('architecture: hubs must be a mapping { max_fan_in?, max_fan_out?, max_layer_fan_in?, ignore? }');
153
+ }
154
+ // A typo'd threshold key (max_fan_inn) must not silently disable the
155
+ // check — unknown keys fail loud, same doctrine as merge()'s allowlist.
156
+ const KNOWN = new Set(['max_fan_in', 'max_fan_out', 'max_layer_fan_in', 'ignore']);
157
+ for (const k of Object.keys(hubs)) {
158
+ if (!KNOWN.has(k)) {
159
+ throw new Error(`architecture: unknown hubs field "${k}" — allowed: ${[...KNOWN].join(', ')}`);
160
+ }
161
+ }
162
+ const out = {};
163
+ for (const key of ['max_fan_in', 'max_fan_out', 'max_layer_fan_in']) {
164
+ if (hubs[key] === undefined || hubs[key] === null) continue; // null default = not checked
165
+ const n = Number(hubs[key]);
166
+ if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) {
167
+ throw new Error(`architecture: hubs.${key} must be a positive integer`);
168
+ }
169
+ out[key] = n;
170
+ }
171
+ if (hubs.ignore !== undefined && hubs.ignore !== null) {
172
+ if (!Array.isArray(hubs.ignore) || !hubs.ignore.every(s => typeof s === 'string' && s.trim() !== '')) {
173
+ throw new Error('architecture: hubs.ignore must be an array of non-empty path prefixes');
174
+ }
175
+ out.ignore = hubs.ignore.map(normPrefix);
176
+ }
177
+ if (Object.keys(out).length === 0) {
178
+ return null; // {} (the default) — no hub thresholds declared, nothing to check
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * Hub checks: compare the model's MEASURED concentration (schematic design
185
+ * data) against the DECLARED thresholds. A violation is a design smell,
186
+ * not a broken edge — severity 'warning' (design decisions escalate to
187
+ * humans via the arch task lane; the Gate never machine-patches shape).
188
+ * @param {object} model — schematic model (design.fan_in/fan_out/layers)
189
+ * @param {object} hubs — normalized hubs config
190
+ * @returns {{ violations: Array, stats: object }}
191
+ */
192
+ function checkHubs(model, hubs) {
193
+ const ignore = hubs.ignore ?? [];
194
+ const ignored = (rel) => ignore.some(pfx => under(rel, pfx));
195
+ const violations = [];
196
+ // Fan counts: prefer the schematic's persisted design data when present,
197
+ // otherwise derive from the model's edges (curator models carry no design
198
+ // field — every internal edge increments importer fan-out and imported
199
+ // fan-in, identical arithmetic to the schematic's own derivation).
200
+ const deriveFromEdges = () => {
201
+ const fi = new Map(); const fo = new Map();
202
+ for (const e of model.edges ?? []) {
203
+ if (e.external) continue;
204
+ fo.set(e.from, (fo.get(e.from) ?? 0) + 1);
205
+ fi.set(e.to, (fi.get(e.to) ?? 0) + 1);
206
+ }
207
+ return { fi, fo };
208
+ };
209
+ let fi; let fo;
210
+ if (model.design?.fan_in && model.design?.fan_out) {
211
+ fi = new Map(Object.entries(model.design.fan_in));
212
+ fo = new Map(Object.entries(model.design.fan_out));
213
+ } else {
214
+ ({ fi, fo } = deriveFromEdges());
215
+ }
216
+ const relFan = (m) => Object.fromEntries(
217
+ [...m.entries()].map(([p, n]) => [relTo(model.root, p), n])
218
+ );
219
+ const fanIn = relFan(fi);
220
+ const fanOut = relFan(fo);
221
+
222
+ if (hubs.max_fan_in !== undefined) {
223
+ for (const [file, n] of Object.entries(fanIn)) {
224
+ if (ignored(file)) continue;
225
+ if (n > hubs.max_fan_in) {
226
+ violations.push(hubFinding(file, 'arch-hub-fan-in',
227
+ `hub: ${file} is imported by ${n} files (declared max_fan_in ${hubs.max_fan_in}) — a change magnet: every edit risks ${n} consumers`,
228
+ { fan_in: n, declared_max: hubs.max_fan_in }));
229
+ }
230
+ }
231
+ }
232
+ if (hubs.max_fan_out !== undefined) {
233
+ for (const [file, n] of Object.entries(fanOut)) {
234
+ if (ignored(file)) continue;
235
+ if (n > hubs.max_fan_out) {
236
+ violations.push(hubFinding(file, 'arch-hub-fan-out',
237
+ `hub: ${file} imports ${n} modules (declared max_fan_out ${hubs.max_fan_out}) — a God module: split it along its consumers`,
238
+ { fan_out: n, declared_max: hubs.max_fan_out }));
239
+ }
240
+ }
241
+ }
242
+ if (hubs.max_layer_fan_in !== undefined) {
243
+ // Layer data comes from the schematic; without it, derive per-top-dir
244
+ // inbound edge counts from the edges (same top-of-path keying).
245
+ let layers = model.design?.layers;
246
+ if (!layers) {
247
+ layers = {};
248
+ for (const e of model.edges ?? []) {
249
+ if (e.external) continue;
250
+ const top = relTo(model.root, e.to).split('/')[0] ?? '.';
251
+ layers[top] = layers[top] ?? { files: 0, fan_in: 0, fan_out: 0 };
252
+ layers[top].fan_in += 1;
253
+ }
254
+ }
255
+ for (const [layer, d] of Object.entries(layers)) {
256
+ if (d.fan_in > hubs.max_layer_fan_in) {
257
+ violations.push(hubFinding(`${layer}/`, 'arch-hub-layer-fan-in',
258
+ `hub: layer ${layer} gathers ${d.fan_in} inbound edges across ${d.files} files (declared max_layer_fan_in ${hubs.max_layer_fan_in}) — the architecture is funneling through one layer`,
259
+ { fan_in: d.fan_in, files: d.files, declared_max: hubs.max_layer_fan_in }));
260
+ }
261
+ }
262
+ }
263
+ const stats = {
264
+ max_fan_in_observed: Math.max(0, ...Object.values(fanIn)),
265
+ max_fan_out_observed: Math.max(0, ...Object.values(fanOut)),
266
+ layers: Object.keys(model.design?.layers ?? {}).length,
267
+ };
268
+ return { violations, stats };
269
+ }
270
+
271
+ /** Analyzer-shaped hub finding (design smell → warning severity). */
272
+ function hubFinding(file, rule, description, detail) {
273
+ return {
274
+ file,
275
+ line: 1,
276
+ rule,
277
+ severity: 'warning',
278
+ description,
279
+ text: '',
280
+ ...detail,
281
+ };
282
+ }
283
+
130
284
  /**
131
285
  * Check the model's internal edges against the declared rules.
132
286
  * External (package) imports are out of scope — layers govern YOUR code.
287
+ * When `architecture.hubs` is configured, the schematic's measured
288
+ * fan-in/fan-out is additionally checked against the declared thresholds.
133
289
  *
134
- * @param {object} model — buildModel()/loadModel() output (files + edges)
135
- * @param {object} [opts] — { rules?: normalized rules (defaults from config), now? }
136
- * @returns {{ rules: number, checked: number, violations: analyzer-shaped findings[] }}
290
+ * @param {object} model — buildModel()/loadModel() output (files + edges + design)
291
+ * @param {object} [opts] — { rules?: normalized rules, hubs?: normalized hubs (defaults from config), now? }
292
+ * @returns {{ rules: number, checked: number, violations: analyzer-shaped findings[], hubs?: { stats } }}
137
293
  */
138
294
  export function checkArchitecture(model, opts = {}) {
139
295
  const rules = opts.rules ?? normalizeRules(load().architecture);
140
- if (rules.length === 0) {
296
+ const hubs = opts.hubs !== undefined ? opts.hubs : normalizeHubs(load().architecture);
297
+ let checked = 0;
298
+ const violations = [];
299
+ if (rules.length === 0 && !hubs) {
141
300
  return { rules: 0, checked: model.edges.filter(e => !e.external).length, violations: [] };
142
301
  }
143
302
  // A line number requires source: look the import up in the fragment.
144
- let checked = 0;
145
- const violations = [];
146
303
  for (const edge of model.edges) {
147
304
  if (edge.external) continue;
148
305
  checked++;
149
- if (!rules.some(r => matchesRule(edge, r, model.root))) continue;
306
+ if (rules.length === 0 || !rules.some(r => matchesRule(edge, r, model.root))) continue;
150
307
  // Locate the exact import line for an analyzer-shaped finding
151
308
  // (deterministic first matching import; unreadable source degrades
152
309
  // to line 1 — the violation stands regardless).
@@ -175,7 +332,19 @@ export function checkArchitecture(model, opts = {}) {
175
332
  import_target: edge.to,
176
333
  });
177
334
  }
178
- return { rules: rules.length, checked, violations };
335
+ // HUB checks (schematic shape rules) ride along — independent of edges.
336
+ let hubStats;
337
+ if (hubs) {
338
+ const { violations: hubV, stats } = checkHubs(model, hubs);
339
+ violations.push(...hubV);
340
+ hubStats = stats;
341
+ }
342
+ return {
343
+ rules: rules.length,
344
+ checked,
345
+ violations,
346
+ ...(hubs ? { hubs: { checked: true, stats: hubStats } } : {}),
347
+ };
179
348
  }
180
349
 
181
350
  /**
@@ -94,7 +94,14 @@ export async function generateCandidate(problem, opts = {}) {
94
94
  const structured = context && typeof context === 'object' && Array.isArray(context.nodes)
95
95
  ? buildCallTreeContext(context, problem)
96
96
  : null;
97
- raw = await generator({ ...problem, ...(context ? { context } : {}), ...(structured ? { prompt: structured } : {}) });
97
+ // Map-first THINK: the codebase digest rides along (mapContext) — the
98
+ // generator sees the architecture briefing without anyone re-reading
99
+ // source to build it.
100
+ raw = await generator({
101
+ ...problem,
102
+ ...(context ? { context } : {}),
103
+ ...(structured ? { prompt: structured } : {}),
104
+ });
98
105
  } catch (err) {
99
106
  trail.journal('generate.error', { error: String(err?.message || err).slice(0, 200) });
100
107
  return null;
@@ -21,9 +21,18 @@ import { normalize as nluNormalize } from './nlu.js';
21
21
 
22
22
  export const MIN_REPLAY_CONFIDENCE = 0.5;
23
23
 
24
+ // AMBIENT keys — context attached to a problem by the harness (routing and
25
+ // map-first THINK metadata) that must NEVER enter a signature: identical
26
+ // problems must replay identically regardless of which task id carries them
27
+ // or how fresh the codebase map was when they arrived.
28
+ const AMBIENT_KEYS = new Set(['taskId', 'mapContext']);
29
+
24
30
  /** Deterministic signature for a repeatable input. */
25
31
  export function signatureOf(input) {
26
- return createHash('sha1').update(JSON.stringify(input)).digest('hex').slice(0, 24);
32
+ const clean = input && typeof input === 'object' && !Array.isArray(input)
33
+ ? Object.fromEntries(Object.entries(input).filter(([k]) => !AMBIENT_KEYS.has(k)))
34
+ : input;
35
+ return createHash('sha1').update(JSON.stringify(clean)).digest('hex').slice(0, 24);
27
36
  }
28
37
 
29
38
  /**