@programinglive/dev-workflow-mcp-server 1.13.9 → 1.13.11

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/git-facade.js CHANGED
@@ -10,8 +10,10 @@
10
10
 
11
11
  import {
12
12
  getCurrentBranch,
13
+ getHeadSha,
13
14
  getLastCommitMessage,
14
15
  getPrimaryBranch,
16
+ getRemoteBranchSha,
15
17
  getStagedChanges,
16
18
  getStatusOutput,
17
19
  hasStagedChanges,
@@ -27,7 +29,9 @@ export function createGitFacade() {
27
29
  hasStagedChanges,
28
30
  getStagedChanges,
29
31
  getCurrentBranch,
32
+ getHeadSha,
30
33
  getPrimaryBranch,
34
+ getRemoteBranchSha,
31
35
  getLastCommitMessage,
32
36
  getStatusOutput,
33
37
  workingTreeSummary,
package/git-helpers.js CHANGED
@@ -145,6 +145,29 @@ export async function getCurrentBranch() {
145
145
  return stdout.trim();
146
146
  }
147
147
 
148
+ /**
149
+ * SHA commit yang sedang di HEAD.
150
+ *
151
+ * Dipakai untuk membuktikan bahwa push benar-benar mengirim commit ini, bukan
152
+ * hanya "git push" yang keluar 0 tanpa memindahkan apa pun.
153
+ */
154
+ export async function getHeadSha() {
155
+ const { stdout } = await exec("git rev-parse HEAD");
156
+ return stdout.trim();
157
+ }
158
+
159
+ /**
160
+ * SHA yang tercatat pada remote-tracking ref `origin/<branch>`.
161
+ * Melempar bila branch itu belum ada di remote.
162
+ */
163
+ export async function getRemoteBranchSha(branch) {
164
+ const { stdout } = await exec("git rev-parse", {
165
+ executable: "git",
166
+ arguments: ["rev-parse", `origin/${branch}`],
167
+ });
168
+ return stdout.trim();
169
+ }
170
+
148
171
  export async function getStatusOutput() {
149
172
  const { stdout } = await exec("git status --porcelain");
150
173
  return stdout;
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.9",
3
+ "version": "1.13.11",
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",
@@ -0,0 +1,111 @@
1
+ // Verifikasi end-to-end commit_and_push: harus mem-push branch AKTIF.
2
+ // Memanggil handler asli (bukan mock) terhadap repo sementara + remote bare.
3
+ import { execFileSync } from "node:child_process";
4
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import path from "node:path";
7
+
8
+ import { handleToolCall } from "../tools/handlers.js";
9
+ import { WorkflowState } from "../workflow-state.js";
10
+ import { shellEscape, normalizeRequestArgs } from "../utils.js";
11
+ import { createCommitMessageParts, determineReleaseTypeFromCommit } from "../commit-helpers.js";
12
+ import { createGitFacade } from "../git-facade.js";
13
+ import { exec } from "../exec.js";
14
+
15
+ const git = (cwd, ...args) =>
16
+ execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
17
+
18
+ const root = mkdtempSync(path.join(tmpdir(), "push-verify-"));
19
+ const remote = path.join(root, "remote.git");
20
+ const work = path.join(root, "work");
21
+
22
+ try {
23
+ git(root, "init", "--bare", "-q", remote);
24
+ git(root, "clone", "-q", remote, work);
25
+ git(work, "config", "user.email", "test@example.test");
26
+ git(work, "config", "user.name", "Test");
27
+
28
+ writeFileSync(path.join(work, "base.txt"), "awal\n");
29
+ git(work, "add", ".");
30
+ git(work, "commit", "-q", "-m", "chore: initial");
31
+ git(work, "branch", "-M", "main");
32
+ git(work, "push", "-q", "origin", "main");
33
+
34
+ // Branch fitur dengan commit yang BELUM di-push.
35
+ git(work, "checkout", "-q", "-b", "feat/verifikasi");
36
+ writeFileSync(path.join(work, "fitur.txt"), "fitur\n");
37
+ git(work, "add", ".");
38
+ git(work, "commit", "-q", "-m", "feat: pekerjaan di branch fitur");
39
+
40
+ const headBefore = git(work, "rev-parse", "HEAD");
41
+ const mainBefore = git(work, "rev-parse", "main");
42
+
43
+ // Perubahan yang BELUM di-commit, supaya tool menempuh jalur commit + push
44
+ // (bukan jalur "no changes detected").
45
+ writeFileSync(path.join(work, "belum-commit.txt"), "perubahan\n");
46
+
47
+ console.log("branch aktif :", git(work, "branch", "--show-current"));
48
+ console.log("HEAD awal :", headBefore.slice(0, 8));
49
+ let remoteFeatBefore = "(belum ada)";
50
+ try {
51
+ remoteFeatBefore = git(work, "rev-parse", "--verify", "-q", "origin/feat/verifikasi").slice(0, 8);
52
+ } catch { /* belum ada */ }
53
+ console.log("origin/feat :", remoteFeatBefore);
54
+
55
+ const stateFile = path.join(root, "state.json");
56
+ const workflowState = new WorkflowState(stateFile);
57
+ await workflowState.load();
58
+ Object.assign(workflowState.state, {
59
+ currentPhase: "commit",
60
+ taskDescription: "verifikasi push branch",
61
+ taskType: "other",
62
+ featureFlowCreated: true,
63
+ bugFixed: true,
64
+ testsCreated: true,
65
+ testsPassed: true,
66
+ testsSkipped: true,
67
+ documentationCreated: true,
68
+ readyToCommit: true,
69
+ readyCheckCompleted: true,
70
+ commitAndPushCompleted: false,
71
+ projectDir: work,
72
+ history: [],
73
+ });
74
+ await workflowState.save();
75
+
76
+ const response = await handleToolCall({
77
+ request: {
78
+ params: {
79
+ name: "commit_and_push",
80
+ arguments: { commitMessage: "feat: uji push", projectDir: work },
81
+ },
82
+ },
83
+ normalizeRequestArgs,
84
+ workflowState,
85
+ exec,
86
+ git: createGitFacade(),
87
+ utils: { shellEscape, determineReleaseTypeFromCommit, createCommitMessageParts },
88
+ });
89
+
90
+ console.log("\n=== output tool ===");
91
+ console.log(response.content[0].text);
92
+
93
+ console.log("\n=== hasil di remote ===");
94
+ let remoteFeat = "";
95
+ try {
96
+ remoteFeat = git(work, "rev-parse", "--verify", "-q", "origin/feat/verifikasi");
97
+ } catch { /* tidak ada */ }
98
+ const remoteMain = git(work, "rev-parse", "main");
99
+ console.log("origin/feat/verifikasi :", remoteFeat ? remoteFeat.slice(0, 8) : "(tidak ada)");
100
+ console.log("origin/main :", remoteMain.slice(0, 8));
101
+
102
+ const headAfter = git(work, "rev-parse", "HEAD");
103
+ const okFeat = remoteFeat === headAfter;
104
+ const okMain = remoteMain === mainBefore;
105
+ console.log("");
106
+ console.log(okFeat ? "BENAR: remote menerima commit di branch fitur" : "SALAH: remote/feat tidak menerima HEAD");
107
+ console.log(okMain ? "BENAR: main tidak tersentuh" : "SALAH: main ikut berubah");
108
+ process.exitCode = okFeat && okMain ? 0 : 1;
109
+ } finally {
110
+ rmSync(root, { recursive: true, force: true });
111
+ }
@@ -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,114 @@
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
+ }
45
+
46
+ /**
47
+ * Branch yang benar-benar dipakai untuk push.
48
+ *
49
+ * Urutan: `branch` eksplisit -> branch aktif -> primary branch.
50
+ *
51
+ * Sebelum 31 Okt 2026 urutannya primaryBranch lebih dulu, dan `getPrimaryBranch()`
52
+ * selalu menjawab "main" bila `origin/main` ada - tanpa melihat branch aktif.
53
+ * Akibatnya di worktree yang sedang di `feat/...`, tool menjalankan
54
+ * `git push origin main`. Karena `main` lokal sudah sama dengan `origin/main`,
55
+ * perintah itu keluar 0 tanpa mengirim apa pun, lalu tool melaporkan
56
+ * "Commit and push completed". Dua commit nyata tidak pernah ter-push.
57
+ *
58
+ * HEAD yang detached ("HEAD") bukan branch, jadi tidak bisa dipakai.
59
+ */
60
+ function pickPushBranch({ requestedBranch, currentBranch, primaryBranch }) {
61
+ if (requestedBranch) return requestedBranch;
62
+ if (currentBranch && currentBranch !== "HEAD") return currentBranch;
63
+ return primaryBranch || currentBranch || "";
64
+ }
65
+
66
+ /**
67
+ * Pastikan remote benar-benar menerima HEAD.
68
+ *
69
+ * `git push` bisa keluar 0 tanpa memindahkan apa pun - itulah yang menyamarkan
70
+ * bug di atas. Satu-satunya bukti yang bisa dipercaya adalah membandingkan SHA.
71
+ */
72
+ async function verifyPushed(git, branch) {
73
+ let head;
74
+ try {
75
+ head = await git.getHeadSha();
76
+ } catch (error) {
77
+ return { ok: false, detail: `tidak bisa membaca HEAD: ${error.message}` };
78
+ }
79
+
80
+ let remote;
81
+ try {
82
+ remote = await git.getRemoteBranchSha(branch);
83
+ } catch {
84
+ return {
85
+ ok: false,
86
+ detail: `branch origin/${branch} tidak ditemukan di remote setelah push`,
87
+ };
88
+ }
89
+
90
+ if (remote !== head) {
91
+ return {
92
+ ok: false,
93
+ detail:
94
+ `origin/${branch} berada di ${remote.slice(0, 8)}, ` +
95
+ `sedangkan HEAD di ${head.slice(0, 8)}`,
96
+ };
97
+ }
98
+
99
+ return { ok: true, head };
100
+ }
4
101
 
5
102
  export async function handleCommitAndPush(args, context) {
6
103
  const { workflowState, exec, git, utils } = context;
104
+
105
+ const projectDirError = applyProjectDirOverride(args);
106
+ if (projectDirError) {
107
+ return textResponse(projectDirError);
108
+ }
109
+
110
+ workflowState.state.projectDir = getActiveProjectDir() || workflowState.state.projectDir || "";
111
+
7
112
  if (!workflowState.state.readyToCommit || !workflowState.state.readyCheckCompleted) {
8
113
  const readyResponse = await handleReadyCheck(workflowState);
9
114
  if (workflowState.state.currentPhase !== "commit") {
@@ -29,17 +134,31 @@ export async function handleCommitAndPush(args, context) {
29
134
  } catch (error) {
30
135
  return textResponse(
31
136
  `āŒ Tidak bisa memeriksa repository Git, jadi tidak ada yang di-commit.\n\n` +
137
+ `Repo: ${repoLabel()}\n` +
32
138
  `Penyebab: ${error.message}\n\n` +
33
- `Set WORKFLOW_PROJECT_DIR ke direktori proyek, atau jalankan server MCP dari dalam direktori proyek.`
139
+ `Sebutkan 'projectDir' pada 'start_task' atau 'commit_and_push' untuk menunjuk repo yang benar.`
34
140
  );
35
141
  }
36
142
 
37
- const branchForPush = requestedBranch || primaryBranch || currentBranch || "";
143
+ const branchForPush = pickPushBranch({ requestedBranch, currentBranch, primaryBranch });
38
144
 
39
145
  if (!hasWorkingChanges) {
40
146
  const lastCommitMessage = await git.getLastCommitMessage();
41
147
  const effectiveCommitMessage = lastCommitMessage || providedCommitMessage;
42
148
 
149
+ // "Tidak ada perubahan" bukan berarti "sudah ter-push". HEAD bisa saja
150
+ // berupa commit yang belum pernah dikirim. Memeriksanya mencegah tool
151
+ // menandai selesai padahal remote masih tertinggal.
152
+ const pushed = await verifyPushed(git, branchForPush);
153
+ if (!pushed.ok) {
154
+ return textResponse(
155
+ `āš ļø Tidak ada perubahan untuk di-commit, tetapi HEAD BELUM ada di remote.\n` +
156
+ `Repo: ${repoLabel()}\nBranch: ${branchForPush || "(tidak diketahui)"}\n` +
157
+ `Sebab: ${pushed.detail}\n\n` +
158
+ `Jalankan 'commit_and_push' lagi dengan 'branch' yang benar, atau push manual.`
159
+ );
160
+ }
161
+
43
162
  workflowState.state.commitAndPushCompleted = true;
44
163
  workflowState.state.lastCommitMessage = effectiveCommitMessage || "";
45
164
  workflowState.state.lastPushBranch = branchForPush;
@@ -47,7 +166,7 @@ export async function handleCommitAndPush(args, context) {
47
166
  await workflowState.save();
48
167
 
49
168
  return textResponse(
50
- `ā„¹ļø No changes detected. Assuming commit and push already completed.\nLast commit: ${effectiveCommitMessage || "(unavailable)"
169
+ `ā„¹ļø No changes detected. Assuming commit and push already completed.\nRepo: ${repoLabel()}\nBranch: ${branchForPush} (verified on remote)\nLast commit: ${effectiveCommitMessage || "(unavailable)"
51
170
  }\n\nNext: Run 'perform_release' to handle the release and push tags.`
52
171
  );
53
172
  }
@@ -102,6 +221,20 @@ export async function handleCommitAndPush(args, context) {
102
221
  );
103
222
  }
104
223
 
224
+ // Exit code 0 dari `git push` BUKAN bukti bahwa commit ini terkirim. Kalau
225
+ // branch yang dipilih sudah sinkron, git tidak melakukan apa pun dan tetap
226
+ // keluar 0. Satu-satunya bukti yang bisa dipercaya: SHA HEAD ada di remote.
227
+ const pushed = await verifyPushed(git, branchForPush);
228
+ if (!pushed.ok) {
229
+ return textResponse(
230
+ `āŒ Commit dibuat, tetapi TIDAK ter-push.\n` +
231
+ `Repo: ${repoLabel()}\nBranch: ${branchForPush || "(tidak diketahui)"}\n` +
232
+ `Sebab: ${pushed.detail}\n\n` +
233
+ `Commit lokal: ${generatedSummary}\n\n` +
234
+ `Jalankan 'commit_and_push' lagi dengan 'branch' yang benar, atau push manual.`
235
+ );
236
+ }
237
+
105
238
  workflowState.state.commitAndPushCompleted = true;
106
239
  workflowState.state.lastCommitMessage = generatedSummary;
107
240
  workflowState.state.lastPushBranch = branchForPush;
@@ -109,8 +242,7 @@ export async function handleCommitAndPush(args, context) {
109
242
  await workflowState.save();
110
243
 
111
244
  return textResponse(
112
- `āœ… Commit and push completed!\n\nCommit message: ${generatedSummary}\nPushed to: ${branchForPush || "(default upstream)"
113
- }\n\nNext: Run 'perform_release' to handle the release and push tags.`
245
+ `āœ… Commit and push completed!\nRepo: ${repoLabel()}\n\nCommit message: ${generatedSummary}\nPushed to: ${branchForPush} (verified at ${pushed.head.slice(0, 8)})\n\nNext: Run 'perform_release' to handle the release and push tags.`
114
246
  );
115
247
  }
116
248
 
@@ -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
@@ -624,6 +624,9 @@ export class WorkflowState {
624
624
  commitAndPushCompleted: false,
625
625
  lastCommitMessage: "",
626
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: "",
627
630
  featureFlowCreated: false,
628
631
  featureFlowDescription: "",
629
632
  history: [],
@@ -665,6 +668,9 @@ export class WorkflowState {
665
668
  commitAndPushCompleted: false,
666
669
  lastCommitMessage: "",
667
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: "",
668
674
  featureFlowCreated: false,
669
675
  featureFlowDescription: "",
670
676
  history: [],
@@ -724,6 +730,9 @@ export class WorkflowState {
724
730
  if (typeof this.state.lastPushBranch !== "string") {
725
731
  this.state.lastPushBranch = "";
726
732
  }
733
+ if (typeof this.state.projectDir !== "string") {
734
+ this.state.projectDir = "";
735
+ }
727
736
  if (typeof this.state.featureFlowCreated !== "boolean") {
728
737
  this.state.featureFlowCreated = false;
729
738
  }
@@ -1174,6 +1183,9 @@ export class WorkflowState {
1174
1183
  commitAndPushCompleted: false,
1175
1184
  lastCommitMessage: "",
1176
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: "",
1177
1189
  featureFlowCreated: false,
1178
1190
  featureFlowDescription: "",
1179
1191
  history,