@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,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.
|
|
131
|
-
-
|
|
132
|
-
-
|
|
114
|
+
2. 🌀 DESIGN FLOW
|
|
115
|
+
- Describe your feature flow using Mermaid
|
|
116
|
+
- Use 'create_feature_flow' to record it
|
|
133
117
|
|
|
134
|
-
3.
|
|
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
|
|