@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
@@ -6,6 +6,7 @@ import {
6
6
  findTask,
7
7
  claimTask,
8
8
  updateTaskStatus,
9
+ updateTaskDetails,
9
10
  createTask,
10
11
  listProjects,
11
12
  formatTaskList,
@@ -149,6 +150,7 @@ export async function handleClaimFactoryTask(args = {}, { workflowState } = {})
149
150
  : "",
150
151
  `Factory status is now running (work session ${session.id}).`,
151
152
  task.projectName ? `Project: ${task.projectName}` : "",
153
+ task.definitionOfDone ? `Definition of done:\n${task.definitionOfDone}` : "",
152
154
  "",
153
155
  "Local workflow started for this task.",
154
156
  "Use 'sync_task_status' with review, blocked or paused to close the session, or done to approve the task once it is in review.",
@@ -199,10 +201,23 @@ export async function handleSyncTaskStatus(args = {}) {
199
201
  // ditolak server dan hanya mengaburkan keadaan papan.
200
202
  stopKeeping();
201
203
 
204
+ // Checklist dibaca lagi setelah status berubah: DoD bisa saja dilengkapi
205
+ // sejak claim, dan agen perlu kriteria terbaru saat melaporkan selesai.
206
+ // Kegagalan bacaan tambahan ini tidak boleh membuat laporan status yang
207
+ // sudah berhasil terlihat gagal.
208
+ let definitionOfDone = "";
209
+ try {
210
+ const task = await findTask(config, recordId);
211
+ definitionOfDone = String(task?.definitionOfDone || "").trim();
212
+ } catch {
213
+ definitionOfDone = "";
214
+ }
215
+
202
216
  return textResponse(
203
217
  [
204
218
  `Updated ${recordId} to ${result.status}.`,
205
219
  result.session?.summary ? `Summary: ${result.session.summary}` : "",
220
+ definitionOfDone ? `Definition of done:\n${definitionOfDone}` : "",
206
221
  result.status === "review" ? "Waiting for human approval in Factory." : "",
207
222
  ]
208
223
  .filter(Boolean)
@@ -241,6 +256,7 @@ export async function handleCreateFactoryTask(args = {}) {
241
256
 
242
257
  const title = typeof args.title === "string" ? args.title.trim() : "";
243
258
  const acceptance = typeof args.acceptance === "string" ? args.acceptance.trim() : "";
259
+ const definitionOfDone = typeof args.definitionOfDone === "string" ? args.definitionOfDone.trim() : "";
244
260
 
245
261
  if (!title) {
246
262
  return textResponse("Provide 'title' for the task (3-140 characters).");
@@ -253,6 +269,17 @@ export async function handleCreateFactoryTask(args = {}) {
253
269
  ].join("\n"),
254
270
  );
255
271
  }
272
+ if (!definitionOfDone) {
273
+ return textResponse(
274
+ [
275
+ "Provide 'definitionOfDone': the checklist that must be true before this task can be marked done.",
276
+ "Use one condition per line, for example:",
277
+ "- [ ] build lulus",
278
+ "- [ ] halaman login terverifikasi",
279
+ "It is stored separately from 'acceptance', which also carries the ```run block for the worker.",
280
+ ].join("\n"),
281
+ );
282
+ }
256
283
  if (!args.projectId) {
257
284
  return textResponse(
258
285
  "Provide 'projectId'. Run 'list_factory_projects' first - Factory needs a real project UUID.",
@@ -265,6 +292,7 @@ export async function handleCreateFactoryTask(args = {}) {
265
292
  title,
266
293
  description: args.description,
267
294
  acceptance,
295
+ definitionOfDone,
268
296
  priority: args.priority,
269
297
  status: args.status,
270
298
  });
@@ -286,3 +314,59 @@ export async function handleCreateFactoryTask(args = {}) {
286
314
  return textResponse(`Could not create the task: ${error.message}`);
287
315
  }
288
316
  }
317
+
318
+ /**
319
+ * Edits an existing task, mainly to fill the definition of done of a task that
320
+ * was created before Factory required it.
321
+ *
322
+ * Local validation runs first so an empty request never reaches the network.
323
+ */
324
+ export async function handleUpdateFactoryTask(args = {}) {
325
+ const { config, response } = requireConfig();
326
+ if (!config) return response;
327
+
328
+ const recordId = typeof args.recordId === "string" ? args.recordId.trim() : "";
329
+ if (!recordId) {
330
+ return textResponse("Provide the 'recordId' of the task to update (see list_factory_tasks).");
331
+ }
332
+
333
+ const hasField = ["title", "description", "acceptance", "definitionOfDone"].some(
334
+ (field) => typeof args[field] === "string",
335
+ );
336
+ if (!hasField) {
337
+ return textResponse(
338
+ [
339
+ "Provide at least one field to update: title, description, acceptance or definitionOfDone.",
340
+ "To fill a missing definition of done, pass definitionOfDone as a checklist, one condition per line.",
341
+ ].join("\n"),
342
+ );
343
+ }
344
+
345
+ try {
346
+ const result = await updateTaskDetails(config, {
347
+ recordId,
348
+ title: args.title,
349
+ description: args.description,
350
+ acceptance: args.acceptance,
351
+ definitionOfDone: args.definitionOfDone,
352
+ });
353
+
354
+ return textResponse(
355
+ [
356
+ `Updated ${recordId}.`,
357
+ result.updatedAt ? `Task version is now ${result.updatedAt}.` : "",
358
+ ]
359
+ .filter(Boolean)
360
+ .join("\n"),
361
+ );
362
+ } catch (error) {
363
+ // Factory menjawab 409 kalau task berubah sejak dibaca. Menyebut ulang
364
+ // langkah yang harus diambil lebih berguna daripada kode HTTP saja.
365
+ if (error?.status === 409) {
366
+ return textResponse(
367
+ `Could not update the task: ${error.message}\nReload the task with list_factory_tasks and try again with the current version.`,
368
+ );
369
+ }
370
+ return textResponse(`Could not update the task: ${error.message}`);
371
+ }
372
+ }
@@ -1,10 +1,16 @@
1
1
  import { textResponse, isReleaseSatisfied, isErrorResponse } from "./shared.js";
2
+ import { assertSafeKnowledge } from '../../security/policy.js';
3
+ import { safeLog, securityOutputFilter } from '../../security/sanitizer.js';
4
+ import { securityAudit } from '../../security/audit.js';
5
+ import path from 'node:path';
6
+ import { assertSafeToolCommand } from '../../security/commands.js';
7
+ import { sessionKeyFromRequest } from "../../workflow-state.js";
2
8
  import { handleStartTask, handleMarkBugFixed, handleCreateTests, handleSkipTests, handleRunTests, handleCreateDocumentation, handleCreateFeatureFlow } from "./workflow-handlers.js";
3
9
  import { handleSkipRelease, handlePerformRelease } from "./release-handlers.js";
4
10
  import { handleCommitAndPush, handleCompleteTask, handleForceCompleteTask, handleDropTask } from "./commit-handlers.js";
5
11
  import { handleProjectSummary, handleProjectSummaryData, handleProjectSummaryDb, handleViewHistory } from "./summary-handlers.js";
6
12
  import { handleGetWorkflowStatus, handleReadyCheck, handleContinueWorkflow, handleRerunWorkflow } from "./status-handlers.js";
7
- import { handleListFactoryTasks, handleClaimFactoryTask, handleSyncTaskStatus, handleListFactoryProjects, handleCreateFactoryTask } from "./factory-handlers.js";
13
+ import { handleListFactoryTasks, handleClaimFactoryTask, handleSyncTaskStatus, handleListFactoryProjects, handleCreateFactoryTask, handleUpdateFactoryTask } from "./factory-handlers.js";
8
14
 
9
15
  function summarizeResponse(label, response) {
10
16
  const text = response?.content?.[0]?.text || "";
@@ -156,7 +162,7 @@ async function handleRunFullWorkflow(args, { workflowState, exec, git, utils })
156
162
  return textResponse(message);
157
163
  }
158
164
 
159
- export async function handleToolCall({
165
+ async function dispatchToolCall({
160
166
  request,
161
167
  normalizeRequestArgs,
162
168
  workflowState,
@@ -166,6 +172,14 @@ export async function handleToolCall({
166
172
  }) {
167
173
  try {
168
174
  const { name } = request.params;
175
+
176
+ // Identitas sesi datang dari request metadata, bukan dari environment.
177
+ // Tanpa ini, satu proses MCP yang dipakai bergantian oleh beberapa sesi
178
+ // akan berbagi satu state dan saling menimpa task.
179
+ const sessionId = sessionKeyFromRequest(request);
180
+ if (sessionId && workflowState?.useSession) {
181
+ await workflowState.useSession(sessionId);
182
+ }
169
183
  const { args, error: argumentsError } = normalizeRequestArgs(request.params.arguments);
170
184
 
171
185
  if (argumentsError) {
@@ -173,6 +187,14 @@ export async function handleToolCall({
173
187
  }
174
188
 
175
189
  switch (name) {
190
+ case 'security_audit': {
191
+ const file=workflowState?.stateFile;
192
+ const marker=file?.replaceAll('\\','/').lastIndexOf('/.state/');
193
+ const root=marker>=0 ? file.slice(0,marker) : file ? path.dirname(file) : process.cwd();
194
+ const userId=process.env.DEV_WORKFLOW_USER_ID || (marker>=0?path.basename(path.dirname(file)):'default');
195
+ const report=await securityAudit({root,state:workflowState?.state,stateFile:file,userId});
196
+ return textResponse(JSON.stringify(report));
197
+ }
176
198
  case "start_task":
177
199
  return handleStartTask(args, workflowState);
178
200
  case "mark_bug_fixed":
@@ -223,6 +245,8 @@ export async function handleToolCall({
223
245
  return handleListFactoryProjects(args);
224
246
  case "create_factory_task":
225
247
  return handleCreateFactoryTask(args);
248
+ case "update_factory_task":
249
+ return handleUpdateFactoryTask(args);
226
250
  case "claim_factory_task":
227
251
  return handleClaimFactoryTask(args, { workflowState });
228
252
  case "sync_task_status":
@@ -231,9 +255,60 @@ export async function handleToolCall({
231
255
  throw new Error(`Unknown tool: ${name}`);
232
256
  }
233
257
  } catch (error) {
234
- console.error("Tool handler error:", error);
258
+ safeLog('error', "Tool handler error:", error);
235
259
  return textResponse(
236
260
  "⚠️ Internal server error while processing the tool request. Please try again."
237
261
  );
238
262
  }
239
263
  }
264
+
265
+ // Await every handler here so asynchronous rejections cannot bypass egress checks.
266
+ export async function handleToolCall(context) {
267
+ try {
268
+ const {args,error}=context.normalizeRequestArgs(context.request.params.arguments);
269
+ if(error) return securityOutputFilter(textResponse(error));
270
+ assertSafeKnowledge({tool:context.request.params.name,args});
271
+ assertSafeToolCommand(context.request.params.name,args);
272
+ if (context.workflowState) context.workflowState.lastConflict = null;
273
+ const response=await dispatchToolCall({...context,request:{...context.request,params:{...context.request.params,arguments:args}}});
274
+ return securityOutputFilter(withConflictNotice(response, context.workflowState));
275
+ } catch(error) {
276
+ if(['SECRET_CONTENT_DENIED','UNSAFE_COMMAND_DENIED'].includes(error.code)) {
277
+ return {isError:true,content:[{type:'text',text:JSON.stringify({error:error.code,message:error.message,findings:error.findings})}]};
278
+ }
279
+ safeLog('error','Tool request failed',error);
280
+ return {isError:true,content:[{type:'text',text:'⚠️ Internal server error while processing the tool request.'}]};
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Tempelkan peringatan bila penyimpanan terakhir kalah dari proses lain.
286
+ *
287
+ * Tanpa ini, tool menjawab "berhasil" untuk state yang sebenarnya tidak
288
+ * tersimpan. Kejadian 29 Okt 2026: `start_task` menjawab "✅ Task Started",
289
+ * tetapi `get_workflow_status` berikutnya menampilkan task lain.
290
+ */
291
+ function withConflictNotice(response, workflowState) {
292
+ const conflict = workflowState?.lastConflict;
293
+ if (!conflict) {
294
+ return response;
295
+ }
296
+ const text = response?.content?.[0]?.text;
297
+ if (typeof text !== "string") {
298
+ return response;
299
+ }
300
+ // Peringatan ditaruh DI DEPAN, dan menyebut terus terang bahwa aksi tadi
301
+ // tidak berlaku. Menempelkan "✅ berhasil" di atas peringatan membuat agen
302
+ // membaca baris pertama lalu melanjutkan seolah semuanya tersimpan.
303
+ const notice =
304
+ `⚠️ AKSI TIDAK BERLAKU - proses MCP lain memulai task yang lebih baru.\n` +
305
+ ` Task di disk : "${conflict.diskTask}"\n` +
306
+ ` Task Anda : "${conflict.staleTask || '(kosong)'}"\n` +
307
+ ` State Anda sudah dimuat ulang dari disk, jadi langkah berikutnya mengikuti task di disk.\n` +
308
+ ` Jalankan ulang aksi ini bila memang untuk task tersebut, atau 'start_task' untuk menggantinya.\n\n` +
309
+ `--- pesan asli tool (TIDAK tersimpan) ---\n`;
310
+ return {
311
+ ...response,
312
+ content: [{ ...response.content[0], text: notice + text }, ...response.content.slice(1)],
313
+ };
314
+ }
@@ -2,6 +2,7 @@ import fs from "fs/promises";
2
2
  import { existsSync } from "fs";
3
3
  import path from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { sanitize } from '../../security/sanitizer.js';
5
6
 
6
7
  export const PROJECT_SUMMARY_FILENAME = "project-summary.json";
7
8
 
@@ -12,7 +13,7 @@ export const textResponse = (text) => ({
12
13
  content: [
13
14
  {
14
15
  type: "text",
15
- text,
16
+ text: sanitize(text),
16
17
  },
17
18
  ],
18
19
  });
@@ -54,6 +55,7 @@ export function formatChecklist(checks) {
54
55
  }
55
56
 
56
57
  export function summarizeHistory(history, limit) {
58
+ history = sanitize(history);
57
59
  const recent = history.slice(-limit).reverse();
58
60
  if (recent.length === 0) {
59
61
  return null;
@@ -70,6 +72,7 @@ export function summarizeHistory(history, limit) {
70
72
  }
71
73
 
72
74
  export function generateProjectSummary(history) {
75
+ history = sanitize(history);
73
76
  if (!history || history.length === 0) {
74
77
  return "No workflow history available.";
75
78
  }
@@ -137,7 +140,7 @@ export async function loadProjectSummary(workflowState) {
137
140
  const summaryPath = path.join(path.dirname(workflowState.stateFile), PROJECT_SUMMARY_FILENAME);
138
141
  try {
139
142
  const data = await fs.readFile(summaryPath, "utf-8");
140
- return JSON.parse(data);
143
+ return sanitize(JSON.parse(data));
141
144
  } catch (error) {
142
145
  if (error?.code === "ENOENT") {
143
146
  return null;
@@ -14,7 +14,7 @@ export async function handleGetWorkflowStatus(workflowState) {
14
14
  const owner = disk.owner || {};
15
15
  sharedWarning =
16
16
  `\n\n⚠️ PERINGATAN: berkas state ini terakhir ditulis proses MCP LAIN ` +
17
- `(${owner.token || "tanpa-token"}, pid ${owner.pid ?? "?"}).\n` +
17
+ `(${owner.processMarker || "tanpa-token"}, pid ${owner.pid ?? "?"}).\n` +
18
18
  ` State workflow dipakai bersama antar sesi di mesin ini.\n` +
19
19
  ` Periksa apakah "Task:" di atas benar-benar milik Anda SEBELUM commit_and_push.`;
20
20
  if (disk.currentPhase && disk.currentPhase !== "idle" && disk.taskDescription) {
@@ -1,5 +1,6 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
+ import { sanitize } from '../../security/sanitizer.js';
3
4
  import { textResponse, generateProjectSummary, summarizeHistory, PROJECT_SUMMARY_FILENAME } from "./shared.js";
4
5
 
5
6
  export function handleProjectSummary(workflowState) {
@@ -11,7 +12,7 @@ export async function handleProjectSummaryData(workflowState) {
11
12
  const summaryPath = path.join(path.dirname(workflowState.stateFile), PROJECT_SUMMARY_FILENAME);
12
13
  try {
13
14
  const data = await fs.readFile(summaryPath, "utf-8");
14
- const parsed = JSON.parse(data);
15
+ const parsed = sanitize(JSON.parse(data));
15
16
  const formatted = `🧠 Project Knowledge Summary (from persisted data)\n\n📊 Stats\n- Total tasks completed: ${parsed.totalTasks}\n- Recent task mix: ${Object.entries(parsed.taskTypes).map(([t, c]) => `${t}: ${c}`).join(", ")}\n- Last active: ${parsed.lastActive ? new Date(parsed.lastActive).toLocaleDateString() : "Never"}\n- Updated: ${new Date(parsed.updatedAt).toLocaleString()}\n\n📝 Recent tasks (last 5)\n${parsed.recentTasks.map((e, i) => `${i + 1}. ${e.description} (${e.type})`).join("\n")}`;
16
17
  return textResponse(formatted);
17
18
  } catch (error) {
@@ -1,3 +1,4 @@
1
+ import { classify } from '../../security/policy.js';
1
2
  import { textResponse, isReleaseSatisfied, isErrorResponse, ensurePrdExists } from "./shared.js";
2
3
 
3
4
  export async function handleStartTask(args, workflowState) {
@@ -8,6 +9,7 @@ export async function handleStartTask(args, workflowState) {
8
9
  const { archivedTask } = workflowState.reset({ archive: true });
9
10
 
10
11
  workflowState.state.currentPhase = "coding";
12
+ workflowState.state.classification = classify(args);
11
13
  workflowState.state.taskDescription = args.description;
12
14
  workflowState.state.taskType = args.type;
13
15
  workflowState.state.testsSkipped = false;
@@ -15,7 +17,10 @@ export async function handleStartTask(args, workflowState) {
15
17
  workflowState.state.commitAndPushCompleted = false;
16
18
  workflowState.state.lastCommitMessage = "";
17
19
  workflowState.state.lastPushBranch = "";
18
- const reconciled = await workflowState.save();
20
+ // start_task memang bermaksud MENGGANTI task, jadi ia boleh menang atas
21
+ // task berjalan. Task yang diganti tetap diarsipkan oleh reconcileWithDisk,
22
+ // bukan dihapus.
23
+ const reconciled = await workflowState.save({ allowReplaceRunningTask: true });
19
24
 
20
25
  const warnings = [];
21
26
  const preservedForeign = reconciled?.preservedTask || preservedBeforeSave?.preservedTask;
package/utils.js CHANGED
@@ -3,6 +3,7 @@ const isWindows = process.platform === "win32";
3
3
  export function shellEscape(value) {
4
4
  const stringValue = String(value);
5
5
  if (isWindows) {
6
+ if(/["%!\r\n]/.test(stringValue)) throw new Error('Unsafe Windows shell argument denied; use a trusted operator session for complex messages.');
6
7
  // Windows cmd.exe requires double quotes for arguments with spaces.
7
8
  // Escape existing double quotes with backslash.
8
9
  return `"${stringValue.replace(/"/g, '\\"')}"`;
package/verify-build.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { safeLog } from './security/sanitizer.js';
2
3
 
3
4
  /**
4
5
  * Build Verification Script
@@ -22,16 +23,16 @@ let failedTests = 0;
22
23
  function test(name, fn) {
23
24
  try {
24
25
  fn();
25
- console.log(`✓ ${name}`);
26
+ safeLog('info', `✓ ${name}`);
26
27
  passedTests++;
27
28
  } catch (error) {
28
- console.error(`✗ ${name}`);
29
- console.error(` Error: ${error.message}`);
29
+ safeLog('error', `✗ ${name}`);
30
+ safeLog('error', ` Error: ${error.message}`);
30
31
  failedTests++;
31
32
  }
32
33
  }
33
34
 
34
- console.log('\n🧪 Running Build Verification Tests...\n');
35
+ safeLog('info', '\n🧪 Running Build Verification Tests...\n');
35
36
 
36
37
  // Test 1: Build succeeds
37
38
  test('Build completes successfully', () => {
@@ -166,11 +167,11 @@ test('Vertical monitor responsive design', () => {
166
167
  }
167
168
  });
168
169
 
169
- console.log(`\n📊 Test Results: ${passedTests} passed, ${failedTests} failed\n`);
170
+ safeLog('info', `\n📊 Test Results: ${passedTests} passed, ${failedTests} failed\n`);
170
171
 
171
172
  if (failedTests > 0) {
172
173
  process.exit(1);
173
174
  } else {
174
- console.log('✅ All tests passed!\n');
175
+ safeLog('info', '✅ All tests passed!\n');
175
176
  process.exit(0);
176
177
  }
package/vite.config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { defineConfig } from 'vite';
2
2
  import { fileURLToPath } from 'url';
3
3
  import path from 'path';
4
+ import { builtinModules } from 'node:module';
4
5
 
5
6
  const __filename = fileURLToPath(import.meta.url);
6
7
  const __dirname = path.dirname(__filename);
@@ -17,6 +18,9 @@ export default defineConfig({
17
18
  format: 'es',
18
19
  },
19
20
  external: [
21
+ ...builtinModules,
22
+ ...builtinModules.map(name=>`node:${name}`),
23
+ 'pg','mysql2','mysql2/promise','better-sqlite3',
20
24
  '@modelcontextprotocol/sdk',
21
25
  // dotenv ships CommonJS only (dist/index.cjs). Bundling it into an ES
22
26
  // module leaks its `require.main === module` guard, which throws