@programinglive/dev-workflow-mcp-server 1.12.1 → 1.13.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.
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 +12 -0
  5. package/db/adapters/mysql.js +11 -5
  6. package/db/adapters/postgres.js +10 -5
  7. package/db/adapters/sqlite.js +15 -7
  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 +45 -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 +2 -0
  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 +109 -18
package/factory-client.js CHANGED
@@ -22,6 +22,9 @@
22
22
 
23
23
  import { createHash, randomUUID } from "crypto";
24
24
  import os from "os";
25
+ import { assertSafeKnowledge } from './security/policy.js';
26
+ import { sanitize } from './security/sanitizer.js';
27
+ import { registerRuntimeSecret } from './security/runtime-secrets.js';
25
28
  import { mkdirSync, readFileSync, writeFileSync } from "fs";
26
29
  import path from "path";
27
30
 
@@ -36,6 +39,34 @@ export const VALID_STATUSES = ["backlog", "queued", "running", "review", "blocke
36
39
  /** Statuses reachable by finishing a work session, and the matching outcome. */
37
40
  const WORK_OUTCOMES = { review: "review", blocked: "blocked", paused: "paused" };
38
41
 
42
+ /**
43
+ * Event kinds yang menentukan apakah sebuah task sudah dilaporkan selesai.
44
+ *
45
+ * `work.paused` ikut diperhitungkan karena itu satu-satunya jalan resmi
46
+ * mengembalikan task dari review ke backlog. Kalau event terakhir adalah
47
+ * paused, pekerjaan memang sedang dikerjakan ulang dan review baru sah.
48
+ */
49
+ export const REVIEW_GATE_KINDS = ["work.review", "task.done", "work.paused"];
50
+
51
+ /**
52
+ * Mengembalikan event yang memblokir laporan review, atau null bila boleh.
53
+ *
54
+ * Memblokir bila event terakhir adalah review/done: Factory membacanya sebagai
55
+ * approval. Membiarkan bila event terakhir adalah paused (pekerjaan ulang).
56
+ * Toleran terhadap workspace lama tanpa `events`.
57
+ */
58
+ export function findBlockingReviewEvent(events, taskId) {
59
+ if (!Array.isArray(events) || !taskId) return null;
60
+
61
+ const relevant = events
62
+ .filter((event) => event && event.targetId === taskId && REVIEW_GATE_KINDS.includes(event.kind))
63
+ .sort((a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || "")));
64
+
65
+ const last = relevant[relevant.length - 1];
66
+ if (!last) return null;
67
+ return last.kind === "work.paused" ? null : last;
68
+ }
69
+
39
70
  /** Statuses reachable through POST /api/factory { action: move_task }. */
40
71
  const MOVABLE_STATUSES = ["backlog", "queued", "cancelled", "done"];
41
72
 
@@ -82,6 +113,7 @@ export function readFactoryConfig(env = process.env) {
82
113
  const email = clean(env.FACTORY_EMAIL);
83
114
  const password = clean(env.FACTORY_PASSWORD);
84
115
  if (!token && !(email && password)) return null;
116
+ registerRuntimeSecret(token); registerRuntimeSecret(password);
85
117
 
86
118
  return {
87
119
  url: trimSlash(url),
@@ -229,6 +261,7 @@ export function mapTaskRecord(record = {}, projects = []) {
229
261
  projectName: byId.get(record.projectId) ?? null,
230
262
  description: record.description || null,
231
263
  acceptance: record.acceptance || null,
264
+ definitionOfDone: record.definitionOfDone || null,
232
265
  createdAt: record.createdAt ?? null,
233
266
  updatedAt: record.updatedAt ?? null,
234
267
  work: work
@@ -267,13 +300,17 @@ export function formatTaskList(tasks = []) {
267
300
 
268
301
  tasks.forEach((task, index) => {
269
302
  const meta = [task.status, task.priority, task.projectName].filter(Boolean).join(" · ");
270
- lines.push(`${index + 1}. [${task.taskKey || task.id}] ${task.title}`);
303
+ // UUID penuh, bukan potongan 8 karakter: `sync_task_status` menuntut UUID,
304
+ // dan sebelumnya agen harus membaca API sendiri untuk mendapatkannya.
305
+ const label = task.id || task.taskKey || "tanpa-id";
306
+ lines.push(`${index + 1}. [${label}] ${task.title}`);
271
307
  if (meta) lines.push(` ${meta}`);
272
308
  if (task.work?.state === "active") {
273
309
  const who = task.work.deviceName ? ` on ${task.work.deviceName}` : "";
274
310
  lines.push(` active session${who}${task.work.step ? `: ${task.work.step}` : ""}`);
275
311
  }
276
312
  if (task.acceptance) lines.push(` acceptance: ${firstLine(task.acceptance)}`);
313
+ if (task.definitionOfDone) lines.push(` DoD: ${firstLine(task.definitionOfDone)}`);
277
314
  });
278
315
 
279
316
  return lines.join("\n");
@@ -361,6 +398,7 @@ export function buildWorkFinishPayload({ sessionId, deviceId, outcome, summary,
361
398
  // ---------------------------------------------------------------------------
362
399
 
363
400
  async function resolveToken(config, fetchImpl = fetch) {
401
+ registerRuntimeSecret(config.token); registerRuntimeSecret(config.password);
364
402
  if (config.token) return config.token;
365
403
 
366
404
  const key = `${config.pocketBaseUrl}|${config.email}`;
@@ -381,6 +419,7 @@ async function resolveToken(config, fetchImpl = fetch) {
381
419
 
382
420
  const body = await response.json();
383
421
  if (!body.token) throw new Error("Factory login returned no token.");
422
+ registerRuntimeSecret(body.token);
384
423
 
385
424
  tokenCache.set(key, { token: body.token, expiresAt: Date.now() + TOKEN_TTL_MS });
386
425
  return body.token;
@@ -392,6 +431,11 @@ export function clearTokenCache() {
392
431
  }
393
432
 
394
433
  async function request(config, path, { method = "GET", body, fetchImpl = fetch } = {}) {
434
+ if(body) {
435
+ // These are Factory work/editor record identifiers, not login sessions.
436
+ const {sessionId,clientSession,...knowledge}=body;
437
+ assertSafeKnowledge({...knowledge,workRecordId:sessionId,editorContext:clientSession});
438
+ }
395
439
  const token = await resolveToken(config, fetchImpl);
396
440
  const response = await fetchImpl(`${config.url}${path}`, {
397
441
  method,
@@ -411,16 +455,16 @@ async function request(config, path, { method = "GET", body, fetchImpl = fetch }
411
455
  }
412
456
 
413
457
  if (!response.ok) {
414
- const detail = parsed.error || parsed.message || text || response.statusText;
458
+ const detail = sanitize(parsed.error || parsed.message || text || response.statusText);
415
459
  // Body dan status ikut dibawa ke pemanggil, supaya penolakan takeover
416
460
  // bisa diterjemahkan jadi arahan yang bisa dijalankan.
417
461
  const error = new Error(`Factory request failed (HTTP ${response.status}): ${detail}`);
418
462
  error.status = response.status;
419
- error.body = parsed;
463
+ error.body = sanitize(parsed);
420
464
  throw error;
421
465
  }
422
466
 
423
- return parsed;
467
+ return sanitize(parsed);
424
468
  }
425
469
 
426
470
  /** Reads the whole workspace: projects, tasks and events. */
@@ -510,11 +554,29 @@ export async function heartbeatWork(config, { sessionId, deviceId, step, clientS
510
554
  *
511
555
  * Factory only accepts an outcome for a session that exists and belongs to the
512
556
  * caller's device, so both are checked first to produce a clear message.
557
+ *
558
+ * Satu penjagaan tambahan: outcome `review` ditolak bila task sudah punya
559
+ * riwayat review. Factory membaca review kedua sebagai approval, sehingga task
560
+ * melompat ke `done` tanpa keputusan pemilik - persis yang terjadi 29 Okt 2026
561
+ * pada empat task (sesi lama sudah review, board masih backlog).
513
562
  */
514
563
  export async function finishWork(config, { taskId, outcome, summary, device } = {}, fetchImpl = fetch) {
515
564
  const deviceId = (device || deviceFromConfig(config)).id;
516
- const task = await findTask(config, taskId, fetchImpl);
517
- if (!task) throw new Error(`No task with id "${taskId}" is visible to this account.`);
565
+ const workspace = await readWorkspace(config, fetchImpl);
566
+ const record = (workspace.tasks || []).find((task) => task.id === taskId);
567
+ if (!record) throw new Error(`No task with id "${taskId}" is visible to this account.`);
568
+ const task = mapTaskRecord(record, workspace.projects || []);
569
+
570
+ if (outcome === "review") {
571
+ const prior = findBlockingReviewEvent(workspace.events, taskId);
572
+ if (prior) {
573
+ throw new Error(
574
+ `Task "${task.title}" sudah pernah dilaporkan untuk review (${prior.kind} pada ${prior.createdAt || "waktu tidak tercatat"}). ` +
575
+ "Melaporkan review kedua kali dibaca Factory sebagai approval, sehingga task bisa melompat ke done tanpa persetujuan pemilik. " +
576
+ "Minta pemilik memutuskan: set done bila pekerjaan diterima, atau kembalikan ke backlog bila perlu dikerjakan ulang.",
577
+ );
578
+ }
579
+ }
518
580
 
519
581
  const session = task.work;
520
582
  if (!session || session.state !== "active") {
@@ -541,9 +603,19 @@ export async function updateTaskStatus(config, { recordId, status, reason, nextA
541
603
  const plan = planStatusTransition(status);
542
604
 
543
605
  if (plan.kind === "move") {
606
+ // Factory mewajibkan alasan saat menutup task sebagai done/cancelled
607
+ // (minimal 3 karakter, maksimal 500). Tanpa field ini, penutupan task
608
+ // selalu ditolak HTTP 400 - dan karena handler mengembalikan teks alih-alih
609
+ // melempar, kegagalannya tampak seperti berhasil.
610
+ const moveReason = typeof reason === "string" ? reason.trim().slice(0, 500) : "";
544
611
  await request(config, "/api/factory", {
545
612
  method: "POST",
546
- body: { action: "move_task", id: recordId, status: plan.status },
613
+ body: {
614
+ action: "move_task",
615
+ id: recordId,
616
+ status: plan.status,
617
+ ...(moveReason ? { reason: moveReason } : {}),
618
+ },
547
619
  fetchImpl,
548
620
  });
549
621
  return { id: recordId, status: plan.status };
@@ -570,6 +642,7 @@ export async function createTask(config, input = {}, fetchImpl = fetch) {
570
642
  const title = clean(input.title) || "";
571
643
  const description = clean(input.description) || "";
572
644
  const acceptance = clean(input.acceptance) || "";
645
+ const definitionOfDone = clean(input.definitionOfDone) || "";
573
646
  const priority = clean(input.priority) || "normal";
574
647
  const status = clean(input.status) || "backlog";
575
648
 
@@ -581,6 +654,11 @@ export async function createTask(config, input = {}, fetchImpl = fetch) {
581
654
  if (acceptance.length < 3 || acceptance.length > 3000) {
582
655
  throw new Error("acceptance must be 3-3000 characters and should state what proves the work is done.");
583
656
  }
657
+ if (definitionOfDone.length < 3 || definitionOfDone.length > 3000) {
658
+ throw new Error(
659
+ "definitionOfDone must be 3-3000 characters and must list what proves the task is done, for example '- [ ] build lulus'.",
660
+ );
661
+ }
584
662
  if (description.length > 4000) throw new Error("description must be at most 4000 characters.");
585
663
  if (!CREATABLE_PRIORITIES.includes(priority)) {
586
664
  throw new Error(`priority must be one of: ${CREATABLE_PRIORITIES.join(", ")}.`);
@@ -591,15 +669,69 @@ export async function createTask(config, input = {}, fetchImpl = fetch) {
591
669
 
592
670
  const body = await request(config, "/api/factory", {
593
671
  method: "POST",
594
- body: { action: "create_task", task: { projectId, title, description, acceptance, priority, status } },
672
+ body: { action: "create_task", task: { projectId, title, description, acceptance, definitionOfDone, priority, status } },
595
673
  fetchImpl,
596
674
  });
597
675
 
598
676
  return { id: body.id ?? null, runId: body.runId ?? null, status };
599
677
  }
600
678
 
601
- /** Lists the owner's projects so a task can reference a real projectId. */
602
- export async function listProjects(config, fetchImpl = fetch) {
679
+ /**
680
+ * Edits the details of an existing task.
681
+ *
682
+ * Dipakai untuk melengkapi `definitionOfDone` pada task yang dibuat sebelum
683
+ * kolom itu ada: Factory menolak `done` selama DoD kosong, jadi agen butuh cara
684
+ * mengisinya tanpa meninggalkan MCP. `expectedUpdatedAt` diambil dari workspace
685
+ * terbaru supaya dua agen tidak saling menimpa; Factory menjawab 409 bila basi.
686
+ */
687
+ export async function updateTaskDetails(
688
+ config,
689
+ { recordId, title, description, acceptance, definitionOfDone } = {},
690
+ fetchImpl = fetch,
691
+ ) {
692
+ if (!recordId) throw new Error("recordId is required to update a task.");
693
+
694
+ const changes = {};
695
+
696
+ if (title !== undefined) {
697
+ const value = String(title).trim();
698
+ if (value.length < 3 || value.length > 140) throw new Error("title must be 3-140 characters.");
699
+ changes.title = value;
700
+ }
701
+
702
+ if (description !== undefined) {
703
+ const value = String(description).trim();
704
+ if (value.length > 4000) throw new Error("description must be at most 4000 characters.");
705
+ changes.description = value;
706
+ }
707
+
708
+ if (acceptance !== undefined) {
709
+ const value = String(acceptance).trim();
710
+ if (value.length < 3 || value.length > 3000) throw new Error("acceptance must be 3-3000 characters.");
711
+ changes.acceptance = value;
712
+ }
713
+
714
+ if (definitionOfDone !== undefined) {
715
+ const value = String(definitionOfDone).trim();
716
+ if (value.length < 3 || value.length > 3000) throw new Error("definitionOfDone must be 3-3000 characters.");
717
+ changes.definitionOfDone = value;
718
+ }
719
+
720
+ if (Object.keys(changes).length === 0) throw new Error("Provide at least one field to update.");
721
+
722
+ const task = await findTask(config, recordId, fetchImpl);
723
+ if (!task) throw new Error(`No task with id "${recordId}" is visible to this account.`);
724
+
725
+ const body = await request(config, "/api/factory", {
726
+ method: "POST",
727
+ body: { action: "update_task", id: recordId, expectedUpdatedAt: task.updatedAt, task: changes },
728
+ fetchImpl,
729
+ });
730
+
731
+ return { id: body.id ?? recordId, updatedAt: body.updatedAt ?? null };
732
+ }
733
+
734
+ /** Lists the owner's projects so a task can reference a real projectId. */export async function listProjects(config, fetchImpl = fetch) {
603
735
  const workspace = await readWorkspace(config, fetchImpl);
604
736
  return (workspace.projects || []).map((project) => ({
605
737
  id: project.id ?? null,
package/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import "dotenv/config";
4
+ import { safeLog, sanitize, securityOutputFilter } from './security/sanitizer.js';
5
+ import { securityMode } from './security/policy.js';
4
6
 
5
7
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -18,7 +20,7 @@ import {
18
20
  createCommitMessageParts,
19
21
  determineReleaseTypeFromCommit,
20
22
  } from "./commit-helpers.js";
21
- import { WorkflowState } from "./workflow-state.js";
23
+ import { WorkflowState, sessionKeyFromRequest } from "./workflow-state.js";
22
24
  import { getToolList } from "./tools/definitions.js";
23
25
  import { handleToolCall } from "./tools/handlers.js";
24
26
 
@@ -52,18 +54,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
52
54
 
53
55
  // Handle tool calls
54
56
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
57
+ try {
55
58
  // Reload state from disk before each request to ensure consistency
56
59
  if (workflowState) {
60
+ const sessionId = sessionKeyFromRequest(request);
61
+ if (sessionId) await workflowState.useSession(sessionId);
57
62
  await workflowState.load();
58
63
  }
59
- return handleToolCall({
64
+ return securityOutputFilter(await handleToolCall({
60
65
  request,
61
66
  normalizeRequestArgs,
62
67
  workflowState,
63
68
  exec,
64
69
  git: createGitFacade(),
65
70
  utils: { shellEscape, determineReleaseTypeFromCommit, createCommitMessageParts },
66
- });
71
+ }));
72
+ } catch(error) {
73
+ safeLog('error','MCP request failed',error);
74
+ return {isError:true,content:[{type:'text',text:'Request failed; inspect sanitized server diagnostics.'}]};
75
+ }
67
76
  });
68
77
 
69
78
  // Helper function
@@ -175,30 +184,30 @@ Use 'check_ready_to_commit' to verify workflow completion.
175
184
 
176
185
  // Start server
177
186
  export async function main() {
187
+ securityMode();
178
188
  const startTime = Date.now();
179
- console.error(`[${new Date().toISOString()}] MCP Server starting...`);
189
+ safeLog('error', `[${new Date().toISOString()}] MCP Server starting...`);
180
190
 
181
191
  try {
182
192
  // Initialize workflow state
183
193
  workflowState = new WorkflowState();
184
- console.error(`[${new Date().toISOString()}] Loading workflow state...`);
185
- await workflowState.load();
186
- console.error(`[${new Date().toISOString()}] Workflow state loaded.`);
187
-
188
- console.error(`[${new Date().toISOString()}] Ensuring primary file...`);
189
- await workflowState.ensurePrimaryFile();
190
- console.error(`[${new Date().toISOString()}] Workflow state initialization complete.`);
194
+ // Select request metadata before the first disk write. Creating an idle
195
+ // PID fallback here leaves a phantom session alongside the real sessions.
196
+ safeLog('error', `[${new Date().toISOString()}] Workflow state initialization complete.`);
191
197
  } catch (error) {
192
- console.error(`[${new Date().toISOString()}] Warning: Failed to initialize workflow state:`, error.message);
198
+ safeLog('error', `[${new Date().toISOString()}] Warning: Failed to initialize workflow state:`, error.message);
193
199
  // Continue anyway - workflow state is optional for MCP to function
194
200
  workflowState = new WorkflowState();
195
201
  }
196
202
 
197
203
  const transport = new StdioServerTransport();
198
- console.error(`[${new Date().toISOString()}] Connecting transport...`);
204
+ // Cover SDK-generated protocol errors as well as tool-handler responses.
205
+ const send = transport.send.bind(transport);
206
+ transport.send = message => send(securityOutputFilter(message));
207
+ safeLog('error', `[${new Date().toISOString()}] Connecting transport...`);
199
208
  await server.connect(transport);
200
209
  const duration = Date.now() - startTime;
201
- console.error(`[${new Date().toISOString()}] Dev Workflow MCP Server running on stdio (startup took ${duration}ms)`);
210
+ safeLog('error', `[${new Date().toISOString()}] Dev Workflow MCP Server running on stdio (startup took ${duration}ms)`);
202
211
  }
203
212
 
204
213
  // --- Lightweight CLI: dev-workflow-mcp call <toolName> [--args '<json>'] ---
@@ -207,7 +216,7 @@ async function cliMain(argv) {
207
216
  if (subcommand !== "call") return false;
208
217
 
209
218
  if (!toolName || typeof toolName !== "string" || toolName.trim() === "") {
210
- console.error("Usage: dev-workflow-mcp call <toolName> [--args '<json>']");
219
+ safeLog('error', "Usage: dev-workflow-mcp call <toolName> [--args '<json>']");
211
220
  process.exit(2);
212
221
  }
213
222
 
@@ -243,11 +252,11 @@ async function cliMain(argv) {
243
252
  });
244
253
 
245
254
  const text = response?.content?.[0]?.text || "";
246
- console.log(text);
247
- const isError = /^\s*[⚠️❌]/u.test(text);
255
+ console.log(sanitize(text));
256
+ const isError = response?.isError === true || /^\s*[⚠️❌]/u.test(text);
248
257
  process.exit(isError ? 1 : 0);
249
258
  } catch (error) {
250
- console.error("CLI error:", error.message || String(error));
259
+ safeLog('error', "CLI error:", error.message || String(error));
251
260
  process.exit(1);
252
261
  }
253
262
  }
@@ -259,7 +268,7 @@ if (isDirectRun) {
259
268
  cliMain(process.argv);
260
269
  } else {
261
270
  main().catch((error) => {
262
- console.error("Server error:", error);
271
+ safeLog('error', "Server error:", error);
263
272
  process.exit(1);
264
273
  });
265
274
  }
package/install.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
  * Installation script for dev-workflow MCP server
@@ -21,7 +22,7 @@ async function install() {
21
22
  // If INIT_CWD points inside node_modules, this script is being executed
22
23
  // while installing the package itself. In that case, skip creating a state file.
23
24
  if (projectRoot.includes(`node_modules${path.sep}`)) {
24
- console.log('ℹ️ Skipping workflow state creation inside node_modules.');
25
+ safeLog('info', 'ℹ️ Skipping workflow state creation inside node_modules.');
25
26
  return;
26
27
  }
27
28
 
@@ -34,7 +35,7 @@ async function install() {
34
35
  // Check if state file already exists
35
36
  try {
36
37
  await fs.access(stateFile);
37
- console.log(`✓ Workflow state file already exists at ${stateFile}`);
38
+ safeLog('info', `✓ Workflow state file already exists at ${stateFile}`);
38
39
  return;
39
40
  } catch {
40
41
  // File doesn't exist, create it
@@ -62,10 +63,10 @@ async function install() {
62
63
  };
63
64
 
64
65
  await fs.writeFile(stateFile, JSON.stringify(initialState, null, 2));
65
- console.log(`✓ Created workflow state file at ${stateFile}`);
66
- console.log(`✓ Each project now has its own isolated workflow history`);
66
+ safeLog('info', `✓ Created workflow state file at ${stateFile}`);
67
+ safeLog('info', `✓ Each project now has its own isolated workflow history`);
67
68
  } catch (error) {
68
- console.error('✗ Installation failed:', error.message);
69
+ safeLog('error', '✗ Installation failed:', error.message);
69
70
  process.exit(1);
70
71
  }
71
72
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@programinglive/dev-workflow-mcp-server",
3
- "version": "1.12.1",
3
+ "version": "1.13.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",
@@ -22,6 +22,9 @@
22
22
  "local": "node index.js",
23
23
  "setup": "node scripts/setup-stack.js",
24
24
  "test": "node --no-deprecation --test",
25
+ "test:security": "node --no-deprecation --test tests/security*.test.js",
26
+ "security:audit": "node index.js call security_audit",
27
+ "lint": "node scripts/lint.mjs",
25
28
  "check": "node --check index.js",
26
29
  "prepare": "node scripts/install-husky.js",
27
30
  "postinstall": "node install.js",
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { safeLog } from './security/sanitizer.js';
3
+ import { assertSafeKnowledge } from './security/policy.js';
2
4
 
3
5
  import { spawn } from 'node:child_process';
4
6
  import fs from 'node:fs/promises';
@@ -74,7 +76,7 @@ async function main() {
74
76
  const additionalArgs = process.argv.slice(3).filter((arg) => arg !== '--force');
75
77
 
76
78
  if (!RELEASE_TYPES.has(releaseTypeArg)) {
77
- console.error(
79
+ safeLog('error',
78
80
  '❌ Release guard: expected release type argument (major | minor | patch). Example: npm run release:patch'
79
81
  );
80
82
  process.exit(1);
@@ -83,14 +85,14 @@ async function main() {
83
85
  const repoRoot = process.cwd();
84
86
  const projectRootCandidate = process.env.INIT_CWD || repoRoot;
85
87
  if (projectRootCandidate.includes(`node_modules${path.sep}`)) {
86
- console.error('❌ Release guard: refusing to run inside node_modules.');
88
+ safeLog('error', '❌ Release guard: refusing to run inside node_modules.');
87
89
  process.exit(1);
88
90
  }
89
91
  const projectRoot = projectRootCandidate;
90
92
 
91
93
  const result = await loadWorkflowStateCandidates(projectRoot);
92
94
  if (!result) {
93
- console.error(
95
+ safeLog('error',
94
96
  `❌ Release guard: cannot locate workflow state under ${path.join(projectRoot, '.state')}.
95
97
  Make sure the dev-workflow MCP is installed and you have started a task.`
96
98
  );
@@ -98,6 +100,12 @@ Make sure the dev-workflow MCP is installed and you have started a task.`
98
100
  }
99
101
 
100
102
  const { state, path: statePath } = result;
103
+ try {
104
+ assertSafeKnowledge(state);
105
+ } catch (error) {
106
+ safeLog('error', 'Release guard: unsafe legacy knowledge; remediate before releasing.', error);
107
+ process.exit(1);
108
+ }
101
109
 
102
110
  const errors = [];
103
111
  if (state.currentPhase !== 'release') {
@@ -116,20 +124,20 @@ Make sure the dev-workflow MCP is installed and you have started a task.`
116
124
  }
117
125
 
118
126
  if (errors.length && !forceFlag) {
119
- console.error('❌ Release guard blocked the release:');
127
+ safeLog('error', '❌ Release guard blocked the release:');
120
128
  for (const message of errors) {
121
- console.error(` • ${message}`);
129
+ safeLog('error', ` • ${message}`);
122
130
  }
123
- console.error("\nUse 'perform_release' through the dev-workflow MCP after completing the missing steps.");
124
- console.error("Or use: npm run release:patch -- --force (to bypass guard)");
131
+ safeLog('error', "\nUse 'perform_release' through the dev-workflow MCP after completing the missing steps.");
132
+ safeLog('error', "Or use: npm run release:patch -- --force (to bypass guard)");
125
133
  process.exit(1);
126
134
  }
127
135
 
128
136
  if (forceFlag && errors.length) {
129
- console.warn('⚠️ --force flag detected. Bypassing workflow guard.');
130
- console.warn('Errors that were skipped:');
137
+ safeLog('warn', '⚠️ --force flag detected. Bypassing workflow guard.');
138
+ safeLog('warn', 'Errors that were skipped:');
131
139
  for (const message of errors) {
132
- console.warn(` • ${message}`);
140
+ safeLog('warn', ` • ${message}`);
133
141
  }
134
142
  }
135
143
 
@@ -138,15 +146,15 @@ Make sure the dev-workflow MCP is installed and you have started a task.`
138
146
  try {
139
147
  await fs.access(commiterReleaseScript);
140
148
  } catch {
141
- console.error('❌ Release guard: @programinglive/commiter release script not found.');
142
- console.error(' Ensure @programinglive/commiter is installed as a dev dependency.');
143
- console.error(' Try: npm install --save-dev @programinglive/commiter');
149
+ safeLog('error', '❌ Release guard: @programinglive/commiter release script not found.');
150
+ safeLog('error', ' Ensure @programinglive/commiter is installed as a dev dependency.');
151
+ safeLog('error', ' Try: npm install --save-dev @programinglive/commiter');
144
152
  process.exit(1);
145
153
  }
146
154
 
147
155
  const runRelease = async () => {
148
156
  if (process.env.DEV_WORKFLOW_SKIP_RELEASE === '1') {
149
- console.log('ℹ️ DEV_WORKFLOW_SKIP_RELEASE=1 detected. Skipping actual release command.');
157
+ safeLog('info', 'ℹ️ DEV_WORKFLOW_SKIP_RELEASE=1 detected. Skipping actual release command.');
150
158
  return 0;
151
159
  }
152
160
 
@@ -154,9 +162,13 @@ Make sure the dev-workflow MCP is installed and you have started a task.`
154
162
  const child = spawn(
155
163
  process.execPath,
156
164
  [commiterReleaseScript, releaseTypeArg, ...additionalArgs],
157
- { stdio: 'inherit', cwd: repoRoot }
165
+ { stdio: ['ignore','pipe','pipe'], cwd: repoRoot }
158
166
  );
159
167
 
168
+ let output='';
169
+ child.stdout.on('data',chunk=>{if(output.length<1024*1024)output+=chunk.toString();});
170
+ child.stderr.on('data',chunk=>{if(output.length<1024*1024)output+=chunk.toString();});
171
+ child.on('close',()=>safeLog('info',output));
160
172
  child.on('error', (error) => reject(error));
161
173
  child.on('close', (code) => resolve(code));
162
174
  });
@@ -165,11 +177,11 @@ Make sure the dev-workflow MCP is installed and you have started a task.`
165
177
  try {
166
178
  const exitCode = await runRelease();
167
179
  if (exitCode !== 0) {
168
- console.error(`❌ Release command exited with code ${exitCode}.`);
180
+ safeLog('error', `❌ Release command exited with code ${exitCode}.`);
169
181
  process.exit(exitCode);
170
182
  }
171
183
  } catch (error) {
172
- console.error(`❌ Failed to run release command: ${error.message}`);
184
+ safeLog('error', `❌ Failed to run release command: ${error.message}`);
173
185
  process.exit(1);
174
186
  }
175
187
 
@@ -180,13 +192,14 @@ Make sure the dev-workflow MCP is installed and you have started a task.`
180
192
  state.currentPhase = 'ready_to_complete';
181
193
 
182
194
  try {
195
+ assertSafeKnowledge(state);
183
196
  await fs.writeFile(statePath, JSON.stringify(state, null, 2));
184
197
  } catch (error) {
185
- console.error(`❌ Release guard: failed to update workflow state. ${error.message}`);
198
+ safeLog('error', `❌ Release guard: failed to update workflow state. ${error.message}`);
186
199
  process.exit(1);
187
200
  }
188
201
 
189
- console.log('✅ Release recorded in workflow state. You can now run complete_task.');
202
+ safeLog('info', '✅ Release recorded in workflow state. You can now run complete_task.');
190
203
  }
191
204
 
192
205
  main();
@@ -0,0 +1,33 @@
1
+ // Bukti bahwa penjaga review-ganda akan menyala pada data Factory sungguhan.
2
+ // Read-only: hanya membaca workspace, tidak pernah POST /api/work.
3
+ import { readFactoryConfig, readWorkspace, findBlockingReviewEvent } from "../factory-client.js";
4
+
5
+ const TASKS = {
6
+ "5127f86d": "5127f86d-e32f-4b00-991c-d817c01e2774",
7
+ "db4468aa": "db4468aa-0000-4000-8000-000000000000",
8
+ "f57f017b": "f57f017b-915c-4a91-a25d-ecb3a7050260",
9
+ "6a833f7d": "6a833f7d-4ea6-4e30-9c9b-3d93f4e2c428",
10
+ };
11
+
12
+ const config = readFactoryConfig();
13
+ if (!config) {
14
+ console.error("Factory belum dikonfigurasi (FACTORY_URL/FACTORY_TOKEN atau email+password).");
15
+ process.exit(1);
16
+ }
17
+
18
+ const workspace = await readWorkspace(config);
19
+ console.log(`workspace: ${(workspace.tasks || []).length} task, ${(workspace.events || []).length} event\n`);
20
+
21
+ for (const [short, id] of Object.entries(TASKS)) {
22
+ const task = (workspace.tasks || []).find((t) => t.id === id);
23
+ if (!task) {
24
+ console.log(`${short}: task tidak ditemukan (id contoh)`);
25
+ continue;
26
+ }
27
+ const prior = findBlockingReviewEvent(workspace.events, id);
28
+ const aktif = task.work?.state === "active";
29
+ console.log(`${short} status=${task.status.padEnd(8)} sesi=${String(task.work?.state).padEnd(8)}`);
30
+ console.log(` penjaga review: ${prior ? `MENOLAK (${prior.kind} @ ${prior.createdAt})` : "lolos"}`);
31
+ if (prior && aktif) console.log(" -> akan ditolak sebelum POST /api/work");
32
+ console.log();
33
+ }