@programinglive/dev-workflow-mcp-server 1.11.0 → 1.12.1

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 +142 -25
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.1",
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,84 @@ 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
+
460
+ /**
461
+ * Induk direktori `.state` untuk sebuah berkas state.
462
+ *
463
+ * Dulu ini dihitung dengan empat `dirname` berurutan, yang hanya benar selama
464
+ * tata letaknya tepat `<root>/.state/users/<user>/<berkas>`. Begitu kedalaman
465
+ * berubah (mis. state sesi ditaruh di subdirektori), angka itu diam-diam
466
+ * menghasilkan root yang salah dan baris database tercatat di proyek lain.
467
+ *
468
+ * Sekarang dicari dengan sengaja: naik sampai menemukan direktori `.state`,
469
+ * lalu ambil induknya. Tidak bergantung pada kedalaman.
470
+ */
471
+ export function resolveProjectRootForState(stateFilePath) {
472
+ const resolved = path.resolve(stateFilePath);
473
+ let current = path.dirname(resolved);
474
+
475
+ for (;;) {
476
+ if (path.basename(current) === STATE_DIR) {
477
+ return path.dirname(current);
478
+ }
479
+ const parent = path.dirname(current);
480
+ if (parent === current) break;
481
+ current = parent;
482
+ }
483
+
484
+ // Di luar tata letak `.state` (mis. path eksplisit untuk tes): pakai
485
+ // direktori yang memuat berkasnya.
486
+ return path.dirname(resolved);
487
+ }
488
+
411
489
  export function resolveStateFile() {
412
490
  const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
413
491
  if (customPath) {
@@ -438,12 +516,15 @@ export function resolveStateFile() {
438
516
 
439
517
  const stateDir = path.join(projectRoot, STATE_DIR);
440
518
  const userDir = getUserScopedDir(stateDir);
441
- return path.join(userDir, STATE_FILENAME);
519
+ return path.join(userDir, sessionStateFilename());
442
520
  }
443
521
 
444
522
  export class WorkflowState {
445
523
  constructor(stateFilePath = resolveStateFile()) {
446
524
  this.stateFile = stateFilePath;
525
+ // History milik proyek, state milik sesi. Dalam tata letak nyata keduanya
526
+ // berkas berbeda; untuk path eksplisit keduanya menunjuk berkas yang sama.
527
+ this.historyFile = resolveHistoryFile(stateFilePath);
447
528
  this.state = {
448
529
  currentPhase: "idle",
449
530
  taskDescription: "",
@@ -482,6 +563,7 @@ export class WorkflowState {
482
563
  // Ensure compatibility mirrors exist whenever the primary file is present
483
564
  // Run this as non-blocking to speed up startup
484
565
  ensureCompatibilityLinks(this.stateFile).catch(() => { });
566
+ await this.mergeSharedHistory();
485
567
  if (typeof this.state.testsCreated !== "boolean") {
486
568
  this.state.testsCreated = false;
487
569
  }
@@ -530,7 +612,59 @@ export class WorkflowState {
530
612
  }
531
613
  // Missing file is fine; bootstrap fresh artifacts for downstream consumers.
532
614
  await this.ensurePrimaryFile();
615
+ // Sesi baru tetap harus melihat riwayat proyek yang sudah ada.
616
+ await this.mergeSharedHistory();
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Menyatukan `history` sesi ini dengan `history` bersama.
622
+ *
623
+ * History adalah catatan proyek: task yang selesai di satu sesi harus
624
+ * terlihat dari sesi lain, dan entri yang sama tidak boleh berganda walau
625
+ * berkali-kali disimpan.
626
+ */
627
+ async mergeSharedHistory() {
628
+ if (this.historyFile === this.stateFile) {
629
+ return 0;
630
+ }
631
+
632
+ const shared = await this.readHistoryFile();
633
+ const merged = this.mergeDiskHistory(shared);
634
+ if (merged > 0) {
635
+ await this.writeHistoryFile();
636
+ }
637
+ return merged;
638
+ }
639
+
640
+ async readHistoryFile() {
641
+ try {
642
+ const data = await fs.readFile(this.historyFile, "utf-8");
643
+ return JSON.parse(data);
644
+ } catch {
645
+ return null;
646
+ }
647
+ }
648
+
649
+ /** Menulis hanya `history` ke berkas bersama, tanpa task milik sesi ini. */
650
+ async writeHistoryFile() {
651
+ if (this.historyFile === this.stateFile) {
652
+ return;
533
653
  }
654
+
655
+ const shared = (await this.readHistoryFile()) || {};
656
+ const merged = Array.isArray(shared.history) ? shared.history.slice() : [];
657
+ const seen = new Set(merged.map((entry) => this.historyKey(entry)));
658
+ for (const entry of this.state.history || []) {
659
+ const key = this.historyKey(entry);
660
+ if (!seen.has(key)) {
661
+ merged.push(entry);
662
+ seen.add(key);
663
+ }
664
+ }
665
+
666
+ await fs.mkdir(path.dirname(this.historyFile), { recursive: true });
667
+ await fs.writeFile(this.historyFile, JSON.stringify({ ...shared, history: merged }, null, 2));
534
668
  }
535
669
 
536
670
  async ensurePrimaryFile() {
@@ -558,6 +692,9 @@ export class WorkflowState {
558
692
  updatedAt: new Date().toISOString(),
559
693
  };
560
694
  await fs.writeFile(this.stateFile, JSON.stringify(this.state, null, 2));
695
+ // History ditulis ke berkas bersama supaya sesi lain melihat task yang
696
+ // selesai di sini, sementara task yang sedang berjalan tetap milik sesi.
697
+ await this.writeHistoryFile();
561
698
  await ensureCompatibilityLinks(this.stateFile);
562
699
  await this.updateProjectSummary();
563
700
  await this.syncToDatabase();
@@ -677,30 +814,10 @@ export class WorkflowState {
677
814
  try {
678
815
  const { insertHistoryEntry, updateSummaryForUser, saveState } = await import("./db/index.js");
679
816
  const userId = getCurrentUserId();
680
- // Use the project directory (parent of .state) as the project identifier
681
- // this.stateFile is .../.state/users/<user>/workflow-state.json
682
- // so we want resolution logic. For now, let's use the absolute path to the project root.
683
- // We can derive it from stateFile path logic in resolveStateFile or just use the directory we are in.
684
- // A safe bet that is unique per project on this machine is the project root found by resolveStateFile.
685
- // Let's perform a simple resolution:
686
-
687
- const stateDir = path.dirname(path.dirname(path.dirname(this.stateFile)));
688
- // stateFile = <root>/.state/users/<user>/workflow-state.json
689
- // dirname = <root>/.state/users/<user>
690
- // dirname = <root>/.state/users
691
- // dirname = <root>/.state
692
- // dirname = <root>
693
-
694
- // Wait, let's look at the path structure from resolveStateFile:
695
- // path.join(userDir, STATE_FILENAME); where userDir is path.join(stateDir, "users", userId);
696
- // and stateDir is path.join(projectRoot, STATE_DIR);
697
- // So: projectRoot/.state/users/userId/workflow-state.json
698
- // 1. dirname -> .../userId
699
- // 2. dirname -> .../users
700
- // 3. dirname -> .../.state
701
- // 4. dirname -> .../projectRoot
702
-
703
- const projectRoot = path.dirname(path.dirname(path.dirname(path.dirname(this.stateFile))));
817
+ // projectRoot dipakai sebagai identitas proyek di database. Ditentukan
818
+ // lewat resolveProjectRootForState, bukan dengan menghitung `dirname`
819
+ // berkali-kali: jumlah itu hanya benar untuk satu kedalaman tertentu.
820
+ const projectRoot = resolveProjectRootForState(this.stateFile);
704
821
 
705
822
  for (const entry of this.state.history || []) {
706
823
  await insertHistoryEntry(userId, projectRoot, entry);