@jhizzard/termdeck 0.8.0 → 0.9.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/index.html +144 -0
- package/packages/client/public/style.css +758 -0
- package/packages/server/src/index.js +137 -5
- package/packages/server/src/orchestration-preview.js +256 -0
- package/packages/server/src/sprint-inject.js +156 -0
- package/packages/server/src/sprint-routes.js +503 -0
|
@@ -63,6 +63,53 @@ 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 orchestrationPreview = require('./orchestration-preview');
|
|
68
|
+
|
|
69
|
+
// Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
|
|
70
|
+
// helper is decoupled from T2's templates.js / init-project.js; we resolve
|
|
71
|
+
// them here and pass them into the helper. If a module is missing (e.g.
|
|
72
|
+
// install hasn't been completed yet), the route surfaces a 503 with a clear
|
|
73
|
+
// error rather than crashing the server.
|
|
74
|
+
let _t2Templates = null;
|
|
75
|
+
let _t2TemplatesResolved = false;
|
|
76
|
+
function _getT2Templates() {
|
|
77
|
+
if (_t2TemplatesResolved) return _t2Templates;
|
|
78
|
+
_t2TemplatesResolved = true;
|
|
79
|
+
try { _t2Templates = require('../../cli/src/templates'); }
|
|
80
|
+
catch (_e) { _t2Templates = null; }
|
|
81
|
+
return _t2Templates;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let _t2InitProject = null;
|
|
85
|
+
let _t2InitProjectResolved = false;
|
|
86
|
+
function _getT2InitProject() {
|
|
87
|
+
if (_t2InitProjectResolved) return _t2InitProject;
|
|
88
|
+
_t2InitProjectResolved = true;
|
|
89
|
+
try {
|
|
90
|
+
const mod = require('../../cli/src/init-project');
|
|
91
|
+
_t2InitProject = (mod && typeof mod.initProject === 'function')
|
|
92
|
+
? mod.initProject
|
|
93
|
+
: (typeof mod === 'function' ? mod : null);
|
|
94
|
+
} catch (_e) {
|
|
95
|
+
_t2InitProject = null;
|
|
96
|
+
}
|
|
97
|
+
return _t2InitProject;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _getT2DestFor() {
|
|
101
|
+
try {
|
|
102
|
+
const mod = require('../../cli/src/init-project');
|
|
103
|
+
return (mod && typeof mod._destFor === 'function') ? mod._destFor : undefined;
|
|
104
|
+
} catch (_e) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function _termdeckVersion() {
|
|
110
|
+
try { return require('../../../package.json').version; }
|
|
111
|
+
catch { return '0.0.0'; }
|
|
112
|
+
}
|
|
66
113
|
|
|
67
114
|
function createServer(config) {
|
|
68
115
|
const app = express();
|
|
@@ -89,6 +136,14 @@ function createServer(config) {
|
|
|
89
136
|
const clientDir = path.join(__dirname, '..', '..', 'client', 'public');
|
|
90
137
|
app.use(express.static(clientDir));
|
|
91
138
|
|
|
139
|
+
// Serve repo-rooted /docs as static markdown so the dashboard right-rail Guide
|
|
140
|
+
// panel can fetch docs/orchestrator-guide.md and render it client-side.
|
|
141
|
+
// Sprint 37 T1.
|
|
142
|
+
const docsDir = path.join(__dirname, '..', '..', '..', 'docs');
|
|
143
|
+
if (fs.existsSync(docsDir)) {
|
|
144
|
+
app.use('/docs', express.static(docsDir));
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
// Initialize database
|
|
93
148
|
let db = null;
|
|
94
149
|
if (Database) {
|
|
@@ -662,10 +717,12 @@ function createServer(config) {
|
|
|
662
717
|
res.json(sessions.getAll());
|
|
663
718
|
});
|
|
664
719
|
|
|
665
|
-
// POST /api/sessions
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
720
|
+
// Reusable PTY spawn + wire helper. Used by POST /api/sessions and the
|
|
721
|
+
// in-dashboard 4+1 sprint runner (Sprint 37 T4) so multi-panel spawns reuse
|
|
722
|
+
// the same wiring (transcripts, RAG, Mnestra flashback) without copy-paste.
|
|
723
|
+
// Returns the Session object regardless of PTY success — status will be
|
|
724
|
+
// 'errored' if pty.spawn threw.
|
|
725
|
+
function spawnTerminalSession({ command, cwd, project, label, type, theme, reason }) {
|
|
669
726
|
const rawCwd = cwd || config.projects?.[project]?.path || os.homedir();
|
|
670
727
|
const resolvedCwd = path.resolve(rawCwd.replace(/^~/, os.homedir()));
|
|
671
728
|
|
|
@@ -679,7 +736,6 @@ function createServer(config) {
|
|
|
679
736
|
reason: reason || 'launched via API'
|
|
680
737
|
});
|
|
681
738
|
|
|
682
|
-
// Spawn PTY
|
|
683
739
|
if (pty) {
|
|
684
740
|
// Three launch shapes:
|
|
685
741
|
// (1) no command → spawn the default shell interactively
|
|
@@ -827,9 +883,25 @@ function createServer(config) {
|
|
|
827
883
|
session.meta.statusDetail = 'node-pty not available';
|
|
828
884
|
}
|
|
829
885
|
|
|
886
|
+
return session;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// POST /api/sessions - create a new terminal session
|
|
890
|
+
app.post('/api/sessions', (req, res) => {
|
|
891
|
+
const { command, cwd, project, label, type, theme, reason } = req.body || {};
|
|
892
|
+
const session = spawnTerminalSession({ command, cwd, project, label, type, theme, reason });
|
|
830
893
|
res.status(201).json(session.toJSON());
|
|
831
894
|
});
|
|
832
895
|
|
|
896
|
+
// Sprint runner endpoints (Sprint 37 T4) — in-dashboard 4+1 sprint runner.
|
|
897
|
+
// Wraps spawnTerminalSession with two-stage submit + verify-and-poke.
|
|
898
|
+
createSprintRoutes({
|
|
899
|
+
app,
|
|
900
|
+
config,
|
|
901
|
+
spawnTerminalSession,
|
|
902
|
+
getSession: (id) => sessions.get(id),
|
|
903
|
+
});
|
|
904
|
+
|
|
833
905
|
// GET /api/sessions/:id - get session details
|
|
834
906
|
app.get('/api/sessions/:id', (req, res) => {
|
|
835
907
|
const session = sessions.get(req.params.id);
|
|
@@ -1145,6 +1217,66 @@ function createServer(config) {
|
|
|
1145
1217
|
}
|
|
1146
1218
|
});
|
|
1147
1219
|
|
|
1220
|
+
// GET /api/projects/:name/orchestration-preview — Sprint 37 T3.
|
|
1221
|
+
// Renders T2's scaffolding templates without writing to disk so the
|
|
1222
|
+
// dashboard can show "if you ran `termdeck init --project <name>`, this
|
|
1223
|
+
// is what would be created." Read-only.
|
|
1224
|
+
app.get('/api/projects/:name/orchestration-preview', (req, res) => {
|
|
1225
|
+
const templates = _getT2Templates();
|
|
1226
|
+
if (!templates) {
|
|
1227
|
+
return res.status(503).json({
|
|
1228
|
+
error: 'Orchestration scaffolding unavailable: packages/cli/src/templates.js not loaded'
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
try {
|
|
1232
|
+
const preview = orchestrationPreview.buildPreview({
|
|
1233
|
+
name: req.params.name,
|
|
1234
|
+
projects: config.projects || {},
|
|
1235
|
+
cwd: process.cwd(),
|
|
1236
|
+
templates,
|
|
1237
|
+
destFor: _getT2DestFor(),
|
|
1238
|
+
version: _termdeckVersion()
|
|
1239
|
+
});
|
|
1240
|
+
res.json(preview);
|
|
1241
|
+
} catch (err) {
|
|
1242
|
+
const status = err.statusCode || 500;
|
|
1243
|
+
if (status >= 500) console.error('[orchestration-preview] GET failed:', err.message);
|
|
1244
|
+
res.status(status).json({ error: err.message });
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
// POST /api/projects/:name/orchestration-preview/generate — Sprint 37 T3.
|
|
1249
|
+
// Calls T2's initProject() to actually write the scaffolding. Body:
|
|
1250
|
+
// { force?: boolean }. Returns the same envelope as the GET preview but
|
|
1251
|
+
// with `created` instead of `wouldCreate`.
|
|
1252
|
+
app.post('/api/projects/:name/orchestration-preview/generate', async (req, res) => {
|
|
1253
|
+
const templates = _getT2Templates();
|
|
1254
|
+
const initProject = _getT2InitProject();
|
|
1255
|
+
if (!templates || !initProject) {
|
|
1256
|
+
return res.status(503).json({
|
|
1257
|
+
error: 'Orchestration scaffolding unavailable: T2 CLI modules not loaded'
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
const force = !!(req.body && req.body.force);
|
|
1261
|
+
try {
|
|
1262
|
+
const result = await orchestrationPreview.generateScaffolding({
|
|
1263
|
+
name: req.params.name,
|
|
1264
|
+
projects: config.projects || {},
|
|
1265
|
+
cwd: process.cwd(),
|
|
1266
|
+
force,
|
|
1267
|
+
initProject,
|
|
1268
|
+
templates,
|
|
1269
|
+
destFor: _getT2DestFor(),
|
|
1270
|
+
version: _termdeckVersion()
|
|
1271
|
+
});
|
|
1272
|
+
res.json(result);
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
const status = err.statusCode || 500;
|
|
1275
|
+
if (status >= 500) console.error('[orchestration-preview] generate failed:', err.message);
|
|
1276
|
+
res.status(status).json({ error: err.message });
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1148
1280
|
// GET /api/status - global status (control room data)
|
|
1149
1281
|
app.get('/api/status', (req, res) => {
|
|
1150
1282
|
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
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Two-stage submit pattern for the in-dashboard 4+1 sprint runner (Sprint 37 T4).
|
|
4
|
+
//
|
|
5
|
+
// The cardinal rule from the global 4+1 inject mandate:
|
|
6
|
+
//
|
|
7
|
+
// Stage 1: write `\x1b[200~<prompt>\x1b[201~` to each session in turn,
|
|
8
|
+
// with a small inter-session gap. NO trailing CR.
|
|
9
|
+
// Settle: ~400ms so the PTY flushes the paste to the input handler.
|
|
10
|
+
// Stage 2: write `\r` alone to each session.
|
|
11
|
+
//
|
|
12
|
+
// Single-stage `<prompt>\x1b[201~\r` is BANNED — when the close marker and the
|
|
13
|
+
// CR ride in one PTY write, the OS-level chunk boundary is non-deterministic;
|
|
14
|
+
// some lanes treat `\r` as the trailing paste byte rather than a submit
|
|
15
|
+
// keystroke, leaving panels visually populated but waiting on a human Enter.
|
|
16
|
+
// That cost Joshua broken sleep during ClaimGuard Sprints 4-5 (2026-04-26) and
|
|
17
|
+
// the Sprint-36 inject (2026-04-27). This module is the structural fix.
|
|
18
|
+
//
|
|
19
|
+
// After both stages, this module verifies each panel reaches `status:'thinking'`
|
|
20
|
+
// within `verifyTimeoutMs`. Any lane that didn't get there is auto-poked
|
|
21
|
+
// (single CR-flood); we never page the user.
|
|
22
|
+
//
|
|
23
|
+
// Pure logic — caller injects writeBytes/getStatus/sleep so tests don't need
|
|
24
|
+
// a live PTY. Wired in by sprint-routes.js.
|
|
25
|
+
|
|
26
|
+
const DEFAULTS = {
|
|
27
|
+
gapMs: 250,
|
|
28
|
+
settleMs: 400,
|
|
29
|
+
verifyTimeoutMs: 8000,
|
|
30
|
+
verifyPollMs: 500,
|
|
31
|
+
postPokeWaitMs: 500,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
async function injectSprintPrompts({
|
|
35
|
+
sessionIds,
|
|
36
|
+
prompts,
|
|
37
|
+
writeBytes,
|
|
38
|
+
getStatus,
|
|
39
|
+
sleep,
|
|
40
|
+
options,
|
|
41
|
+
}) {
|
|
42
|
+
if (!Array.isArray(sessionIds) || !Array.isArray(prompts)) {
|
|
43
|
+
throw new Error('sessionIds and prompts must be arrays');
|
|
44
|
+
}
|
|
45
|
+
if (sessionIds.length !== prompts.length) {
|
|
46
|
+
throw new Error('sessionIds and prompts must be the same length');
|
|
47
|
+
}
|
|
48
|
+
if (sessionIds.length === 0) {
|
|
49
|
+
throw new Error('at least one session required');
|
|
50
|
+
}
|
|
51
|
+
if (typeof writeBytes !== 'function') {
|
|
52
|
+
throw new Error('writeBytes(sessionId, bytes) callback required');
|
|
53
|
+
}
|
|
54
|
+
if (typeof sleep !== 'function') {
|
|
55
|
+
throw new Error('sleep(ms) callback required');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const opts = { ...DEFAULTS, ...(options || {}) };
|
|
59
|
+
|
|
60
|
+
const lanes = sessionIds.map((sessionId, i) => ({
|
|
61
|
+
sessionId,
|
|
62
|
+
prompt: prompts[i],
|
|
63
|
+
paste: null,
|
|
64
|
+
submit: null,
|
|
65
|
+
verified: false,
|
|
66
|
+
poked: false,
|
|
67
|
+
finalStatus: null,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
// Stage 1: paste-only across all lanes, gapMs between each.
|
|
71
|
+
for (let i = 0; i < lanes.length; i++) {
|
|
72
|
+
const lane = lanes[i];
|
|
73
|
+
const payload = `\x1b[200~${lane.prompt}\x1b[201~`;
|
|
74
|
+
try {
|
|
75
|
+
const r = await writeBytes(lane.sessionId, payload);
|
|
76
|
+
lane.paste = { ok: true, bytes: (r && r.bytes) || payload.length };
|
|
77
|
+
} catch (err) {
|
|
78
|
+
lane.paste = { ok: false, error: err && err.message ? err.message : String(err) };
|
|
79
|
+
}
|
|
80
|
+
if (i < lanes.length - 1) await sleep(opts.gapMs);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Settle window — long enough for the PTY to flush each paste to the TUI's
|
|
84
|
+
// input handler before the trailing CR lands.
|
|
85
|
+
await sleep(opts.settleMs);
|
|
86
|
+
|
|
87
|
+
// Stage 2: submit-only (\r alone, guaranteed its own PTY write).
|
|
88
|
+
for (let i = 0; i < lanes.length; i++) {
|
|
89
|
+
const lane = lanes[i];
|
|
90
|
+
if (!lane.paste || !lane.paste.ok) {
|
|
91
|
+
lane.submit = { ok: false, skipped: 'paste-failed' };
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const r = await writeBytes(lane.sessionId, '\r');
|
|
96
|
+
lane.submit = { ok: true, bytes: (r && r.bytes) || 1 };
|
|
97
|
+
} catch (err) {
|
|
98
|
+
lane.submit = { ok: false, error: err && err.message ? err.message : String(err) };
|
|
99
|
+
}
|
|
100
|
+
if (i < lanes.length - 1) await sleep(opts.gapMs);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Verify: poll each lane's status until it reads `thinking` or we hit the
|
|
104
|
+
// deadline. Lanes that never thinking → auto-/poke (cr-flood).
|
|
105
|
+
if (typeof getStatus === 'function') {
|
|
106
|
+
const deadline = Date.now() + opts.verifyTimeoutMs;
|
|
107
|
+
while (Date.now() < deadline) {
|
|
108
|
+
let anyPending = false;
|
|
109
|
+
for (const lane of lanes) {
|
|
110
|
+
if (lane.verified) continue;
|
|
111
|
+
try {
|
|
112
|
+
const s = await getStatus(lane.sessionId);
|
|
113
|
+
lane.finalStatus = s && s.status ? s.status : null;
|
|
114
|
+
if (lane.finalStatus === 'thinking') {
|
|
115
|
+
lane.verified = true;
|
|
116
|
+
} else {
|
|
117
|
+
anyPending = true;
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
anyPending = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!anyPending) break;
|
|
124
|
+
await sleep(opts.verifyPollMs);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Auto-poke (cr-flood) any lane that didn't reach `thinking`. Best-effort —
|
|
128
|
+
// never page the user; the orchestrator dashboard surfaces the result.
|
|
129
|
+
for (const lane of lanes) {
|
|
130
|
+
if (lane.verified) continue;
|
|
131
|
+
try {
|
|
132
|
+
await writeBytes(lane.sessionId, '\r\r\r');
|
|
133
|
+
lane.poked = true;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
lane.pokeError = err && err.message ? err.message : String(err);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
await sleep(opts.postPokeWaitMs);
|
|
139
|
+
try {
|
|
140
|
+
const s = await getStatus(lane.sessionId);
|
|
141
|
+
lane.finalStatus = s && s.status ? s.status : lane.finalStatus;
|
|
142
|
+
if (lane.finalStatus === 'thinking') lane.verified = true;
|
|
143
|
+
} catch {
|
|
144
|
+
// ignore
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const ok = lanes.every((l) => l.paste && l.paste.ok && l.submit && l.submit.ok);
|
|
150
|
+
return { ok, lanes };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
injectSprintPrompts,
|
|
155
|
+
DEFAULTS,
|
|
156
|
+
};
|