lemmafit 0.0.1 → 0.2.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/LICENSE +21 -0
- package/README.md +93 -4
- package/blank-template/README.md +3 -0
- package/blank-template/SPEC.yaml +1 -0
- package/blank-template/index.html +12 -0
- package/blank-template/lemmafit/.vibe/config.json +5 -0
- package/blank-template/lemmafit/dafny/Domain.dfy +5 -0
- package/blank-template/lemmafit/dafny/Replay.dfy +147 -0
- package/blank-template/package.json +25 -0
- package/blank-template/src/App.css +3 -0
- package/blank-template/src/App.tsx +10 -0
- package/blank-template/src/dafny/.gitkeep +0 -0
- package/blank-template/src/index.css +29 -0
- package/blank-template/src/main.tsx +10 -0
- package/blank-template/src/vite-env.d.ts +6 -0
- package/blank-template/template.gitignore +3 -0
- package/blank-template/tsconfig.json +21 -0
- package/blank-template/tsconfig.node.json +11 -0
- package/blank-template/vite.config.js +9 -0
- package/cli/context-hook.js +103 -0
- package/cli/daemon.js +24 -0
- package/cli/download-dafny2js.js +136 -0
- package/cli/generate-guarantees-md.js +223 -0
- package/cli/lemmafit.js +385 -0
- package/cli/session-hook.js +74 -0
- package/cli/sync.js +168 -0
- package/cli/verify-hook.js +221 -0
- package/commands/guarantees.md +138 -0
- package/docs/CLAUDE_INSTRUCTIONS.md +137 -0
- package/kernels/Replay.dfy +147 -0
- package/lib/daemon-client.js +54 -0
- package/lib/daemon.js +990 -0
- package/lib/download-dafny.js +130 -0
- package/lib/log.js +32 -0
- package/lib/spawn-claude.js +51 -0
- package/package.json +49 -5
- package/skills/lemmafit-dafny/SKILL.md +101 -0
- package/skills/lemmafit-post-react-audit/SKILL.md +46 -0
- package/skills/lemmafit-pre-react-audits/SKILL.md +67 -0
- package/skills/lemmafit-proofs/SKILL.md +24 -0
- package/skills/lemmafit-react-pattern/SKILL.md +62 -0
- package/skills/lemmafit-spec/SKILL.md +71 -0
- package/index.js +0 -5
package/lib/daemon.js
ADDED
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lemmafit Daemon - Continuous verification and compilation for verified vibe coding.
|
|
3
|
+
*
|
|
4
|
+
* Watches Dafny files, runs verification, compiles on success, and writes status.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const net = require('net');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const { spawn } = require('child_process');
|
|
13
|
+
const WebSocket = require('ws');
|
|
14
|
+
const { spawnClaude } = require('./spawn-claude');
|
|
15
|
+
|
|
16
|
+
class Daemon {
|
|
17
|
+
constructor(projectDir, options = {}) {
|
|
18
|
+
this.projectDir = path.resolve(projectDir);
|
|
19
|
+
this.dafnyDir = path.join(this.projectDir, 'lemmafit', 'dafny');
|
|
20
|
+
this.vibeDir = path.join(this.projectDir, 'lemmafit', '.vibe');
|
|
21
|
+
this.statusPath = path.join(this.vibeDir, 'status.json');
|
|
22
|
+
this.configPath = path.join(this.vibeDir, 'config.json');
|
|
23
|
+
this.srcDafnyDir = path.join(this.projectDir, 'src', 'dafny');
|
|
24
|
+
this.pollInterval = options.pollInterval || 500;
|
|
25
|
+
this.dafnyPath = options.dafnyPath || this.findDafny();
|
|
26
|
+
|
|
27
|
+
// Ensure directories exist
|
|
28
|
+
fs.mkdirSync(this.vibeDir, { recursive: true });
|
|
29
|
+
fs.mkdirSync(this.srcDafnyDir, { recursive: true });
|
|
30
|
+
|
|
31
|
+
// Shared cache paths (~/.lemmafit), with fallback to package-local
|
|
32
|
+
const cacheDir = path.join(os.homedir(), '.lemmafit');
|
|
33
|
+
const cacheDafny2js = path.join(cacheDir, '.dafny2js', 'dafny2js');
|
|
34
|
+
this.dafny2jsBin = fs.existsSync(cacheDafny2js)
|
|
35
|
+
? cacheDafny2js
|
|
36
|
+
: path.join(__dirname, '..', '.dafny2js', 'dafny2js');
|
|
37
|
+
this.kernelPath = path.join(__dirname, '..', 'kernels', 'Replay.dfy');
|
|
38
|
+
|
|
39
|
+
// Load or create config
|
|
40
|
+
this.config = this.loadConfig();
|
|
41
|
+
this.modulesPath = path.join(this.vibeDir, 'modules.json');
|
|
42
|
+
this.modules = this.loadModules();
|
|
43
|
+
|
|
44
|
+
// WS relay state (only active when config.server is set)
|
|
45
|
+
this.relayWs = null;
|
|
46
|
+
this.relayConnected = false;
|
|
47
|
+
this.relayRetryDelay = 1000;
|
|
48
|
+
|
|
49
|
+
// Sync bundled Replay.dfy into project
|
|
50
|
+
this.syncKernel();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
syncKernel() {
|
|
54
|
+
const target = path.join(this.dafnyDir, 'Replay.dfy');
|
|
55
|
+
if (!fs.existsSync(this.kernelPath)) return;
|
|
56
|
+
try {
|
|
57
|
+
const src = fs.readFileSync(this.kernelPath, 'utf8');
|
|
58
|
+
const existing = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
|
|
59
|
+
if (src !== existing) {
|
|
60
|
+
fs.mkdirSync(this.dafnyDir, { recursive: true });
|
|
61
|
+
fs.writeFileSync(target, src);
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
findDafny() {
|
|
67
|
+
// Check shared cache directory first (~/.lemmafit)
|
|
68
|
+
const cacheDafny = path.join(os.homedir(), '.lemmafit', '.dafny', 'dafny', 'dafny');
|
|
69
|
+
if (fs.existsSync(cacheDafny)) {
|
|
70
|
+
return cacheDafny;
|
|
71
|
+
}
|
|
72
|
+
// Check lemmafit package directory (fallback)
|
|
73
|
+
const lemmafitDafny = path.join(__dirname, '..', '.dafny', 'dafny', 'dafny');
|
|
74
|
+
if (fs.existsSync(lemmafitDafny)) {
|
|
75
|
+
return lemmafitDafny;
|
|
76
|
+
}
|
|
77
|
+
// Fall back to global dafny
|
|
78
|
+
return 'dafny';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
loadConfig() {
|
|
82
|
+
const defaultConfig = {
|
|
83
|
+
entry: 'lemmafit/dafny/Domain.dfy',
|
|
84
|
+
appCore: 'AppCore',
|
|
85
|
+
outputName: 'Domain'
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (fs.existsSync(this.configPath)) {
|
|
89
|
+
try {
|
|
90
|
+
return { ...defaultConfig, ...JSON.parse(fs.readFileSync(this.configPath, 'utf8')) };
|
|
91
|
+
} catch {
|
|
92
|
+
return defaultConfig;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fs.writeFileSync(this.configPath, JSON.stringify(defaultConfig, null, 2));
|
|
97
|
+
console.log(`Created default config at ${this.configPath}`);
|
|
98
|
+
return defaultConfig;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
loadModules() {
|
|
102
|
+
if (fs.existsSync(this.modulesPath)) {
|
|
103
|
+
try {
|
|
104
|
+
const modules = JSON.parse(fs.readFileSync(this.modulesPath, 'utf8'));
|
|
105
|
+
if (Array.isArray(modules) && modules.length > 0) {
|
|
106
|
+
return modules;
|
|
107
|
+
}
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
// Fall back to single-module from config.json
|
|
111
|
+
return [{
|
|
112
|
+
entry: this.config.entry,
|
|
113
|
+
appCore: this.config.appCore,
|
|
114
|
+
outputName: this.config.outputName
|
|
115
|
+
}];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
isMultiModule() {
|
|
119
|
+
return fs.existsSync(this.modulesPath);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
readSpecFile() {
|
|
123
|
+
const specPath = path.join(this.projectDir, 'SPEC.yaml');
|
|
124
|
+
if (!fs.existsSync(specPath)) return null;
|
|
125
|
+
try {
|
|
126
|
+
return fs.readFileSync(specPath, 'utf8');
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
hashSpecFile() {
|
|
133
|
+
const content = this.readSpecFile();
|
|
134
|
+
if (!content) return '';
|
|
135
|
+
return crypto.createHash('md5').update(content).digest('hex');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
diffLines(oldText, newText) {
|
|
139
|
+
const oldLines = new Set((oldText || '').split('\n'));
|
|
140
|
+
const newLines = (newText || '').split('\n');
|
|
141
|
+
const newSet = new Set(newLines);
|
|
142
|
+
const changes = [];
|
|
143
|
+
|
|
144
|
+
// Lines in new but not in old → added
|
|
145
|
+
for (let i = 0; i < newLines.length; i++) {
|
|
146
|
+
if (!oldLines.has(newLines[i])) {
|
|
147
|
+
changes.push({ line: i + 1, type: 'added', text: newLines[i] });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Lines in old but not in new → removed
|
|
152
|
+
for (const line of oldLines) {
|
|
153
|
+
if (!newSet.has(line)) {
|
|
154
|
+
changes.push({ type: 'removed', text: line });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return changes;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
makeChangeId(change) {
|
|
162
|
+
const key = `${change.type}:${change.text}`;
|
|
163
|
+
return crypto.createHash('md5').update(key).digest('hex').slice(0, 8);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
onSpecChanged(newContent) {
|
|
167
|
+
const timestamp = () => new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
168
|
+
console.log(`[${timestamp()}] SPEC.yaml changed`);
|
|
169
|
+
|
|
170
|
+
let currentStatus = {};
|
|
171
|
+
try {
|
|
172
|
+
currentStatus = JSON.parse(fs.readFileSync(this.statusPath, 'utf8'));
|
|
173
|
+
} catch {}
|
|
174
|
+
|
|
175
|
+
// Diff against ackedSpecContent (last content Claude acted on),
|
|
176
|
+
// not specContent (which tracks the latest file state)
|
|
177
|
+
const baseContent = currentStatus.ackedSpecContent || currentStatus.specContent || null;
|
|
178
|
+
const changes = this.diffLines(baseContent, newContent);
|
|
179
|
+
|
|
180
|
+
const specQueue = changes.map(c => ({
|
|
181
|
+
id: this.makeChangeId(c),
|
|
182
|
+
...c
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
for (const c of changes) {
|
|
186
|
+
if (c.type === 'added') {
|
|
187
|
+
console.log(` +${c.line}: ${c.text}`);
|
|
188
|
+
} else if (c.type === 'removed') {
|
|
189
|
+
console.log(` - ${c.text}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
this.writeStatus({
|
|
194
|
+
...currentStatus,
|
|
195
|
+
specQueue,
|
|
196
|
+
specContent: newContent
|
|
197
|
+
});
|
|
198
|
+
this.relaySend({ type: 'stateUpdate', key: 'spec', payload: newContent });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
ackSpec() {
|
|
202
|
+
const currentContent = this.readSpecFile();
|
|
203
|
+
if (!currentContent) return;
|
|
204
|
+
|
|
205
|
+
let currentStatus = {};
|
|
206
|
+
try {
|
|
207
|
+
currentStatus = JSON.parse(fs.readFileSync(this.statusPath, 'utf8'));
|
|
208
|
+
} catch {}
|
|
209
|
+
|
|
210
|
+
currentStatus.ackedSpecContent = currentContent;
|
|
211
|
+
currentStatus.specContent = currentContent;
|
|
212
|
+
currentStatus.specQueue = [];
|
|
213
|
+
|
|
214
|
+
this.writeStatus(currentStatus);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
hashDafnyFiles() {
|
|
218
|
+
if (!fs.existsSync(this.dafnyDir)) return '';
|
|
219
|
+
|
|
220
|
+
const files = this.findDafnyFiles(this.dafnyDir);
|
|
221
|
+
if (files.length === 0) return '';
|
|
222
|
+
|
|
223
|
+
const hash = crypto.createHash('md5');
|
|
224
|
+
for (const file of files.sort()) {
|
|
225
|
+
try {
|
|
226
|
+
hash.update(fs.readFileSync(file, 'utf8'));
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
// Include modules.json so config changes trigger recompilation
|
|
230
|
+
try {
|
|
231
|
+
hash.update(fs.readFileSync(this.modulesPath, 'utf8'));
|
|
232
|
+
} catch {}
|
|
233
|
+
return hash.digest('hex');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
findDafnyFiles(dir) {
|
|
237
|
+
const files = [];
|
|
238
|
+
if (!fs.existsSync(dir)) return files;
|
|
239
|
+
|
|
240
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
241
|
+
const fullPath = path.join(dir, entry.name);
|
|
242
|
+
if (entry.isDirectory()) {
|
|
243
|
+
files.push(...this.findDafnyFiles(fullPath));
|
|
244
|
+
} else if (entry.name.endsWith('.dfy')) {
|
|
245
|
+
files.push(fullPath);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return files;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
writeStatus(status) {
|
|
252
|
+
// Preserve spec fields across writes (e.g. verification cycles)
|
|
253
|
+
try {
|
|
254
|
+
const prev = JSON.parse(fs.readFileSync(this.statusPath, 'utf8'));
|
|
255
|
+
if (!('specContent' in status) && prev.specContent) {
|
|
256
|
+
status.specContent = prev.specContent;
|
|
257
|
+
}
|
|
258
|
+
if (!('ackedSpecContent' in status) && prev.ackedSpecContent) {
|
|
259
|
+
status.ackedSpecContent = prev.ackedSpecContent;
|
|
260
|
+
}
|
|
261
|
+
if (!('specQueue' in status) && prev.specQueue) {
|
|
262
|
+
status.specQueue = prev.specQueue;
|
|
263
|
+
}
|
|
264
|
+
} catch {}
|
|
265
|
+
status.timestamp = new Date().toISOString();
|
|
266
|
+
fs.writeFileSync(this.statusPath, JSON.stringify(status, null, 2));
|
|
267
|
+
this.relaySend({ type: 'stateUpdate', key: 'status', payload: status });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
parseDafnyErrors(output) {
|
|
271
|
+
const errors = [];
|
|
272
|
+
// Match: file.dfy(line,col): Error: message
|
|
273
|
+
const pattern = /([^\s(]+)\((\d+),(\d+)\):\s*Error:\s*(.+)/g;
|
|
274
|
+
let match;
|
|
275
|
+
while ((match = pattern.exec(output)) !== null) {
|
|
276
|
+
errors.push({
|
|
277
|
+
file: match[1],
|
|
278
|
+
line: parseInt(match[2]),
|
|
279
|
+
column: parseInt(match[3]),
|
|
280
|
+
message: match[4]
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return errors;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
parseDafnyWarnings(output) {
|
|
287
|
+
const warnings = [];
|
|
288
|
+
// Match: file.dfy(line,col): Warning: message
|
|
289
|
+
const pattern = /([^\s(]+)\((\d+),(\d+)\):\s*Warning:\s*(.+)/g;
|
|
290
|
+
let match;
|
|
291
|
+
while ((match = pattern.exec(output)) !== null) {
|
|
292
|
+
warnings.push({
|
|
293
|
+
file: match[1],
|
|
294
|
+
line: parseInt(match[2]),
|
|
295
|
+
column: parseInt(match[3]),
|
|
296
|
+
message: match[4]
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return warnings;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
extractAxioms(dafnyFile) {
|
|
303
|
+
const axioms = [];
|
|
304
|
+
try {
|
|
305
|
+
const content = fs.readFileSync(dafnyFile, 'utf8');
|
|
306
|
+
const lines = content.split('\n');
|
|
307
|
+
for (let i = 0; i < lines.length; i++) {
|
|
308
|
+
const line = lines[i];
|
|
309
|
+
if (line.includes('assume {:axiom}') || line.includes('assume{:axiom}')) {
|
|
310
|
+
axioms.push({
|
|
311
|
+
file: path.relative(this.projectDir, dafnyFile),
|
|
312
|
+
line: i + 1,
|
|
313
|
+
content: line.trim()
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} catch {}
|
|
318
|
+
return axioms;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
runCommand(cmd, args, cwd) {
|
|
322
|
+
return new Promise((resolve) => {
|
|
323
|
+
const proc = spawn(cmd, args, {
|
|
324
|
+
cwd: cwd || this.projectDir,
|
|
325
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
let stdout = '';
|
|
329
|
+
let stderr = '';
|
|
330
|
+
|
|
331
|
+
proc.stdout.on('data', (data) => { stdout += data; });
|
|
332
|
+
proc.stderr.on('data', (data) => { stderr += data; });
|
|
333
|
+
|
|
334
|
+
proc.on('close', (code) => {
|
|
335
|
+
resolve({ code, stdout, stderr });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
proc.on('error', (err) => {
|
|
339
|
+
resolve({ code: 1, stdout: '', stderr: err.message });
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async verify() {
|
|
345
|
+
const dafnyFiles = this.findDafnyFiles(this.dafnyDir);
|
|
346
|
+
if (dafnyFiles.length === 0) {
|
|
347
|
+
return { passed: false, fileStatuses: {}, axioms: [] };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const fileStatuses = {};
|
|
351
|
+
const allAxioms = [];
|
|
352
|
+
let allPassed = true;
|
|
353
|
+
|
|
354
|
+
for (const dafnyFile of dafnyFiles) {
|
|
355
|
+
const relPath = path.relative(this.projectDir, dafnyFile);
|
|
356
|
+
const axioms = this.extractAxioms(dafnyFile);
|
|
357
|
+
allAxioms.push(...axioms);
|
|
358
|
+
|
|
359
|
+
const result = await this.runCommand(
|
|
360
|
+
this.dafnyPath,
|
|
361
|
+
['verify', dafnyFile, '--verification-time-limit=300']
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const output = result.stdout + result.stderr;
|
|
365
|
+
const errors = this.parseDafnyErrors(output);
|
|
366
|
+
const warnings = this.parseDafnyWarnings(output);
|
|
367
|
+
|
|
368
|
+
if (result.code === 0) {
|
|
369
|
+
fileStatuses[relPath] = { verified: true, errors: [], warnings, axioms };
|
|
370
|
+
} else {
|
|
371
|
+
fileStatuses[relPath] = { verified: false, errors, warnings, axioms };
|
|
372
|
+
allPassed = false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return { passed: allPassed, fileStatuses, axioms: allAxioms };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async compile() {
|
|
380
|
+
// Reload modules in case modules.json was created/changed
|
|
381
|
+
this.modules = this.loadModules();
|
|
382
|
+
|
|
383
|
+
const errors = [];
|
|
384
|
+
for (const mod of this.modules) {
|
|
385
|
+
const result = await this.compileModule(mod);
|
|
386
|
+
if (!result.success) {
|
|
387
|
+
errors.push(`${mod.outputName}: ${result.error}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (errors.length > 0) {
|
|
392
|
+
return { success: false, error: errors.join('\n') };
|
|
393
|
+
}
|
|
394
|
+
return { success: true };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async compileModule(mod) {
|
|
398
|
+
const entryPath = path.join(this.projectDir, mod.entry);
|
|
399
|
+
if (!fs.existsSync(entryPath)) {
|
|
400
|
+
return { success: false, error: `Entry file not found: ${mod.entry}` };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const generatedDir = path.join(this.projectDir, 'generated');
|
|
404
|
+
fs.mkdirSync(generatedDir, { recursive: true });
|
|
405
|
+
|
|
406
|
+
const outputBase = path.join(generatedDir, mod.outputName);
|
|
407
|
+
|
|
408
|
+
// Step 1: dafny translate js
|
|
409
|
+
const translateResult = await this.runCommand(this.dafnyPath, [
|
|
410
|
+
'translate', 'js',
|
|
411
|
+
'--no-verify',
|
|
412
|
+
'-o', outputBase,
|
|
413
|
+
'--include-runtime',
|
|
414
|
+
entryPath
|
|
415
|
+
]);
|
|
416
|
+
|
|
417
|
+
if (translateResult.code !== 0) {
|
|
418
|
+
return { success: false, error: `dafny translate failed: ${translateResult.stderr}` };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Step 2: Copy to src/dafny/
|
|
422
|
+
const generatedJs = `${outputBase}.js`;
|
|
423
|
+
if (!fs.existsSync(generatedJs)) {
|
|
424
|
+
return { success: false, error: `Generated JS not found: ${generatedJs}` };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const outputDir = mod.outputDir
|
|
428
|
+
? path.resolve(this.projectDir, mod.outputDir)
|
|
429
|
+
: this.srcDafnyDir;
|
|
430
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
431
|
+
|
|
432
|
+
const targetCjs = path.join(outputDir, `${mod.outputName}.cjs`);
|
|
433
|
+
fs.copyFileSync(generatedJs, targetCjs);
|
|
434
|
+
|
|
435
|
+
// Step 3: Run dafny2js to generate wrapper
|
|
436
|
+
if (!fs.existsSync(this.dafny2jsBin)) {
|
|
437
|
+
return { success: false, error: `dafny2js not found at ${this.dafny2jsBin}` };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Multi-module: each module gets its own {outputName}.ts
|
|
441
|
+
// Single-module (backward compat): output is app.ts
|
|
442
|
+
const wrapperName = this.isMultiModule()
|
|
443
|
+
? `${mod.outputName}.ts`
|
|
444
|
+
: 'app.ts';
|
|
445
|
+
const wrapperPath = path.join(outputDir, wrapperName);
|
|
446
|
+
|
|
447
|
+
const targetFlag = `--${mod.target || 'client'}`;
|
|
448
|
+
const dafny2jsArgs = [
|
|
449
|
+
'--file', entryPath,
|
|
450
|
+
'--app-core', mod.appCore,
|
|
451
|
+
'--cjs-name', `${mod.outputName}.cjs`,
|
|
452
|
+
targetFlag, wrapperPath
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
if (mod.jsonApi) dafny2jsArgs.push('--json-api');
|
|
456
|
+
if (mod.nullOptions) dafny2jsArgs.push('--null-options');
|
|
457
|
+
|
|
458
|
+
const dafny2jsResult = await this.runCommand(this.dafny2jsBin, dafny2jsArgs);
|
|
459
|
+
|
|
460
|
+
if (dafny2jsResult.code !== 0) {
|
|
461
|
+
return { success: false, error: `dafny2js failed: ${dafny2jsResult.stderr || dafny2jsResult.stdout}` };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { success: true };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async extractClaims() {
|
|
468
|
+
const claimsPath = path.join(this.vibeDir, 'claims.json');
|
|
469
|
+
const allClaims = { axioms: [], lemmas: [], predicates: [], functions: [] };
|
|
470
|
+
|
|
471
|
+
for (const mod of this.modules) {
|
|
472
|
+
const entryPath = path.join(this.projectDir, mod.entry);
|
|
473
|
+
if (!fs.existsSync(entryPath)) continue;
|
|
474
|
+
|
|
475
|
+
const result = await this.runCommand(this.dafny2jsBin, [
|
|
476
|
+
'--file', entryPath,
|
|
477
|
+
'--claims'
|
|
478
|
+
]);
|
|
479
|
+
|
|
480
|
+
if (result.code !== 0) {
|
|
481
|
+
return { success: false, error: `claims extraction failed for ${mod.outputName}: ${result.stderr || result.stdout}` };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const claims = JSON.parse(result.stdout);
|
|
486
|
+
for (const key of Object.keys(allClaims)) {
|
|
487
|
+
if (claims[key]) allClaims[key].push(...claims[key]);
|
|
488
|
+
}
|
|
489
|
+
} catch (err) {
|
|
490
|
+
return { success: false, error: `Failed to parse claims JSON for ${mod.outputName}: ${err.message}` };
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
fs.writeFileSync(claimsPath, JSON.stringify(allClaims, null, 2));
|
|
495
|
+
this.generateAssumptions(allClaims);
|
|
496
|
+
return { success: true, claims: allClaims };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async extractLogicSurface() {
|
|
500
|
+
const allSurfaces = [];
|
|
501
|
+
|
|
502
|
+
for (const mod of this.modules) {
|
|
503
|
+
const entryPath = path.join(this.projectDir, mod.entry);
|
|
504
|
+
if (!fs.existsSync(entryPath)) continue;
|
|
505
|
+
|
|
506
|
+
const result = await this.runCommand(this.dafny2jsBin, [
|
|
507
|
+
'--file', entryPath,
|
|
508
|
+
'--logic-surface',
|
|
509
|
+
'--app-core', mod.appCore
|
|
510
|
+
]);
|
|
511
|
+
|
|
512
|
+
if (result.code !== 0) {
|
|
513
|
+
return { success: false, error: `logic surface extraction failed for ${mod.outputName}: ${result.stderr || result.stdout}` };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
const surface = JSON.parse(result.stdout);
|
|
518
|
+
surface._module = mod.outputName;
|
|
519
|
+
allSurfaces.push(surface);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
return { success: false, error: `Failed to parse logic surface JSON for ${mod.outputName}: ${err.message}` };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Single module: write surface directly (backward compat)
|
|
526
|
+
// Multi-module: write array of surfaces
|
|
527
|
+
const output = allSurfaces.length === 1 ? allSurfaces[0] : allSurfaces;
|
|
528
|
+
const surfacePath = path.join(this.vibeDir, 'logic-surface.json');
|
|
529
|
+
fs.writeFileSync(surfacePath, JSON.stringify(output, null, 2));
|
|
530
|
+
return { success: true, surface: output };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
generateAssumptions(claims) {
|
|
534
|
+
const assumptionsPath = path.join(this.projectDir, 'ASSUMPTIONS.md');
|
|
535
|
+
const axioms = claims.axioms || [];
|
|
536
|
+
|
|
537
|
+
if (axioms.length === 0) {
|
|
538
|
+
// No axioms, remove file if it exists
|
|
539
|
+
if (fs.existsSync(assumptionsPath)) {
|
|
540
|
+
fs.unlinkSync(assumptionsPath);
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Group axioms by type (lemma vs inline assume)
|
|
546
|
+
const lemmaAxioms = [];
|
|
547
|
+
const inlineAxioms = [];
|
|
548
|
+
|
|
549
|
+
for (const axiom of axioms) {
|
|
550
|
+
if (axiom.content.startsWith('assume')) {
|
|
551
|
+
inlineAxioms.push(axiom);
|
|
552
|
+
} else {
|
|
553
|
+
lemmaAxioms.push(axiom);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
let content = '# Assumptions\n\n';
|
|
558
|
+
content += 'This file is auto-generated by lemmafit. It lists all axioms in the Dafny code.\n\n';
|
|
559
|
+
|
|
560
|
+
if (lemmaAxioms.length > 0) {
|
|
561
|
+
content += '## Axiom Lemmas\n\n';
|
|
562
|
+
for (const axiom of lemmaAxioms) {
|
|
563
|
+
const relFile = path.relative(this.projectDir, axiom.file);
|
|
564
|
+
content += `- \`${axiom.module}\` (${relFile}:${axiom.line})\n`;
|
|
565
|
+
content += ` \`\`\`dafny\n ${axiom.content}\n \`\`\`\n\n`;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (inlineAxioms.length > 0) {
|
|
570
|
+
content += '## Inline Assumes\n\n';
|
|
571
|
+
for (const axiom of inlineAxioms) {
|
|
572
|
+
const relFile = path.relative(this.projectDir, axiom.file);
|
|
573
|
+
content += `- \`${axiom.module}\` (${relFile}:${axiom.line})\n`;
|
|
574
|
+
content += ` \`\`\`dafny\n ${axiom.content}\n \`\`\`\n\n`;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
fs.writeFileSync(assumptionsPath, content);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async verifyAndCompile() {
|
|
582
|
+
// If already running, wait for the in-flight result instead of spawning another process
|
|
583
|
+
if (this._verifyPromise) {
|
|
584
|
+
return this._verifyPromise;
|
|
585
|
+
}
|
|
586
|
+
this._verifyPromise = this._doVerifyAndCompile();
|
|
587
|
+
try {
|
|
588
|
+
return await this._verifyPromise;
|
|
589
|
+
} finally {
|
|
590
|
+
this._verifyPromise = null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async _doVerifyAndCompile() {
|
|
595
|
+
const timestamp = () => new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
596
|
+
console.log(`[${timestamp()}] Change detected, verifying...`);
|
|
597
|
+
|
|
598
|
+
this.writeStatus({ state: 'verifying', files: {}, axioms: [], compiled: false });
|
|
599
|
+
|
|
600
|
+
// Run verification
|
|
601
|
+
const { passed, fileStatuses, axioms } = await this.verify();
|
|
602
|
+
|
|
603
|
+
if (!passed) {
|
|
604
|
+
const errorCount = Object.values(fileStatuses).reduce((sum, f) => sum + f.errors.length, 0);
|
|
605
|
+
const warningCount = Object.values(fileStatuses).reduce((sum, f) => sum + (f.warnings?.length || 0), 0);
|
|
606
|
+
if (errorCount === 0 && warningCount > 0) {
|
|
607
|
+
console.log(`[${timestamp()}] Verification passed, compilation blocked (${warningCount} warning${warningCount !== 1 ? 's' : ''})`);
|
|
608
|
+
} else {
|
|
609
|
+
console.log(`[${timestamp()}] Verification failed (${errorCount} error${errorCount !== 1 ? 's' : ''})`);
|
|
610
|
+
}
|
|
611
|
+
this.writeStatus({
|
|
612
|
+
state: 'error',
|
|
613
|
+
files: fileStatuses,
|
|
614
|
+
axioms,
|
|
615
|
+
compiled: false,
|
|
616
|
+
});
|
|
617
|
+
return { verified: false, fileStatuses, axioms };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
console.log(`[${timestamp()}] Verification passed, compiling...`);
|
|
621
|
+
|
|
622
|
+
// Update status to compiling
|
|
623
|
+
this.writeStatus({
|
|
624
|
+
state: 'compiling',
|
|
625
|
+
files: fileStatuses,
|
|
626
|
+
axioms,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// Run compilation
|
|
630
|
+
const { success, error } = await this.compile();
|
|
631
|
+
|
|
632
|
+
if (!success) {
|
|
633
|
+
console.log(`[${timestamp()}] Compilation failed: ${error}`);
|
|
634
|
+
this.writeStatus({
|
|
635
|
+
state: 'error',
|
|
636
|
+
files: fileStatuses,
|
|
637
|
+
axioms,
|
|
638
|
+
compiled: false,
|
|
639
|
+
compileError: error,
|
|
640
|
+
});
|
|
641
|
+
return { verified: true, compiled: false, error, fileStatuses, axioms };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Extract claims for spec matching
|
|
645
|
+
const claimsResult = await this.extractClaims();
|
|
646
|
+
if (!claimsResult.success) {
|
|
647
|
+
console.log(`[${timestamp()}] Claims extraction failed: ${claimsResult.error}`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Extract logic surface
|
|
651
|
+
const surfaceResult = await this.extractLogicSurface();
|
|
652
|
+
if (!surfaceResult.success) {
|
|
653
|
+
console.log(`[${timestamp()}] Logic surface extraction failed: ${surfaceResult.error}`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const axiomNote = axioms.length > 0 ? ` (${axioms.length} axioms)` : '';
|
|
657
|
+
console.log(`[${timestamp()}] Verified and compiled${axiomNote}`);
|
|
658
|
+
|
|
659
|
+
this.writeStatus({
|
|
660
|
+
state: 'verified',
|
|
661
|
+
files: fileStatuses,
|
|
662
|
+
axioms,
|
|
663
|
+
compiled: true,
|
|
664
|
+
lastCompiled: new Date().toISOString(),
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
return { verified: true, compiled: true, fileStatuses, axioms };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// --- WebSocket relay methods ---
|
|
671
|
+
|
|
672
|
+
connectRelay() {
|
|
673
|
+
let url = this.config.server;
|
|
674
|
+
if (!url) return;
|
|
675
|
+
|
|
676
|
+
// Append secret as query param for authentication
|
|
677
|
+
if (this.config.secret) {
|
|
678
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
679
|
+
url = `${url}${sep}secret=${encodeURIComponent(this.config.secret)}`;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
console.log(`Relay: connecting to ${this.config.server}`);
|
|
683
|
+
const ws = new WebSocket(url);
|
|
684
|
+
this.relayWs = ws;
|
|
685
|
+
|
|
686
|
+
ws.on('open', () => {
|
|
687
|
+
console.log('Relay: connected');
|
|
688
|
+
this.relayConnected = true;
|
|
689
|
+
this.relayRetryDelay = 1000;
|
|
690
|
+
|
|
691
|
+
// Push current state on connect
|
|
692
|
+
try {
|
|
693
|
+
const status = JSON.parse(fs.readFileSync(this.statusPath, 'utf8'));
|
|
694
|
+
this.relaySend({ type: 'stateUpdate', key: 'status', payload: status });
|
|
695
|
+
} catch {}
|
|
696
|
+
|
|
697
|
+
const spec = this.readSpecFile();
|
|
698
|
+
if (spec) {
|
|
699
|
+
this.relaySend({ type: 'stateUpdate', key: 'spec', payload: spec });
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Push config and project info
|
|
703
|
+
this.relaySend({ type: 'stateUpdate', key: 'config', payload: this.config });
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
ws.on('message', (data) => {
|
|
707
|
+
try {
|
|
708
|
+
const msg = JSON.parse(data);
|
|
709
|
+
this.handleRelayCommand(msg);
|
|
710
|
+
} catch (err) {
|
|
711
|
+
console.error('Relay: bad message', err.message);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
ws.on('close', () => {
|
|
716
|
+
this.relayConnected = false;
|
|
717
|
+
console.log(`Relay: disconnected, retrying in ${this.relayRetryDelay}ms`);
|
|
718
|
+
setTimeout(() => this.connectRelay(), this.relayRetryDelay);
|
|
719
|
+
this.relayRetryDelay = Math.min(this.relayRetryDelay * 2, 30000);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
ws.on('error', (err) => {
|
|
723
|
+
console.error('Relay: error', err.message || err.code || err);
|
|
724
|
+
// close event will handle reconnect
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
relaySend(msg) {
|
|
729
|
+
if (!this.relayConnected || !this.relayWs) return;
|
|
730
|
+
try {
|
|
731
|
+
this.relayWs.send(JSON.stringify(msg));
|
|
732
|
+
} catch {}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async handleRelayCommand(msg) {
|
|
736
|
+
const { id, type, payload } = msg;
|
|
737
|
+
if (!id || !type) return;
|
|
738
|
+
if (!type.includes('File')) {
|
|
739
|
+
console.log(`Relay: command ${type} (id=${id})`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
try {
|
|
743
|
+
let result;
|
|
744
|
+
switch (type) {
|
|
745
|
+
case 'spawnClaude': {
|
|
746
|
+
const { prompt, model, maxTurns, effort } = payload || {};
|
|
747
|
+
console.log(`Relay: spawnClaude (model=${model || 'haiku'}, prompt=${prompt.slice(0, 80)}...)`);
|
|
748
|
+
const output = await spawnClaude(prompt, this.projectDir, { model, maxTurns, effort });
|
|
749
|
+
console.log(`Relay: spawnClaude done (${output.length} chars)`);
|
|
750
|
+
result = output;
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
case 'readFile': {
|
|
754
|
+
const filePath = path.resolve(this.projectDir, payload.path);
|
|
755
|
+
if (!filePath.startsWith(this.projectDir + path.sep) && filePath !== this.projectDir) {
|
|
756
|
+
throw new Error('Path traversal not allowed');
|
|
757
|
+
}
|
|
758
|
+
if (!fs.existsSync(filePath)) {
|
|
759
|
+
result = null;
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
result = fs.readFileSync(filePath, 'utf8');
|
|
763
|
+
break;
|
|
764
|
+
}
|
|
765
|
+
case 'writeFile': {
|
|
766
|
+
const filePath = path.resolve(this.projectDir, payload.path);
|
|
767
|
+
if (!filePath.startsWith(this.projectDir + path.sep)) {
|
|
768
|
+
throw new Error('Path traversal not allowed');
|
|
769
|
+
}
|
|
770
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
771
|
+
fs.writeFileSync(filePath, payload.content);
|
|
772
|
+
result = 'ok';
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
case 'listFiles': {
|
|
776
|
+
const globDir = payload?.dir || '.';
|
|
777
|
+
const target = path.resolve(this.projectDir, globDir);
|
|
778
|
+
if (!target.startsWith(this.projectDir + path.sep) && target !== this.projectDir) {
|
|
779
|
+
throw new Error('Path traversal not allowed');
|
|
780
|
+
}
|
|
781
|
+
result = this.globDir(target);
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
case 'apiProxy': {
|
|
785
|
+
// Generic fallback: read/write .vibe JSON files based on API path
|
|
786
|
+
const { path: apiPath, method, body } = payload || {};
|
|
787
|
+
const vibeFile = this.resolveVibeFile(apiPath);
|
|
788
|
+
if (!vibeFile) { result = null; break; }
|
|
789
|
+
if (method === 'GET') {
|
|
790
|
+
if (!fs.existsSync(vibeFile)) { result = null; break; }
|
|
791
|
+
try { result = JSON.parse(fs.readFileSync(vibeFile, 'utf8')); } catch { result = fs.readFileSync(vibeFile, 'utf8'); }
|
|
792
|
+
} else {
|
|
793
|
+
fs.mkdirSync(path.dirname(vibeFile), { recursive: true });
|
|
794
|
+
fs.writeFileSync(vibeFile, JSON.stringify(body, null, 2));
|
|
795
|
+
result = { ok: true };
|
|
796
|
+
}
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
default:
|
|
800
|
+
throw new Error(`Unknown command: ${type}`);
|
|
801
|
+
}
|
|
802
|
+
this.relaySend({ id, type: 'result', payload: result });
|
|
803
|
+
} catch (err) {
|
|
804
|
+
console.error(`Relay: command ${type} error:`, err.message);
|
|
805
|
+
this.relaySend({ id, type: 'error', payload: err.message });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
resolveVibeFile(apiPath) {
|
|
810
|
+
// Map API paths like /api/foo/bar to .vibe/foo-bar.json
|
|
811
|
+
if (!apiPath || !apiPath.startsWith('/api/')) return null;
|
|
812
|
+
const slug = apiPath.replace('/api/', '').replace(/\//g, '-');
|
|
813
|
+
const filePath = path.join(this.vibeDir, `${slug}.json`);
|
|
814
|
+
if (!filePath.startsWith(this.vibeDir + path.sep) && filePath !== this.vibeDir) return null;
|
|
815
|
+
return filePath;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
globDir(dir) {
|
|
819
|
+
const results = [];
|
|
820
|
+
if (!fs.existsSync(dir)) return results;
|
|
821
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
822
|
+
const full = path.join(dir, entry.name);
|
|
823
|
+
const rel = path.relative(this.projectDir, full);
|
|
824
|
+
if (entry.isDirectory()) {
|
|
825
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
826
|
+
results.push(...this.globDir(full));
|
|
827
|
+
} else {
|
|
828
|
+
results.push(rel);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return results;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async watch() {
|
|
835
|
+
console.log(`Lemmafit daemon watching: ${this.projectDir}`);
|
|
836
|
+
console.log(` Dafny files: ${this.dafnyDir}`);
|
|
837
|
+
console.log(` Status: ${this.statusPath}`);
|
|
838
|
+
if (this.isMultiModule()) {
|
|
839
|
+
console.log(` Modules: ${this.modules.map(m => m.outputName).join(', ')}`);
|
|
840
|
+
} else {
|
|
841
|
+
console.log(` Entry: ${this.config.entry}`);
|
|
842
|
+
}
|
|
843
|
+
console.log(` Dafny: ${this.dafnyPath}`);
|
|
844
|
+
if (this.config.server) {
|
|
845
|
+
console.log(` Relay: ${this.config.server}`);
|
|
846
|
+
}
|
|
847
|
+
console.log('');
|
|
848
|
+
|
|
849
|
+
// Start WS relay if configured
|
|
850
|
+
if (this.config.server) {
|
|
851
|
+
this.connectRelay();
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Start Unix domain socket server for hook communication
|
|
855
|
+
this.startSocketServer();
|
|
856
|
+
|
|
857
|
+
this._lastHash = null;
|
|
858
|
+
this._lastSpecHash = null;
|
|
859
|
+
|
|
860
|
+
// Detect offline spec changes and seed baseline
|
|
861
|
+
const initialContent = this.readSpecFile();
|
|
862
|
+
if (initialContent) {
|
|
863
|
+
let status = {};
|
|
864
|
+
try {
|
|
865
|
+
status = JSON.parse(fs.readFileSync(this.statusPath, 'utf8'));
|
|
866
|
+
} catch {}
|
|
867
|
+
|
|
868
|
+
const ackedContent = status.ackedSpecContent || status.specContent || null;
|
|
869
|
+
|
|
870
|
+
if (!ackedContent) {
|
|
871
|
+
// First run ever — seed both baselines, no queue needed
|
|
872
|
+
status.specContent = initialContent;
|
|
873
|
+
status.ackedSpecContent = initialContent;
|
|
874
|
+
status.specQueue = [];
|
|
875
|
+
this.writeStatus(status);
|
|
876
|
+
} else if (ackedContent !== initialContent) {
|
|
877
|
+
// SPEC.yaml changed while daemon was off — populate queue
|
|
878
|
+
console.log('Detected offline SPEC.yaml changes, populating queue...');
|
|
879
|
+
this.onSpecChanged(initialContent);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
this._lastSpecHash = this.hashSpecFile();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const poll = async () => {
|
|
886
|
+
try {
|
|
887
|
+
const currentHash = this.hashDafnyFiles();
|
|
888
|
+
const currentSpecHash = this.hashSpecFile();
|
|
889
|
+
|
|
890
|
+
const dafnyChanged = currentHash && currentHash !== this._lastHash;
|
|
891
|
+
const specChanged = currentSpecHash && currentSpecHash !== this._lastSpecHash;
|
|
892
|
+
|
|
893
|
+
if (dafnyChanged) {
|
|
894
|
+
this._lastHash = currentHash;
|
|
895
|
+
this._lastSpecHash = currentSpecHash;
|
|
896
|
+
await this.verifyAndCompile();
|
|
897
|
+
} else if (specChanged) {
|
|
898
|
+
this._lastSpecHash = currentSpecHash;
|
|
899
|
+
this.onSpecChanged(this.readSpecFile());
|
|
900
|
+
}
|
|
901
|
+
} catch (err) {
|
|
902
|
+
console.error('Error:', err.message);
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
// Initial check
|
|
907
|
+
await poll();
|
|
908
|
+
|
|
909
|
+
// Watch loop
|
|
910
|
+
setInterval(poll, this.pollInterval);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
startSocketServer() {
|
|
914
|
+
const sockPath = path.join(this.vibeDir, 'daemon.sock');
|
|
915
|
+
|
|
916
|
+
// Clean up stale socket from previous crash
|
|
917
|
+
try { fs.unlinkSync(sockPath); } catch {}
|
|
918
|
+
|
|
919
|
+
this._socketServer = net.createServer((conn) => {
|
|
920
|
+
let buffer = '';
|
|
921
|
+
conn.on('data', (chunk) => {
|
|
922
|
+
buffer += chunk.toString();
|
|
923
|
+
const newlineIdx = buffer.indexOf('\n');
|
|
924
|
+
if (newlineIdx === -1) return;
|
|
925
|
+
|
|
926
|
+
const line = buffer.slice(0, newlineIdx);
|
|
927
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
928
|
+
|
|
929
|
+
let msg;
|
|
930
|
+
try { msg = JSON.parse(line); } catch {
|
|
931
|
+
conn.end(JSON.stringify({ error: 'Invalid JSON' }) + '\n');
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
this.handleSocketMessage(msg).then((result) => {
|
|
936
|
+
conn.end(JSON.stringify(result) + '\n');
|
|
937
|
+
}).catch((err) => {
|
|
938
|
+
conn.end(JSON.stringify({ error: err.message }) + '\n');
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
this._socketServer.listen(sockPath, () => {
|
|
944
|
+
console.log(` Socket: ${sockPath}`);
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
this._socketServer.on('error', (err) => {
|
|
948
|
+
console.error('Socket server error:', err.message);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
// Cleanup on exit
|
|
952
|
+
const cleanup = () => {
|
|
953
|
+
try { fs.unlinkSync(sockPath); } catch {}
|
|
954
|
+
process.exit();
|
|
955
|
+
};
|
|
956
|
+
process.on('SIGINT', cleanup);
|
|
957
|
+
process.on('SIGTERM', cleanup);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async handleSocketMessage(msg) {
|
|
961
|
+
if (msg.action === 'verify') {
|
|
962
|
+
await this.verifyAndCompile();
|
|
963
|
+
// Update hashes so poll loop doesn't re-verify
|
|
964
|
+
this._lastHash = this.hashDafnyFiles();
|
|
965
|
+
this._lastSpecHash = this.hashSpecFile();
|
|
966
|
+
// Read and return current status
|
|
967
|
+
try {
|
|
968
|
+
return JSON.parse(fs.readFileSync(this.statusPath, 'utf8'));
|
|
969
|
+
} catch {
|
|
970
|
+
return { error: 'Could not read status after verification' };
|
|
971
|
+
}
|
|
972
|
+
} else if (msg.action === 'specChanged' && msg.content) {
|
|
973
|
+
this.onSpecChanged(msg.content);
|
|
974
|
+
this._lastSpecHash = this.hashSpecFile();
|
|
975
|
+
try {
|
|
976
|
+
return JSON.parse(fs.readFileSync(this.statusPath, 'utf8'));
|
|
977
|
+
} catch {
|
|
978
|
+
return { error: 'Could not read status after spec change' };
|
|
979
|
+
}
|
|
980
|
+
} else {
|
|
981
|
+
return { error: `Unknown action: ${msg.action}` };
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async runOnce() {
|
|
986
|
+
return this.verifyAndCompile();
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
module.exports = { Daemon };
|