@jhizzard/termdeck 0.8.0 → 0.10.0
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/docs/orchestrator-guide.md +335 -0
- package/package.json +3 -1
- package/packages/cli/src/index.js +26 -3
- package/packages/cli/src/init-project.js +213 -0
- package/packages/cli/src/templates.js +84 -0
- package/packages/cli/templates/.claude-settings.json.tmpl +32 -0
- package/packages/cli/templates/.gitignore.tmpl +28 -0
- package/packages/cli/templates/CLAUDE.md.tmpl +35 -0
- package/packages/cli/templates/CONTRADICTIONS.md.tmpl +30 -0
- package/packages/cli/templates/README.md.tmpl +15 -0
- package/packages/cli/templates/RESTART-PROMPT.md.tmpl +38 -0
- package/packages/cli/templates/docs-orchestration-README.md.tmpl +29 -0
- package/packages/cli/templates/project_facts.md.tmpl +39 -0
- package/packages/client/public/app.js +781 -0
- package/packages/client/public/graph.html +104 -0
- package/packages/client/public/graph.js +683 -0
- package/packages/client/public/index.html +145 -0
- package/packages/client/public/style.css +1185 -0
- package/packages/server/src/graph-routes.js +555 -0
- package/packages/server/src/index.js +158 -5
- package/packages/server/src/orchestration-preview.js +256 -0
- package/packages/server/src/preflight.js +82 -0
- package/packages/server/src/rag.js +138 -0
- package/packages/server/src/setup/mnestra-migrations/009_memory_relationship_metadata.sql +126 -0
- package/packages/server/src/setup/mnestra-migrations/010_memory_recall_graph.sql +147 -0
- package/packages/server/src/setup/rumen/migrations/003_graph_inference_schedule.sql +49 -0
- package/packages/server/src/sprint-inject.js +156 -0
- package/packages/server/src/sprint-routes.js +503 -0
|
@@ -63,6 +63,54 @@ const { getFullHealth } = require('./health');
|
|
|
63
63
|
const { themes, statusColors } = require('./themes');
|
|
64
64
|
const { loadConfig, addProject, updateConfig } = require('./config');
|
|
65
65
|
const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
|
|
66
|
+
const { createSprintRoutes } = require('./sprint-routes');
|
|
67
|
+
const { createGraphRoutes } = require('./graph-routes');
|
|
68
|
+
const orchestrationPreview = require('./orchestration-preview');
|
|
69
|
+
|
|
70
|
+
// Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
|
|
71
|
+
// helper is decoupled from T2's templates.js / init-project.js; we resolve
|
|
72
|
+
// them here and pass them into the helper. If a module is missing (e.g.
|
|
73
|
+
// install hasn't been completed yet), the route surfaces a 503 with a clear
|
|
74
|
+
// error rather than crashing the server.
|
|
75
|
+
let _t2Templates = null;
|
|
76
|
+
let _t2TemplatesResolved = false;
|
|
77
|
+
function _getT2Templates() {
|
|
78
|
+
if (_t2TemplatesResolved) return _t2Templates;
|
|
79
|
+
_t2TemplatesResolved = true;
|
|
80
|
+
try { _t2Templates = require('../../cli/src/templates'); }
|
|
81
|
+
catch (_e) { _t2Templates = null; }
|
|
82
|
+
return _t2Templates;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let _t2InitProject = null;
|
|
86
|
+
let _t2InitProjectResolved = false;
|
|
87
|
+
function _getT2InitProject() {
|
|
88
|
+
if (_t2InitProjectResolved) return _t2InitProject;
|
|
89
|
+
_t2InitProjectResolved = true;
|
|
90
|
+
try {
|
|
91
|
+
const mod = require('../../cli/src/init-project');
|
|
92
|
+
_t2InitProject = (mod && typeof mod.initProject === 'function')
|
|
93
|
+
? mod.initProject
|
|
94
|
+
: (typeof mod === 'function' ? mod : null);
|
|
95
|
+
} catch (_e) {
|
|
96
|
+
_t2InitProject = null;
|
|
97
|
+
}
|
|
98
|
+
return _t2InitProject;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function _getT2DestFor() {
|
|
102
|
+
try {
|
|
103
|
+
const mod = require('../../cli/src/init-project');
|
|
104
|
+
return (mod && typeof mod._destFor === 'function') ? mod._destFor : undefined;
|
|
105
|
+
} catch (_e) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _termdeckVersion() {
|
|
111
|
+
try { return require('../../../package.json').version; }
|
|
112
|
+
catch { return '0.0.0'; }
|
|
113
|
+
}
|
|
66
114
|
|
|
67
115
|
function createServer(config) {
|
|
68
116
|
const app = express();
|
|
@@ -89,6 +137,14 @@ function createServer(config) {
|
|
|
89
137
|
const clientDir = path.join(__dirname, '..', '..', 'client', 'public');
|
|
90
138
|
app.use(express.static(clientDir));
|
|
91
139
|
|
|
140
|
+
// Serve repo-rooted /docs as static markdown so the dashboard right-rail Guide
|
|
141
|
+
// panel can fetch docs/orchestrator-guide.md and render it client-side.
|
|
142
|
+
// Sprint 37 T1.
|
|
143
|
+
const docsDir = path.join(__dirname, '..', '..', '..', 'docs');
|
|
144
|
+
if (fs.existsSync(docsDir)) {
|
|
145
|
+
app.use('/docs', express.static(docsDir));
|
|
146
|
+
}
|
|
147
|
+
|
|
92
148
|
// Initialize database
|
|
93
149
|
let db = null;
|
|
94
150
|
if (Database) {
|
|
@@ -115,6 +171,17 @@ function createServer(config) {
|
|
|
115
171
|
const mnestraBridge = createBridge(config);
|
|
116
172
|
console.log(`[mnestra-bridge] mode=${mnestraBridge.mode}`);
|
|
117
173
|
|
|
174
|
+
// Sprint 38 / T3 — let RAGIntegration delegate vector recall to the
|
|
175
|
+
// bridge so we don't duplicate the embed pipeline. Graph recall stays
|
|
176
|
+
// in rag.js because it's a different RPC and doesn't share the
|
|
177
|
+
// direct/webhook/mcp mode shape.
|
|
178
|
+
rag.setBridge(mnestraBridge);
|
|
179
|
+
if (rag.graphRecall) {
|
|
180
|
+
console.log(
|
|
181
|
+
`[rag] graph-aware recall ENABLED (depth=${rag.graphRecallDepth}, k=${rag.graphRecallK}, half-life=${rag.graphRecallRecencyHalflifeDays}d)`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
118
185
|
// Initialize transcript writer (Session Transcripts — Sprint 6)
|
|
119
186
|
const transcriptConfig = config.transcripts || {};
|
|
120
187
|
const transcriptEnabled = transcriptConfig.enabled !== undefined
|
|
@@ -662,10 +729,12 @@ function createServer(config) {
|
|
|
662
729
|
res.json(sessions.getAll());
|
|
663
730
|
});
|
|
664
731
|
|
|
665
|
-
// POST /api/sessions
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
732
|
+
// Reusable PTY spawn + wire helper. Used by POST /api/sessions and the
|
|
733
|
+
// in-dashboard 4+1 sprint runner (Sprint 37 T4) so multi-panel spawns reuse
|
|
734
|
+
// the same wiring (transcripts, RAG, Mnestra flashback) without copy-paste.
|
|
735
|
+
// Returns the Session object regardless of PTY success — status will be
|
|
736
|
+
// 'errored' if pty.spawn threw.
|
|
737
|
+
function spawnTerminalSession({ command, cwd, project, label, type, theme, reason }) {
|
|
669
738
|
const rawCwd = cwd || config.projects?.[project]?.path || os.homedir();
|
|
670
739
|
const resolvedCwd = path.resolve(rawCwd.replace(/^~/, os.homedir()));
|
|
671
740
|
|
|
@@ -679,7 +748,6 @@ function createServer(config) {
|
|
|
679
748
|
reason: reason || 'launched via API'
|
|
680
749
|
});
|
|
681
750
|
|
|
682
|
-
// Spawn PTY
|
|
683
751
|
if (pty) {
|
|
684
752
|
// Three launch shapes:
|
|
685
753
|
// (1) no command → spawn the default shell interactively
|
|
@@ -827,9 +895,34 @@ function createServer(config) {
|
|
|
827
895
|
session.meta.statusDetail = 'node-pty not available';
|
|
828
896
|
}
|
|
829
897
|
|
|
898
|
+
return session;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// POST /api/sessions - create a new terminal session
|
|
902
|
+
app.post('/api/sessions', (req, res) => {
|
|
903
|
+
const { command, cwd, project, label, type, theme, reason } = req.body || {};
|
|
904
|
+
const session = spawnTerminalSession({ command, cwd, project, label, type, theme, reason });
|
|
830
905
|
res.status(201).json(session.toJSON());
|
|
831
906
|
});
|
|
832
907
|
|
|
908
|
+
// Sprint runner endpoints (Sprint 37 T4) — in-dashboard 4+1 sprint runner.
|
|
909
|
+
// Wraps spawnTerminalSession with two-stage submit + verify-and-poke.
|
|
910
|
+
createSprintRoutes({
|
|
911
|
+
app,
|
|
912
|
+
config,
|
|
913
|
+
spawnTerminalSession,
|
|
914
|
+
getSession: (id) => sessions.get(id),
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
// Graph endpoints (Sprint 38 T4) — knowledge-graph view backing graph.html.
|
|
918
|
+
// Reuses the petvetbid pg pool (same DATABASE_URL serves memory_items +
|
|
919
|
+
// memory_relationships alongside rumen_*). Graceful-degrades when the pool
|
|
920
|
+
// is absent.
|
|
921
|
+
createGraphRoutes({
|
|
922
|
+
app,
|
|
923
|
+
getPool: getRumenPool,
|
|
924
|
+
});
|
|
925
|
+
|
|
833
926
|
// GET /api/sessions/:id - get session details
|
|
834
927
|
app.get('/api/sessions/:id', (req, res) => {
|
|
835
928
|
const session = sessions.get(req.params.id);
|
|
@@ -1145,6 +1238,66 @@ function createServer(config) {
|
|
|
1145
1238
|
}
|
|
1146
1239
|
});
|
|
1147
1240
|
|
|
1241
|
+
// GET /api/projects/:name/orchestration-preview — Sprint 37 T3.
|
|
1242
|
+
// Renders T2's scaffolding templates without writing to disk so the
|
|
1243
|
+
// dashboard can show "if you ran `termdeck init --project <name>`, this
|
|
1244
|
+
// is what would be created." Read-only.
|
|
1245
|
+
app.get('/api/projects/:name/orchestration-preview', (req, res) => {
|
|
1246
|
+
const templates = _getT2Templates();
|
|
1247
|
+
if (!templates) {
|
|
1248
|
+
return res.status(503).json({
|
|
1249
|
+
error: 'Orchestration scaffolding unavailable: packages/cli/src/templates.js not loaded'
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
try {
|
|
1253
|
+
const preview = orchestrationPreview.buildPreview({
|
|
1254
|
+
name: req.params.name,
|
|
1255
|
+
projects: config.projects || {},
|
|
1256
|
+
cwd: process.cwd(),
|
|
1257
|
+
templates,
|
|
1258
|
+
destFor: _getT2DestFor(),
|
|
1259
|
+
version: _termdeckVersion()
|
|
1260
|
+
});
|
|
1261
|
+
res.json(preview);
|
|
1262
|
+
} catch (err) {
|
|
1263
|
+
const status = err.statusCode || 500;
|
|
1264
|
+
if (status >= 500) console.error('[orchestration-preview] GET failed:', err.message);
|
|
1265
|
+
res.status(status).json({ error: err.message });
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
// POST /api/projects/:name/orchestration-preview/generate — Sprint 37 T3.
|
|
1270
|
+
// Calls T2's initProject() to actually write the scaffolding. Body:
|
|
1271
|
+
// { force?: boolean }. Returns the same envelope as the GET preview but
|
|
1272
|
+
// with `created` instead of `wouldCreate`.
|
|
1273
|
+
app.post('/api/projects/:name/orchestration-preview/generate', async (req, res) => {
|
|
1274
|
+
const templates = _getT2Templates();
|
|
1275
|
+
const initProject = _getT2InitProject();
|
|
1276
|
+
if (!templates || !initProject) {
|
|
1277
|
+
return res.status(503).json({
|
|
1278
|
+
error: 'Orchestration scaffolding unavailable: T2 CLI modules not loaded'
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
const force = !!(req.body && req.body.force);
|
|
1282
|
+
try {
|
|
1283
|
+
const result = await orchestrationPreview.generateScaffolding({
|
|
1284
|
+
name: req.params.name,
|
|
1285
|
+
projects: config.projects || {},
|
|
1286
|
+
cwd: process.cwd(),
|
|
1287
|
+
force,
|
|
1288
|
+
initProject,
|
|
1289
|
+
templates,
|
|
1290
|
+
destFor: _getT2DestFor(),
|
|
1291
|
+
version: _termdeckVersion()
|
|
1292
|
+
});
|
|
1293
|
+
res.json(result);
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
const status = err.statusCode || 500;
|
|
1296
|
+
if (status >= 500) console.error('[orchestration-preview] generate failed:', err.message);
|
|
1297
|
+
res.status(status).json({ error: err.message });
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1148
1301
|
// GET /api/status - global status (control room data)
|
|
1149
1302
|
app.get('/api/status', (req, res) => {
|
|
1150
1303
|
const allSessions = sessions.getAll();
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Sprint 37 T3 — Orchestration preview helper.
|
|
2
|
+
//
|
|
3
|
+
// Renders the per-project scaffolding (CLAUDE.md, CONTRADICTIONS.md, etc.)
|
|
4
|
+
// without writing to disk so the dashboard can show "if you ran
|
|
5
|
+
// `termdeck init --project <name>` here, this is what would be created."
|
|
6
|
+
//
|
|
7
|
+
// All filesystem writes go through T2's initProject(); this file only
|
|
8
|
+
// renders previews. Templates and the writer are injected by the caller so
|
|
9
|
+
// the helper is unit-testable without depending on T2's CLI modules
|
|
10
|
+
// directly — server route wires the production deps; tests pass stubs.
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
// Same constraint T2's CLI validator should enforce. Keep these in sync if
|
|
19
|
+
// T2's regex differs at sprint close.
|
|
20
|
+
const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
21
|
+
const PREVIEW_LINES = 30;
|
|
22
|
+
|
|
23
|
+
function validateName(name) {
|
|
24
|
+
if (typeof name !== 'string' || !NAME_RE.test(name) || name.includes('..')) {
|
|
25
|
+
const err = new Error(
|
|
26
|
+
'Invalid project name: must be lowercase, start with a letter or digit, ' +
|
|
27
|
+
'and contain only letters, digits, dots, underscores, or hyphens.'
|
|
28
|
+
);
|
|
29
|
+
err.statusCode = 400;
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function expandHome(p) {
|
|
35
|
+
if (typeof p !== 'string') return p;
|
|
36
|
+
if (p === '~' || p.startsWith('~/')) return path.join(os.homedir(), p.slice(1));
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Decide where the scaffolding would land for a given project name:
|
|
41
|
+
// 1. If `projects[name]` exists in config, use its `.path` (tilde-expanded,
|
|
42
|
+
// resolved). Preview shows what would be added/skipped against that dir.
|
|
43
|
+
// 2. Otherwise, target = path.resolve(cwd, name). Preview shows a fresh
|
|
44
|
+
// project being created from scratch.
|
|
45
|
+
function resolveTargetPath({ name, projects, cwd }) {
|
|
46
|
+
if (projects && Object.prototype.hasOwnProperty.call(projects, name)) {
|
|
47
|
+
const proj = projects[name];
|
|
48
|
+
if (proj && typeof proj.path === 'string' && proj.path) {
|
|
49
|
+
return path.resolve(expandHome(proj.path));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return path.resolve(cwd, name);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function previewContent(rendered) {
|
|
56
|
+
const lines = rendered.split('\n');
|
|
57
|
+
const totalLines = lines.length;
|
|
58
|
+
const truncated = totalLines > PREVIEW_LINES;
|
|
59
|
+
const contentPreview = truncated
|
|
60
|
+
? lines.slice(0, PREVIEW_LINES).join('\n')
|
|
61
|
+
: rendered;
|
|
62
|
+
return { contentPreview, totalLines, truncated };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Normalize templates.listTemplates() output. T2's templates.js returns a
|
|
66
|
+
// flat array of filenames (e.g. 'CLAUDE.md.tmpl'); legacy/test stubs may
|
|
67
|
+
// return objects with explicit { name, targetPath }. Either is accepted.
|
|
68
|
+
//
|
|
69
|
+
// `destFor` maps a filename → absolute path inside the project tree. T2
|
|
70
|
+
// exports this from init-project.js as `_destFor`. The preview computes the
|
|
71
|
+
// path relative to `projectRoot` so the response payload uses repo-relative
|
|
72
|
+
// paths the UI can render directly.
|
|
73
|
+
function normalizeTemplateItems(rawItems, destFor, projectRoot) {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const item of rawItems) {
|
|
76
|
+
if (typeof item === 'string') {
|
|
77
|
+
if (typeof destFor !== 'function') {
|
|
78
|
+
// No mapper available — best-effort: strip a trailing '.tmpl' and
|
|
79
|
+
// use the bare name as the target path. Fine for tests and a
|
|
80
|
+
// graceful fallback when init-project.js isn't on disk yet.
|
|
81
|
+
const fallback = item.endsWith('.tmpl') ? item.slice(0, -'.tmpl'.length) : item;
|
|
82
|
+
out.push({ name: item, targetPath: fallback });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
let abs;
|
|
86
|
+
try {
|
|
87
|
+
abs = destFor(item, projectRoot);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// Unknown template — skip rather than fail the whole preview.
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
out.push({ name: item, targetPath: path.relative(projectRoot, abs) });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (item && typeof item === 'object' && item.name && item.targetPath) {
|
|
96
|
+
out.push({ name: item.name, targetPath: item.targetPath });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildPreview({ name, projects, cwd, templates, destFor, version, now }) {
|
|
103
|
+
validateName(name);
|
|
104
|
+
if (!templates || typeof templates.listTemplates !== 'function' ||
|
|
105
|
+
typeof templates.renderTemplate !== 'function') {
|
|
106
|
+
const err = new Error('templates module is missing listTemplates / renderTemplate');
|
|
107
|
+
err.statusCode = 503;
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const targetPath = resolveTargetPath({ name, projects, cwd });
|
|
112
|
+
const exists = fs.existsSync(targetPath);
|
|
113
|
+
const renderedAt = (now && typeof now === 'function' ? now() : new Date()).toISOString();
|
|
114
|
+
|
|
115
|
+
const vars = {
|
|
116
|
+
project_name: name,
|
|
117
|
+
project_path: targetPath,
|
|
118
|
+
generated_at: renderedAt,
|
|
119
|
+
termdeck_version: version || '0.0.0'
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const items = normalizeTemplateItems(templates.listTemplates() || [], destFor, targetPath);
|
|
123
|
+
const wouldCreate = [];
|
|
124
|
+
const wouldSkip = [];
|
|
125
|
+
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
if (!item || !item.name || !item.targetPath) continue;
|
|
128
|
+
const filePath = path.join(targetPath, item.targetPath);
|
|
129
|
+
let rendered;
|
|
130
|
+
try {
|
|
131
|
+
rendered = templates.renderTemplate(item.name, vars);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// Surface render errors as a wouldSkip entry rather than failing the
|
|
134
|
+
// whole preview — gives the user actionable info without breaking the
|
|
135
|
+
// pane. (Most likely cause: template references a placeholder we don't
|
|
136
|
+
// populate yet — coordination point with T2.)
|
|
137
|
+
wouldSkip.push({
|
|
138
|
+
path: item.targetPath,
|
|
139
|
+
contentPreview: '',
|
|
140
|
+
totalLines: 0,
|
|
141
|
+
renderedAt,
|
|
142
|
+
reason: `render failed: ${err.message}`
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const { contentPreview, totalLines } = previewContent(rendered);
|
|
147
|
+
const entry = {
|
|
148
|
+
path: item.targetPath,
|
|
149
|
+
contentPreview,
|
|
150
|
+
totalLines,
|
|
151
|
+
renderedAt
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// If targetPath dir doesn't exist yet, every file is a wouldCreate.
|
|
155
|
+
// If it exists, individual files that already exist are wouldSkip.
|
|
156
|
+
const fileExists = exists && fs.existsSync(filePath);
|
|
157
|
+
if (fileExists) {
|
|
158
|
+
wouldSkip.push({ ...entry, reason: 'file already exists' });
|
|
159
|
+
} else {
|
|
160
|
+
wouldCreate.push(entry);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
projectName: name,
|
|
166
|
+
targetPath,
|
|
167
|
+
exists,
|
|
168
|
+
wouldCreate,
|
|
169
|
+
wouldSkip
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// POST handler logic. Calls T2's initProject({ dryRun: false, force, cwd, name }),
|
|
174
|
+
// then returns preview-shape with `created` populated. T2's initProject is
|
|
175
|
+
// async and returns { exitCode, files } — we await it and translate a
|
|
176
|
+
// non-zero exitCode into an HTTP-mappable error.
|
|
177
|
+
async function generateScaffolding({ name, projects, cwd, force, initProject, templates, destFor, version, now }) {
|
|
178
|
+
validateName(name);
|
|
179
|
+
if (typeof initProject !== 'function') {
|
|
180
|
+
const err = new Error('initProject is not available — T2 init-project module not loaded');
|
|
181
|
+
err.statusCode = 503;
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const targetPath = resolveTargetPath({ name, projects, cwd });
|
|
186
|
+
|
|
187
|
+
// Refuse on existing non-empty dir without force, mirroring T2's CLI semantics.
|
|
188
|
+
if (fs.existsSync(targetPath)) {
|
|
189
|
+
const entries = (() => {
|
|
190
|
+
try { return fs.readdirSync(targetPath); } catch { return []; }
|
|
191
|
+
})();
|
|
192
|
+
const nonEmpty = entries.length > 0;
|
|
193
|
+
if (nonEmpty && !force) {
|
|
194
|
+
const err = new Error(
|
|
195
|
+
`Target ${targetPath} exists and is non-empty. Pass { force: true } to overwrite.`
|
|
196
|
+
);
|
|
197
|
+
err.statusCode = 409;
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// For fresh-name case, derive the cwd we hand to initProject from targetPath's
|
|
203
|
+
// parent so initProject({ name, cwd }) lands at the same targetPath.
|
|
204
|
+
const initCwd = path.dirname(targetPath);
|
|
205
|
+
const result = await initProject({ name, dryRun: false, force: !!force, cwd: initCwd });
|
|
206
|
+
|
|
207
|
+
if (result && typeof result === 'object' && typeof result.exitCode === 'number' && result.exitCode !== 0) {
|
|
208
|
+
const err = new Error(`initProject returned exit code ${result.exitCode}`);
|
|
209
|
+
err.statusCode = 500;
|
|
210
|
+
err.initProjectResult = result;
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Re-render to return preview-shape with `created` populated. We re-read
|
|
215
|
+
// each file from disk so the response reflects exactly what landed.
|
|
216
|
+
const renderedAt = (now && typeof now === 'function' ? now() : new Date()).toISOString();
|
|
217
|
+
const created = [];
|
|
218
|
+
if (templates && typeof templates.listTemplates === 'function') {
|
|
219
|
+
const items = normalizeTemplateItems(templates.listTemplates() || [], destFor, targetPath);
|
|
220
|
+
for (const item of items) {
|
|
221
|
+
if (!item || !item.name || !item.targetPath) continue;
|
|
222
|
+
const filePath = path.join(targetPath, item.targetPath);
|
|
223
|
+
if (!fs.existsSync(filePath)) continue;
|
|
224
|
+
try {
|
|
225
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
226
|
+
const { contentPreview, totalLines } = previewContent(raw);
|
|
227
|
+
created.push({
|
|
228
|
+
path: item.targetPath,
|
|
229
|
+
contentPreview,
|
|
230
|
+
totalLines,
|
|
231
|
+
renderedAt
|
|
232
|
+
});
|
|
233
|
+
} catch (err) {
|
|
234
|
+
// Skip unreadable files — caller can re-fetch the preview if needed.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
projectName: name,
|
|
241
|
+
targetPath,
|
|
242
|
+
exists: true,
|
|
243
|
+
created,
|
|
244
|
+
initProjectResult: result || null
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
buildPreview,
|
|
250
|
+
generateScaffolding,
|
|
251
|
+
resolveTargetPath,
|
|
252
|
+
validateName,
|
|
253
|
+
expandHome,
|
|
254
|
+
_PREVIEW_LINES: PREVIEW_LINES,
|
|
255
|
+
_NAME_RE: NAME_RE
|
|
256
|
+
};
|
|
@@ -136,6 +136,82 @@ async function checkDatabase() {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// Sprint 38 / T3 — graph-health check. Returns:
|
|
140
|
+
// pass : memory_relationships has rows AND last inferred_at < 48h ago
|
|
141
|
+
// warn : has rows but last inference > 48h ago (T2 cron may have drifted)
|
|
142
|
+
// fail : pg unreachable, table missing, or zero edges
|
|
143
|
+
//
|
|
144
|
+
// Reads `inferred_at` (T1's migration 009 column). Falls back to `created_at`
|
|
145
|
+
// for the 749 pre-T2 edges that have no inferred_at value yet, so the check
|
|
146
|
+
// doesn't perma-warn on the substrate that already exists.
|
|
147
|
+
async function checkGraphHealth(config) {
|
|
148
|
+
// Only meaningful when graph features are enabled. Treat as pass with a
|
|
149
|
+
// descriptive detail so the banner doesn't FAIL on installs that haven't
|
|
150
|
+
// opted into graph recall yet.
|
|
151
|
+
const graphEnabled = config.rag?.graphRecall === true;
|
|
152
|
+
if (!graphEnabled) {
|
|
153
|
+
return { name: 'graph_health', passed: true, detail: 'graph recall disabled' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
157
|
+
if (!dbUrl) {
|
|
158
|
+
return { name: 'graph_health', passed: false, detail: 'DATABASE_URL not set — cannot check graph' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let pg;
|
|
162
|
+
try { pg = require('pg'); } catch (err) { pg = null; }
|
|
163
|
+
if (!pg) {
|
|
164
|
+
return { name: 'graph_health', passed: false, detail: 'pg module not installed' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const pool = new pg.Pool({
|
|
168
|
+
connectionString: dbUrl,
|
|
169
|
+
max: 1,
|
|
170
|
+
connectionTimeoutMillis: 5000,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
// Single round-trip: edge count + last inference timestamp. coalesce on
|
|
175
|
+
// inferred_at so the substrate's pre-T2 edges register their created_at
|
|
176
|
+
// (otherwise max() returns NULL and the staleness check trips).
|
|
177
|
+
const res = await pool.query(
|
|
178
|
+
`SELECT
|
|
179
|
+
count(*)::int AS edges,
|
|
180
|
+
max(coalesce(inferred_at, created_at)) AS last_inferred_at
|
|
181
|
+
FROM memory_relationships`
|
|
182
|
+
);
|
|
183
|
+
const row = res.rows[0] || {};
|
|
184
|
+
const edges = Number(row.edges || 0);
|
|
185
|
+
if (edges === 0) {
|
|
186
|
+
return {
|
|
187
|
+
name: 'graph_health', passed: false,
|
|
188
|
+
detail: 'memory_relationships is empty — run T2 inference cron or seed edges manually',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const last = row.last_inferred_at ? new Date(row.last_inferred_at) : null;
|
|
193
|
+
if (!last) {
|
|
194
|
+
return {
|
|
195
|
+
name: 'graph_health', passed: true,
|
|
196
|
+
detail: `${edges.toLocaleString()} edges, last inference timestamp unknown`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const agoMs = Date.now() - last.getTime();
|
|
201
|
+
const agoH = (agoMs / 3_600_000).toFixed(1);
|
|
202
|
+
const stale = agoMs > 48 * 3_600_000; // 48h cron drift threshold
|
|
203
|
+
return {
|
|
204
|
+
name: 'graph_health',
|
|
205
|
+
passed: !stale,
|
|
206
|
+
detail: stale
|
|
207
|
+
? `${edges.toLocaleString()} edges, last inference ${agoH}h ago (stale — expected within 48h)`
|
|
208
|
+
: `${edges.toLocaleString()} edges, last inference ${agoH}h ago`,
|
|
209
|
+
};
|
|
210
|
+
} finally {
|
|
211
|
+
await pool.end().catch(() => {});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
139
215
|
async function checkProjectPaths(config) {
|
|
140
216
|
const projects = config.projects || {};
|
|
141
217
|
const names = Object.keys(projects);
|
|
@@ -282,6 +358,10 @@ async function runPreflight(config) {
|
|
|
282
358
|
name: 'shell_sanity', passed: false,
|
|
283
359
|
detail: `check failed — ${err.message}`,
|
|
284
360
|
})),
|
|
361
|
+
checkGraphHealth(config).catch((err) => ({
|
|
362
|
+
name: 'graph_health', passed: false,
|
|
363
|
+
detail: `check failed — ${err.message}`,
|
|
364
|
+
})),
|
|
285
365
|
]);
|
|
286
366
|
|
|
287
367
|
const result = {
|
|
@@ -330,6 +410,7 @@ const REMEDIATION = {
|
|
|
330
410
|
database_url: 'Set DATABASE_URL in ~/.termdeck/secrets.env',
|
|
331
411
|
project_paths: 'Fix paths in ~/.termdeck/config.yaml → projects',
|
|
332
412
|
shell_sanity: 'Check $SHELL and your login profile (~/.zshrc or ~/.bashrc)',
|
|
413
|
+
graph_health: 'Run T2 inference cron or apply migrations 009/010 to populate edges',
|
|
333
414
|
};
|
|
334
415
|
|
|
335
416
|
const CHECK_LABELS = {
|
|
@@ -339,6 +420,7 @@ const CHECK_LABELS = {
|
|
|
339
420
|
database_url: 'Database',
|
|
340
421
|
project_paths: 'Project paths',
|
|
341
422
|
shell_sanity: 'Shell',
|
|
423
|
+
graph_health: 'Graph',
|
|
342
424
|
};
|
|
343
425
|
|
|
344
426
|
function printHealthBanner(result) {
|