@programinglive/dev-workflow-mcp-server 1.12.0 → 1.13.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 (48) hide show
  1. package/.dockerignore +24 -0
  2. package/.env.example +11 -31
  3. package/README.md +109 -6
  4. package/db/adapters/base.js +12 -0
  5. package/db/adapters/mysql.js +11 -5
  6. package/db/adapters/postgres.js +10 -5
  7. package/db/adapters/sqlite.js +15 -7
  8. package/db/index.js +2 -1
  9. package/docker-compose.yml +7 -5
  10. package/exec.js +78 -12
  11. package/factory-client.js +142 -10
  12. package/index.js +28 -19
  13. package/install.js +6 -5
  14. package/package.json +4 -1
  15. package/release-wrapper.js +32 -19
  16. package/scripts/check-review-guard.mjs +33 -0
  17. package/scripts/create-admin.js +11 -9
  18. package/scripts/install-husky.js +3 -2
  19. package/scripts/lint.mjs +20 -0
  20. package/scripts/release.cjs +25 -22
  21. package/scripts/release.js +6 -5
  22. package/scripts/setup-stack.js +42 -41
  23. package/scripts/update-release-notes.cjs +13 -12
  24. package/scripts/update-release-notes.js +8 -7
  25. package/scripts/verify-bundle.mjs +8 -7
  26. package/security/audit.js +86 -0
  27. package/security/commands.js +10 -0
  28. package/security/config.js +24 -0
  29. package/security/files.js +27 -0
  30. package/security/http.js +26 -0
  31. package/security/log-bridge.cjs +17 -0
  32. package/security/policy.js +37 -0
  33. package/security/references.js +21 -0
  34. package/security/runtime-secrets.js +42 -0
  35. package/security/sanitizer.js +42 -0
  36. package/security/scanner.js +108 -0
  37. package/tools/definitions.js +51 -3
  38. package/tools/handler-modules/commit-handlers.js +3 -7
  39. package/tools/handler-modules/factory-handlers.js +84 -0
  40. package/tools/handler-modules/index.js +45 -3
  41. package/tools/handler-modules/shared.js +5 -2
  42. package/tools/handler-modules/status-handlers.js +1 -1
  43. package/tools/handler-modules/summary-handlers.js +2 -1
  44. package/tools/handler-modules/workflow-handlers.js +2 -0
  45. package/utils.js +1 -0
  46. package/verify-build.js +7 -6
  47. package/vite.config.js +4 -0
  48. package/workflow-state.js +142 -42
package/workflow-state.js CHANGED
@@ -1,6 +1,9 @@
1
+ import { safeLog } from './security/sanitizer.js';
1
2
  import fs from "fs/promises";
2
3
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
3
4
  import path from "path";
5
+ import { assertSafeKnowledge } from './security/policy.js';
6
+ import { sanitize } from './security/sanitizer.js';
4
7
  import { fileURLToPath } from "url";
5
8
 
6
9
  const __filename = fileURLToPath(import.meta.url);
@@ -30,6 +33,14 @@ let cachedUserStateDir = null;
30
33
  */
31
34
  const PROCESS_TOKEN = `proc-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
32
35
 
36
+ function migrateOwnerMetadata(value) {
37
+ if(value?.owner && !value.owner.processMarker && /^proc-[A-Za-z0-9-]+$/.test(value.owner.token || '')) {
38
+ value.owner.processMarker=value.owner.token;
39
+ delete value.owner.token;
40
+ }
41
+ return value;
42
+ }
43
+
33
44
  function generateUserId() {
34
45
  const timestamp = Date.now().toString(16);
35
46
  const randomSuffix = Math.random().toString(16).slice(2, 10);
@@ -249,6 +260,8 @@ async function migrateLegacyStateFile(targetPath) {
249
260
  }
250
261
 
251
262
  await fs.mkdir(stateDir, { recursive: true });
263
+ const migrated = migrateOwnerMetadata(JSON.parse(await fs.readFile(legacyPath, 'utf8')));
264
+ assertSafeKnowledge(migrated);
252
265
 
253
266
  try {
254
267
  await fs.access(targetPath);
@@ -258,7 +271,8 @@ async function migrateLegacyStateFile(targetPath) {
258
271
  // New state file doesn't exist yet; fall through to migrate.
259
272
  }
260
273
 
261
- await fs.rename(legacyPath, targetPath);
274
+ await fs.writeFile(targetPath, JSON.stringify(migrated, null, 2), { flag: 'wx' });
275
+ await fs.unlink(legacyPath);
262
276
  }
263
277
 
264
278
  function isRootPath(candidate) {
@@ -338,6 +352,7 @@ async function ensureCompatibilityLinks(primaryPath) {
338
352
  if (!(await pathExists(resolvedPrimary))) {
339
353
  return;
340
354
  }
355
+ assertSafeKnowledge(JSON.parse(await fs.readFile(resolvedPrimary, 'utf8')));
341
356
 
342
357
  const compatibilityPaths = getCompatibilityPaths(resolvedPrimary);
343
358
 
@@ -413,18 +428,38 @@ async function ensureCompatibilityLinks(primaryPath) {
413
428
  *
414
429
  * MENGAPA ADA
415
430
  * -----------
416
- * Berkas state dulu disimpan per USER, jadi empat proses MCP berbagi satu
431
+ * Berkas state dulu disimpan per USER, jadi beberapa proses MCP berbagi satu
417
432
  * `workflow-state.json`. Akibatnya `start_task` di satu sesi mengganti task
418
433
  * sesi lain di tengah jalan, dan gerbang kualitas (ready check, tests, docs)
419
434
  * ikut dibagi sehingga sesi B bisa memakai hasil sesi A untuk commit.
420
435
  *
421
436
  * Sekarang task dan gerbangnya milik SESI; `history` tetap milik PROYEK.
422
437
  *
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.
438
+ * MENGAPA BUKAN ENV SAJA
439
+ * ----------------------
440
+ * Percobaan pertama membaca `OPENCODE_SESSION_ID` dari environment. OpenCode
441
+ * tidak men-set variabel itu, sehingga hampir semua berkas jatuh ke
442
+ * `pid-<pid>`; karena satu proses MCP dipakai bergantian oleh beberapa sesi,
443
+ * sesi-sesi itu tetap berbagi satu berkas dan saling menimpa.
444
+ *
445
+ * OpenCode v2 mengirim identitas sesi per panggilan di
446
+ * `CallToolRequest.params._meta["ai.opencode/sessionID"]`. Itu sumber utama
447
+ * sekarang; env tetap dibaca sebagai cadangan untuk tes dan pemanggil lain.
426
448
  */
427
- export function resolveSessionKey(env = process.env) {
449
+ export const SESSION_META_KEY = "ai.opencode/sessionID";
450
+
451
+ /** Mengambil identitas sesi dari request metadata MCP, atau null. */
452
+ export function sessionKeyFromRequest(request) {
453
+ const value = request?.params?._meta?.[SESSION_META_KEY];
454
+ if (typeof value !== "string") return null;
455
+ const trimmed = value.trim();
456
+ return trimmed === "" ? null : trimmed;
457
+ }
458
+
459
+ export function resolveSessionKey(env = process.env, explicitSessionId = null) {
460
+ const fromRequest = typeof explicitSessionId === "string" ? explicitSessionId.trim() : "";
461
+ if (fromRequest) return sanitizeSessionKey(fromRequest);
462
+
428
463
  for (const candidate of [env.DEV_WORKFLOW_SESSION_ID, env.OPENCODE_SESSION_ID, env.CLIENT_SESSION_ID]) {
429
464
  const value = typeof candidate === "string" ? candidate.trim() : "";
430
465
  if (value) return sanitizeSessionKey(value);
@@ -438,8 +473,8 @@ function sanitizeSessionKey(value) {
438
473
  }
439
474
 
440
475
  /** Nama berkas state untuk sesi ini, di direktori user yang sama. */
441
- function sessionStateFilename(env = process.env) {
442
- return `workflow-state.${resolveSessionKey(env)}.json`;
476
+ function sessionStateFilename(env = process.env, explicitSessionId = null) {
477
+ return `workflow-state.${resolveSessionKey(env, explicitSessionId)}.json`;
443
478
  }
444
479
 
445
480
  /**
@@ -457,7 +492,36 @@ export function resolveHistoryFile(stateFilePath) {
457
492
  return inUserTree ? path.join(dir, STATE_FILENAME) : resolved;
458
493
  }
459
494
 
460
- export function resolveStateFile() {
495
+ /**
496
+ * Induk direktori `.state` untuk sebuah berkas state.
497
+ *
498
+ * Dulu ini dihitung dengan empat `dirname` berurutan, yang hanya benar selama
499
+ * tata letaknya tepat `<root>/.state/users/<user>/<berkas>`. Begitu kedalaman
500
+ * berubah (mis. state sesi ditaruh di subdirektori), angka itu diam-diam
501
+ * menghasilkan root yang salah dan baris database tercatat di proyek lain.
502
+ *
503
+ * Sekarang dicari dengan sengaja: naik sampai menemukan direktori `.state`,
504
+ * lalu ambil induknya. Tidak bergantung pada kedalaman.
505
+ */
506
+ export function resolveProjectRootForState(stateFilePath) {
507
+ const resolved = path.resolve(stateFilePath);
508
+ let current = path.dirname(resolved);
509
+
510
+ for (;;) {
511
+ if (path.basename(current) === STATE_DIR) {
512
+ return path.dirname(current);
513
+ }
514
+ const parent = path.dirname(current);
515
+ if (parent === current) break;
516
+ current = parent;
517
+ }
518
+
519
+ // Di luar tata letak `.state` (mis. path eksplisit untuk tes): pakai
520
+ // direktori yang memuat berkasnya.
521
+ return path.dirname(resolved);
522
+ }
523
+
524
+ export function resolveStateFile({ sessionId = null } = {}) {
461
525
  const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
462
526
  if (customPath) {
463
527
  return path.resolve(customPath);
@@ -487,7 +551,7 @@ export function resolveStateFile() {
487
551
 
488
552
  const stateDir = path.join(projectRoot, STATE_DIR);
489
553
  const userDir = getUserScopedDir(stateDir);
490
- return path.join(userDir, sessionStateFilename());
554
+ return path.join(userDir, sessionStateFilename(process.env, sessionId));
491
555
  }
492
556
 
493
557
  export class WorkflowState {
@@ -498,6 +562,7 @@ export class WorkflowState {
498
562
  this.historyFile = resolveHistoryFile(stateFilePath);
499
563
  this.state = {
500
564
  currentPhase: "idle",
565
+ classification: "INTERNAL",
501
566
  taskDescription: "",
502
567
  bugFixed: false,
503
568
  testsCreated: false,
@@ -522,11 +587,54 @@ export class WorkflowState {
522
587
  };
523
588
  }
524
589
 
590
+ /**
591
+ * Berpindah ke state milik sesi tertentu.
592
+ *
593
+ * Satu proses MCP bisa melayani beberapa sesi bergantian, jadi state harus
594
+ * ditentukan per panggilan - bukan sekali saat proses start. Mengembalikan
595
+ * true bila berkas benar-benar berpindah.
596
+ */
597
+ async useSession(sessionId) {
598
+ const target = resolveStateFile({ sessionId });
599
+ if (target === this.stateFile) return false;
600
+
601
+ this.stateFile = target;
602
+ this.historyFile = resolveHistoryFile(target);
603
+ // Mulai dari state bersih, lalu muat milik sesi itu. Menyalin state sesi
604
+ // sebelumnya akan membuat sesi baru mewarisi gerbang kualitas sesi lain.
605
+ this.state = {
606
+ currentPhase: "idle",
607
+ taskDescription: "",
608
+ bugFixed: false,
609
+ testsCreated: false,
610
+ testsPassed: false,
611
+ testsSkipped: false,
612
+ testsSkippedReason: "",
613
+ documentationCreated: false,
614
+ readyToCommit: false,
615
+ readyCheckCompleted: false,
616
+ released: false,
617
+ releaseSkipped: false,
618
+ releaseSkippedReason: "",
619
+ releaseCommand: "",
620
+ releaseNotes: "",
621
+ commitAndPushCompleted: false,
622
+ lastCommitMessage: "",
623
+ lastPushBranch: "",
624
+ featureFlowCreated: false,
625
+ featureFlowDescription: "",
626
+ history: [],
627
+ taskType: "",
628
+ };
629
+ await this.load();
630
+ return true;
631
+ }
632
+
525
633
  async load() {
526
634
  await migrateLegacyStateFile(this.stateFile);
527
635
  try {
528
636
  const data = await fs.readFile(this.stateFile, "utf-8");
529
- const parsed = JSON.parse(data);
637
+ const parsed = sanitize(migrateOwnerMetadata(JSON.parse(data)));
530
638
  this.state = {
531
639
  ...this.state,
532
640
  ...parsed,
@@ -634,11 +742,14 @@ export class WorkflowState {
634
742
  }
635
743
  }
636
744
 
745
+ const payload = { ...shared, history: merged };
746
+ assertSafeKnowledge(payload);
637
747
  await fs.mkdir(path.dirname(this.historyFile), { recursive: true });
638
- await fs.writeFile(this.historyFile, JSON.stringify({ ...shared, history: merged }, null, 2));
748
+ await fs.writeFile(this.historyFile, JSON.stringify(payload, null, 2));
639
749
  }
640
750
 
641
751
  async ensurePrimaryFile() {
752
+ assertSafeKnowledge(this.state);
642
753
  let created = false;
643
754
  try {
644
755
  await fs.access(this.stateFile);
@@ -656,9 +767,11 @@ export class WorkflowState {
656
767
  }
657
768
 
658
769
  async save() {
770
+ assertSafeKnowledge(this.state);
659
771
  const reconciled = await this.reconcileWithDisk();
772
+ assertSafeKnowledge(this.state);
660
773
  this.state.owner = {
661
- token: PROCESS_TOKEN,
774
+ processMarker: PROCESS_TOKEN,
662
775
  pid: process.pid,
663
776
  updatedAt: new Date().toISOString(),
664
777
  };
@@ -680,7 +793,7 @@ export class WorkflowState {
680
793
  async readDiskState() {
681
794
  try {
682
795
  const data = await fs.readFile(this.stateFile, "utf-8");
683
- return JSON.parse(data);
796
+ return sanitize(migrateOwnerMetadata(JSON.parse(data)));
684
797
  } catch {
685
798
  return null;
686
799
  }
@@ -688,7 +801,7 @@ export class WorkflowState {
688
801
 
689
802
  /** True kalau berkas di disk terakhir ditulis proses MCP lain. */
690
803
  isForeignWrite(diskState) {
691
- const token = diskState?.owner?.token;
804
+ const token = diskState?.owner?.processMarker;
692
805
  return typeof token === "string" && token.length > 0 && token !== PROCESS_TOKEN;
693
806
  }
694
807
 
@@ -739,7 +852,7 @@ export class WorkflowState {
739
852
  return result;
740
853
  }
741
854
 
742
- result.foreignToken = disk.owner.token;
855
+ result.foreignToken = disk.owner.processMarker;
743
856
 
744
857
  const hasRunningTask =
745
858
  typeof disk.taskDescription === "string" &&
@@ -759,6 +872,7 @@ export class WorkflowState {
759
872
  }
760
873
 
761
874
  this.state.history.push({
875
+ classification: disk.classification || "INTERNAL",
762
876
  taskDescription: disk.taskDescription,
763
877
  taskType: disk.taskType || "other",
764
878
  commitMessage: disk.lastCommitMessage || "",
@@ -767,7 +881,7 @@ export class WorkflowState {
767
881
  dropReason:
768
882
  "Ditimpa proses agen lain: state workflow dipakai bersama antar sesi di mesin ini",
769
883
  interruptedAtPhase: disk.currentPhase,
770
- interruptedBy: disk.owner.token,
884
+ interruptedBy: disk.owner.processMarker,
771
885
  timestamp: new Date().toISOString(),
772
886
  });
773
887
  result.preservedTask = disk.taskDescription;
@@ -775,40 +889,23 @@ export class WorkflowState {
775
889
  }
776
890
 
777
891
  async updateProjectSummary() {
892
+ assertSafeKnowledge(this.state);
778
893
  const summaryPath = path.join(path.dirname(this.stateFile), PROJECT_SUMMARY_FILENAME);
779
894
  const summary = this.generateProjectSummaryData();
895
+ assertSafeKnowledge(summary);
780
896
  await fs.mkdir(path.dirname(summaryPath), { recursive: true });
781
897
  await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
782
898
  }
783
899
 
784
900
  async syncToDatabase() {
901
+ assertSafeKnowledge(this.state);
785
902
  try {
786
903
  const { insertHistoryEntry, updateSummaryForUser, saveState } = await import("./db/index.js");
787
904
  const userId = getCurrentUserId();
788
- // Use the project directory (parent of .state) as the project identifier
789
- // this.stateFile is .../.state/users/<user>/workflow-state.json
790
- // so we want resolution logic. For now, let's use the absolute path to the project root.
791
- // We can derive it from stateFile path logic in resolveStateFile or just use the directory we are in.
792
- // A safe bet that is unique per project on this machine is the project root found by resolveStateFile.
793
- // Let's perform a simple resolution:
794
-
795
- const stateDir = path.dirname(path.dirname(path.dirname(this.stateFile)));
796
- // stateFile = <root>/.state/users/<user>/workflow-state.json
797
- // dirname = <root>/.state/users/<user>
798
- // dirname = <root>/.state/users
799
- // dirname = <root>/.state
800
- // dirname = <root>
801
-
802
- // Wait, let's look at the path structure from resolveStateFile:
803
- // path.join(userDir, STATE_FILENAME); where userDir is path.join(stateDir, "users", userId);
804
- // and stateDir is path.join(projectRoot, STATE_DIR);
805
- // So: projectRoot/.state/users/userId/workflow-state.json
806
- // 1. dirname -> .../userId
807
- // 2. dirname -> .../users
808
- // 3. dirname -> .../.state
809
- // 4. dirname -> .../projectRoot
810
-
811
- const projectRoot = path.dirname(path.dirname(path.dirname(path.dirname(this.stateFile))));
905
+ // projectRoot dipakai sebagai identitas proyek di database. Ditentukan
906
+ // lewat resolveProjectRootForState, bukan dengan menghitung `dirname`
907
+ // berkali-kali: jumlah itu hanya benar untuk satu kedalaman tertentu.
908
+ const projectRoot = resolveProjectRootForState(this.stateFile);
812
909
 
813
910
  for (const entry of this.state.history || []) {
814
911
  await insertHistoryEntry(userId, projectRoot, entry);
@@ -817,7 +914,7 @@ export class WorkflowState {
817
914
  await saveState(userId, projectRoot, this.state);
818
915
  } catch (error) {
819
916
  // Database sync failed; log it but don't crash
820
- console.error("Database sync failed:", error.message);
917
+ safeLog('error', "Database sync failed:", error.message);
821
918
  }
822
919
  }
823
920
 
@@ -883,6 +980,7 @@ export class WorkflowState {
883
980
  if (archive && hasRunningTask) {
884
981
  archivedTask = previous.taskDescription;
885
982
  history.push({
983
+ classification: previous.classification || "INTERNAL",
886
984
  taskDescription: previous.taskDescription,
887
985
  taskType: previous.taskType || "other",
888
986
  commitMessage: previous.lastCommitMessage || "",
@@ -897,6 +995,7 @@ export class WorkflowState {
897
995
 
898
996
  this.state = {
899
997
  currentPhase: "idle",
998
+ classification: "INTERNAL",
900
999
  taskDescription: "",
901
1000
  bugFixed: false,
902
1001
  testsCreated: false,
@@ -925,6 +1024,7 @@ export class WorkflowState {
925
1024
 
926
1025
  addToHistory(entry) {
927
1026
  this.state.history.push({
1027
+ classification: this.state.classification || "INTERNAL",
928
1028
  ...entry,
929
1029
  timestamp: new Date().toISOString(),
930
1030
  });