futura-scion 0.2.7 → 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
@@ -858,6 +858,51 @@ the current task needs, retain only what compounds.
858
858
  rung's action threshold — so only REPETITION makes them actionable.
859
859
  One occurrence is an observation; repetition is knowledge.
860
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
+
861
906
  ### FS Desktop — the standalone UI (chat / agent / plan / architect)
862
907
 
863
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "futura-scion",
3
- "version": "0.2.7",
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
@@ -28,6 +28,7 @@ export const DEFAULTS = Object.freeze({
28
28
  queue: { max_attempts: 3 },
29
29
  analyzer: { enabled: true },
30
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
31
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)
32
33
  daemon: { auto_fix: false, max_fixes_per_scan: 10 },
33
34
  muscle: { path_deny: [], path_allow: [] }, // empty deny = shipped defaults
package/src/ladder.js CHANGED
@@ -188,6 +188,18 @@ export async function decide(problem, opts = {}) {
188
188
  }
189
189
  if (warmMap?.digest) {
190
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
+ }
191
203
  trail.journal('ladder.map-context', {
192
204
  root: warmMap.root, cachedMs: Date.now() - warmMap.at,
193
205
  });
@@ -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
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * mind/commitbench.js — COMMIT-RECONSTRUCTION EVAL (Codebuff BuffBench
3
+ * lineage, scored deterministically).
4
+ *
5
+ * BuffBench's insight: the honest benchmark for a coding agent is "rebuild
6
+ * a REAL commit" — ground truth is the actual diff, not a planted fixture.
7
+ * Its judge was an LLM; FS's doctrine doesn't allow that, and it doesn't
8
+ * need one: a diff is text, and diff similarity is arithmetic.
9
+ *
10
+ * The harness, for one fixture (a repo snapshot + a commit to reconstruct):
11
+ * 1. Check out the PARENT of the target commit into a scratch worktree.
12
+ * 2. Hand the problem statement (the commit message) to the solver — any
13
+ * async (worktreePath, problem) => { files: [{file, content}] } —
14
+ * injected, deterministic by construction, or the generator worker.
15
+ * 3. Score the attempt against the ground-truth diff WITHOUT any judge:
16
+ * file_recall — fraction of truly-changed files the solver touched
17
+ * file_precision — fraction of touched files that were truly changed
18
+ * line_f1 — token-level F1 between the union of changed lines
19
+ * and the solver's changed lines (order-free,
20
+ * whitespace-normalized)
21
+ * gate — did the repo still build/lint (exit-code oracle)
22
+ * overall = 0.4*file_recall + 0.3*file_precision + 0.3*line_f1,
23
+ * reported per-fixture and aggregated (mean, and the BuffBench-style
24
+ * hard subset separately).
25
+ *
26
+ * runBench(fixtures, solver, opts) → per-fixture + aggregate report
27
+ *
28
+ * @module mind/commitbench
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ import { spawnSync } from 'node:child_process';
34
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs';
35
+ import { tmpdir } from 'node:os';
36
+ import { join } from 'node:path';
37
+ import { journal as trailJournal } from '../kernel/trail.js';
38
+
39
+ /* ------------------------------------------------------------------ *
40
+ * Ground truth — the real diff, straight from git.
41
+ * ------------------------------------------------------------------ */
42
+
43
+ /** Parse `git diff --numstat` output into { file, added, removed }. */
44
+ export function parseNumstat(text) {
45
+ const out = [];
46
+ for (const line of String(text || '').split('\n')) {
47
+ const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
48
+ if (!m) continue;
49
+ const [, a, r, file] = m;
50
+ out.push({ file: file.replace(/^"|"$/g, ''), added: a === '-' ? 0 : Number(a), removed: r === '-' ? 0 : Number(r) });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** Extract added-or-removed line CONTENT (order-free token multiset basis). */
56
+ export function changedLines(diffText) {
57
+ const lines = [];
58
+ for (const line of String(diffText || '').split('\n')) {
59
+ if ((line.startsWith('+') && !line.startsWith('+++')) || (line.startsWith('-') && !line.startsWith('---'))) {
60
+ lines.push(line.slice(1).trim());
61
+ }
62
+ }
63
+ return lines.filter(l => l.length > 0);
64
+ }
65
+
66
+ function tokenMultiset(lines) {
67
+ const counts = new Map();
68
+ for (const l of lines) {
69
+ for (const tok of l.toLowerCase().split(/[^a-z0-9_]+/).filter(t => t)) {
70
+ counts.set(tok, (counts.get(tok) || 0) + 1);
71
+ }
72
+ }
73
+ return counts;
74
+ }
75
+
76
+ /** Set-F1 between two line sets, tokenized and order-free. */
77
+ export function lineF1(linesA, linesB) {
78
+ const A = tokenMultiset(linesA);
79
+ const B = tokenMultiset(linesB);
80
+ if (A.size === 0 && B.size === 0) return 1;
81
+ if (A.size === 0 || B.size === 0) return 0;
82
+ let overlap = 0;
83
+ for (const [tok, ca] of A) overlap += Math.min(ca, B.get(tok) || 0);
84
+ const totalA = [...A.values()].reduce((s, n) => s + n, 0);
85
+ const totalB = [...B.values()].reduce((s, n) => s + n, 0);
86
+ const p = overlap / totalA, r = overlap / totalB;
87
+ return (p + r) === 0 ? 0 : (2 * p * r) / (p + r);
88
+ }
89
+
90
+ /* ------------------------------------------------------------------ *
91
+ * Worktree plumbing.
92
+ * ------------------------------------------------------------------ */
93
+
94
+ function git(repo, args, opts = {}) {
95
+ const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8', timeout: opts.timeout_ms ?? 60_000 });
96
+ if (r.status !== 0 && !opts.allowFail) {
97
+ throw new Error(`commitbench: git ${args.join(' ')} failed: ${(r.stderr || `exit ${r.status}`).slice(0, 300)}`);
98
+ }
99
+ return r;
100
+ }
101
+
102
+ /**
103
+ * One fixture run: check out commit^ into a scratch worktree, collect the
104
+ * ground truth, invoke the solver, score it.
105
+ */
106
+ export async function runFixture(fixture, solver, opts = {}) {
107
+ const { repo, commit, problem } = fixture;
108
+ const worktree = mkdtempSync(join(tmpdir(), 'scion-commitbench-'));
109
+ try {
110
+ // Ground truth from the REAL repo — the parent tree is the starting line.
111
+ git(repo, ['worktree', 'add', '--detach', worktree, `${commit}^`]);
112
+ const truth = git(repo, ['show', '--numstat', '--format=', commit]);
113
+ const truthFiles = parseNumstat(truth.stdout).map(n => n.file);
114
+ const truthDiff = git(repo, ['show', '--format=', commit]).stdout;
115
+ const truthLines = changedLines(truthDiff);
116
+
117
+ // The solver works in the worktree at the parent commit; it receives
118
+ // the fixture as a third argument (a per-fixture solver needs the commit).
119
+ const attempt = await solver(worktree, problem ?? git(repo, ['log', '-1', '--format=%B', commit]).stdout.trim(), fixture);
120
+ const touched = (attempt?.files || []).map(f => f.file);
121
+
122
+ // Deterministic scoring — no judge, arithmetic.
123
+ const truthSet = new Set(truthFiles);
124
+ const touchedSet = new Set(touched);
125
+ const hits = [...touchedSet].filter(f => truthSet.has(f));
126
+ const file_recall = truthSet.size === 0 ? 1 : hits.length / truthSet.size;
127
+ const file_precision = touchedSet.size === 0 ? 0 : hits.length / touchedSet.size;
128
+
129
+ // The attempt's changed lines come from the WORKTREE whenever the solver
130
+ // touched it (files: [] = "I edited in place — diff HEAD"), and from the
131
+ // returned file contents only when they carry content.
132
+ const solverReturnedContent = (attempt?.files || []).some(f => f.content != null);
133
+ let attemptLines = [];
134
+ if (solverReturnedContent) {
135
+ attemptLines = attempt.files.flatMap(f => changedLines(String(f.content ?? '')));
136
+ } else {
137
+ // HEAD (not the bare worktree diff): a solver that stages its work
138
+ // (git apply --index, cherry-pick --no-commit) must still be seen.
139
+ const after = git(worktree, ['diff', '-U0', 'HEAD', '--']).stdout;
140
+ attemptLines = changedLines(after);
141
+ }
142
+ const f1 = lineF1(truthLines, attemptLines);
143
+
144
+ // Exit-code oracle: optional gate command over the attempt (build/test).
145
+ let gate = null;
146
+ if (opts.gate_cmd) {
147
+ const g = spawnSync(opts.gate_cmd, { cwd: worktree, encoding: 'utf8', shell: true, timeout: opts.gate_timeout_ms ?? 300_000 });
148
+ gate = { cmd: opts.gate_cmd, ok: g.status === 0, exit: g.status };
149
+ }
150
+
151
+ const overall = 0.4 * file_recall + 0.3 * file_precision + 0.3 * f1;
152
+ return {
153
+ id: fixture.id ?? `${commit.slice(0, 10)}`,
154
+ file_recall, file_precision, line_f1: f1, overall,
155
+ touched_files: touched.length, truth_files: truthFiles.length,
156
+ ...(gate ? { gate } : {}),
157
+ };
158
+ } finally {
159
+ git(repo, ['worktree', 'remove', '--force', worktree], { allowFail: true });
160
+ try { rmSync(worktree, { recursive: true, force: true }); } catch { /* worktree remove already handled it */ }
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Run a bench: fixtures × one solver. Aggregates mean scores; the top-quartile
166
+ * hardest fixtures (fewest truth files? no — lowest overall) form the HARD
167
+ * subset, reported separately like BuffBench's hard sets.
168
+ */
169
+ export async function runBench(fixtures, solver, opts = {}) {
170
+ if (!Array.isArray(fixtures) || fixtures.length === 0) {
171
+ throw new Error('commitbench.runBench: fixtures must be a non-empty array');
172
+ }
173
+ if (typeof solver !== 'function') {
174
+ throw new Error('commitbench.runBench: solver must be async (worktreePath, problem) => { files }');
175
+ }
176
+ const results = [];
177
+ for (const f of fixtures) results.push(await runFixture(f, solver, opts));
178
+ const mean = (key) => results.reduce((s, r) => s + r[key], 0) / results.length;
179
+ const sorted = [...results].sort((a, b) => a.overall - b.overall);
180
+ const hardN = Math.max(1, Math.ceil(results.length / 4));
181
+ const report = {
182
+ fixtures: results.length,
183
+ mean: {
184
+ file_recall: mean('file_recall'),
185
+ file_precision: mean('file_precision'),
186
+ line_f1: mean('line_f1'),
187
+ overall: mean('overall'),
188
+ },
189
+ hard_subset: { count: hardN, mean_overall: sorted.slice(0, hardN).reduce((s, r) => s + r.overall, 0) / hardN },
190
+ results,
191
+ };
192
+ trailJournal('commitbench.ran', { fixtures: report.fixtures, overall: report.mean.overall, hard: report.hard_subset.mean_overall });
193
+ return report;
194
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * mind/librarian.js — EXTERNAL REPO COMPREHENSION (Codebuff librarian
3
+ * lineage, rebuilt deterministically).
4
+ *
5
+ * Codebuff's librarian shallow-clones a repo into /tmp and answers
6
+ * questions with an LLM over the clone. FS's version answers with the
7
+ * SCHEMATIC: shallow-clone (depth 1), run the codebase map over the clone,
8
+ * and return a structured digest — layers, entries, hubs, exported surface
9
+ * — plus the files relevant to the question (deterministic symbol-name
10
+ * match over the map). No model anywhere: the map IS the understanding.
11
+ *
12
+ * Safety: clones land in a temp dir under the OS tmpdir, the answer cites
13
+ * paths INSIDE the clone, and cleanup is explicit (or opts.keep for
14
+ * follow-up inspection). A repo that fails to clone fails loud with the
15
+ * command and its stderr — never a silent null.
16
+ *
17
+ * askRepo(repoUrl, question, opts) → { digest, relevant_files, clone_dir, stats }
18
+ * cleanup(cloneDir) → rm -rf the clone
19
+ *
20
+ * @module mind/librarian
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ import { spawnSync } from 'node:child_process';
26
+ import { mkdtempSync, rmSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+ import * as schematic from './schematic.js';
30
+ import { journal as trailJournal } from '../kernel/trail.js';
31
+
32
+ const REPO_URL_RE = /^(https:\/\/[^\s]+|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/;
33
+
34
+ function shallowClone(repoUrl, opts) {
35
+ const dir = mkdtempSync(join(tmpdir(), 'scion-librarian-'));
36
+ const url = /^https:/.test(repoUrl)
37
+ ? repoUrl
38
+ : `https://github.com/${repoUrl}.git`;
39
+ const r = spawnSync('git', ['clone', '--depth', '1', '--quiet', url, dir], {
40
+ encoding: 'utf8',
41
+ timeout: opts.timeout_ms ?? 120_000,
42
+ });
43
+ if (r.status !== 0) {
44
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* nothing to clean */ }
45
+ throw new Error(`librarian: git clone failed for ${url}: ${(r.stderr || r.error?.message || `exit ${r.status}`).slice(0, 400)}`);
46
+ }
47
+ return dir;
48
+ }
49
+
50
+ /** Deterministic relevance: symbol/token names from the question, scored over the map. */
51
+ function relevantFiles(model, question, limit) {
52
+ const tokens = String(question || '')
53
+ .toLowerCase()
54
+ .split(/[^a-z0-9_]+/)
55
+ .filter(t => t.length >= 4);
56
+ if (tokens.length === 0) return [];
57
+ const scored = [];
58
+ for (const f of model.files) {
59
+ const hay = `${f.file} ${(model.exports[f.file] || []).join(' ').toLowerCase()}`;
60
+ const symbolsInFile = model.symbols.filter(s => s.file === f.file).map(s => s.name.toLowerCase());
61
+ let score = 0;
62
+ for (const tok of tokens) {
63
+ if (hay.includes(tok)) score += 2;
64
+ if (symbolsInFile.some(s => s.includes(tok))) score += 3;
65
+ }
66
+ if (score > 0) scored.push({ file: f.file, score });
67
+ }
68
+ scored.sort((a, b) => b.score - a.score || (a.file < b.file ? -1 : 1));
69
+ return scored.slice(0, limit).map(s => s.file);
70
+ }
71
+
72
+ /**
73
+ * Shallow-clone an external repository and answer a question about it from
74
+ * its schematic — one full read of a foreign codebase, zero file re-reads
75
+ * afterwards (the map answers; only cited files need opening).
76
+ * @param {string} repoUrl https URL or `owner/repo` shorthand
77
+ * @param {string} question what to answer — drives relevance ranking
78
+ * @returns {{ digest: string, relevant_files: string[], clone_dir: string,
79
+ * stats: { files, symbols, edges } }}
80
+ */
81
+ export function askRepo(repoUrl, question, opts = {}) {
82
+ if (!REPO_URL_RE.test(repoUrl || '')) {
83
+ throw new Error(`librarian: repoUrl must be an https URL or owner/repo (got ${JSON.stringify(repoUrl)})`);
84
+ }
85
+ const started = Date.now();
86
+ const cloneDir = shallowClone(repoUrl, opts);
87
+ try {
88
+ const { model } = schematic.mapFor(cloneDir);
89
+ const digest = schematic.digest(model);
90
+ const relevant = relevantFiles(model, question, opts.limit ?? 8);
91
+ const stats = {
92
+ files: model.files.length,
93
+ symbols: model.symbols.length,
94
+ edges: model.edges.length,
95
+ ms: Date.now() - started,
96
+ };
97
+ trailJournal('librarian.asked', { repo: repoUrl, question: String(question).slice(0, 120), files: stats.files, symbols: stats.symbols, ms: stats.ms });
98
+ return { digest, relevant_files: relevant, clone_dir: cloneDir, stats };
99
+ } catch (err) {
100
+ if (!opts.keep) { try { rmSync(cloneDir, { recursive: true, force: true }); } catch { /* best-effort */ } }
101
+ throw err;
102
+ }
103
+ }
104
+
105
+ /** Remove a kept clone (the caller's cleanup duty — Codebuff's `rm -rf cloneDir`). */
106
+ export function cleanup(cloneDir) {
107
+ rmSync(cloneDir, { recursive: true, force: true });
108
+ trailJournal('librarian.cleaned', { dir: cloneDir });
109
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * mind/skills.js — PROCEDURAL KNOWLEDGE PACKS (Codebuff SKILL.md lineage).
3
+ *
4
+ * The gap it closes: FS's knowledge packs are FACTS and PRINCIPLES; nothing
5
+ * carried PROCEDURES — the how-to for a situation ("how to release this
6
+ * project", "how to write a migration here", "how this repo runs tests").
7
+ * Skills are markdown files with a small frontmatter block:
8
+ *
9
+ * ---
10
+ * name: run-tests
11
+ * description: How to run and interpret this repo's test suites
12
+ * triggers: [test, suite, verify]
13
+ * ---
14
+ * # body — the actual procedure …
15
+ *
16
+ * Loading is deterministic: frontmatter is parsed loud (bad frontmatter is
17
+ * a thrown error naming the file), the body is stored verbatim, and a
18
+ * skill's trigger match against a task's text is exact keyword inclusion —
19
+ * never a model's judgment. A matched skill is injected into the task's
20
+ * working set (the ephemeral brief), NOT the durable brain: procedures are
21
+ * fuel for the task, and the lesson distiller keeps only what the outcome
22
+ * teaches.
23
+ *
24
+ * loadSkills(dirs) → load every SKILL.md under dirs
25
+ * getSkill(name) / listSkills()
26
+ * matchSkills(text, { limit }) → deterministic trigger scoring
27
+ * injectSkills(taskId, text) → match + absorb into the task brief
28
+ *
29
+ * @module mind/skills
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
35
+ import { join, basename, dirname } from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+ import { journal as trailJournal } from '../kernel/trail.js';
38
+
39
+ const _skills = new Map();
40
+
41
+ /** The shipped skills live with the package — data, always available. */
42
+ const SHIPPED_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills');
43
+ let _bootstrapped = false;
44
+
45
+ /** Idempotent: every registry reader sees the shipped skills (load-once). */
46
+ function bootstrap() {
47
+ if (_bootstrapped) return;
48
+ _bootstrapped = true;
49
+ if (existsSync(SHIPPED_DIR)) loadSkills([SHIPPED_DIR]);
50
+ }
51
+
52
+ const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
53
+
54
+ /* ------------------------------------------------------------------ *
55
+ * Parsing — loud, file-naming errors.
56
+ * ------------------------------------------------------------------ */
57
+
58
+ export function parseSkill(raw, source = '<inline>') {
59
+ const where = `skills[${source}]`;
60
+ if (typeof raw !== 'string') throw new Error(`${where}: skill source must be a string`);
61
+ const text = raw.replace(/\r\n/g, '\n');
62
+ if (!text.startsWith('---')) {
63
+ throw new Error(`${where}: missing frontmatter — a skill starts with a --- block (name, description)`);
64
+ }
65
+ const end = text.indexOf('\n---', 3);
66
+ if (end === -1) throw new Error(`${where}: unterminated frontmatter (no closing ---)`);
67
+
68
+ const fmLines = text.slice(4, end).split('\n');
69
+ const fm = {};
70
+ let currentKey = null;
71
+ for (const line of fmLines) {
72
+ if (!line.trim() || line.trim().startsWith('#')) continue;
73
+ const m = line.match(/^([a-z_-]+):\s*(.*)$/);
74
+ if (!m) throw new Error(`${where}: cannot parse frontmatter line ${JSON.stringify(line)}`);
75
+ const [, key, rest] = m;
76
+ if (rest.trim() !== '') {
77
+ fm[key] = rest.trim();
78
+ currentKey = key;
79
+ } else {
80
+ fm[key] = [];
81
+ currentKey = key;
82
+ }
83
+ }
84
+ // Inline arrays and list items both fold into an array value.
85
+ const listOf = (key) => {
86
+ const v = fm[key];
87
+ if (v === undefined) return [];
88
+ if (Array.isArray(v)) return v;
89
+ if (typeof v === 'string') {
90
+ const s = v.trim();
91
+ if (s.startsWith('[')) {
92
+ return s.replace(/^\[/, '').replace(/\]$/, '').split(',').map(x => x.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
93
+ }
94
+ return [s];
95
+ }
96
+ return [];
97
+ };
98
+
99
+ const name = typeof fm.name === 'string' ? fm.name : '';
100
+ if (!NAME_RE.test(name)) {
101
+ throw new Error(`${where}: frontmatter name must be a lowercase hyphenated slug (got ${JSON.stringify(name)})`);
102
+ }
103
+ if (!fm.description || typeof fm.description !== 'string' || fm.description.length < 4) {
104
+ throw new Error(`${where}: frontmatter description is required (it is what a matcher shows before loading)`);
105
+ }
106
+ const body = text.slice(end + 4).replace(/^\n+/, '');
107
+ if (!body.trim()) throw new Error(`${where}: skill body is empty — a procedure with no steps is not a skill`);
108
+
109
+ return {
110
+ name,
111
+ description: fm.description,
112
+ triggers: listOf('triggers').map(t => t.toLowerCase()),
113
+ version: typeof fm.version === 'string' ? fm.version : null,
114
+ source,
115
+ body,
116
+ };
117
+ }
118
+
119
+ /* ------------------------------------------------------------------ *
120
+ * Loading.
121
+ * ------------------------------------------------------------------ */
122
+
123
+ export function loadSkills(dirs) {
124
+ const loaded = [];
125
+ for (const dir of dirs) {
126
+ if (!existsSync(dir)) continue; // optional dirs skip silently; an EXPLICIT dir that exists but is unreadable still errors below
127
+ const walk = (d) => {
128
+ let entries;
129
+ try { entries = readdirSync(d); } catch { return; }
130
+ for (const name of entries.sort()) {
131
+ const p = join(d, name);
132
+ let st;
133
+ try { st = statSync(p); } catch { continue; }
134
+ if (st.isDirectory()) walk(p);
135
+ else if (name === 'SKILL.md' || name.endsWith('.skill.md')) {
136
+ const skill = parseSkill(readFileSync(p, 'utf8'), p);
137
+ // Filename discipline: SKILL.md's directory names it; *.skill.md names itself.
138
+ const derived = name === 'SKILL.md' ? basename(dirname(p)) : name.replace(/\.skill\.md$/, '');
139
+ if (derived !== skill.name) {
140
+ throw new Error(`skills: ${p} declares name ${JSON.stringify(skill.name)} but its ${name === 'SKILL.md' ? 'directory' : 'filename'} is ${JSON.stringify(derived)}`);
141
+ }
142
+ _skills.set(skill.name, skill);
143
+ loaded.push({ name: skill.name, path: p });
144
+ }
145
+ }
146
+ };
147
+ walk(dir);
148
+ }
149
+ trailJournal('skills.loaded', { dirs, count: loaded.length, names: loaded.map(l => l.name) });
150
+ return loaded;
151
+ }
152
+
153
+ export function getSkill(name) {
154
+ bootstrap();
155
+ return _skills.get(name) ?? null;
156
+ }
157
+
158
+ export function listSkills() {
159
+ bootstrap();
160
+ return [..._skills.values()].sort((a, b) => a.name < b.name ? -1 : 1);
161
+ }
162
+
163
+ /* ------------------------------------------------------------------ *
164
+ * Deterministic matching — exact keyword inclusion, scored, stable order.
165
+ * ------------------------------------------------------------------ */
166
+
167
+ export function matchSkills(text, { limit = 3 } = {}) {
168
+ bootstrap();
169
+ const hay = String(text || '').toLowerCase();
170
+ const scored = [];
171
+ for (const s of listSkills()) {
172
+ let score = 0;
173
+ const hits = [];
174
+ for (const trig of s.triggers) {
175
+ if (hay.includes(trig)) { score += 2; hits.push(trig); }
176
+ }
177
+ // Name itself is a weak trigger ("use the run-tests skill").
178
+ if (hay.includes(s.name)) score += 3;
179
+ if (score > 0) scored.push({ name: s.name, description: s.description, score, hits });
180
+ }
181
+ scored.sort((a, b) => b.score - a.score || (a.name < b.name ? -1 : 1));
182
+ return scored.slice(0, limit);
183
+ }
184
+
185
+ /**
186
+ * Match skills against a task's text and absorb the bodies into the task's
187
+ * working set (the ephemeral brief — see mind/task-brief.js). Skills are
188
+ * fuel for THIS task, not durable knowledge.
189
+ */
190
+ export async function injectSkills(taskId, text, { limit = 3 } = {}) {
191
+ bootstrap();
192
+ const matches = matchSkills(text, { limit });
193
+ if (matches.length === 0) return { injected: [] };
194
+ const { briefAbsorb } = await import('./task-brief.js');
195
+ const injected = [];
196
+ for (const m of matches) {
197
+ const skill = _skills.get(m.name);
198
+ const absorbed = briefAbsorb(taskId, [{
199
+ text: `SKILL ${skill.name} — ${skill.description}\n${skill.body}`,
200
+ source: `skill:${skill.name}`,
201
+ }]);
202
+ injected.push({ name: m.name, hits: m.hits, absorbed: absorbed.absorbed });
203
+ trailJournal('skill.injected', { task: taskId, skill: m.name, hits: m.hits });
204
+ }
205
+ return { injected };
206
+ }
207
+
208
+ export function reset() {
209
+ _skills.clear();
210
+ }
@@ -27,8 +27,14 @@
27
27
 
28
28
  import { getDb } from '../brain/db.js';
29
29
  import { journal as trailJournal } from '../kernel/trail.js';
30
+ import { get as getConfig } from '../config.js';
30
31
 
31
- /** Defaults: small on purpose. Overrides are loud constants, not vibes. */
32
+ /**
33
+ * Defaults: small on purpose, and DECLARED, not hardcoded (Codebuff
34
+ * CompactionPolicy lineage): the shipped numbers mirror config.brief —
35
+ * the real source of truth is the config file, and briefOpen/briefRead
36
+ * consult it before falling back here. No hidden behavior knobs.
37
+ */
32
38
  export const DEFAULTS = Object.freeze({
33
39
  ttl_s: 3600, // 1 hour — gathered material outlives the task barely
34
40
  budget_bytes: 64_000, // 64 KiB of working text — enough, and no more
@@ -36,6 +42,17 @@ export const DEFAULTS = Object.freeze({
36
42
  max_item_bytes: 8_000 // one finding larger than this is truncated to it
37
43
  });
38
44
 
45
+ /** Config-declared policy wins over the shipped constants; opts win over both. */
46
+ function policyFor(key, optsKey, opts) {
47
+ const fromOpts = opts[optsKey];
48
+ if (fromOpts !== undefined) return fromOpts;
49
+ try {
50
+ const declared = getConfig()?.brief?.[key];
51
+ if (declared !== undefined) return declared;
52
+ } catch { /* config not loaded (tests) — shipped default applies */ }
53
+ return DEFAULTS[key];
54
+ }
55
+
39
56
  /**
40
57
  * Open (or resume) the working set for a task. Idempotent: an existing
41
58
  * live set is returned as-is (resume semantics), a swept/expired one is
@@ -51,8 +68,8 @@ export function briefOpen(taskId, opts = {}) {
51
68
  }
52
69
  const db = getDb();
53
70
  briefSweep(); // opportunistic global expiry — expired rows never accumulate
54
- const ttl = Math.max(1, Number(opts.ttl_s ?? DEFAULTS.ttl_s));
55
- const budget = Math.max(1024, Number(opts.budget_bytes ?? DEFAULTS.budget_bytes));
71
+ const ttl = Math.max(1, Number(policyFor('ttl_s', 'ttl_s', opts)));
72
+ const budget = Math.max(1024, Number(policyFor('budget_bytes', 'budget_bytes', opts)));
56
73
  const row = db.prepare(
57
74
  `SELECT COUNT(*) AS n, COALESCE(SUM(bytes), 0) AS b
58
75
  FROM task_working_set WHERE task_id = ?`
@@ -78,9 +95,9 @@ export function briefAbsorb(taskId, findings, opts = {}) {
78
95
  }
79
96
  const list = Array.isArray(findings) ? findings : (findings ? [findings] : []);
80
97
  const db = getDb();
81
- const ttl = Math.max(1, Number(opts.ttl_s ?? DEFAULTS.ttl_s));
82
- const budget = Math.max(1024, Number(opts.budget_bytes ?? DEFAULTS.budget_bytes));
83
- const maxItem = Math.max(64, Number(opts.max_item_bytes ?? DEFAULTS.max_item_bytes));
98
+ const ttl = Math.max(1, Number(policyFor('ttl_s', 'ttl_s', opts)));
99
+ const budget = Math.max(1024, Number(policyFor('budget_bytes', 'budget_bytes', opts)));
100
+ const maxItem = Math.max(64, Number(policyFor('max_item_bytes', 'max_item_bytes', opts)));
84
101
  const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
85
102
  const source = opts.source ?? null;
86
103
 
@@ -128,7 +145,7 @@ export function briefAbsorb(taskId, findings, opts = {}) {
128
145
  */
129
146
  export function briefRead(taskId, opts = {}) {
130
147
  const db = getDb();
131
- const limit = Math.max(1, Number(opts.limit ?? DEFAULTS.read_limit));
148
+ const limit = Math.max(1, Number(policyFor('read_limit', 'limit', opts)));
132
149
  const now = new Date().toISOString();
133
150
  // Lazy expiry: TTL past → gone. A read is a sweep for this task.
134
151
  db.prepare(
@@ -0,0 +1,118 @@
1
+ /**
2
+ * mind/trust.js — THE TRUST GATE for loaded packs (Codebuff's publisher-trust
3
+ * lineage, rebuilt as evidence instead of identity).
4
+ *
5
+ * Codebuff gates registry agents by publisher identity. FS has no accounts —
6
+ * its doctrine is EVIDENCE — so trust here is content-addressed: a pack is
7
+ * trusted when its sha256 matches a pin recorded in the brain, or when the
8
+ * operator explicitly pins it. An unpinned external pack loads in
9
+ * `quarantine`: visible, listable, but never consulted by rungs until a
10
+ * human promotes it. Nothing is silently trusted, nothing is silently
11
+ * refused — every decision journals.
12
+ *
13
+ * trustStatus(path) → { state: 'pinned'|'quarantine'|'unseen', ... }
14
+ * pin(path) → record the checksum pin (operator action)
15
+ * unpin(path) → remove the pin
16
+ * listTrusted() → every pin
17
+ * gate(path, { requirePinned })→ the load-time decision point
18
+ *
19
+ * @module mind/trust
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ import { createHash } from 'node:crypto';
25
+ import { readFileSync, existsSync } from 'node:fs';
26
+ import { getDb } from '../brain/db.js';
27
+ import { journal as trailJournal } from '../kernel/trail.js';
28
+
29
+ function ensureTable(db) {
30
+ db.exec(`CREATE TABLE IF NOT EXISTS trust_pins (
31
+ path TEXT PRIMARY KEY,
32
+ sha256 TEXT NOT NULL,
33
+ pinned_at TEXT NOT NULL,
34
+ note TEXT
35
+ )`);
36
+ }
37
+
38
+ export function sha256File(path) {
39
+ if (!existsSync(path)) throw new Error(`trust: file not found: ${path}`);
40
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
41
+ }
42
+
43
+ /**
44
+ * The trust status of a pack file. `unseen` = never loaded before;
45
+ * `quarantine` = known content, never pinned; `pinned` = checksum matches
46
+ * the recorded pin; `changed` = PINNED ONCE but the content now differs —
47
+ * the loudest state there is (a pinned pack that mutated is either an
48
+ * update or a substitution, and a human decides which).
49
+ */
50
+ export function trustStatus(path) {
51
+ const db = getDb();
52
+ ensureTable(db);
53
+ const digest = sha256File(path);
54
+ const row = db.prepare('SELECT * FROM trust_pins WHERE path = ?').get(path);
55
+ if (!row) return { path, sha256: digest, state: 'unseen' };
56
+ if (row.sha256 === digest) return { path, sha256: digest, state: 'pinned', pinned_at: row.pinned_at };
57
+ return { path, sha256: digest, state: 'changed', pinned_sha256: row.sha256, pinned_at: row.pinned_at };
58
+ }
59
+
60
+ /** Operator action: pin the current content of a pack. */
61
+ export function pin(path, note = null) {
62
+ const db = getDb();
63
+ ensureTable(db);
64
+ const digest = sha256File(path);
65
+ db.prepare(
66
+ `INSERT INTO trust_pins (path, sha256, pinned_at, note) VALUES (?, ?, ?, ?)
67
+ ON CONFLICT(path) DO UPDATE SET sha256 = excluded.sha256, pinned_at = excluded.pinned_at, note = excluded.note`
68
+ ).run(path, digest, new Date().toISOString(), note);
69
+ trailJournal('trust.pinned', { path, sha256: digest });
70
+ return { path, sha256: digest, state: 'pinned' };
71
+ }
72
+
73
+ export function unpin(path) {
74
+ const db = getDb();
75
+ ensureTable(db);
76
+ const gone = db.prepare('DELETE FROM trust_pins WHERE path = ?').run(path);
77
+ trailJournal('trust.unpinned', { path, existed: gone.changes > 0 });
78
+ return { path, removed: gone.changes > 0 };
79
+ }
80
+
81
+ export function listTrusted() {
82
+ const db = getDb();
83
+ ensureTable(db);
84
+ return db.prepare('SELECT path, sha256, pinned_at, note FROM trust_pins ORDER BY path').all();
85
+ }
86
+
87
+ /**
88
+ * THE GATE. Called at pack-load time. Semantics:
89
+ * pinned → allowed.
90
+ * unseen → allowed ONCE in quarantine (the loader records it; the pack
91
+ * is loadable but flagged — first contact is observation).
92
+ * changed → REFUSED unless opts.allowChanged (an explicit operator
93
+ * re-pin) — a pinned pack that changed content must never
94
+ * slide through on a routine load.
95
+ * @returns {{ allowed: boolean, state: string, quarantined?: boolean, reason?: string }}
96
+ */
97
+ export function gate(path, opts = {}) {
98
+ const status = trustStatus(path);
99
+ if (status.state === 'pinned') return { allowed: true, state: status.state };
100
+ if (status.state === 'unseen') return { allowed: true, state: status.state, quarantined: true };
101
+ // changed
102
+ if (opts.allowChanged) {
103
+ trailJournal('trust.gate', { path, state: 'changed', decision: 'allowed-explicit' });
104
+ return { allowed: true, state: 'changed', quarantined: true };
105
+ }
106
+ trailJournal('trust.gate', { path, state: 'changed', decision: 'refused' });
107
+ return {
108
+ allowed: false,
109
+ state: 'changed',
110
+ reason: `trust: ${path} was pinned as ${status.pinned_sha256} but now hashes ${status.sha256} — re-pin it explicitly if this change is intended (scion trust pin)`,
111
+ };
112
+ }
113
+
114
+ export function reset() {
115
+ const db = getDb();
116
+ ensureTable(db);
117
+ db.prepare('DELETE FROM trust_pins').run();
118
+ }