@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.
Files changed (114) hide show
  1. package/.env.example +21 -1
  2. package/AGENTS.md +25 -0
  3. package/README.md +372 -649
  4. package/factory-client.js +554 -0
  5. package/git-facade.js +35 -0
  6. package/index.js +9 -34
  7. package/package.json +21 -20
  8. package/scripts/bootstrap-unix.sh +55 -0
  9. package/scripts/bootstrap-windows.ps1 +75 -0
  10. package/scripts/install-husky.js +43 -0
  11. package/scripts/setup-stack.js +436 -0
  12. package/scripts/setup-test-dbs.sh +61 -0
  13. package/scripts/test-db-adapters.sh +71 -0
  14. package/scripts/verify-bundle.mjs +92 -0
  15. package/tools/definitions.js +143 -0
  16. package/tools/handler-modules/commit-handlers.js +2 -1
  17. package/tools/handler-modules/factory-handlers.js +276 -0
  18. package/tools/handler-modules/index.js +24 -7
  19. package/tools/handler-modules/shared.js +7 -41
  20. package/tools/handler-modules/status-handlers.js +25 -3
  21. package/tools/handler-modules/workflow-handlers.js +57 -10
  22. package/vite.config.js +6 -0
  23. package/workflow-state.js +176 -2
  24. package/.agent/workflows/dev-workflow-bugfix.md +0 -250
  25. package/.agent/workflows/dev-workflow-feature.md +0 -201
  26. package/.agent/workflows/dev-workflow-full-cycle.md +0 -400
  27. package/.agent/workflows/dev-workflow-refactor.md +0 -238
  28. package/.env.production +0 -16
  29. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -34
  30. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -25
  31. package/.github/pull_request_template.md +0 -22
  32. package/.github/workflows/github-release.yml +0 -131
  33. package/.husky/commit-msg +0 -3
  34. package/.husky/pre-commit +0 -2
  35. package/CHANGELOG.md +0 -1145
  36. package/docs/GCP_DEPLOYMENT.md +0 -298
  37. package/docs/NETLIFY_DEPLOYMENT.md +0 -125
  38. package/docs/PLESK_DEPLOYMENT.md +0 -170
  39. package/docs/PRD.md +0 -86
  40. package/docs/WORKFLOW-BEST-PRACTICES.md +0 -58
  41. package/docs/antigravity/EXAMPLES.md +0 -535
  42. package/docs/antigravity/GETTING-STARTED.md +0 -292
  43. package/docs/antigravity/TROUBLESHOOTING.md +0 -742
  44. package/docs/antigravity/WALKTHROUGH.md +0 -25
  45. package/docs/antigravity/WORKFLOW-TEMPLATES.md +0 -670
  46. package/docs/release-notes/RELEASE_NOTES.md +0 -524
  47. package/docs/web-dashboard.md +0 -175
  48. package/scripts/test-auth-flow.js +0 -75
  49. package/tailwind.config.js +0 -8
  50. package/tests/build.test.js +0 -74
  51. package/tests/db-adapters.test.js +0 -108
  52. package/tests/env-support.test.js +0 -46
  53. package/tests/exec.test.js +0 -35
  54. package/tests/handlers-commit.test.js +0 -174
  55. package/tests/handlers-release.test.js +0 -212
  56. package/tests/handlers-workflow.test.js +0 -68
  57. package/tests/handlers.test.js +0 -1142
  58. package/tests/hello-world.test.js +0 -13
  59. package/tests/mcpRegistry.test.js +0 -65
  60. package/tests/patch-release.test.js +0 -7
  61. package/tests/release-wrapper.test.js +0 -107
  62. package/tests/startup-resilience.test.js +0 -73
  63. package/tests/test-helpers.js +0 -50
  64. package/tests/workflowState.test.js +0 -520
  65. package/web/Dockerfile.api +0 -25
  66. package/web/README.md +0 -133
  67. package/web/__tests__/api/workflow-history.test.js +0 -63
  68. package/web/api-server.js +0 -211
  69. package/web/app/docs/page.tsx +0 -202
  70. package/web/app/favicon.ico +0 -0
  71. package/web/app/globals.css +0 -91
  72. package/web/app/history/page.tsx +0 -220
  73. package/web/app/layout.tsx +0 -39
  74. package/web/app/login/page.tsx +0 -142
  75. package/web/app/page.tsx +0 -13
  76. package/web/components/ApiReference.tsx +0 -20
  77. package/web/components/Features.tsx +0 -61
  78. package/web/components/Footer.tsx +0 -9
  79. package/web/components/Hero.tsx +0 -96
  80. package/web/components/History.tsx +0 -175
  81. package/web/components/Navbar.tsx +0 -208
  82. package/web/components/Terminal.tsx +0 -114
  83. package/web/components/ThemeProvider.tsx +0 -58
  84. package/web/eslint.config.mjs +0 -18
  85. package/web/lib/api-client.ts +0 -123
  86. package/web/lib/auth.js +0 -75
  87. package/web/lib/db.js +0 -131
  88. package/web/lib/docs.js +0 -73
  89. package/web/middleware/auth.js +0 -26
  90. package/web/netlify.toml +0 -8
  91. package/web/next.config.ts +0 -19
  92. package/web/package-lock.json +0 -10911
  93. package/web/package.json +0 -42
  94. package/web/postcss.config.mjs +0 -9
  95. package/web/public/assets/android-chrome-192x192.png +0 -0
  96. package/web/public/assets/android-chrome-512x512.png +0 -0
  97. package/web/public/assets/apple-touch-icon.png +0 -0
  98. package/web/public/assets/favicon-16x16.png +0 -0
  99. package/web/public/assets/favicon-32x32.png +0 -0
  100. package/web/public/assets/favicon.ico +0 -0
  101. package/web/public/assets/favicon.png +0 -0
  102. package/web/public/assets/logo.png +0 -0
  103. package/web/public/assets/og-preview.png +0 -0
  104. package/web/public/assets/site.webmanifest +0 -1
  105. package/web/public/file.svg +0 -1
  106. package/web/public/globe.svg +0 -1
  107. package/web/public/next.svg +0 -1
  108. package/web/public/vercel.svg +0 -1
  109. package/web/public/window.svg +0 -1
  110. package/web/scripts/db-query-history.js +0 -120
  111. package/web/scripts/smoke-test.js +0 -30
  112. package/web/server.js +0 -184
  113. package/web/tailwind.config.ts +0 -20
  114. 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);
@@ -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, handleReadyCheck } from "./shared.js";
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
+ }