@programinglive/dev-workflow-mcp-server 1.12.1 → 1.13.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 (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 +32 -0
  5. package/db/adapters/mysql.js +13 -7
  6. package/db/adapters/postgres.js +12 -7
  7. package/db/adapters/sqlite.js +17 -9
  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 +78 -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 +6 -1
  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 +249 -34
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
  /**
@@ -486,7 +521,7 @@ export function resolveProjectRootForState(stateFilePath) {
486
521
  return path.dirname(resolved);
487
522
  }
488
523
 
489
- export function resolveStateFile() {
524
+ export function resolveStateFile({ sessionId = null } = {}) {
490
525
  const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
491
526
  if (customPath) {
492
527
  return path.resolve(customPath);
@@ -516,7 +551,7 @@ export function resolveStateFile() {
516
551
 
517
552
  const stateDir = path.join(projectRoot, STATE_DIR);
518
553
  const userDir = getUserScopedDir(stateDir);
519
- return path.join(userDir, sessionStateFilename());
554
+ return path.join(userDir, sessionStateFilename(process.env, sessionId));
520
555
  }
521
556
 
522
557
  export class WorkflowState {
@@ -525,6 +560,55 @@ export class WorkflowState {
525
560
  // History milik proyek, state milik sesi. Dalam tata letak nyata keduanya
526
561
  // berkas berbeda; untuk path eksplisit keduanya menunjuk berkas yang sama.
527
562
  this.historyFile = resolveHistoryFile(stateFilePath);
563
+ // Task yang terakhir kita baca dari / tulis ke disk. Dipakai untuk
564
+ // membedakan "task ini milik kita" dari "task ini ditulis proses lain
565
+ // sesudah kita memuat" - tanpa ini penulis basi menang tanpa perlawanan.
566
+ this.loadedTask = "";
567
+ // Konflik penyimpanan terakhir, dibaca oleh lapisan tool untuk memberi tahu
568
+ // pengguna. Null berarti penyimpanan terakhir aman.
569
+ this.lastConflict = null;
570
+ this.state = {
571
+ currentPhase: "idle",
572
+ classification: "INTERNAL",
573
+ taskDescription: "",
574
+ bugFixed: false,
575
+ testsCreated: false,
576
+ testsPassed: false,
577
+ testsSkipped: false,
578
+ testsSkippedReason: "",
579
+ documentationCreated: false,
580
+ readyToCommit: false,
581
+ readyCheckCompleted: false,
582
+ released: false,
583
+ releaseSkipped: false,
584
+ releaseSkippedReason: "",
585
+ releaseCommand: "",
586
+ releaseNotes: "",
587
+ commitAndPushCompleted: false,
588
+ lastCommitMessage: "",
589
+ lastPushBranch: "",
590
+ featureFlowCreated: false,
591
+ featureFlowDescription: "",
592
+ history: [],
593
+ taskType: "",
594
+ };
595
+ }
596
+
597
+ /**
598
+ * Berpindah ke state milik sesi tertentu.
599
+ *
600
+ * Satu proses MCP bisa melayani beberapa sesi bergantian, jadi state harus
601
+ * ditentukan per panggilan - bukan sekali saat proses start. Mengembalikan
602
+ * true bila berkas benar-benar berpindah.
603
+ */
604
+ async useSession(sessionId) {
605
+ const target = resolveStateFile({ sessionId });
606
+ if (target === this.stateFile) return false;
607
+
608
+ this.stateFile = target;
609
+ this.historyFile = resolveHistoryFile(target);
610
+ // Mulai dari state bersih, lalu muat milik sesi itu. Menyalin state sesi
611
+ // sebelumnya akan membuat sesi baru mewarisi gerbang kualitas sesi lain.
528
612
  this.state = {
529
613
  currentPhase: "idle",
530
614
  taskDescription: "",
@@ -549,13 +633,15 @@ export class WorkflowState {
549
633
  history: [],
550
634
  taskType: "",
551
635
  };
636
+ await this.load();
637
+ return true;
552
638
  }
553
639
 
554
640
  async load() {
555
641
  await migrateLegacyStateFile(this.stateFile);
556
642
  try {
557
643
  const data = await fs.readFile(this.stateFile, "utf-8");
558
- const parsed = JSON.parse(data);
644
+ const parsed = sanitize(migrateOwnerMetadata(JSON.parse(data)));
559
645
  this.state = {
560
646
  ...this.state,
561
647
  ...parsed,
@@ -564,6 +650,7 @@ export class WorkflowState {
564
650
  // Run this as non-blocking to speed up startup
565
651
  ensureCompatibilityLinks(this.stateFile).catch(() => { });
566
652
  await this.mergeSharedHistory();
653
+ this.loadedTask = this.state.taskDescription || "";
567
654
  if (typeof this.state.testsCreated !== "boolean") {
568
655
  this.state.testsCreated = false;
569
656
  }
@@ -663,11 +750,14 @@ export class WorkflowState {
663
750
  }
664
751
  }
665
752
 
753
+ const payload = { ...shared, history: merged };
754
+ assertSafeKnowledge(payload);
666
755
  await fs.mkdir(path.dirname(this.historyFile), { recursive: true });
667
- await fs.writeFile(this.historyFile, JSON.stringify({ ...shared, history: merged }, null, 2));
756
+ await fs.writeFile(this.historyFile, JSON.stringify(payload, null, 2));
668
757
  }
669
758
 
670
759
  async ensurePrimaryFile() {
760
+ assertSafeKnowledge(this.state);
671
761
  let created = false;
672
762
  try {
673
763
  await fs.access(this.stateFile);
@@ -684,10 +774,27 @@ export class WorkflowState {
684
774
  return created;
685
775
  }
686
776
 
687
- async save() {
777
+ async save(options = {}) {
778
+ assertSafeKnowledge(this.state);
688
779
  const reconciled = await this.reconcileWithDisk();
780
+ assertSafeKnowledge(this.state);
781
+
782
+ // Penulis basi tidak boleh menang.
783
+ //
784
+ // Bug 29 Okt 2026: satu proses MCP memegang state lama, lalu menyimpan
785
+ // sesudah proses lain memulai task baru. Task baru tertimpa dan pemanggil
786
+ // tetap diberi tahu "berhasil" - `get_workflow_status` berikutnya
787
+ // menampilkan task yang sama sekali lain.
788
+ const conflict = this.detectStaleOverwrite(reconciled, options);
789
+ if (conflict) {
790
+ await this.adoptDiskState();
791
+ this.lastConflict = conflict;
792
+ return { ...reconciled, conflict, verified: false };
793
+ }
794
+ this.lastConflict = null;
795
+
689
796
  this.state.owner = {
690
- token: PROCESS_TOKEN,
797
+ processMarker: PROCESS_TOKEN,
691
798
  pid: process.pid,
692
799
  updatedAt: new Date().toISOString(),
693
800
  };
@@ -699,9 +806,82 @@ export class WorkflowState {
699
806
  await this.updateProjectSummary();
700
807
  await this.syncToDatabase();
701
808
  this.lastReconcile = reconciled;
809
+ this.loadedTask = this.state.taskDescription || "";
810
+ await this.verifyPersisted(this.loadedTask);
702
811
  return reconciled;
703
812
  }
704
813
 
814
+ /**
815
+ * Apakah penyimpanan ini akan menimpa task berjalan milik proses lain?
816
+ *
817
+ * Konflik hanya kalau SEMUA ini benar:
818
+ * - disk memuat task yang SEDANG berjalan (bukan idle),
819
+ * - task di disk bukan task yang terakhir kita muat (jadi ditulis orang
820
+ * lain sesudah kita membaca),
821
+ * - task di disk bukan task yang akan kita tulis (jadi bukan kita sendiri),
822
+ * - pemanggil tidak menyatakan maksud mengganti task.
823
+ *
824
+ * `start_task` memang bermaksud mengganti task, jadi ia memakai
825
+ * `allowReplaceRunningTask: true` dan tetap menang - tetapi task yang diganti
826
+ * diarsipkan oleh `reconcileWithDisk`, bukan dihapus.
827
+ */
828
+ detectStaleOverwrite(reconciled, options = {}) {
829
+ if (options.allowReplaceRunningTask === true) {
830
+ return null;
831
+ }
832
+ const diskTask = reconciled?.diskTask || "";
833
+ const diskPhase = reconciled?.diskPhase || "idle";
834
+ if (!diskTask || diskPhase === "idle") {
835
+ return null;
836
+ }
837
+ if (diskTask === this.loadedTask) {
838
+ return null;
839
+ }
840
+ if (diskTask === (this.state.taskDescription || "")) {
841
+ return null;
842
+ }
843
+ return {
844
+ diskTask,
845
+ staleTask: this.state.taskDescription || "",
846
+ diskPhase,
847
+ };
848
+ }
849
+
850
+ /** Ambil state dari disk apa adanya, dengan history digabung, tanpa menulis. */
851
+ async adoptDiskState() {
852
+ const disk = await this.readDiskState();
853
+ if (!disk) {
854
+ return false;
855
+ }
856
+ const mergedHistory = this.state.history || [];
857
+ this.state = { ...disk, history: mergedHistory };
858
+ this.mergeDiskHistory(disk);
859
+ this.loadedTask = this.state.taskDescription || "";
860
+ return true;
861
+ }
862
+
863
+ /**
864
+ * Pastikan tulisan kita benar-benar bertahan.
865
+ *
866
+ * Tanpa ini, proses yang kalah balapan tetap melaporkan sukses. Lebih baik
867
+ * gagal terang-terangan daripada memberi tahu "berhasil" untuk state yang
868
+ * sudah ditimpa proses lain.
869
+ */
870
+ async verifyPersisted(expectedTask) {
871
+ const disk = await this.readDiskState();
872
+ if (!disk) {
873
+ return true;
874
+ }
875
+ const actual = disk.taskDescription || "";
876
+ if (actual !== (expectedTask || "")) {
877
+ throw new Error(
878
+ `State workflow tidak bertahan: yang ditulis "${expectedTask}" tetapi di disk "${actual}". ` +
879
+ `Proses MCP lain menimpanya. Muat ulang state sebelum melanjutkan.`
880
+ );
881
+ }
882
+ return true;
883
+ }
884
+
705
885
  /**
706
886
  * Baca isi berkas state APA ADANYA dari disk (bukan dari memori).
707
887
  * Mengembalikan null kalau belum ada / rusak.
@@ -709,7 +889,7 @@ export class WorkflowState {
709
889
  async readDiskState() {
710
890
  try {
711
891
  const data = await fs.readFile(this.stateFile, "utf-8");
712
- return JSON.parse(data);
892
+ return sanitize(migrateOwnerMetadata(JSON.parse(data)));
713
893
  } catch {
714
894
  return null;
715
895
  }
@@ -717,7 +897,7 @@ export class WorkflowState {
717
897
 
718
898
  /** True kalau berkas di disk terakhir ditulis proses MCP lain. */
719
899
  isForeignWrite(diskState) {
720
- const token = diskState?.owner?.token;
900
+ const token = diskState?.owner?.processMarker;
721
901
  return typeof token === "string" && token.length > 0 && token !== PROCESS_TOKEN;
722
902
  }
723
903
 
@@ -756,19 +936,27 @@ export class WorkflowState {
756
936
  * pekerjaan resize docker-alma tidak pernah muncul di view_history.
757
937
  */
758
938
  async reconcileWithDisk() {
759
- const result = { mergedHistory: 0, preservedTask: null, foreignToken: null };
939
+ const result = {
940
+ mergedHistory: 0,
941
+ preservedTask: null,
942
+ foreignToken: null,
943
+ diskTask: "",
944
+ diskPhase: "idle",
945
+ };
760
946
  const disk = await this.readDiskState();
761
947
  if (!disk) {
762
948
  return result;
763
949
  }
764
950
 
951
+ result.diskTask = disk.taskDescription || "";
952
+ result.diskPhase = disk.currentPhase || "idle";
765
953
  result.mergedHistory = this.mergeDiskHistory(disk);
766
954
 
767
955
  if (!this.isForeignWrite(disk)) {
768
956
  return result;
769
957
  }
770
958
 
771
- result.foreignToken = disk.owner.token;
959
+ result.foreignToken = disk.owner.processMarker;
772
960
 
773
961
  const hasRunningTask =
774
962
  typeof disk.taskDescription === "string" &&
@@ -788,6 +976,7 @@ export class WorkflowState {
788
976
  }
789
977
 
790
978
  this.state.history.push({
979
+ classification: disk.classification || "INTERNAL",
791
980
  taskDescription: disk.taskDescription,
792
981
  taskType: disk.taskType || "other",
793
982
  commitMessage: disk.lastCommitMessage || "",
@@ -796,7 +985,7 @@ export class WorkflowState {
796
985
  dropReason:
797
986
  "Ditimpa proses agen lain: state workflow dipakai bersama antar sesi di mesin ini",
798
987
  interruptedAtPhase: disk.currentPhase,
799
- interruptedBy: disk.owner.token,
988
+ interruptedBy: disk.owner.processMarker,
800
989
  timestamp: new Date().toISOString(),
801
990
  });
802
991
  result.preservedTask = disk.taskDescription;
@@ -804,29 +993,52 @@ export class WorkflowState {
804
993
  }
805
994
 
806
995
  async updateProjectSummary() {
996
+ assertSafeKnowledge(this.state);
807
997
  const summaryPath = path.join(path.dirname(this.stateFile), PROJECT_SUMMARY_FILENAME);
808
998
  const summary = this.generateProjectSummaryData();
999
+ assertSafeKnowledge(summary);
809
1000
  await fs.mkdir(path.dirname(summaryPath), { recursive: true });
810
1001
  await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
811
1002
  }
812
1003
 
813
- async syncToDatabase() {
814
- try {
815
- const { insertHistoryEntry, updateSummaryForUser, saveState } = await import("./db/index.js");
816
- const userId = getCurrentUserId();
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);
821
-
822
- for (const entry of this.state.history || []) {
823
- await insertHistoryEntry(userId, projectRoot, entry);
1004
+ /**
1005
+ * Menyalin history, ringkasan, dan state ke database.
1006
+ *
1007
+ * `db` dapat diinjeksi untuk pengujian; pemanggil biasa tidak mengirimnya.
1008
+ *
1009
+ * Satu entri yang gagal TIDAK boleh menghentikan sisanya. Dulu kegagalan
1010
+ * pertama (mis. entri lama tanpa `task_type`) membatalkan seluruh fungsi,
1011
+ * sehingga `updateSummaryForUser` dan `saveState` ikut terlewat dan riwayat
1012
+ * proyek diam-diam berhenti tersimpan sama sekali.
1013
+ */
1014
+ async syncToDatabase(options = {}) {
1015
+ assertSafeKnowledge(this.state);
1016
+ const db = options.db ?? (await import("./db/index.js"));
1017
+ const userId = getCurrentUserId();
1018
+ // projectRoot dipakai sebagai identitas proyek di database. Ditentukan
1019
+ // lewat resolveProjectRootForState, bukan dengan menghitung `dirname`
1020
+ // berkali-kali: jumlah itu hanya benar untuk satu kedalaman tertentu.
1021
+ const projectRoot = resolveProjectRootForState(this.stateFile);
1022
+
1023
+ let skipped = 0;
1024
+ for (const entry of this.state.history || []) {
1025
+ try {
1026
+ await db.insertHistoryEntry(userId, projectRoot, entry);
1027
+ } catch (error) {
1028
+ skipped++;
1029
+ safeLog("error", "History entry skipped:", error.message);
824
1030
  }
825
- await updateSummaryForUser(userId, projectRoot);
826
- await saveState(userId, projectRoot, this.state);
1031
+ }
1032
+ if (skipped > 0) {
1033
+ safeLog("error", `History sync skipped ${skipped} entr${skipped === 1 ? "y" : "ies"}.`);
1034
+ }
1035
+
1036
+ try {
1037
+ await db.updateSummaryForUser(userId, projectRoot);
1038
+ await db.saveState(userId, projectRoot, this.state);
827
1039
  } catch (error) {
828
- // Database sync failed; log it but don't crash
829
- console.error("Database sync failed:", error.message);
1040
+ // Ringkasan dan state tetap opsional: jangan pernah menggagalkan save().
1041
+ safeLog("error", "Database sync failed:", error.message);
830
1042
  }
831
1043
  }
832
1044
 
@@ -892,6 +1104,7 @@ export class WorkflowState {
892
1104
  if (archive && hasRunningTask) {
893
1105
  archivedTask = previous.taskDescription;
894
1106
  history.push({
1107
+ classification: previous.classification || "INTERNAL",
895
1108
  taskDescription: previous.taskDescription,
896
1109
  taskType: previous.taskType || "other",
897
1110
  commitMessage: previous.lastCommitMessage || "",
@@ -906,6 +1119,7 @@ export class WorkflowState {
906
1119
 
907
1120
  this.state = {
908
1121
  currentPhase: "idle",
1122
+ classification: "INTERNAL",
909
1123
  taskDescription: "",
910
1124
  bugFixed: false,
911
1125
  testsCreated: false,
@@ -934,6 +1148,7 @@ export class WorkflowState {
934
1148
 
935
1149
  addToHistory(entry) {
936
1150
  this.state.history.push({
1151
+ classification: this.state.classification || "INTERNAL",
937
1152
  ...entry,
938
1153
  timestamp: new Date().toISOString(),
939
1154
  });