@programinglive/dev-workflow-mcp-server 1.14.1 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -8,6 +8,8 @@ You are connected to the `dev-workflow` MCP server. **You MUST NOT perform any c
8
8
  Before taking any action or answering user requests regarding code changes, check your workflow status and follow these exact steps in sequence:
9
9
 
10
10
  1. **Start Task**: Call `start_task` with a clear description, type (`feature`, `bugfix`, `refactor`, or `other`), `projectDir`, **and a `definitionOfDone` checklist**. Every task must say, up front, which concrete conditions must be true before it counts as finished.
11
+
12
+ `start_task` **refuses** when another live MCP process is working the same project (heartbeat within the last 10 minutes). Two sessions on one working tree overwrite each other's files silently — on 31 October 2026 a session lost every edit in `tools/handler-modules/index.js` that way. Close the other window, use a git worktree, or pass `force: true` when you know the other session is idle. The check runs before any state is touched, so a refused call changes nothing.
11
13
  2. **Implement**: Write your code changes.
12
14
  3. **Mark Fixed**: Call `mark_bug_fixed` with a summary of the implementation.
13
15
  4. **Prove the Definition of Done**: For each item, call `mark_dod_item` with `evidence` (5-500 characters naming what you actually ran or checked, e.g. `npm test -> 505 pass`). An item that turned out not to apply is excluded with `waive_dod_item` and a written reason - never silently dropped.
@@ -16,6 +18,12 @@ Before taking any action or answering user requests regarding code changes, chec
16
18
  7. **Document**: Update documentation (README, PRD, release notes) and call `create_documentation`.
17
19
  8. **Ready Check**: Call `check_ready_to_commit` to verify all steps. It fails while any definition-of-done item is still open.
18
20
  9. **Commit & Push**: Call `commit_and_push` with a conventional commit message. It refuses while any item is open.
21
+
22
+ If the repo also contains another session's uncommitted work, `commit_and_push`
23
+ refuses to stage the whole tree. Pass `paths` with only the files this task
24
+ touched (recommended), or `includeForeign: true` after verifying that work
25
+ belongs in this commit. Never resolve it by overwriting the other session's
26
+ files.
19
27
  10. **Release**: Call `perform_release` with the release command (or `skip_release` if non-Node/docs-only).
20
28
  11. **Complete**: Call `complete_task` to finish the session and reset the workflow. It refuses while any item is open, and records `dodSummary` in history.
21
29
 
@@ -32,6 +40,10 @@ Before taking any action or answering user requests regarding code changes, chec
32
40
 
33
41
  ### Quick Shortcuts
34
42
  - If you have all information ready, you can use `run_full_workflow` to execute all steps safely in sequence.
43
+ It requires `dodEvidence` for every definition-of-done item that is still open — for example
44
+ `"dodEvidence": { "dod-1": "npm test -> 533 pass" }`. Without it the call stops **before the first step** and
45
+ lists the items it needs, because it cannot invent proof. The evidence is recorded just before the ready check,
46
+ so it describes code that has already been written.
35
47
  - Use `get_workflow_status` at any time to check what step is next.
36
48
 
37
49
  **Do not bypass these steps. The MCP server will block commits and releases if any required step is skipped.**
package/README.md CHANGED
@@ -125,14 +125,14 @@ See [MCP client configuration](#mcp-client-configuration).
125
125
  | `run_tests` | Record test results (blocks commit when failing) |
126
126
  | `create_documentation` | Mark docs as written |
127
127
  | `check_ready_to_commit` | Verify every required step is complete |
128
- | `commit_and_push` | `git add` → `commit` → `push` |
129
- | `perform_release` | Record a release |
128
+ | `commit_and_push` | `git add` → `commit` → `push`, refusing to sweep in another session's work |
129
+ | `perform_release` | Record a release; runs even when only another session's files are left unstaged |
130
130
  | `skip_release` | Skip the release step with a justification |
131
131
  | `complete_task` | Finish and reset the workflow |
132
132
  | `get_workflow_status` | Show the current phase |
133
133
  | `continue_workflow` | Get guidance on the next step |
134
134
  | `view_history` | List completed tasks |
135
- | `run_full_workflow` | Run every step in one call |
135
+ | `run_full_workflow` | Run every step in one call (requires `dodEvidence` for open items) |
136
136
  | `rerun_workflow` | Restart the current task from the beginning |
137
137
  | `drop_task` | Abandon the task without completing it |
138
138
  | `force_complete_task` | Complete despite missing steps, with a reason |
@@ -611,6 +611,66 @@ fail on husky.
611
611
  - Committing without documentation
612
612
  - Forgetting a workflow step
613
613
  - Releasing without committing and pushing
614
+ - Committing another session's uncommitted work by accident
615
+ - Blocking a release because another session's work is deliberately left alone
616
+
617
+ ---
618
+
619
+ ## Committing when someone else is editing the same repo
620
+
621
+ `commit_and_push` stages the working tree, so pre-existing changes from another
622
+ session ride along into your commit — and once committed, they cannot be
623
+ separated from yours. Observed on 31 Oct 2026: two lines of another session's
624
+ `billing.yaml` landed in an unrelated commit, and a `devices.yaml` being edited
625
+ elsewhere nearly got overwritten.
626
+
627
+ The guard has three parts:
628
+
629
+ 1. **`start_task` records a baseline** (`dirtyAtStart`): every path that was
630
+ already dirty when the task began.
631
+ 2. **`commit_and_push` refuses** when any of those paths is still dirty. Files
632
+ that appear *during* the task are never blamed — there is no way to tell a
633
+ new file of yours from a new file of theirs, and false accusations would get
634
+ the check ignored.
635
+ 3. **You choose explicitly**:
636
+
637
+ ```jsonc
638
+ // recommended: stage only what this task touched
639
+ {"commitMessage": "fix: ...", "paths": ["src/app.js", "tests/app.test.js"]}
640
+
641
+ // or, after verifying the other work belongs here
642
+ {"commitMessage": "fix: ...", "includeForeign": true}
643
+ ```
644
+
645
+ With `paths`, staging is `git add -- <paths>` instead of `git add .`, and any
646
+ file left unstaged is listed in the success message so it is not silently
647
+ forgotten.
648
+
649
+ Failure is closed, not open: the refusal happens *before* `git add`, so nothing
650
+ is staged and nothing is committed. Tasks started before this feature have no
651
+ baseline and keep the old behaviour.
652
+
653
+ Known limit: this catches work that was **already** dirty when the task started.
654
+ A file created by another session mid-task cannot be attributed, because git
655
+ records content, not authorship.
656
+
657
+ ### What this means for `perform_release`
658
+
659
+ Release used to require a completely clean working tree, which was correct while
660
+ commits swept everything. Once `commit_and_push` deliberately *leaves* another
661
+ session's work unstaged, that rule would block every release — and its advice
662
+ ("commit or stash all changes") would ask you to commit exactly what the guard
663
+ just refused.
664
+
665
+ So `perform_release` now distinguishes two kinds of dirt:
666
+
667
+ | Working tree contains | Release |
668
+ |---|---|
669
+ | only paths from `dirtyAtStart` | runs, and lists them as deliberately left alone |
670
+ | any path that is not in the baseline | refused, naming those files |
671
+
672
+ Without a baseline (tasks started before this feature) the old strict rule
673
+ applies: anything dirty blocks the release.
614
674
 
615
675
  ---
616
676
 
@@ -619,14 +679,23 @@ fail on husky.
619
679
  1. Always start with `start_task` — it sets the intent for the session
620
680
  2. Do not skip tests unless it is genuinely impossible — use `skip_tests` with a clear reason
621
681
  3. Call `get_workflow_status` whenever you lose track of the phase
622
- 4. `run_full_workflow` is a shortcut for simple tasks where all the data is already known
682
+ 4. `run_full_workflow` is a shortcut for simple tasks where all the data is already known. Pass
683
+ `dodEvidence` keyed by item id (`{ "dod-1": "npm test -> 533 pass" }`); without it the call refuses up front
684
+ instead of stopping halfway with implementation, tests and docs already recorded but nothing committed
623
685
  5. For docs-only work, use `skip_release` rather than forcing a release
624
686
 
625
687
  ---
626
688
 
627
689
  ## Troubleshooting
628
690
 
629
- **`ERR_MODULE_NOT_FOUND: Cannot find package 'dotenv'`** — dependencies are not
691
+ **`start_task` refuses because another session is working the same project.** A
692
+ second session on one working tree silently overwrites the first session's files.
693
+ Close the other OpenCode window, or give this session its own checkout with
694
+ `git worktree add <path> -b <branch>` and run it from there. The check ignores
695
+ locks with no heartbeat for 10 minutes, so a leftover MCP process does not block
696
+ you; `force: true` overrides it when you know the other session is idle.
697
+
698
+ **`ERR_MODULE_NOT_FOUND: Cannot find package 'dotenv'`** - dependencies are not
630
699
  installed. Run `npm install` in the repo.
631
700
 
632
701
  **`gyp ERR! find VS` on Windows** — the native module is compiling from source
@@ -1,6 +1,6 @@
1
1
  import { safeLog } from '../../security/sanitizer.js';
2
2
  import Database from "better-sqlite3";
3
- import { existsSync } from "fs";
3
+ import { existsSync, mkdirSync } from "fs";
4
4
  import path from "path";
5
5
  import { DbAdapter, normalizeTaskType } from "./base.js";
6
6
  import { assertSafeKnowledge, classify } from '../../security/policy.js';
@@ -43,6 +43,11 @@ export class SqliteAdapter extends DbAdapter {
43
43
  async connect() {
44
44
  if (!this.db) {
45
45
  const dbPath = this.getDbPath();
46
+ // Direktori induk belum tentu ada: pada clone bersih, `.state/`
47
+ // belum pernah dibuat. Tanpa ini SQLite melempar
48
+ // "Cannot open database because the directory does not exist" -
49
+ // hijau di mesin penulis, merah di tempat lain.
50
+ mkdirSync(path.dirname(dbPath), { recursive: true });
46
51
  this.db = new Database(dbPath);
47
52
  this.db.pragma("journal_mode = WAL");
48
53
  await this.migrate();
package/git-facade.js CHANGED
@@ -20,6 +20,9 @@ import {
20
20
  hasStagedChanges,
21
21
  hasTestChanges,
22
22
  hasWorkingChanges,
23
+ foreignDirtyEntries,
24
+ hasOnlyForeignChanges,
25
+ parseStatusEntries,
23
26
  workingTreeSummary,
24
27
  } from "./git-helpers.js";
25
28
 
@@ -37,5 +40,8 @@ export function createGitFacade() {
37
40
  getStatusOutput,
38
41
  getToplevel,
39
42
  workingTreeSummary,
43
+ parseStatusEntries,
44
+ foreignDirtyEntries,
45
+ hasOnlyForeignChanges,
40
46
  };
41
47
  }
package/git-helpers.js CHANGED
@@ -94,6 +94,93 @@ export function containsTestFilesInStatus(statusOutput) {
94
94
  .some((path) => isTestFilePath(path));
95
95
  }
96
96
 
97
+ /**
98
+ * `git status --porcelain` -> [{code, path}].
99
+ *
100
+ * Dipakai untuk dua hal: mencatat berkas yang SUDAH kotor saat `start_task`
101
+ * (baseline), dan membandingkannya lagi saat commit. Tanpa ini, `git add .`
102
+ * tidak bisa membedakan pekerjaan sesi ini dari pekerjaan sesi lain.
103
+ *
104
+ * Path TIDAK dibaca dengan `slice(3)`: format porcelain adalah `XY path`
105
+ * (X = index, Y = worktree), jadi perubahan yang belum di-stage berbentuk
106
+ * `" M path"` sementara berkas baru berbentuk `"?? path"`. Memotong kolom tetap
107
+ * memakan huruf pertama nama berkas - pernah menghasilkan `nowledge/...`.
108
+ */
109
+ export function parseStatusEntries(statusOutput) {
110
+ if (typeof statusOutput !== "string") {
111
+ return [];
112
+ }
113
+
114
+ return statusOutput
115
+ .split("\n")
116
+ .filter((line) => line && line.trim().length > 0)
117
+ .map((line) => {
118
+ const match = /^\s*([MADRCU?!]{1,2})\s+(.*)$/.exec(line);
119
+ if (!match) {
120
+ return null;
121
+ }
122
+ const rawPath = match[2].trim();
123
+ const target = rawPath.includes(" -> ")
124
+ ? rawPath.split(" -> ").pop().trim()
125
+ : rawPath;
126
+ return target ? { code: match[1], path: target } : null;
127
+ })
128
+ .filter(Boolean);
129
+ }
130
+
131
+ /**
132
+ * Berkas yang sudah kotor SEBELUM task dimulai dan masih kotor sekarang.
133
+ *
134
+ * Ini satu-satunya atribusi yang bisa dipercaya: berkas yang sudah kotor di
135
+ * baseline bukan hasil pekerjaan sesi ini. Berkas baru yang muncul selama task
136
+ * sengaja TIDAK masuk sini - tidak ada cara membedakan berkas baru milik kita
137
+ * dari berkas baru milik sesi lain, dan menebaknya akan menghasilkan tuduhan
138
+ * palsu yang membuat fitur ini diabaikan.
139
+ */
140
+ export function foreignDirtyEntries(baselineEntries, statusOutput) {
141
+ const baseline = new Set(
142
+ (Array.isArray(baselineEntries) ? baselineEntries : [])
143
+ .map((entry) => String(entry?.path || "").trim())
144
+ .filter(Boolean),
145
+ );
146
+ if (baseline.size === 0) {
147
+ return [];
148
+ }
149
+
150
+ return parseStatusEntries(statusOutput).filter((entry) => baseline.has(entry.path));
151
+ }
152
+
153
+ /**
154
+ * Apakah SELURUH working tree hanya berisi berkas yang sudah kotor sejak
155
+ * `start_task`.
156
+ *
157
+ * Dipakai `perform_release`. Sebelumnya rilis menuntut working tree benar-benar
158
+ * bersih, dan itu benar selama commit menyapu semua. Setelah `commit_and_push`
159
+ * sengaja MENINGGALKAN pekerjaan sesi lain, syarat itu berubah jadi jebakan:
160
+ * rilis menolak selamanya dan menyarankan "commit or stash", padahal
161
+ * meng-commit berkas itu justru yang kita cegah.
162
+ *
163
+ * Mengembalikan false kalau baseline kosong: tanpa baseline tidak ada dasar
164
+ * untuk menyatakan sesuatu "bukan milik kita", jadi perilaku lama dipertahankan.
165
+ */
166
+ export function hasOnlyForeignChanges(baselineEntries, statusOutput) {
167
+ const baseline = new Set(
168
+ (Array.isArray(baselineEntries) ? baselineEntries : [])
169
+ .map((entry) => String(entry?.path || "").trim())
170
+ .filter(Boolean),
171
+ );
172
+ if (baseline.size === 0) {
173
+ return false;
174
+ }
175
+
176
+ const current = parseStatusEntries(statusOutput);
177
+ if (current.length === 0) {
178
+ return false;
179
+ }
180
+
181
+ return current.every((entry) => baseline.has(entry.path));
182
+ }
183
+
97
184
  function parseNameStatusLine(line) {
98
185
  if (!line) {
99
186
  return null;
package/index.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  } from "@modelcontextprotocol/sdk/types.js";
15
15
  import { fileURLToPath } from "url";
16
16
  import path from "path";
17
- import { acquireProcessLock, releaseProcessLock, lockWarning, projectLockDir } from "./process-lock.js";
17
+ import { acquireProcessLock, releaseProcessLock, lockWarning, projectLockDir, refreshProcessLock, HEARTBEAT_MS } from "./process-lock.js";
18
18
  import { exec, setActiveProjectDir } from "./exec.js";
19
19
  import { normalizeRequestArgs, shellEscape } from "./utils.js";
20
20
  import { createGitFacade } from "./git-facade.js";
@@ -220,6 +220,16 @@ export async function main() {
220
220
  const warning = lockWarning(acquireProcessLock({ dir: lockDir }));
221
221
  if (warning) safeLog('error', warning);
222
222
  process.once('exit', () => releaseProcessLock({ dir: lockDir }));
223
+
224
+ // Detak jantung penanda proses.
225
+ //
226
+ // Tanpa ini, penanda hanya menyebut KAPAN proses start. Proses MCP yang
227
+ // tertinggal hidup setelah jendela OpenCode-nya ditutup akan terlihat sama
228
+ // hidupnya dengan sesi yang benar-benar bekerja, sehingga aturan "satu
229
+ // penulis per repo" tidak bisa ditegakkan tanpa memblokir sesi yang sah.
230
+ // Dengan detak, "hidup" berarti "hidup DAN baru saja bekerja".
231
+ const heartbeat = setInterval(() => refreshProcessLock(), HEARTBEAT_MS);
232
+ heartbeat.unref?.();
223
233
  } catch (lockError) {
224
234
  safeLog('error', 'Warning: could not record process lock:', lockError.message);
225
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@programinglive/dev-workflow-mcp-server",
3
- "version": "1.14.1",
3
+ "version": "1.15.0",
4
4
  "description": "MCP server to enforce development workflow discipline",
5
5
  "homepage": "https://devworkflow.programinglive.com/",
6
6
  "mcpName": "io.github.programinglive/dev-workflow-mcp-server",
package/process-lock.js CHANGED
@@ -80,8 +80,7 @@ export function readProcessLock({ dir }) {
80
80
  }
81
81
  }
82
82
 
83
- function defaultIsAlive(pid) {
84
- if (!Number.isFinite(pid) || pid <= 0) return false;
83
+ function defaultIsAlive(pid) { if (!Number.isFinite(pid) || pid <= 0) return false;
85
84
 
86
85
  try {
87
86
  // Sinyal 0 tidak mengirim apa pun; ia hanya memeriksa apakah proses ada.
@@ -111,12 +110,13 @@ export function acquireProcessLock({
111
110
  const existing = readProcessLock({ dir });
112
111
 
113
112
  if (existing && existing.pid !== pid && isAlive(existing.pid)) {
113
+ rememberLockDir(dir);
114
114
  return { acquired: false, holder: existing };
115
115
  }
116
116
 
117
117
  const tookOverFrom = existing && existing.pid !== pid ? existing.pid : undefined;
118
118
 
119
- const payload = { pid, startedAt: now, host: safeHostname() };
119
+ const payload = { pid, startedAt: now, heartbeatAt: now, host: safeHostname() };
120
120
 
121
121
  try {
122
122
  mkdirSync(dir, { recursive: true });
@@ -133,8 +133,7 @@ export function acquireProcessLock({
133
133
  };
134
134
  }
135
135
 
136
- return tookOverFrom ? { acquired: true, tookOverFrom } : { acquired: true };
137
- }
136
+ return tookOverFrom ? { acquired: true, tookOverFrom } : { acquired: true };}
138
137
 
139
138
  /** Melepas lock hanya bila memang milik kita. */
140
139
  export function releaseProcessLock({ dir, pid = process.pid }) {
@@ -189,3 +188,86 @@ export function lockWarning(result) {
189
188
  "memastikan hanya satu proses yang hidup."
190
189
  );
191
190
  }
191
+
192
+ /**
193
+ * Berapa lama penanda proses dianggap masih "baru saja bekerja".
194
+ *
195
+ * Dipakai untuk membedakan sesi yang benar-benar aktif dari proses MCP yang
196
+ * tertinggal hidup setelah jendela OpenCode-nya ditutup. Tanpa batas ini,
197
+ * "satu penulis per repo" hanya bisa ditegakkan dengan memblokir sesi yang sah.
198
+ */
199
+ export const DEFAULT_LOCK_STALE_MS = 10 * 60 * 1000;
200
+
201
+ /** Interval detak jantung; sepertiga dari batas basi, supaya ada toleransi. */
202
+ export const HEARTBEAT_MS = Math.floor(DEFAULT_LOCK_STALE_MS / 3);
203
+
204
+ /** Direktori lock yang terakhir dipegang proses ini (diisi acquireProcessLock). */
205
+ let rememberedLockDir = null;
206
+
207
+ function lockStamp(lock) {
208
+ const value = Number(lock?.heartbeatAt ?? lock?.startedAt);
209
+ return Number.isFinite(value) ? value : 0;
210
+ }
211
+
212
+ /**
213
+ * Menyegarkan penanda milik kita sendiri.
214
+ *
215
+ * Mengembalikan false bila penanda bukan milik kita atau tidak bisa ditulis.
216
+ * Tidak pernah melempar: gagal menyegarkan penanda tidak boleh mematikan server,
217
+ * tetapi juga tidak boleh diam-diam dianggap berhasil.
218
+ */
219
+ export function refreshProcessLock({ dir = rememberedLockDir, pid = process.pid, now = Date.now(), writeFileImpl = writeFileSync } = {}) {
220
+ if (!dir) return false;
221
+
222
+ const existing = readProcessLock({ dir });
223
+ if (!existing || existing.pid !== pid) return false;
224
+
225
+ try {
226
+ writeFileImpl(processLockPath({ dir }), JSON.stringify({ ...existing, heartbeatAt: now }), "utf8");
227
+ return true;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Pemegang lock lain yang masih hidup DAN masih bekerja, atau null.
235
+ *
236
+ * Inilah yang membuat aturan "satu penulis per repo" punya gigi: pemanggil bisa
237
+ * menolak memulai pekerjaan alih-alih menimpanya. Penanda yang basi (tidak ada
238
+ * detak dalam `staleMs`) diabaikan, karena proses MCP yang tertinggal hidup
239
+ * bukan alasan memblokir sesi baru.
240
+ */
241
+ export function activeForeignLock({
242
+ dir = rememberedLockDir,
243
+ pid = process.pid,
244
+ now = Date.now(),
245
+ staleMs = DEFAULT_LOCK_STALE_MS,
246
+ isAlive = defaultIsAlive,
247
+ } = {}) {
248
+ if (!dir) return null;
249
+
250
+ const lock = readProcessLock({ dir });
251
+ if (!lock || lock.pid === pid) return null;
252
+ if (!isAlive(lock.pid)) return null;
253
+
254
+ const age = now - lockStamp(lock);
255
+ if (age > staleMs) return null;
256
+
257
+ return { ...lock, ageMs: age };
258
+ }
259
+
260
+ /** Mengingat direktori lock supaya detak jantung tidak perlu parameter. */
261
+ export function rememberLockDir(dir) {
262
+ rememberedLockDir = dir || null;
263
+ }
264
+
265
+ /** Hanya untuk tes: mengganti pemeriksa "proses hidup". */
266
+ let isAliveOverride = null;
267
+ export function setIsAliveForTests(fn) {
268
+ isAliveOverride = typeof fn === "function" ? fn : null;
269
+ }
270
+
271
+ export function currentIsAlive() {
272
+ return isAliveOverride || defaultIsAlive;
273
+ }
@@ -301,6 +301,12 @@ Before taking any action or answering user requests regarding code changes, chec
301
301
  6. **Document**: Update documentation and call \`create_documentation\`.
302
302
  7. **Ready Check**: Call \`check_ready_to_commit\` to verify all steps.
303
303
  8. **Commit & Push**: Call \`commit_and_push\` with a conventional commit message.
304
+
305
+ If the repo also contains another session's uncommitted work, \`commit_and_push\`
306
+ refuses to stage the whole tree. Pass \`paths\` with only the files this task
307
+ touched (recommended), or \`includeForeign: true\` after verifying that work
308
+ belongs in this commit. Never resolve it by overwriting the other session's
309
+ files.
304
310
  9. **Release**: Call \`perform_release\` with the release command (or \`skip_release\` if docs-only).
305
311
  10. **Complete**: Call \`complete_task\` to finish the session and reset the workflow.
306
312
 
@@ -44,6 +44,13 @@ export function getToolList() {
44
44
  "Each item must later be proven with 'mark_dod_item' (evidence required) or waived with " +
45
45
  "'waive_dod_item' (reason required). A task cannot be committed or closed while any item is open.",
46
46
  },
47
+ force: {
48
+ type: "boolean",
49
+ description:
50
+ "Start anyway when another live MCP process is working the same project. Only for the machine owner: " +
51
+ "two sessions on one working tree overwrite each other's files silently. Prefer closing the other " +
52
+ "session or using a git worktree.",
53
+ },
47
54
  },
48
55
  required: ["description", "type", "definitionOfDone"],
49
56
  },
@@ -228,7 +235,9 @@ export function getToolList() {
228
235
  {
229
236
  name: "commit_and_push",
230
237
  description:
231
- "Automatically run git add, commit, and push once the ready check passes.",
238
+ "Automatically run git add, commit, and push once the ready check passes. " +
239
+ "Refuses to stage the whole tree when files were already dirty before the task " +
240
+ "started (another session's work): pass 'paths' to stage only your files.",
232
241
  inputSchema: {
233
242
  type: "object",
234
243
  properties: {
@@ -246,6 +255,19 @@ export function getToolList() {
246
255
  "Absolute path to the repository to commit in. Overrides the repo pinned by 'start_task' " +
247
256
  "for this call. Use it to fix a misdirected commit without restarting the task.",
248
257
  },
258
+ paths: {
259
+ type: "array",
260
+ items: { type: "string" },
261
+ description:
262
+ "Stage only these paths instead of the whole tree. Use this when the repo " +
263
+ "also contains another session's uncommitted work.",
264
+ },
265
+ includeForeign: {
266
+ type: "boolean",
267
+ description:
268
+ "Stage everything anyway, including files that were already dirty before this " +
269
+ "task started. Only after verifying that work belongs in this commit.",
270
+ },
249
271
  },
250
272
  required: [],
251
273
  },
@@ -423,6 +445,15 @@ export function getToolList() {
423
445
  type: "string",
424
446
  description: "Optional release notes to record",
425
447
  },
448
+ dodEvidence: {
449
+ type: "object",
450
+ additionalProperties: { type: "string" },
451
+ description:
452
+ "Evidence for each open definition-of-done item, keyed by item id and applied just before the ready check, " +
453
+ "for example { \"dod-1\": \"npm test -> 533 pass\" }. Required whenever an item is still open: the workflow " +
454
+ "refuses to run at all rather than stop halfway through, because it cannot invent proof. Each value must be " +
455
+ "at least 5 characters. Items that do not apply must be waived with 'waive_dod_item' first.",
456
+ },
426
457
  releaseType: {
427
458
  type: "string",
428
459
  description: "Optional release type override (major/minor/patch)",
@@ -250,8 +250,44 @@ export async function handleCommitAndPush(args, context) {
250
250
  return textResponse("⚠️ Please include test updates in your changes before running 'commit_and_push'. Ensure at least one test file is modified.");
251
251
  }
252
252
 
253
+ /*
254
+ * `git add .` men-staging SELURUH working tree, termasuk pekerjaan sesi lain
255
+ * yang kebetulan ada di repo yang sama - lalu commit ini mengakuinya sebagai
256
+ * miliknya. Itu bukan teori: pada 31 Okt 2026 dua baris `billing.yaml` milik
257
+ * sesi lain ikut masuk commit sesi ini, dan sebuah `devices.yaml` yang
258
+ * ditulis ulang di tengah task nyaris tertimpa.
259
+ *
260
+ * Baseline dicatat `start_task`. Yang bisa dipastikan bukan milik kita adalah
261
+ * berkas yang SUDAH kotor sejak baseline. Berkas baru selama task tidak
262
+ * dituduh - tidak ada cara membedakannya, dan tuduhan palsu akan membuat
263
+ * pemeriksaan ini diabaikan.
264
+ */
265
+ const foreignDirty = typeof git.foreignDirtyEntries === "function"
266
+ ? git.foreignDirtyEntries(workflowState.state.dirtyAtStart, statusOutput)
267
+ : [];
268
+ const explicitPaths = Array.isArray(args.paths)
269
+ ? args.paths.map((entry) => String(entry).trim()).filter(Boolean)
270
+ : [];
271
+
272
+ if (foreignDirty.length > 0 && explicitPaths.length === 0 && args.includeForeign !== true) {
273
+ const list = foreignDirty.map((entry) => ` ${entry.code.padEnd(3)} ${entry.path}`).join("\n");
274
+ return textResponse(
275
+ `❌ Refusing to stage the whole tree: ${foreignDirty.length} file(s) were already dirty ` +
276
+ `before this task started, so they are almost certainly another session's work.\n\n` +
277
+ `${list}\n\n` +
278
+ `Choose one:\n` +
279
+ ` • pass 'paths' with only the files this task changed (recommended),\n` +
280
+ ` • or pass 'includeForeign: true' if you have verified that work belongs in this commit.\n\n` +
281
+ `Nothing was staged and nothing was committed.`
282
+ );
283
+ }
284
+
253
285
  try {
254
- await exec("git add .");
286
+ if (explicitPaths.length > 0) {
287
+ await exec("git add", { executable: "git", arguments: ["add", "--", ...explicitPaths] });
288
+ } else {
289
+ await exec("git add .");
290
+ }
255
291
  } catch (error) {
256
292
  return textResponse(
257
293
  `❌ Failed to stage files:\n\n${error.stderr || error.stdout || error.message}`
@@ -314,8 +350,15 @@ export async function handleCommitAndPush(args, context) {
314
350
  workflowState.state.currentPhase = isReleaseSatisfied(workflowState.state) ? "ready_to_complete" : "release";
315
351
  await workflowState.save();
316
352
 
353
+ // Berkas yang ditinggalkan sengaja tetap disebut: kalau tidak, orang
354
+ // mengira seluruh pekerjaan sudah ter-commit padahal ada yang tertinggal.
355
+ const foreignNote = foreignDirty.length > 0
356
+ ? `\n\nℹ️ Left unstaged (already dirty before this task, not committed):\n` +
357
+ foreignDirty.map((entry) => ` ${entry.code.padEnd(3)} ${entry.path}`).join("\n")
358
+ : "";
359
+
317
360
  return textResponse(
318
- `✅ Commit and push completed!\nRepo: ${repoName}\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.`
361
+ `✅ Commit and push completed!\nRepo: ${repoName}\n\nCommit message: ${generatedSummary}\nPushed to: ${branchForPush} (verified at ${pushed.head.slice(0, 8)})${foreignNote}\n\nNext: Run 'perform_release' to handle the release and push tags.`
319
362
  );
320
363
  }
321
364
 
@@ -11,6 +11,7 @@ import { handleCommitAndPush, handleCompleteTask, handleForceCompleteTask, handl
11
11
  import { handleProjectSummary, handleProjectSummaryData, handleProjectSummaryDb, handleViewHistory } from "./summary-handlers.js";
12
12
  import { handleGetWorkflowStatus, handleReadyCheck, handleContinueWorkflow, handleRerunWorkflow } from "./status-handlers.js";
13
13
  import { handleSetDefinitionOfDone, handleMarkDodItem, handleWaiveDodItem } from "./dod-handlers.js";
14
+ import { openItems, MIN_EVIDENCE } from "./dod.js";
14
15
  import { handleListFactoryTasks, handleClaimFactoryTask, handleSyncTaskStatus, handleListFactoryProjects, handleCreateFactoryTask, handleUpdateFactoryTask } from "./factory-handlers.js";
15
16
 
16
17
  function summarizeResponse(label, response) {
@@ -49,7 +50,52 @@ async function handleRunFullWorkflow(args, { workflowState, exec, git, utils })
49
50
  }
50
51
  }
51
52
 
53
+ // Definition of done diperiksa di DEPAN, bukan setelah tujuh langkah.
54
+ //
55
+ // `start_task` mewajibkan DoD, jadi setiap task baru punya item terbuka. Sejak
56
+ // gerbang DoD ada (1.14.0), `run_full_workflow` tidak punya langkah untuk
57
+ // membuktikan item - jadi ia selalu berhenti di ready check, SETELAH
58
+ // mark_bug_fixed, create_tests, run_tests, dan create_documentation semuanya
59
+ // tercatat padahal commit belum pernah terjadi. Task seperti itu tertinggal
60
+ // setengah jadi dan membingungkan `get_workflow_status`.
61
+ //
62
+ // Lebih baik menolak di awal dengan daftar item yang perlu dibuktikan.
63
+ const dodEvidenceArg = args.dodEvidence;
64
+ const dodEvidence = dodEvidenceArg && typeof dodEvidenceArg === "object" && !Array.isArray(dodEvidenceArg)
65
+ ? dodEvidenceArg
66
+ : {};
67
+
68
+ const dodItemsBefore = Array.isArray(workflowState.state.definitionOfDone)
69
+ ? workflowState.state.definitionOfDone
70
+ : [];
71
+ const dodRevisionBefore = Number(workflowState.state.dodRevision) || 0;
72
+ const outstandingBefore = openItems(dodItemsBefore, dodRevisionBefore);
73
+
74
+ const missingEvidence = outstandingBefore.filter((item) => {
75
+ const value = dodEvidence[item.id];
76
+ return typeof value !== "string" || value.trim().length < MIN_EVIDENCE;
77
+ });
78
+
79
+ if (missingEvidence.length > 0) {
80
+ const list = missingEvidence.map((item) => ` ${item.id}: ${item.text}`).join("\n");
81
+ const example = missingEvidence
82
+ .map((item) => `"${item.id}": "npm test -> 533 pass"`)
83
+ .join(", ");
84
+
85
+ return textResponse(
86
+ `⚠️ Dibatalkan sebelum satu langkah pun berjalan: ${missingEvidence.length} item Definition of Done belum terbukti.\n\n` +
87
+ `${list}\n\n` +
88
+ `'run_full_workflow' tidak bisa menebak buktinya. Kirim 'dodEvidence' berisi bukti untuk tiap item, ` +
89
+ `atau buktikan dulu lewat 'mark_dod_item' lalu panggil 'run_full_workflow' lagi.\n\n` +
90
+ `Contoh:\n "dodEvidence": { ${example} }\n\n` +
91
+ `Bukti minimal ${MIN_EVIDENCE} karakter dan harus menyebut apa yang benar-benar dijalankan atau diperiksa. ` +
92
+ `Item yang memang tidak berlaku kecualikan dengan 'waive_dod_item' { id, reason }.\n\n` +
93
+ `Tidak ada langkah yang dicatat; workflow tidak berubah.`
94
+ );
95
+ }
96
+
52
97
  const steps = [];
98
+ let dodApplied = false;
53
99
  const testsPassed = typeof args.testsPassed === "boolean" ? args.testsPassed : true;
54
100
  const testDetails = typeof args.testDetails === "string" ? args.testDetails : "";
55
101
  const releaseArgs = {
@@ -121,6 +167,28 @@ async function handleRunFullWorkflow(args, { workflowState, exec, git, utils })
121
167
  }
122
168
 
123
169
  if (!state.readyCheckCompleted) {
170
+ // Buktikan item DoD dari `dodEvidence` sebelum ready check, sekali saja.
171
+ //
172
+ // Diletakkan di sini, bukan di awal, karena bukti baru benar setelah
173
+ // implementasi dan tes selesai. Kalau dicatat di awal, bukti yang dikirim
174
+ // bersama panggilan ini akan menempel pada kode yang belum ditulis.
175
+ if (!dodApplied) {
176
+ const applied = [];
177
+ for (const item of openItems(state.definitionOfDone || [], Number(state.dodRevision) || 0)) {
178
+ const evidence = dodEvidence[item.id];
179
+ if (typeof evidence !== "string" || evidence.trim() === "") continue;
180
+ const resp = await handleMarkDodItem({ id: item.id, evidence }, workflowState);
181
+ if (isErrorResponse(resp)) return resp;
182
+ applied.push(item.id);
183
+ }
184
+ dodApplied = true;
185
+ if (applied.length > 0) {
186
+ steps.push(`mark_dod_item: ${applied.join(", ")}`);
187
+ await workflowState.load();
188
+ continue;
189
+ }
190
+ }
191
+
124
192
  const resp = await executeStep("check_ready_to_commit", handleReadyCheck, [workflowState]);
125
193
  if (isErrorResponse(resp)) return resp;
126
194
  continue;
@@ -197,7 +265,7 @@ async function dispatchToolCall({
197
265
  return textResponse(JSON.stringify(report));
198
266
  }
199
267
  case "start_task":
200
- return handleStartTask(args, workflowState);
268
+ return handleStartTask(args, workflowState, git);
201
269
  case "set_definition_of_done":
202
270
  return handleSetDefinitionOfDone(args, workflowState);
203
271
  case "mark_dod_item":
@@ -80,6 +80,30 @@ export async function handleSkipRelease(args, workflowState) {
80
80
  );
81
81
  }
82
82
 
83
+ /**
84
+ * Perubahan yang BUKAN berasal dari sebelum task.
85
+ *
86
+ * Dipakai untuk menyebutkan berkas mana yang menghalangi rilis. Pesan lama
87
+ * hanya berkata "detected new changes" tanpa menyebut apa pun, sehingga orang
88
+ * harus menebak sendiri yang mana.
89
+ */
90
+ async function unexpectedChanges(git, baselineEntries) {
91
+ try {
92
+ const statusOutput = await git.getStatusOutput();
93
+ if (typeof git.parseStatusEntries !== "function") {
94
+ return [];
95
+ }
96
+ const known = new Set(
97
+ (Array.isArray(baselineEntries) ? baselineEntries : [])
98
+ .map((entry) => String(entry?.path || "").trim())
99
+ .filter(Boolean),
100
+ );
101
+ return git.parseStatusEntries(statusOutput).filter((entry) => !known.has(entry.path));
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
106
+
83
107
  export async function handlePerformRelease(args, context) {
84
108
  const { workflowState, exec, git, utils } = context;
85
109
 
@@ -111,8 +135,14 @@ export async function handlePerformRelease(args, context) {
111
135
  }
112
136
 
113
137
  if (hasNewChanges) {
138
+ const unexpected = await unexpectedChanges(git, workflowState.state.dirtyAtStart);
139
+ const details = unexpected.length > 0
140
+ ? `\n\nBerkas yang bukan bagian dari pekerjaan task ini:\n` +
141
+ unexpected.map((entry) => ` • ${entry.code.padEnd(3)} ${entry.path}`).join("\n")
142
+ : "";
114
143
  return textResponse(
115
- "⚠️ Detected new changes after the last commit. Please run 'commit_and_push' again before recording a release."
144
+ "⚠️ Detected new changes after the last commit. Please run 'commit_and_push' again before recording a release." +
145
+ details
116
146
  );
117
147
  }
118
148
 
@@ -203,7 +233,16 @@ export async function handlePerformRelease(args, context) {
203
233
  }
204
234
 
205
235
  const statusSummary = git.workingTreeSummary(statusOutput);
206
- if (statusSummary.hasChanges) {
236
+
237
+ // "Bersih" di sini berarti: tidak ada perubahan yang BELUM kita putuskan.
238
+ // Berkas yang sudah kotor sejak `start_task` adalah pekerjaan sesi lain yang
239
+ // sengaja ditinggalkan `commit_and_push` - rilis boleh lanjut, asalkan
240
+ // disebutkan supaya tidak tampak seperti repo yang bersih total.
241
+ const onlyForeign =
242
+ typeof git.hasOnlyForeignChanges === "function" &&
243
+ git.hasOnlyForeignChanges(workflowState.state.dirtyAtStart, statusOutput);
244
+
245
+ if (statusSummary.hasChanges && !onlyForeign) {
207
246
  const changeDetails =
208
247
  statusSummary.lines.length > 0
209
248
  ? statusSummary.lines.map((line) => `• ${line}`).join("\n")
@@ -214,6 +253,11 @@ export async function handlePerformRelease(args, context) {
214
253
  );
215
254
  }
216
255
 
256
+ const leftOverNote = onlyForeign && statusSummary.lines.length > 0
257
+ ? `\n\nℹ️ Dibiarkan tidak ter-commit (sudah kotor sebelum task ini, milik sesi lain):\n` +
258
+ statusSummary.lines.map((line) => ` • ${line}`).join("\n")
259
+ : "";
260
+
217
261
  const lastCommitMessage = await git.getLastCommitMessage();
218
262
  if (!releaseType) {
219
263
  releaseType = utils.determineReleaseTypeFromCommit(
@@ -313,8 +357,8 @@ export async function handlePerformRelease(args, context) {
313
357
 
314
358
  const message = summarizeHistory(workflowState.state.history, args.limit || 10);
315
359
  if (!message) {
316
- return textResponse("📜 No workflow history yet.");
360
+ return textResponse(`📜 No workflow history yet.${leftOverNote}`);
317
361
  }
318
362
 
319
- return textResponse(message);
363
+ return textResponse(`${message}${leftOverNote}`);
320
364
  }
@@ -39,6 +39,23 @@ export async function resetToCommitIfWorkingChanges(workflowState, git) {
39
39
  return false;
40
40
  }
41
41
 
42
+ // Berkas yang SUDAH kotor sejak `start_task` sengaja ditinggalkan
43
+ // `commit_and_push` (pekerjaan sesi lain). Itu bukan alasan untuk membatalkan
44
+ // rilis: kalau dihitung sebagai "perubahan baru", rilis menolak selamanya dan
45
+ // menyarankan meng-commit berkas yang justru tidak boleh di-commit.
46
+ if (typeof git.hasOnlyForeignChanges === "function" &&
47
+ typeof git.getStatusOutput === "function") {
48
+ let statusOutput = "";
49
+ try {
50
+ statusOutput = await git.getStatusOutput();
51
+ } catch {
52
+ statusOutput = "";
53
+ }
54
+ if (git.hasOnlyForeignChanges(workflowState.state.dirtyAtStart, statusOutput)) {
55
+ return false;
56
+ }
57
+ }
58
+
42
59
  workflowState.state.commitAndPushCompleted = false;
43
60
  workflowState.state.released = false;
44
61
  workflowState.state.releaseSkipped = false;
@@ -105,7 +122,18 @@ export function getContinueGuidance(status) {
105
122
  switch (status.currentPhase) {
106
123
  case "coding": {
107
124
  const task = status.taskDescription ? ` "${status.taskDescription}"` : "";
108
- return `Describe your feature flow with Mermaid using 'create_feature_flow', then finish implementing${task} and run 'mark_bug_fixed' with a brief summary of the change.`;
125
+ // Item DoD yang masih terbuka disebut di sini karena agen sering berhenti
126
+ // di "implementasi selesai" dan lupa bahwa task-nya masih terkunci.
127
+ const open = (status.definitionOfDone || []).filter(
128
+ (item) => item && item.status !== "waived" && !(item.status === "done" && item.revision === status.dodRevision),
129
+ );
130
+ const dodHint =
131
+ open.length > 0
132
+ ? ` Prove each definition-of-done item with 'mark_dod_item' { id, evidence } - still open: ${open
133
+ .map((item) => `${item.id} (${item.text})`)
134
+ .join(", ")}.`
135
+ : "";
136
+ return `Describe your feature flow with Mermaid using 'create_feature_flow', then finish implementing${task} and run 'mark_bug_fixed' with a brief summary of the change.${dodHint}`;
109
137
  }
110
138
  case "testing":
111
139
  if (!status.testsCreated) {
@@ -1,6 +1,7 @@
1
1
  import { classify } from '../../security/policy.js';
2
2
  import { textResponse, isReleaseSatisfied, isErrorResponse, ensurePrdExists } from "./shared.js";
3
3
  import { validateItemTexts } from "./dod.js";
4
+ import { activeForeignLock, projectLockDir, currentIsAlive, DEFAULT_LOCK_STALE_MS } from "../../process-lock.js";
4
5
  import { resolveProjectDir, setActiveProjectDir } from "../../exec.js";
5
6
 
6
7
  /**
@@ -68,7 +69,66 @@ function pinProjectDir(args, workflowState) {
68
69
  return { message };
69
70
  }
70
71
 
71
- export async function handleStartTask(args, workflowState) {
72
+ /**
73
+ * Daftar berkas yang sudah kotor sebelum task dimulai.
74
+ *
75
+ * Kegagalan git di sini tidak boleh menggagalkan `start_task`: baseline hanya
76
+ * dipakai untuk memperingatkan saat commit, dan task tanpa baseline tetap sah
77
+ * (perilaku lama).
78
+ */
79
+ async function captureDirtyAtStart(git) {
80
+ if (!git || typeof git.getStatusOutput !== "function" ||
81
+ typeof git.parseStatusEntries !== "function") {
82
+ return [];
83
+ }
84
+ try {
85
+ return git.parseStatusEntries(await git.getStatusOutput());
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ export async function handleStartTask(args, workflowState, git) {
92
+ // Satu penulis per repo.
93
+ //
94
+ // 31 Oktober 2026 dua sesi OpenCode menulis satu working tree yang sama. Satu
95
+ // sesi kehilangan seluruh suntingannya di `tools/handler-modules/index.js`
96
+ // tanpa pesan apa pun: berkasnya kembali ke versi HEAD di tengah pekerjaan.
97
+ // Penanda proses sudah ada sejak sebelumnya, tetapi hanya DICATAT sebagai
98
+ // peringatan, jadi tidak ada yang menghentikan tabrakan.
99
+ //
100
+ // Sekarang `start_task` menolak memulai bila ada proses MCP lain yang hidup
101
+ // DAN baru saja bekerja (ada detak dalam 10 menit) pada proyek yang sama.
102
+ // Penanda yang basi diabaikan supaya proses MCP yang tertinggal tidak
103
+ // memblokir selamanya. `force: true` tetap tersedia untuk pemilik mesin yang
104
+ // tahu apa yang ia lakukan, dan alasannya dicatat di jawaban.
105
+ const foreignLock = activeForeignLock({
106
+ dir: projectLockDir(workflowState.stateFile),
107
+ isAlive: currentIsAlive(),
108
+ });
109
+
110
+ if (foreignLock && args.force !== true) {
111
+ const minutes = Math.max(1, Math.round(foreignLock.ageMs / 60000));
112
+ const host = foreignLock.host ? ` di ${foreignLock.host}` : "";
113
+ const started = Number.isFinite(Number(foreignLock.startedAt))
114
+ ? new Date(Number(foreignLock.startedAt)).toISOString()
115
+ : "waktu tidak diketahui";
116
+
117
+ return textResponse(
118
+ `⚠️ Dibatalkan: sesi lain sedang mengerjakan proyek ini.\n\n` +
119
+ `Proses MCP PID ${foreignLock.pid}${host} berdetak ${minutes} menit lalu ` +
120
+ `(hidup sejak ${started}).\n` +
121
+ `Dua sesi pada satu working tree saling menimpa berkas: pada 31 Oktober 2026 ` +
122
+ `satu sesi kehilangan seluruh suntingannya tanpa pesan.\n\n` +
123
+ `Pilih salah satu:\n` +
124
+ ` 1. Tutup jendela OpenCode yang lain, lalu ulangi 'start_task'.\n` +
125
+ ` 2. Beri sesi ini worktree sendiri: opencode.worktree_create, lalu jalankan sesi dari sana.\n` +
126
+ ` 3. Kalau Anda yakin sesi itu sudah tidak dipakai, ulangi dengan { "force": true }.\n\n` +
127
+ `Penanda dianggap basi setelah ${Math.round(DEFAULT_LOCK_STALE_MS / 60000)} menit tanpa detak.\n` +
128
+ `Task BELUM dimulai; tidak ada state yang diubah.`
129
+ );
130
+ }
131
+
72
132
  // Validasi DoD SEBELUM menyentuh state apa pun. Task yang ditolak tidak boleh
73
133
  // mengganti task yang sedang berjalan, dan tidak boleh meninggalkan state
74
134
  // setengah jadi yang membuat `get_workflow_status` membingungkan.
@@ -111,6 +171,15 @@ export async function handleStartTask(args, workflowState) {
111
171
  workflowState.state.lastPushBranch = "";
112
172
  // Setelah reset(), karena reset mengosongkan projectDir.
113
173
  const project = pinProjectDir(args, workflowState);
174
+
175
+ // Catat berkas yang SUDAH kotor sebelum task ini dimulai.
176
+ //
177
+ // Saat commit nanti, berkas itu tidak boleh ikut ter-stage lewat `git add .`:
178
+ // itu pekerjaan sesi lain yang kebetulan berada di working tree yang sama.
179
+ // Kejadian nyata 31 Okt 2026: dua baris `billing.yaml` milik sesi lain masuk
180
+ // commit sesi ini, dan tidak ada yang bisa membedakannya dari luar.
181
+ workflowState.state.dirtyAtStart = await captureDirtyAtStart(git);
182
+
114
183
  // start_task memang bermaksud MENGGANTI task, jadi ia boleh menang atas
115
184
  // task berjalan. Task yang diganti tetap diarsipkan oleh reconcileWithDisk,
116
185
  // bukan dihapus.