@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.
- package/.dockerignore +24 -0
- package/.env.example +11 -31
- package/README.md +109 -6
- package/db/adapters/base.js +12 -0
- package/db/adapters/mysql.js +11 -5
- package/db/adapters/postgres.js +10 -5
- package/db/adapters/sqlite.js +15 -7
- package/db/index.js +2 -1
- package/docker-compose.yml +7 -5
- package/exec.js +78 -12
- package/factory-client.js +142 -10
- package/index.js +28 -19
- package/install.js +6 -5
- package/package.json +4 -1
- package/release-wrapper.js +32 -19
- package/scripts/check-review-guard.mjs +33 -0
- package/scripts/create-admin.js +11 -9
- package/scripts/install-husky.js +3 -2
- package/scripts/lint.mjs +20 -0
- package/scripts/release.cjs +25 -22
- package/scripts/release.js +6 -5
- package/scripts/setup-stack.js +42 -41
- package/scripts/update-release-notes.cjs +13 -12
- package/scripts/update-release-notes.js +8 -7
- package/scripts/verify-bundle.mjs +8 -7
- package/security/audit.js +86 -0
- package/security/commands.js +10 -0
- package/security/config.js +24 -0
- package/security/files.js +27 -0
- package/security/http.js +26 -0
- package/security/log-bridge.cjs +17 -0
- package/security/policy.js +37 -0
- package/security/references.js +21 -0
- package/security/runtime-secrets.js +42 -0
- package/security/sanitizer.js +42 -0
- package/security/scanner.js +108 -0
- package/tools/definitions.js +51 -3
- package/tools/handler-modules/commit-handlers.js +3 -7
- package/tools/handler-modules/factory-handlers.js +84 -0
- package/tools/handler-modules/index.js +45 -3
- package/tools/handler-modules/shared.js +5 -2
- package/tools/handler-modules/status-handlers.js +1 -1
- package/tools/handler-modules/summary-handlers.js +2 -1
- package/tools/handler-modules/workflow-handlers.js +2 -0
- package/utils.js +1 -0
- package/verify-build.js +7 -6
- package/vite.config.js +4 -0
- 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
|
-
|
|
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
|
|
517
|
-
|
|
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: {
|
|
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
|
-
/**
|
|
602
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/release-wrapper.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
+
safeLog('error', '❌ Release guard blocked the release:');
|
|
120
128
|
for (const message of errors) {
|
|
121
|
-
|
|
129
|
+
safeLog('error', ` • ${message}`);
|
|
122
130
|
}
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
137
|
+
safeLog('warn', '⚠️ --force flag detected. Bypassing workflow guard.');
|
|
138
|
+
safeLog('warn', 'Errors that were skipped:');
|
|
131
139
|
for (const message of errors) {
|
|
132
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
180
|
+
safeLog('error', `❌ Release command exited with code ${exitCode}.`);
|
|
169
181
|
process.exit(exitCode);
|
|
170
182
|
}
|
|
171
183
|
} catch (error) {
|
|
172
|
-
|
|
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
|
-
|
|
198
|
+
safeLog('error', `❌ Release guard: failed to update workflow state. ${error.message}`);
|
|
186
199
|
process.exit(1);
|
|
187
200
|
}
|
|
188
201
|
|
|
189
|
-
|
|
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
|
+
}
|