@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,554 @@
1
+ /**
2
+ * Factory task integration for the real Factory API.
3
+ *
4
+ * Factory keeps tasks in SQLite table `factory_tasks` and only exposes three
5
+ * write paths:
6
+ * - POST /api/factory { action: create_task | move_task | update_task }
7
+ * - POST /api/work { action: start | heartbeat | finish }
8
+ *
9
+ * There is no "set any status" endpoint. `running` is a work session, and
10
+ * `review` / `blocked` / `paused` are outcomes of finishing that session.
11
+ * `done` is an approval that the owner may record, but Factory accepts it only
12
+ * for a task that is already in `review`. This module encodes those rules
13
+ * instead of pretending the API is a generic CRUD store.
14
+ *
15
+ * Authentication uses a PocketBase `activity_users` token sent as a Bearer
16
+ * header, which Factory accepts for non-browser clients. The token can be
17
+ * supplied directly (FACTORY_TOKEN) or obtained from an email/password pair.
18
+ *
19
+ * Every network call accepts an optional fetch implementation so the rules
20
+ * above can be tested without a live Factory.
21
+ */
22
+
23
+ import { createHash } from "crypto";
24
+ import os from "os";
25
+
26
+ export const DEFAULT_POCKETBASE_URL = "https://pocketbase.programinglive.com";
27
+
28
+ /** Statuses that still need attention. `done` and `cancelled` are terminal. */
29
+ export const ACTIVE_STATUSES = ["running", "queued", "backlog", "review", "blocked"];
30
+
31
+ /** Every status Factory can actually hold. */
32
+ export const VALID_STATUSES = ["backlog", "queued", "running", "review", "blocked", "done", "cancelled"];
33
+
34
+ /** Statuses reachable by finishing a work session, and the matching outcome. */
35
+ const WORK_OUTCOMES = { review: "review", blocked: "blocked", paused: "paused" };
36
+
37
+ /** Statuses reachable through POST /api/factory { action: move_task }. */
38
+ const MOVABLE_STATUSES = ["backlog", "queued", "cancelled", "done"];
39
+
40
+ /**
41
+ * Values accepted by POST /api/factory { action: create_task }.
42
+ *
43
+ * `queued` is deliberately not the default: Factory enqueues a worker run for
44
+ * it and rejects the task (409) unless `acceptance` contains a ```run block.
45
+ */
46
+ const CREATABLE_STATUSES = ["backlog", "queued"];
47
+ const CREATABLE_PRIORITIES = ["low", "normal", "high"];
48
+
49
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
50
+
51
+ const STATUS_ORDER = { running: 0, queued: 1, backlog: 2, review: 3, blocked: 4, done: 5, cancelled: 6 };
52
+ const PRIORITY_ORDER = { high: 0, normal: 1, low: 2 };
53
+
54
+ const TOKEN_TTL_MS = 10 * 60 * 1000;
55
+ const tokenCache = new Map();
56
+
57
+ function clean(value) {
58
+ if (typeof value !== "string") return null;
59
+ const trimmed = value.trim();
60
+ return trimmed === "" ? null : trimmed;
61
+ }
62
+
63
+ function trimSlash(value) {
64
+ return value.replace(/\/+$/, "");
65
+ }
66
+
67
+ /**
68
+ * Reads Factory configuration from an environment object.
69
+ *
70
+ * Returns null unless a URL and one usable credential set are present. Two
71
+ * credential shapes are supported:
72
+ * - FACTORY_TOKEN (a PocketBase activity_users token)
73
+ * - FACTORY_EMAIL + FACTORY_PASSWORD (exchanged for a token on demand)
74
+ */
75
+ export function readFactoryConfig(env = process.env) {
76
+ const url = clean(env.FACTORY_URL) || clean(env.FACTORY_API_URL);
77
+ if (!url) return null;
78
+
79
+ const token = clean(env.FACTORY_TOKEN);
80
+ const email = clean(env.FACTORY_EMAIL);
81
+ const password = clean(env.FACTORY_PASSWORD);
82
+ if (!token && !(email && password)) return null;
83
+
84
+ return {
85
+ url: trimSlash(url),
86
+ token,
87
+ email,
88
+ password,
89
+ pocketBaseUrl: trimSlash(clean(env.FACTORY_PB_URL) || DEFAULT_POCKETBASE_URL),
90
+ machineId: clean(env.FACTORY_MACHINE_ID),
91
+ deviceId: clean(env.FACTORY_DEVICE_ID),
92
+ agent: clean(env.FACTORY_AGENT) || "dev-workflow-mcp",
93
+ // Sesi editor yang sedang berjalan. Papan Factory memakainya untuk
94
+ // menunjukkan sesi mana yang mengerjakan, bukan hanya perangkatnya.
95
+ clientSessionId: clean(env.OPENCODE_SESSION_ID) || clean(env.CLIENT_SESSION_ID),
96
+ clientSessionTitle: clean(env.OPENCODE_SESSION_TITLE) || clean(env.CLIENT_SESSION_TITLE),
97
+ };
98
+ }
99
+
100
+ export function isConfigured(env = process.env) {
101
+ return readFactoryConfig(env) !== null;
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Device identity
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * Derives a stable UUID from a name (same input, same id).
110
+ *
111
+ * Factory keys devices by UUID, so a random id per process would create a new
112
+ * device row on every run. Deriving it from the machine name keeps one row per
113
+ * machine without needing a state file.
114
+ */
115
+ export function deviceIdFor(seed) {
116
+ const hash = createHash("sha1").update(String(seed || "dev-workflow-mcp")).digest("hex").split("");
117
+ hash[12] = "5";
118
+ hash[16] = ((parseInt(hash[16], 16) & 0x3) | 0x8).toString(16);
119
+ const value = hash.join("");
120
+ return [
121
+ value.slice(0, 8),
122
+ value.slice(8, 12),
123
+ value.slice(12, 16),
124
+ value.slice(16, 20),
125
+ value.slice(20, 32),
126
+ ].join("-");
127
+ }
128
+
129
+ /** Builds the `device` object Factory expects on every work call. */
130
+ export function deviceFromConfig(config) {
131
+ const name = (config.machineId || os.hostname() || "unknown").slice(0, 100);
132
+ return {
133
+ id: config.deviceId || deviceIdFor(name),
134
+ name,
135
+ platform: String(process.platform).slice(0, 60),
136
+ agent: String(config.agent || "dev-workflow-mcp").slice(0, 40),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Sesi editor yang sedang berjalan, kalau diketahui.
142
+ *
143
+ * Papan Factory memakai ini untuk menjawab "sesi mana yang mengerjakan task
144
+ * ini", bukan sekadar "perangkat mana". Tanpa nilai, sesi kerja tetap dibuat -
145
+ * perangkat lama tidak boleh gagal hanya karena variabel ini belum diset.
146
+ */
147
+ export function clientSessionFromConfig(config) {
148
+ const id = String(config?.clientSessionId || "").trim();
149
+ if (!id) return null;
150
+ return { id: id.slice(0, 120), title: String(config?.clientSessionTitle || "").trim().slice(0, 200) };
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Record mapping and ordering
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Maps a Factory task row to the shape the agent consumes.
159
+ *
160
+ * `projects` is the project list returned by the same API call, used to turn
161
+ * the opaque projectId into a readable name.
162
+ */
163
+ export function mapTaskRecord(record = {}, projects = []) {
164
+ const byId = new Map((projects || []).map((project) => [project.id, project.name]));
165
+ const work = record.work || null;
166
+
167
+ return {
168
+ id: record.id ?? null,
169
+ taskKey: record.id ? String(record.id).slice(0, 8) : null,
170
+ title: record.title ?? null,
171
+ status: record.status ?? null,
172
+ priority: record.priority || "normal",
173
+ projectId: record.projectId ?? null,
174
+ projectName: byId.get(record.projectId) ?? null,
175
+ description: record.description || null,
176
+ acceptance: record.acceptance || null,
177
+ createdAt: record.createdAt ?? null,
178
+ updatedAt: record.updatedAt ?? null,
179
+ work: work
180
+ ? {
181
+ id: work.id ?? null,
182
+ state: work.state ?? null,
183
+ activityStatus: work.activityStatus ?? null,
184
+ step: work.step ?? null,
185
+ summary: work.summary ?? null,
186
+ deviceName: work.device?.name ?? null,
187
+ startedAt: work.startedAt ?? null,
188
+ }
189
+ : null,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Orders tasks so the most actionable one comes first: status, then priority.
195
+ * Returns a new array; the input is not mutated.
196
+ */
197
+ export function sortTasks(tasks = []) {
198
+ return [...tasks].sort((a, b) => {
199
+ const byStatus = (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9);
200
+ if (byStatus !== 0) return byStatus;
201
+ return (PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9);
202
+ });
203
+ }
204
+
205
+ /** Human-readable rendering of a task queue. */
206
+ export function formatTaskList(tasks = []) {
207
+ if (!Array.isArray(tasks) || tasks.length === 0) {
208
+ return "No tasks in the Factory queue match this filter.";
209
+ }
210
+
211
+ const lines = [`${tasks.length} task(s) in Factory:`, ""];
212
+
213
+ tasks.forEach((task, index) => {
214
+ const meta = [task.status, task.priority, task.projectName].filter(Boolean).join(" · ");
215
+ lines.push(`${index + 1}. [${task.taskKey || task.id}] ${task.title}`);
216
+ if (meta) lines.push(` ${meta}`);
217
+ if (task.work?.state === "active") {
218
+ const who = task.work.deviceName ? ` on ${task.work.deviceName}` : "";
219
+ lines.push(` active session${who}${task.work.step ? `: ${task.work.step}` : ""}`);
220
+ }
221
+ if (task.acceptance) lines.push(` acceptance: ${firstLine(task.acceptance)}`);
222
+ });
223
+
224
+ return lines.join("\n");
225
+ }
226
+
227
+ function firstLine(value) {
228
+ const line = String(value).split(/\r?\n/).find((part) => part.trim() !== "") || "";
229
+ return line.length > 120 ? `${line.slice(0, 117)}...` : line;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Status transition rules
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /**
237
+ * Decides which Factory endpoint can produce a status, before any network call.
238
+ *
239
+ * Throws for statuses the agent must not set, so the caller gets an actionable
240
+ * message instead of a silent no-op.
241
+ */
242
+ export function planStatusTransition(status) {
243
+ const value = typeof status === "string" ? status.trim() : "";
244
+
245
+ // `paused` is a work outcome, not a task status: Factory stores it as
246
+ // backlog after the session closes. Handle outcomes before the status check.
247
+ if (WORK_OUTCOMES[value]) {
248
+ return { kind: "work-finish", outcome: WORK_OUTCOMES[value] };
249
+ }
250
+
251
+ if (!VALID_STATUSES.includes(value)) {
252
+ throw new Error(`Unknown status "${value}". Factory accepts: ${VALID_STATUSES.join(", ")}.`);
253
+ }
254
+
255
+ if (value === "running") {
256
+ throw new Error(
257
+ "Use claim_factory_task to move a task to running: Factory records that as a work session, not a status edit.",
258
+ );
259
+ }
260
+
261
+ // MOVABLE_STATUSES: backlog, queued, cancelled, done
262
+ return { kind: "move", status: value };
263
+ }
264
+
265
+ /** Payload for POST /api/work { action: "start" }. */
266
+ export function buildWorkStartPayload({ taskId, device, step, takeoverReason, clientSession, now = null } = {}) {
267
+ if (!taskId) throw new Error("taskId is required to start a work session.");
268
+ if (!device?.id) throw new Error("A device with an id is required to start a work session.");
269
+ // Alasan yang hanya berisi spasi dianggap tidak ada, jadi Factory tetap
270
+ // menjawab dengan permintaan alasan takeover, bukan pesan format.
271
+ const reason = typeof takeoverReason === "string" ? takeoverReason.trim() : "";
272
+ return {
273
+ action: "start",
274
+ taskId,
275
+ device,
276
+ step: String(step || "Working on the task").slice(0, 240),
277
+ ...(reason ? { takeoverReason: reason } : {}),
278
+ ...(clientSession?.id ? { clientSession: { id: String(clientSession.id).slice(0, 120), title: String(clientSession.title || "").slice(0, 200) } } : {}),
279
+ ...(now ? { now } : {}),
280
+ };
281
+ }
282
+
283
+ /** Payload for POST /api/work { action: "finish" }. */
284
+ export function buildWorkFinishPayload({ sessionId, deviceId, outcome, summary, now = null } = {}) {
285
+ if (!sessionId) throw new Error("sessionId is required to finish a work session.");
286
+ if (!deviceId) throw new Error("deviceId is required to finish a work session.");
287
+ if (!WORK_OUTCOMES[outcome]) {
288
+ throw new Error(`Unknown work outcome "${outcome}". Expected one of: ${Object.keys(WORK_OUTCOMES).join(", ")}.`);
289
+ }
290
+
291
+ const text = typeof summary === "string" ? summary.trim() : "";
292
+ if (text.length < 3) throw new Error("A summary of at least 3 characters is required to finish a work session.");
293
+
294
+ return {
295
+ action: "finish",
296
+ sessionId,
297
+ deviceId,
298
+ outcome,
299
+ summary: text.slice(0, 2000),
300
+ ...(now ? { now } : {}),
301
+ };
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // HTTP layer
306
+ // ---------------------------------------------------------------------------
307
+
308
+ async function resolveToken(config, fetchImpl = fetch) {
309
+ if (config.token) return config.token;
310
+
311
+ const key = `${config.pocketBaseUrl}|${config.email}`;
312
+ const cached = tokenCache.get(key);
313
+ if (cached && cached.expiresAt > Date.now()) return cached.token;
314
+
315
+ const response = await fetchImpl(`${config.pocketBaseUrl}/api/collections/activity_users/auth-with-password`, {
316
+ method: "POST",
317
+ headers: { "content-type": "application/json" },
318
+ body: JSON.stringify({ identity: config.email, password: config.password }),
319
+ });
320
+
321
+ if (!response.ok) {
322
+ throw new Error(
323
+ `Factory login failed (HTTP ${response.status}). Check FACTORY_EMAIL and FACTORY_PASSWORD, or set FACTORY_TOKEN.`,
324
+ );
325
+ }
326
+
327
+ const body = await response.json();
328
+ if (!body.token) throw new Error("Factory login returned no token.");
329
+
330
+ tokenCache.set(key, { token: body.token, expiresAt: Date.now() + TOKEN_TTL_MS });
331
+ return body.token;
332
+ }
333
+
334
+ /** Test helper: forget cached tokens (for example after a password change). */
335
+ export function clearTokenCache() {
336
+ tokenCache.clear();
337
+ }
338
+
339
+ async function request(config, path, { method = "GET", body, fetchImpl = fetch } = {}) {
340
+ const token = await resolveToken(config, fetchImpl);
341
+ const response = await fetchImpl(`${config.url}${path}`, {
342
+ method,
343
+ headers: {
344
+ Authorization: `Bearer ${token}`,
345
+ ...(body ? { "content-type": "application/json" } : {}),
346
+ },
347
+ ...(body ? { body: JSON.stringify(body) } : {}),
348
+ });
349
+
350
+ const text = await response.text();
351
+ let parsed = {};
352
+ try {
353
+ parsed = text ? JSON.parse(text) : {};
354
+ } catch {
355
+ parsed = {};
356
+ }
357
+
358
+ if (!response.ok) {
359
+ const detail = parsed.error || parsed.message || text || response.statusText;
360
+ // Body dan status ikut dibawa ke pemanggil, supaya penolakan takeover
361
+ // bisa diterjemahkan jadi arahan yang bisa dijalankan.
362
+ const error = new Error(`Factory request failed (HTTP ${response.status}): ${detail}`);
363
+ error.status = response.status;
364
+ error.body = parsed;
365
+ throw error;
366
+ }
367
+
368
+ return parsed;
369
+ }
370
+
371
+ /** Reads the whole workspace: projects, tasks and events. */
372
+ export async function readWorkspace(config, fetchImpl = fetch) {
373
+ return request(config, "/api/factory", { fetchImpl });
374
+ }
375
+
376
+ /**
377
+ * Lists tasks, newest first inside each status group.
378
+ *
379
+ * Factory returns every task of the owner in one payload, so filtering happens
380
+ * here. That keeps the client usable against older servers without query
381
+ * parameters.
382
+ */
383
+ export async function listTasks(config, options = {}, fetchImpl = fetch) {
384
+ const workspace = await readWorkspace(config, fetchImpl);
385
+ const statuses = Array.isArray(options.statuses) && options.statuses.length ? options.statuses : ACTIVE_STATUSES;
386
+ const project = typeof options.project === "string" ? options.project.trim().toLowerCase() : "";
387
+ const limit = Number.isFinite(options.limit) ? Math.min(Math.max(options.limit, 1), 200) : 50;
388
+
389
+ const mapped = (workspace.tasks || [])
390
+ .map((task) => mapTaskRecord(task, workspace.projects || []))
391
+ .filter((task) => statuses.includes(task.status))
392
+ .filter((task) => {
393
+ if (!project) return true;
394
+ return [task.projectId, task.projectName].filter(Boolean).some((value) => String(value).toLowerCase().includes(project));
395
+ });
396
+
397
+ return sortTasks(mapped).slice(0, limit);
398
+ }
399
+
400
+ /** Finds one task by id, or null. */
401
+ export async function findTask(config, taskId, fetchImpl = fetch) {
402
+ const workspace = await readWorkspace(config, fetchImpl);
403
+ const record = (workspace.tasks || []).find((task) => task.id === taskId);
404
+ if (!record) return null;
405
+ return mapTaskRecord(record, workspace.projects || []);
406
+ }
407
+
408
+ /**
409
+ * Claims a task by opening a work session, which is what makes it `running` in
410
+ * the Factory UI.
411
+ */
412
+ export async function claimTask(config, { recordId, device, step, takeoverReason } = {}, fetchImpl = fetch) {
413
+ if (!recordId) throw new Error("recordId is required to claim a task.");
414
+
415
+ const payload = buildWorkStartPayload({
416
+ taskId: recordId,
417
+ device: device || deviceFromConfig(config),
418
+ step,
419
+ takeoverReason,
420
+ clientSession: clientSessionFromConfig(config),
421
+ });
422
+ const body = await request(config, "/api/work", { method: "POST", body: payload, fetchImpl });
423
+ return body.session || null;
424
+ }
425
+
426
+ /**
427
+ * Finishes the active work session of a task.
428
+ *
429
+ * Factory only accepts an outcome for a session that exists and belongs to the
430
+ * caller's device, so both are checked first to produce a clear message.
431
+ */
432
+ export async function finishWork(config, { taskId, outcome, summary, device } = {}, fetchImpl = fetch) {
433
+ const deviceId = (device || deviceFromConfig(config)).id;
434
+ const task = await findTask(config, taskId, fetchImpl);
435
+ if (!task) throw new Error(`No task with id "${taskId}" is visible to this account.`);
436
+
437
+ const session = task.work;
438
+ if (!session || session.state !== "active") {
439
+ throw new Error(
440
+ `Task "${task.title}" has no active work session. Use claim_factory_task first, then report the outcome.`,
441
+ );
442
+ }
443
+
444
+ const payload = buildWorkFinishPayload({ sessionId: session.id, deviceId, outcome, summary });
445
+ const body = await request(config, "/api/work", { method: "POST", body: payload, fetchImpl });
446
+ return body.session || null;
447
+ }
448
+
449
+ /**
450
+ * Reports a status change using the one Factory endpoint that supports it.
451
+ *
452
+ * - review / blocked / paused -> finish the active work session
453
+ * - backlog / queued / cancelled -> move_task
454
+ * - running / done -> rejected with an explanation
455
+ */
456
+ export async function updateTaskStatus(config, { recordId, status, reason, nextAction, device } = {}, fetchImpl = fetch) {
457
+ if (!recordId) throw new Error("recordId is required to update a task.");
458
+
459
+ const plan = planStatusTransition(status);
460
+
461
+ if (plan.kind === "move") {
462
+ await request(config, "/api/factory", {
463
+ method: "POST",
464
+ body: { action: "move_task", id: recordId, status: plan.status },
465
+ fetchImpl,
466
+ });
467
+ return { id: recordId, status: plan.status };
468
+ }
469
+
470
+ const summary = buildSummary({ reason, nextAction, status });
471
+ const session = await finishWork(config, { taskId: recordId, outcome: plan.outcome, summary, device }, fetchImpl);
472
+ return { id: recordId, status: plan.outcome, session };
473
+ }
474
+
475
+ /**
476
+ * Creates a task on the Factory board.
477
+ *
478
+ * Factory validates the payload strictly (see its zod schema), so the same
479
+ * limits are enforced here to give the agent an actionable message instead of
480
+ * an opaque HTTP 400.
481
+ *
482
+ * `status` defaults to `backlog` on purpose. Factory only enqueues a worker run
483
+ * for `queued`, and a queued task without a ```run block is rejected with 409.
484
+ * Recording work should never start execution as a side effect.
485
+ */
486
+ export async function createTask(config, input = {}, fetchImpl = fetch) {
487
+ const projectId = clean(input.projectId);
488
+ const title = clean(input.title) || "";
489
+ const description = clean(input.description) || "";
490
+ const acceptance = clean(input.acceptance) || "";
491
+ const priority = clean(input.priority) || "normal";
492
+ const status = clean(input.status) || "backlog";
493
+
494
+ if (!projectId) throw new Error("projectId is required. Use list_factory_projects to find one.");
495
+ if (!UUID_RE.test(projectId)) throw new Error(`projectId "${projectId}" is not a UUID.`);
496
+ if (title.length < 3 || title.length > 140) {
497
+ throw new Error("title must be 3-140 characters.");
498
+ }
499
+ if (acceptance.length < 3 || acceptance.length > 3000) {
500
+ throw new Error("acceptance must be 3-3000 characters and should state what proves the work is done.");
501
+ }
502
+ if (description.length > 4000) throw new Error("description must be at most 4000 characters.");
503
+ if (!CREATABLE_PRIORITIES.includes(priority)) {
504
+ throw new Error(`priority must be one of: ${CREATABLE_PRIORITIES.join(", ")}.`);
505
+ }
506
+ if (!CREATABLE_STATUSES.includes(status)) {
507
+ throw new Error(`status must be one of: ${CREATABLE_STATUSES.join(", ")}.`);
508
+ }
509
+
510
+ const body = await request(config, "/api/factory", {
511
+ method: "POST",
512
+ body: { action: "create_task", task: { projectId, title, description, acceptance, priority, status } },
513
+ fetchImpl,
514
+ });
515
+
516
+ return { id: body.id ?? null, runId: body.runId ?? null, status };
517
+ }
518
+
519
+ /** Lists the owner's projects so a task can reference a real projectId. */
520
+ export async function listProjects(config, fetchImpl = fetch) {
521
+ const workspace = await readWorkspace(config, fetchImpl);
522
+ return (workspace.projects || []).map((project) => ({
523
+ id: project.id ?? null,
524
+ name: project.name ?? null,
525
+ repository: project.repository ?? null,
526
+ branch: project.branch ?? null,
527
+ stack: project.stack ?? null,
528
+ }));
529
+ }
530
+
531
+ /** Human-readable rendering of the project list. */
532
+ export function formatProjectList(projects = []) {
533
+ if (!Array.isArray(projects) || projects.length === 0) {
534
+ return "No projects in this Factory workspace yet. Create one in the Factory UI first.";
535
+ }
536
+
537
+ const lines = [`${projects.length} project(s) in Factory:`, ""];
538
+ projects.forEach((project, index) => {
539
+ lines.push(`${index + 1}. ${project.name} [${project.id}]`);
540
+ const meta = [project.stack, project.branch].filter(Boolean).join(" · ");
541
+ if (meta) lines.push(` ${meta}`);
542
+ if (project.repository) lines.push(` ${project.repository}`);
543
+ });
544
+ return lines.join("\n");
545
+ }
546
+
547
+ /** Builds the finish summary, which Factory requires (3-2000 chars). */
548
+ export function buildSummary({ reason, nextAction, status } = {}) {
549
+ const parts = [clean(reason), clean(nextAction)].filter(Boolean);
550
+ const text = parts.length ? parts.join(" — ") : `Status dilaporkan sebagai ${clean(status) || "review"}.`;
551
+ return text.length < 3 ? `${text} (dilaporkan agen)` : text.slice(0, 2000);
552
+ }
553
+
554
+ export { MOVABLE_STATUSES, WORK_OUTCOMES, CREATABLE_STATUSES, CREATABLE_PRIORITIES };
package/git-facade.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The single place that decides which git helpers are exposed to tool handlers.
3
+ *
4
+ * Handlers call `context.git.<helper>`. When a helper exists in git-helpers.js
5
+ * but is forgotten here, the tool fails at runtime with
6
+ * "git.getStatusOutput is not a function" — a wiring bug the unit tests miss
7
+ * because their mocks provide everything. Keeping the facade in its own module
8
+ * lets a test compare it against what the handlers actually call.
9
+ */
10
+
11
+ import {
12
+ getCurrentBranch,
13
+ getLastCommitMessage,
14
+ getPrimaryBranch,
15
+ getStagedChanges,
16
+ getStatusOutput,
17
+ hasStagedChanges,
18
+ hasTestChanges,
19
+ hasWorkingChanges,
20
+ workingTreeSummary,
21
+ } from "./git-helpers.js";
22
+
23
+ export function createGitFacade() {
24
+ return {
25
+ hasWorkingChanges,
26
+ hasTestChanges,
27
+ hasStagedChanges,
28
+ getStagedChanges,
29
+ getCurrentBranch,
30
+ getPrimaryBranch,
31
+ getLastCommitMessage,
32
+ getStatusOutput,
33
+ workingTreeSummary,
34
+ };
35
+ }
package/index.js CHANGED
@@ -13,16 +13,7 @@ import {
13
13
  import { fileURLToPath } from "url";
14
14
  import { exec } from "./exec.js";
15
15
  import { normalizeRequestArgs, shellEscape } from "./utils.js";
16
- import {
17
- getCurrentBranch,
18
- getLastCommitMessage,
19
- getPrimaryBranch,
20
- getStagedChanges,
21
- hasStagedChanges,
22
- hasTestChanges,
23
- hasWorkingChanges,
24
- workingTreeSummary,
25
- } from "./git-helpers.js";
16
+ import { createGitFacade } from "./git-facade.js";
26
17
  import {
27
18
  createCommitMessageParts,
28
19
  determineReleaseTypeFromCommit,
@@ -33,6 +24,7 @@ import { handleToolCall } from "./tools/handlers.js";
33
24
 
34
25
  export { createCommitMessageParts, determineReleaseTypeFromCommit } from "./commit-helpers.js";
35
26
  export { containsTestFilesInStatus, workingTreeSummary } from "./git-helpers.js";
27
+ export { createGitFacade } from "./git-facade.js";
36
28
  export { WorkflowState } from "./workflow-state.js";
37
29
 
38
30
  export let workflowState = null;
@@ -69,22 +61,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
69
61
  normalizeRequestArgs,
70
62
  workflowState,
71
63
  exec,
72
- git: {
73
- hasWorkingChanges,
74
- hasTestChanges,
75
- hasStagedChanges,
76
- getStagedChanges,
77
- getCurrentBranch,
78
- getPrimaryBranch,
79
- getLastCommitMessage,
80
- workingTreeSummary,
81
- },
64
+ git: createGitFacade(),
82
65
  utils: { shellEscape, determineReleaseTypeFromCommit, createCommitMessageParts },
83
66
  });
84
67
  });
85
68
 
86
69
  // Helper function
87
70
  export function getNextStep(status) {
71
+ if (!status.featureFlowCreated) return "Describe feature flow with Mermaid";
88
72
  if (!status.bugFixed) return "Mark feature/bug as fixed";
89
73
  if (!status.testsCreated) return "Create tests";
90
74
  if (!status.testsPassed) return "Run tests";
@@ -127,11 +111,11 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
127
111
  - Use 'start_task' to declare what you're coding
128
112
  - Be clear about your intention
129
113
 
130
- 2. 🔨 CODE WITH PURPOSE
131
- - Implement your feature or fix
132
- - Follow best practices
114
+ 2. 🌀 DESIGN FLOW
115
+ - Describe your feature flow using Mermaid
116
+ - Use 'create_feature_flow' to record it
133
117
 
134
- 3. 🧪 ALWAYS CREATE TESTS
118
+ 3. 🔨 CODE WITH PURPOSE
135
119
  - After fixing/implementing, create tests
136
120
  - Use 'mark_bug_fixed' when done
137
121
 
@@ -254,16 +238,7 @@ async function cliMain(argv) {
254
238
  normalizeRequestArgs,
255
239
  workflowState,
256
240
  exec,
257
- git: {
258
- hasWorkingChanges,
259
- hasTestChanges,
260
- hasStagedChanges,
261
- getStagedChanges,
262
- getCurrentBranch,
263
- getPrimaryBranch,
264
- getLastCommitMessage,
265
- workingTreeSummary,
266
- },
241
+ git: createGitFacade(),
267
242
  utils: { shellEscape, determineReleaseTypeFromCommit, createCommitMessageParts },
268
243
  });
269
244