@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.
Files changed (28) hide show
  1. package/docs/orchestrator-guide.md +335 -0
  2. package/package.json +3 -1
  3. package/packages/cli/src/index.js +26 -3
  4. package/packages/cli/src/init-project.js +213 -0
  5. package/packages/cli/src/templates.js +84 -0
  6. package/packages/cli/templates/.claude-settings.json.tmpl +32 -0
  7. package/packages/cli/templates/.gitignore.tmpl +28 -0
  8. package/packages/cli/templates/CLAUDE.md.tmpl +35 -0
  9. package/packages/cli/templates/CONTRADICTIONS.md.tmpl +30 -0
  10. package/packages/cli/templates/README.md.tmpl +15 -0
  11. package/packages/cli/templates/RESTART-PROMPT.md.tmpl +38 -0
  12. package/packages/cli/templates/docs-orchestration-README.md.tmpl +29 -0
  13. package/packages/cli/templates/project_facts.md.tmpl +39 -0
  14. package/packages/client/public/app.js +781 -0
  15. package/packages/client/public/graph.html +104 -0
  16. package/packages/client/public/graph.js +683 -0
  17. package/packages/client/public/index.html +145 -0
  18. package/packages/client/public/style.css +1185 -0
  19. package/packages/server/src/graph-routes.js +555 -0
  20. package/packages/server/src/index.js +158 -5
  21. package/packages/server/src/orchestration-preview.js +256 -0
  22. package/packages/server/src/preflight.js +82 -0
  23. package/packages/server/src/rag.js +138 -0
  24. package/packages/server/src/setup/mnestra-migrations/009_memory_relationship_metadata.sql +126 -0
  25. package/packages/server/src/setup/mnestra-migrations/010_memory_recall_graph.sql +147 -0
  26. package/packages/server/src/setup/rumen/migrations/003_graph_inference_schedule.sql +49 -0
  27. package/packages/server/src/sprint-inject.js +156 -0
  28. 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 - create a new terminal session
666
- app.post('/api/sessions', (req, res) => {
667
- const { command, cwd, project, label, type, theme, reason } = req.body;
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) {