@programinglive/dev-workflow-mcp-server 1.5.2 → 1.5.4

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 (78) hide show
  1. package/.agent/workflows/dev-workflow-bugfix.md +250 -250
  2. package/.agent/workflows/dev-workflow-feature.md +201 -201
  3. package/.agent/workflows/dev-workflow-full-cycle.md +400 -400
  4. package/.agent/workflows/dev-workflow-refactor.md +238 -238
  5. package/.github/ISSUE_TEMPLATE/bug_report.md +34 -34
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +25 -25
  7. package/.github/pull_request_template.md +22 -22
  8. package/.github/workflows/github-release.yml +56 -0
  9. package/.github/workflows/npm-publish.yml +50 -50
  10. package/.husky/commit-msg +0 -0
  11. package/CHANGELOG.md +759 -741
  12. package/CODE_OF_CONDUCT.md +79 -79
  13. package/CONTRIBUTING.md +60 -60
  14. package/LICENSE +21 -21
  15. package/README.md +632 -632
  16. package/SECURITY.md +20 -20
  17. package/db/index.js +204 -204
  18. package/docs/NETLIFY_DEPLOYMENT.md +125 -125
  19. package/docs/PLESK_DEPLOYMENT.md +170 -170
  20. package/docs/WORKFLOW-BEST-PRACTICES.md +58 -58
  21. package/docs/antigravity/EXAMPLES.md +535 -535
  22. package/docs/antigravity/GETTING-STARTED.md +292 -292
  23. package/docs/antigravity/TROUBLESHOOTING.md +742 -742
  24. package/docs/antigravity/WALKTHROUGH.md +25 -25
  25. package/docs/antigravity/WORKFLOW-TEMPLATES.md +670 -670
  26. package/docs/product/PRD.md +67 -67
  27. package/docs/release-notes/RELEASE_NOTES.md +233 -226
  28. package/docs/web-dashboard.md +175 -175
  29. package/exec.js +49 -49
  30. package/index.js +279 -279
  31. package/install.js +73 -73
  32. package/package.json +4 -4
  33. package/release-wrapper.js +192 -192
  34. package/scripts/build-docs.js +63 -63
  35. package/scripts/extract-release-notes.cjs +78 -0
  36. package/scripts/hello-world.js +1 -1
  37. package/scripts/release.cjs +252 -252
  38. package/scripts/release.js +252 -0
  39. package/scripts/update-release-notes.cjs +178 -178
  40. package/scripts/update-release-notes.js +182 -0
  41. package/server.json +23 -23
  42. package/tailwind.config.js +8 -8
  43. package/tests/build.test.js +83 -83
  44. package/tests/exec.test.js +35 -35
  45. package/tests/handlers.test.js +1058 -1058
  46. package/tests/hello-world.test.js +13 -13
  47. package/tests/mcpRegistry.test.js +65 -65
  48. package/tests/patch-release.test.js +7 -0
  49. package/tests/release-wrapper.test.js +107 -107
  50. package/tests/workflowState.test.js +505 -505
  51. package/tools/definitions.js +332 -332
  52. package/tools/handlers.js +1019 -1019
  53. package/vite.config.js +43 -43
  54. package/vite.web.config.js +23 -23
  55. package/web/README.md +36 -36
  56. package/web/app/docs/page.tsx +202 -202
  57. package/web/app/globals.css +90 -90
  58. package/web/app/layout.tsx +36 -36
  59. package/web/app/page.tsx +15 -15
  60. package/web/components/ApiReference.tsx +20 -20
  61. package/web/components/Features.tsx +61 -61
  62. package/web/components/Footer.tsx +9 -9
  63. package/web/components/Hero.tsx +96 -96
  64. package/web/components/History.tsx +143 -143
  65. package/web/components/Navbar.tsx +64 -64
  66. package/web/components/Terminal.tsx +114 -114
  67. package/web/eslint.config.mjs +18 -18
  68. package/web/lib/db.js +131 -131
  69. package/web/lib/docs.js +73 -73
  70. package/web/netlify.toml +3 -3
  71. package/web/next.config.ts +12 -12
  72. package/web/package-lock.json +6560 -6560
  73. package/web/package.json +28 -28
  74. package/web/postcss.config.mjs +9 -9
  75. package/web/server.js +31 -31
  76. package/web/tailwind.config.ts +19 -19
  77. package/web/tsconfig.json +42 -42
  78. package/workflow-state.js +612 -612
package/workflow-state.js CHANGED
@@ -1,612 +1,612 @@
1
- import fs from "fs/promises";
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
3
- import path from "path";
4
- import { fileURLToPath } from "url";
5
-
6
- const __filename = fileURLToPath(import.meta.url);
7
- const __dirname = path.dirname(__filename);
8
- const STATE_DIR = ".state";
9
- const STATE_FILENAME = "workflow-state.json";
10
- const LEGACY_STATE_FILENAME = ".workflow-state.json";
11
- const PROJECT_SUMMARY_FILENAME = "project-summary.json";
12
- const USER_ID_FILENAME = "user-id";
13
-
14
- let cachedUserId = null;
15
- let cachedUserStateDir = null;
16
-
17
- function generateUserId() {
18
- const timestamp = Date.now().toString(16);
19
- const randomSuffix = Math.random().toString(16).slice(2, 10);
20
- return `user-${timestamp}-${randomSuffix}`;
21
- }
22
-
23
- function resolveUserId(stateDir) {
24
- const envUserId = (process.env.DEV_WORKFLOW_USER_ID || "").trim();
25
- if (envUserId) {
26
- cachedUserId = envUserId;
27
- cachedUserStateDir = stateDir;
28
- return envUserId;
29
- }
30
-
31
- if (cachedUserId && cachedUserStateDir === stateDir) {
32
- return cachedUserId;
33
- }
34
-
35
- const userIdFile = path.join(stateDir, USER_ID_FILENAME);
36
-
37
- try {
38
- if (existsSync(userIdFile)) {
39
- const stored = readFileSync(userIdFile, "utf-8").trim();
40
- if (stored) {
41
- cachedUserId = stored;
42
- cachedUserStateDir = stateDir;
43
- return stored;
44
- }
45
- }
46
- } catch {
47
- // Ignore read errors; we'll generate a new ID below.
48
- }
49
-
50
- const usersDir = path.join(stateDir, "users");
51
-
52
- try {
53
- const entries = readdirSync(usersDir, { withFileTypes: true });
54
- const candidateDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
55
- if (candidateDirs.length === 1) {
56
- const legacyId = candidateDirs[0];
57
- try {
58
- mkdirSync(stateDir, { recursive: true });
59
- writeFileSync(userIdFile, legacyId, "utf-8");
60
- } catch {
61
- // Persisting the legacy ID is best-effort.
62
- }
63
- cachedUserId = legacyId;
64
- cachedUserStateDir = stateDir;
65
- return legacyId;
66
- }
67
- } catch (error) {
68
- if (error?.code !== "ENOENT") {
69
- // Unexpected error accessing users directory – fall through to generate new ID.
70
- }
71
- }
72
-
73
- const legacyDefaultPath = path.join(usersDir, "default", STATE_FILENAME);
74
- if (existsSync(legacyDefaultPath)) {
75
- try {
76
- mkdirSync(stateDir, { recursive: true });
77
- writeFileSync(userIdFile, "default", "utf-8");
78
- } catch {
79
- // Best-effort persistence; continue with in-memory fallback.
80
- }
81
- cachedUserId = "default";
82
- cachedUserStateDir = stateDir;
83
- return "default";
84
- }
85
-
86
- const generatedId = generateUserId();
87
- try {
88
- mkdirSync(stateDir, { recursive: true });
89
- writeFileSync(userIdFile, generatedId, "utf-8");
90
- } catch {
91
- // Ignore persistence errors; continue with in-memory ID.
92
- }
93
-
94
- cachedUserId = generatedId;
95
- cachedUserStateDir = stateDir;
96
- return generatedId;
97
- }
98
-
99
- function hasProjectMarkers(candidate) {
100
- if (!candidate) {
101
- return false;
102
- }
103
-
104
- const resolved = path.resolve(candidate);
105
- return (
106
- existsSync(path.join(resolved, "package.json")) || existsSync(path.join(resolved, ".git"))
107
- );
108
- }
109
-
110
- function normalizeAndStoreCandidate(list, candidate) {
111
- if (!candidate) {
112
- return;
113
- }
114
-
115
- const resolved = path.resolve(candidate);
116
- if (!list.includes(resolved)) {
117
- list.push(resolved);
118
- }
119
- }
120
-
121
- function addAncestorCandidates(list, start) {
122
- if (!start) {
123
- return;
124
- }
125
-
126
- let current = path.resolve(start);
127
- while (!isRootPath(current)) {
128
- const parent = path.dirname(current);
129
- if (parent === current) {
130
- break;
131
- }
132
- if (!isRootPath(parent)) {
133
- normalizeAndStoreCandidate(list, parent);
134
- }
135
- current = parent;
136
- }
137
- }
138
-
139
- function selectProjectRoot(candidates, moduleRoot) {
140
- const cwdResolved = path.resolve(process.cwd());
141
- const prioritized = [];
142
-
143
- for (const candidate of candidates) {
144
- if (!candidate) {
145
- continue;
146
- }
147
-
148
- const resolved = path.resolve(candidate);
149
- if (prioritized.some((entry) => entry.resolved === resolved)) {
150
- continue;
151
- }
152
-
153
- prioritized.push({
154
- resolved,
155
- hasMarkers: hasProjectMarkers(resolved),
156
- isRoot: isRootPath(resolved),
157
- isCwdAncestor: cwdResolved === resolved || cwdResolved.startsWith(`${resolved}${path.sep}`),
158
- });
159
- }
160
-
161
- const ancestorWithMarkers = prioritized.find((entry) => entry.isCwdAncestor && entry.hasMarkers);
162
- if (ancestorWithMarkers) {
163
- return ancestorWithMarkers.resolved;
164
- }
165
-
166
- const ancestorWithoutMarkers = prioritized
167
- .filter((entry) => entry.isCwdAncestor && !entry.hasMarkers && !entry.isRoot)
168
- .sort((a, b) => b.resolved.length - a.resolved.length);
169
- if (ancestorWithoutMarkers.length > 0) {
170
- return ancestorWithoutMarkers[0].resolved;
171
- }
172
-
173
- if (moduleRoot) {
174
- const moduleResolved = path.resolve(moduleRoot);
175
- const moduleEntry = prioritized.find((entry) => entry.resolved === moduleResolved && !entry.isRoot);
176
- if (moduleEntry) {
177
- return moduleEntry.resolved;
178
- }
179
- }
180
-
181
- const withMarkers = prioritized.find((entry) => entry.hasMarkers);
182
- if (withMarkers) {
183
- return withMarkers.resolved;
184
- }
185
-
186
- const nonRoot = prioritized.find((entry) => !entry.isRoot);
187
- if (nonRoot) {
188
- return nonRoot.resolved;
189
- }
190
-
191
- return prioritized.length > 0 ? prioritized[0].resolved : null;
192
- }
193
-
194
- function getUserScopedDir(baseDir) {
195
- const userId = resolveUserId(baseDir);
196
- return path.join(baseDir, "users", userId);
197
- }
198
-
199
- function getCurrentUserId() {
200
- const envUserId = (process.env.DEV_WORKFLOW_USER_ID || "").trim();
201
- if (envUserId) {
202
- return envUserId;
203
- }
204
-
205
- if (cachedUserId) {
206
- return cachedUserId;
207
- }
208
-
209
- if (cachedUserStateDir) {
210
- return resolveUserId(cachedUserStateDir);
211
- }
212
-
213
- return "default";
214
- }
215
-
216
- async function migrateLegacyStateFile(targetPath) {
217
- const stateDir = path.dirname(targetPath);
218
- if (path.basename(stateDir) !== STATE_DIR) {
219
- return;
220
- }
221
-
222
- const projectRoot = path.dirname(stateDir);
223
- const legacyPath = path.join(projectRoot, LEGACY_STATE_FILENAME);
224
-
225
- if (legacyPath === targetPath) {
226
- return;
227
- }
228
-
229
- try {
230
- await fs.access(legacyPath);
231
- } catch {
232
- return;
233
- }
234
-
235
- await fs.mkdir(stateDir, { recursive: true });
236
-
237
- try {
238
- await fs.access(targetPath);
239
- await fs.unlink(legacyPath);
240
- return;
241
- } catch {
242
- // New state file doesn't exist yet; fall through to migrate.
243
- }
244
-
245
- await fs.rename(legacyPath, targetPath);
246
- }
247
-
248
- function isRootPath(candidate) {
249
- if (!candidate) {
250
- return true;
251
- }
252
-
253
- const resolved = path.resolve(candidate);
254
- return path.parse(resolved).root === resolved;
255
- }
256
-
257
- function findProjectRoot(startDir) {
258
- if (!startDir) {
259
- return null;
260
- }
261
-
262
- let current = path.resolve(startDir);
263
- const { root } = path.parse(current);
264
-
265
- while (true) {
266
- const hasPackageJson = existsSync(path.join(current, "package.json"));
267
- const hasGitDir = existsSync(path.join(current, ".git"));
268
- if (hasPackageJson || hasGitDir) {
269
- return current;
270
- }
271
-
272
- if (current === root) {
273
- return null;
274
- }
275
-
276
- const parent = path.dirname(current);
277
- if (parent === current) {
278
- return null;
279
- }
280
-
281
- current = parent;
282
- }
283
- }
284
-
285
- function getCompatibilityPaths(primaryPath) {
286
- const resolvedPrimary = path.resolve(primaryPath);
287
- const candidates = new Set();
288
- const moduleDir = __dirname;
289
-
290
- const directCandidate = path.resolve(moduleDir, STATE_DIR, STATE_FILENAME);
291
- if (directCandidate !== resolvedPrimary) {
292
- candidates.add(directCandidate);
293
- }
294
-
295
- const possibleDistDirs = [
296
- path.join(moduleDir, "dist"),
297
- path.join(path.dirname(moduleDir), "dist"),
298
- ];
299
-
300
- for (const distDir of possibleDistDirs) {
301
- const candidate = path.resolve(distDir, STATE_DIR, STATE_FILENAME);
302
- if (candidate !== resolvedPrimary) {
303
- candidates.add(candidate);
304
- }
305
- }
306
-
307
- return Array.from(candidates);
308
- }
309
-
310
- async function pathExists(filePath) {
311
- try {
312
- await fs.access(filePath);
313
- return true;
314
- } catch {
315
- return false;
316
- }
317
- }
318
-
319
- async function ensureCompatibilityLinks(primaryPath) {
320
- const resolvedPrimary = path.resolve(primaryPath);
321
-
322
- if (!(await pathExists(resolvedPrimary))) {
323
- return;
324
- }
325
-
326
- const compatibilityPaths = getCompatibilityPaths(resolvedPrimary);
327
-
328
- for (const compatPath of compatibilityPaths) {
329
- try {
330
- await fs.mkdir(path.dirname(compatPath), { recursive: true });
331
- } catch {
332
- // Ignore directory creation errors; we'll surface them on actual writes.
333
- }
334
-
335
- try {
336
- const compatStat = await fs.lstat(compatPath);
337
- if (compatStat.isSymbolicLink()) {
338
- const target = await fs.readlink(compatPath);
339
- if (path.resolve(path.dirname(compatPath), target) === resolvedPrimary) {
340
- continue;
341
- }
342
- await fs.unlink(compatPath);
343
- } else if (compatStat.isFile()) {
344
- const [primaryStat, existingStat] = await Promise.all([
345
- fs.stat(resolvedPrimary),
346
- fs.stat(compatPath),
347
- ]);
348
- if (primaryStat.dev === existingStat.dev && primaryStat.ino === existingStat.ino) {
349
- continue;
350
- }
351
- await fs.unlink(compatPath);
352
- } else {
353
- await fs.unlink(compatPath);
354
- }
355
- } catch (error) {
356
- if (error.code !== "ENOENT") {
357
- // Unexpected error accessing legacy path; skip mirroring to avoid breaking primary file.
358
- continue;
359
- }
360
- }
361
-
362
- try {
363
- await fs.link(resolvedPrimary, compatPath);
364
- continue;
365
- } catch (error) {
366
- if (!["EEXIST", "EXDEV", "EPERM"].includes(error.code)) {
367
- continue;
368
- }
369
- if (error.code === "EEXIST") {
370
- continue;
371
- }
372
- }
373
-
374
- try {
375
- await fs.symlink(resolvedPrimary, compatPath);
376
- continue;
377
- } catch (error) {
378
- if (error.code === "EEXIST") {
379
- continue;
380
- }
381
- if (!["EPERM", "ENOTSUP", "EEXIST"].includes(error.code)) {
382
- // If symlink fails for another reason, skip to avoid interfering with primary file.
383
- continue;
384
- }
385
- }
386
-
387
- try {
388
- await fs.copyFile(resolvedPrimary, compatPath);
389
- } catch {
390
- // Final fallback failed; ignore to avoid disrupting primary save.
391
- }
392
- }
393
- }
394
-
395
- export function resolveStateFile() {
396
- const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
397
- if (customPath) {
398
- return path.resolve(customPath);
399
- }
400
-
401
- const moduleRoot = path.resolve(__dirname, "..");
402
- const cwdResolved = path.resolve(process.cwd());
403
- const initCwd = process.env.INIT_CWD ? path.resolve(process.env.INIT_CWD) : null;
404
-
405
- // 1. Prefer the project rooted at the current working directory
406
- let projectRoot = findProjectRoot(cwdResolved);
407
-
408
- // 2. Fall back to the project rooted at INIT_CWD if different
409
- if (!projectRoot && initCwd) {
410
- projectRoot = findProjectRoot(initCwd);
411
- }
412
-
413
- // 3. Fall back to the project containing this module
414
- if (!projectRoot) {
415
- projectRoot = findProjectRoot(moduleRoot);
416
- }
417
-
418
- // 4. Final fallback: use a non-root working directory or the module root
419
- if (!projectRoot) {
420
- projectRoot = !isRootPath(cwdResolved) ? cwdResolved : moduleRoot;
421
- }
422
-
423
- const stateDir = path.join(projectRoot, STATE_DIR);
424
- const userDir = getUserScopedDir(stateDir);
425
- return path.join(userDir, STATE_FILENAME);
426
- }
427
-
428
- export class WorkflowState {
429
- constructor(stateFilePath = resolveStateFile()) {
430
- this.stateFile = stateFilePath;
431
- this.state = {
432
- currentPhase: "idle",
433
- taskDescription: "",
434
- bugFixed: false,
435
- testsCreated: false,
436
- testsPassed: false,
437
- testsSkipped: false,
438
- testsSkippedReason: "",
439
- documentationCreated: false,
440
- readyToCommit: false,
441
- readyCheckCompleted: false,
442
- released: false,
443
- releaseCommand: "",
444
- releaseNotes: "",
445
- commitAndPushCompleted: false,
446
- lastCommitMessage: "",
447
- lastPushBranch: "",
448
- history: [],
449
- taskType: "",
450
- };
451
- }
452
-
453
- async load() {
454
- await migrateLegacyStateFile(this.stateFile);
455
- try {
456
- const data = await fs.readFile(this.stateFile, "utf-8");
457
- const parsed = JSON.parse(data);
458
- this.state = {
459
- ...this.state,
460
- ...parsed,
461
- };
462
- // Ensure compatibility mirrors exist whenever the primary file is present
463
- await ensureCompatibilityLinks(this.stateFile);
464
- if (typeof this.state.testsCreated !== "boolean") {
465
- this.state.testsCreated = false;
466
- }
467
- if (typeof this.state.testsSkipped !== "boolean") {
468
- this.state.testsSkipped = false;
469
- }
470
- if (typeof this.state.testsSkippedReason !== "string") {
471
- this.state.testsSkippedReason = "";
472
- }
473
- if (typeof this.state.readyCheckCompleted !== "boolean") {
474
- this.state.readyCheckCompleted = false;
475
- }
476
- if (typeof this.state.released !== "boolean") {
477
- this.state.released = false;
478
- }
479
- if (typeof this.state.commitAndPushCompleted !== "boolean") {
480
- this.state.commitAndPushCompleted = false;
481
- }
482
- if (typeof this.state.releaseCommand !== "string") {
483
- this.state.releaseCommand = "";
484
- }
485
- if (typeof this.state.releaseNotes !== "string") {
486
- this.state.releaseNotes = "";
487
- }
488
- if (typeof this.state.lastCommitMessage !== "string") {
489
- this.state.lastCommitMessage = "";
490
- }
491
- if (typeof this.state.lastPushBranch !== "string") {
492
- this.state.lastPushBranch = "";
493
- }
494
- } catch (error) {
495
- if (error?.code !== "ENOENT") {
496
- throw error;
497
- }
498
- // Missing file is fine; bootstrap fresh artifacts for downstream consumers.
499
- await this.ensurePrimaryFile();
500
- }
501
- }
502
-
503
- async ensurePrimaryFile() {
504
- let created = false;
505
- try {
506
- await fs.access(this.stateFile);
507
- } catch {
508
- await fs.mkdir(path.dirname(this.stateFile), { recursive: true });
509
- await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2));
510
- created = true;
511
- }
512
-
513
- // Always ensure a fresh project summary so dependent tooling can read it immediately.
514
- await this.updateProjectSummary();
515
- await ensureCompatibilityLinks(this.stateFile);
516
-
517
- return created;
518
- }
519
-
520
- async save() {
521
- await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2));
522
- await ensureCompatibilityLinks(this.stateFile);
523
- await this.updateProjectSummary();
524
- await this.syncToDatabase();
525
- }
526
-
527
- async updateProjectSummary() {
528
- const summaryPath = path.join(path.dirname(this.stateFile), PROJECT_SUMMARY_FILENAME);
529
- const summary = this.generateProjectSummaryData();
530
- await fs.mkdir(path.dirname(summaryPath), { recursive: true });
531
- await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
532
- }
533
-
534
- async syncToDatabase() {
535
- try {
536
- const { insertHistoryEntry, updateSummaryForUser } = await import("./db/index.js");
537
- const userId = getCurrentUserId();
538
- for (const entry of this.state.history || []) {
539
- insertHistoryEntry(userId, entry);
540
- }
541
- updateSummaryForUser(userId);
542
- } catch {
543
- // SQLite not available; skip sync
544
- }
545
- }
546
-
547
- generateProjectSummaryData() {
548
- const history = this.state.history || [];
549
- if (!history || history.length === 0) {
550
- return {
551
- totalTasks: 0,
552
- taskTypes: {},
553
- lastActive: null,
554
- recentTasks: [],
555
- updatedAt: new Date().toISOString(),
556
- };
557
- }
558
-
559
- const taskTypes = {};
560
- const recentTasks = history.slice(-20);
561
-
562
- for (const entry of recentTasks) {
563
- const type = entry.taskType || "other";
564
- taskTypes[type] = (taskTypes[type] || 0) + 1;
565
- }
566
-
567
- const totalTasks = history.length;
568
- const lastTask = history[history.length - 1];
569
- const lastActive = lastTask ? lastTask.timestamp : null;
570
-
571
- return {
572
- totalTasks,
573
- taskTypes,
574
- lastActive,
575
- recentTasks: history.slice(-5).reverse().map((e) => ({
576
- description: e.taskDescription,
577
- type: e.taskType,
578
- timestamp: e.timestamp,
579
- })),
580
- updatedAt: new Date().toISOString(),
581
- };
582
- }
583
-
584
- reset() {
585
- this.state = {
586
- currentPhase: "idle",
587
- taskDescription: "",
588
- bugFixed: false,
589
- testsCreated: false,
590
- testsPassed: false,
591
- testsSkipped: false,
592
- testsSkippedReason: "",
593
- documentationCreated: false,
594
- readyToCommit: false,
595
- readyCheckCompleted: false,
596
- released: false,
597
- releaseCommand: "",
598
- releaseNotes: "",
599
- commitAndPushCompleted: false,
600
- lastCommitMessage: "",
601
- lastPushBranch: "",
602
- history: this.state.history,
603
- };
604
- }
605
-
606
- addToHistory(entry) {
607
- this.state.history.push({
608
- ...entry,
609
- timestamp: new Date().toISOString(),
610
- });
611
- }
612
- }
1
+ import fs from "fs/promises";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ const STATE_DIR = ".state";
9
+ const STATE_FILENAME = "workflow-state.json";
10
+ const LEGACY_STATE_FILENAME = ".workflow-state.json";
11
+ const PROJECT_SUMMARY_FILENAME = "project-summary.json";
12
+ const USER_ID_FILENAME = "user-id";
13
+
14
+ let cachedUserId = null;
15
+ let cachedUserStateDir = null;
16
+
17
+ function generateUserId() {
18
+ const timestamp = Date.now().toString(16);
19
+ const randomSuffix = Math.random().toString(16).slice(2, 10);
20
+ return `user-${timestamp}-${randomSuffix}`;
21
+ }
22
+
23
+ function resolveUserId(stateDir) {
24
+ const envUserId = (process.env.DEV_WORKFLOW_USER_ID || "").trim();
25
+ if (envUserId) {
26
+ cachedUserId = envUserId;
27
+ cachedUserStateDir = stateDir;
28
+ return envUserId;
29
+ }
30
+
31
+ if (cachedUserId && cachedUserStateDir === stateDir) {
32
+ return cachedUserId;
33
+ }
34
+
35
+ const userIdFile = path.join(stateDir, USER_ID_FILENAME);
36
+
37
+ try {
38
+ if (existsSync(userIdFile)) {
39
+ const stored = readFileSync(userIdFile, "utf-8").trim();
40
+ if (stored) {
41
+ cachedUserId = stored;
42
+ cachedUserStateDir = stateDir;
43
+ return stored;
44
+ }
45
+ }
46
+ } catch {
47
+ // Ignore read errors; we'll generate a new ID below.
48
+ }
49
+
50
+ const usersDir = path.join(stateDir, "users");
51
+
52
+ try {
53
+ const entries = readdirSync(usersDir, { withFileTypes: true });
54
+ const candidateDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
55
+ if (candidateDirs.length === 1) {
56
+ const legacyId = candidateDirs[0];
57
+ try {
58
+ mkdirSync(stateDir, { recursive: true });
59
+ writeFileSync(userIdFile, legacyId, "utf-8");
60
+ } catch {
61
+ // Persisting the legacy ID is best-effort.
62
+ }
63
+ cachedUserId = legacyId;
64
+ cachedUserStateDir = stateDir;
65
+ return legacyId;
66
+ }
67
+ } catch (error) {
68
+ if (error?.code !== "ENOENT") {
69
+ // Unexpected error accessing users directory – fall through to generate new ID.
70
+ }
71
+ }
72
+
73
+ const legacyDefaultPath = path.join(usersDir, "default", STATE_FILENAME);
74
+ if (existsSync(legacyDefaultPath)) {
75
+ try {
76
+ mkdirSync(stateDir, { recursive: true });
77
+ writeFileSync(userIdFile, "default", "utf-8");
78
+ } catch {
79
+ // Best-effort persistence; continue with in-memory fallback.
80
+ }
81
+ cachedUserId = "default";
82
+ cachedUserStateDir = stateDir;
83
+ return "default";
84
+ }
85
+
86
+ const generatedId = generateUserId();
87
+ try {
88
+ mkdirSync(stateDir, { recursive: true });
89
+ writeFileSync(userIdFile, generatedId, "utf-8");
90
+ } catch {
91
+ // Ignore persistence errors; continue with in-memory ID.
92
+ }
93
+
94
+ cachedUserId = generatedId;
95
+ cachedUserStateDir = stateDir;
96
+ return generatedId;
97
+ }
98
+
99
+ function hasProjectMarkers(candidate) {
100
+ if (!candidate) {
101
+ return false;
102
+ }
103
+
104
+ const resolved = path.resolve(candidate);
105
+ return (
106
+ existsSync(path.join(resolved, "package.json")) || existsSync(path.join(resolved, ".git"))
107
+ );
108
+ }
109
+
110
+ function normalizeAndStoreCandidate(list, candidate) {
111
+ if (!candidate) {
112
+ return;
113
+ }
114
+
115
+ const resolved = path.resolve(candidate);
116
+ if (!list.includes(resolved)) {
117
+ list.push(resolved);
118
+ }
119
+ }
120
+
121
+ function addAncestorCandidates(list, start) {
122
+ if (!start) {
123
+ return;
124
+ }
125
+
126
+ let current = path.resolve(start);
127
+ while (!isRootPath(current)) {
128
+ const parent = path.dirname(current);
129
+ if (parent === current) {
130
+ break;
131
+ }
132
+ if (!isRootPath(parent)) {
133
+ normalizeAndStoreCandidate(list, parent);
134
+ }
135
+ current = parent;
136
+ }
137
+ }
138
+
139
+ function selectProjectRoot(candidates, moduleRoot) {
140
+ const cwdResolved = path.resolve(process.cwd());
141
+ const prioritized = [];
142
+
143
+ for (const candidate of candidates) {
144
+ if (!candidate) {
145
+ continue;
146
+ }
147
+
148
+ const resolved = path.resolve(candidate);
149
+ if (prioritized.some((entry) => entry.resolved === resolved)) {
150
+ continue;
151
+ }
152
+
153
+ prioritized.push({
154
+ resolved,
155
+ hasMarkers: hasProjectMarkers(resolved),
156
+ isRoot: isRootPath(resolved),
157
+ isCwdAncestor: cwdResolved === resolved || cwdResolved.startsWith(`${resolved}${path.sep}`),
158
+ });
159
+ }
160
+
161
+ const ancestorWithMarkers = prioritized.find((entry) => entry.isCwdAncestor && entry.hasMarkers);
162
+ if (ancestorWithMarkers) {
163
+ return ancestorWithMarkers.resolved;
164
+ }
165
+
166
+ const ancestorWithoutMarkers = prioritized
167
+ .filter((entry) => entry.isCwdAncestor && !entry.hasMarkers && !entry.isRoot)
168
+ .sort((a, b) => b.resolved.length - a.resolved.length);
169
+ if (ancestorWithoutMarkers.length > 0) {
170
+ return ancestorWithoutMarkers[0].resolved;
171
+ }
172
+
173
+ if (moduleRoot) {
174
+ const moduleResolved = path.resolve(moduleRoot);
175
+ const moduleEntry = prioritized.find((entry) => entry.resolved === moduleResolved && !entry.isRoot);
176
+ if (moduleEntry) {
177
+ return moduleEntry.resolved;
178
+ }
179
+ }
180
+
181
+ const withMarkers = prioritized.find((entry) => entry.hasMarkers);
182
+ if (withMarkers) {
183
+ return withMarkers.resolved;
184
+ }
185
+
186
+ const nonRoot = prioritized.find((entry) => !entry.isRoot);
187
+ if (nonRoot) {
188
+ return nonRoot.resolved;
189
+ }
190
+
191
+ return prioritized.length > 0 ? prioritized[0].resolved : null;
192
+ }
193
+
194
+ function getUserScopedDir(baseDir) {
195
+ const userId = resolveUserId(baseDir);
196
+ return path.join(baseDir, "users", userId);
197
+ }
198
+
199
+ function getCurrentUserId() {
200
+ const envUserId = (process.env.DEV_WORKFLOW_USER_ID || "").trim();
201
+ if (envUserId) {
202
+ return envUserId;
203
+ }
204
+
205
+ if (cachedUserId) {
206
+ return cachedUserId;
207
+ }
208
+
209
+ if (cachedUserStateDir) {
210
+ return resolveUserId(cachedUserStateDir);
211
+ }
212
+
213
+ return "default";
214
+ }
215
+
216
+ async function migrateLegacyStateFile(targetPath) {
217
+ const stateDir = path.dirname(targetPath);
218
+ if (path.basename(stateDir) !== STATE_DIR) {
219
+ return;
220
+ }
221
+
222
+ const projectRoot = path.dirname(stateDir);
223
+ const legacyPath = path.join(projectRoot, LEGACY_STATE_FILENAME);
224
+
225
+ if (legacyPath === targetPath) {
226
+ return;
227
+ }
228
+
229
+ try {
230
+ await fs.access(legacyPath);
231
+ } catch {
232
+ return;
233
+ }
234
+
235
+ await fs.mkdir(stateDir, { recursive: true });
236
+
237
+ try {
238
+ await fs.access(targetPath);
239
+ await fs.unlink(legacyPath);
240
+ return;
241
+ } catch {
242
+ // New state file doesn't exist yet; fall through to migrate.
243
+ }
244
+
245
+ await fs.rename(legacyPath, targetPath);
246
+ }
247
+
248
+ function isRootPath(candidate) {
249
+ if (!candidate) {
250
+ return true;
251
+ }
252
+
253
+ const resolved = path.resolve(candidate);
254
+ return path.parse(resolved).root === resolved;
255
+ }
256
+
257
+ function findProjectRoot(startDir) {
258
+ if (!startDir) {
259
+ return null;
260
+ }
261
+
262
+ let current = path.resolve(startDir);
263
+ const { root } = path.parse(current);
264
+
265
+ while (true) {
266
+ const hasPackageJson = existsSync(path.join(current, "package.json"));
267
+ const hasGitDir = existsSync(path.join(current, ".git"));
268
+ if (hasPackageJson || hasGitDir) {
269
+ return current;
270
+ }
271
+
272
+ if (current === root) {
273
+ return null;
274
+ }
275
+
276
+ const parent = path.dirname(current);
277
+ if (parent === current) {
278
+ return null;
279
+ }
280
+
281
+ current = parent;
282
+ }
283
+ }
284
+
285
+ function getCompatibilityPaths(primaryPath) {
286
+ const resolvedPrimary = path.resolve(primaryPath);
287
+ const candidates = new Set();
288
+ const moduleDir = __dirname;
289
+
290
+ const directCandidate = path.resolve(moduleDir, STATE_DIR, STATE_FILENAME);
291
+ if (directCandidate !== resolvedPrimary) {
292
+ candidates.add(directCandidate);
293
+ }
294
+
295
+ const possibleDistDirs = [
296
+ path.join(moduleDir, "dist"),
297
+ path.join(path.dirname(moduleDir), "dist"),
298
+ ];
299
+
300
+ for (const distDir of possibleDistDirs) {
301
+ const candidate = path.resolve(distDir, STATE_DIR, STATE_FILENAME);
302
+ if (candidate !== resolvedPrimary) {
303
+ candidates.add(candidate);
304
+ }
305
+ }
306
+
307
+ return Array.from(candidates);
308
+ }
309
+
310
+ async function pathExists(filePath) {
311
+ try {
312
+ await fs.access(filePath);
313
+ return true;
314
+ } catch {
315
+ return false;
316
+ }
317
+ }
318
+
319
+ async function ensureCompatibilityLinks(primaryPath) {
320
+ const resolvedPrimary = path.resolve(primaryPath);
321
+
322
+ if (!(await pathExists(resolvedPrimary))) {
323
+ return;
324
+ }
325
+
326
+ const compatibilityPaths = getCompatibilityPaths(resolvedPrimary);
327
+
328
+ for (const compatPath of compatibilityPaths) {
329
+ try {
330
+ await fs.mkdir(path.dirname(compatPath), { recursive: true });
331
+ } catch {
332
+ // Ignore directory creation errors; we'll surface them on actual writes.
333
+ }
334
+
335
+ try {
336
+ const compatStat = await fs.lstat(compatPath);
337
+ if (compatStat.isSymbolicLink()) {
338
+ const target = await fs.readlink(compatPath);
339
+ if (path.resolve(path.dirname(compatPath), target) === resolvedPrimary) {
340
+ continue;
341
+ }
342
+ await fs.unlink(compatPath);
343
+ } else if (compatStat.isFile()) {
344
+ const [primaryStat, existingStat] = await Promise.all([
345
+ fs.stat(resolvedPrimary),
346
+ fs.stat(compatPath),
347
+ ]);
348
+ if (primaryStat.dev === existingStat.dev && primaryStat.ino === existingStat.ino) {
349
+ continue;
350
+ }
351
+ await fs.unlink(compatPath);
352
+ } else {
353
+ await fs.unlink(compatPath);
354
+ }
355
+ } catch (error) {
356
+ if (error.code !== "ENOENT") {
357
+ // Unexpected error accessing legacy path; skip mirroring to avoid breaking primary file.
358
+ continue;
359
+ }
360
+ }
361
+
362
+ try {
363
+ await fs.link(resolvedPrimary, compatPath);
364
+ continue;
365
+ } catch (error) {
366
+ if (!["EEXIST", "EXDEV", "EPERM"].includes(error.code)) {
367
+ continue;
368
+ }
369
+ if (error.code === "EEXIST") {
370
+ continue;
371
+ }
372
+ }
373
+
374
+ try {
375
+ await fs.symlink(resolvedPrimary, compatPath);
376
+ continue;
377
+ } catch (error) {
378
+ if (error.code === "EEXIST") {
379
+ continue;
380
+ }
381
+ if (!["EPERM", "ENOTSUP", "EEXIST"].includes(error.code)) {
382
+ // If symlink fails for another reason, skip to avoid interfering with primary file.
383
+ continue;
384
+ }
385
+ }
386
+
387
+ try {
388
+ await fs.copyFile(resolvedPrimary, compatPath);
389
+ } catch {
390
+ // Final fallback failed; ignore to avoid disrupting primary save.
391
+ }
392
+ }
393
+ }
394
+
395
+ export function resolveStateFile() {
396
+ const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
397
+ if (customPath) {
398
+ return path.resolve(customPath);
399
+ }
400
+
401
+ const moduleRoot = path.resolve(__dirname, "..");
402
+ const cwdResolved = path.resolve(process.cwd());
403
+ const initCwd = process.env.INIT_CWD ? path.resolve(process.env.INIT_CWD) : null;
404
+
405
+ // 1. Prefer the project rooted at the current working directory
406
+ let projectRoot = findProjectRoot(cwdResolved);
407
+
408
+ // 2. Fall back to the project rooted at INIT_CWD if different
409
+ if (!projectRoot && initCwd) {
410
+ projectRoot = findProjectRoot(initCwd);
411
+ }
412
+
413
+ // 3. Fall back to the project containing this module
414
+ if (!projectRoot) {
415
+ projectRoot = findProjectRoot(moduleRoot);
416
+ }
417
+
418
+ // 4. Final fallback: use a non-root working directory or the module root
419
+ if (!projectRoot) {
420
+ projectRoot = !isRootPath(cwdResolved) ? cwdResolved : moduleRoot;
421
+ }
422
+
423
+ const stateDir = path.join(projectRoot, STATE_DIR);
424
+ const userDir = getUserScopedDir(stateDir);
425
+ return path.join(userDir, STATE_FILENAME);
426
+ }
427
+
428
+ export class WorkflowState {
429
+ constructor(stateFilePath = resolveStateFile()) {
430
+ this.stateFile = stateFilePath;
431
+ this.state = {
432
+ currentPhase: "idle",
433
+ taskDescription: "",
434
+ bugFixed: false,
435
+ testsCreated: false,
436
+ testsPassed: false,
437
+ testsSkipped: false,
438
+ testsSkippedReason: "",
439
+ documentationCreated: false,
440
+ readyToCommit: false,
441
+ readyCheckCompleted: false,
442
+ released: false,
443
+ releaseCommand: "",
444
+ releaseNotes: "",
445
+ commitAndPushCompleted: false,
446
+ lastCommitMessage: "",
447
+ lastPushBranch: "",
448
+ history: [],
449
+ taskType: "",
450
+ };
451
+ }
452
+
453
+ async load() {
454
+ await migrateLegacyStateFile(this.stateFile);
455
+ try {
456
+ const data = await fs.readFile(this.stateFile, "utf-8");
457
+ const parsed = JSON.parse(data);
458
+ this.state = {
459
+ ...this.state,
460
+ ...parsed,
461
+ };
462
+ // Ensure compatibility mirrors exist whenever the primary file is present
463
+ await ensureCompatibilityLinks(this.stateFile);
464
+ if (typeof this.state.testsCreated !== "boolean") {
465
+ this.state.testsCreated = false;
466
+ }
467
+ if (typeof this.state.testsSkipped !== "boolean") {
468
+ this.state.testsSkipped = false;
469
+ }
470
+ if (typeof this.state.testsSkippedReason !== "string") {
471
+ this.state.testsSkippedReason = "";
472
+ }
473
+ if (typeof this.state.readyCheckCompleted !== "boolean") {
474
+ this.state.readyCheckCompleted = false;
475
+ }
476
+ if (typeof this.state.released !== "boolean") {
477
+ this.state.released = false;
478
+ }
479
+ if (typeof this.state.commitAndPushCompleted !== "boolean") {
480
+ this.state.commitAndPushCompleted = false;
481
+ }
482
+ if (typeof this.state.releaseCommand !== "string") {
483
+ this.state.releaseCommand = "";
484
+ }
485
+ if (typeof this.state.releaseNotes !== "string") {
486
+ this.state.releaseNotes = "";
487
+ }
488
+ if (typeof this.state.lastCommitMessage !== "string") {
489
+ this.state.lastCommitMessage = "";
490
+ }
491
+ if (typeof this.state.lastPushBranch !== "string") {
492
+ this.state.lastPushBranch = "";
493
+ }
494
+ } catch (error) {
495
+ if (error?.code !== "ENOENT") {
496
+ throw error;
497
+ }
498
+ // Missing file is fine; bootstrap fresh artifacts for downstream consumers.
499
+ await this.ensurePrimaryFile();
500
+ }
501
+ }
502
+
503
+ async ensurePrimaryFile() {
504
+ let created = false;
505
+ try {
506
+ await fs.access(this.stateFile);
507
+ } catch {
508
+ await fs.mkdir(path.dirname(this.stateFile), { recursive: true });
509
+ await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2));
510
+ created = true;
511
+ }
512
+
513
+ // Always ensure a fresh project summary so dependent tooling can read it immediately.
514
+ await this.updateProjectSummary();
515
+ await ensureCompatibilityLinks(this.stateFile);
516
+
517
+ return created;
518
+ }
519
+
520
+ async save() {
521
+ await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2));
522
+ await ensureCompatibilityLinks(this.stateFile);
523
+ await this.updateProjectSummary();
524
+ await this.syncToDatabase();
525
+ }
526
+
527
+ async updateProjectSummary() {
528
+ const summaryPath = path.join(path.dirname(this.stateFile), PROJECT_SUMMARY_FILENAME);
529
+ const summary = this.generateProjectSummaryData();
530
+ await fs.mkdir(path.dirname(summaryPath), { recursive: true });
531
+ await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
532
+ }
533
+
534
+ async syncToDatabase() {
535
+ try {
536
+ const { insertHistoryEntry, updateSummaryForUser } = await import("./db/index.js");
537
+ const userId = getCurrentUserId();
538
+ for (const entry of this.state.history || []) {
539
+ insertHistoryEntry(userId, entry);
540
+ }
541
+ updateSummaryForUser(userId);
542
+ } catch {
543
+ // SQLite not available; skip sync
544
+ }
545
+ }
546
+
547
+ generateProjectSummaryData() {
548
+ const history = this.state.history || [];
549
+ if (!history || history.length === 0) {
550
+ return {
551
+ totalTasks: 0,
552
+ taskTypes: {},
553
+ lastActive: null,
554
+ recentTasks: [],
555
+ updatedAt: new Date().toISOString(),
556
+ };
557
+ }
558
+
559
+ const taskTypes = {};
560
+ const recentTasks = history.slice(-20);
561
+
562
+ for (const entry of recentTasks) {
563
+ const type = entry.taskType || "other";
564
+ taskTypes[type] = (taskTypes[type] || 0) + 1;
565
+ }
566
+
567
+ const totalTasks = history.length;
568
+ const lastTask = history[history.length - 1];
569
+ const lastActive = lastTask ? lastTask.timestamp : null;
570
+
571
+ return {
572
+ totalTasks,
573
+ taskTypes,
574
+ lastActive,
575
+ recentTasks: history.slice(-5).reverse().map((e) => ({
576
+ description: e.taskDescription,
577
+ type: e.taskType,
578
+ timestamp: e.timestamp,
579
+ })),
580
+ updatedAt: new Date().toISOString(),
581
+ };
582
+ }
583
+
584
+ reset() {
585
+ this.state = {
586
+ currentPhase: "idle",
587
+ taskDescription: "",
588
+ bugFixed: false,
589
+ testsCreated: false,
590
+ testsPassed: false,
591
+ testsSkipped: false,
592
+ testsSkippedReason: "",
593
+ documentationCreated: false,
594
+ readyToCommit: false,
595
+ readyCheckCompleted: false,
596
+ released: false,
597
+ releaseCommand: "",
598
+ releaseNotes: "",
599
+ commitAndPushCompleted: false,
600
+ lastCommitMessage: "",
601
+ lastPushBranch: "",
602
+ history: this.state.history,
603
+ };
604
+ }
605
+
606
+ addToHistory(entry) {
607
+ this.state.history.push({
608
+ ...entry,
609
+ timestamp: new Date().toISOString(),
610
+ });
611
+ }
612
+ }