@liduchuan/atlas-loop 0.0.0-stage → 0.1.1
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 +112 -2
- package/SKILL.md +61 -0
- package/assets/viewer-shell.html +1911 -0
- package/bin/atlas-loop.mjs +99 -0
- package/package.json +52 -4
- package/references/schema.md +122 -0
- package/scripts/build_atlas.mjs +261 -0
- package/scripts/verify_atlas.mjs +316 -0
- package/template/app.jsx +1339 -0
- package/template/build-shell.mjs +82 -0
- package/template/dev-server.mjs +177 -0
- package/template/sample-data.json +253 -0
- package/template/styles.css +1188 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
10
|
+
|
|
11
|
+
function readVersion() {
|
|
12
|
+
try {
|
|
13
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
14
|
+
return pkg.version || '0.1.1';
|
|
15
|
+
} catch (_) {
|
|
16
|
+
return '0.1.1';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function printHelp() {
|
|
21
|
+
console.log(`Atlas Loop (@liduchuan/atlas-loop v${readVersion()})
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
npx @liduchuan/atlas-loop install [--dir <skills-dir>]
|
|
25
|
+
npx @liduchuan/atlas-loop dev [--port 5179] [--data <graph-data.json>]
|
|
26
|
+
npx @liduchuan/atlas-loop build --data <graph-data.json> [--out <output.html>]
|
|
27
|
+
npx @liduchuan/atlas-loop verify --html <output.html>
|
|
28
|
+
|
|
29
|
+
Commands:
|
|
30
|
+
install Copy the atlas-loop skill into an Agent skills directory (default: ~/.agents/skills/atlas-loop)
|
|
31
|
+
dev Launch the local live-reload runtime for template & style debugging
|
|
32
|
+
build Validate AtlasGraphData JSON and synthesize a standalone offline HTML learning map
|
|
33
|
+
verify Run headless Playwright verification against a generated HTML learning map
|
|
34
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function copySkillFiles(destDir) {
|
|
38
|
+
const items = ['SKILL.md', 'assets', 'references', 'scripts', 'template'];
|
|
39
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
40
|
+
for (const name of items) {
|
|
41
|
+
const src = path.join(PKG_ROOT, name);
|
|
42
|
+
const dst = path.join(destDir, name);
|
|
43
|
+
if (!fs.existsSync(src)) continue;
|
|
44
|
+
fs.rmSync(dst, { recursive: true, force: true });
|
|
45
|
+
fs.cpSync(src, dst, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveInstallDir(args) {
|
|
50
|
+
for (let i = 0; i < args.length; i++) {
|
|
51
|
+
if (args[i] === '--dir' && args[i + 1]) {
|
|
52
|
+
const given = path.resolve(args[++i]);
|
|
53
|
+
return path.basename(given) === 'atlas-loop' ? given : path.join(given, 'atlas-loop');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return path.join(os.homedir(), '.agents', 'skills', 'atlas-loop');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function runNodeFile(relFilePath, forwardArgs) {
|
|
60
|
+
const fullPath = path.join(PKG_ROOT, relFilePath);
|
|
61
|
+
const res = spawnSync(process.execPath, [fullPath, ...forwardArgs], { stdio: 'inherit' });
|
|
62
|
+
process.exit(res.status ?? 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const argv = process.argv.slice(2);
|
|
66
|
+
const cmd = argv[0];
|
|
67
|
+
|
|
68
|
+
if (!cmd || cmd === '-h' || cmd === '--help' || cmd === 'help') {
|
|
69
|
+
printHelp();
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (cmd === '-v' || cmd === '--version' || cmd === 'version') {
|
|
74
|
+
console.log(readVersion());
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (cmd === 'install') {
|
|
79
|
+
const dest = resolveInstallDir(argv.slice(1));
|
|
80
|
+
copySkillFiles(dest);
|
|
81
|
+
console.log(`Installed Atlas Loop skill to: ${dest}`);
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (cmd === 'dev') {
|
|
86
|
+
runNodeFile(path.join('template', 'dev-server.mjs'), argv.slice(1));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (cmd === 'build') {
|
|
90
|
+
runNodeFile(path.join('scripts', 'build_atlas.mjs'), argv.slice(1));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (cmd === 'verify') {
|
|
94
|
+
runNodeFile(path.join('scripts', 'verify_atlas.mjs'), argv.slice(1));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
98
|
+
printHelp();
|
|
99
|
+
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liduchuan/atlas-loop",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Atlas Loop — Universal recursive learning-map Agent Skill that dissects any unfamiliar subject into a standalone offline interactive HTML atlas with per-node deep-dive and recursive sub-atlas prompts.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "liduchuan",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/liduchuan/Atlas-Loop.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/liduchuan/Atlas-Loop#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/liduchuan/Atlas-Loop/issues"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"atlas-loop": "bin/atlas-loop.mjs"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"dev": "node template/dev-server.mjs",
|
|
21
|
+
"build:shell": "node template/build-shell.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"SKILL.md",
|
|
25
|
+
"README.md",
|
|
26
|
+
"assets",
|
|
27
|
+
"references",
|
|
28
|
+
"scripts",
|
|
29
|
+
"template",
|
|
30
|
+
"bin"
|
|
31
|
+
],
|
|
32
|
+
"keywords": [
|
|
33
|
+
"agent-skill",
|
|
34
|
+
"skills",
|
|
35
|
+
"learning-map",
|
|
36
|
+
"knowledge-graph",
|
|
37
|
+
"interactive-map",
|
|
38
|
+
"react-flow",
|
|
39
|
+
"atlas-loop"
|
|
40
|
+
],
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@dagrejs/dagre": "^1.1.8",
|
|
43
|
+
"@xyflow/react": "^12.12.1",
|
|
44
|
+
"esbuild": "^0.25.0",
|
|
45
|
+
"react": "^18.3.1",
|
|
46
|
+
"react-dom": "^18.3.1"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18.0.0"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Atlas Loop Graph Data Schema (`AtlasGraphData` v2)
|
|
2
|
+
|
|
3
|
+
`scripts/build_atlas.mjs` consumes a UTF-8 JSON file matching this schema and injects it into `assets/viewer-shell.html` to produce a standalone, zero-CDN offline `.html` file.
|
|
4
|
+
|
|
5
|
+
## Top-Level Structure
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"schemaVersion": "2.0",
|
|
10
|
+
"meta": {
|
|
11
|
+
"title": "Subject Title",
|
|
12
|
+
"subtitle": "One-line scope and core takeaway",
|
|
13
|
+
"subjectTag": "Free-form category tag (e.g. Physics, Craft, Book, CLI Tool, Architecture)",
|
|
14
|
+
"language": "zh-CN",
|
|
15
|
+
"uiLabels": {},
|
|
16
|
+
"generatedByModel": "<Your Exact Active Model Name>",
|
|
17
|
+
"level": 0,
|
|
18
|
+
"drillDownTarget": null,
|
|
19
|
+
"parentMapHtml": null,
|
|
20
|
+
"editor": "vscode",
|
|
21
|
+
"uncoveredScopes": [],
|
|
22
|
+
"unverifiedNotes": []
|
|
23
|
+
},
|
|
24
|
+
"streams": [
|
|
25
|
+
{
|
|
26
|
+
"id": "stream-main",
|
|
27
|
+
"title": "Core Stream Title",
|
|
28
|
+
"summary": "What this thread explains",
|
|
29
|
+
"learningOrder": ["node-1", "node-2"],
|
|
30
|
+
"deepDive": {
|
|
31
|
+
"hoverHint": "Short preview shown on ? hover",
|
|
32
|
+
"askPrompts": [
|
|
33
|
+
{
|
|
34
|
+
"title": "Focused follow-up title",
|
|
35
|
+
"prompt": "Concrete, context-anchored deep-dive question (>= 20 chars)…"
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"subAtlasPrompt": "Use Atlas Loop to dissect \"<Stream Focus>\" in depth (L1+), with parentMapHtml set to \"<current-output.html>\"."
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
"nodes": [
|
|
43
|
+
{
|
|
44
|
+
"id": "node-1",
|
|
45
|
+
"streamIds": ["stream-main"],
|
|
46
|
+
"role": "entry",
|
|
47
|
+
"title": "Node Title",
|
|
48
|
+
"summary": "Concise 1–2 sentence explanation shown on canvas card and inspector",
|
|
49
|
+
"whyItMatters": "Why a learner must grasp this node",
|
|
50
|
+
"keyFactors": {
|
|
51
|
+
"inputs": "Preconditions, triggers, assumptions, or inputs",
|
|
52
|
+
"outputs": "Resulting state, conclusions, or outputs"
|
|
53
|
+
},
|
|
54
|
+
"mechanismsAndEffects": "How it works internally, side effects, or causal transitions",
|
|
55
|
+
"watchouts": "Common misconceptions, edge cases, or constraints",
|
|
56
|
+
"confidence": "verified",
|
|
57
|
+
"unverifiedReason": null,
|
|
58
|
+
"anchors": [],
|
|
59
|
+
"detailGraph": {
|
|
60
|
+
"nodes": [
|
|
61
|
+
{
|
|
62
|
+
"id": "sub-1",
|
|
63
|
+
"role": "core",
|
|
64
|
+
"title": "Internal Step / Sub-concept",
|
|
65
|
+
"summary": "What happens inside",
|
|
66
|
+
"whyItMatters": "Optional",
|
|
67
|
+
"mechanismsAndEffects": "Optional",
|
|
68
|
+
"watchouts": "Optional",
|
|
69
|
+
"confidence": "verified",
|
|
70
|
+
"anchors": []
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"edges": []
|
|
74
|
+
},
|
|
75
|
+
"deepDive": {
|
|
76
|
+
"hoverHint": "What deeper nuance lies beneath this node",
|
|
77
|
+
"askPrompts": [
|
|
78
|
+
{
|
|
79
|
+
"title": "Specific Inquiry Angle",
|
|
80
|
+
"prompt": "Detailed prompt referencing exact concepts, mechanisms, or file/chapter anchors…"
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
"subAtlasPrompt": "Use Atlas Loop to dissect \"<Node Focus>\" in depth (L1+), with parentMapHtml set to \"<current-output.html>\"."
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
"edges": [
|
|
88
|
+
{
|
|
89
|
+
"id": "e-1",
|
|
90
|
+
"source": "node-1",
|
|
91
|
+
"target": "node-2",
|
|
92
|
+
"label": "Explicit causal, sequential, or branch condition label"
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Field Rules
|
|
99
|
+
|
|
100
|
+
1. **`meta.language` & `meta.uiLabels`**:
|
|
101
|
+
- Built-in dictionaries automatically localize the UI for `zh*` and `en*`.
|
|
102
|
+
- When `meta.language` is another language (e.g. `ja`, `ko`, `es`, `fr`, `de`), populate `meta.uiLabels` with localized strings for keys (`skipToCanvas`, `modelPrefix`, `backParent`, `levelOverview`, `levelSubPrefix`, `allStreams`, `searchLabel`, `searchPlaceholder`, `zoomIn`, `zoomOut`, `lock`, `unlock`, `fitView`, `reset`, `legend`, `scopeNotes`, `learningPath`, `expandDetail`, `collapseDetail`, `detailEdgeLabel`, `deepDiveModalTitle`, `tabAsk`, `tabRecursive`, `copyBtn`, `copiedBtn`, `openInEditor`, `whatItDoes`, `whyItMatters`, `inputsLabel`, `outputsLabel`, `mechanismsLabel`, `watchoutsLabel`, `anchorsLabel`, `openDeepDiveBtn`, `unverifiedTag`, `verifiedTag`, `close`, and `roles`).
|
|
103
|
+
2. **7 Universal Cognitive Roles (`node.role`)**:
|
|
104
|
+
- `entry`: Starting point, user action, core question, or trigger.
|
|
105
|
+
- `core`: Primary mechanism, processing step, or main argument.
|
|
106
|
+
- `branch`: Decision point, trade-off, bifurcation, or classification boundary.
|
|
107
|
+
- `foundation`: Underlying principle, mental model, axiom, data store, or config base.
|
|
108
|
+
- `context`: External system, historical/environmental context, or ecosystem dependency.
|
|
109
|
+
- `extension`: Async effect, advanced variation, downstream consequence, or corollary.
|
|
110
|
+
- `outcome`: Final result, takeaway, or target state.
|
|
111
|
+
3. **Polymorphic `anchors` (Optional — Use What Fits the Subject)**:
|
|
112
|
+
- Formula / CLI command / mental checklist / quote (`snippet`):
|
|
113
|
+
`{ "kind": "snippet", "label": "Key Formula / Command", "content": "…" }`
|
|
114
|
+
- Web / documentation reference (`link`):
|
|
115
|
+
`{ "kind": "link", "label": "Official Guide", "url": "https://…" }`
|
|
116
|
+
- Book chapter / paper / factual citation (`source`):
|
|
117
|
+
`{ "kind": "source", "label": "Chapter 4, p.112", "citation": "…" }`
|
|
118
|
+
- Local file jump (`editor` — **only when verified local files exist on disk**):
|
|
119
|
+
`{ "kind": "editor", "label": "Entry handler", "filePath": "src/app.ts", "absPath": "/abs/path/src/app.ts", "line": 42, "symbol": "runApp", "editor": "vscode" }`
|
|
120
|
+
(`build_atlas.mjs` verifies `absPath` and `line` exist on disk and computes the encoded `vscode://file/…` or `zed://file/…` URI automatically.)
|
|
121
|
+
4. **Edge Labels**:
|
|
122
|
+
- Every top-level edge MUST carry a non-empty `label` explaining the causal link, sequence, or branch condition.
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const SKILL_ROOT = path.resolve(__dirname, '..');
|
|
8
|
+
const SHELL_PATH = path.join(SKILL_ROOT, 'assets', 'viewer-shell.html');
|
|
9
|
+
|
|
10
|
+
const VALID_ROLES = new Set([
|
|
11
|
+
'entry',
|
|
12
|
+
'core',
|
|
13
|
+
'branch',
|
|
14
|
+
'foundation',
|
|
15
|
+
'context',
|
|
16
|
+
'extension',
|
|
17
|
+
'outcome',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const SECRET_PATTERNS = [
|
|
21
|
+
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
|
22
|
+
/\bsk-[A-Za-z0-9]{24,}\b/,
|
|
23
|
+
/\bghp_[A-Za-z0-9]{30,}\b/,
|
|
24
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function parseArgs(argv) {
|
|
28
|
+
const args = {};
|
|
29
|
+
for (let i = 2; i < argv.length; i++) {
|
|
30
|
+
const k = argv[i];
|
|
31
|
+
if (k === '--data') args.data = argv[++i];
|
|
32
|
+
else if (k === '--out') args.out = argv[++i];
|
|
33
|
+
}
|
|
34
|
+
return args;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function slugify(input, fallback = 'subject') {
|
|
38
|
+
const s = String(input || '')
|
|
39
|
+
.trim()
|
|
40
|
+
.replace(/[\/\\:*?"<>|\s]+/g, '-')
|
|
41
|
+
.replace(/-+/g, '-')
|
|
42
|
+
.replace(/^-|-$/g, '')
|
|
43
|
+
.slice(0, 48);
|
|
44
|
+
return s || fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function encodeEditorUri(absPath, line = 1, editor = 'vscode') {
|
|
48
|
+
const normalized = absPath.replace(/\\/g, '/');
|
|
49
|
+
const encodedSegments = normalized
|
|
50
|
+
.split('/')
|
|
51
|
+
.map((seg, idx) => {
|
|
52
|
+
if (idx === 0 && /^[A-Za-z]:$/.test(seg)) return seg;
|
|
53
|
+
return encodeURIComponent(seg);
|
|
54
|
+
})
|
|
55
|
+
.join('/');
|
|
56
|
+
const pathPart = encodedSegments.startsWith('/') ? encodedSegments : `/${encodedSegments}`;
|
|
57
|
+
const scheme = editor === 'zed' ? 'zed' : 'vscode';
|
|
58
|
+
return `${scheme}://file${pathPart}:${Math.max(1, Number(line) || 1)}:1`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function escapeHtml(s) {
|
|
62
|
+
return String(s || '')
|
|
63
|
+
.replace(/&/g, '&')
|
|
64
|
+
.replace(/</g, '<')
|
|
65
|
+
.replace(/>/g, '>')
|
|
66
|
+
.replace(/"/g, '"');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateAndEnrich(data) {
|
|
70
|
+
const errors = [];
|
|
71
|
+
if (!data || typeof data !== 'object') {
|
|
72
|
+
throw new Error('Graph data must be a JSON object.');
|
|
73
|
+
}
|
|
74
|
+
const meta = data.meta || {};
|
|
75
|
+
if (!meta.title || typeof meta.title !== 'string') errors.push('Missing meta.title');
|
|
76
|
+
if (!meta.language || typeof meta.language !== 'string') errors.push('Missing meta.language');
|
|
77
|
+
if (!meta.generatedByModel || typeof meta.generatedByModel !== 'string') {
|
|
78
|
+
errors.push('Missing meta.generatedByModel (must carry the active model name)');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const streams = Array.isArray(data.streams) ? data.streams : [];
|
|
82
|
+
if (streams.length === 0) errors.push('At least one stream is required in data.streams');
|
|
83
|
+
const streamIds = new Set(streams.map((s) => s.id));
|
|
84
|
+
|
|
85
|
+
const nodes = Array.isArray(data.nodes) ? data.nodes : [];
|
|
86
|
+
if (nodes.length === 0) errors.push('At least one node is required in data.nodes');
|
|
87
|
+
|
|
88
|
+
const nodeIds = new Set();
|
|
89
|
+
let deepDiveNodeCount = 0;
|
|
90
|
+
|
|
91
|
+
const verifyAnchors = (anchors, ownerLabel) => {
|
|
92
|
+
if (!Array.isArray(anchors)) return;
|
|
93
|
+
for (const anc of anchors) {
|
|
94
|
+
if (!anc || typeof anc !== 'object') continue;
|
|
95
|
+
if (!['editor', 'snippet', 'link', 'source'].includes(anc.kind)) {
|
|
96
|
+
errors.push(`${ownerLabel}: invalid anchor.kind "${anc.kind}"`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (anc.kind === 'editor') {
|
|
100
|
+
if (!anc.absPath || !path.isAbsolute(anc.absPath)) {
|
|
101
|
+
errors.push(`${ownerLabel}: editor anchor requires verified absolute path in absPath`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!fs.existsSync(anc.absPath) || !fs.statSync(anc.absPath).isFile()) {
|
|
105
|
+
errors.push(`${ownerLabel}: editor anchor file does not exist: ${anc.absPath}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const totalLines = fs.readFileSync(anc.absPath, 'utf8').split(/\r?\n/).length;
|
|
109
|
+
const lineNum = Number(anc.line || 1);
|
|
110
|
+
if (lineNum < 1 || lineNum > totalLines) {
|
|
111
|
+
errors.push(
|
|
112
|
+
`${ownerLabel}: editor anchor line ${lineNum} out of range (1..${totalLines}) for ${anc.absPath}`
|
|
113
|
+
);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const ed = anc.editor === 'zed' ? 'zed' : meta.editor === 'zed' ? 'zed' : 'vscode';
|
|
117
|
+
anc.editor = ed;
|
|
118
|
+
anc.editorUri = encodeEditorUri(anc.absPath, lineNum, ed);
|
|
119
|
+
if (anc.editorUri.startsWith('file://')) {
|
|
120
|
+
errors.push(`${ownerLabel}: plain file:// link is forbidden for editor jump`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
for (const n of nodes) {
|
|
127
|
+
if (!n.id) errors.push('Every node must have an id');
|
|
128
|
+
if (nodeIds.has(n.id)) errors.push(`Duplicate node id: ${n.id}`);
|
|
129
|
+
nodeIds.add(n.id);
|
|
130
|
+
|
|
131
|
+
if (!n.title) errors.push(`Node ${n.id}: missing title`);
|
|
132
|
+
if (!VALID_ROLES.has(n.role)) {
|
|
133
|
+
errors.push(`Node ${n.id}: invalid role "${n.role}". Must be one of ${[...VALID_ROLES].join(', ')}`);
|
|
134
|
+
}
|
|
135
|
+
if (!Array.isArray(n.streamIds) || n.streamIds.length === 0) {
|
|
136
|
+
errors.push(`Node ${n.id}: streamIds must reference at least one stream`);
|
|
137
|
+
} else {
|
|
138
|
+
for (const sid of n.streamIds) {
|
|
139
|
+
if (!streamIds.has(sid)) errors.push(`Node ${n.id}: unknown streamId "${sid}"`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
verifyAnchors(n.anchors, `Node ${n.id}`);
|
|
144
|
+
|
|
145
|
+
if (n.deepDive) {
|
|
146
|
+
const asks = Array.isArray(n.deepDive.askPrompts) ? n.deepDive.askPrompts : [];
|
|
147
|
+
if (asks.length > 0 || n.deepDive.subAtlasPrompt) deepDiveNodeCount++;
|
|
148
|
+
for (const ap of asks) {
|
|
149
|
+
if (!ap.prompt || String(ap.prompt).trim().length < 20) {
|
|
150
|
+
errors.push(`Node ${n.id}: askPrompts must be specific, non-trivial questions (>= 20 chars)`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (n.detailGraph) {
|
|
156
|
+
const subNodes = Array.isArray(n.detailGraph.nodes) ? n.detailGraph.nodes : [];
|
|
157
|
+
const subIds = new Set();
|
|
158
|
+
for (const sn of subNodes) {
|
|
159
|
+
if (!sn.id || !sn.title) errors.push(`Node ${n.id} detailGraph subnode missing id/title`);
|
|
160
|
+
if (subIds.has(sn.id)) errors.push(`Node ${n.id} detailGraph duplicate subnode id "${sn.id}"`);
|
|
161
|
+
subIds.add(sn.id);
|
|
162
|
+
verifyAnchors(sn.anchors, `Subnode ${n.id}::${sn.id}`);
|
|
163
|
+
}
|
|
164
|
+
for (const se of n.detailGraph.edges || []) {
|
|
165
|
+
if (!subIds.has(se.source) || !subIds.has(se.target)) {
|
|
166
|
+
errors.push(`Node ${n.id} detailGraph edge references unknown subnode: ${se.source} -> ${se.target}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (deepDiveNodeCount === 0) {
|
|
173
|
+
errors.push('At least one core node must provide deepDive prompts (? Loop Prompts)');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const s of streams) {
|
|
177
|
+
for (const lid of s.learningOrder || []) {
|
|
178
|
+
if (!nodeIds.has(lid)) {
|
|
179
|
+
errors.push(`Stream ${s.id}: learningOrder references unknown node "${lid}"`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const e of data.edges || []) {
|
|
185
|
+
if (!nodeIds.has(e.source) || !nodeIds.has(e.target)) {
|
|
186
|
+
errors.push(`Edge references unknown node: ${e.source} -> ${e.target}`);
|
|
187
|
+
}
|
|
188
|
+
if (!e.label || !String(e.label).trim()) {
|
|
189
|
+
errors.push(`Edge ${e.source} -> ${e.target}: edge label is required to clarify relationship/branch`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const rawJsonString = JSON.stringify(data);
|
|
194
|
+
for (const pat of SECRET_PATTERNS) {
|
|
195
|
+
if (pat.test(rawJsonString)) {
|
|
196
|
+
errors.push(`Security check failed: detected potential secret matching ${pat}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (errors.length > 0) {
|
|
201
|
+
throw new Error(`AtlasGraphData validation failed:\n- ${errors.join('\n- ')}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return data;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function main() {
|
|
208
|
+
const args = parseArgs(process.argv);
|
|
209
|
+
if (!args.data) {
|
|
210
|
+
console.error('Usage: node build_atlas.mjs --data <graph-data.json> [--out <output.html>]');
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
if (!fs.existsSync(SHELL_PATH)) {
|
|
214
|
+
console.error(`Missing viewer shell template at ${SHELL_PATH}`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const raw = JSON.parse(fs.readFileSync(args.data, 'utf8'));
|
|
219
|
+
const validated = validateAndEnrich(raw);
|
|
220
|
+
const meta = validated.meta;
|
|
221
|
+
|
|
222
|
+
let outPath = args.out;
|
|
223
|
+
if (!outPath) {
|
|
224
|
+
const baseSlug = slugify(meta.title, 'learning');
|
|
225
|
+
if (Number(meta.level) > 0 && meta.drillDownTarget) {
|
|
226
|
+
const subSlug = slugify(meta.drillDownTarget, 'detail');
|
|
227
|
+
outPath = path.resolve(process.cwd(), `${baseSlug}-sub-${subSlug}-atlas.html`);
|
|
228
|
+
} else {
|
|
229
|
+
outPath = path.resolve(process.cwd(), `${baseSlug}-atlas.html`);
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
outPath = path.resolve(outPath);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const shellHtml = fs.readFileSync(SHELL_PATH, 'utf8');
|
|
236
|
+
const safeJsonPayload = JSON.stringify(validated).replace(/</g, '\\u003c');
|
|
237
|
+
|
|
238
|
+
const finalHtml = shellHtml
|
|
239
|
+
.replace('__ATLAS_HTML_LANG__', escapeHtml(meta.language || 'en'))
|
|
240
|
+
.replace('__ATLAS_HTML_TITLE__', escapeHtml(`${meta.title} — Atlas Loop`))
|
|
241
|
+
.replace('__ATLAS_DATA_JSON__', () => safeJsonPayload);
|
|
242
|
+
|
|
243
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
244
|
+
fs.writeFileSync(outPath, finalHtml, 'utf8');
|
|
245
|
+
|
|
246
|
+
console.log(
|
|
247
|
+
JSON.stringify(
|
|
248
|
+
{
|
|
249
|
+
ok: true,
|
|
250
|
+
outputHtml: outPath,
|
|
251
|
+
bytes: fs.statSync(outPath).size,
|
|
252
|
+
nodeCount: validated.nodes.length,
|
|
253
|
+
streamCount: validated.streams.length,
|
|
254
|
+
},
|
|
255
|
+
null,
|
|
256
|
+
2
|
|
257
|
+
)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
main();
|