futura-scion 0.2.3 → 0.2.5
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/bin/scion.js +132 -0
- package/config/scion.config.yaml +1 -5
- package/package.json +2 -1
- package/src/brain/memory.js +24 -0
- package/src/config.js +29 -1
- package/src/kernel/safety.js +5 -1
- package/src/kernel/workflow.js +25 -1
- package/src/mind/builtins-register.js +88 -0
- package/src/mind/plan-fullstack.js +164 -0
- package/src/mind/research/academic.js +183 -0
- package/src/mind/research/corroborate.js +110 -0
- package/src/mind/research/extract.js +90 -0
- package/src/mind/research/fallback.js +51 -0
- package/src/mind/research/fetcher.js +147 -0
- package/src/mind/research/index.js +204 -0
- package/src/mind/research/loop-learner.js +121 -0
- package/src/mind/research/rank.js +162 -0
- package/src/mind/research/search.js +191 -0
- package/src/mind/scaffold.js +158 -0
- package/src/mind/tools.js +20 -2
- package/templates/agent-tool.yaml +69 -0
- package/templates/node-api.yaml +116 -0
- package/templates/node-cli.yaml +69 -0
- package/templates/python-service.yaml +101 -0
- package/templates/static-web.yaml +64 -0
package/bin/scion.js
CHANGED
|
@@ -791,6 +791,134 @@ switch (cmd || '') {
|
|
|
791
791
|
console.log(JSON.stringify(out, null, 2));
|
|
792
792
|
break;
|
|
793
793
|
}
|
|
794
|
+
case 'scaffold': {
|
|
795
|
+
// CONSTRUCTION: compose a template into a project skeleton, gated by
|
|
796
|
+
// the stack's real verifier recipe before success is reported.
|
|
797
|
+
const { scaffold, listTemplates, loadTemplate } = await import('../src/mind/scaffold.js');
|
|
798
|
+
if (!arg || arg === 'list' || arg === '--list') {
|
|
799
|
+
const tpls = listTemplates();
|
|
800
|
+
if (tpls.length === 0) { console.log('no templates in templates/ — add YAML manifests'); break; }
|
|
801
|
+
for (const t of tpls) console.log(`${t.id.padEnd(16)} ${(t.stack ?? '-').toString().padEnd(8)} ${t.description}`);
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
const flags = restArgs.filter((a) => a.startsWith('--'));
|
|
805
|
+
const positional = restArgs.filter((a) => !a.startsWith('--'));
|
|
806
|
+
const target = positional[0]; // output directory
|
|
807
|
+
if (!target) {
|
|
808
|
+
console.error('usage: scion scaffold <template|fullstack|list> <target-dir> [--name x] [--port N] [--concurrency N] [--no-verify] [--dry-run]');
|
|
809
|
+
process.exitCode = 2;
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
if (arg === 'fullstack') {
|
|
813
|
+
// COMPOSABLE CONSTRUCTION: a workflow DAG that scaffolds backend,
|
|
814
|
+
// frontend, and CLI in parallel, then wires them into one repo.
|
|
815
|
+
const { planFullstack } = await import('../src/mind/plan-fullstack.js');
|
|
816
|
+
const params = {};
|
|
817
|
+
for (const f of flags) {
|
|
818
|
+
const m = f.match(/^--([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
819
|
+
if (m) params[m[1]] = m[2];
|
|
820
|
+
}
|
|
821
|
+
const manifest = planFullstack(resolve(target), {
|
|
822
|
+
name: params.name,
|
|
823
|
+
...(params.port !== undefined ? { port: Number(params.port) } : {}),
|
|
824
|
+
});
|
|
825
|
+
if (flags.includes('--dry-run')) {
|
|
826
|
+
console.log(`fullstack plan for ${target}:`);
|
|
827
|
+
for (const t of manifest.tasks) console.log(` ${t.id.padEnd(20)} deps: ${(t.depends_on ?? []).join(', ') || '(parallel)'}`);
|
|
828
|
+
console.log('\nrun with --approve-as <reviewer> to construct');
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
const approveIdx = restArgs.indexOf('--approve-as');
|
|
832
|
+
const reviewer = approveIdx !== -1 ? restArgs[approveIdx + 1] : null;
|
|
833
|
+
if (!reviewer) {
|
|
834
|
+
console.error('fullstack construction is a mutating workflow — approve it: --approve-as <reviewer> (or inspect first with --dry-run)');
|
|
835
|
+
process.exitCode = 2;
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
const { runWorkflow } = await import('../src/kernel/workflow.js');
|
|
839
|
+
const conc = Number(flags.find((f) => f.startsWith('--concurrency='))?.split('=')[1]) || 3;
|
|
840
|
+
const r = await runWorkflow(manifest, { concurrency: conc, approval: { reviewer } });
|
|
841
|
+
console.log(`fullstack ${r.status}: ${r.completed}/${r.total} steps (concurrency ${conc})`);
|
|
842
|
+
for (const t of r.tasks ?? []) {
|
|
843
|
+
if (t.status !== 'completed') console.log(` ${t.status.toUpperCase()}: ${t.manifestId ?? t.id} ${t.error ?? ''}`);
|
|
844
|
+
}
|
|
845
|
+
if (r.status !== 'completed') process.exitCode = 1;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
const tpl = loadTemplate(arg);
|
|
849
|
+
const params = {};
|
|
850
|
+
for (const f of flags) {
|
|
851
|
+
const m = f.match(/^--([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
852
|
+
if (m) params[m[1]] = m[2];
|
|
853
|
+
}
|
|
854
|
+
// Default missing params to sensible template-agnostic values instead
|
|
855
|
+
// of a raw throw — the CLI user should see WHICH param is missing in a
|
|
856
|
+
// friendly line, not a stack trace.
|
|
857
|
+
for (const req of ['name', 'title', 'port', 'description']) {
|
|
858
|
+
if (!(req in params)) {
|
|
859
|
+
if (req === 'port') params.port = '3000';
|
|
860
|
+
else if (req === 'title') params.title = params.name ?? arg;
|
|
861
|
+
else if (req === 'name') params.name = params.name ?? 'app';
|
|
862
|
+
else params.description ??= `built with FS scaffold (${arg})`;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
let r;
|
|
866
|
+
try {
|
|
867
|
+
r = await scaffold(arg, { params, cwd: resolve(target), verify: !flags.includes('--no-verify') });
|
|
868
|
+
} catch (err) {
|
|
869
|
+
console.error(`scaffold failed: ${err.message}`);
|
|
870
|
+
process.exitCode = 1;
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
if (r.ok === false && r.error) {
|
|
874
|
+
console.error(`scaffold failed: ${r.error}`);
|
|
875
|
+
process.exitCode = 1;
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
878
|
+
console.log(`scaffold ${r.template} → ${target}`);
|
|
879
|
+
for (const f of r.files) console.log(` + ${f}`);
|
|
880
|
+
if (r.gate) {
|
|
881
|
+
console.log(`gate: ${r.gate.verified ? 'VERIFIED' : 'FAILED'} — [${r.gate.evidence.exit_codes.join(',')}]`);
|
|
882
|
+
if (!r.gate.verified) process.exitCode = 1;
|
|
883
|
+
} else {
|
|
884
|
+
console.log('gate: skipped (no stack recipe)');
|
|
885
|
+
}
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
case 'research': {
|
|
889
|
+
// The research rung, driven from the CLI (webresearcher lineage):
|
|
890
|
+
// deterministic-first web research with a completion gate.
|
|
891
|
+
const q = [arg, ...restArgs.filter((a) => !a.startsWith('--'))].filter(Boolean).join(' ').trim();
|
|
892
|
+
const flags = restArgs.filter((a) => a.startsWith('--')) || [];
|
|
893
|
+
const { runResearch } = await import('../src/mind/research/index.js');
|
|
894
|
+
if (!q) {
|
|
895
|
+
console.error('usage: scion research "<question>" [--no-persist] [--pages N] [--deep] [--learn]');
|
|
896
|
+
process.exitCode = 2;
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
const recipe = flags.includes('--deep')
|
|
900
|
+
? { id: 'deep-dive', parameters: { max_pages: 16 }, verify: { min_pages: 2, min_items: 5, min_corroborated: 1 } }
|
|
901
|
+
: undefined;
|
|
902
|
+
const report = await runResearch(q, {
|
|
903
|
+
recipe,
|
|
904
|
+
persist: !flags.includes('--no-persist'),
|
|
905
|
+
max_pages: Number((flags.find((f) => f.startsWith('--pages')) ?? '').split('=')[1]) || (flags.includes('--pages') ? Number(restArgs[restArgs.indexOf('--pages') + 1]) : undefined) || undefined,
|
|
906
|
+
});
|
|
907
|
+
console.log(`research: ${report.pages} pages → ${report.items.length} items ` +
|
|
908
|
+
`(${report.census.SUPPORTED} corroborated, ${report.census.CONFLICTING} conflicting)`);
|
|
909
|
+
console.log(`gate: ${report.gate.verdict} — ${report.gate.checks.map((c) => `${c.check}:${c.pass ? 'ok' : 'fail'}(${c.actual})`).join(' ')}`);
|
|
910
|
+
for (const f of report.summary.KEY_FACT.slice(0, 5)) console.log(` ✓ ${f.slice(0, 140)}`);
|
|
911
|
+
for (const c of report.summary.CONTRADICTION.slice(0, 3)) console.log(` ⚡ ${c.slice(0, 140)}`);
|
|
912
|
+
for (const e of report.summary.EVIDENCE.slice(0, 3)) console.log(` · ${e.slice(0, 140)}`);
|
|
913
|
+
if (report.fallback) console.log(` [llm-fallback] ${report.fallback.synthesis.slice(0, 200)}`);
|
|
914
|
+
if (flags.includes('--learn')) {
|
|
915
|
+
const { learnResearchLoops } = await import('../src/mind/research/loop-learner.js');
|
|
916
|
+
const learned = learnResearchLoops();
|
|
917
|
+
console.log(`loop-learner: ${learned.length} lesson(s) persisted`);
|
|
918
|
+
}
|
|
919
|
+
process.exitCode = report.gate.verdict === 'PASS' ? 0 : 1;
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
794
922
|
case 'conventions': {
|
|
795
923
|
// Measure + persist project conventions (cortex convention-detector
|
|
796
924
|
// lineage, as EVIDENCE): discovered, never enforced.
|
|
@@ -884,6 +1012,10 @@ switch (cmd || '') {
|
|
|
884
1012
|
leaseMs, pollMs,
|
|
885
1013
|
primaryUrl: followerMode || primaryUrl ? (primaryUrl || null) : null,
|
|
886
1014
|
});
|
|
1015
|
+
// Lever 3: the capability tools (web-search, scholar-search, scaffold-*)
|
|
1016
|
+
// register at boot so serve/UI/MCP all share the widened hands.
|
|
1017
|
+
const { registerBuiltinCapabilities } = await import('../src/mind/builtins-register.js');
|
|
1018
|
+
registerBuiltinCapabilities();
|
|
887
1019
|
const api = await startApi({ port: Number(arg) || undefined, leader });
|
|
888
1020
|
leader.start();
|
|
889
1021
|
// FS Desktop support: provider-backed generator (rung G) from config,
|
package/config/scion.config.yaml
CHANGED
|
@@ -11,12 +11,8 @@ ladder:
|
|
|
11
11
|
min_insight_confidence: 0.55 # reasoner rung admission floor
|
|
12
12
|
|
|
13
13
|
llm:
|
|
14
|
-
daily_tokens:
|
|
14
|
+
daily_tokens: 0 # 0 = LLM rung off (zero-LLM doctrine)
|
|
15
15
|
|
|
16
|
-
apiKey: null
|
|
17
|
-
model: "qwen2.5-coder:7b"
|
|
18
|
-
baseUrl: "http://localhost:11434/v1"
|
|
19
|
-
provider: "ollama"
|
|
20
16
|
bounds:
|
|
21
17
|
max_turns: 25
|
|
22
18
|
timeout_ms: 300000
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "futura-scion",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"bin/",
|
|
14
14
|
"src/",
|
|
15
15
|
"recipes/",
|
|
16
|
+
"templates/",
|
|
16
17
|
"config/",
|
|
17
18
|
"knowledge/",
|
|
18
19
|
"README.md",
|
package/src/brain/memory.js
CHANGED
|
@@ -368,6 +368,30 @@ export function list(opts = {}) {
|
|
|
368
368
|
return rows.map(r => ({ ...r, tags: JSON.parse(r.tags || '[]') }));
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
/**
|
|
372
|
+
* reinforce — DEDUP-REINFORCE (webresearcher cortex_bridge lineage): when
|
|
373
|
+
* new evidence matches an existing memory, STRENGTHEN it instead of
|
|
374
|
+
* duplicating. Confidence rises by delta (capped at 100), importance to at
|
|
375
|
+
* least the floor, and the access counter ticks. Repeated corroboration is
|
|
376
|
+
* a trust signal — the memory-graph weights read it on every recall.
|
|
377
|
+
* @param {number} id — existing memory id
|
|
378
|
+
* @param {object} [opts] — { confidence_delta=5, importance_floor=null }
|
|
379
|
+
* @returns {{ id, confidence, importance, reinforced: true }}
|
|
380
|
+
*/
|
|
381
|
+
export function reinforce(id, opts = {}) {
|
|
382
|
+
const db = getDb();
|
|
383
|
+
const row = db.prepare('SELECT confidence, importance FROM memories WHERE id = ?').get(id);
|
|
384
|
+
if (!row) throw new Error(`memory.reinforce: no memory ${id}`);
|
|
385
|
+
const delta = opts.confidence_delta ?? 5;
|
|
386
|
+
const confidence = Math.min(100, row.confidence + delta);
|
|
387
|
+
const importance = opts.importance_floor != null
|
|
388
|
+
? Math.max(row.importance, opts.importance_floor)
|
|
389
|
+
: Math.min(10, row.importance + (delta >= 5 ? 1 : 0));
|
|
390
|
+
db.prepare('UPDATE memories SET confidence = ?, importance = ?, access_count = access_count + 1 WHERE id = ?')
|
|
391
|
+
.run(confidence, importance, id);
|
|
392
|
+
return { id, confidence, importance, reinforced: true };
|
|
393
|
+
}
|
|
394
|
+
|
|
371
395
|
export function stats() {
|
|
372
396
|
const db = getDb();
|
|
373
397
|
const row = db.prepare(
|
package/src/config.js
CHANGED
|
@@ -59,6 +59,22 @@ function parseScalar(raw) {
|
|
|
59
59
|
return v.replace(/^['"]|['"]$/g, '');
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Strip a block scalar's BASE indentation while preserving relative
|
|
64
|
+
* indentation (Python code in templates lives or dies by its indents).
|
|
65
|
+
* The base is the indentation of the FIRST content line; subsequent lines
|
|
66
|
+
* are dedented by the same amount. `|` literal blocks are exact; `>` folded
|
|
67
|
+
* blocks joined with spaces downstream, so per-line dedent is harmless.
|
|
68
|
+
*/
|
|
69
|
+
function dedentBlockLine(line, blockScalar) {
|
|
70
|
+
if (blockScalar.folded) return line.trim();
|
|
71
|
+
if (blockScalar.baseIndent === undefined) {
|
|
72
|
+
blockScalar.baseIndent = line.match(/^\s*/)[0].length;
|
|
73
|
+
}
|
|
74
|
+
const cut = Math.min(blockScalar.baseIndent, line.match(/^\s*/)[0].length);
|
|
75
|
+
return line.slice(cut);
|
|
76
|
+
}
|
|
77
|
+
|
|
62
78
|
/**
|
|
63
79
|
* Minimal YAML-subset parser: nested maps by indentation, scalars, inline
|
|
64
80
|
* arrays, `#` comments. Deliberately NOT full YAML — unsupported syntax
|
|
@@ -144,11 +160,23 @@ export function parseYaml(text, source = CONFIG_PATH) {
|
|
|
144
160
|
if (!match) {
|
|
145
161
|
// Continuation line of a block scalar ("key: >" folded text)?
|
|
146
162
|
if (blockScalar !== null && /^\s+\S/.test(line)) {
|
|
147
|
-
blockScalar.text.push(line
|
|
163
|
+
blockScalar.text.push(dedentBlockLine(line, blockScalar));
|
|
148
164
|
continue;
|
|
149
165
|
}
|
|
150
166
|
throw new Error(`config: ${source}:${lineno} unsupported syntax — ${JSON.stringify(line.trim())}`);
|
|
151
167
|
}
|
|
168
|
+
// A block scalar is OPEN and this line is deeper-indented than its key:
|
|
169
|
+
// it is SCALAR TEXT, never a new key — even when it looks like
|
|
170
|
+
// "word: value" (template manifests embed whole files in block scalars;
|
|
171
|
+
// their `port: 3000`-shaped lines are content, not structure). Leading
|
|
172
|
+
// indentation is PRESERVED relative to the block's first content line —
|
|
173
|
+
// Python files in templates live or die by their indentation.
|
|
174
|
+
if (blockScalar !== null
|
|
175
|
+
&& match[1].length > (blockScalar.indent ?? 0)
|
|
176
|
+
&& !/^\s*-\s/.test(line)) {
|
|
177
|
+
blockScalar.text.push(dedentBlockLine(line, blockScalar));
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
152
180
|
const [, ws, key, rest] = match;
|
|
153
181
|
const indent = ws.length;
|
|
154
182
|
|
package/src/kernel/safety.js
CHANGED
|
@@ -86,8 +86,12 @@ export function assertPathAllowed(p) {
|
|
|
86
86
|
throw new Error('safety: path must be a non-empty string');
|
|
87
87
|
}
|
|
88
88
|
const norm = p.replace(/\\/g, '/');
|
|
89
|
+
const base = norm.split('/').pop(); // final segment decides secrets, not substrings
|
|
89
90
|
for (const d of denylist()) {
|
|
90
|
-
|
|
91
|
+
// Segment-aware matching: `.env` denies the secrets file itself, not
|
|
92
|
+
// `.env.example` or `backend-service/` (substring matching blocked the
|
|
93
|
+
// scaffolded env CONTRACT — a template file, not a secret).
|
|
94
|
+
if (d === '.env' ? base === '.env' : norm.includes(d)) {
|
|
91
95
|
throw new Error(`safety: path denied by muscle.path_deny ("${d}"): ${p}`);
|
|
92
96
|
}
|
|
93
97
|
}
|
package/src/kernel/workflow.js
CHANGED
|
@@ -231,11 +231,35 @@ async function kernelCallable(fn) {
|
|
|
231
231
|
if (fn === 'ocrImage') {
|
|
232
232
|
return (args) => ocrImage(args?.image, { ...(args?.engine ? { engine: args.engine } : {}) });
|
|
233
233
|
}
|
|
234
|
+
if (fn === 'scaffold') {
|
|
235
|
+
return async (args) => {
|
|
236
|
+
const { scaffold } = await import('../mind/scaffold.js');
|
|
237
|
+
const r = await scaffold(args?.template, {
|
|
238
|
+
params: args?.params ?? {},
|
|
239
|
+
...(args?.cwd !== undefined ? { cwd: args.cwd } : {}),
|
|
240
|
+
...(args?.verify !== undefined ? { verify: args.verify } : {}),
|
|
241
|
+
});
|
|
242
|
+
if (!r.ok) throw new Error(r.error ?? `scaffold ${args?.template} failed (gate: ${r.gate?.verified})`);
|
|
243
|
+
return { template: r.template, files: r.files, gate: r.gate?.verified ?? null };
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (fn === 'scaffold.file') {
|
|
247
|
+
// Wiring step: write one glue file through the transactional applier
|
|
248
|
+
// (snapshot + rollback) with the same path guardrails as any mutation.
|
|
249
|
+
return async (args) => {
|
|
250
|
+
const { transactionalApplier } = await import('./apply.js');
|
|
251
|
+
const tx = transactionalApplier({
|
|
252
|
+
files: [{ file: args?.file, content: args?.content ?? '', description: args?.description ?? `wire ${args?.file}` }],
|
|
253
|
+
});
|
|
254
|
+
const description = tx.apply();
|
|
255
|
+
return { description, file: args?.file };
|
|
256
|
+
};
|
|
257
|
+
}
|
|
234
258
|
return null;
|
|
235
259
|
}
|
|
236
260
|
|
|
237
261
|
function callableSurface() {
|
|
238
|
-
return 'boostConsensus, ocrImage';
|
|
262
|
+
return 'boostConsensus, ocrImage, scaffold, scaffold.file';
|
|
239
263
|
}
|
|
240
264
|
|
|
241
265
|
async function execShellStep(step) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mind/builtins-register.js — LEVER 3 (tool-calling breadth): registers the
|
|
3
|
+
* network + construction capabilities as FIRST-CLASS registry tools so the
|
|
4
|
+
* research rung (`research()`) and the swarm can invoke them by name like
|
|
5
|
+
* any shell tool. Import once at boot/serve; registration is idempotent.
|
|
6
|
+
*
|
|
7
|
+
* These are ASYNC capability tools (functions, not argv templates) — the
|
|
8
|
+
* registry stores their runner and runTool dispatches to it. Same rails
|
|
9
|
+
* apply: journaled, redacted, bounded.
|
|
10
|
+
*
|
|
11
|
+
* @module mind/builtins-register
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
import { registerTool } from './tools.js';
|
|
17
|
+
import { searchWeb } from './research/search.js';
|
|
18
|
+
import { listTemplates, loadTemplate } from './scaffold.js';
|
|
19
|
+
|
|
20
|
+
let registered = false;
|
|
21
|
+
|
|
22
|
+
/** Idempotently register the capability tools. */
|
|
23
|
+
export function registerBuiltinCapabilities() {
|
|
24
|
+
if (registered) return listTemplates();
|
|
25
|
+
registered = true;
|
|
26
|
+
|
|
27
|
+
registerTool({
|
|
28
|
+
name: 'web-search',
|
|
29
|
+
description: 'search the web (DuckDuckGo keyless / Tavily keyed); {topic} is the query',
|
|
30
|
+
tags: ['web', 'research', 'network'],
|
|
31
|
+
capability: async (opts = {}) => {
|
|
32
|
+
const r = await searchWeb(opts.topic ?? '', { max_results: opts.max_results ?? 5 });
|
|
33
|
+
return {
|
|
34
|
+
ok: r.results.length > 0,
|
|
35
|
+
stdout: r.results.map((x) => `- ${x.title}\n ${x.url}\n ${String(x.snippet ?? '').slice(0, 160)}`).join('\n').slice(0, 8000),
|
|
36
|
+
meta: { provider: r.provider, count: r.results.length, attempts: r.attempts.length },
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
registerTool({
|
|
42
|
+
name: 'scholar-search',
|
|
43
|
+
description: 'search scholarship (arXiv/Semantic Scholar/CrossRef); {topic} is the query',
|
|
44
|
+
tags: ['academic', 'research', 'network'],
|
|
45
|
+
capability: async (opts = {}) => {
|
|
46
|
+
const { searchScholar } = await import('./research/academic.js');
|
|
47
|
+
const papers = await searchScholar(opts.topic ?? '', { max_results: 5 });
|
|
48
|
+
return {
|
|
49
|
+
ok: papers.length > 0,
|
|
50
|
+
stdout: papers.map((p) => `- ${p.title} (${p.year ?? 'n.d.'}, cites: ${p.citation_count ?? '?'})\n ${p.url}`).join('\n').slice(0, 8000),
|
|
51
|
+
meta: { count: papers.length },
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
registerTool({
|
|
57
|
+
name: 'scaffold-list',
|
|
58
|
+
description: 'list constructable project templates (what FS can BUILD)',
|
|
59
|
+
tags: ['construction'],
|
|
60
|
+
capability: async () => {
|
|
61
|
+
const tpls = listTemplates();
|
|
62
|
+
return {
|
|
63
|
+
ok: tpls.length > 0,
|
|
64
|
+
stdout: tpls.map((t) => `${t.id.padEnd(16)} ${(t.stack ?? '-').padEnd(8)} ${t.description}`).join('\n'),
|
|
65
|
+
meta: { count: tpls.length },
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
registerTool({
|
|
71
|
+
name: 'scaffold-inspect',
|
|
72
|
+
description: 'show a template manifest (files, post commands, stack) for {topic}=template id',
|
|
73
|
+
tags: ['construction'],
|
|
74
|
+
capability: async (opts = {}) => {
|
|
75
|
+
try {
|
|
76
|
+
const t = loadTemplate(opts.topic ?? '');
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
stdout: `${t.name} (stack: ${t.stack ?? 'none'})\n${t.description}\nfiles:\n${t.files.map((f) => ` + ${f.path}`).join('\n')}\npost:\n${(t.post ?? []).map((c) => ` $ ${c}`).join('\n') || ' (none)'}`,
|
|
80
|
+
};
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return { ok: false, error: String(err?.message || err).slice(0, 200) };
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return listTemplates();
|
|
88
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mind/plan-fullstack.js — COMPOSABLE CONSTRUCTION: a fullstack scaffold is
|
|
3
|
+
* a WORKFLOW DAG (the plan-fix lineage applied to building).
|
|
4
|
+
*
|
|
5
|
+
* Wave 1 (parallel): scaffold the backend, the frontend, and the CLI into
|
|
6
|
+
* their subdirectories — three independent `scaffold` kernel calls.
|
|
7
|
+
* Wave 2 (wiring, after all of wave 1): generate the cross-component glue
|
|
8
|
+
* that only makes sense once all three exist:
|
|
9
|
+
*
|
|
10
|
+
* - frontend/src/api.js — a typed-ish fetch client for the backend
|
|
11
|
+
* - README.md — the repo-level map of all three components
|
|
12
|
+
* - .env.example — shared env contract (PORT for the backend)
|
|
13
|
+
*
|
|
14
|
+
* The wiring steps are shell-independent: they are emitted as additional
|
|
15
|
+
* `scaffold`-adjacent `call` steps writing files through the same
|
|
16
|
+
* transactional path the Gate judges. Resumability inherits from the
|
|
17
|
+
* workflow engine: a failed wiring step can be re-driven; completed
|
|
18
|
+
* scaffolds never re-run.
|
|
19
|
+
*
|
|
20
|
+
* @module mind/plan-fullstack
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
import * as trail from '../kernel/trail.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Generate the fullstack workflow manifest.
|
|
29
|
+
* @param {string} targetDir — repo root to construct into
|
|
30
|
+
* @param {object} [opts] — { name?, backend?, frontend?, cli?, port? }
|
|
31
|
+
* backend/frontend/cli: { template, params } overrides; defaults build
|
|
32
|
+
* node-api + static-web + node-cli.
|
|
33
|
+
* @returns {object} a workflow manifest ({ name, tasks })
|
|
34
|
+
*/
|
|
35
|
+
export function planFullstack(targetDir, opts = {}) {
|
|
36
|
+
if (!targetDir || typeof targetDir !== 'string') {
|
|
37
|
+
throw new Error('planFullstack: targetDir is required');
|
|
38
|
+
}
|
|
39
|
+
const name = opts.name ?? targetDir.split(/[\\/]/).pop() ?? 'fullstack-app';
|
|
40
|
+
const port = String(opts.port ?? 3000);
|
|
41
|
+
const backend = { template: 'node-api', dir: 'backend', params: { name: `${name}-api`, port }, ...(opts.backend ?? {}) };
|
|
42
|
+
const frontend = { template: 'static-web', dir: 'frontend', params: { name: `${name}-web`, title: name }, ...(opts.frontend ?? {}) };
|
|
43
|
+
const cli = { template: 'node-cli', dir: 'cli', params: { name: `${name}-cli` }, ...(opts.cli ?? {}) };
|
|
44
|
+
const base = String(targetDir).replace(/\\/g, '/').replace(/\/$/, '');
|
|
45
|
+
|
|
46
|
+
const tasks = [];
|
|
47
|
+
|
|
48
|
+
// ── Wave 1: the three scaffolds, mutually independent → parallel ──
|
|
49
|
+
tasks.push(
|
|
50
|
+
{ id: 'scaffold-backend', type: 'call', fn: 'scaffold', args: { template: backend.template, params: backend.params, cwd: `${base}/${backend.dir}` } },
|
|
51
|
+
{ id: 'scaffold-frontend', type: 'call', fn: 'scaffold', args: { template: frontend.template, params: frontend.params, cwd: `${base}/${frontend.dir}` } },
|
|
52
|
+
{ id: 'scaffold-cli', type: 'call', fn: 'scaffold', args: { template: cli.template, params: cli.params, cwd: `${base}/${cli.dir}` } },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// ── Wave 2: wiring — depends on ALL of wave 1 ──
|
|
56
|
+
tasks.push(
|
|
57
|
+
{
|
|
58
|
+
id: 'wire-frontend-api',
|
|
59
|
+
type: 'call',
|
|
60
|
+
fn: 'scaffold.file',
|
|
61
|
+
args: {
|
|
62
|
+
file: `${base}/${frontend.dir}/js/api.js`,
|
|
63
|
+
content: apiClientModule(port),
|
|
64
|
+
description: 'frontend API client for the backend',
|
|
65
|
+
},
|
|
66
|
+
depends_on: ['scaffold-backend', 'scaffold-frontend'],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'wire-env-contract',
|
|
70
|
+
type: 'call',
|
|
71
|
+
fn: 'scaffold.file',
|
|
72
|
+
args: {
|
|
73
|
+
file: `${base}/.env.example`,
|
|
74
|
+
content: envContract(port),
|
|
75
|
+
description: 'shared env contract',
|
|
76
|
+
},
|
|
77
|
+
depends_on: ['scaffold-backend'],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'wire-root-readme',
|
|
81
|
+
type: 'call',
|
|
82
|
+
fn: 'scaffold.file',
|
|
83
|
+
args: {
|
|
84
|
+
file: `${base}/README.md`,
|
|
85
|
+
content: rootReadme(name, { backend, frontend, cli, port }),
|
|
86
|
+
description: 'repo-level map of the three components',
|
|
87
|
+
},
|
|
88
|
+
depends_on: ['scaffold-backend', 'scaffold-frontend', 'scaffold-cli'],
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const manifest = {
|
|
93
|
+
name: `fullstack-${name}`,
|
|
94
|
+
description: `Fullstack construct: ${backend.template} backend + ${frontend.template} frontend + ${cli.template} CLI, wired into one repo`,
|
|
95
|
+
tasks,
|
|
96
|
+
};
|
|
97
|
+
trail.journal('scaffold.fullstack-plan', { name, target: base, tasks: tasks.length });
|
|
98
|
+
return manifest;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The frontend's generated API client — fetch wrapper over the backend. */
|
|
102
|
+
function apiClientModule(port) {
|
|
103
|
+
return `/**
|
|
104
|
+
* api.js — generated by FS fullstack scaffold. The ONLY place the frontend
|
|
105
|
+
* knows the backend's address; change API_URL here and nowhere else.
|
|
106
|
+
*/
|
|
107
|
+
const API_URL = 'http://localhost:${port}';
|
|
108
|
+
|
|
109
|
+
async function request(path, opts = {}) {
|
|
110
|
+
const res = await fetch(\`\${API_URL}\${path}\`, {
|
|
111
|
+
headers: { 'content-type': 'application/json' },
|
|
112
|
+
...opts,
|
|
113
|
+
});
|
|
114
|
+
const body = await res.json().catch(() => ({}));
|
|
115
|
+
if (!res.ok) throw new Error(body.error ?? \`HTTP \${res.status}\`);
|
|
116
|
+
return body;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const api = {
|
|
120
|
+
health: () => request('/health'),
|
|
121
|
+
listItems: () => request('/items'),
|
|
122
|
+
addItem: (title) => request('/items', { method: 'POST', body: JSON.stringify({ title }) }),
|
|
123
|
+
};
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The shared env contract. */
|
|
128
|
+
function envContract(port) {
|
|
129
|
+
return `# Generated by FS fullstack scaffold — the env contract shared by all components.
|
|
130
|
+
PORT=${port}
|
|
131
|
+
NODE_ENV=development
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The repo-level README. */
|
|
136
|
+
function rootReadme(name, { backend, frontend, cli, port }) {
|
|
137
|
+
return `# ${name}
|
|
138
|
+
|
|
139
|
+
Fullstack repo constructed by FS scaffold — three gate-verified components
|
|
140
|
+
wired into one tree. Every file passed the stack's verifier recipe (real
|
|
141
|
+
exit codes) before this run reported success.
|
|
142
|
+
|
|
143
|
+
## Layout
|
|
144
|
+
|
|
145
|
+
| Component | Template | Location |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| Backend API | \`${backend.template}\` | \`${backend.dir}/\` — serves on :${port} |
|
|
148
|
+
| Frontend | \`${frontend.template}\` | \`${frontend.dir}/\` — open \`index.html\` |
|
|
149
|
+
| CLI | \`${cli.template}\` | \`${cli.dir}/\` |
|
|
150
|
+
|
|
151
|
+
## Run
|
|
152
|
+
|
|
153
|
+
\`\`\`bash
|
|
154
|
+
cd ${backend.dir} && npm install && npm start # API on :${port}
|
|
155
|
+
cd ${frontend.dir} # open index.html (js/api.js talks to :${port})
|
|
156
|
+
cd ${cli.dir} && npm install && npm test
|
|
157
|
+
\`\`\`
|
|
158
|
+
|
|
159
|
+
## Contract
|
|
160
|
+
|
|
161
|
+
- The frontend's \`${frontend.dir}/js/api.js\` is the single seam to the backend.
|
|
162
|
+
- \`.env.example\` is the shared environment contract.
|
|
163
|
+
`;
|
|
164
|
+
}
|