@programinglive/dev-workflow-mcp-server 1.8.1 → 1.10.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/.env.example +21 -1
- package/AGENTS.md +25 -0
- package/README.md +372 -649
- package/factory-client.js +554 -0
- package/git-facade.js +35 -0
- package/index.js +9 -34
- package/package.json +21 -20
- package/scripts/bootstrap-unix.sh +55 -0
- package/scripts/bootstrap-windows.ps1 +75 -0
- package/scripts/install-husky.js +43 -0
- package/scripts/setup-stack.js +436 -0
- package/scripts/setup-test-dbs.sh +61 -0
- package/scripts/test-db-adapters.sh +71 -0
- package/scripts/verify-bundle.mjs +92 -0
- package/tools/definitions.js +143 -0
- package/tools/handler-modules/commit-handlers.js +2 -1
- package/tools/handler-modules/factory-handlers.js +276 -0
- package/tools/handler-modules/index.js +24 -7
- package/tools/handler-modules/shared.js +7 -41
- package/tools/handler-modules/status-handlers.js +25 -3
- package/tools/handler-modules/workflow-handlers.js +57 -10
- package/vite.config.js +6 -0
- package/workflow-state.js +176 -2
- package/.agent/workflows/dev-workflow-bugfix.md +0 -250
- package/.agent/workflows/dev-workflow-feature.md +0 -201
- package/.agent/workflows/dev-workflow-full-cycle.md +0 -400
- package/.agent/workflows/dev-workflow-refactor.md +0 -238
- package/.env.production +0 -16
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -34
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -25
- package/.github/pull_request_template.md +0 -22
- package/.github/workflows/github-release.yml +0 -131
- package/.husky/commit-msg +0 -3
- package/.husky/pre-commit +0 -2
- package/CHANGELOG.md +0 -1145
- package/docs/GCP_DEPLOYMENT.md +0 -298
- package/docs/NETLIFY_DEPLOYMENT.md +0 -125
- package/docs/PLESK_DEPLOYMENT.md +0 -170
- package/docs/PRD.md +0 -86
- package/docs/WORKFLOW-BEST-PRACTICES.md +0 -58
- package/docs/antigravity/EXAMPLES.md +0 -535
- package/docs/antigravity/GETTING-STARTED.md +0 -292
- package/docs/antigravity/TROUBLESHOOTING.md +0 -742
- package/docs/antigravity/WALKTHROUGH.md +0 -25
- package/docs/antigravity/WORKFLOW-TEMPLATES.md +0 -670
- package/docs/release-notes/RELEASE_NOTES.md +0 -524
- package/docs/web-dashboard.md +0 -175
- package/scripts/test-auth-flow.js +0 -75
- package/tailwind.config.js +0 -8
- package/tests/build.test.js +0 -74
- package/tests/db-adapters.test.js +0 -108
- package/tests/env-support.test.js +0 -46
- package/tests/exec.test.js +0 -35
- package/tests/handlers-commit.test.js +0 -174
- package/tests/handlers-release.test.js +0 -212
- package/tests/handlers-workflow.test.js +0 -68
- package/tests/handlers.test.js +0 -1142
- package/tests/hello-world.test.js +0 -13
- package/tests/mcpRegistry.test.js +0 -65
- package/tests/patch-release.test.js +0 -7
- package/tests/release-wrapper.test.js +0 -107
- package/tests/startup-resilience.test.js +0 -73
- package/tests/test-helpers.js +0 -50
- package/tests/workflowState.test.js +0 -520
- package/web/Dockerfile.api +0 -25
- package/web/README.md +0 -133
- package/web/__tests__/api/workflow-history.test.js +0 -63
- package/web/api-server.js +0 -211
- package/web/app/docs/page.tsx +0 -202
- package/web/app/favicon.ico +0 -0
- package/web/app/globals.css +0 -91
- package/web/app/history/page.tsx +0 -220
- package/web/app/layout.tsx +0 -39
- package/web/app/login/page.tsx +0 -142
- package/web/app/page.tsx +0 -13
- package/web/components/ApiReference.tsx +0 -20
- package/web/components/Features.tsx +0 -61
- package/web/components/Footer.tsx +0 -9
- package/web/components/Hero.tsx +0 -96
- package/web/components/History.tsx +0 -175
- package/web/components/Navbar.tsx +0 -208
- package/web/components/Terminal.tsx +0 -114
- package/web/components/ThemeProvider.tsx +0 -58
- package/web/eslint.config.mjs +0 -18
- package/web/lib/api-client.ts +0 -123
- package/web/lib/auth.js +0 -75
- package/web/lib/db.js +0 -131
- package/web/lib/docs.js +0 -73
- package/web/middleware/auth.js +0 -26
- package/web/netlify.toml +0 -8
- package/web/next.config.ts +0 -19
- package/web/package-lock.json +0 -10911
- package/web/package.json +0 -42
- package/web/postcss.config.mjs +0 -9
- package/web/public/assets/android-chrome-192x192.png +0 -0
- package/web/public/assets/android-chrome-512x512.png +0 -0
- package/web/public/assets/apple-touch-icon.png +0 -0
- package/web/public/assets/favicon-16x16.png +0 -0
- package/web/public/assets/favicon-32x32.png +0 -0
- package/web/public/assets/favicon.ico +0 -0
- package/web/public/assets/favicon.png +0 -0
- package/web/public/assets/logo.png +0 -0
- package/web/public/assets/og-preview.png +0 -0
- package/web/public/assets/site.webmanifest +0 -1
- package/web/public/file.svg +0 -1
- package/web/public/globe.svg +0 -1
- package/web/public/next.svg +0 -1
- package/web/public/vercel.svg +0 -1
- package/web/public/window.svg +0 -1
- package/web/scripts/db-query-history.js +0 -120
- package/web/scripts/smoke-test.js +0 -30
- package/web/server.js +0 -184
- package/web/tailwind.config.ts +0 -20
- package/web/tsconfig.json +0 -42
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Creates isolated test databases and users on the database host.
|
|
4
|
+
#
|
|
5
|
+
# Run this on bw-server (it uses sudo locally). It creates:
|
|
6
|
+
# - PostgreSQL database `devworkflow_test` and role `devworkflow_test`
|
|
7
|
+
# - MySQL database `devworkflow_test` and user `dwftest`
|
|
8
|
+
#
|
|
9
|
+
# It never touches an existing database, and the role is granted access only to
|
|
10
|
+
# its own database. The adapter tests INSERT and UPDATE real rows, so pointing
|
|
11
|
+
# them at a production database would corrupt real history data.
|
|
12
|
+
#
|
|
13
|
+
# Prints the two connection URLs at the end.
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
PG_DB=devworkflow_test
|
|
18
|
+
PG_USER=devworkflow_test
|
|
19
|
+
PG_PASS="$(openssl rand -hex 16)"
|
|
20
|
+
|
|
21
|
+
MY_DB=devworkflow_test
|
|
22
|
+
MY_USER=dwftest
|
|
23
|
+
MY_PASS="$(openssl rand -hex 16)"
|
|
24
|
+
|
|
25
|
+
echo "=== PostgreSQL ==="
|
|
26
|
+
sudo -u postgres psql -v ON_ERROR_STOP=1 <<SQL
|
|
27
|
+
DO \$\$
|
|
28
|
+
BEGIN
|
|
29
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='${PG_USER}') THEN
|
|
30
|
+
CREATE ROLE ${PG_USER} WITH LOGIN PASSWORD '${PG_PASS}';
|
|
31
|
+
ELSE
|
|
32
|
+
ALTER ROLE ${PG_USER} WITH LOGIN PASSWORD '${PG_PASS}';
|
|
33
|
+
END IF;
|
|
34
|
+
END
|
|
35
|
+
\$\$;
|
|
36
|
+
SQL
|
|
37
|
+
|
|
38
|
+
sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${PG_DB}'" | grep -q 1 \
|
|
39
|
+
|| sudo -u postgres createdb -O "${PG_USER}" "${PG_DB}"
|
|
40
|
+
|
|
41
|
+
sudo -u postgres psql -d "${PG_DB}" -c "GRANT ALL ON SCHEMA public TO ${PG_USER};" > /dev/null
|
|
42
|
+
sudo -u postgres psql -d "${PG_DB}" -c "ALTER SCHEMA public OWNER TO ${PG_USER};" > /dev/null
|
|
43
|
+
|
|
44
|
+
echo "=== MySQL ==="
|
|
45
|
+
sudo mysql <<SQL
|
|
46
|
+
CREATE DATABASE IF NOT EXISTS \`${MY_DB}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
47
|
+
CREATE USER IF NOT EXISTS '${MY_USER}'@'localhost' IDENTIFIED BY '${MY_PASS}';
|
|
48
|
+
ALTER USER '${MY_USER}'@'localhost' IDENTIFIED BY '${MY_PASS}';
|
|
49
|
+
GRANT ALL PRIVILEGES ON \`${MY_DB}\`.* TO '${MY_USER}'@'localhost';
|
|
50
|
+
FLUSH PRIVILEGES;
|
|
51
|
+
SQL
|
|
52
|
+
|
|
53
|
+
echo "=== verification ==="
|
|
54
|
+
mysql -h 127.0.0.1 -u "${MY_USER}" -p"${MY_PASS}" -N -B -e "SELECT 'mysql ok'" "${MY_DB}"
|
|
55
|
+
|
|
56
|
+
echo
|
|
57
|
+
echo "Add these to your shell before running scripts/test-db-adapters.sh"
|
|
58
|
+
echo "(ports are the local tunnel ports):"
|
|
59
|
+
echo
|
|
60
|
+
echo "TEST_POSTGRES_URL=postgresql://${PG_USER}:${PG_PASS}@127.0.0.1:15432/${PG_DB}"
|
|
61
|
+
echo "TEST_MYSQL_URL=mysql://${MY_USER}:${MY_PASS}@127.0.0.1:13306/${MY_DB}"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Runs the database adapter tests against real MySQL and PostgreSQL.
|
|
4
|
+
#
|
|
5
|
+
# The two adapters are skipped in the default suite because they need a live
|
|
6
|
+
# database. This script opens an SSH tunnel to bw-server (where both servers
|
|
7
|
+
# run on localhost), runs only the adapter tests, and closes the tunnel.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# ./scripts/test-db-adapters.sh
|
|
11
|
+
#
|
|
12
|
+
# Requires:
|
|
13
|
+
# - an SSH host alias `bw-tunnel` that can reach 127.0.0.1:3306 and :5432
|
|
14
|
+
# (see ~/.ssh/config; the restricted dbtunnel account is enough)
|
|
15
|
+
# - TEST_MYSQL_URL / TEST_POSTGRES_URL pointing at the forwarded ports,
|
|
16
|
+
# or the defaults below
|
|
17
|
+
#
|
|
18
|
+
# The test databases are created by scripts/setup-test-dbs.sh on the server.
|
|
19
|
+
# Tests write real rows, so never point these at a production database.
|
|
20
|
+
|
|
21
|
+
set -euo pipefail
|
|
22
|
+
|
|
23
|
+
LOCAL_PG_PORT="${LOCAL_PG_PORT:-15432}"
|
|
24
|
+
LOCAL_MY_PORT="${LOCAL_MY_PORT:-13306}"
|
|
25
|
+
SSH_HOST="${SSH_HOST:-bw-tunnel}"
|
|
26
|
+
|
|
27
|
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
28
|
+
|
|
29
|
+
if ! command -v ssh > /dev/null 2>&1; then
|
|
30
|
+
echo "ssh not found; this script needs an SSH tunnel to the database host." >&2
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
cleanup() {
|
|
35
|
+
if [ -n "${TUNNEL_PID:-}" ] && kill -0 "$TUNNEL_PID" 2> /dev/null; then
|
|
36
|
+
kill "$TUNNEL_PID" 2> /dev/null || true
|
|
37
|
+
wait "$TUNNEL_PID" 2> /dev/null || true
|
|
38
|
+
echo "tunnel closed"
|
|
39
|
+
fi
|
|
40
|
+
}
|
|
41
|
+
trap cleanup EXIT
|
|
42
|
+
|
|
43
|
+
echo "opening tunnel to $SSH_HOST ($LOCAL_PG_PORT -> 5432, $LOCAL_MY_PORT -> 3306)"
|
|
44
|
+
ssh -o BatchMode=yes -o ExitOnForwardFailure=yes -N \
|
|
45
|
+
-L "${LOCAL_PG_PORT}:127.0.0.1:5432" \
|
|
46
|
+
-L "${LOCAL_MY_PORT}:127.0.0.1:3306" \
|
|
47
|
+
"$SSH_HOST" &
|
|
48
|
+
TUNNEL_PID=$!
|
|
49
|
+
|
|
50
|
+
# Wait for the forwards to accept connections.
|
|
51
|
+
for port in "$LOCAL_PG_PORT" "$LOCAL_MY_PORT"; do
|
|
52
|
+
ready=0
|
|
53
|
+
for _ in $(seq 1 20); do
|
|
54
|
+
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2> /dev/null; then
|
|
55
|
+
exec 3>&- 3<&-
|
|
56
|
+
ready=1
|
|
57
|
+
break
|
|
58
|
+
fi
|
|
59
|
+
sleep 0.5
|
|
60
|
+
done
|
|
61
|
+
if [ "$ready" -ne 1 ]; then
|
|
62
|
+
echo "port $port did not open; is $SSH_HOST reachable?" >&2
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
done
|
|
66
|
+
|
|
67
|
+
: "${TEST_POSTGRES_URL:?set TEST_POSTGRES_URL (see scripts/setup-test-dbs.sh)}"
|
|
68
|
+
: "${TEST_MYSQL_URL:?set TEST_MYSQL_URL (see scripts/setup-test-dbs.sh)}"
|
|
69
|
+
|
|
70
|
+
cd "$REPO_ROOT"
|
|
71
|
+
node --no-deprecation --test tests/db-adapters.test.js
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verifies that the built bundle actually starts.
|
|
5
|
+
*
|
|
6
|
+
* `npm run build:server` succeeding only means the bundler emitted a file. It
|
|
7
|
+
* says nothing about whether that file runs. This bit us with vite 8: the build
|
|
8
|
+
* was green and smaller, but `node dist/index.mjs` threw
|
|
9
|
+
*
|
|
10
|
+
* Calling `require` for "fs" in an environment that doesn't expose the
|
|
11
|
+
* `require` function
|
|
12
|
+
*
|
|
13
|
+
* because rolldown's CJS shim assumed CommonJS. CI only ran the build, so a
|
|
14
|
+
* broken artefact would have shipped.
|
|
15
|
+
*
|
|
16
|
+
* The MCP server is a stdio server: it exits cleanly when stdin closes. So the
|
|
17
|
+
* pass condition is the startup banner, not the exit code - the same rule used
|
|
18
|
+
* by scripts/setup-stack.js and the release workflow.
|
|
19
|
+
*
|
|
20
|
+
* Usage: node scripts/verify-bundle.mjs [dist/index.mjs]
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
const entry = path.resolve(process.argv[2] || 'dist/index.mjs');
|
|
28
|
+
const STARTUP_PATTERN = /running on stdio|MCP Server starting/i;
|
|
29
|
+
const TIMEOUT_MS = 15000;
|
|
30
|
+
|
|
31
|
+
if (!existsSync(entry)) {
|
|
32
|
+
console.error(`✖ Bundle not found: ${entry}`);
|
|
33
|
+
console.error(' Run "npm run build:server" first.');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(`Verifying ${path.relative(process.cwd(), entry)}`);
|
|
38
|
+
|
|
39
|
+
const child = spawn(process.execPath, [entry], {
|
|
40
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
let stdout = '';
|
|
44
|
+
let stderr = '';
|
|
45
|
+
let settled = false;
|
|
46
|
+
|
|
47
|
+
child.stdout.on('data', (chunk) => {
|
|
48
|
+
stdout += chunk.toString();
|
|
49
|
+
});
|
|
50
|
+
child.stderr.on('data', (chunk) => {
|
|
51
|
+
stderr += chunk.toString();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function finish(ok, reason) {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
try {
|
|
59
|
+
child.kill();
|
|
60
|
+
} catch {
|
|
61
|
+
// already gone
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const output = `${stdout}${stderr}`;
|
|
65
|
+
if (ok) {
|
|
66
|
+
console.log(`✓ ${reason}`);
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.error(`✖ ${reason}`);
|
|
71
|
+
console.error('--- bundle output ---');
|
|
72
|
+
console.error(output.trim() || '(no output)');
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
child.on('error', (error) => finish(false, `Failed to spawn: ${error.message}`));
|
|
77
|
+
|
|
78
|
+
child.on('exit', (code) => {
|
|
79
|
+
if (STARTUP_PATTERN.test(`${stdout}${stderr}`)) {
|
|
80
|
+
finish(true, 'Bundle started and exited cleanly (stdin closed).');
|
|
81
|
+
} else {
|
|
82
|
+
finish(false, `Bundle exited with code ${code} without reporting that it started.`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
if (STARTUP_PATTERN.test(`${stdout}${stderr}`)) {
|
|
88
|
+
finish(true, 'Bundle started and stayed alive.');
|
|
89
|
+
} else {
|
|
90
|
+
finish(false, `Bundle produced no startup output within ${TIMEOUT_MS}ms.`);
|
|
91
|
+
}
|
|
92
|
+
}, TIMEOUT_MS);
|
package/tools/definitions.js
CHANGED
|
@@ -35,6 +35,25 @@ export function getToolList() {
|
|
|
35
35
|
required: ["summary"],
|
|
36
36
|
},
|
|
37
37
|
},
|
|
38
|
+
{
|
|
39
|
+
name: "create_feature_flow",
|
|
40
|
+
description:
|
|
41
|
+
"Record the feature flow using Mermaid syntax. This helps visualize the implementation plan.",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
mermaidCode: {
|
|
46
|
+
type: "string",
|
|
47
|
+
description: "The Mermaid code representing the feature flow",
|
|
48
|
+
},
|
|
49
|
+
description: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description: "A brief description of this flow",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
required: ["mermaidCode", "description"],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
38
57
|
{
|
|
39
58
|
name: "create_tests",
|
|
40
59
|
description: "Mark that tests covering the change have been created.",
|
|
@@ -343,5 +362,129 @@ export function getToolList() {
|
|
|
343
362
|
properties: {},
|
|
344
363
|
},
|
|
345
364
|
},
|
|
365
|
+
{
|
|
366
|
+
name: "list_factory_tasks",
|
|
367
|
+
description:
|
|
368
|
+
"List tasks from the Factory board (factory.programinglive.com), newest activity first. Defaults to work that still needs attention: running, queued, backlog, review and blocked. Requires FACTORY_URL plus FACTORY_TOKEN, or FACTORY_EMAIL and FACTORY_PASSWORD.",
|
|
369
|
+
inputSchema: {
|
|
370
|
+
type: "object",
|
|
371
|
+
properties: {
|
|
372
|
+
statuses: {
|
|
373
|
+
type: "array",
|
|
374
|
+
items: { type: "string", enum: ["backlog", "queued", "running", "review", "blocked", "done", "cancelled"] },
|
|
375
|
+
description: "Statuses to include. Defaults to running, queued, backlog, review and blocked.",
|
|
376
|
+
},
|
|
377
|
+
project: {
|
|
378
|
+
type: "string",
|
|
379
|
+
description: "Optional project name or id to filter by (case-insensitive substring).",
|
|
380
|
+
},
|
|
381
|
+
limit: {
|
|
382
|
+
type: "number",
|
|
383
|
+
description: "Maximum number of tasks to return (1-200, default 50).",
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: "list_factory_projects",
|
|
390
|
+
description:
|
|
391
|
+
"List projects in the Factory workspace with their UUIDs. Run this before create_factory_task, because Factory requires a real projectId and cannot accept an invented one.",
|
|
392
|
+
inputSchema: {
|
|
393
|
+
type: "object",
|
|
394
|
+
properties: {},
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
name: "create_factory_task",
|
|
399
|
+
description:
|
|
400
|
+
"Create a task on the Factory board through the official write path (POST /api/factory create_task). Defaults to status 'backlog', which records the work WITHOUT starting a worker run. Only pass status 'queued' when you really want the worker to execute it - Factory rejects a queued task (409) unless 'acceptance' contains a ```run block. Use list_factory_projects first to get a real projectId.",
|
|
401
|
+
inputSchema: {
|
|
402
|
+
type: "object",
|
|
403
|
+
properties: {
|
|
404
|
+
projectId: {
|
|
405
|
+
type: "string",
|
|
406
|
+
description: "Project UUID from list_factory_projects. Required.",
|
|
407
|
+
},
|
|
408
|
+
title: {
|
|
409
|
+
type: "string",
|
|
410
|
+
description: "Task title, 3-140 characters.",
|
|
411
|
+
},
|
|
412
|
+
description: {
|
|
413
|
+
type: "string",
|
|
414
|
+
description: "Optional context: current behaviour, what should change. Max 4000 characters.",
|
|
415
|
+
},
|
|
416
|
+
acceptance: {
|
|
417
|
+
type: "string",
|
|
418
|
+
description:
|
|
419
|
+
"What must be true for this task to be done. 3-3000 characters. Required by Factory.",
|
|
420
|
+
},
|
|
421
|
+
priority: {
|
|
422
|
+
type: "string",
|
|
423
|
+
enum: ["low", "normal", "high"],
|
|
424
|
+
description: "Defaults to normal.",
|
|
425
|
+
},
|
|
426
|
+
status: {
|
|
427
|
+
type: "string",
|
|
428
|
+
enum: ["backlog", "queued"],
|
|
429
|
+
description:
|
|
430
|
+
"Defaults to backlog. 'queued' also creates a worker run and requires a ```run block in acceptance.",
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
required: ["projectId", "title", "acceptance"],
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
name: "claim_factory_task",
|
|
438
|
+
description:
|
|
439
|
+
"Claim a task by opening a Factory work session. This is what moves the task to 'running' on the board, because Factory has no plain status edit for it. Also starts the local workflow for that task. Use the task id from list_factory_tasks. If the task is held by another device, pass 'takeoverReason' to take it over; Factory records that reason on the board and locks the previous holder out.",
|
|
440
|
+
inputSchema: {
|
|
441
|
+
type: "object",
|
|
442
|
+
properties: {
|
|
443
|
+
recordId: {
|
|
444
|
+
type: "string",
|
|
445
|
+
description: "Factory task id (UUID) to claim.",
|
|
446
|
+
},
|
|
447
|
+
step: {
|
|
448
|
+
type: "string",
|
|
449
|
+
description: "Short description of what you are about to do, shown on the device row (max 240 chars).",
|
|
450
|
+
},
|
|
451
|
+
takeoverReason: {
|
|
452
|
+
type: "string",
|
|
453
|
+
description:
|
|
454
|
+
"Alasan mengambil alih task yang sedang dipegang perangkat lain (3-500 karakter). Wajib diisi hanya bila task itu dipegang perangkat lain. Alasannya dicatat di papan.",
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
required: ["recordId"],
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
name: "sync_task_status",
|
|
462
|
+
description:
|
|
463
|
+
"Report a task status change to Factory using the endpoint that supports it: review, blocked and paused finish the work session; backlog, queued, cancelled and done move the task. 'done' is the approval step: Factory accepts it only when the task is currently in review, and it marks the work as finished. 'running' is set by claim_factory_task, not here.",
|
|
464
|
+
inputSchema: {
|
|
465
|
+
type: "object",
|
|
466
|
+
properties: {
|
|
467
|
+
recordId: {
|
|
468
|
+
type: "string",
|
|
469
|
+
description: "Factory task id (UUID).",
|
|
470
|
+
},
|
|
471
|
+
status: {
|
|
472
|
+
type: "string",
|
|
473
|
+
enum: ["backlog", "queued", "review", "blocked", "paused", "cancelled", "done"],
|
|
474
|
+
description:
|
|
475
|
+
"New status. 'running' is set by claim_factory_task, not here. 'done' is the approval step and is only valid when the task is currently in review.",
|
|
476
|
+
},
|
|
477
|
+
reason: {
|
|
478
|
+
type: "string",
|
|
479
|
+
description: "Required when status is 'blocked'; used as the session summary otherwise.",
|
|
480
|
+
},
|
|
481
|
+
nextAction: {
|
|
482
|
+
type: "string",
|
|
483
|
+
description: "What happens next. Appended to the session summary.",
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
required: ["recordId", "status"],
|
|
487
|
+
},
|
|
488
|
+
},
|
|
346
489
|
];
|
|
347
490
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { textResponse, isReleaseSatisfied, formatChecklist
|
|
1
|
+
import { textResponse, isReleaseSatisfied, formatChecklist } from "./shared.js";
|
|
2
|
+
import { handleReadyCheck } from "./status-handlers.js";
|
|
2
3
|
import { containsTestFilesInStatus } from "../../git-helpers.js";
|
|
3
4
|
|
|
4
5
|
export async function handleCommitAndPush(args, context) {
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { textResponse } from "./shared.js";
|
|
2
|
+
import {
|
|
3
|
+
readFactoryConfig,
|
|
4
|
+
listTasks,
|
|
5
|
+
findTask,
|
|
6
|
+
claimTask,
|
|
7
|
+
updateTaskStatus,
|
|
8
|
+
createTask,
|
|
9
|
+
listProjects,
|
|
10
|
+
formatTaskList,
|
|
11
|
+
formatProjectList,
|
|
12
|
+
planStatusTransition,
|
|
13
|
+
} from "../../factory-client.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Factory tools stay inert unless a Factory URL and credentials are set.
|
|
17
|
+
* That keeps the MCP server usable on machines without Factory access.
|
|
18
|
+
*/
|
|
19
|
+
function requireConfig() {
|
|
20
|
+
const config = readFactoryConfig();
|
|
21
|
+
if (!config) {
|
|
22
|
+
return {
|
|
23
|
+
config: null,
|
|
24
|
+
response: textResponse(
|
|
25
|
+
[
|
|
26
|
+
"Factory is not configured on this machine.",
|
|
27
|
+
"",
|
|
28
|
+
"Set these environment variables where the MCP server is launched:",
|
|
29
|
+
" FACTORY_URL=https://factory.programinglive.com",
|
|
30
|
+
" FACTORY_TOKEN=<PocketBase activity_users token>",
|
|
31
|
+
"",
|
|
32
|
+
"Or let the client log in for you:",
|
|
33
|
+
" FACTORY_EMAIL=<activity_users email>",
|
|
34
|
+
" FACTORY_PASSWORD=<password>",
|
|
35
|
+
"",
|
|
36
|
+
"Optional:",
|
|
37
|
+
" FACTORY_PB_URL=<PocketBase base URL, default https://pocketbase.programinglive.com>",
|
|
38
|
+
" FACTORY_MACHINE_ID=<name shown on the device row>",
|
|
39
|
+
" FACTORY_DEVICE_ID=<fixed device UUID>",
|
|
40
|
+
].join("\n"),
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { config, response: null };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function handleListFactoryTasks(args = {}) {
|
|
48
|
+
const { config, response } = requireConfig();
|
|
49
|
+
if (!config) return response;
|
|
50
|
+
|
|
51
|
+
const statuses = Array.isArray(args.statuses) && args.statuses.length ? args.statuses : undefined;
|
|
52
|
+
const project = typeof args.project === "string" && args.project.trim() ? args.project.trim() : null;
|
|
53
|
+
const limit = Number.isFinite(args.limit) ? Math.min(Math.max(args.limit, 1), 200) : 50;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const tasks = await listTasks(config, { statuses, project, limit });
|
|
57
|
+
return textResponse(formatTaskList(tasks));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return textResponse(`Could not read the Factory queue: ${error.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Formats how long ago a work session was last seen.
|
|
65
|
+
*
|
|
66
|
+
* Returns an empty string when the timestamp is missing or unparsable, so the
|
|
67
|
+
* caller can leave that part of the message out instead of printing garbage.
|
|
68
|
+
*/
|
|
69
|
+
function formatLastSeen(value) {
|
|
70
|
+
if (!value) return "";
|
|
71
|
+
const seen = Date.parse(value);
|
|
72
|
+
if (Number.isNaN(seen)) return "";
|
|
73
|
+
const minutes = Math.floor((Date.now() - seen) / 60000);
|
|
74
|
+
if (minutes < 1) return "baru saja";
|
|
75
|
+
if (minutes < 60) return `${minutes} menit lalu`;
|
|
76
|
+
const hours = Math.floor(minutes / 60);
|
|
77
|
+
if (hours < 24) return `${hours} jam lalu`;
|
|
78
|
+
return `${Math.floor(hours / 24)} hari lalu`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Builds the message shown when Factory refuses a claim because another device
|
|
83
|
+
* still holds the task.
|
|
84
|
+
*
|
|
85
|
+
* The refusal is actionable: it names the holder and shows the exact call to
|
|
86
|
+
* make, so the agent does not have to guess which parameter is missing.
|
|
87
|
+
*/
|
|
88
|
+
function takeoverRequiredMessage(title, holder = {}) {
|
|
89
|
+
const seen = formatLastSeen(holder.lastSeenAt);
|
|
90
|
+
const holderText = [holder.deviceName, holder.agent].filter(Boolean).join(" (") + (holder.agent ? ")" : "");
|
|
91
|
+
return [
|
|
92
|
+
`"${title}" sedang dikerjakan oleh perangkat lain.`,
|
|
93
|
+
"",
|
|
94
|
+
holderText ? `Pemegang sesi: ${holderText}${seen ? `, terakhir terlihat ${seen}` : ""}.` : "",
|
|
95
|
+
"",
|
|
96
|
+
"Ambil alih dengan menyertakan alasan (3-500 karakter):",
|
|
97
|
+
` claim_factory_task { "recordId": "<id>", "step": "<langkah>", "takeoverReason": "<alasanmu>" }`,
|
|
98
|
+
"",
|
|
99
|
+
"Alasannya dicatat di papan, jadi orang berikutnya tahu kenapa pekerjaan berpindah tangan.",
|
|
100
|
+
]
|
|
101
|
+
.filter((line) => line !== "")
|
|
102
|
+
.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function handleClaimFactoryTask(args = {}, { workflowState } = {}) {
|
|
106
|
+
const { config, response } = requireConfig();
|
|
107
|
+
if (!config) return response;
|
|
108
|
+
|
|
109
|
+
const recordId = typeof args.recordId === "string" ? args.recordId.trim() : "";
|
|
110
|
+
if (!recordId) {
|
|
111
|
+
return textResponse("Provide the 'recordId' of the task to claim (see list_factory_tasks).");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const takeoverReason = typeof args.takeoverReason === "string" ? args.takeoverReason.trim() : "";
|
|
115
|
+
// Judul diambil di luar try supaya pesan penolakan tetap menyebut task yang
|
|
116
|
+
// sebenarnya, bukan teks umum.
|
|
117
|
+
let taskTitle = "Task ini";
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const task = await findTask(config, recordId);
|
|
121
|
+
if (!task) {
|
|
122
|
+
return textResponse(`No task with id "${recordId}" is visible to this account.`);
|
|
123
|
+
}
|
|
124
|
+
taskTitle = task.title;
|
|
125
|
+
|
|
126
|
+
const step = typeof args.step === "string" && args.step.trim() ? args.step.trim() : "Working on the task";
|
|
127
|
+
const session = await claimTask(config, { recordId, step, takeoverReason });
|
|
128
|
+
if (!session) throw new Error("Factory accepted the claim but returned no work session.");
|
|
129
|
+
|
|
130
|
+
if (workflowState) {
|
|
131
|
+
workflowState.reset();
|
|
132
|
+
workflowState.state.currentPhase = "coding";
|
|
133
|
+
workflowState.state.taskDescription = `[${task.taskKey || task.id}] ${task.title}`;
|
|
134
|
+
workflowState.state.taskType = "other";
|
|
135
|
+
await workflowState.save();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return textResponse(
|
|
139
|
+
[
|
|
140
|
+
`Claimed [${task.taskKey || task.id}] ${task.title}`,
|
|
141
|
+
`Factory status is now running (work session ${session.id}).`,
|
|
142
|
+
task.projectName ? `Project: ${task.projectName}` : "",
|
|
143
|
+
"",
|
|
144
|
+
"Local workflow started for this task.",
|
|
145
|
+
"Use 'sync_task_status' with review, blocked or paused to close the session, or done to approve the task once it is in review.",
|
|
146
|
+
]
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join("\n"),
|
|
149
|
+
);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// Factory menolak take over tanpa alasan. Balas dengan arahan yang bisa
|
|
152
|
+
// dijalankan, bukan dengan pesan error mentah.
|
|
153
|
+
if (error?.body?.takeoverRequired) {
|
|
154
|
+
return textResponse(takeoverRequiredMessage(taskTitle, error.body.holder));
|
|
155
|
+
}
|
|
156
|
+
return textResponse(`Could not claim the task: ${error.message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function handleSyncTaskStatus(args = {}) {
|
|
161
|
+
const { config, response } = requireConfig();
|
|
162
|
+
if (!config) return response;
|
|
163
|
+
|
|
164
|
+
const recordId = typeof args.recordId === "string" ? args.recordId.trim() : "";
|
|
165
|
+
const status = typeof args.status === "string" ? args.status.trim() : "";
|
|
166
|
+
const reason = typeof args.reason === "string" ? args.reason : null;
|
|
167
|
+
const nextAction = typeof args.nextAction === "string" ? args.nextAction : null;
|
|
168
|
+
|
|
169
|
+
if (!recordId) {
|
|
170
|
+
return textResponse("Provide the 'recordId' of the task to update.");
|
|
171
|
+
}
|
|
172
|
+
if (!status) {
|
|
173
|
+
return textResponse("Provide 'status'. Factory accepts: backlog, queued, review, blocked, paused, cancelled, done.");
|
|
174
|
+
}
|
|
175
|
+
if (status === "blocked" && !reason) {
|
|
176
|
+
return textResponse("Marking a task blocked requires a 'reason' so the next person knows what is missing.");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Validate locally so the agent gets an actionable message instead of a
|
|
180
|
+
// transport error from Factory.
|
|
181
|
+
try {
|
|
182
|
+
planStatusTransition(status);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return textResponse(error.message);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const result = await updateTaskStatus(config, { recordId, status, reason, nextAction });
|
|
189
|
+
|
|
190
|
+
return textResponse(
|
|
191
|
+
[
|
|
192
|
+
`Updated ${recordId} to ${result.status}.`,
|
|
193
|
+
result.session?.summary ? `Summary: ${result.session.summary}` : "",
|
|
194
|
+
result.status === "review" ? "Waiting for human approval in Factory." : "",
|
|
195
|
+
]
|
|
196
|
+
.filter(Boolean)
|
|
197
|
+
.join("\n"),
|
|
198
|
+
);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return textResponse(`Could not update the task: ${error.message}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Lists projects, because create_task needs a real projectId (UUID) and the
|
|
206
|
+
* agent cannot invent one.
|
|
207
|
+
*/
|
|
208
|
+
export async function handleListFactoryProjects() {
|
|
209
|
+
const { config, response } = requireConfig();
|
|
210
|
+
if (!config) return response;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const projects = await listProjects(config);
|
|
214
|
+
return textResponse(formatProjectList(projects));
|
|
215
|
+
} catch (error) {
|
|
216
|
+
return textResponse(`Could not read the Factory projects: ${error.message}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Creates a task on the board through the official write path.
|
|
222
|
+
*
|
|
223
|
+
* Defaults to `backlog`. Factory starts a worker run for `queued` tasks, so
|
|
224
|
+
* recording work must not enqueue execution by accident.
|
|
225
|
+
*/
|
|
226
|
+
export async function handleCreateFactoryTask(args = {}) {
|
|
227
|
+
const { config, response } = requireConfig();
|
|
228
|
+
if (!config) return response;
|
|
229
|
+
|
|
230
|
+
const title = typeof args.title === "string" ? args.title.trim() : "";
|
|
231
|
+
const acceptance = typeof args.acceptance === "string" ? args.acceptance.trim() : "";
|
|
232
|
+
|
|
233
|
+
if (!title) {
|
|
234
|
+
return textResponse("Provide 'title' for the task (3-140 characters).");
|
|
235
|
+
}
|
|
236
|
+
if (!acceptance) {
|
|
237
|
+
return textResponse(
|
|
238
|
+
[
|
|
239
|
+
"Provide 'acceptance': what must be true for this task to count as done.",
|
|
240
|
+
"Factory requires it, and it is what the next person checks.",
|
|
241
|
+
].join("\n"),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (!args.projectId) {
|
|
245
|
+
return textResponse(
|
|
246
|
+
"Provide 'projectId'. Run 'list_factory_projects' first - Factory needs a real project UUID.",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const created = await createTask(config, {
|
|
252
|
+
projectId: args.projectId,
|
|
253
|
+
title,
|
|
254
|
+
description: args.description,
|
|
255
|
+
acceptance,
|
|
256
|
+
priority: args.priority,
|
|
257
|
+
status: args.status,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
return textResponse(
|
|
261
|
+
[
|
|
262
|
+
`Created task ${created.id ? `[${String(created.id).slice(0, 8)}] ` : ""}${title}`,
|
|
263
|
+
`Status: ${created.status}${created.status === "backlog" ? " (no worker run started)" : ""}`,
|
|
264
|
+
created.runId ? `Worker run queued: ${created.runId}` : "",
|
|
265
|
+
"",
|
|
266
|
+
created.status === "backlog"
|
|
267
|
+
? "Recorded in the backlog. Use claim_factory_task when someone starts working on it."
|
|
268
|
+
: "Move it out of the queue if execution was not intended.",
|
|
269
|
+
]
|
|
270
|
+
.filter(Boolean)
|
|
271
|
+
.join("\n"),
|
|
272
|
+
);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return textResponse(`Could not create the task: ${error.message}`);
|
|
275
|
+
}
|
|
276
|
+
}
|