forge-orkes 0.62.0 → 0.64.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/bin/create-forge.js +250 -99
- package/experimental/conventions/README.md +87 -0
- package/experimental/conventions/install.sh +179 -0
- package/experimental/conventions/source/rules/code-quality.md +17 -0
- package/experimental/conventions/source/rules/laravel-http.md +21 -0
- package/experimental/conventions/source/rules/laravel-jobs.md +19 -0
- package/experimental/conventions/source/rules/laravel-migrations.md +19 -0
- package/experimental/conventions/source/rules/laravel-services.md +21 -0
- package/experimental/conventions/source/rules/python.md +14 -0
- package/experimental/conventions/source/rules/testing.md +15 -0
- package/experimental/conventions/source/rules/vue-inertia.md +24 -0
- package/experimental/conventions/source/skills/laravel-conventions/SKILL.md +124 -0
- package/experimental/conventions/source/skills/python-conventions/SKILL.md +100 -0
- package/experimental/conventions/source/skills/testing-standards/SKILL.md +142 -0
- package/experimental/conventions/source/skills/vue-conventions/SKILL.md +92 -0
- package/experimental/conventions/uninstall.sh +97 -0
- package/experimental/m10/README.md +130 -0
- package/experimental/m10/install.sh +198 -0
- package/experimental/m10/source/hooks/forge-branch-guard.sh +61 -0
- package/experimental/m10/source/hooks/forge-claim-check-doctor.sh +77 -0
- package/experimental/m10/source/hooks/forge-claim-check.sh +113 -0
- package/experimental/m10/source/hooks/forge-session-id.sh +22 -0
- package/experimental/m10/source/mcp-server/README.md +74 -0
- package/experimental/m10/source/mcp-server/example.mcp.json +8 -0
- package/experimental/m10/source/mcp-server/index.js +460 -0
- package/experimental/m10/source/mcp-server/package.json +17 -0
- package/experimental/m10/source/skills/orchestrating/SKILL.md +223 -0
- package/experimental/m10/source/skills/orchestrating/bootstrap-checks.md +70 -0
- package/experimental/m10/uninstall.sh +69 -0
- package/package.json +3 -2
- package/template/.claude/hooks/forge-release-fold.sh +9 -1
- package/template/.claude/settings.json +1 -7
- package/template/.claude/skills/jarvis/SKILL.md +204 -0
- package/template/.claude/skills/upgrading/SKILL.md +105 -176
- package/template/.forge/FORGE.md +2 -2
- package/template/.forge/checks/forge-board-render.sh +11 -5
- package/template/.forge/checks/forge-jarvis-answer.sh +79 -0
- package/template/.forge/checks/forge-jarvis-awareness.sh +128 -0
- package/template/.forge/checks/forge-jarvis-bridge-clean.sh +130 -0
- package/template/.forge/checks/forge-jarvis-dispatch.sh +176 -0
- package/template/.forge/checks/forge-jarvis-instruments.sh +146 -0
- package/template/.forge/checks/forge-jarvis-notify-logonly.sh +40 -0
- package/template/.forge/checks/forge-jarvis-promote.sh +103 -0
- package/template/.forge/checks/forge-jarvis-prwatch.sh +92 -0
- package/template/.forge/checks/forge-jarvis-relay.sh +215 -0
- package/template/.forge/checks/forge-jarvis.sh +160 -0
- package/template/.forge/checks/tests/forge-jarvis-answer.test.sh +75 -0
- package/template/.forge/checks/tests/forge-jarvis-bridge-clean.test.sh +94 -0
- package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +162 -0
- package/template/.forge/checks/tests/forge-jarvis-instruments.test.sh +64 -0
- package/template/.forge/checks/tests/forge-jarvis-promote.test.sh +102 -0
- package/template/.forge/checks/tests/forge-jarvis-prwatch.test.sh +66 -0
- package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +135 -0
- package/template/.forge/checks/tests/forge-jarvis.test.sh +122 -0
- package/template/.forge/migrations/0.63.0-origin-first-upgrading.md +49 -0
- package/template/.forge/templates/project.yml +11 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Forge MCP orchestrator — production server.
|
|
3
|
+
// Tools: forge_claim_files, forge_release_claims, forge_list_claims, forge_queue_commit, forge_ping.
|
|
4
|
+
// Lifecycle: stdio MCP is one-server-per-client. Multiple Claude Code sessions get their own
|
|
5
|
+
// server process; concurrent state shared via SQLite WAL on claims.db. Server exits when
|
|
6
|
+
// client stdin closes (orphan defense for Claude Code #33947 without pidfile sibling-kill).
|
|
7
|
+
// #40207 mitigation: 8s stderr keepalive.
|
|
8
|
+
// Contract: .forge/phases/m10-01-orchestration/contracts/mcp-tools.md
|
|
9
|
+
// Schema: .forge/phases/m10-01-orchestration/data-model.md
|
|
10
|
+
|
|
11
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
14
|
+
import { DatabaseSync as Database } from "node:sqlite";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { mkdirSync, appendFileSync, chmodSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
// --- Paths + config ---
|
|
21
|
+
const REPO_ROOT = process.env.FORGE_REPO_ROOT || process.cwd();
|
|
22
|
+
const SERVER_DIR = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const DB_PATH = process.env.FORGE_DB || join(SERVER_DIR, "claims.db");
|
|
24
|
+
const EVENT_LOG = process.env.FORGE_EVENT_LOG || join(SERVER_DIR, "events.jsonl");
|
|
25
|
+
const DEFAULT_TTL_S = 900; // 15 min, per data-model.md
|
|
26
|
+
const KEEPALIVE_MS = 8 * 1000; // #40207 mitigation
|
|
27
|
+
const SCHEMA_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
// --- Ref/SHA validators (command-injection defense for attemptMerge) ---
|
|
30
|
+
function isValidRef(s) {
|
|
31
|
+
return typeof s === "string" && /^[A-Za-z0-9._/-]+$/.test(s) && !s.startsWith("-") && s.length <= 255;
|
|
32
|
+
}
|
|
33
|
+
function isValidSha(s) {
|
|
34
|
+
return typeof s === "string" && /^[0-9a-f]{7,64}$/.test(s);
|
|
35
|
+
}
|
|
36
|
+
// session_id is stored in the DB and interpolated into merge commit messages.
|
|
37
|
+
function isValidSessionId(s) {
|
|
38
|
+
return typeof s === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(s);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- SQLite setup ---
|
|
42
|
+
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
43
|
+
const db = new Database(DB_PATH);
|
|
44
|
+
// Claim contents are session-private; restrict to owner (single-user repo checkout assumed).
|
|
45
|
+
try { chmodSync(DB_PATH, 0o600); } catch { /* best-effort; FS may not support chmod */ }
|
|
46
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
47
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
48
|
+
db.exec(`
|
|
49
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
50
|
+
file_path TEXT NOT NULL,
|
|
51
|
+
session_id TEXT NOT NULL,
|
|
52
|
+
claimed_at INTEGER NOT NULL,
|
|
53
|
+
expires_at INTEGER NOT NULL,
|
|
54
|
+
reason TEXT,
|
|
55
|
+
PRIMARY KEY (file_path)
|
|
56
|
+
);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS claims_session_idx ON claims(session_id);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS claims_expires_idx ON claims(expires_at);
|
|
59
|
+
`);
|
|
60
|
+
const currentVersion = db.prepare("PRAGMA user_version").get().user_version;
|
|
61
|
+
if (currentVersion < SCHEMA_VERSION) {
|
|
62
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function nowSec() { return Math.floor(Date.now() / 1000); }
|
|
66
|
+
|
|
67
|
+
// --- Event log (JSONL, append-only) ---
|
|
68
|
+
// One line per tool invocation. Summarizes args/result; never logs full file lists or shas to bound size.
|
|
69
|
+
function summarizeArgs(tool, args) {
|
|
70
|
+
if (!args || typeof args !== "object") return {};
|
|
71
|
+
const out = {};
|
|
72
|
+
if (typeof args.session_id === "string") out.session_id = args.session_id;
|
|
73
|
+
if (Array.isArray(args.files)) out.files_count = args.files.length;
|
|
74
|
+
if (typeof args.ttl_seconds === "number") out.ttl_seconds = args.ttl_seconds;
|
|
75
|
+
if (typeof args.reason === "string") out.reason = args.reason.slice(0, 80);
|
|
76
|
+
if (typeof args.branch === "string") out.branch = args.branch;
|
|
77
|
+
if (typeof args.target_branch === "string") out.target_branch = args.target_branch;
|
|
78
|
+
if (typeof args.base_sha === "string") out.base_sha = args.base_sha.slice(0, 12);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function summarizeResult(tool, result) {
|
|
83
|
+
if (!result || typeof result !== "object") return {};
|
|
84
|
+
const out = {};
|
|
85
|
+
if (Array.isArray(result.claimed)) out.claimed_count = result.claimed.length;
|
|
86
|
+
if (Array.isArray(result.rejected)) out.rejected_count = result.rejected.length;
|
|
87
|
+
if (Array.isArray(result.released)) out.released_count = result.released.length;
|
|
88
|
+
if (Array.isArray(result.claims)) out.claims_count = result.claims.length;
|
|
89
|
+
if (typeof result.status === "string") out.status = result.status;
|
|
90
|
+
if (Array.isArray(result.conflicted_files)) out.conflicted_count = result.conflicted_files.length;
|
|
91
|
+
if (typeof result.merged_sha === "string") out.merged_sha = result.merged_sha.slice(0, 12);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function logEvent(event) {
|
|
96
|
+
try {
|
|
97
|
+
appendFileSync(EVENT_LOG, JSON.stringify(event) + "\n");
|
|
98
|
+
} catch (e) {
|
|
99
|
+
process.stderr.write(`[forge-orchestrator] event-log write failed: ${e.message}\n`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function purgeExpired() {
|
|
104
|
+
db.prepare("DELETE FROM claims WHERE expires_at <= ?").run(nowSec());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Tool implementations ---
|
|
108
|
+
function claimFiles({ session_id, files, ttl_seconds, reason }) {
|
|
109
|
+
if (!session_id || !Array.isArray(files) || files.length === 0) {
|
|
110
|
+
throw Object.assign(new Error("INVALID_INPUT: session_id and non-empty files required"), { code: "INVALID_INPUT" });
|
|
111
|
+
}
|
|
112
|
+
if (!isValidSessionId(session_id)) {
|
|
113
|
+
throw Object.assign(new Error("INVALID_INPUT: session_id must match /^[A-Za-z0-9_-]{1,64}$/"), { code: "INVALID_INPUT" });
|
|
114
|
+
}
|
|
115
|
+
purgeExpired();
|
|
116
|
+
const ttl = Number.isInteger(ttl_seconds) && ttl_seconds > 0 ? ttl_seconds : DEFAULT_TTL_S;
|
|
117
|
+
const t = nowSec();
|
|
118
|
+
const expires = t + ttl;
|
|
119
|
+
const checkStmt = db.prepare("SELECT session_id, expires_at FROM claims WHERE file_path = ? AND expires_at > ?");
|
|
120
|
+
const upsertStmt = db.prepare(`
|
|
121
|
+
INSERT INTO claims (file_path, session_id, claimed_at, expires_at, reason)
|
|
122
|
+
VALUES (?, ?, ?, ?, ?)
|
|
123
|
+
ON CONFLICT(file_path) DO UPDATE SET
|
|
124
|
+
session_id = excluded.session_id,
|
|
125
|
+
claimed_at = excluded.claimed_at,
|
|
126
|
+
expires_at = excluded.expires_at,
|
|
127
|
+
reason = excluded.reason
|
|
128
|
+
WHERE claims.session_id = excluded.session_id OR claims.expires_at <= excluded.claimed_at
|
|
129
|
+
`);
|
|
130
|
+
const result = { claimed: [], rejected: [] };
|
|
131
|
+
db.exec("BEGIN");
|
|
132
|
+
try {
|
|
133
|
+
for (const p of files) {
|
|
134
|
+
const existing = checkStmt.get(p, t);
|
|
135
|
+
if (existing && existing.session_id !== session_id) {
|
|
136
|
+
result.rejected.push({ file: p, owner_session: existing.session_id, expires_at: existing.expires_at });
|
|
137
|
+
} else {
|
|
138
|
+
upsertStmt.run(p, session_id, t, expires, reason || null);
|
|
139
|
+
result.claimed.push(p);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
db.exec("COMMIT");
|
|
143
|
+
} catch (e) {
|
|
144
|
+
db.exec("ROLLBACK");
|
|
145
|
+
throw e;
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function releaseClaims({ session_id, files }) {
|
|
151
|
+
if (!session_id) {
|
|
152
|
+
throw Object.assign(new Error("INVALID_INPUT: session_id required"), { code: "INVALID_INPUT" });
|
|
153
|
+
}
|
|
154
|
+
if (!isValidSessionId(session_id)) {
|
|
155
|
+
throw Object.assign(new Error("INVALID_INPUT: session_id must match /^[A-Za-z0-9_-]{1,64}$/"), { code: "INVALID_INPUT" });
|
|
156
|
+
}
|
|
157
|
+
const released = [];
|
|
158
|
+
if (Array.isArray(files) && files.length > 0) {
|
|
159
|
+
const stmt = db.prepare("DELETE FROM claims WHERE file_path = ? AND session_id = ? RETURNING file_path");
|
|
160
|
+
for (const p of files) {
|
|
161
|
+
const row = stmt.get(p, session_id);
|
|
162
|
+
if (row) released.push(row.file_path);
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
const rows = db.prepare("DELETE FROM claims WHERE session_id = ? RETURNING file_path").all(session_id);
|
|
166
|
+
for (const r of rows) released.push(r.file_path);
|
|
167
|
+
}
|
|
168
|
+
return { released };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function listClaims({ session_id } = {}) {
|
|
172
|
+
purgeExpired();
|
|
173
|
+
const rows = session_id
|
|
174
|
+
? db.prepare("SELECT file_path, session_id, claimed_at, expires_at, reason FROM claims WHERE session_id = ?").all(session_id)
|
|
175
|
+
: db.prepare("SELECT file_path, session_id, claimed_at, expires_at, reason FROM claims").all();
|
|
176
|
+
return {
|
|
177
|
+
claims: rows.map((r) => ({
|
|
178
|
+
file: r.file_path,
|
|
179
|
+
session_id: r.session_id,
|
|
180
|
+
claimed_at: r.claimed_at,
|
|
181
|
+
expires_at: r.expires_at,
|
|
182
|
+
reason: r.reason,
|
|
183
|
+
})),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- Merge queue (serialized via Node single-thread + busy flag) ---
|
|
188
|
+
let queueBusy = false;
|
|
189
|
+
const queue = [];
|
|
190
|
+
|
|
191
|
+
function queueCommit(args) {
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
queue.push({ args, resolve });
|
|
194
|
+
drainQueue();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function drainQueue() {
|
|
199
|
+
if (queueBusy || queue.length === 0) return;
|
|
200
|
+
queueBusy = true;
|
|
201
|
+
const job = queue.shift();
|
|
202
|
+
try {
|
|
203
|
+
const result = attemptMerge(job.args);
|
|
204
|
+
job.resolve(result);
|
|
205
|
+
} catch (e) {
|
|
206
|
+
job.resolve({ status: "error", code: "MERGE_TOOL_ERROR", error: e.message });
|
|
207
|
+
} finally {
|
|
208
|
+
queueBusy = false;
|
|
209
|
+
if (queue.length) drainQueue();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function git(argv, opts = {}) {
|
|
214
|
+
const r = spawnSync("git", argv, { cwd: REPO_ROOT, encoding: "utf-8", ...opts });
|
|
215
|
+
if (r.status !== 0) throw new Error(`git ${argv.join(" ")} failed: ${r.stderr}`);
|
|
216
|
+
return (r.stdout || "").trim();
|
|
217
|
+
}
|
|
218
|
+
// Non-throwing sibling: centralizes cwd + encoding for callers that branch on
|
|
219
|
+
// exit status or need raw stdout/stderr (merge-tree conflict path, FF check, CAS update-ref).
|
|
220
|
+
function gitRaw(argv, opts = {}) {
|
|
221
|
+
return spawnSync("git", argv, { cwd: REPO_ROOT, encoding: "utf-8", ...opts });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// stale_base is the CAS-rejection contract — built at both the pre-check and the
|
|
225
|
+
// update-ref race sites. One constructor keeps the shape + guidance in sync (R039: main → target).
|
|
226
|
+
function staleBaseResponse(currentTargetSha) {
|
|
227
|
+
return {
|
|
228
|
+
status: "stale_base",
|
|
229
|
+
current_target_sha: currentTargetSha,
|
|
230
|
+
guidance: `rebase onto ${currentTargetSha} and retry`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function branchExists(branch) {
|
|
235
|
+
// branch validated by caller before reaching here
|
|
236
|
+
try { git(["rev-parse", "--verify", `refs/heads/${branch}`]); return true; } catch { return false; }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function attemptMerge({ session_id, branch, base_sha, target_branch }) {
|
|
240
|
+
if (!session_id || !branch || !base_sha) {
|
|
241
|
+
return { status: "error", code: "INVALID_INPUT", error: "session_id, branch, base_sha required" };
|
|
242
|
+
}
|
|
243
|
+
if (!isValidSessionId(session_id)) {
|
|
244
|
+
return { status: "error", code: "INVALID_INPUT", error: "session_id must match /^[A-Za-z0-9_-]{1,64}$/" };
|
|
245
|
+
}
|
|
246
|
+
if (!isValidRef(branch)) {
|
|
247
|
+
return { status: "error", code: "INVALID_INPUT", error: "branch contains invalid characters" };
|
|
248
|
+
}
|
|
249
|
+
// Ownership: a session may only merge its own branch. Convention is forge/<session_id>
|
|
250
|
+
// (orchestrating skill). Prevents one session merging another's in-flight branch.
|
|
251
|
+
if (branch !== `forge/${session_id}`) {
|
|
252
|
+
return { status: "error", code: "FORBIDDEN", error: `session ${session_id} may only merge branch forge/${session_id}` };
|
|
253
|
+
}
|
|
254
|
+
if (target_branch && !isValidRef(target_branch)) {
|
|
255
|
+
return { status: "error", code: "INVALID_INPUT", error: "target_branch contains invalid characters" };
|
|
256
|
+
}
|
|
257
|
+
if (!isValidSha(base_sha)) {
|
|
258
|
+
return { status: "error", code: "INVALID_INPUT", error: "base_sha must be a hex SHA (7-64 chars)" };
|
|
259
|
+
}
|
|
260
|
+
const targetBranch = target_branch || "main";
|
|
261
|
+
if (!branchExists(branch)) {
|
|
262
|
+
return { status: "error", code: "BRANCH_NOT_FOUND", error: `branch ${branch} not found` };
|
|
263
|
+
}
|
|
264
|
+
const currentTargetSha = git(["rev-parse", targetBranch]);
|
|
265
|
+
if (!isValidSha(currentTargetSha)) {
|
|
266
|
+
return { status: "error", code: "MERGE_TOOL_ERROR", error: "rev-parse target returned non-SHA" };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// CAS pre-check: caller rebased onto base_sha; if target moved, reject
|
|
270
|
+
if (currentTargetSha !== base_sha) {
|
|
271
|
+
return staleBaseResponse(currentTargetSha);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const branchSha = git(["rev-parse", branch]);
|
|
275
|
+
if (!isValidSha(branchSha)) {
|
|
276
|
+
return { status: "error", code: "MERGE_TOOL_ERROR", error: "rev-parse branch returned non-SHA" };
|
|
277
|
+
}
|
|
278
|
+
const mergeTreeRes = gitRaw(
|
|
279
|
+
["merge-tree", "--write-tree", "--name-only", "-z", currentTargetSha, branchSha]
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
if (mergeTreeRes.status !== 0) {
|
|
283
|
+
const conflicted = (mergeTreeRes.stdout || "").split("\0").filter(Boolean);
|
|
284
|
+
return {
|
|
285
|
+
status: "conflict",
|
|
286
|
+
conflicted_files: conflicted,
|
|
287
|
+
base_sha: currentTargetSha,
|
|
288
|
+
messages: (mergeTreeRes.stderr || "").split("\n").filter(Boolean),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// -z output is NUL-separated: <tree-oid>\0<conflicted-file>\0... JS .trim() does NOT
|
|
293
|
+
// strip NUL, so the old .trim().split("\n")[0] left a \0-tailed OID that crashed
|
|
294
|
+
// commit-tree on every non-FF merge. Split on NUL and take the first field.
|
|
295
|
+
const mergedTree = (mergeTreeRes.stdout || "").split("\0")[0].trim();
|
|
296
|
+
if (!mergedTree) {
|
|
297
|
+
return { status: "error", code: "MERGE_TOOL_ERROR", error: "merge-tree produced no tree OID" };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const isFF = gitRaw(["merge-base", "--is-ancestor", currentTargetSha, branchSha]).status === 0;
|
|
301
|
+
|
|
302
|
+
let newSha;
|
|
303
|
+
if (isFF) {
|
|
304
|
+
newSha = branchSha;
|
|
305
|
+
} else {
|
|
306
|
+
const msg = `merge ${branch} into ${targetBranch} (session ${session_id})`;
|
|
307
|
+
const ct = gitRaw(["commit-tree", mergedTree, "-p", currentTargetSha, "-p", branchSha, "-m", msg]);
|
|
308
|
+
if (ct.status !== 0) throw new Error(`git commit-tree failed: ${ct.stderr}`);
|
|
309
|
+
newSha = (ct.stdout || "").trim();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// CAS via update-ref — fails if someone else advanced the ref since we read it
|
|
313
|
+
const ur = gitRaw(["update-ref", `refs/heads/${targetBranch}`, newSha, currentTargetSha]);
|
|
314
|
+
if (ur.status !== 0) {
|
|
315
|
+
return staleBaseResponse(git(["rev-parse", targetBranch]));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Delete merged branch — best-effort
|
|
319
|
+
const deleted = gitRaw(["branch", "-D", branch]).status === 0;
|
|
320
|
+
|
|
321
|
+
return { status: "merged", new_main_sha: newSha, merged_branch_deleted: deleted };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- MCP wiring ---
|
|
325
|
+
const server = new Server(
|
|
326
|
+
{ name: "forge-orchestrator", version: "1.0.0" },
|
|
327
|
+
{ capabilities: { tools: {} } }
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
331
|
+
tools: [
|
|
332
|
+
{
|
|
333
|
+
name: "forge_claim_files",
|
|
334
|
+
description: "Claim files for exclusive edit by a session. Returns claimed + rejected lists.",
|
|
335
|
+
inputSchema: {
|
|
336
|
+
type: "object",
|
|
337
|
+
properties: {
|
|
338
|
+
session_id: { type: "string" },
|
|
339
|
+
files: { type: "array", items: { type: "string" }, minItems: 1 },
|
|
340
|
+
ttl_seconds:{ type: "integer", minimum: 1 },
|
|
341
|
+
reason: { type: "string" },
|
|
342
|
+
},
|
|
343
|
+
required: ["session_id", "files"],
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: "forge_release_claims",
|
|
348
|
+
description: "Release claims held by a session. Omit files to release all.",
|
|
349
|
+
inputSchema: {
|
|
350
|
+
type: "object",
|
|
351
|
+
properties: {
|
|
352
|
+
session_id: { type: "string" },
|
|
353
|
+
files: { type: "array", items: { type: "string" } },
|
|
354
|
+
},
|
|
355
|
+
required: ["session_id"],
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
name: "forge_list_claims",
|
|
360
|
+
description: "Read-only inspection of active claims.",
|
|
361
|
+
inputSchema: {
|
|
362
|
+
type: "object",
|
|
363
|
+
properties: { session_id: { type: "string" } },
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
name: "forge_queue_commit",
|
|
368
|
+
description: "Request auto-merge of branch onto target (default main). CAS-checked via base_sha.",
|
|
369
|
+
inputSchema: {
|
|
370
|
+
type: "object",
|
|
371
|
+
properties: {
|
|
372
|
+
session_id: { type: "string" },
|
|
373
|
+
branch: { type: "string" },
|
|
374
|
+
base_sha: { type: "string" },
|
|
375
|
+
target_branch: { type: "string" },
|
|
376
|
+
},
|
|
377
|
+
required: ["session_id", "branch", "base_sha"],
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
name: "forge_ping",
|
|
382
|
+
description: "Keepalive probe. Resets Claude Code idle timeout (#40207 workaround).",
|
|
383
|
+
inputSchema: { type: "object", properties: {} },
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
}));
|
|
387
|
+
|
|
388
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
389
|
+
const { name, arguments: args = {} } = req.params;
|
|
390
|
+
const startMs = Date.now();
|
|
391
|
+
try {
|
|
392
|
+
let result;
|
|
393
|
+
switch (name) {
|
|
394
|
+
case "forge_claim_files": result = claimFiles(args); break;
|
|
395
|
+
case "forge_release_claims": result = releaseClaims(args); break;
|
|
396
|
+
case "forge_list_claims": result = listClaims(args); break;
|
|
397
|
+
case "forge_queue_commit": result = await queueCommit(args); break;
|
|
398
|
+
case "forge_ping": result = { pong: true, server_time: nowSec() }; break;
|
|
399
|
+
default: throw Object.assign(new Error(`unknown tool: ${name}`), { code: "INVALID_INPUT" });
|
|
400
|
+
}
|
|
401
|
+
logEvent({
|
|
402
|
+
ts: new Date().toISOString(),
|
|
403
|
+
tool: name,
|
|
404
|
+
args: summarizeArgs(name, args),
|
|
405
|
+
result: summarizeResult(name, result),
|
|
406
|
+
duration_ms: Date.now() - startMs,
|
|
407
|
+
ok: true,
|
|
408
|
+
});
|
|
409
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
410
|
+
} catch (e) {
|
|
411
|
+
logEvent({
|
|
412
|
+
ts: new Date().toISOString(),
|
|
413
|
+
tool: name,
|
|
414
|
+
args: summarizeArgs(name, args),
|
|
415
|
+
error: e.message,
|
|
416
|
+
code: e.code || "INTERNAL_ERROR",
|
|
417
|
+
duration_ms: Date.now() - startMs,
|
|
418
|
+
ok: false,
|
|
419
|
+
});
|
|
420
|
+
const payload = { error: e.message, code: e.code || "INTERNAL_ERROR" };
|
|
421
|
+
return { content: [{ type: "text", text: JSON.stringify(payload) }], isError: true };
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// --- Lifecycle ---
|
|
426
|
+
// stdio MCP is one-server-per-client. Sessions sharing claims.db coexist via SQLite WAL.
|
|
427
|
+
// Server lifetime tied to client stdin: when client disconnects, stdin closes → exit.
|
|
428
|
+
// Claims persist across server restarts via DB; sessions release explicitly via forge_release_claims.
|
|
429
|
+
function cleanup() {
|
|
430
|
+
try { db.close(); } catch { /* already closed */ }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
|
434
|
+
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
|
435
|
+
process.on("exit", cleanup);
|
|
436
|
+
|
|
437
|
+
// --- Self-test mode (no MCP transport) ---
|
|
438
|
+
if (process.argv.includes("--self-test")) {
|
|
439
|
+
const probe = claimFiles({ session_id: "self-test", files: ["/tmp/forge-self-test.txt"], reason: "self-test" });
|
|
440
|
+
if (!probe.claimed.length) { process.stderr.write(`self-test FAIL: claim returned no rows\n`); cleanup(); process.exit(1); }
|
|
441
|
+
releaseClaims({ session_id: "self-test" });
|
|
442
|
+
const list = listClaims();
|
|
443
|
+
process.stderr.write(`self-test OK: claim+release+list round-trip, schema_version=${SCHEMA_VERSION}, db=${DB_PATH}, claims_now=${list.claims.length}\n`);
|
|
444
|
+
cleanup();
|
|
445
|
+
process.exit(0);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// --- Run server ---
|
|
449
|
+
const keepalive = setInterval(() => {
|
|
450
|
+
process.stderr.write(`[forge-orchestrator] keepalive ${new Date().toISOString()}\n`);
|
|
451
|
+
}, KEEPALIVE_MS);
|
|
452
|
+
keepalive.unref();
|
|
453
|
+
|
|
454
|
+
// Orphan defense (#33947): exit when client stdin closes. Replaces fragile pidfile SIGTERM logic.
|
|
455
|
+
process.stdin.on("end", () => { cleanup(); process.exit(0); });
|
|
456
|
+
process.stdin.on("close", () => { cleanup(); process.exit(0); });
|
|
457
|
+
|
|
458
|
+
const transport = new StdioServerTransport();
|
|
459
|
+
await server.connect(transport);
|
|
460
|
+
process.stderr.write(`[forge-orchestrator] started pid=${process.pid} db=${DB_PATH}\n`);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "forge-orchestrator",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Forge MCP orchestrator — file claims + commit queue for multi-agent sessions.",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"self-test": "node index.js --self-test"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=24"
|
|
16
|
+
}
|
|
17
|
+
}
|