@programinglive/dev-workflow-mcp-server 1.14.2 → 1.15.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.
package/AGENTS.md CHANGED
@@ -8,6 +8,8 @@ You are connected to the `dev-workflow` MCP server. **You MUST NOT perform any c
8
8
  Before taking any action or answering user requests regarding code changes, check your workflow status and follow these exact steps in sequence:
9
9
 
10
10
  1. **Start Task**: Call `start_task` with a clear description, type (`feature`, `bugfix`, `refactor`, or `other`), `projectDir`, **and a `definitionOfDone` checklist**. Every task must say, up front, which concrete conditions must be true before it counts as finished.
11
+
12
+ `start_task` **refuses** when another live MCP process is working the same project (heartbeat within the last 10 minutes). Two sessions on one working tree overwrite each other's files silently — on 31 October 2026 a session lost every edit in `tools/handler-modules/index.js` that way. Close the other window, use a git worktree, or pass `force: true` when you know the other session is idle. The check runs before any state is touched, so a refused call changes nothing.
11
13
  2. **Implement**: Write your code changes.
12
14
  3. **Mark Fixed**: Call `mark_bug_fixed` with a summary of the implementation.
13
15
  4. **Prove the Definition of Done**: For each item, call `mark_dod_item` with `evidence` (5-500 characters naming what you actually ran or checked, e.g. `npm test -> 505 pass`). An item that turned out not to apply is excluded with `waive_dod_item` and a written reason - never silently dropped.
package/README.md CHANGED
@@ -688,7 +688,14 @@ applies: anything dirty blocks the release.
688
688
 
689
689
  ## Troubleshooting
690
690
 
691
- **`ERR_MODULE_NOT_FOUND: Cannot find package 'dotenv'`** — dependencies are not
691
+ **`start_task` refuses because another session is working the same project.** A
692
+ second session on one working tree silently overwrites the first session's files.
693
+ Close the other OpenCode window, or give this session its own checkout with
694
+ `git worktree add <path> -b <branch>` and run it from there. The check ignores
695
+ locks with no heartbeat for 10 minutes, so a leftover MCP process does not block
696
+ you; `force: true` overrides it when you know the other session is idle.
697
+
698
+ **`ERR_MODULE_NOT_FOUND: Cannot find package 'dotenv'`** - dependencies are not
692
699
  installed. Run `npm install` in the repo.
693
700
 
694
701
  **`gyp ERR! find VS` on Windows** — the native module is compiling from source
package/index.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  } from "@modelcontextprotocol/sdk/types.js";
15
15
  import { fileURLToPath } from "url";
16
16
  import path from "path";
17
- import { acquireProcessLock, releaseProcessLock, lockWarning, projectLockDir } from "./process-lock.js";
17
+ import { acquireProcessLock, releaseProcessLock, lockWarning, projectLockDir, refreshProcessLock, HEARTBEAT_MS } from "./process-lock.js";
18
18
  import { exec, setActiveProjectDir } from "./exec.js";
19
19
  import { normalizeRequestArgs, shellEscape } from "./utils.js";
20
20
  import { createGitFacade } from "./git-facade.js";
@@ -220,6 +220,16 @@ export async function main() {
220
220
  const warning = lockWarning(acquireProcessLock({ dir: lockDir }));
221
221
  if (warning) safeLog('error', warning);
222
222
  process.once('exit', () => releaseProcessLock({ dir: lockDir }));
223
+
224
+ // Detak jantung penanda proses.
225
+ //
226
+ // Tanpa ini, penanda hanya menyebut KAPAN proses start. Proses MCP yang
227
+ // tertinggal hidup setelah jendela OpenCode-nya ditutup akan terlihat sama
228
+ // hidupnya dengan sesi yang benar-benar bekerja, sehingga aturan "satu
229
+ // penulis per repo" tidak bisa ditegakkan tanpa memblokir sesi yang sah.
230
+ // Dengan detak, "hidup" berarti "hidup DAN baru saja bekerja".
231
+ const heartbeat = setInterval(() => refreshProcessLock(), HEARTBEAT_MS);
232
+ heartbeat.unref?.();
223
233
  } catch (lockError) {
224
234
  safeLog('error', 'Warning: could not record process lock:', lockError.message);
225
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@programinglive/dev-workflow-mcp-server",
3
- "version": "1.14.2",
3
+ "version": "1.15.0",
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/process-lock.js CHANGED
@@ -80,8 +80,7 @@ export function readProcessLock({ dir }) {
80
80
  }
81
81
  }
82
82
 
83
- function defaultIsAlive(pid) {
84
- if (!Number.isFinite(pid) || pid <= 0) return false;
83
+ function defaultIsAlive(pid) { if (!Number.isFinite(pid) || pid <= 0) return false;
85
84
 
86
85
  try {
87
86
  // Sinyal 0 tidak mengirim apa pun; ia hanya memeriksa apakah proses ada.
@@ -111,12 +110,13 @@ export function acquireProcessLock({
111
110
  const existing = readProcessLock({ dir });
112
111
 
113
112
  if (existing && existing.pid !== pid && isAlive(existing.pid)) {
113
+ rememberLockDir(dir);
114
114
  return { acquired: false, holder: existing };
115
115
  }
116
116
 
117
117
  const tookOverFrom = existing && existing.pid !== pid ? existing.pid : undefined;
118
118
 
119
- const payload = { pid, startedAt: now, host: safeHostname() };
119
+ const payload = { pid, startedAt: now, heartbeatAt: now, host: safeHostname() };
120
120
 
121
121
  try {
122
122
  mkdirSync(dir, { recursive: true });
@@ -133,8 +133,7 @@ export function acquireProcessLock({
133
133
  };
134
134
  }
135
135
 
136
- return tookOverFrom ? { acquired: true, tookOverFrom } : { acquired: true };
137
- }
136
+ return tookOverFrom ? { acquired: true, tookOverFrom } : { acquired: true };}
138
137
 
139
138
  /** Melepas lock hanya bila memang milik kita. */
140
139
  export function releaseProcessLock({ dir, pid = process.pid }) {
@@ -189,3 +188,86 @@ export function lockWarning(result) {
189
188
  "memastikan hanya satu proses yang hidup."
190
189
  );
191
190
  }
191
+
192
+ /**
193
+ * Berapa lama penanda proses dianggap masih "baru saja bekerja".
194
+ *
195
+ * Dipakai untuk membedakan sesi yang benar-benar aktif dari proses MCP yang
196
+ * tertinggal hidup setelah jendela OpenCode-nya ditutup. Tanpa batas ini,
197
+ * "satu penulis per repo" hanya bisa ditegakkan dengan memblokir sesi yang sah.
198
+ */
199
+ export const DEFAULT_LOCK_STALE_MS = 10 * 60 * 1000;
200
+
201
+ /** Interval detak jantung; sepertiga dari batas basi, supaya ada toleransi. */
202
+ export const HEARTBEAT_MS = Math.floor(DEFAULT_LOCK_STALE_MS / 3);
203
+
204
+ /** Direktori lock yang terakhir dipegang proses ini (diisi acquireProcessLock). */
205
+ let rememberedLockDir = null;
206
+
207
+ function lockStamp(lock) {
208
+ const value = Number(lock?.heartbeatAt ?? lock?.startedAt);
209
+ return Number.isFinite(value) ? value : 0;
210
+ }
211
+
212
+ /**
213
+ * Menyegarkan penanda milik kita sendiri.
214
+ *
215
+ * Mengembalikan false bila penanda bukan milik kita atau tidak bisa ditulis.
216
+ * Tidak pernah melempar: gagal menyegarkan penanda tidak boleh mematikan server,
217
+ * tetapi juga tidak boleh diam-diam dianggap berhasil.
218
+ */
219
+ export function refreshProcessLock({ dir = rememberedLockDir, pid = process.pid, now = Date.now(), writeFileImpl = writeFileSync } = {}) {
220
+ if (!dir) return false;
221
+
222
+ const existing = readProcessLock({ dir });
223
+ if (!existing || existing.pid !== pid) return false;
224
+
225
+ try {
226
+ writeFileImpl(processLockPath({ dir }), JSON.stringify({ ...existing, heartbeatAt: now }), "utf8");
227
+ return true;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Pemegang lock lain yang masih hidup DAN masih bekerja, atau null.
235
+ *
236
+ * Inilah yang membuat aturan "satu penulis per repo" punya gigi: pemanggil bisa
237
+ * menolak memulai pekerjaan alih-alih menimpanya. Penanda yang basi (tidak ada
238
+ * detak dalam `staleMs`) diabaikan, karena proses MCP yang tertinggal hidup
239
+ * bukan alasan memblokir sesi baru.
240
+ */
241
+ export function activeForeignLock({
242
+ dir = rememberedLockDir,
243
+ pid = process.pid,
244
+ now = Date.now(),
245
+ staleMs = DEFAULT_LOCK_STALE_MS,
246
+ isAlive = defaultIsAlive,
247
+ } = {}) {
248
+ if (!dir) return null;
249
+
250
+ const lock = readProcessLock({ dir });
251
+ if (!lock || lock.pid === pid) return null;
252
+ if (!isAlive(lock.pid)) return null;
253
+
254
+ const age = now - lockStamp(lock);
255
+ if (age > staleMs) return null;
256
+
257
+ return { ...lock, ageMs: age };
258
+ }
259
+
260
+ /** Mengingat direktori lock supaya detak jantung tidak perlu parameter. */
261
+ export function rememberLockDir(dir) {
262
+ rememberedLockDir = dir || null;
263
+ }
264
+
265
+ /** Hanya untuk tes: mengganti pemeriksa "proses hidup". */
266
+ let isAliveOverride = null;
267
+ export function setIsAliveForTests(fn) {
268
+ isAliveOverride = typeof fn === "function" ? fn : null;
269
+ }
270
+
271
+ export function currentIsAlive() {
272
+ return isAliveOverride || defaultIsAlive;
273
+ }
@@ -44,6 +44,13 @@ export function getToolList() {
44
44
  "Each item must later be proven with 'mark_dod_item' (evidence required) or waived with " +
45
45
  "'waive_dod_item' (reason required). A task cannot be committed or closed while any item is open.",
46
46
  },
47
+ force: {
48
+ type: "boolean",
49
+ description:
50
+ "Start anyway when another live MCP process is working the same project. Only for the machine owner: " +
51
+ "two sessions on one working tree overwrite each other's files silently. Prefer closing the other " +
52
+ "session or using a git worktree.",
53
+ },
47
54
  },
48
55
  required: ["description", "type", "definitionOfDone"],
49
56
  },
@@ -1,6 +1,7 @@
1
1
  import { classify } from '../../security/policy.js';
2
2
  import { textResponse, isReleaseSatisfied, isErrorResponse, ensurePrdExists } from "./shared.js";
3
3
  import { validateItemTexts } from "./dod.js";
4
+ import { activeForeignLock, projectLockDir, currentIsAlive, DEFAULT_LOCK_STALE_MS } from "../../process-lock.js";
4
5
  import { resolveProjectDir, setActiveProjectDir } from "../../exec.js";
5
6
 
6
7
  /**
@@ -88,6 +89,46 @@ async function captureDirtyAtStart(git) {
88
89
  }
89
90
 
90
91
  export async function handleStartTask(args, workflowState, git) {
92
+ // Satu penulis per repo.
93
+ //
94
+ // 31 Oktober 2026 dua sesi OpenCode menulis satu working tree yang sama. Satu
95
+ // sesi kehilangan seluruh suntingannya di `tools/handler-modules/index.js`
96
+ // tanpa pesan apa pun: berkasnya kembali ke versi HEAD di tengah pekerjaan.
97
+ // Penanda proses sudah ada sejak sebelumnya, tetapi hanya DICATAT sebagai
98
+ // peringatan, jadi tidak ada yang menghentikan tabrakan.
99
+ //
100
+ // Sekarang `start_task` menolak memulai bila ada proses MCP lain yang hidup
101
+ // DAN baru saja bekerja (ada detak dalam 10 menit) pada proyek yang sama.
102
+ // Penanda yang basi diabaikan supaya proses MCP yang tertinggal tidak
103
+ // memblokir selamanya. `force: true` tetap tersedia untuk pemilik mesin yang
104
+ // tahu apa yang ia lakukan, dan alasannya dicatat di jawaban.
105
+ const foreignLock = activeForeignLock({
106
+ dir: projectLockDir(workflowState.stateFile),
107
+ isAlive: currentIsAlive(),
108
+ });
109
+
110
+ if (foreignLock && args.force !== true) {
111
+ const minutes = Math.max(1, Math.round(foreignLock.ageMs / 60000));
112
+ const host = foreignLock.host ? ` di ${foreignLock.host}` : "";
113
+ const started = Number.isFinite(Number(foreignLock.startedAt))
114
+ ? new Date(Number(foreignLock.startedAt)).toISOString()
115
+ : "waktu tidak diketahui";
116
+
117
+ return textResponse(
118
+ `⚠️ Dibatalkan: sesi lain sedang mengerjakan proyek ini.\n\n` +
119
+ `Proses MCP PID ${foreignLock.pid}${host} berdetak ${minutes} menit lalu ` +
120
+ `(hidup sejak ${started}).\n` +
121
+ `Dua sesi pada satu working tree saling menimpa berkas: pada 31 Oktober 2026 ` +
122
+ `satu sesi kehilangan seluruh suntingannya tanpa pesan.\n\n` +
123
+ `Pilih salah satu:\n` +
124
+ ` 1. Tutup jendela OpenCode yang lain, lalu ulangi 'start_task'.\n` +
125
+ ` 2. Beri sesi ini worktree sendiri: opencode.worktree_create, lalu jalankan sesi dari sana.\n` +
126
+ ` 3. Kalau Anda yakin sesi itu sudah tidak dipakai, ulangi dengan { "force": true }.\n\n` +
127
+ `Penanda dianggap basi setelah ${Math.round(DEFAULT_LOCK_STALE_MS / 60000)} menit tanpa detak.\n` +
128
+ `Task BELUM dimulai; tidak ada state yang diubah.`
129
+ );
130
+ }
131
+
91
132
  // Validasi DoD SEBELUM menyentuh state apa pun. Task yang ditolak tidak boleh
92
133
  // mengganti task yang sedang berjalan, dan tidak boleh meninggalkan state
93
134
  // setengah jadi yang membuat `get_workflow_status` membingungkan.