@programinglive/dev-workflow-mcp-server 1.13.10 → 1.13.12

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.
package/git-facade.js CHANGED
@@ -10,8 +10,10 @@
10
10
 
11
11
  import {
12
12
  getCurrentBranch,
13
+ getHeadSha,
13
14
  getLastCommitMessage,
14
15
  getPrimaryBranch,
16
+ getRemoteBranchSha,
15
17
  getStagedChanges,
16
18
  getStatusOutput,
17
19
  hasStagedChanges,
@@ -27,7 +29,9 @@ export function createGitFacade() {
27
29
  hasStagedChanges,
28
30
  getStagedChanges,
29
31
  getCurrentBranch,
32
+ getHeadSha,
30
33
  getPrimaryBranch,
34
+ getRemoteBranchSha,
31
35
  getLastCommitMessage,
32
36
  getStatusOutput,
33
37
  workingTreeSummary,
package/git-helpers.js CHANGED
@@ -145,6 +145,29 @@ export async function getCurrentBranch() {
145
145
  return stdout.trim();
146
146
  }
147
147
 
148
+ /**
149
+ * SHA commit yang sedang di HEAD.
150
+ *
151
+ * Dipakai untuk membuktikan bahwa push benar-benar mengirim commit ini, bukan
152
+ * hanya "git push" yang keluar 0 tanpa memindahkan apa pun.
153
+ */
154
+ export async function getHeadSha() {
155
+ const { stdout } = await exec("git rev-parse HEAD");
156
+ return stdout.trim();
157
+ }
158
+
159
+ /**
160
+ * SHA yang tercatat pada remote-tracking ref `origin/<branch>`.
161
+ * Melempar bila branch itu belum ada di remote.
162
+ */
163
+ export async function getRemoteBranchSha(branch) {
164
+ const { stdout } = await exec("git rev-parse", {
165
+ executable: "git",
166
+ arguments: ["rev-parse", `origin/${branch}`],
167
+ });
168
+ return stdout.trim();
169
+ }
170
+
148
171
  export async function getStatusOutput() {
149
172
  const { stdout } = await exec("git status --porcelain");
150
173
  return stdout;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@programinglive/dev-workflow-mcp-server",
3
- "version": "1.13.10",
3
+ "version": "1.13.12",
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",
@@ -0,0 +1,111 @@
1
+ // Verifikasi end-to-end commit_and_push: harus mem-push branch AKTIF.
2
+ // Memanggil handler asli (bukan mock) terhadap repo sementara + remote bare.
3
+ import { execFileSync } from "node:child_process";
4
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import path from "node:path";
7
+
8
+ import { handleToolCall } from "../tools/handlers.js";
9
+ import { WorkflowState } from "../workflow-state.js";
10
+ import { shellEscape, normalizeRequestArgs } from "../utils.js";
11
+ import { createCommitMessageParts, determineReleaseTypeFromCommit } from "../commit-helpers.js";
12
+ import { createGitFacade } from "../git-facade.js";
13
+ import { exec } from "../exec.js";
14
+
15
+ const git = (cwd, ...args) =>
16
+ execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
17
+
18
+ const root = mkdtempSync(path.join(tmpdir(), "push-verify-"));
19
+ const remote = path.join(root, "remote.git");
20
+ const work = path.join(root, "work");
21
+
22
+ try {
23
+ git(root, "init", "--bare", "-q", remote);
24
+ git(root, "clone", "-q", remote, work);
25
+ git(work, "config", "user.email", "test@example.test");
26
+ git(work, "config", "user.name", "Test");
27
+
28
+ writeFileSync(path.join(work, "base.txt"), "awal\n");
29
+ git(work, "add", ".");
30
+ git(work, "commit", "-q", "-m", "chore: initial");
31
+ git(work, "branch", "-M", "main");
32
+ git(work, "push", "-q", "origin", "main");
33
+
34
+ // Branch fitur dengan commit yang BELUM di-push.
35
+ git(work, "checkout", "-q", "-b", "feat/verifikasi");
36
+ writeFileSync(path.join(work, "fitur.txt"), "fitur\n");
37
+ git(work, "add", ".");
38
+ git(work, "commit", "-q", "-m", "feat: pekerjaan di branch fitur");
39
+
40
+ const headBefore = git(work, "rev-parse", "HEAD");
41
+ const mainBefore = git(work, "rev-parse", "main");
42
+
43
+ // Perubahan yang BELUM di-commit, supaya tool menempuh jalur commit + push
44
+ // (bukan jalur "no changes detected").
45
+ writeFileSync(path.join(work, "belum-commit.txt"), "perubahan\n");
46
+
47
+ console.log("branch aktif :", git(work, "branch", "--show-current"));
48
+ console.log("HEAD awal :", headBefore.slice(0, 8));
49
+ let remoteFeatBefore = "(belum ada)";
50
+ try {
51
+ remoteFeatBefore = git(work, "rev-parse", "--verify", "-q", "origin/feat/verifikasi").slice(0, 8);
52
+ } catch { /* belum ada */ }
53
+ console.log("origin/feat :", remoteFeatBefore);
54
+
55
+ const stateFile = path.join(root, "state.json");
56
+ const workflowState = new WorkflowState(stateFile);
57
+ await workflowState.load();
58
+ Object.assign(workflowState.state, {
59
+ currentPhase: "commit",
60
+ taskDescription: "verifikasi push branch",
61
+ taskType: "other",
62
+ featureFlowCreated: true,
63
+ bugFixed: true,
64
+ testsCreated: true,
65
+ testsPassed: true,
66
+ testsSkipped: true,
67
+ documentationCreated: true,
68
+ readyToCommit: true,
69
+ readyCheckCompleted: true,
70
+ commitAndPushCompleted: false,
71
+ projectDir: work,
72
+ history: [],
73
+ });
74
+ await workflowState.save();
75
+
76
+ const response = await handleToolCall({
77
+ request: {
78
+ params: {
79
+ name: "commit_and_push",
80
+ arguments: { commitMessage: "feat: uji push", projectDir: work },
81
+ },
82
+ },
83
+ normalizeRequestArgs,
84
+ workflowState,
85
+ exec,
86
+ git: createGitFacade(),
87
+ utils: { shellEscape, determineReleaseTypeFromCommit, createCommitMessageParts },
88
+ });
89
+
90
+ console.log("\n=== output tool ===");
91
+ console.log(response.content[0].text);
92
+
93
+ console.log("\n=== hasil di remote ===");
94
+ let remoteFeat = "";
95
+ try {
96
+ remoteFeat = git(work, "rev-parse", "--verify", "-q", "origin/feat/verifikasi");
97
+ } catch { /* tidak ada */ }
98
+ const remoteMain = git(work, "rev-parse", "main");
99
+ console.log("origin/feat/verifikasi :", remoteFeat ? remoteFeat.slice(0, 8) : "(tidak ada)");
100
+ console.log("origin/main :", remoteMain.slice(0, 8));
101
+
102
+ const headAfter = git(work, "rev-parse", "HEAD");
103
+ const okFeat = remoteFeat === headAfter;
104
+ const okMain = remoteMain === mainBefore;
105
+ console.log("");
106
+ console.log(okFeat ? "BENAR: remote menerima commit di branch fitur" : "SALAH: remote/feat tidak menerima HEAD");
107
+ console.log(okMain ? "BENAR: main tidak tersentuh" : "SALAH: main ikut berubah");
108
+ process.exitCode = okFeat && okMain ? 0 : 1;
109
+ } finally {
110
+ rmSync(root, { recursive: true, force: true });
111
+ }
@@ -43,6 +43,62 @@ function applyProjectDirOverride(args) {
43
43
  }
44
44
  }
45
45
 
46
+ /**
47
+ * Branch yang benar-benar dipakai untuk push.
48
+ *
49
+ * Urutan: `branch` eksplisit -> branch aktif -> primary branch.
50
+ *
51
+ * Sebelum 31 Okt 2026 urutannya primaryBranch lebih dulu, dan `getPrimaryBranch()`
52
+ * selalu menjawab "main" bila `origin/main` ada - tanpa melihat branch aktif.
53
+ * Akibatnya di worktree yang sedang di `feat/...`, tool menjalankan
54
+ * `git push origin main`. Karena `main` lokal sudah sama dengan `origin/main`,
55
+ * perintah itu keluar 0 tanpa mengirim apa pun, lalu tool melaporkan
56
+ * "Commit and push completed". Dua commit nyata tidak pernah ter-push.
57
+ *
58
+ * HEAD yang detached ("HEAD") bukan branch, jadi tidak bisa dipakai.
59
+ */
60
+ function pickPushBranch({ requestedBranch, currentBranch, primaryBranch }) {
61
+ if (requestedBranch) return requestedBranch;
62
+ if (currentBranch && currentBranch !== "HEAD") return currentBranch;
63
+ return primaryBranch || currentBranch || "";
64
+ }
65
+
66
+ /**
67
+ * Pastikan remote benar-benar menerima HEAD.
68
+ *
69
+ * `git push` bisa keluar 0 tanpa memindahkan apa pun - itulah yang menyamarkan
70
+ * bug di atas. Satu-satunya bukti yang bisa dipercaya adalah membandingkan SHA.
71
+ */
72
+ async function verifyPushed(git, branch) {
73
+ let head;
74
+ try {
75
+ head = await git.getHeadSha();
76
+ } catch (error) {
77
+ return { ok: false, detail: `tidak bisa membaca HEAD: ${error.message}` };
78
+ }
79
+
80
+ let remote;
81
+ try {
82
+ remote = await git.getRemoteBranchSha(branch);
83
+ } catch {
84
+ return {
85
+ ok: false,
86
+ detail: `branch origin/${branch} tidak ditemukan di remote setelah push`,
87
+ };
88
+ }
89
+
90
+ if (remote !== head) {
91
+ return {
92
+ ok: false,
93
+ detail:
94
+ `origin/${branch} berada di ${remote.slice(0, 8)}, ` +
95
+ `sedangkan HEAD di ${head.slice(0, 8)}`,
96
+ };
97
+ }
98
+
99
+ return { ok: true, head };
100
+ }
101
+
46
102
  export async function handleCommitAndPush(args, context) {
47
103
  const { workflowState, exec, git, utils } = context;
48
104
 
@@ -84,12 +140,25 @@ export async function handleCommitAndPush(args, context) {
84
140
  );
85
141
  }
86
142
 
87
- const branchForPush = requestedBranch || primaryBranch || currentBranch || "";
143
+ const branchForPush = pickPushBranch({ requestedBranch, currentBranch, primaryBranch });
88
144
 
89
145
  if (!hasWorkingChanges) {
90
146
  const lastCommitMessage = await git.getLastCommitMessage();
91
147
  const effectiveCommitMessage = lastCommitMessage || providedCommitMessage;
92
148
 
149
+ // "Tidak ada perubahan" bukan berarti "sudah ter-push". HEAD bisa saja
150
+ // berupa commit yang belum pernah dikirim. Memeriksanya mencegah tool
151
+ // menandai selesai padahal remote masih tertinggal.
152
+ const pushed = await verifyPushed(git, branchForPush);
153
+ if (!pushed.ok) {
154
+ return textResponse(
155
+ `⚠️ Tidak ada perubahan untuk di-commit, tetapi HEAD BELUM ada di remote.\n` +
156
+ `Repo: ${repoLabel()}\nBranch: ${branchForPush || "(tidak diketahui)"}\n` +
157
+ `Sebab: ${pushed.detail}\n\n` +
158
+ `Jalankan 'commit_and_push' lagi dengan 'branch' yang benar, atau push manual.`
159
+ );
160
+ }
161
+
93
162
  workflowState.state.commitAndPushCompleted = true;
94
163
  workflowState.state.lastCommitMessage = effectiveCommitMessage || "";
95
164
  workflowState.state.lastPushBranch = branchForPush;
@@ -97,7 +166,7 @@ export async function handleCommitAndPush(args, context) {
97
166
  await workflowState.save();
98
167
 
99
168
  return textResponse(
100
- `ℹ️ No changes detected. Assuming commit and push already completed.\nRepo: ${repoLabel()}\nLast commit: ${effectiveCommitMessage || "(unavailable)"
169
+ `ℹ️ No changes detected. Assuming commit and push already completed.\nRepo: ${repoLabel()}\nBranch: ${branchForPush} (verified on remote)\nLast commit: ${effectiveCommitMessage || "(unavailable)"
101
170
  }\n\nNext: Run 'perform_release' to handle the release and push tags.`
102
171
  );
103
172
  }
@@ -152,6 +221,20 @@ export async function handleCommitAndPush(args, context) {
152
221
  );
153
222
  }
154
223
 
224
+ // Exit code 0 dari `git push` BUKAN bukti bahwa commit ini terkirim. Kalau
225
+ // branch yang dipilih sudah sinkron, git tidak melakukan apa pun dan tetap
226
+ // keluar 0. Satu-satunya bukti yang bisa dipercaya: SHA HEAD ada di remote.
227
+ const pushed = await verifyPushed(git, branchForPush);
228
+ if (!pushed.ok) {
229
+ return textResponse(
230
+ `❌ Commit dibuat, tetapi TIDAK ter-push.\n` +
231
+ `Repo: ${repoLabel()}\nBranch: ${branchForPush || "(tidak diketahui)"}\n` +
232
+ `Sebab: ${pushed.detail}\n\n` +
233
+ `Commit lokal: ${generatedSummary}\n\n` +
234
+ `Jalankan 'commit_and_push' lagi dengan 'branch' yang benar, atau push manual.`
235
+ );
236
+ }
237
+
155
238
  workflowState.state.commitAndPushCompleted = true;
156
239
  workflowState.state.lastCommitMessage = generatedSummary;
157
240
  workflowState.state.lastPushBranch = branchForPush;
@@ -159,8 +242,7 @@ export async function handleCommitAndPush(args, context) {
159
242
  await workflowState.save();
160
243
 
161
244
  return textResponse(
162
- `✅ Commit and push completed!\nRepo: ${repoLabel()}\n\nCommit message: ${generatedSummary}\nPushed to: ${branchForPush || "(default upstream)"
163
- }\n\nNext: Run 'perform_release' to handle the release and push tags.`
245
+ `✅ Commit and push completed!\nRepo: ${repoLabel()}\n\nCommit message: ${generatedSummary}\nPushed to: ${branchForPush} (verified at ${pushed.head.slice(0, 8)})\n\nNext: Run 'perform_release' to handle the release and push tags.`
164
246
  );
165
247
  }
166
248
 
package/workflow-state.js CHANGED
@@ -33,6 +33,22 @@ let cachedUserStateDir = null;
33
33
  */
34
34
  const PROCESS_TOKEN = `proc-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
35
35
 
36
+ /**
37
+ * Langkah workflow yang hanya boleh MAJU dalam satu task.
38
+ *
39
+ * Hanya flag PENCAPAIAN. Gerbang kualitas (`readyToCommit`,
40
+ * `readyCheckCompleted`, `commitAndPushCompleted`, `released`) SENGAJA tidak
41
+ * masuk: keempatnya direset ketika langkah sebelumnya diulang, dan memaksa
42
+ * nilainya tetap true akan melemahkan gerbang yang justru ingin ditegakkan.
43
+ */
44
+ const MONOTONIC_STEP_FIELDS = [
45
+ "featureFlowCreated",
46
+ "bugFixed",
47
+ "testsCreated",
48
+ "testsPassed",
49
+ "documentationCreated",
50
+ ];
51
+
36
52
  function migrateOwnerMetadata(value) {
37
53
  if(value?.owner && !value.owner.processMarker && /^proc-[A-Za-z0-9-]+$/.test(value.owner.token || '')) {
38
54
  value.owner.processMarker=value.owner.token;
@@ -820,6 +836,44 @@ export class WorkflowState {
820
836
  return created;
821
837
  }
822
838
 
839
+ /**
840
+ * Gabungkan kemajuan langkah dari disk bila task-nya sama.
841
+ *
842
+ * Hanya menyentuh flag yang MONOTON dalam satu task. Field yang bisa turun
843
+ * dengan sengaja (mis. `readyCheckCompleted` yang direset `create_tests`)
844
+ * tidak digabung, karena itu bagian dari alur normal satu proses.
845
+ *
846
+ * `reconciled` sudah memuat state disk terbaru; `this.state` adalah milik
847
+ * proses ini. Kalau task berbeda atau disk idle, tidak ada yang digabung:
848
+ * itu task lain, atau task baru yang memang harus mulai dari nol.
849
+ */
850
+ mergeMonotonicProgress(reconciled) {
851
+ const diskTask = (reconciled?.diskTask || "").trim();
852
+ const ourTask = (this.state.taskDescription || "").trim();
853
+ if (!diskTask || !ourTask || diskTask !== ourTask) {
854
+ return;
855
+ }
856
+ if ((reconciled?.diskPhase || "idle") === "idle") {
857
+ return;
858
+ }
859
+
860
+ // Harus dari `reconciled` yang BARU dibaca. `lastReconcile` adalah hasil
861
+ // penyimpanan sebelumnya, sehingga bisa tertinggal satu putaran - dan
862
+ // justru itu yang membuat penggabungan gagal menangkap langkah terbaru.
863
+ const disk = reconciled?.diskState;
864
+ if (!disk) {
865
+ return;
866
+ }
867
+
868
+ // Kemajuan hanya boleh maju. Sekali sebuah langkah tercapai di proses mana
869
+ // pun, langkah itu tetap tercapai.
870
+ for (const field of MONOTONIC_STEP_FIELDS) {
871
+ if (disk[field] === true && this.state[field] !== true) {
872
+ this.state[field] = true;
873
+ }
874
+ }
875
+ }
876
+
823
877
  async save(options = {}) {
824
878
  assertSafeKnowledge(this.state);
825
879
  const reconciled = await this.reconcileWithDisk();
@@ -839,6 +893,19 @@ export class WorkflowState {
839
893
  }
840
894
  this.lastConflict = null;
841
895
 
896
+ // Dua proses MCP bisa melayani sesi yang SAMA (OpenCode men-spawn lebih dari
897
+ // satu instance), dan keduanya menulis berkas state yang sama. Tanpa ini,
898
+ // proses yang memegang state basi menimpa langkah yang sudah dicapai proses
899
+ // lain: `mark_bug_fixed` menjawab sukses, lalu `create_tests` menjawab
900
+ // "mark your feature/bug as fixed first". Gejalanya intermiten, sehingga
901
+ // mudah disalahkan pada hal lain.
902
+ //
903
+ // Langkah workflow bersifat monoton dalam satu task: sekali tercapai, tidak
904
+ // boleh mundur. Karena itu digabung dengan OR - tetapi HANYA bila task di
905
+ // disk memang task yang sama. `start_task` mengganti task, dan pada saat itu
906
+ // flag harus benar-benar direset, bukan dihidupkan kembali.
907
+ this.mergeMonotonicProgress(reconciled);
908
+
842
909
  this.state.owner = {
843
910
  processMarker: PROCESS_TOKEN,
844
911
  pid: process.pid,
@@ -988,11 +1055,15 @@ export class WorkflowState {
988
1055
  foreignToken: null,
989
1056
  diskTask: "",
990
1057
  diskPhase: "idle",
1058
+ // Dipakai mergeMonotonicProgress: state disk apa adanya, supaya flag
1059
+ // langkah yang sudah dicapai proses lain bisa digabung.
1060
+ diskState: null,
991
1061
  };
992
1062
  const disk = await this.readDiskState();
993
1063
  if (!disk) {
994
1064
  return result;
995
1065
  }
1066
+ result.diskState = disk;
996
1067
 
997
1068
  result.diskTask = disk.taskDescription || "";
998
1069
  result.diskPhase = disk.currentPhase || "idle";