specdacular 0.10.0 → 0.11.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 (60) hide show
  1. package/README.md +3 -3
  2. package/bin/install.js +3 -1
  3. package/bin/specd.js +135 -0
  4. package/commands/specd.best-practices.md +75 -0
  5. package/commands/specd.docs.md +81 -0
  6. package/commands/specd.docs.review.md +80 -0
  7. package/commands/specd.generate-skills.learn.md +65 -0
  8. package/commands/specd.new-project.md +58 -0
  9. package/commands/specd.new-runner-task.md +52 -0
  10. package/commands/specd.new.md +6 -6
  11. package/commands/specd.runner-status.md +27 -0
  12. package/package.json +6 -2
  13. package/runner/main/agent/parser.js +39 -0
  14. package/runner/main/agent/runner.js +137 -0
  15. package/runner/main/agent/template.js +16 -0
  16. package/runner/main/bootstrap.js +69 -0
  17. package/runner/main/db.js +45 -0
  18. package/runner/main/index.js +103 -0
  19. package/runner/main/ipc.js +72 -0
  20. package/runner/main/notifications/telegram.js +45 -0
  21. package/runner/main/orchestrator.js +193 -0
  22. package/runner/main/paths.js +36 -0
  23. package/runner/main/pipeline/resolver.js +20 -0
  24. package/runner/main/pipeline/sequencer.js +42 -0
  25. package/runner/main/server/api.js +125 -0
  26. package/runner/main/server/index.js +33 -0
  27. package/runner/main/server/websocket.js +24 -0
  28. package/runner/main/state/manager.js +83 -0
  29. package/runner/main/template-manager.js +41 -0
  30. package/runner/main/test/agent-parser.test.js +44 -0
  31. package/runner/main/test/bootstrap.test.js +58 -0
  32. package/runner/main/test/db.test.js +72 -0
  33. package/runner/main/test/paths.test.js +29 -0
  34. package/runner/main/test/state-manager.test.js +72 -0
  35. package/runner/main/test/template-manager.test.js +66 -0
  36. package/runner/main/worktree/manager.js +95 -0
  37. package/runner/package.json +22 -0
  38. package/runner/preload.js +19 -0
  39. package/specdacular/HELP.md +20 -11
  40. package/specdacular/agents/best-practices-researcher.md +271 -0
  41. package/specdacular/agents/project-researcher.md +409 -0
  42. package/specdacular/references/load-context.md +4 -7
  43. package/specdacular/templates/orchestrator/CONCERNS.md +1 -1
  44. package/specdacular/templates/orchestrator/PROJECTS.md +3 -4
  45. package/specdacular/templates/tasks/PLAN.md +2 -2
  46. package/specdacular/templates/tasks/PROJECT.md +52 -0
  47. package/specdacular/templates/tasks/REQUIREMENTS.md +75 -0
  48. package/specdacular/workflows/best-practices.md +472 -0
  49. package/specdacular/workflows/context-add.md +16 -30
  50. package/specdacular/workflows/context-manual-review.md +7 -7
  51. package/specdacular/workflows/docs-review.md +273 -0
  52. package/specdacular/workflows/docs.md +420 -0
  53. package/specdacular/workflows/generate-learn-skill.md +214 -0
  54. package/specdacular/workflows/new-project.md +799 -0
  55. package/specdacular/workflows/new.md +5 -4
  56. package/specdacular/workflows/orchestrator/new.md +4 -4
  57. package/specdacular/workflows/orchestrator/plan.md +6 -6
  58. package/commands/specd.codebase.map.md +0 -72
  59. package/commands/specd.codebase.review.md +0 -39
  60. package/specdacular/workflows/map-codebase.md +0 -715
@@ -0,0 +1,39 @@
1
+ import { EventEmitter } from 'events';
2
+
3
+ export class StreamParser extends EventEmitter {
4
+ constructor() {
5
+ super();
6
+ this.inBlock = null;
7
+ this.blockLines = [];
8
+ }
9
+
10
+ feed(line) {
11
+ if (line.startsWith('```specd-status')) {
12
+ this.inBlock = 'status';
13
+ this.blockLines = [];
14
+ return;
15
+ }
16
+ if (line.startsWith('```specd-result')) {
17
+ this.inBlock = 'result';
18
+ this.blockLines = [];
19
+ return;
20
+ }
21
+ if (line === '```' && this.inBlock) {
22
+ const content = this.blockLines.join('\n');
23
+ try {
24
+ const parsed = JSON.parse(content);
25
+ this.emit(this.inBlock, parsed);
26
+ } catch (err) {
27
+ this.emit('error', err);
28
+ }
29
+ this.inBlock = null;
30
+ this.blockLines = [];
31
+ return;
32
+ }
33
+ if (this.inBlock) {
34
+ this.blockLines.push(line);
35
+ } else {
36
+ this.emit('output', line);
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,137 @@
1
+ import { spawn } from 'child_process';
2
+ import { EventEmitter } from 'events';
3
+ import { createWriteStream } from 'fs';
4
+ import { StreamParser } from './parser.js';
5
+
6
+ export class AgentRunner extends EventEmitter {
7
+ constructor({ cmd, input_mode, output_format, system_prompt, timeout, stuck_timeout }) {
8
+ super();
9
+ this.cmd = cmd;
10
+ this.inputMode = input_mode || 'stdin';
11
+ this.outputFormat = output_format || 'stream_json';
12
+ this.systemPrompt = system_prompt || '';
13
+ this.timeout = (timeout || 3600) * 1000;
14
+ this.stuckTimeout = (stuck_timeout || 1800) * 1000;
15
+ }
16
+
17
+ async run(prompt, { cwd, logPath } = {}) {
18
+ return new Promise((resolve, reject) => {
19
+ const fullPrompt = this.systemPrompt ? `${this.systemPrompt}\n\n${prompt}` : prompt;
20
+ const args = this.cmd.split(' ').slice(1);
21
+ const bin = this.cmd.split(' ')[0];
22
+
23
+ const proc = spawn(bin, args, {
24
+ cwd,
25
+ shell: true,
26
+ stdio: ['pipe', 'pipe', 'pipe'],
27
+ env: { ...process.env },
28
+ });
29
+
30
+ const logStream = logPath ? createWriteStream(logPath, { flags: 'a' }) : null;
31
+ let lastOutputAt = Date.now();
32
+ let result = null;
33
+
34
+ const parser = new StreamParser();
35
+ parser.on('status', (s) => {
36
+ lastOutputAt = Date.now();
37
+ this.emit('status', s);
38
+ });
39
+ parser.on('result', (r) => {
40
+ lastOutputAt = Date.now();
41
+ result = r;
42
+ this.emit('result', r);
43
+ });
44
+ parser.on('output', (line) => {
45
+ lastOutputAt = Date.now();
46
+ this.emit('output', line);
47
+ });
48
+
49
+ const handleLine = (line) => {
50
+ if (logStream) logStream.write(line + '\n');
51
+
52
+ if (this.outputFormat === 'stream_json') {
53
+ try {
54
+ const event = JSON.parse(line);
55
+ if (event.type === 'assistant' && event.message?.content) {
56
+ for (const block of event.message.content) {
57
+ if (block.type === 'text') {
58
+ for (const textLine of block.text.split('\n')) {
59
+ parser.feed(textLine);
60
+ }
61
+ }
62
+ }
63
+ } else if (event.type === 'result' && event.result) {
64
+ for (const block of event.result) {
65
+ if (block.type === 'text') {
66
+ for (const textLine of block.text.split('\n')) {
67
+ parser.feed(textLine);
68
+ }
69
+ }
70
+ }
71
+ }
72
+ } catch {
73
+ parser.feed(line);
74
+ }
75
+ } else {
76
+ parser.feed(line);
77
+ }
78
+ };
79
+
80
+ let stdout = '';
81
+ proc.stdout.on('data', (chunk) => {
82
+ stdout += chunk.toString();
83
+ const lines = stdout.split('\n');
84
+ stdout = lines.pop();
85
+ for (const line of lines) {
86
+ if (line.trim()) handleLine(line.trim());
87
+ }
88
+ });
89
+
90
+ proc.stderr.on('data', (chunk) => {
91
+ if (logStream) logStream.write(`[stderr] ${chunk}`);
92
+ });
93
+
94
+ if (this.inputMode === 'stdin') {
95
+ proc.stdin.write(fullPrompt);
96
+ proc.stdin.end();
97
+ }
98
+
99
+ // Global timeout
100
+ const globalTimer = setTimeout(() => {
101
+ proc.kill('SIGTERM');
102
+ setTimeout(() => proc.kill('SIGKILL'), 5000);
103
+ }, this.timeout);
104
+
105
+ // Stuck detection
106
+ const stuckCheck = setInterval(() => {
107
+ if (Date.now() - lastOutputAt > this.stuckTimeout) {
108
+ this.emit('error', new Error('Agent stuck — no output'));
109
+ proc.kill('SIGTERM');
110
+ setTimeout(() => proc.kill('SIGKILL'), 5000);
111
+ }
112
+ }, 30000);
113
+
114
+ proc.on('close', (code) => {
115
+ clearTimeout(globalTimer);
116
+ clearInterval(stuckCheck);
117
+ if (logStream) logStream.end();
118
+ if (stdout.trim()) handleLine(stdout.trim());
119
+
120
+ if (result) {
121
+ resolve(result);
122
+ } else if (code === 0) {
123
+ resolve({ status: 'success', summary: 'Agent completed without explicit result' });
124
+ } else {
125
+ resolve({ status: 'failure', summary: `Agent exited with code ${code}` });
126
+ }
127
+ });
128
+
129
+ proc.on('error', (err) => {
130
+ clearTimeout(globalTimer);
131
+ clearInterval(stuckCheck);
132
+ if (logStream) logStream.end();
133
+ reject(err);
134
+ });
135
+ });
136
+ }
137
+ }
@@ -0,0 +1,16 @@
1
+ export function resolveTemplate(template, variables) {
2
+ return template.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
3
+ const value = path.trim().split('.').reduce((obj, key) => obj?.[key], variables);
4
+ return value !== undefined ? String(value) : match;
5
+ });
6
+ }
7
+
8
+ export function buildTemplateContext(task, stage, pipeline, paths) {
9
+ return {
10
+ task: { id: task.id, name: task.name, spec: task.spec || '' },
11
+ stage: { name: stage.stage, index: stage.index, total: stage.total },
12
+ pipeline: { name: pipeline.name },
13
+ status_file: paths?.statusJson || '',
14
+ log_dir: paths?.logsDir || '',
15
+ };
16
+ }
@@ -0,0 +1,69 @@
1
+ import { mkdirSync, writeFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const DEFAULT_CONFIG = {
5
+ server: { port: 3700 },
6
+ notifications: { telegram: { enabled: false } },
7
+ defaults: {
8
+ pipeline: 'default',
9
+ failure_policy: 'skip',
10
+ timeout: 3600,
11
+ stuck_timeout: 1800,
12
+ max_parallel: 1,
13
+ },
14
+ };
15
+
16
+ const DEFAULT_AGENTS = {
17
+ 'claude-planner': {
18
+ cmd: 'claude -p --dangerously-skip-permissions',
19
+ input_mode: 'stdin',
20
+ output_format: 'stream_json',
21
+ system_prompt: 'You are a feature planner working on: {{task.name}} ({{task.id}})\nPipeline: {{pipeline.name}} | Stage: {{stage.name}} ({{stage.index}}/{{stage.total}})\n\nResearch the codebase thoroughly, then create a detailed implementation plan.\n\nEmit progress:\n```specd-status\n{"task_id":"{{task.id}}","stage":"{{stage.name}}","progress":"...","percent":0}\n```\n\nWhen done:\n```specd-result\n{"status":"success","summary":"...","files_changed":[],"issues":[]}\n```',
22
+ },
23
+ 'claude-implementer': {
24
+ cmd: 'claude -p --dangerously-skip-permissions',
25
+ input_mode: 'stdin',
26
+ output_format: 'stream_json',
27
+ system_prompt: 'You are an implementer working on: {{task.name}} ({{task.id}})\nPipeline: {{pipeline.name}} | Stage: {{stage.name}} ({{stage.index}}/{{stage.total}})\n\nImplement the plan from the previous stage. Write clean, tested code.\n\nEmit progress:\n```specd-status\n{"task_id":"{{task.id}}","stage":"{{stage.name}}","progress":"...","percent":0}\n```\n\nWhen done:\n```specd-result\n{"status":"success","summary":"...","files_changed":[],"issues":[]}\n```',
28
+ },
29
+ 'claude-reviewer': {
30
+ cmd: 'claude -p --dangerously-skip-permissions',
31
+ input_mode: 'stdin',
32
+ output_format: 'stream_json',
33
+ system_prompt: 'You are a code reviewer for: {{task.name}} ({{task.id}})\nPipeline: {{pipeline.name}} | Stage: {{stage.name}} ({{stage.index}}/{{stage.total}})\n\nReview the implementation from the previous stage. Check for bugs, style, tests.\n\nEmit progress:\n```specd-status\n{"task_id":"{{task.id}}","stage":"{{stage.name}}","progress":"...","percent":0}\n```\n\nWhen done:\n```specd-result\n{"status":"success","summary":"...","files_changed":[],"issues":[]}\n```',
34
+ },
35
+ };
36
+
37
+ const DEFAULT_PIPELINE = {
38
+ name: 'default',
39
+ stages: [
40
+ { stage: 'plan', agent: 'claude-planner', critical: true },
41
+ { stage: 'implement', agent: 'claude-implementer', critical: true },
42
+ { stage: 'review', agent: 'claude-reviewer', on_fail: 'retry', max_retries: 2 },
43
+ ],
44
+ };
45
+
46
+ function writeIfMissing(filePath, data) {
47
+ if (!existsSync(filePath)) {
48
+ writeFileSync(filePath, JSON.stringify(data, null, 2));
49
+ }
50
+ }
51
+
52
+ export async function bootstrap(paths) {
53
+ // Create directories
54
+ mkdirSync(paths.agentTemplatesDir, { recursive: true });
55
+ mkdirSync(paths.pipelineTemplatesDir, { recursive: true });
56
+ mkdirSync(paths.projectsDir, { recursive: true });
57
+
58
+ // Write default files
59
+ writeIfMissing(paths.db, { projects: [] });
60
+ writeIfMissing(paths.config, DEFAULT_CONFIG);
61
+
62
+ // Write default agent templates
63
+ for (const [name, agent] of Object.entries(DEFAULT_AGENTS)) {
64
+ writeIfMissing(join(paths.agentTemplatesDir, `${name}.json`), agent);
65
+ }
66
+
67
+ // Write default pipeline template
68
+ writeIfMissing(join(paths.pipelineTemplatesDir, 'default.json'), DEFAULT_PIPELINE);
69
+ }
@@ -0,0 +1,45 @@
1
+ import { readFileSync, writeFileSync } from 'fs';
2
+ import { randomUUID } from 'crypto';
3
+
4
+ export class ProjectDB {
5
+ constructor(dbPath) {
6
+ this.dbPath = dbPath;
7
+ this.data = JSON.parse(readFileSync(dbPath, 'utf-8'));
8
+ }
9
+
10
+ register(name, folderPath) {
11
+ const project = {
12
+ id: randomUUID().slice(0, 8),
13
+ name,
14
+ path: folderPath,
15
+ active: true,
16
+ registeredAt: new Date().toISOString(),
17
+ };
18
+ this.data.projects.push(project);
19
+ this._save();
20
+ return project;
21
+ }
22
+
23
+ unregister(id) {
24
+ this.data.projects = this.data.projects.filter(p => p.id !== id);
25
+ this._save();
26
+ }
27
+
28
+ get(id) {
29
+ return this.data.projects.find(p => p.id === id) || null;
30
+ }
31
+
32
+ findByPath(folderPath) {
33
+ return this.data.projects.find(p =>
34
+ folderPath === p.path || folderPath.startsWith(p.path + '/')
35
+ ) || null;
36
+ }
37
+
38
+ list() {
39
+ return this.data.projects;
40
+ }
41
+
42
+ _save() {
43
+ writeFileSync(this.dbPath, JSON.stringify(this.data, null, 2));
44
+ }
45
+ }
@@ -0,0 +1,103 @@
1
+ // runner/main/index.js
2
+ import { app, BrowserWindow } from 'electron';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
8
+ import { Paths } from './paths.js';
9
+ import { bootstrap } from './bootstrap.js';
10
+ import { ProjectDB } from './db.js';
11
+ import { Orchestrator } from './orchestrator.js';
12
+ import { createServer } from './server/index.js';
13
+ import { setupIpc } from './ipc.js';
14
+
15
+ let mainWindow;
16
+ const paths = new Paths();
17
+ const orchestrators = new Map();
18
+ let db;
19
+ let config;
20
+ let server;
21
+
22
+ function getContext() {
23
+ return { db, config, paths, orchestrators };
24
+ }
25
+
26
+ async function initBackend() {
27
+ await bootstrap(paths);
28
+
29
+ db = new ProjectDB(paths.db);
30
+ config = JSON.parse(readFileSync(paths.config, 'utf-8'));
31
+
32
+ // Initialize orchestrators for active projects
33
+ for (const project of db.list().filter(p => p.active)) {
34
+ // Ensure project.json exists (may not if registered via CLI only)
35
+ const projectPaths = paths.forProject(project.id);
36
+ mkdirSync(projectPaths.dir, { recursive: true });
37
+ if (!existsSync(projectPaths.projectJson)) {
38
+ writeFileSync(projectPaths.projectJson, JSON.stringify({
39
+ name: project.name,
40
+ path: project.path,
41
+ registeredAt: project.registeredAt,
42
+ }, null, 2));
43
+ }
44
+
45
+ const orch = new Orchestrator({ projectId: project.id, paths, config });
46
+ orch.init();
47
+ orchestrators.set(project.id, orch);
48
+ }
49
+
50
+ // Start API server
51
+ server = createServer(getContext);
52
+ const port = config.server?.port || 3700;
53
+ await server.start(port);
54
+
55
+ // Wire orchestrators to WebSocket
56
+ for (const orch of orchestrators.values()) {
57
+ server.wireOrchestrator(orch);
58
+ }
59
+
60
+ // Start orchestrator loops
61
+ for (const orch of orchestrators.values()) {
62
+ orch.startLoop().catch(err => console.error('Loop error:', err));
63
+ }
64
+ }
65
+
66
+ function createWindow() {
67
+ mainWindow = new BrowserWindow({
68
+ width: 1200,
69
+ height: 800,
70
+ webPreferences: {
71
+ preload: join(__dirname, '..', 'preload.js'),
72
+ contextIsolation: true,
73
+ nodeIntegration: false,
74
+ },
75
+ });
76
+
77
+ if (process.env.NODE_ENV === 'development') {
78
+ mainWindow.loadURL('http://localhost:5173');
79
+ } else {
80
+ mainWindow.loadFile(join(__dirname, '..', 'renderer', 'dist', 'index.html'));
81
+ }
82
+ }
83
+
84
+ app.whenReady().then(async () => {
85
+ setupIpc(getContext);
86
+ await initBackend();
87
+ createWindow();
88
+ });
89
+
90
+ app.on('window-all-closed', () => {
91
+ if (process.platform !== 'darwin') app.quit();
92
+ });
93
+
94
+ app.on('activate', () => {
95
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
96
+ });
97
+
98
+ app.on('before-quit', () => {
99
+ for (const orch of orchestrators.values()) {
100
+ orch.stop();
101
+ }
102
+ if (server) server.stop();
103
+ });
@@ -0,0 +1,72 @@
1
+ // runner/main/ipc.js
2
+ import { ipcMain } from 'electron';
3
+ import { readFileSync, existsSync } from 'fs';
4
+ import { join } from 'path';
5
+
6
+ export function setupIpc(getContext) {
7
+ ipcMain.handle('get-projects', () => {
8
+ const { db, orchestrators } = getContext();
9
+ return db.list().map(p => {
10
+ const orch = orchestrators.get(p.id);
11
+ const tasks = orch ? orch.getTasks() : [];
12
+ return {
13
+ ...p,
14
+ taskCounts: {
15
+ total: tasks.length,
16
+ ready: tasks.filter(t => t.status === 'ready').length,
17
+ running: tasks.filter(t => t.status === 'in_progress').length,
18
+ done: tasks.filter(t => t.status === 'done').length,
19
+ failed: tasks.filter(t => t.status === 'failed').length,
20
+ },
21
+ };
22
+ });
23
+ });
24
+
25
+ ipcMain.handle('get-project-status', (event, projectId) => {
26
+ const { orchestrators } = getContext();
27
+ const orch = orchestrators.get(projectId);
28
+ if (!orch) return null;
29
+ return orch.stateManager.getState();
30
+ });
31
+
32
+ ipcMain.handle('get-tasks', (event, projectId) => {
33
+ const { orchestrators } = getContext();
34
+ const orch = orchestrators.get(projectId);
35
+ if (!orch) return [];
36
+ return orch.getTasks();
37
+ });
38
+
39
+ ipcMain.handle('get-task', (event, projectId, taskId) => {
40
+ const { orchestrators } = getContext();
41
+ const orch = orchestrators.get(projectId);
42
+ if (!orch) return null;
43
+ return orch.getTask(taskId);
44
+ });
45
+
46
+ ipcMain.handle('create-task', (event, projectId, taskData) => {
47
+ const { orchestrators } = getContext();
48
+ const orch = orchestrators.get(projectId);
49
+ if (!orch) return null;
50
+ return orch.createTask(taskData);
51
+ });
52
+
53
+ ipcMain.handle('retry-task', (event, projectId, taskId) => {
54
+ const { orchestrators } = getContext();
55
+ const orch = orchestrators.get(projectId);
56
+ if (!orch) return null;
57
+ return orch.updateTask(taskId, { status: 'ready' });
58
+ });
59
+
60
+ ipcMain.handle('get-task-logs', (event, projectId, taskId) => {
61
+ const { paths } = getContext();
62
+ const logPath = join(paths.forProject(projectId).logsDir, `${taskId}.log`);
63
+ if (!existsSync(logPath)) return { lines: [] };
64
+ const content = readFileSync(logPath, 'utf-8');
65
+ return { lines: content.split('\n').slice(-200) };
66
+ });
67
+
68
+ ipcMain.handle('get-config', () => {
69
+ const { config } = getContext();
70
+ return config;
71
+ });
72
+ }
@@ -0,0 +1,45 @@
1
+ // runner/main/notifications/telegram.js
2
+ import { request } from 'https';
3
+
4
+ export class TelegramNotifier {
5
+ constructor({ bot_token, chat_id, notify_on }) {
6
+ this.botToken = bot_token;
7
+ this.chatId = chat_id;
8
+ this.notifyOn = new Set(notify_on || ['task_complete', 'task_failed']);
9
+ }
10
+
11
+ async onTaskComplete(taskId, taskName, summary) {
12
+ if (!this.notifyOn.has('task_complete')) return;
13
+ await this._send(`✅ *Task Complete*\n*${taskName}* (${taskId})\n${summary}`);
14
+ }
15
+
16
+ async onTaskFailed(taskId, taskName, stage, error) {
17
+ if (!this.notifyOn.has('task_failed')) return;
18
+ await this._send(`❌ *Task Failed*\n*${taskName}* (${taskId})\nStage: ${stage}\nError: ${error}`);
19
+ }
20
+
21
+ async onStuck(taskId, taskName, stage) {
22
+ if (!this.notifyOn.has('task_stuck')) return;
23
+ await this._send(`⚠️ *Agent Stuck*\n*${taskName}* (${taskId})\nStage: ${stage}`);
24
+ }
25
+
26
+ async onNeedsInput(taskId, taskName, stage, question) {
27
+ if (!this.notifyOn.has('needs_input')) return;
28
+ await this._send(`❓ *Input Needed*\n*${taskName}* (${taskId})\nStage: ${stage}\n${question}`);
29
+ }
30
+
31
+ _send(text) {
32
+ return new Promise((resolve) => {
33
+ const data = JSON.stringify({ chat_id: this.chatId, text, parse_mode: 'Markdown' });
34
+ const req = request({
35
+ hostname: 'api.telegram.org',
36
+ path: `/bot${this.botToken}/sendMessage`,
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
39
+ }, resolve);
40
+ req.on('error', (err) => { console.error('Telegram error:', err.message); resolve(); });
41
+ req.write(data);
42
+ req.end();
43
+ });
44
+ }
45
+ }