@programinglive/dev-workflow-mcp-server 1.13.8 → 1.13.10

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/README.md CHANGED
@@ -610,14 +610,33 @@ then reinstall.
610
610
  changes** - git ran in the wrong repository. The server used to fall back to its
611
611
  own package directory, which is also a git repo, so commits landed there (or
612
612
  nowhere) instead of in your project. It now refuses to run git in the package
613
- repo and asks for the project explicitly:
613
+ repo and asks for the project explicitly.
614
+
615
+ Since 30 October 2026 the reliable fix is **per task**, not global. Pass
616
+ `projectDir` to `start_task`; the repo is pinned for that task and every later
617
+ git operation uses it:
618
+
619
+ ```json
620
+ { "description": "Fix login redirect", "type": "bugfix", "projectDir": "D:\\code\\clinic" }
621
+ ```
622
+
623
+ `start_task` answers with `šŸ“ Repo proyek: <path> (<source>)`, so the repo in use
624
+ is never a guess. `commit_and_push` repeats it on every response.
625
+
626
+ `WORKFLOW_PROJECT_DIR` still works as an operator-wide default and still wins
627
+ over the session directory, but it is one value for the whole server process.
628
+ When the session directory points at a *different* repo, `start_task` now
629
+ reports `āš ļø KONFLIK` and tells you to pass `projectDir` — previously that
630
+ mismatch was silent, which is how a commit landed in the wrong repo. Prefer
631
+ `projectDir`; keep the env var only for machines with a single project.
614
632
 
615
633
  ```bash
616
634
  export WORKFLOW_PROJECT_DIR=/path/to/your/project
617
635
  ```
618
636
 
619
637
  Point it at any directory inside the project; the repo root is discovered
620
- upward. Non-git commands are unaffected.
638
+ upward. Non-git commands are unaffected. Full incident write-up:
639
+ [`docs/2026-10-30-project-dir-misrouting.md`](docs/2026-10-30-project-dir-misrouting.md).
621
640
 
622
641
  **A task jumped straight to `done` after reporting `review`** - it was reported
623
642
  twice. Factory treats the second report as approval. Current versions block that
package/exec.js CHANGED
@@ -49,64 +49,138 @@ function samePath(a, b) {
49
49
  }
50
50
 
51
51
  /**
52
- * Menentukan direktori repo proyek tempat git boleh dijalankan.
52
+ * Direktori repo proyek yang aktif untuk pemanggilan ini.
53
53
  *
54
- * Urutan: eksplisit -> WORKFLOW_PROJECT_DIR/PROJECT_DIR -> kandidat.
54
+ * Kenapa ada: `WORKFLOW_PROJECT_DIR` adalah satu nilai global untuk seluruh
55
+ * proses MCP. Di mesin ini nilainya `D:\code\clinic`, sehingga sesi yang
56
+ * sebenarnya bekerja di repo lain tetap menjalankan git di clinic — dan
57
+ * `commit_and_push` melaporkan "No changes detected" seolah sudah selesai.
55
58
  *
56
- * Repo paket MCP sengaja tidak dipilih sebagai cadangan: dulu server pernah
57
- * menjalankan git di repo-nya sendiri saat cwd proyek gagal diresolusi, lalu
58
- * melaporkan "No changes detected" sementara perubahan proyek tidak pernah
59
- * ter-commit.
59
+ * Direktori aktif diisi dari state sesi (`projectDir`), yang ditentukan saat
60
+ * `start_task` atau saat `commit_and_push`/`perform_release` dipanggil dengan
61
+ * argumen `projectDir` eksplisit.
62
+ */
63
+ let activeProjectDir = "";
64
+
65
+ export function setActiveProjectDir(dir) {
66
+ activeProjectDir = dir ? path.resolve(dir) : "";
67
+ return activeProjectDir;
68
+ }
69
+
70
+ export function getActiveProjectDir() {
71
+ return activeProjectDir;
72
+ }
73
+
74
+ function repoRootOrThrow(dir, message) {
75
+ const root = findRepoRoot(dir);
76
+ if (!root) {
77
+ throw new Error(message);
78
+ }
79
+ return root;
80
+ }
81
+
82
+ /**
83
+ * Menentukan direktori repo proyek, sekaligus melaporkan DARI MANA asalnya.
60
84
  *
61
- * Tetapi kandidat yang MEMANG berada di dalam repo paket berarti pemanggil
62
- * sengaja bekerja di sana - misalnya saat mengembangkan server MCP ini sendiri.
63
- * Membuangnya tanpa syarat membuat `commit_and_push` mustahil dipakai di repo
64
- * paket, jadi kandidat seperti itu diterima. Repo proyek lain tetap menang.
85
+ * Urutan (per 30 Oktober 2026):
86
+ * 1. explicit - argumen `projectDir` pada panggilan tool
87
+ * 2. pinned - `projectDir` yang dicatat `start_task` untuk sesi ini
88
+ * 3. env - WORKFLOW_PROJECT_DIR / PROJECT_DIR (setelan operator, global)
89
+ * 4. cwd - direktori kerja sesi (kandidat)
90
+ *
91
+ * `env` tetap di atas `cwd` karena ia setelan operator yang disengaja: kalau
92
+ * seseorang menunjuk repo lewat env, ia bermaksud demikian.
93
+ *
94
+ * Yang berubah: env sekarang bisa DIKALAHKAN per sesi lewat `projectDir`, dan
95
+ * bila env dipakai sementara direktori sesi menunjuk repo lain, konfliknya
96
+ * dilaporkan (`conflict`). Sebelumnya tidak ada jalan keluar sama sekali:
97
+ * satu nilai global membajak semua sesi, `git status` di repo yang salah
98
+ * menjawab "bersih", dan workflow melaporkan sukses palsu.
99
+ *
100
+ * `source` dan `conflict` dipakai tool untuk memberi tahu pengguna repo mana
101
+ * yang dipakai - dan kapan ia kemungkinan salah.
65
102
  */
66
- export function resolveWorkingDirectory({ explicit = "", env = process.env, candidates = [] } = {}) {
103
+ export function resolveProjectDir({ explicit = "", pinned = "", env = process.env, candidates = [] } = {}) {
67
104
  if (explicit) {
68
- const root = findRepoRoot(explicit);
69
- if (!root) {
70
- throw new Error(`Direktori "${explicit}" bukan repo git (tidak ada .git).`);
71
- }
72
- return root;
105
+ return {
106
+ dir: repoRootOrThrow(explicit, `Direktori "${explicit}" bukan repo git (tidak ada .git).`),
107
+ source: "explicit",
108
+ conflict: null,
109
+ };
73
110
  }
74
111
 
75
- const envDir = (env.WORKFLOW_PROJECT_DIR || env.PROJECT_DIR || "").trim();
76
- if (envDir) {
77
- const root = findRepoRoot(envDir);
78
- if (!root) {
79
- throw new Error(`WORKFLOW_PROJECT_DIR "${envDir}" bukan repo git (tidak ada .git).`);
80
- }
81
- return root;
112
+ if (pinned) {
113
+ return {
114
+ dir: repoRootOrThrow(
115
+ pinned,
116
+ `projectDir sesi "${pinned}" bukan repo git (tidak ada .git). ` +
117
+ "Perbaiki dengan memanggil 'start_task' lagi dengan projectDir yang benar.",
118
+ ),
119
+ source: "pinned",
120
+ conflict: null,
121
+ };
82
122
  }
83
123
 
84
124
  const found = candidates.map((candidate) => findRepoRoot(candidate)).filter(Boolean);
125
+ const cwdRepo = found.find((dir) => !samePath(dir, PACKAGE_DIR)) || null;
126
+ const packageCandidate = found.some((dir) => samePath(dir, PACKAGE_DIR));
127
+
128
+ const envDir = (env.WORKFLOW_PROJECT_DIR || env.PROJECT_DIR || "").trim();
129
+ if (envDir) {
130
+ const envRepo = repoRootOrThrow(envDir, `WORKFLOW_PROJECT_DIR "${envDir}" bukan repo git (tidak ada .git).`);
131
+ return {
132
+ dir: envRepo,
133
+ source: "env",
134
+ // Direktori sesi menunjuk repo lain: kemungkinan besar env ini milik
135
+ // proyek lain, dan git akan berjalan di repo yang salah.
136
+ conflict: cwdRepo && !samePath(cwdRepo, envRepo) ? { envDir: envRepo, cwdDir: cwdRepo } : null,
137
+ };
138
+ }
85
139
 
86
- const projectRepo = found.find((dir) => !samePath(dir, PACKAGE_DIR));
87
- if (projectRepo) {
88
- return projectRepo;
140
+ if (cwdRepo) {
141
+ return { dir: cwdRepo, source: "cwd", conflict: null };
89
142
  }
90
143
 
91
144
  // Hanya repo paket yang ditemukan. Itu hanya mungkin bila salah satu kandidat
92
145
  // benar-benar berada di dalamnya, jadi pemanggil memilih repo ini dengan sadar.
93
- if (found.some((dir) => samePath(dir, PACKAGE_DIR))) {
94
- return path.resolve(PACKAGE_DIR);
146
+ if (packageCandidate) {
147
+ return { dir: path.resolve(PACKAGE_DIR), source: "cwd", conflict: null };
95
148
  }
96
149
 
97
150
  throw new Error(
98
151
  "Tidak bisa menentukan repo proyek: tidak ada repository git pada " +
99
152
  `${candidates.filter(Boolean).map((candidate) => `"${candidate}"`).join(", ") || "direktori kerja"}. ` +
100
- "Set WORKFLOW_PROJECT_DIR ke direktori proyek, atau jalankan server MCP dari dalam direktori proyek.",
153
+ "Sebutkan 'projectDir' pada 'start_task' (disarankan), atau set WORKFLOW_PROJECT_DIR, " +
154
+ "atau jalankan server MCP dari dalam direktori proyek.",
101
155
  );
102
156
  }
103
157
 
158
+ /**
159
+ * Penentu direktori repo proyek tempat git boleh dijalankan.
160
+ *
161
+ * Urutan: eksplisit -> WORKFLOW_PROJECT_DIR/PROJECT_DIR -> kandidat.
162
+ *
163
+ * Repo paket MCP sengaja tidak dipilih sebagai cadangan: dulu server pernah
164
+ * menjalankan git di repo-nya sendiri saat cwd proyek gagal diresolusi, lalu
165
+ * melaporkan "No changes detected" sementara perubahan proyek tidak pernah
166
+ * ter-commit.
167
+ *
168
+ * Tetapi kandidat yang MEMANG berada di dalam repo paket berarti pemanggil
169
+ * sengaja bekerja di sana - misalnya saat mengembangkan server MCP ini sendiri.
170
+ * Membuangnya tanpa syarat membuat `commit_and_push` mustahil dipakai di repo
171
+ * paket, jadi kandidat seperti itu diterima. Repo proyek lain tetap menang.
172
+ */
173
+ export function resolveWorkingDirectory(options = {}) {
174
+ return resolveProjectDir(options).dir;
175
+ }
176
+
104
177
  function getDefaultCwd(command) {
105
178
  try {
106
- return resolveWorkingDirectory({
179
+ return resolveProjectDir({
180
+ explicit: activeProjectDir,
107
181
  env: process.env,
108
182
  candidates: [process.cwd(), process.env.INIT_CWD],
109
- });
183
+ }).dir;
110
184
  } catch (error) {
111
185
  // Perintah non-git (echo, npm run release, ...) tidak butuh repo proyek.
112
186
  // Yang berbahaya hanya git: di sana cwd menentukan repo mana yang diubah.
package/index.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  GetPromptRequestSchema,
14
14
  } from "@modelcontextprotocol/sdk/types.js";
15
15
  import { fileURLToPath } from "url";
16
- import { exec } from "./exec.js";
16
+ import { exec, setActiveProjectDir } from "./exec.js";
17
17
  import { normalizeRequestArgs, shellEscape } from "./utils.js";
18
18
  import { createGitFacade } from "./git-facade.js";
19
19
  import {
@@ -60,6 +60,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
60
60
  const sessionId = sessionKeyFromRequest(request);
61
61
  if (sessionId) await workflowState.useSession(sessionId);
62
62
  await workflowState.load();
63
+ // Repo proyek mengikuti sesi, bukan satu nilai global. Tanpa ini, sesi yang
64
+ // bekerja di repo lain tetap menjalankan git di WORKFLOW_PROJECT_DIR.
65
+ setActiveProjectDir(workflowState.state.projectDir || "");
63
66
  }
64
67
  return securityOutputFilter(await handleToolCall({
65
68
  request,
@@ -234,6 +237,7 @@ async function cliMain(argv) {
234
237
  workflowState = new WorkflowState();
235
238
  await workflowState.load();
236
239
  await workflowState.ensurePrimaryFile();
240
+ setActiveProjectDir(workflowState.state.projectDir || "");
237
241
 
238
242
  const request = {
239
243
  params: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@programinglive/dev-workflow-mcp-server",
3
- "version": "1.13.8",
3
+ "version": "1.13.10",
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",
@@ -25,6 +25,14 @@ export function getToolList() {
25
25
  enum: ["feature", "bugfix", "refactor", "other"],
26
26
  description: "Type of task",
27
27
  },
28
+ projectDir: {
29
+ type: "string",
30
+ description:
31
+ "Absolute path to the repository this task works on (e.g. D:\\code\\clinic). " +
32
+ "Pinned for the whole task; every git operation afterwards runs there. " +
33
+ "Pass it whenever the session directory is not itself a git repository, " +
34
+ "otherwise the server may fall back to WORKFLOW_PROJECT_DIR and commit to the wrong repo.",
35
+ },
28
36
  },
29
37
  required: ["description", "type"],
30
38
  },
@@ -153,6 +161,12 @@ export function getToolList() {
153
161
  type: "string",
154
162
  description: "Optional branch name to push to (defaults to current branch)",
155
163
  },
164
+ projectDir: {
165
+ type: "string",
166
+ description:
167
+ "Absolute path to the repository to commit in. Overrides the repo pinned by 'start_task' " +
168
+ "for this call. Use it to fix a misdirected commit without restarting the task.",
169
+ },
156
170
  },
157
171
  required: [],
158
172
  },
@@ -172,6 +186,11 @@ export function getToolList() {
172
186
  type: "string",
173
187
  description: "Optional release notes",
174
188
  },
189
+ projectDir: {
190
+ type: "string",
191
+ description:
192
+ "Absolute path to the repository to release. Overrides the repo pinned by 'start_task' for this call.",
193
+ },
175
194
  },
176
195
  required: ["command"],
177
196
  },
@@ -1,9 +1,58 @@
1
1
  import { textResponse, isReleaseSatisfied, formatChecklist } from "./shared.js";
2
2
  import { handleReadyCheck } from "./status-handlers.js";
3
3
  import { containsTestFilesInStatus } from "../../git-helpers.js";
4
+ import { resolveProjectDir, setActiveProjectDir, getActiveProjectDir } from "../../exec.js";
5
+
6
+ /**
7
+ * Label repo yang benar-benar dipakai, untuk ditampilkan di setiap jawaban.
8
+ *
9
+ * Tanpa ini, "No changes detected" tidak bisa dibedakan antara "sudah commit"
10
+ * dan "git berjalan di repo yang salah". Itu persis yang menyamarkan commit
11
+ * salah arah pada 30 Oktober 2026.
12
+ */
13
+ function repoLabel() {
14
+ try {
15
+ const resolved = resolveProjectDir({
16
+ explicit: getActiveProjectDir(),
17
+ env: process.env,
18
+ candidates: [process.cwd(), process.env.INIT_CWD],
19
+ });
20
+ const origin = resolved.source === "env" ? " — dari WORKFLOW_PROJECT_DIR (global)" : "";
21
+ return resolved.dir + origin;
22
+ } catch {
23
+ return "(tidak bisa ditentukan)";
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Terapkan `projectDir` dari argumen tool bila ada.
29
+ * Mengembalikan pesan kesalahan, atau null bila tidak ada masalah.
30
+ */
31
+ function applyProjectDirOverride(args) {
32
+ const requested = typeof args.projectDir === "string" ? args.projectDir.trim() : "";
33
+ if (!requested) {
34
+ return null;
35
+ }
36
+
37
+ try {
38
+ const resolved = resolveProjectDir({ explicit: requested });
39
+ setActiveProjectDir(resolved.dir);
40
+ return null;
41
+ } catch (error) {
42
+ return `āŒ projectDir tidak valid: ${error.message}`;
43
+ }
44
+ }
4
45
 
5
46
  export async function handleCommitAndPush(args, context) {
6
47
  const { workflowState, exec, git, utils } = context;
48
+
49
+ const projectDirError = applyProjectDirOverride(args);
50
+ if (projectDirError) {
51
+ return textResponse(projectDirError);
52
+ }
53
+
54
+ workflowState.state.projectDir = getActiveProjectDir() || workflowState.state.projectDir || "";
55
+
7
56
  if (!workflowState.state.readyToCommit || !workflowState.state.readyCheckCompleted) {
8
57
  const readyResponse = await handleReadyCheck(workflowState);
9
58
  if (workflowState.state.currentPhase !== "commit") {
@@ -29,8 +78,9 @@ export async function handleCommitAndPush(args, context) {
29
78
  } catch (error) {
30
79
  return textResponse(
31
80
  `āŒ Tidak bisa memeriksa repository Git, jadi tidak ada yang di-commit.\n\n` +
81
+ `Repo: ${repoLabel()}\n` +
32
82
  `Penyebab: ${error.message}\n\n` +
33
- `Set WORKFLOW_PROJECT_DIR ke direktori proyek, atau jalankan server MCP dari dalam direktori proyek.`
83
+ `Sebutkan 'projectDir' pada 'start_task' atau 'commit_and_push' untuk menunjuk repo yang benar.`
34
84
  );
35
85
  }
36
86
 
@@ -47,7 +97,7 @@ export async function handleCommitAndPush(args, context) {
47
97
  await workflowState.save();
48
98
 
49
99
  return textResponse(
50
- `ā„¹ļø No changes detected. Assuming commit and push already completed.\nLast commit: ${effectiveCommitMessage || "(unavailable)"
100
+ `ā„¹ļø No changes detected. Assuming commit and push already completed.\nRepo: ${repoLabel()}\nLast commit: ${effectiveCommitMessage || "(unavailable)"
51
101
  }\n\nNext: Run 'perform_release' to handle the release and push tags.`
52
102
  );
53
103
  }
@@ -109,7 +159,7 @@ export async function handleCommitAndPush(args, context) {
109
159
  await workflowState.save();
110
160
 
111
161
  return textResponse(
112
- `āœ… Commit and push completed!\n\nCommit message: ${generatedSummary}\nPushed to: ${branchForPush || "(default upstream)"
162
+ `āœ… Commit and push completed!\nRepo: ${repoLabel()}\n\nCommit message: ${generatedSummary}\nPushed to: ${branchForPush || "(default upstream)"
113
163
  }\n\nNext: Run 'perform_release' to handle the release and push tags.`
114
164
  );
115
165
  }
@@ -1,4 +1,5 @@
1
1
  import { textResponse, isReleaseSatisfied, resetToCommitIfWorkingChanges, summarizeHistory } from "./shared.js";
2
+ import { resolveProjectDir, setActiveProjectDir, getActiveProjectDir } from "../../exec.js";
2
3
 
3
4
  export async function handleSkipRelease(args, workflowState) {
4
5
  if (workflowState.state.currentPhase === "idle") {
@@ -50,6 +51,18 @@ export async function handlePerformRelease(args, context) {
50
51
  return textResponse("āš ļø Please start a task first using 'start_task'!");
51
52
  }
52
53
 
54
+ // Sama seperti commit_and_push: projectDir eksplisit menang atas repo sesi.
55
+ const requestedProjectDir =
56
+ typeof args.projectDir === "string" ? args.projectDir.trim() : "";
57
+ if (requestedProjectDir) {
58
+ try {
59
+ setActiveProjectDir(resolveProjectDir({ explicit: requestedProjectDir }).dir);
60
+ } catch (error) {
61
+ return textResponse(`āŒ projectDir tidak valid: ${error.message}`);
62
+ }
63
+ workflowState.state.projectDir = getActiveProjectDir();
64
+ }
65
+
53
66
  let hasNewChanges;
54
67
  try {
55
68
  hasNewChanges = await resetToCommitIfWorkingChanges(workflowState, git);
@@ -81,7 +94,7 @@ export async function handlePerformRelease(args, context) {
81
94
  return textResponse(
82
95
  `āŒ Tidak bisa memeriksa repository Git, jadi status rilis tidak dapat diverifikasi.\n\n` +
83
96
  `Penyebab: ${error.message}\n\n` +
84
- `Set WORKFLOW_PROJECT_DIR ke direktori proyek, atau jalankan server MCP dari dalam direktori proyek.`
97
+ `Sebutkan 'projectDir' pada 'perform_release' untuk menunjuk repo yang benar.`
85
98
  );
86
99
  }
87
100
 
@@ -1,5 +1,71 @@
1
1
  import { classify } from '../../security/policy.js';
2
2
  import { textResponse, isReleaseSatisfied, isErrorResponse, ensurePrdExists } from "./shared.js";
3
+ import { resolveProjectDir, setActiveProjectDir } from "../../exec.js";
4
+
5
+ /**
6
+ * Catat repo proyek untuk sesi ini.
7
+ *
8
+ * Tanpa ini, satu-satunya penentu adalah WORKFLOW_PROJECT_DIR yang global.
9
+ * Di mesin ini nilainya `D:\code\clinic`, jadi task yang sebenarnya bekerja di
10
+ * repo lain tetap menjalankan git di clinic dan `commit_and_push` menjawab
11
+ * "No changes detected" seolah sudah selesai (30 Oktober 2026).
12
+ *
13
+ * Best-effort: kalau repo belum bisa ditentukan, task tetap boleh mulai, tapi
14
+ * pengguna diberi tahu supaya tidak terkejut saat commit.
15
+ */
16
+ function pinProjectDir(args, workflowState) {
17
+ const requested = typeof args.projectDir === "string" ? args.projectDir.trim() : "";
18
+
19
+ let resolved = null;
20
+ let error = null;
21
+ try {
22
+ resolved = resolveProjectDir({
23
+ explicit: requested,
24
+ env: process.env,
25
+ candidates: [process.cwd(), process.env.INIT_CWD],
26
+ });
27
+ } catch (resolveError) {
28
+ error = resolveError;
29
+ }
30
+
31
+ if (!resolved) {
32
+ workflowState.state.projectDir = "";
33
+ setActiveProjectDir("");
34
+ return {
35
+ message:
36
+ `āš ļø Repo proyek belum ditentukan: ${error.message}\n` +
37
+ ` Sebutkan 'projectDir' pada 'start_task' supaya commit tidak salah arah.`,
38
+ };
39
+ }
40
+
41
+ workflowState.state.projectDir = resolved.dir;
42
+ setActiveProjectDir(resolved.dir);
43
+
44
+ const sourceNote = {
45
+ explicit: "dari argumen projectDir",
46
+ cwd: "dari direktori kerja sesi",
47
+ env: "dari WORKFLOW_PROJECT_DIR (nilai global)",
48
+ pinned: "dari projectDir sesi",
49
+ }[resolved.source] || resolved.source;
50
+
51
+ let message = `šŸ“ Repo proyek: ${resolved.dir} (${sourceNote})`;
52
+
53
+ // Sumber env berarti tidak ada sinyal per-sesi sama sekali. Kalau direktori
54
+ // sesi ternyata menunjuk repo LAIN, hampir pasti env ini milik proyek lain -
55
+ // dan itulah yang dulu membuat `git add .` berjalan di repo yang salah.
56
+ if (resolved.conflict) {
57
+ message +=
58
+ `\n āš ļø KONFLIK: direktori sesi menunjuk repo lain, ${resolved.conflict.cwdDir}.` +
59
+ `\n Repo dipilih dari WORKFLOW_PROJECT_DIR. Kalau task ini untuk repo sesi,` +
60
+ `\n ulangi 'start_task' dengan projectDir: "${resolved.conflict.cwdDir}".`;
61
+ } else if (resolved.source === "env") {
62
+ message +=
63
+ `\n āš ļø Repo ini berasal dari WORKFLOW_PROJECT_DIR, bukan dari sesi.` +
64
+ `\n Kalau task ini bekerja di repo lain, ulangi 'start_task' dengan projectDir yang benar.`;
65
+ }
66
+
67
+ return { message };
68
+ }
3
69
 
4
70
  export async function handleStartTask(args, workflowState) {
5
71
  // Selamatkan dulu pekerjaan yang mungkin tertimpa. Ini menangani dua kasus:
@@ -17,6 +83,8 @@ export async function handleStartTask(args, workflowState) {
17
83
  workflowState.state.commitAndPushCompleted = false;
18
84
  workflowState.state.lastCommitMessage = "";
19
85
  workflowState.state.lastPushBranch = "";
86
+ // Setelah reset(), karena reset mengosongkan projectDir.
87
+ const project = pinProjectDir(args, workflowState);
20
88
  // start_task memang bermaksud MENGGANTI task, jadi ia boleh menang atas
21
89
  // task berjalan. Task yang diganti tetap diarsipkan oleh reconcileWithDisk,
22
90
  // bukan dihapus.
@@ -47,7 +115,7 @@ export async function handleStartTask(args, workflowState) {
47
115
  const warningBlock = warnings.length > 0 ? `\n\n${warnings.join("\n\n")}` : "";
48
116
 
49
117
  return textResponse(
50
- `āœ… Task Started: ${args.description}${warningBlock}\n\nšŸŽÆ Be conscious about what you're coding!\n\nWorkflow Steps:\n1. āœ“ Start task (current)\n2. ā³ Describe feature flow with Mermaid\n3. ā³ Fix/implement the feature\n4. ā³ Create tests\n5. ā³ Run tests (must pass!)\n6. ā³ Create documentation\n7. ā³ Run 'check_ready_to_commit'\n8. ā³ Run 'commit_and_push' (commits and pushes)\n9. ā³ Run 'perform_release' (handles versioning and tags) or 'skip_release' (when no release is needed)\n10. ā³ Complete task\n\nReminder: Focus on writing clean, maintainable code!`
118
+ `āœ… Task Started: ${args.description}\n\n${project.message}${warningBlock}\n\nšŸŽÆ Be conscious about what you're coding!\n\nWorkflow Steps:\n1. āœ“ Start task (current)\n2. ā³ Describe feature flow with Mermaid\n3. ā³ Fix/implement the feature\n4. ā³ Create tests\n5. ā³ Run tests (must pass!)\n6. ā³ Create documentation\n7. ā³ Run 'check_ready_to_commit'\n8. ā³ Run 'commit_and_push' (commits and pushes)\n9. ā³ Run 'perform_release' (handles versioning and tags) or 'skip_release' (when no release is needed)\n10. ā³ Complete task\n\nReminder: Focus on writing clean, maintainable code!`
51
119
  );
52
120
  }
53
121
 
package/workflow-state.js CHANGED
@@ -223,18 +223,55 @@ function getUserScopedDir(baseDir) {
223
223
  return path.join(baseDir, "users", userId);
224
224
  }
225
225
 
226
- function getCurrentUserId() {
226
+ /**
227
+ * Identitas user untuk baris database milik state ini.
228
+ *
229
+ * MENGAPA TIDAK MEMBACA CACHE MODUL
230
+ * ---------------------------------
231
+ * Versi sebelumnya hanya memakai `cachedUserId`, yang terisi lewat
232
+ * `resolveUserId()` - dan itu hanya dipanggil dari `resolveStateFile()`.
233
+ * Pemanggil yang membuat `WorkflowState` dengan path EKSPLISIT (skrip
234
+ * perbaikan, CLI, tes) tidak pernah memicunya, sehingga `syncToDatabase()`
235
+ * diam-diam menulis baris dengan `user_id = "default"`.
236
+ *
237
+ * Akibat nyata 30 Oktober 2026: satu proyek punya riwayat di dua identitas
238
+ * (`user-…` dan `default`) dalam database yang sama, dan 150 baris duplikat
239
+ * harus dibersihkan manual.
240
+ *
241
+ * Membaca langsung dari direktori `.state` milik state ini membuat hasilnya
242
+ * tidak bergantung pada direktori kerja proses, tidak bergantung pada urutan
243
+ * pemanggilan, dan tidak bocor antar proyek dalam satu proses.
244
+ */
245
+ function getCurrentUserId(stateFile = null) {
227
246
  const envUserId = (process.env.DEV_WORKFLOW_USER_ID || "").trim();
228
247
  if (envUserId) {
229
248
  return envUserId;
230
249
  }
231
250
 
232
- if (cachedUserId) {
233
- return cachedUserId;
251
+ if (stateFile) {
252
+ const stateDir = path.dirname(path.resolve(stateFile));
253
+ const userIdFile = path.join(stateDir, USER_ID_FILENAME);
254
+
255
+ try {
256
+ const stored = readFileSync(userIdFile, "utf-8").trim();
257
+ if (stored) {
258
+ return stored;
259
+ }
260
+ } catch {
261
+ // Berkas belum ada: jatuh ke pencarian di bawah.
262
+ }
263
+
264
+ // .state langsung (bukan .state/users/<id>): pakai resolveUserId, yang
265
+ // menangani migrasi id lama maupun pembuatan id baru.
266
+ try {
267
+ return resolveUserId(stateDir);
268
+ } catch {
269
+ // Biarkan jatuh ke nilai cadangan di bawah.
270
+ }
234
271
  }
235
272
 
236
- if (cachedUserStateDir) {
237
- return resolveUserId(cachedUserStateDir);
273
+ if (cachedUserId) {
274
+ return cachedUserId;
238
275
  }
239
276
 
240
277
  return "default";
@@ -587,6 +624,9 @@ export class WorkflowState {
587
624
  commitAndPushCompleted: false,
588
625
  lastCommitMessage: "",
589
626
  lastPushBranch: "",
627
+ // Repo proyek untuk sesi ini. Ditentukan saat start_task, dipakai semua
628
+ // operasi git berikutnya. Kosong berarti ikuti urutan resolusi di exec.js.
629
+ projectDir: "",
590
630
  featureFlowCreated: false,
591
631
  featureFlowDescription: "",
592
632
  history: [],
@@ -628,6 +668,9 @@ export class WorkflowState {
628
668
  commitAndPushCompleted: false,
629
669
  lastCommitMessage: "",
630
670
  lastPushBranch: "",
671
+ // Repo proyek untuk sesi ini. Ditentukan saat start_task, dipakai semua
672
+ // operasi git berikutnya. Kosong berarti ikuti urutan resolusi di exec.js.
673
+ projectDir: "",
631
674
  featureFlowCreated: false,
632
675
  featureFlowDescription: "",
633
676
  history: [],
@@ -687,6 +730,9 @@ export class WorkflowState {
687
730
  if (typeof this.state.lastPushBranch !== "string") {
688
731
  this.state.lastPushBranch = "";
689
732
  }
733
+ if (typeof this.state.projectDir !== "string") {
734
+ this.state.projectDir = "";
735
+ }
690
736
  if (typeof this.state.featureFlowCreated !== "boolean") {
691
737
  this.state.featureFlowCreated = false;
692
738
  }
@@ -1014,7 +1060,7 @@ export class WorkflowState {
1014
1060
  async syncToDatabase(options = {}) {
1015
1061
  assertSafeKnowledge(this.state);
1016
1062
  const db = options.db ?? (await import("./db/index.js"));
1017
- const userId = getCurrentUserId();
1063
+ const userId = getCurrentUserId(this.stateFile);
1018
1064
  // projectRoot dipakai sebagai identitas proyek di database. Ditentukan
1019
1065
  // lewat resolveProjectRootForState, bukan dengan menghitung `dirname`
1020
1066
  // berkali-kali: jumlah itu hanya benar untuk satu kedalaman tertentu.
@@ -1137,6 +1183,9 @@ export class WorkflowState {
1137
1183
  commitAndPushCompleted: false,
1138
1184
  lastCommitMessage: "",
1139
1185
  lastPushBranch: "",
1186
+ // Repo proyek untuk sesi ini. Ditentukan saat start_task, dipakai semua
1187
+ // operasi git berikutnya. Kosong berarti ikuti urutan resolusi di exec.js.
1188
+ projectDir: "",
1140
1189
  featureFlowCreated: false,
1141
1190
  featureFlowDescription: "",
1142
1191
  history,