@programinglive/dev-workflow-mcp-server 1.11.0 → 1.12.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 (2) hide show
  1. package/package.json +1 -1
  2. package/workflow-state.js +109 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@programinglive/dev-workflow-mcp-server",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "MCP server to enforce development workflow discipline",
5
5
  "homepage": "https://devworkflow.programinglive.com/",
6
6
  "mcpName": "io.github.programinglive/dev-workflow-mcp-server",
package/workflow-state.js CHANGED
@@ -408,6 +408,55 @@ async function ensureCompatibilityLinks(primaryPath) {
408
408
  }
409
409
  }
410
410
 
411
+ /**
412
+ * Identitas sesi agen ini.
413
+ *
414
+ * MENGAPA ADA
415
+ * -----------
416
+ * Berkas state dulu disimpan per USER, jadi empat proses MCP berbagi satu
417
+ * `workflow-state.json`. Akibatnya `start_task` di satu sesi mengganti task
418
+ * sesi lain di tengah jalan, dan gerbang kualitas (ready check, tests, docs)
419
+ * ikut dibagi sehingga sesi B bisa memakai hasil sesi A untuk commit.
420
+ *
421
+ * Sekarang task dan gerbangnya milik SESI; `history` tetap milik PROYEK.
422
+ *
423
+ * Urutannya sengaja: env eksplisit dulu (untuk tes dan pemanggil lain), lalu
424
+ * id sesi editor, lalu proses. Jatuh ke proses membuat identitas tetap stabil
425
+ * walau modul di-reload dalam proses yang sama.
426
+ */
427
+ export function resolveSessionKey(env = process.env) {
428
+ for (const candidate of [env.DEV_WORKFLOW_SESSION_ID, env.OPENCODE_SESSION_ID, env.CLIENT_SESSION_ID]) {
429
+ const value = typeof candidate === "string" ? candidate.trim() : "";
430
+ if (value) return sanitizeSessionKey(value);
431
+ }
432
+ return `pid-${process.pid}`;
433
+ }
434
+
435
+ function sanitizeSessionKey(value) {
436
+ const cleaned = String(value).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
437
+ return (cleaned || "session").slice(0, 64);
438
+ }
439
+
440
+ /** Nama berkas state untuk sesi ini, di direktori user yang sama. */
441
+ function sessionStateFilename(env = process.env) {
442
+ return `workflow-state.${resolveSessionKey(env)}.json`;
443
+ }
444
+
445
+ /**
446
+ * Berkas tempat `history` bersama disimpan.
447
+ *
448
+ * Dalam tata letak nyata (`.state/users/<user>/`) history dipisahkan dari state
449
+ * sesi supaya catatan proyek tidak terpecah per sesi. Pemanggil yang memberi
450
+ * path eksplisit di luar tata letak itu tetap memakai satu berkas, persis
451
+ * seperti sebelumnya.
452
+ */
453
+ export function resolveHistoryFile(stateFilePath) {
454
+ const resolved = path.resolve(stateFilePath);
455
+ const dir = path.dirname(resolved);
456
+ const inUserTree = path.basename(path.dirname(dir)) === "users";
457
+ return inUserTree ? path.join(dir, STATE_FILENAME) : resolved;
458
+ }
459
+
411
460
  export function resolveStateFile() {
412
461
  const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
413
462
  if (customPath) {
@@ -438,12 +487,15 @@ export function resolveStateFile() {
438
487
 
439
488
  const stateDir = path.join(projectRoot, STATE_DIR);
440
489
  const userDir = getUserScopedDir(stateDir);
441
- return path.join(userDir, STATE_FILENAME);
490
+ return path.join(userDir, sessionStateFilename());
442
491
  }
443
492
 
444
493
  export class WorkflowState {
445
494
  constructor(stateFilePath = resolveStateFile()) {
446
495
  this.stateFile = stateFilePath;
496
+ // History milik proyek, state milik sesi. Dalam tata letak nyata keduanya
497
+ // berkas berbeda; untuk path eksplisit keduanya menunjuk berkas yang sama.
498
+ this.historyFile = resolveHistoryFile(stateFilePath);
447
499
  this.state = {
448
500
  currentPhase: "idle",
449
501
  taskDescription: "",
@@ -482,6 +534,7 @@ export class WorkflowState {
482
534
  // Ensure compatibility mirrors exist whenever the primary file is present
483
535
  // Run this as non-blocking to speed up startup
484
536
  ensureCompatibilityLinks(this.stateFile).catch(() => { });
537
+ await this.mergeSharedHistory();
485
538
  if (typeof this.state.testsCreated !== "boolean") {
486
539
  this.state.testsCreated = false;
487
540
  }
@@ -530,9 +583,61 @@ export class WorkflowState {
530
583
  }
531
584
  // Missing file is fine; bootstrap fresh artifacts for downstream consumers.
532
585
  await this.ensurePrimaryFile();
586
+ // Sesi baru tetap harus melihat riwayat proyek yang sudah ada.
587
+ await this.mergeSharedHistory();
533
588
  }
534
589
  }
535
590
 
591
+ /**
592
+ * Menyatukan `history` sesi ini dengan `history` bersama.
593
+ *
594
+ * History adalah catatan proyek: task yang selesai di satu sesi harus
595
+ * terlihat dari sesi lain, dan entri yang sama tidak boleh berganda walau
596
+ * berkali-kali disimpan.
597
+ */
598
+ async mergeSharedHistory() {
599
+ if (this.historyFile === this.stateFile) {
600
+ return 0;
601
+ }
602
+
603
+ const shared = await this.readHistoryFile();
604
+ const merged = this.mergeDiskHistory(shared);
605
+ if (merged > 0) {
606
+ await this.writeHistoryFile();
607
+ }
608
+ return merged;
609
+ }
610
+
611
+ async readHistoryFile() {
612
+ try {
613
+ const data = await fs.readFile(this.historyFile, "utf-8");
614
+ return JSON.parse(data);
615
+ } catch {
616
+ return null;
617
+ }
618
+ }
619
+
620
+ /** Menulis hanya `history` ke berkas bersama, tanpa task milik sesi ini. */
621
+ async writeHistoryFile() {
622
+ if (this.historyFile === this.stateFile) {
623
+ return;
624
+ }
625
+
626
+ const shared = (await this.readHistoryFile()) || {};
627
+ const merged = Array.isArray(shared.history) ? shared.history.slice() : [];
628
+ const seen = new Set(merged.map((entry) => this.historyKey(entry)));
629
+ for (const entry of this.state.history || []) {
630
+ const key = this.historyKey(entry);
631
+ if (!seen.has(key)) {
632
+ merged.push(entry);
633
+ seen.add(key);
634
+ }
635
+ }
636
+
637
+ await fs.mkdir(path.dirname(this.historyFile), { recursive: true });
638
+ await fs.writeFile(this.historyFile, JSON.stringify({ ...shared, history: merged }, null, 2));
639
+ }
640
+
536
641
  async ensurePrimaryFile() {
537
642
  let created = false;
538
643
  try {
@@ -558,6 +663,9 @@ export class WorkflowState {
558
663
  updatedAt: new Date().toISOString(),
559
664
  };
560
665
  await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2));
666
+ // History ditulis ke berkas bersama supaya sesi lain melihat task yang
667
+ // selesai di sini, sementara task yang sedang berjalan tetap milik sesi.
668
+ await this.writeHistoryFile();
561
669
  await ensureCompatibilityLinks(this.stateFile);
562
670
  await this.updateProjectSummary();
563
671
  await this.syncToDatabase();