@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
@@ -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,27 @@ 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
+ const response=await dispatchToolCall({...context,request:{...context.request,params:{...context.request.params,arguments:args}}});
273
+ return securityOutputFilter(response);
274
+ } catch(error) {
275
+ if(['SECRET_CONTENT_DENIED','UNSAFE_COMMAND_DENIED'].includes(error.code)) {
276
+ return {isError:true,content:[{type:'text',text:JSON.stringify({error:error.code,message:error.message,findings:error.findings})}]};
277
+ }
278
+ safeLog('error','Tool request failed',error);
279
+ return {isError:true,content:[{type:'text',text:'⚠️ Internal server error while processing the tool request.'}]};
280
+ }
281
+ }
@@ -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;
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
package/workflow-state.js CHANGED
@@ -1,6 +1,9 @@
1
+ import { safeLog } from './security/sanitizer.js';
1
2
  import fs from "fs/promises";
2
3
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
3
4
  import path from "path";
5
+ import { assertSafeKnowledge } from './security/policy.js';
6
+ import { sanitize } from './security/sanitizer.js';
4
7
  import { fileURLToPath } from "url";
5
8
 
6
9
  const __filename = fileURLToPath(import.meta.url);
@@ -30,6 +33,14 @@ let cachedUserStateDir = null;
30
33
  */
31
34
  const PROCESS_TOKEN = `proc-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
32
35
 
36
+ function migrateOwnerMetadata(value) {
37
+ if(value?.owner && !value.owner.processMarker && /^proc-[A-Za-z0-9-]+$/.test(value.owner.token || '')) {
38
+ value.owner.processMarker=value.owner.token;
39
+ delete value.owner.token;
40
+ }
41
+ return value;
42
+ }
43
+
33
44
  function generateUserId() {
34
45
  const timestamp = Date.now().toString(16);
35
46
  const randomSuffix = Math.random().toString(16).slice(2, 10);
@@ -249,6 +260,8 @@ async function migrateLegacyStateFile(targetPath) {
249
260
  }
250
261
 
251
262
  await fs.mkdir(stateDir, { recursive: true });
263
+ const migrated = migrateOwnerMetadata(JSON.parse(await fs.readFile(legacyPath, 'utf8')));
264
+ assertSafeKnowledge(migrated);
252
265
 
253
266
  try {
254
267
  await fs.access(targetPath);
@@ -258,7 +271,8 @@ async function migrateLegacyStateFile(targetPath) {
258
271
  // New state file doesn't exist yet; fall through to migrate.
259
272
  }
260
273
 
261
- await fs.rename(legacyPath, targetPath);
274
+ await fs.writeFile(targetPath, JSON.stringify(migrated, null, 2), { flag: 'wx' });
275
+ await fs.unlink(legacyPath);
262
276
  }
263
277
 
264
278
  function isRootPath(candidate) {
@@ -338,6 +352,7 @@ async function ensureCompatibilityLinks(primaryPath) {
338
352
  if (!(await pathExists(resolvedPrimary))) {
339
353
  return;
340
354
  }
355
+ assertSafeKnowledge(JSON.parse(await fs.readFile(resolvedPrimary, 'utf8')));
341
356
 
342
357
  const compatibilityPaths = getCompatibilityPaths(resolvedPrimary);
343
358
 
@@ -413,18 +428,38 @@ async function ensureCompatibilityLinks(primaryPath) {
413
428
  *
414
429
  * MENGAPA ADA
415
430
  * -----------
416
- * Berkas state dulu disimpan per USER, jadi empat proses MCP berbagi satu
431
+ * Berkas state dulu disimpan per USER, jadi beberapa proses MCP berbagi satu
417
432
  * `workflow-state.json`. Akibatnya `start_task` di satu sesi mengganti task
418
433
  * sesi lain di tengah jalan, dan gerbang kualitas (ready check, tests, docs)
419
434
  * ikut dibagi sehingga sesi B bisa memakai hasil sesi A untuk commit.
420
435
  *
421
436
  * Sekarang task dan gerbangnya milik SESI; `history` tetap milik PROYEK.
422
437
  *
423
- * Urutannya sengaja: env eksplisit dulu (untuk tes dan pemanggil lain), lalu
424
- * id sesi editor, lalu proses. Jatuh ke proses membuat identitas tetap stabil
425
- * walau modul di-reload dalam proses yang sama.
438
+ * MENGAPA BUKAN ENV SAJA
439
+ * ----------------------
440
+ * Percobaan pertama membaca `OPENCODE_SESSION_ID` dari environment. OpenCode
441
+ * tidak men-set variabel itu, sehingga hampir semua berkas jatuh ke
442
+ * `pid-<pid>`; karena satu proses MCP dipakai bergantian oleh beberapa sesi,
443
+ * sesi-sesi itu tetap berbagi satu berkas dan saling menimpa.
444
+ *
445
+ * OpenCode v2 mengirim identitas sesi per panggilan di
446
+ * `CallToolRequest.params._meta["ai.opencode/sessionID"]`. Itu sumber utama
447
+ * sekarang; env tetap dibaca sebagai cadangan untuk tes dan pemanggil lain.
426
448
  */
427
- export function resolveSessionKey(env = process.env) {
449
+ export const SESSION_META_KEY = "ai.opencode/sessionID";
450
+
451
+ /** Mengambil identitas sesi dari request metadata MCP, atau null. */
452
+ export function sessionKeyFromRequest(request) {
453
+ const value = request?.params?._meta?.[SESSION_META_KEY];
454
+ if (typeof value !== "string") return null;
455
+ const trimmed = value.trim();
456
+ return trimmed === "" ? null : trimmed;
457
+ }
458
+
459
+ export function resolveSessionKey(env = process.env, explicitSessionId = null) {
460
+ const fromRequest = typeof explicitSessionId === "string" ? explicitSessionId.trim() : "";
461
+ if (fromRequest) return sanitizeSessionKey(fromRequest);
462
+
428
463
  for (const candidate of [env.DEV_WORKFLOW_SESSION_ID, env.OPENCODE_SESSION_ID, env.CLIENT_SESSION_ID]) {
429
464
  const value = typeof candidate === "string" ? candidate.trim() : "";
430
465
  if (value) return sanitizeSessionKey(value);
@@ -438,8 +473,8 @@ function sanitizeSessionKey(value) {
438
473
  }
439
474
 
440
475
  /** Nama berkas state untuk sesi ini, di direktori user yang sama. */
441
- function sessionStateFilename(env = process.env) {
442
- return `workflow-state.${resolveSessionKey(env)}.json`;
476
+ function sessionStateFilename(env = process.env, explicitSessionId = null) {
477
+ return `workflow-state.${resolveSessionKey(env, explicitSessionId)}.json`;
443
478
  }
444
479
 
445
480
  /**
@@ -486,7 +521,7 @@ export function resolveProjectRootForState(stateFilePath) {
486
521
  return path.dirname(resolved);
487
522
  }
488
523
 
489
- export function resolveStateFile() {
524
+ export function resolveStateFile({ sessionId = null } = {}) {
490
525
  const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
491
526
  if (customPath) {
492
527
  return path.resolve(customPath);
@@ -516,7 +551,7 @@ export function resolveStateFile() {
516
551
 
517
552
  const stateDir = path.join(projectRoot, STATE_DIR);
518
553
  const userDir = getUserScopedDir(stateDir);
519
- return path.join(userDir, sessionStateFilename());
554
+ return path.join(userDir, sessionStateFilename(process.env, sessionId));
520
555
  }
521
556
 
522
557
  export class WorkflowState {
@@ -525,6 +560,48 @@ export class WorkflowState {
525
560
  // History milik proyek, state milik sesi. Dalam tata letak nyata keduanya
526
561
  // berkas berbeda; untuk path eksplisit keduanya menunjuk berkas yang sama.
527
562
  this.historyFile = resolveHistoryFile(stateFilePath);
563
+ this.state = {
564
+ currentPhase: "idle",
565
+ classification: "INTERNAL",
566
+ taskDescription: "",
567
+ bugFixed: false,
568
+ testsCreated: false,
569
+ testsPassed: false,
570
+ testsSkipped: false,
571
+ testsSkippedReason: "",
572
+ documentationCreated: false,
573
+ readyToCommit: false,
574
+ readyCheckCompleted: false,
575
+ released: false,
576
+ releaseSkipped: false,
577
+ releaseSkippedReason: "",
578
+ releaseCommand: "",
579
+ releaseNotes: "",
580
+ commitAndPushCompleted: false,
581
+ lastCommitMessage: "",
582
+ lastPushBranch: "",
583
+ featureFlowCreated: false,
584
+ featureFlowDescription: "",
585
+ history: [],
586
+ taskType: "",
587
+ };
588
+ }
589
+
590
+ /**
591
+ * Berpindah ke state milik sesi tertentu.
592
+ *
593
+ * Satu proses MCP bisa melayani beberapa sesi bergantian, jadi state harus
594
+ * ditentukan per panggilan - bukan sekali saat proses start. Mengembalikan
595
+ * true bila berkas benar-benar berpindah.
596
+ */
597
+ async useSession(sessionId) {
598
+ const target = resolveStateFile({ sessionId });
599
+ if (target === this.stateFile) return false;
600
+
601
+ this.stateFile = target;
602
+ this.historyFile = resolveHistoryFile(target);
603
+ // Mulai dari state bersih, lalu muat milik sesi itu. Menyalin state sesi
604
+ // sebelumnya akan membuat sesi baru mewarisi gerbang kualitas sesi lain.
528
605
  this.state = {
529
606
  currentPhase: "idle",
530
607
  taskDescription: "",
@@ -549,13 +626,15 @@ export class WorkflowState {
549
626
  history: [],
550
627
  taskType: "",
551
628
  };
629
+ await this.load();
630
+ return true;
552
631
  }
553
632
 
554
633
  async load() {
555
634
  await migrateLegacyStateFile(this.stateFile);
556
635
  try {
557
636
  const data = await fs.readFile(this.stateFile, "utf-8");
558
- const parsed = JSON.parse(data);
637
+ const parsed = sanitize(migrateOwnerMetadata(JSON.parse(data)));
559
638
  this.state = {
560
639
  ...this.state,
561
640
  ...parsed,
@@ -663,11 +742,14 @@ export class WorkflowState {
663
742
  }
664
743
  }
665
744
 
745
+ const payload = { ...shared, history: merged };
746
+ assertSafeKnowledge(payload);
666
747
  await fs.mkdir(path.dirname(this.historyFile), { recursive: true });
667
- await fs.writeFile(this.historyFile, JSON.stringify({ ...shared, history: merged }, null, 2));
748
+ await fs.writeFile(this.historyFile, JSON.stringify(payload, null, 2));
668
749
  }
669
750
 
670
751
  async ensurePrimaryFile() {
752
+ assertSafeKnowledge(this.state);
671
753
  let created = false;
672
754
  try {
673
755
  await fs.access(this.stateFile);
@@ -685,9 +767,11 @@ export class WorkflowState {
685
767
  }
686
768
 
687
769
  async save() {
770
+ assertSafeKnowledge(this.state);
688
771
  const reconciled = await this.reconcileWithDisk();
772
+ assertSafeKnowledge(this.state);
689
773
  this.state.owner = {
690
- token: PROCESS_TOKEN,
774
+ processMarker: PROCESS_TOKEN,
691
775
  pid: process.pid,
692
776
  updatedAt: new Date().toISOString(),
693
777
  };
@@ -709,7 +793,7 @@ export class WorkflowState {
709
793
  async readDiskState() {
710
794
  try {
711
795
  const data = await fs.readFile(this.stateFile, "utf-8");
712
- return JSON.parse(data);
796
+ return sanitize(migrateOwnerMetadata(JSON.parse(data)));
713
797
  } catch {
714
798
  return null;
715
799
  }
@@ -717,7 +801,7 @@ export class WorkflowState {
717
801
 
718
802
  /** True kalau berkas di disk terakhir ditulis proses MCP lain. */
719
803
  isForeignWrite(diskState) {
720
- const token = diskState?.owner?.token;
804
+ const token = diskState?.owner?.processMarker;
721
805
  return typeof token === "string" && token.length > 0 && token !== PROCESS_TOKEN;
722
806
  }
723
807
 
@@ -768,7 +852,7 @@ export class WorkflowState {
768
852
  return result;
769
853
  }
770
854
 
771
- result.foreignToken = disk.owner.token;
855
+ result.foreignToken = disk.owner.processMarker;
772
856
 
773
857
  const hasRunningTask =
774
858
  typeof disk.taskDescription === "string" &&
@@ -788,6 +872,7 @@ export class WorkflowState {
788
872
  }
789
873
 
790
874
  this.state.history.push({
875
+ classification: disk.classification || "INTERNAL",
791
876
  taskDescription: disk.taskDescription,
792
877
  taskType: disk.taskType || "other",
793
878
  commitMessage: disk.lastCommitMessage || "",
@@ -796,7 +881,7 @@ export class WorkflowState {
796
881
  dropReason:
797
882
  "Ditimpa proses agen lain: state workflow dipakai bersama antar sesi di mesin ini",
798
883
  interruptedAtPhase: disk.currentPhase,
799
- interruptedBy: disk.owner.token,
884
+ interruptedBy: disk.owner.processMarker,
800
885
  timestamp: new Date().toISOString(),
801
886
  });
802
887
  result.preservedTask = disk.taskDescription;
@@ -804,13 +889,16 @@ export class WorkflowState {
804
889
  }
805
890
 
806
891
  async updateProjectSummary() {
892
+ assertSafeKnowledge(this.state);
807
893
  const summaryPath = path.join(path.dirname(this.stateFile), PROJECT_SUMMARY_FILENAME);
808
894
  const summary = this.generateProjectSummaryData();
895
+ assertSafeKnowledge(summary);
809
896
  await fs.mkdir(path.dirname(summaryPath), { recursive: true });
810
897
  await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
811
898
  }
812
899
 
813
900
  async syncToDatabase() {
901
+ assertSafeKnowledge(this.state);
814
902
  try {
815
903
  const { insertHistoryEntry, updateSummaryForUser, saveState } = await import("./db/index.js");
816
904
  const userId = getCurrentUserId();
@@ -826,7 +914,7 @@ export class WorkflowState {
826
914
  await saveState(userId, projectRoot, this.state);
827
915
  } catch (error) {
828
916
  // Database sync failed; log it but don't crash
829
- console.error("Database sync failed:", error.message);
917
+ safeLog('error', "Database sync failed:", error.message);
830
918
  }
831
919
  }
832
920
 
@@ -892,6 +980,7 @@ export class WorkflowState {
892
980
  if (archive && hasRunningTask) {
893
981
  archivedTask = previous.taskDescription;
894
982
  history.push({
983
+ classification: previous.classification || "INTERNAL",
895
984
  taskDescription: previous.taskDescription,
896
985
  taskType: previous.taskType || "other",
897
986
  commitMessage: previous.lastCommitMessage || "",
@@ -906,6 +995,7 @@ export class WorkflowState {
906
995
 
907
996
  this.state = {
908
997
  currentPhase: "idle",
998
+ classification: "INTERNAL",
909
999
  taskDescription: "",
910
1000
  bugFixed: false,
911
1001
  testsCreated: false,
@@ -934,6 +1024,7 @@ export class WorkflowState {
934
1024
 
935
1025
  addToHistory(entry) {
936
1026
  this.state.history.push({
1027
+ classification: this.state.classification || "INTERNAL",
937
1028
  ...entry,
938
1029
  timestamp: new Date().toISOString(),
939
1030
  });