@programinglive/dev-workflow-mcp-server 1.1.4 ā 1.1.6
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/CHANGELOG.md +15 -0
- package/README.md +13 -0
- package/package.json +4 -4
- package/release-wrapper.js +106 -0
- package/tests/handlers.test.js +6 -13
- package/tests/release-wrapper.test.js +91 -0
- package/tools/handlers.js +7 -19
- package/workflow-state.js +33 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [1.1.6](https://github.com/programinglive/dev-workflow-mcp-server/compare/v1.1.5...v1.1.6) (2025-10-26)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### š Bug Fixes
|
|
9
|
+
|
|
10
|
+
* avoid root workflow state path ([a9bd01b](https://github.com/programinglive/dev-workflow-mcp-server/commit/a9bd01bac6b0a17307385688a51873668498e0a0))
|
|
11
|
+
* require explicit commit before release ([5f58ead](https://github.com/programinglive/dev-workflow-mcp-server/commit/5f58ead9006595b7e57460fef585e3f9ea9de409))
|
|
12
|
+
|
|
13
|
+
### [1.1.5](https://github.com/programinglive/dev-workflow-mcp-server/compare/v1.1.4...v1.1.5) (2025-10-25)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### ⨠Features
|
|
17
|
+
|
|
18
|
+
* enforce release guard and per-project workflow state ([1c845a1](https://github.com/programinglive/dev-workflow-mcp-server/commit/1c845a1eb164863ab0269510b142e28ed1ea2136))
|
|
19
|
+
|
|
5
20
|
### [1.1.4](https://github.com/programinglive/dev-workflow-mcp-server/compare/v1.1.3...v1.1.4) (2025-10-25)
|
|
6
21
|
|
|
7
22
|
|
package/README.md
CHANGED
|
@@ -149,6 +149,19 @@ If you're using this package, add this to your project's `.gitignore`:
|
|
|
149
149
|
|
|
150
150
|
This keeps workflow state local to each developer's machine.
|
|
151
151
|
|
|
152
|
+
## š« Releasing Without the Workflow Steps
|
|
153
|
+
|
|
154
|
+
The package ships with a release guard (`release-wrapper.js`) that backs the `npm run release:*` scripts. The guard refuses to run unless:
|
|
155
|
+
|
|
156
|
+
- The current workflow phase is **release**
|
|
157
|
+
- `check_ready_to_commit` and `commit_and_push` have been completed
|
|
158
|
+
- A release has not already been recorded for the active task
|
|
159
|
+
|
|
160
|
+
If any requirements are missing, the guard exits with guidance to return to the MCP tools. This prevents accidentally bumping versions or tagging releases outside the managed workflow. To release correctly:
|
|
161
|
+
|
|
162
|
+
1. Use `perform_release {"command":"patch"}` (or `minor`/`major`) via the MCP client.
|
|
163
|
+
2. The guard runs automatically, verifies the workflow state, and records the release before letting you finish with `complete_task`.
|
|
164
|
+
|
|
152
165
|
## š ļø Available Tools
|
|
153
166
|
|
|
154
167
|
### Tool Argument Requirements
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@programinglive/dev-workflow-mcp-server",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"description": "MCP server to enforce development workflow discipline",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
"prepare": "husky",
|
|
16
16
|
"postinstall": "node install.js",
|
|
17
17
|
"release": "standard-version",
|
|
18
|
-
"release:major": "
|
|
19
|
-
"release:minor": "
|
|
20
|
-
"release:patch": "
|
|
18
|
+
"release:major": "node release-wrapper.js major",
|
|
19
|
+
"release:minor": "node release-wrapper.js minor",
|
|
20
|
+
"release:patch": "node release-wrapper.js patch"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
23
23
|
"mcp",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const RELEASE_TYPES = new Set(['major', 'minor', 'patch']);
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
const releaseTypeArg = (process.argv[2] || '').toLowerCase();
|
|
11
|
+
|
|
12
|
+
if (!RELEASE_TYPES.has(releaseTypeArg)) {
|
|
13
|
+
console.error(
|
|
14
|
+
'ā Release guard: expected release type argument (major | minor | patch). Example: npm run release:patch'
|
|
15
|
+
);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const projectRoot = process.env.INIT_CWD || process.cwd();
|
|
20
|
+
if (projectRoot.includes(`node_modules${path.sep}`)) {
|
|
21
|
+
console.error('ā Release guard: refusing to run inside node_modules.');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const statePath = path.join(projectRoot, '.workflow-state.json');
|
|
26
|
+
let state;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const raw = await fs.readFile(statePath, 'utf8');
|
|
30
|
+
state = JSON.parse(raw);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error(
|
|
33
|
+
`ā Release guard: cannot locate workflow state at ${statePath}. Make sure the dev-workflow MCP is installed and you have started a task.`
|
|
34
|
+
);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const errors = [];
|
|
39
|
+
if (state.currentPhase !== 'release') {
|
|
40
|
+
errors.push(
|
|
41
|
+
"Current workflow phase isn't 'release'. Complete documentation, commit, and push steps first."
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (!state.readyCheckCompleted) {
|
|
45
|
+
errors.push("'check_ready_to_commit' has not been completed.");
|
|
46
|
+
}
|
|
47
|
+
if (!state.commitAndPushCompleted) {
|
|
48
|
+
errors.push("'commit_and_push' has not been completed.");
|
|
49
|
+
}
|
|
50
|
+
if (state.released) {
|
|
51
|
+
errors.push('A release has already been recorded for this task.');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (errors.length) {
|
|
55
|
+
console.error('ā Release guard blocked the release:');
|
|
56
|
+
for (const message of errors) {
|
|
57
|
+
console.error(` ⢠${message}`);
|
|
58
|
+
}
|
|
59
|
+
console.error("\nUse 'perform_release' through the dev-workflow MCP after completing the missing steps.");
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const runRelease = async () => {
|
|
64
|
+
if (process.env.DEV_WORKFLOW_SKIP_RELEASE === '1') {
|
|
65
|
+
console.log('ā¹ļø DEV_WORKFLOW_SKIP_RELEASE=1 detected. Skipping actual release command.');
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return await new Promise((resolve, reject) => {
|
|
70
|
+
const child = spawn(
|
|
71
|
+
'npm',
|
|
72
|
+
['run', 'release', '--', '--release-as', releaseTypeArg],
|
|
73
|
+
{ stdio: 'inherit', shell: process.platform === 'win32', cwd: projectRoot }
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
child.on('error', (error) => reject(error));
|
|
77
|
+
child.on('close', (code) => resolve(code));
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const exitCode = await runRelease();
|
|
83
|
+
if (exitCode !== 0) {
|
|
84
|
+
console.error(`ā Release command exited with code ${exitCode}.`);
|
|
85
|
+
process.exit(exitCode);
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error(`ā Failed to run release command: ${error.message}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
state.released = true;
|
|
93
|
+
state.releaseCommand = `npm run release:${releaseTypeArg}`;
|
|
94
|
+
state.currentPhase = 'ready_to_complete';
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await fs.writeFile(statePath, JSON.stringify(state, null, 2));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error(`ā Release guard: failed to update workflow state. ${error.message}`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log('ā
Release recorded in workflow state. You can now run complete_task.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
main();
|
package/tests/handlers.test.js
CHANGED
|
@@ -437,7 +437,7 @@ test("perform_release respects explicit releaseType option", async () => {
|
|
|
437
437
|
});
|
|
438
438
|
});
|
|
439
439
|
|
|
440
|
-
test("perform_release
|
|
440
|
+
test("perform_release requires commit_and_push completion when tree is clean", async () => {
|
|
441
441
|
await withWorkflowState(async (workflowState) => {
|
|
442
442
|
workflowState.state.currentPhase = "release";
|
|
443
443
|
workflowState.state.commitAndPushCompleted = false;
|
|
@@ -449,10 +449,6 @@ test("perform_release auto-detects commit when tree is clean", async () => {
|
|
|
449
449
|
const commands = [];
|
|
450
450
|
const execStub = async (command) => {
|
|
451
451
|
commands.push(command);
|
|
452
|
-
if (command === "git status --porcelain") {
|
|
453
|
-
return { stdout: "" };
|
|
454
|
-
}
|
|
455
|
-
|
|
456
452
|
return { stdout: "" };
|
|
457
453
|
};
|
|
458
454
|
|
|
@@ -475,15 +471,12 @@ test("perform_release auto-detects commit when tree is clean", async () => {
|
|
|
475
471
|
utils,
|
|
476
472
|
});
|
|
477
473
|
|
|
478
|
-
|
|
479
|
-
assert.
|
|
480
|
-
assert.equal(workflowState.state.commitAndPushCompleted,
|
|
481
|
-
assert.equal(workflowState.state.lastCommitMessage, "chore: already pushed");
|
|
482
|
-
assert.equal(workflowState.state.lastPushBranch, "main");
|
|
483
|
-
assert.equal(workflowState.state.currentPhase, "ready_to_complete");
|
|
474
|
+
assert.equal(commands.length, 0, "no release commands should run when commit_and_push not completed");
|
|
475
|
+
assert.equal(workflowState.state.currentPhase, "commit");
|
|
476
|
+
assert.equal(workflowState.state.commitAndPushCompleted, false);
|
|
484
477
|
assert.ok(
|
|
485
|
-
response.content[0].text.includes("
|
|
486
|
-
"response should
|
|
478
|
+
response.content[0].text.includes("Please run 'commit_and_push' after the ready check"),
|
|
479
|
+
"response should instruct user to run commit_and_push"
|
|
487
480
|
);
|
|
488
481
|
});
|
|
489
482
|
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const PROJECT_ROOT = path.resolve(new URL("..", import.meta.url).pathname);
|
|
9
|
+
const RELEASE_WRAPPER = path.join(PROJECT_ROOT, "release-wrapper.js");
|
|
10
|
+
|
|
11
|
+
async function runWrapper({ state, args = ["patch"], env = {} }) {
|
|
12
|
+
const dir = await mkdtemp(path.join(tmpdir(), "release-wrapper-test-"));
|
|
13
|
+
const statePath = path.join(dir, ".workflow-state.json");
|
|
14
|
+
await writeFile(statePath, JSON.stringify(state, null, 2));
|
|
15
|
+
|
|
16
|
+
const childEnv = {
|
|
17
|
+
...process.env,
|
|
18
|
+
INIT_CWD: dir,
|
|
19
|
+
DEV_WORKFLOW_SKIP_RELEASE: "1",
|
|
20
|
+
...env,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return await new Promise((resolve) => {
|
|
24
|
+
const child = spawn(
|
|
25
|
+
process.execPath,
|
|
26
|
+
[RELEASE_WRAPPER, ...args],
|
|
27
|
+
{
|
|
28
|
+
cwd: PROJECT_ROOT,
|
|
29
|
+
env: childEnv,
|
|
30
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
31
|
+
}
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
let stdout = "";
|
|
35
|
+
let stderr = "";
|
|
36
|
+
|
|
37
|
+
child.stdout.on("data", (chunk) => {
|
|
38
|
+
stdout += chunk;
|
|
39
|
+
});
|
|
40
|
+
child.stderr.on("data", (chunk) => {
|
|
41
|
+
stderr += chunk;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
child.on("close", async (code) => {
|
|
45
|
+
let updatedState = null;
|
|
46
|
+
try {
|
|
47
|
+
const rawState = await readFile(statePath, "utf8");
|
|
48
|
+
updatedState = JSON.parse(rawState);
|
|
49
|
+
} catch {
|
|
50
|
+
// ignore
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await rm(dir, { recursive: true, force: true });
|
|
54
|
+
resolve({ code, stdout, stderr, updatedState });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("release-wrapper blocks when workflow phase is not release", async () => {
|
|
60
|
+
const result = await runWrapper({
|
|
61
|
+
state: {
|
|
62
|
+
currentPhase: "commit",
|
|
63
|
+
readyCheckCompleted: true,
|
|
64
|
+
commitAndPushCompleted: true,
|
|
65
|
+
released: false,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
assert.equal(result.code, 1);
|
|
70
|
+
assert.ok(result.stderr.includes("Release guard blocked the release"));
|
|
71
|
+
assert.ok(result.stderr.includes("Current workflow phase"));
|
|
72
|
+
assert.equal(result.updatedState.released, false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("release-wrapper marks release after validation passes", async () => {
|
|
76
|
+
const result = await runWrapper({
|
|
77
|
+
state: {
|
|
78
|
+
currentPhase: "release",
|
|
79
|
+
readyCheckCompleted: true,
|
|
80
|
+
commitAndPushCompleted: true,
|
|
81
|
+
released: false,
|
|
82
|
+
releaseCommand: "",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
assert.equal(result.code, 0);
|
|
87
|
+
assert.ok(result.stdout.includes("Release recorded"));
|
|
88
|
+
assert.equal(result.updatedState.released, true);
|
|
89
|
+
assert.equal(result.updatedState.currentPhase, "ready_to_complete");
|
|
90
|
+
assert.equal(result.updatedState.releaseCommand, "npm run release:patch");
|
|
91
|
+
});
|
package/tools/handlers.js
CHANGED
|
@@ -480,8 +480,6 @@ async function handlePerformRelease(args, context) {
|
|
|
480
480
|
);
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
-
let autoDetectedCommit = false;
|
|
484
|
-
|
|
485
483
|
if (!workflowState.state.commitAndPushCompleted) {
|
|
486
484
|
const hasWorkingChanges = await git.hasWorkingChanges();
|
|
487
485
|
|
|
@@ -491,16 +489,12 @@ async function handlePerformRelease(args, context) {
|
|
|
491
489
|
);
|
|
492
490
|
}
|
|
493
491
|
|
|
494
|
-
|
|
495
|
-
const currentBranch = await git.getCurrentBranch();
|
|
496
|
-
|
|
497
|
-
workflowState.state.commitAndPushCompleted = true;
|
|
498
|
-
workflowState.state.lastCommitMessage =
|
|
499
|
-
lastCommitMessage || workflowState.state.lastCommitMessage || "";
|
|
500
|
-
workflowState.state.lastPushBranch = currentBranch || workflowState.state.lastPushBranch || "";
|
|
501
|
-
workflowState.state.currentPhase = "release";
|
|
502
|
-
autoDetectedCommit = true;
|
|
492
|
+
workflowState.state.currentPhase = "commit";
|
|
503
493
|
await workflowState.save();
|
|
494
|
+
|
|
495
|
+
return textResponse(
|
|
496
|
+
"ā ļø Please run 'commit_and_push' after the ready check before recording a release!"
|
|
497
|
+
);
|
|
504
498
|
}
|
|
505
499
|
|
|
506
500
|
const rawReleaseCommand = typeof args.command === "string" ? args.command.trim() : "";
|
|
@@ -610,16 +604,10 @@ async function handlePerformRelease(args, context) {
|
|
|
610
604
|
|
|
611
605
|
const message = summarizeHistory(workflowState.state.history, args.limit || 10);
|
|
612
606
|
if (!message) {
|
|
613
|
-
|
|
614
|
-
? "\n\nā¹ļø Detected clean working tree and treated commit/push as already completed."
|
|
615
|
-
: "";
|
|
616
|
-
return textResponse(`š No workflow history yet.${autoNote}`);
|
|
607
|
+
return textResponse("š No workflow history yet.");
|
|
617
608
|
}
|
|
618
609
|
|
|
619
|
-
|
|
620
|
-
? "\n\nā¹ļø Detected clean working tree and treated commit/push as already completed."
|
|
621
|
-
: "";
|
|
622
|
-
return textResponse(`${message}${autoNote}`);
|
|
610
|
+
return textResponse(message);
|
|
623
611
|
}
|
|
624
612
|
|
|
625
613
|
async function handleCompleteTask(args, workflowState) {
|
package/workflow-state.js
CHANGED
|
@@ -4,9 +4,41 @@ import { fileURLToPath } from "url";
|
|
|
4
4
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
|
+
const STATE_FILENAME = ".workflow-state.json";
|
|
8
|
+
|
|
9
|
+
function isRootPath(candidate) {
|
|
10
|
+
if (!candidate) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const resolved = path.resolve(candidate);
|
|
15
|
+
return path.parse(resolved).root === resolved;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveDefaultStateFile() {
|
|
19
|
+
if (process.env.DEV_WORKFLOW_STATE_FILE) {
|
|
20
|
+
return path.resolve(process.env.DEV_WORKFLOW_STATE_FILE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const cwd = process.cwd();
|
|
24
|
+
if (!cwd.includes(`node_modules${path.sep}`) && !isRootPath(cwd)) {
|
|
25
|
+
return path.join(cwd, STATE_FILENAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (process.env.INIT_CWD && !isRootPath(process.env.INIT_CWD)) {
|
|
29
|
+
return path.join(process.env.INIT_CWD, STATE_FILENAME);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const [beforeNodeModules] = cwd.split(`node_modules${path.sep}`);
|
|
33
|
+
if (beforeNodeModules && !isRootPath(beforeNodeModules)) {
|
|
34
|
+
return path.join(beforeNodeModules, STATE_FILENAME);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return path.join(__dirname, STATE_FILENAME);
|
|
38
|
+
}
|
|
7
39
|
|
|
8
40
|
export class WorkflowState {
|
|
9
|
-
constructor(stateFilePath =
|
|
41
|
+
constructor(stateFilePath = resolveDefaultStateFile()) {
|
|
10
42
|
this.stateFile = stateFilePath;
|
|
11
43
|
this.state = {
|
|
12
44
|
currentPhase: "idle",
|