@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 +2 -0
- package/README.md +8 -1
- package/index.js +11 -1
- package/package.json +1 -1
- package/process-lock.js +87 -5
- package/tools/definitions.js +7 -0
- package/tools/handler-modules/workflow-handlers.js +41 -0
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
|
-
**`
|
|
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.
|
|
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
|
+
}
|
package/tools/definitions.js
CHANGED
|
@@ -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.
|