@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 +10 -0
- package/README.md +72 -5
- package/db/adapters/sqlite.js +6 -1
- package/git-facade.js +6 -0
- package/git-helpers.js +87 -0
- package/netlify.toml +20 -3
- package/package.json +1 -1
- package/scripts/setup-stack.js +6 -0
- package/tools/definitions.js +25 -1
- package/tools/handler-modules/commit-handlers.js +45 -2
- package/tools/handler-modules/index.js +69 -1
- package/tools/handler-modules/release-handlers.js +48 -4
- package/tools/handler-modules/shared.js +29 -1
- package/tools/handler-modules/workflow-handlers.js +29 -1
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
|
-
{
|
|
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
|
package/db/adapters/sqlite.js
CHANGED
|
@@ -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
|
-
|
|
19
|
+
publish = "out"
|
|
5
20
|
|
|
6
|
-
[
|
|
7
|
-
|
|
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.
|
|
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",
|
package/scripts/setup-stack.js
CHANGED
|
@@ -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
|
|
package/tools/definitions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|