@programinglive/dev-workflow-mcp-server 1.14.0 → 1.14.2

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
@@ -16,6 +16,12 @@ Before taking any action or answering user requests regarding code changes, chec
16
16
  7. **Document**: Update documentation (README, PRD, release notes) and call `create_documentation`.
17
17
  8. **Ready Check**: Call `check_ready_to_commit` to verify all steps. It fails while any definition-of-done item is still open.
18
18
  9. **Commit & Push**: Call `commit_and_push` with a conventional commit message. It refuses while any item is open.
19
+
20
+ If the repo also contains another session's uncommitted work, `commit_and_push`
21
+ refuses to stage the whole tree. Pass `paths` with only the files this task
22
+ touched (recommended), or `includeForeign: true` after verifying that work
23
+ belongs in this commit. Never resolve it by overwriting the other session's
24
+ files.
19
25
  10. **Release**: Call `perform_release` with the release command (or `skip_release` if non-Node/docs-only).
20
26
  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
27
 
@@ -32,6 +38,10 @@ Before taking any action or answering user requests regarding code changes, chec
32
38
 
33
39
  ### Quick Shortcuts
34
40
  - If you have all information ready, you can use `run_full_workflow` to execute all steps safely in sequence.
41
+ It requires `dodEvidence` for every definition-of-done item that is still open — for example
42
+ `"dodEvidence": { "dod-1": "npm test -> 533 pass" }`. Without it the call stops **before the first step** and
43
+ lists the items it needs, because it cannot invent proof. The evidence is recorded just before the ready check,
44
+ so it describes code that has already been written.
35
45
  - Use `get_workflow_status` at any time to check what step is next.
36
46
 
37
47
  **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,7 +679,9 @@ 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
  ---
@@ -647,7 +709,12 @@ Since 30 October 2026 the reliable fix is **per task**, not global. Pass
647
709
  git operation uses it:
648
710
 
649
711
  ```json
650
- { "description": "Fix login redirect", "type": "bugfix", "projectDir": "D:\\code\\clinic" }
712
+ {
713
+ "description": "Fix login redirect",
714
+ "type": "bugfix",
715
+ "projectDir": "D:\\code\\clinic",
716
+ "definitionOfDone": ["redirect kembali ke halaman asal", "build lulus"]
717
+ }
651
718
  ```
652
719
 
653
720
  `start_task` answers with `📁 Repo proyek: <path> (<source>)`, so the repo in use
@@ -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/netlify.toml CHANGED
@@ -1,7 +1,24 @@
1
+ # Deploy for devworkflow.programinglive.com
2
+ #
3
+ # The site is a Next.js **static export** (`output: "export"` in
4
+ # web/next.config.ts), so `next build` writes plain HTML/JS into `web/out` and
5
+ # there is no server runtime at all.
6
+ #
7
+ # Two things this file has to get right, because both have silently broken the
8
+ # deploy before:
9
+ #
10
+ # 1. `publish` must point at `out`, relative to `base`. With the line removed
11
+ # ("auto-detection"), Netlify could not find the export and kept serving an
12
+ # old successful deploy while every new build looked fine.
13
+ # 2. `@netlify/plugin-nextjs` must NOT be loaded. It is for server-rendered
14
+ # Next.js and expects `.next`; with a static export it only adds ways for
15
+ # the build to fail.
1
16
  [build]
2
17
  base = "web"
3
18
  command = "npm run build"
4
- # publish = ".next" (Removed to allow auto-detection)
19
+ publish = "out"
5
20
 
6
- [[plugins]]
7
- package = "@netlify/plugin-nextjs"
21
+ [build.environment]
22
+ # Next.js 16 requires Node 20.9+. Pin it so a Netlify default bump cannot
23
+ # change the build under us.
24
+ NODE_VERSION = "22"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@programinglive/dev-workflow-mcp-server",
3
- "version": "1.14.0",
3
+ "version": "1.14.2",
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",
@@ -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
 
@@ -228,7 +228,9 @@ export function getToolList() {
228
228
  {
229
229
  name: "commit_and_push",
230
230
  description:
231
- "Automatically run git add, commit, and push once the ready check passes.",
231
+ "Automatically run git add, commit, and push once the ready check passes. " +
232
+ "Refuses to stage the whole tree when files were already dirty before the task " +
233
+ "started (another session's work): pass 'paths' to stage only your files.",
232
234
  inputSchema: {
233
235
  type: "object",
234
236
  properties: {
@@ -246,6 +248,19 @@ export function getToolList() {
246
248
  "Absolute path to the repository to commit in. Overrides the repo pinned by 'start_task' " +
247
249
  "for this call. Use it to fix a misdirected commit without restarting the task.",
248
250
  },
251
+ paths: {
252
+ type: "array",
253
+ items: { type: "string" },
254
+ description:
255
+ "Stage only these paths instead of the whole tree. Use this when the repo " +
256
+ "also contains another session's uncommitted work.",
257
+ },
258
+ includeForeign: {
259
+ type: "boolean",
260
+ description:
261
+ "Stage everything anyway, including files that were already dirty before this " +
262
+ "task started. Only after verifying that work belongs in this commit.",
263
+ },
249
264
  },
250
265
  required: [],
251
266
  },
@@ -423,6 +438,15 @@ export function getToolList() {
423
438
  type: "string",
424
439
  description: "Optional release notes to record",
425
440
  },
441
+ dodEvidence: {
442
+ type: "object",
443
+ additionalProperties: { type: "string" },
444
+ description:
445
+ "Evidence for each open definition-of-done item, keyed by item id and applied just before the ready check, " +
446
+ "for example { \"dod-1\": \"npm test -> 533 pass\" }. Required whenever an item is still open: the workflow " +
447
+ "refuses to run at all rather than stop halfway through, because it cannot invent proof. Each value must be " +
448
+ "at least 5 characters. Items that do not apply must be waived with 'waive_dod_item' first.",
449
+ },
426
450
  releaseType: {
427
451
  type: "string",
428
452
  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) {
@@ -68,7 +68,26 @@ function pinProjectDir(args, workflowState) {
68
68
  return { message };
69
69
  }
70
70
 
71
- export async function handleStartTask(args, workflowState) {
71
+ /**
72
+ * Daftar berkas yang sudah kotor sebelum task dimulai.
73
+ *
74
+ * Kegagalan git di sini tidak boleh menggagalkan `start_task`: baseline hanya
75
+ * dipakai untuk memperingatkan saat commit, dan task tanpa baseline tetap sah
76
+ * (perilaku lama).
77
+ */
78
+ async function captureDirtyAtStart(git) {
79
+ if (!git || typeof git.getStatusOutput !== "function" ||
80
+ typeof git.parseStatusEntries !== "function") {
81
+ return [];
82
+ }
83
+ try {
84
+ return git.parseStatusEntries(await git.getStatusOutput());
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
89
+
90
+ export async function handleStartTask(args, workflowState, git) {
72
91
  // Validasi DoD SEBELUM menyentuh state apa pun. Task yang ditolak tidak boleh
73
92
  // mengganti task yang sedang berjalan, dan tidak boleh meninggalkan state
74
93
  // setengah jadi yang membuat `get_workflow_status` membingungkan.
@@ -111,6 +130,15 @@ export async function handleStartTask(args, workflowState) {
111
130
  workflowState.state.lastPushBranch = "";
112
131
  // Setelah reset(), karena reset mengosongkan projectDir.
113
132
  const project = pinProjectDir(args, workflowState);
133
+
134
+ // Catat berkas yang SUDAH kotor sebelum task ini dimulai.
135
+ //
136
+ // Saat commit nanti, berkas itu tidak boleh ikut ter-stage lewat `git add .`:
137
+ // itu pekerjaan sesi lain yang kebetulan berada di working tree yang sama.
138
+ // Kejadian nyata 31 Okt 2026: dua baris `billing.yaml` milik sesi lain masuk
139
+ // commit sesi ini, dan tidak ada yang bisa membedakannya dari luar.
140
+ workflowState.state.dirtyAtStart = await captureDirtyAtStart(git);
141
+
114
142
  // start_task memang bermaksud MENGGANTI task, jadi ia boleh menang atas
115
143
  // task berjalan. Task yang diganti tetap diarsipkan oleh reconcileWithDisk,
116
144
  // bukan dihapus.