@programinglive/dev-workflow-mcp-server 1.13.13 → 1.13.15

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
@@ -16,6 +16,7 @@ import {
16
16
  getRemoteBranchSha,
17
17
  getStagedChanges,
18
18
  getStatusOutput,
19
+ getToplevel,
19
20
  hasStagedChanges,
20
21
  hasTestChanges,
21
22
  hasWorkingChanges,
@@ -34,6 +35,7 @@ export function createGitFacade() {
34
35
  getRemoteBranchSha,
35
36
  getLastCommitMessage,
36
37
  getStatusOutput,
38
+ getToplevel,
37
39
  workingTreeSummary,
38
40
  };
39
41
  }
package/git-helpers.js CHANGED
@@ -173,6 +173,18 @@ export async function getStatusOutput() {
173
173
  return stdout;
174
174
  }
175
175
 
176
+ /**
177
+ * Direktori teratas repo tempat git BENAR-BENAR berjalan.
178
+ *
179
+ * `git rev-parse --show-toplevel` menjawab dari repo yang aktif, bukan dari
180
+ * tebakan kita tentang direktori mana yang dimaksud. Dipakai untuk memastikan
181
+ * laporan `commit_and_push` menyebut repo yang sama dengan yang di-commit.
182
+ */
183
+ export async function getToplevel() {
184
+ const { stdout } = await exec("git rev-parse --show-toplevel");
185
+ return stdout.trim();
186
+ }
187
+
176
188
  export async function getPrimaryBranch() {
177
189
  try {
178
190
  // Check if 'main' exists on origin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@programinglive/dev-workflow-mcp-server",
3
- "version": "1.13.13",
3
+ "version": "1.13.15",
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",
@@ -21,7 +21,7 @@
21
21
  "start": "node index.js",
22
22
  "local": "node index.js",
23
23
  "setup": "node scripts/setup-stack.js",
24
- "test": "node --no-deprecation --test",
24
+ "test": "node --no-deprecation --import ./tests/setup-env.js --test",
25
25
  "test:security": "node --no-deprecation --test tests/security*.test.js",
26
26
  "security:audit": "node index.js call security_audit",
27
27
  "lint": "node scripts/lint.mjs",
@@ -2,6 +2,7 @@ import { textResponse, isReleaseSatisfied, formatChecklist } from "./shared.js";
2
2
  import { handleReadyCheck } from "./status-handlers.js";
3
3
  import { containsTestFilesInStatus } from "../../git-helpers.js";
4
4
  import { resolveProjectDir, setActiveProjectDir, getActiveProjectDir } from "../../exec.js";
5
+ import path from "path";
5
6
 
6
7
  /**
7
8
  * Label repo yang benar-benar dipakai, untuk ditampilkan di setiap jawaban.
@@ -24,6 +25,18 @@ function repoLabel() {
24
25
  }
25
26
  }
26
27
 
28
+ /**
29
+ * Apakah dua path menunjuk direktori yang sama.
30
+ *
31
+ * Windows tidak membedakan huruf besar/kecil dan kadang memberi garis miring
32
+ * di akhir, jadi perbandingan string mentah akan salah.
33
+ */
34
+ function samePath(a, b) {
35
+ if (!a || !b) return false;
36
+ const normal = (value) => path.resolve(value).replace(/[\\/]+$/, "").toLowerCase();
37
+ return normal(a) === normal(b);
38
+ }
39
+
27
40
  /**
28
41
  * Terapkan `projectDir` dari argumen tool bila ada.
29
42
  * Mengembalikan pesan kesalahan, atau null bila tidak ada masalah.
@@ -127,10 +140,21 @@ export async function handleCommitAndPush(args, context) {
127
140
  let primaryBranch;
128
141
  let currentBranch;
129
142
  let hasWorkingChanges;
143
+ let gitToplevel = "";
130
144
  try {
131
145
  primaryBranch = await git.getPrimaryBranch();
132
146
  currentBranch = await git.getCurrentBranch();
133
147
  hasWorkingChanges = await git.hasWorkingChanges();
148
+
149
+ // Tempat git BENAR-BENAR berjalan, menurut git sendiri.
150
+ //
151
+ // 31 Oktober 2026 laporan menyebut `portal-beautyworld` untuk commit yang
152
+ // masuk ke repo lain, karena labelnya dibaca dari variabel global yang bisa
153
+ // berubah - bukan dari repo tempat git berjalan. Laporan seperti itu membuat
154
+ // agen percaya commit-nya ada di tempat yang salah.
155
+ if (typeof git.getToplevel === "function") {
156
+ gitToplevel = String((await git.getToplevel()) || "").trim();
157
+ }
134
158
  } catch (error) {
135
159
  return textResponse(
136
160
  `❌ Tidak bisa memeriksa repository Git, jadi tidak ada yang di-commit.\n\n` +
@@ -140,20 +164,40 @@ export async function handleCommitAndPush(args, context) {
140
164
  );
141
165
  }
142
166
 
167
+ // Kalau git berjalan di repo lain daripada yang diminta, BERHENTI. Commit ke
168
+ // repo yang salah jauh lebih mahal daripada satu pesan kesalahan.
169
+ const requestedRepo = getActiveProjectDir();
170
+ if (gitToplevel && requestedRepo && !samePath(gitToplevel, requestedRepo)) {
171
+ return textResponse(
172
+ `❌ Dibatalkan: git berjalan di repo yang BERBEDA dari yang diminta.\n\n` +
173
+ `Diminta : ${requestedRepo}\n` +
174
+ `Dipakai git: ${gitToplevel}\n\n` +
175
+ `Tidak ada yang di-commit. Sebutkan 'projectDir' yang benar pada 'commit_and_push', ` +
176
+ `atau jalankan ulang 'start_task' dengan 'projectDir' repo tersebut.`
177
+ );
178
+ }
179
+
180
+ // Label laporan memakai repo yang benar-benar dipakai git bila diketahui.
181
+ const repoName = gitToplevel || repoLabel();
182
+
143
183
  const branchForPush = pickPushBranch({ requestedBranch, currentBranch, primaryBranch });
144
184
 
145
185
  if (!hasWorkingChanges) {
146
186
  const lastCommitMessage = await git.getLastCommitMessage();
147
187
  const effectiveCommitMessage = lastCommitMessage || providedCommitMessage;
148
188
 
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.
189
+ // "Tidak ada perubahan" bukan berarti "sudah ter-push", dan juga bukan
190
+ // berarti "panggilan ini sudah commit". HEAD bisa saja berupa commit yang
191
+ // belum pernah dikirim, dan bisa juga sudah terkirim oleh panggilan
192
+ // SEBELUMNYA. Memeriksanya mencegah tool menandai selesai padahal remote
193
+ // masih tertinggal - dan kalimat "Assuming commit and push already
194
+ // completed" (dipakai sampai 31 Oktober 2026) membuat agen percaya
195
+ // pekerjaannya sudah masuk padahal tidak ada yang dikerjakan sama sekali.
152
196
  const pushed = await verifyPushed(git, branchForPush);
153
197
  if (!pushed.ok) {
154
198
  return textResponse(
155
199
  `⚠️ Tidak ada perubahan untuk di-commit, tetapi HEAD BELUM ada di remote.\n` +
156
- `Repo: ${repoLabel()}\nBranch: ${branchForPush || "(tidak diketahui)"}\n` +
200
+ `Repo: ${repoName}\nBranch: ${branchForPush || "(tidak diketahui)"}\n` +
157
201
  `Sebab: ${pushed.detail}\n\n` +
158
202
  `Jalankan 'commit_and_push' lagi dengan 'branch' yang benar, atau push manual.`
159
203
  );
@@ -165,9 +209,20 @@ export async function handleCommitAndPush(args, context) {
165
209
  workflowState.state.currentPhase = isReleaseSatisfied(workflowState.state) ? "ready_to_complete" : "release";
166
210
  await workflowState.save();
167
211
 
212
+ const noActiveTask = workflowState.state.currentPhase === "idle" || !workflowState.state.taskDescription;
213
+
168
214
  return textResponse(
169
- `ℹ️ No changes detected. Assuming commit and push already completed.\nRepo: ${repoLabel()}\nBranch: ${branchForPush} (verified on remote)\nLast commit: ${effectiveCommitMessage || "(unavailable)"
170
- }\n\nNext: Run 'perform_release' to handle the release and push tags.`
215
+ `ℹ️ No changes detected. No commit was made by this call.\n` +
216
+ `Repo: ${repoName}\nBranch: ${branchForPush} (verified on remote, HEAD ${pushed.head.slice(0, 8)})\n` +
217
+ `Commit terakhir: ${effectiveCommitMessage || "(tidak terbaca)"}\n\n` +
218
+ (noActiveTask
219
+ ? `Tidak ada task aktif di state workflow (currentPhase "idle"), jadi memang tidak ada ` +
220
+ `yang diharapkan dari panggilan ini.\n\n`
221
+ : "") +
222
+ `Yang terbukti: commit terakhir sudah ada di remote. Yang TIDAK terbukti: commit itu berisi ` +
223
+ `pekerjaan task ini. Kalau perubahan seharusnya ada, berarti belum ditulis, sudah di-commit ` +
224
+ `oleh panggilan sebelumnya, atau berada di repo/branch lain.\n\n` +
225
+ `Next: Run 'perform_release' to handle the release and push tags.`
171
226
  );
172
227
  }
173
228
 
@@ -228,7 +283,7 @@ export async function handleCommitAndPush(args, context) {
228
283
  if (!pushed.ok) {
229
284
  return textResponse(
230
285
  `❌ Commit dibuat, tetapi TIDAK ter-push.\n` +
231
- `Repo: ${repoLabel()}\nBranch: ${branchForPush || "(tidak diketahui)"}\n` +
286
+ `Repo: ${repoName}\nBranch: ${branchForPush || "(tidak diketahui)"}\n` +
232
287
  `Sebab: ${pushed.detail}\n\n` +
233
288
  `Commit lokal: ${generatedSummary}\n\n` +
234
289
  `Jalankan 'commit_and_push' lagi dengan 'branch' yang benar, atau push manual.`
@@ -242,7 +297,7 @@ export async function handleCommitAndPush(args, context) {
242
297
  await workflowState.save();
243
298
 
244
299
  return textResponse(
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.`
300
+ `✅ Commit and push completed!\nRepo: ${repoName}\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.`
246
301
  );
247
302
  }
248
303
 
@@ -134,8 +134,19 @@ export async function handleClaimFactoryTask(args = {}, { workflowState } = {})
134
134
  // dikerjakan, dan papan terlihat penuh task mati.
135
135
  startKeeping(config, session, step);
136
136
 
137
+ // Arsipkan task lokal yang sedang berjalan, jangan hapus diam-diam.
138
+ //
139
+ // 31 Oktober 2026 claim_factory_task dipanggil di tengah task lain yang baru
140
+ // sampai tahap rilis. `reset()` tanpa archive membuat task itu lenyap: tidak
141
+ // ada entri history, tidak ada peringatan, tidak disebut di jawaban.
142
+ // `start_task` sudah mengarsipkan sejak awal; claim tidak.
143
+ let archivedTask = null;
137
144
  if (workflowState) {
138
- workflowState.reset();
145
+ const archived = workflowState.reset({
146
+ archive: true,
147
+ reason: "Diganti claim_factory_task sebelum task selesai",
148
+ });
149
+ archivedTask = archived?.archivedTask || null;
139
150
  workflowState.state.currentPhase = "coding";
140
151
  workflowState.state.taskDescription = `[${task.taskKey || task.id}] ${task.title}`;
141
152
  workflowState.state.taskType = "other";
@@ -145,6 +156,10 @@ export async function handleClaimFactoryTask(args = {}, { workflowState } = {})
145
156
  return textResponse(
146
157
  [
147
158
  `Claimed [${task.taskKey || task.id}] ${task.title}`,
159
+ archivedTask
160
+ ? `⚠️ Task lokal yang belum selesai diarsipkan ke history: "${archivedTask}". ` +
161
+ `Pekerjaannya tidak dihapus, tetapi tidak lagi aktif di sesi ini.`
162
+ : "",
148
163
  session.tookOver && session.previousHolder
149
164
  ? `Took over from ${session.previousHolder.deviceName} (${session.previousHolder.agent}). The reason is recorded on the board.`
150
165
  : "",
@@ -1,6 +1,42 @@
1
1
  import { textResponse, isReleaseSatisfied, resetToCommitIfWorkingChanges, summarizeHistory } from "./shared.js";
2
2
  import { resolveProjectDir, setActiveProjectDir, getActiveProjectDir } from "../../exec.js";
3
3
 
4
+ /**
5
+ * Label repo yang dipakai, untuk ditampilkan di laporan.
6
+ *
7
+ * Sama seperti di commit-handlers: laporan harus menyebut repo yang benar-benar
8
+ * dipakai, bukan tebakan.
9
+ */
10
+ function repoLabel() {
11
+ try {
12
+ const resolved = resolveProjectDir({
13
+ explicit: getActiveProjectDir(),
14
+ env: process.env,
15
+ candidates: [process.cwd(), process.env.INIT_CWD],
16
+ });
17
+ const origin = resolved.source === "env" ? " — dari WORKFLOW_PROJECT_DIR (global)" : "";
18
+ return resolved.dir + origin;
19
+ } catch {
20
+ return "(tidak bisa ditentukan)";
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Menjalankan perintah git yang hasilnya hanya dipakai untuk laporan.
26
+ *
27
+ * Kegagalan di sini tidak boleh menggagalkan rilis: kalau `git describe`
28
+ * tidak menemukan tag, itu bukan alasan untuk berhenti. Yang penting adalah
29
+ * rilis tetap berjalan dan laporannya jujur tentang apa yang tidak diketahui.
30
+ */
31
+ async function safeExecStdout(exec, command) {
32
+ try {
33
+ const { stdout } = await exec(command);
34
+ return String(stdout || "").trim();
35
+ } catch {
36
+ return "";
37
+ }
38
+ }
39
+
4
40
  export async function handleSkipRelease(args, workflowState) {
5
41
  if (workflowState.state.currentPhase === "idle") {
6
42
  return textResponse("⚠️ Please start a task first using 'start_task' before skipping release.");
@@ -185,11 +221,45 @@ export async function handlePerformRelease(args, context) {
185
221
  );
186
222
  }
187
223
 
224
+ // Tipe rilis WAJIB sah sebelum perintah disusun.
225
+ //
226
+ // 31 Oktober 2026: `determineReleaseTypeFromCommit` mengembalikan string kosong,
227
+ // lalu perintahnya menjadi `npm run release -- --release-as ` + "null" ->
228
+ // standard-version menulis `"version": null` dan membuat tag `vnull`, lalu
229
+ // keduanya di-push. Yang salah bukan hanya hasilnya: perintah yang mustahil
230
+ // itu tetap dijalankan tanpa perlawanan.
231
+ if (!recognizedTypes.has(releaseType)) {
232
+ return textResponse(
233
+ `⚠️ Tipe rilis tidak bisa ditentukan dari commit terakhir, jadi rilis TIDAK dijalankan.\n\n` +
234
+ `Commit terakhir: ${lastCommitMessage || "(kosong)"}\n\n` +
235
+ `Sebutkan tipe rilisnya secara eksplisit:\n` +
236
+ ` • 'perform_release' dengan releaseType: "patch" | "minor" | "major"\n` +
237
+ ` • atau command: "npm run release:patch"\n\n` +
238
+ `Tanpa itu perintahnya akan menjadi "--release-as null", yang menghasilkan tag vnull ` +
239
+ `dan package.json berversi null.`
240
+ );
241
+ }
242
+
188
243
  const finalCommand =
189
244
  releaseCommand.includes("--release-as") || /release:(major|minor|patch)/i.test(releaseCommand)
190
245
  ? releaseCommand
191
246
  : `${releaseCommand} -- --release-as ${releaseType}`;
192
247
 
248
+ // Jaring pengaman terakhir: apa pun yang terjadi di atas, jangan pernah
249
+ // menjalankan perintah yang membawa tipe rilis kosong.
250
+ if (/--release-as\s+(?:null|undefined|)\s*$/i.test(finalCommand) || /\snull\b/i.test(finalCommand)) {
251
+ return textResponse(
252
+ `⚠️ Perintah rilis akan berisi tipe rilis yang tidak sah, jadi TIDAK dijalankan:\n\n` +
253
+ ` ${finalCommand}\n\n` +
254
+ `Sebutkan tipe rilisnya: releaseType "patch" | "minor" | "major".`
255
+ );
256
+ }
257
+
258
+ // Catat keadaan SEBELUM rilis supaya, kalau push gagal, kita bisa menyebut
259
+ // persis apa yang tertinggal di mesin.
260
+ const headBefore = await safeExecStdout(exec, "git rev-parse HEAD");
261
+ const tagBefore = await safeExecStdout(exec, "git describe --tags --abbrev=0");
262
+
193
263
  try {
194
264
  await exec(finalCommand);
195
265
  } catch (error) {
@@ -198,6 +268,9 @@ export async function handlePerformRelease(args, context) {
198
268
  );
199
269
  }
200
270
 
271
+ const headAfter = await safeExecStdout(exec, "git rev-parse HEAD");
272
+ const tagAfter = await safeExecStdout(exec, "git describe --tags --abbrev=0");
273
+
201
274
  const branchForPush = workflowState.state.lastPushBranch || (await git.getCurrentBranch()) || "";
202
275
  const pushCommand = branchForPush
203
276
  ? `git push --follow-tags origin ${utils.shellEscape(branchForPush)}`
@@ -206,8 +279,29 @@ export async function handlePerformRelease(args, context) {
206
279
  try {
207
280
  await exec(pushCommand);
208
281
  } catch (error) {
282
+ // Rilis SUDAH membuat commit dan tag di mesin; hanya push yang gagal.
283
+ // Tanpa menyebut yang tertinggal, tidak jelas apa yang harus dibereskan -
284
+ // dan pada 31 Oktober 2026 hal itu membuat rilis tampak selesai.
285
+ const leftBehind = [];
286
+ if (headAfter && headAfter !== headBefore) {
287
+ leftBehind.push(` • commit rilis ${headAfter.slice(0, 8)}`);
288
+ }
289
+ if (tagAfter && tagAfter !== tagBefore) {
290
+ leftBehind.push(` • tag ${tagAfter}`);
291
+ }
292
+
209
293
  return textResponse(
210
- `❌ git push (with tags) failed:\n\n${error.stderr || error.stdout || error.message}`
294
+ `❌ Push rilis GAGAL. Rilis TIDAK tercatat sebagai selesai.\n\n` +
295
+ `Repo: ${repoLabel()}\nBranch: ${branchForPush || "(tidak diketahui)"}\n` +
296
+ `Penyebab: ${error.stderr || error.stdout || error.message}\n\n` +
297
+ (leftBehind.length > 0
298
+ ? `Tertinggal di mesin (belum sampai ke remote):\n${leftBehind.join("\n")}\n\n`
299
+ : "") +
300
+ `Pemulihan:\n` +
301
+ ` 1. periksa: git log --oneline -3 && git tag --points-at HEAD\n` +
302
+ ` 2. kirim ulang: git push --follow-tags origin ${branchForPush || "<branch>"}\n` +
303
+ ` 3. atau batalkan rilis lokal: git reset --hard HEAD~1 && git tag -d ${tagAfter || "<tag>"}\n\n` +
304
+ `Jangan tandai task selesai sebelum tag benar-benar ada di remote.`
211
305
  );
212
306
  }
213
307
 
package/workflow-state.js CHANGED
@@ -400,6 +400,19 @@ async function pathExists(filePath) {
400
400
  }
401
401
 
402
402
  async function ensureCompatibilityLinks(primaryPath) {
403
+ // Cermin kompatibilitas ditulis ke `<paket>/.state/workflow-state.json`,
404
+ // BUKAN ke lokasi state yang sebenarnya. Itu benar untuk pemakaian nyata
405
+ // (CLI dan dasbor web membaca jalur lama), tetapi merusak saat tes: setiap
406
+ // WorkflowState yang dibuat tes - meski di direktori sementara - menimpa
407
+ // state repo, dan 31 Oktober 2026 itu menghapus task yang sedang berjalan di
408
+ // tengah `npm test`.
409
+ //
410
+ // Tes mematikan cermin ini lewat DEV_WORKFLOW_NO_COMPAT_LINKS=1 (lihat
411
+ // tests/setup-env.js). Perilaku produksi tidak berubah.
412
+ if (process.env.DEV_WORKFLOW_NO_COMPAT_LINKS === "1") {
413
+ return;
414
+ }
415
+
403
416
  const resolvedPrimary = path.resolve(primaryPath);
404
417
 
405
418
  if (!(await pathExists(resolvedPrimary))) {