futura-scion 0.2.6 → 0.2.8

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:
@@ -824,6 +858,51 @@ the current task needs, retain only what compounds.
824
858
  rung's action threshold — so only REPETITION makes them actionable.
825
859
  One occurrence is an observation; repetition is knowledge.
826
860
 
861
+ ### FS as the learner of its own harness — Codebuff adoption organs (v0.2.8)
862
+
863
+ From studying the open-source Codebuff/Freebuff harnesses, six patterns were
864
+ adopted — each rebuilt to fit the zero-LLM doctrine, none copied as LLM
865
+ machinery:
866
+
867
+ - **Agent templates as data** (`agent-templates/`, `scion templates`): an
868
+ agent is a YAML contract — `spawner_prompt` (how a parent chooses it),
869
+ `input_schema`/`output_schema` (validated at the spawn boundary),
870
+ `tool_allowlist` (least privilege), `capabilities` (routed into the
871
+ kernel's role registry), `bounds`, and `spawnable_agents` — a spawn graph
872
+ that is cycle-checked at load and depth-bounded at spawn. Six templates
873
+ ship (fixer, researcher, mapper, librarian, constructor→verifier,
874
+ verifier). Routing is scored keyword matching, deterministic and journaled:
875
+
876
+ ```bash
877
+ scion templates load|list|route "fix the bug"|spawn <id>
878
+ ```
879
+
880
+ - **Skills — procedural knowledge packs** (`skills/`, `scion skills`):
881
+ `SKILL.md` files with a small frontmatter block (name, description,
882
+ triggers). Parsed loud, name/filename discipline enforced, trigger match
883
+ exact. Wired into ladder THINK: a task mentioning "ship a release"
884
+ absorbs the release-checklist skill into its ephemeral working set
885
+ automatically; swept at task end. New procedures = drop a file in
886
+ `skills/`, zero code.
887
+
888
+ - **The trust gate** (`scion trust`): content-addressed trust for every
889
+ loaded pack. `unseen` → observed; `pin` → checksum-pinned; a PINNED pack
890
+ that changes refuses loudly (with both hashes) until explicitly re-pinned.
891
+
892
+ - **The repo librarian** (`scion librarian <owner/repo> "<question>"`):
893
+ shallow-clone an external repo, run the codebase schematics over it, and
894
+ answer from the map with ranked relevant files — one deterministic pass
895
+ over a foreign codebase (Codebuff's librarian, no LLM).
896
+
897
+ - **Commit-reconstruction benchmark** (`src/mind/commitbench.js`): BuffBench's
898
+ honest methodology with the judge replaced by arithmetic — reconstruct
899
+ real commits against ground-truth diffs, scored on file recall/precision
900
+ + token-level F1, hard subset reported. Oracle = 1.0, empty = 0, proven.
901
+
902
+ - **Declared brief budgets** (`config.brief`): the task working-set's
903
+ TTL/budget/read-limit are config policy (Codebuff CompactionPolicy
904
+ lineage), not module constants.
905
+
827
906
  ### FS Desktop — the standalone UI (chat / agent / plan / architect)
828
907
 
829
908
  FS ships a real UI in two forms, both zero-build:
package/bin/scion.js CHANGED
@@ -678,6 +678,113 @@ switch (cmd || '') {
678
678
  }
679
679
  break;
680
680
  }
681
+ case 'templates': {
682
+ // Declarative agent templates (Codebuff agents-as-data lineage):
683
+ // scion templates load [dir] load every template yaml (default: agent-templates/)
684
+ // scion templates list the registry, with capabilities + spawn graph
685
+ // scion templates route <text> deterministic routing: which template for this problem
686
+ // scion templates spawn <id> bind the template's role (printed, not bound to a live worker)
687
+ const at = await import('../src/mind/agent-templates.js');
688
+ const sub = arg || 'list';
689
+ if (sub === 'load') {
690
+ const dir = restArgs[0] || 'agent-templates';
691
+ const loaded = at.loadTemplates(dir);
692
+ at.checkSpawnGraph();
693
+ console.log(JSON.stringify({ ok: true, dir, loaded }, null, 2));
694
+ break;
695
+ }
696
+ if (sub === 'list') {
697
+ for (const t of at.listTemplates()) {
698
+ console.log(`${t.id.padEnd(14)} ${t.display_name.padEnd(14)} caps: ${t.capabilities.join(',')}${t.spawnable_agents?.length ? ` spawns: ${t.spawnable_agents.join(',')}` : ''}`);
699
+ }
700
+ break;
701
+ }
702
+ if (sub === 'route') {
703
+ const text = restArgs.join(' ');
704
+ if (!text) { console.error('usage: scion templates route <problem text>'); process.exitCode = 2; break; }
705
+ const ranked = at.routeToTemplate(text);
706
+ for (const r of ranked) console.log(`${r.score} ${r.id.padEnd(14)} hits: ${r.hits.join(',') || '(capabilities)'}`);
707
+ if (ranked.length === 0) console.log('(no template matches — the ladder routes to the generalist)');
708
+ break;
709
+ }
710
+ if (sub === 'spawn') {
711
+ if (!restArgs[0]) { console.error('usage: scion templates spawn <id>'); process.exitCode = 2; break; }
712
+ const r = at.spawnTemplate(restArgs[0]);
713
+ console.log(JSON.stringify({ ok: true, template: r.template.id, role: r.role.id, capabilities: r.role.capabilities }, null, 2));
714
+ break;
715
+ }
716
+ console.error('usage: scion templates [load|list|route|spawn]');
717
+ process.exitCode = 2;
718
+ break;
719
+ }
720
+ case 'skills': {
721
+ // Procedural knowledge packs (Codebuff SKILL.md lineage):
722
+ // scion skills load [dir...] load every SKILL.md / *.skill.md under the dirs
723
+ // scion skills list name + description of every loaded skill
724
+ // scion skills match <text> deterministic trigger match, scored
725
+ // scion skills show <name> the full body
726
+ const sk = await import('../src/mind/skills.js');
727
+ const sub = arg || 'list';
728
+ if (sub === 'load') {
729
+ const dirs = restArgs.length ? restArgs : ['skills'];
730
+ const loaded = sk.loadSkills(dirs);
731
+ console.log(JSON.stringify({ ok: true, loaded }, null, 2));
732
+ break;
733
+ }
734
+ if (sub === 'match') {
735
+ const text = restArgs.join(' ');
736
+ if (!text) { console.error('usage: scion skills match <task text>'); process.exitCode = 2; break; }
737
+ for (const m of sk.matchSkills(text)) console.log(`${m.score} ${m.name.padEnd(24)} ${m.description}`);
738
+ if (sk.matchSkills(text).length === 0) console.log('(no skill matches)');
739
+ break;
740
+ }
741
+ if (sub === 'show') {
742
+ const s = sk.getSkill(restArgs[0]);
743
+ if (!s) { console.error(`skills: unknown skill ${JSON.stringify(restArgs[0])} — known: ${sk.listSkills().map(x => x.name).join(', ') || '(none loaded)'}`); process.exitCode = 1; break; }
744
+ console.log(`# ${s.name} — ${s.description}\n(source: ${s.source})\n\n${s.body}`);
745
+ break;
746
+ }
747
+ for (const s of sk.listSkills()) console.log(`${s.name.padEnd(24)} ${s.description}`);
748
+ break;
749
+ }
750
+ case 'trust': {
751
+ // The trust gate for loaded packs (checksum pins, loud refusals):
752
+ // scion trust status <file> state: unseen | quarantine | pinned | changed
753
+ // scion trust pin <file> pin current content (operator action)
754
+ // scion trust unpin <file> remove the pin
755
+ // scion trust list every pin
756
+ const tr = await import('../src/mind/trust.js');
757
+ const sub = arg || 'list';
758
+ if (sub === 'pin') {
759
+ if (!restArgs[0]) { console.error('usage: scion trust pin <file>'); process.exitCode = 2; break; }
760
+ console.log(JSON.stringify(tr.pin(restArgs[0]), null, 2));
761
+ break;
762
+ }
763
+ if (sub === 'unpin') {
764
+ if (!restArgs[0]) { console.error('usage: scion trust unpin <file>'); process.exitCode = 2; break; }
765
+ console.log(JSON.stringify(tr.unpin(restArgs[0]), null, 2));
766
+ break;
767
+ }
768
+ if (sub === 'status') {
769
+ if (!restArgs[0]) { console.error('usage: scion trust status <file>'); process.exitCode = 2; break; }
770
+ console.log(JSON.stringify(tr.trustStatus(restArgs[0]), null, 2));
771
+ break;
772
+ }
773
+ for (const p of tr.listTrusted()) console.log(`${p.sha256.slice(0, 12)} ${p.path} pinned ${p.pinned_at}`);
774
+ break;
775
+ }
776
+ case 'librarian': {
777
+ // External repo comprehension: clone → schematic → structured answer.
778
+ // scion librarian <owner/repo|url> "<question>"
779
+ const lib = await import('../src/mind/librarian.js');
780
+ if (!arg || restArgs.length === 0) { console.error('usage: scion librarian <owner/repo> "<question>"'); process.exitCode = 2; break; }
781
+ const r = lib.askRepo(arg, restArgs.join(' '));
782
+ console.log(r.digest);
783
+ console.log(`\nrelevant files:`);
784
+ for (const f of r.relevant_files) console.log(` ${f}`);
785
+ console.log(`\nclone (kept for inspection): ${r.clone_dir} [files=${r.stats.files} symbols=${r.stats.symbols} ${r.stats.ms}ms]`);
786
+ break;
787
+ }
681
788
  case 'evolve': {
682
789
  // The nightly harness-evolution pass (A4): mine weaknesses → propose
683
790
  // harness edits → gate them (SICA utility) → apply the accepted. Bounded
@@ -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.8",
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,9 @@ 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
+ brief: { ttl_s: 3600, budget_bytes: 64000, read_limit: 24, max_item_bytes: 8000 }, // the working-set policy, declared (Codebuff CompactionPolicy lineage) — task-brief defaults defer to this
32
+ 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
33
  daemon: { auto_fix: false, max_fixes_per_scan: 10 },
32
34
  muscle: { path_deny: [], path_allow: [] }, // empty deny = shipped defaults
33
35
  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,56 @@ 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
+ // SKILL MATCH (procedural context, same THINK pass): matched skills
192
+ // inject into the task's working set (ephemeral brief) — a skill
193
+ // for THIS task is fuel, not furniture. No match, no injection.
194
+ if (_routingTaskId) {
195
+ try {
196
+ const sk = await import('./mind/skills.js');
197
+ const { injected } = await sk.injectSkills(_routingTaskId, String(input.text || ''), { limit: 2 });
198
+ if (injected.length) trail.journal('ladder.skills-injected', { task: _routingTaskId, skills: injected.map(i => i.name) });
199
+ } catch (err) {
200
+ trail.journal('ladder.skills-skipped', { error: String(err?.message || err).slice(0, 120) });
201
+ }
202
+ }
203
+ trail.journal('ladder.map-context', {
204
+ root: warmMap.root, cachedMs: Date.now() - warmMap.at,
205
+ });
206
+ }
207
+ } catch (err) {
208
+ trail.journal('ladder.map-context-skipped', { error: String(err?.message || err).slice(0, 120) });
209
+ }
210
+ }
211
+
157
212
  // Rung 1 — replay (TEXT-ONLY problems): the zero-generation fast path.
158
213
  // For problems carrying a concrete file the analyzer (below) runs FIRST:
159
214
  // a replay verdict for a file task is an OUTCOME RECORD ("this was fixed
@@ -0,0 +1,287 @@
1
+ /**
2
+ * mind/agent-templates.js — DECLARATIVE AGENT TEMPLATES (Codebuff
3
+ * agents-as-data lineage).
4
+ *
5
+ * The gap it closes: FS's swarm registers one hardcoded `swarm-generalist`
6
+ * role and every worker is identical. Codebuff's insight — the same doctrine
7
+ * as FS's rules/recipes/templates — is that AGENTS are data too: a template
8
+ * declares a display name, what problems it solves (so a parent can choose),
9
+ * the input/output schemas at its spawn boundary, the tool allowlist (least
10
+ * privilege), the capabilities it can claim, its bounds, and WHICH OTHER
11
+ * TEMPLATES it may spawn (the spawn graph).
12
+ *
13
+ * Zero-LLM: templates carry no model, no prompts-as-code, no handleSteps —
14
+ * a template is a CONTRACT for a kernel worker. Validation is loud at load
15
+ * (the offending path in every error), unknown template references fail at
16
+ * LOAD time, not spawn time, and everything journals to the trail.
17
+ *
18
+ * loadTemplates(dir) → load all *.yaml templates (loud, idempotent)
19
+ * registerTemplate(def) → register one (validated)
20
+ * getTemplate(id) / listTemplates()
21
+ * routeToTemplate(problem, opts) → deterministic capability routing:
22
+ * score templates against a task's text +
23
+ * capabilities; returns ranked candidates
24
+ * spawnTemplate(id, { taskId }) → bind a worker to the template's role
25
+ * contract (role registry integration)
26
+ *
27
+ * @module mind/agent-templates
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
33
+ import { join, basename, dirname } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+ import { parseYaml } from '../config.js';
36
+ import * as roles from '../kernel/roles.js';
37
+ import { journal as trailJournal } from '../kernel/trail.js';
38
+
39
+ const _templates = new Map();
40
+
41
+ /** The shipped defaults ship with the package — data, always available. */
42
+ const SHIPPED_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'agent-templates');
43
+ let _bootstrapped = false;
44
+
45
+ /** Idempotent: every registry reader sees the shipped templates (load-once). */
46
+ function bootstrap() {
47
+ if (_bootstrapped) return;
48
+ _bootstrapped = true;
49
+ if (existsSync(SHIPPED_DIR)) loadTemplates(SHIPPED_DIR);
50
+ }
51
+
52
+ /** Spawn graph guard: an agent that spawns everything, spawns itself. */
53
+ const MAX_SPAWN_DEPTH = 8;
54
+
55
+ /* ------------------------------------------------------------------ *
56
+ * Validation — loud, with the offending path in every message.
57
+ * ------------------------------------------------------------------ */
58
+
59
+ const VALID_CAPABILITY = /^[a-z0-9:*._-]+$/;
60
+
61
+ export function validateTemplate(def, source = '<inline>') {
62
+ const where = `agent-templates[${def?.id || basename(String(source))}]`;
63
+ if (!def || typeof def !== 'object') {
64
+ throw new Error(`${where}: template must be an object`);
65
+ }
66
+ if (!def.id || typeof def.id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(def.id)) {
67
+ throw new Error(`${where}: id must be a lowercase dotted/hyphenated slug (got ${JSON.stringify(def.id)})`);
68
+ }
69
+ if (!def.display_name || typeof def.display_name !== 'string') {
70
+ throw new Error(`${where}: display_name is required (what reports show)`);
71
+ }
72
+ if (!def.spawner_prompt || typeof def.spawner_prompt !== 'string' || def.spawner_prompt.length < 8) {
73
+ throw new Error(`${where}: spawner_prompt is required — a parent must be able to CHOOSE this template from its description`);
74
+ }
75
+ if (!Array.isArray(def.capabilities) || def.capabilities.length === 0 ||
76
+ def.capabilities.some(c => typeof c !== 'string' || !VALID_CAPABILITY.test(c))) {
77
+ throw new Error(`${where}: capabilities must be a non-empty array of slug strings (codebuff lineage: capability routing starts in the template)`);
78
+ }
79
+ // Schemas: declared as field-type maps — deterministic, no zod-style magic.
80
+ for (const key of ['input_schema', 'output_schema']) {
81
+ const schema = def[key];
82
+ if (schema === undefined) continue;
83
+ if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
84
+ throw new Error(`${where}: ${key} must be an object mapping field → type`);
85
+ }
86
+ for (const [field, type] of Object.entries(schema)) {
87
+ if (!/^[a-z_][a-z0-9_]*$/.test(field)) {
88
+ throw new Error(`${where}: ${key} field ${JSON.stringify(field)} is not a slug`);
89
+ }
90
+ if (!['string', 'number', 'boolean', 'array', 'object', 'any'].includes(type)) {
91
+ throw new Error(`${where}: ${key}.${field} has unknown type ${JSON.stringify(type)} (string|number|boolean|array|object|any)`);
92
+ }
93
+ }
94
+ }
95
+ if (def.tool_allowlist !== undefined) {
96
+ if (!Array.isArray(def.tool_allowlist) || def.tool_allowlist.some(t => typeof t !== 'string' || !t)) {
97
+ throw new Error(`${where}: tool_allowlist must be an array of tool-name strings`);
98
+ }
99
+ }
100
+ if (def.spawnable_agents !== undefined) {
101
+ if (!Array.isArray(def.spawnable_agents) ||
102
+ def.spawnable_agents.some(a => typeof a !== 'string' || !a)) {
103
+ throw new Error(`${where}: spawnable_agents must be an array of template ids`);
104
+ }
105
+ if (def.spawnable_agents.includes(def.id)) {
106
+ throw new Error(`${where}: a template cannot spawn itself (spawn graph must be acyclic by construction)`);
107
+ }
108
+ }
109
+ if (def.bounds !== undefined && (typeof def.bounds !== 'object' || def.bounds === null || Array.isArray(def.bounds))) {
110
+ throw new Error(`${where}: bounds must be an object (max_turns | timeout_ms | ...)`);
111
+ }
112
+ if (def.max_concurrency !== undefined && (!Number.isFinite(def.max_concurrency) || def.max_concurrency < 1)) {
113
+ throw new Error(`${where}: max_concurrency must be a positive number`);
114
+ }
115
+ if (def.triggers !== undefined) {
116
+ if (!Array.isArray(def.triggers) || def.triggers.some(t => typeof t !== 'string' || !t)) {
117
+ throw new Error(`${where}: triggers must be an array of lowercase keyword strings (deterministic routing)`);
118
+ }
119
+ }
120
+ return def;
121
+ }
122
+
123
+ /* ------------------------------------------------------------------ *
124
+ * Registry.
125
+ * ------------------------------------------------------------------ */
126
+
127
+ export function registerTemplate(def, opts = {}) {
128
+ validateTemplate(def, opts.source);
129
+ const prev = _templates.get(def.id);
130
+ _templates.set(def.id, {
131
+ id: def.id,
132
+ display_name: def.display_name,
133
+ spawner_prompt: def.spawner_prompt,
134
+ capabilities: [...new Set(def.capabilities)],
135
+ ...(def.input_schema ? { input_schema: { ...def.input_schema } } : {}),
136
+ ...(def.output_schema ? { output_schema: { ...def.output_schema } } : {}),
137
+ ...(def.tool_allowlist ? { tool_allowlist: [...def.tool_allowlist] } : {}),
138
+ ...(def.spawnable_agents ? { spawnable_agents: [...def.spawnable_agents] } : {}),
139
+ ...(def.triggers ? { triggers: [...new Set(def.triggers)] } : {}),
140
+ ...(def.bounds ? { bounds: { ...def.bounds } } : {}),
141
+ ...(Number.isFinite(def.max_concurrency) ? { max_concurrency: def.max_concurrency } : {}),
142
+ ...(def.source ? { source: def.source } : {}),
143
+ });
144
+ trailJournal('template.registered', { id: def.id, updated: !!prev });
145
+ return _templates.get(def.id);
146
+ }
147
+
148
+ /** Load every *.yaml template from a directory (and the shipped defaults). */
149
+ export function loadTemplates(dir) {
150
+ const loaded = [];
151
+ if (dir) {
152
+ if (!existsSync(dir)) throw new Error(`agent-templates: directory not found: ${dir}`);
153
+ for (const name of readdirSync(dir).filter(n => n.endsWith('.yaml') || n.endsWith('.yml')).sort()) {
154
+ const path = join(dir, name);
155
+ const doc = parseYaml(readFileSync(path, 'utf8'), path);
156
+ const defs = Array.isArray(doc?.templates) ? doc.templates : [doc];
157
+ for (const def of defs) {
158
+ registerTemplate({ ...def, source: path });
159
+ loaded.push({ id: def.id, path });
160
+ }
161
+ }
162
+ }
163
+ trailJournal('templates.loaded', { dir: dir || null, count: loaded.length });
164
+ return loaded;
165
+ }
166
+
167
+ export function getTemplate(id) {
168
+ bootstrap();
169
+ return _templates.get(id) ?? null;
170
+ }
171
+
172
+ export function listTemplates() {
173
+ bootstrap();
174
+ return [..._templates.values()].sort((a, b) => a.id < b.id ? -1 : 1);
175
+ }
176
+
177
+ /* ------------------------------------------------------------------ *
178
+ * Spawn-graph integrity — load-time cycle detection over spawnable_agents.
179
+ * Codebuff trusts its publishers; FS trusts no graph it hasn't checked.
180
+ * ------------------------------------------------------------------ */
181
+
182
+ export function checkSpawnGraph() {
183
+ const cycles = [];
184
+ const visiting = new Set();
185
+ const done = new Set();
186
+ const walk = (id, path) => {
187
+ if (done.has(id)) return;
188
+ if (visiting.has(id)) {
189
+ cycles.push([...path.slice(path.indexOf(id)), id]);
190
+ return;
191
+ }
192
+ visiting.add(id);
193
+ const t = _templates.get(id);
194
+ for (const child of (t?.spawnable_agents || [])) {
195
+ if (_templates.has(child)) walk(child, [...path, id]);
196
+ }
197
+ visiting.delete(id);
198
+ done.add(id);
199
+ };
200
+ for (const id of _templates.keys()) walk(id, []);
201
+ if (cycles.length > 0) {
202
+ throw new Error(`agent-templates: spawn graph has cycles: ${cycles.map(c => c.join(' → ')).join('; ')}`);
203
+ }
204
+ return { cycles: [] };
205
+ }
206
+
207
+ /** Depth-limited spawn closure — a spawn request beyond this is refused. */
208
+ export function spawnClosure(id) {
209
+ bootstrap();
210
+ if (!_templates.has(id)) {
211
+ throw new Error(`agent-templates.spawnClosure: unknown template ${JSON.stringify(id)} — known: ${[..._templates.keys()].join(', ') || '(none)'}`);
212
+ }
213
+ const seen = new Set();
214
+ let frontier = [id];
215
+ let depth = 0;
216
+ while (frontier.length && depth < MAX_SPAWN_DEPTH) {
217
+ const next = [];
218
+ for (const cur of frontier) {
219
+ if (seen.has(cur)) continue;
220
+ seen.add(cur);
221
+ for (const child of (_templates.get(cur)?.spawnable_agents || [])) next.push(child);
222
+ }
223
+ frontier = next;
224
+ depth++;
225
+ }
226
+ return { templates: [...seen].sort(), depth };
227
+ }
228
+
229
+ /* ------------------------------------------------------------------ *
230
+ * Deterministic routing — WHICH template for THIS problem?
231
+ * Codebuff's spawnerPrompt is an LLM choice; FS's is a scored match:
232
+ * trigger keyword hits + capability coverage, deterministic and journaled.
233
+ * ------------------------------------------------------------------ */
234
+
235
+ export function routeToTemplate(problem, opts = {}) {
236
+ bootstrap();
237
+ const text = String(problem || '').toLowerCase();
238
+ const needCaps = new Set(opts.capabilities || []);
239
+ const scored = [];
240
+ for (const t of listTemplates()) {
241
+ let score = 0;
242
+ const hits = [];
243
+ for (const trig of (t.triggers || [])) {
244
+ if (text.includes(trig)) { score += 2; hits.push(trig); }
245
+ }
246
+ if (needCaps.size > 0) {
247
+ const covered = [...needCaps].filter(c =>
248
+ t.capabilities.includes(c) || t.capabilities.includes('*'));
249
+ if (covered.length < needCaps.size) continue; // hard capability gate
250
+ score += covered.length;
251
+ }
252
+ if (score > 0) scored.push({ id: t.id, display_name: t.display_name, score, hits });
253
+ }
254
+ scored.sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : 1));
255
+ trailJournal('template.route', { problem: String(problem || '').slice(0, 80), routed: scored[0]?.id ?? null, candidates: scored.length });
256
+ return scored;
257
+ }
258
+
259
+ /* ------------------------------------------------------------------ *
260
+ * Spawn — a template becomes a ROLE (the kernel's claim gate is the
261
+ * only execution authority; templates never execute anything).
262
+ * ------------------------------------------------------------------ */
263
+
264
+ export function spawnTemplate(id, { workerId } = {}) {
265
+ bootstrap();
266
+ const t = _templates.get(id);
267
+ if (!t) {
268
+ throw new Error(`agent-templates.spawn: unknown template ${JSON.stringify(id)} — known: ${[..._templates.keys()].join(', ') || '(none)'}`);
269
+ }
270
+ const roleId = `agent-template:${id}`;
271
+ if (!roles.getRole(roleId)) {
272
+ roles.registerRole({
273
+ id: roleId,
274
+ capabilities: t.capabilities,
275
+ ...(t.bounds ? { bounds: t.bounds } : {}),
276
+ ...(Number.isFinite(t.max_concurrency) ? { max_concurrency: t.max_concurrency } : {}),
277
+ });
278
+ }
279
+ let bound = null;
280
+ if (workerId) bound = roles.bindWorker(workerId, roleId);
281
+ trailJournal('template.spawned', { id, worker: workerId ?? null, role: roleId });
282
+ return { template: t, role: roles.getRole(roleId), bound };
283
+ }
284
+
285
+ export function reset() {
286
+ _templates.clear();
287
+ }