@programinglive/dev-workflow-mcp-server 1.9.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.
@@ -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,16 +61,7 @@ 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
  });
@@ -255,16 +238,7 @@ async function cliMain(argv) {
255
238
  normalizeRequestArgs,
256
239
  workflowState,
257
240
  exec,
258
- git: {
259
- hasWorkingChanges,
260
- hasTestChanges,
261
- hasStagedChanges,
262
- getStagedChanges,
263
- getCurrentBranch,
264
- getPrimaryBranch,
265
- getLastCommitMessage,
266
- workingTreeSummary,
267
- },
241
+ git: createGitFacade(),
268
242
  utils: { shellEscape, determineReleaseTypeFromCommit, createCommitMessageParts },
269
243
  });
270
244