jules-orchestrator-kit 0.52.8 ā 0.54.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/README.md +68 -8
- package/bin/agentctl.mjs +327 -24
- package/bin/init.js +87 -74
- package/index.mjs +10 -0
- package/package.json +5 -2
- package/scripts/jules-create.mjs +5 -16
- package/scripts/jules-patch.mjs +18 -10
- package/scripts/stale-base-check.mjs +1 -1
- package/src/ci-templates.mjs +199 -0
- package/src/config-edit.mjs +138 -0
- package/src/config.mjs +39 -11
- package/src/engine.mjs +2 -21
- package/src/env-aliases.mjs +80 -0
- package/src/git.mjs +79 -0
- package/src/mcp.mjs +19 -1
- package/src/ops/cli-intent.mjs +29 -0
- package/src/ops/command-registry.mjs +19 -10
- package/src/ops/doctor-registry.mjs +75 -32
- package/src/ops/ide-scaffold.mjs +22 -16
- package/src/ops/next-step.mjs +38 -7
- package/src/ops/verify-output.mjs +38 -0
- package/src/profiles.mjs +188 -0
- package/src/provider-readiness.mjs +284 -0
- package/src/security.mjs +42 -1
- package/src/tui.mjs +14 -3
- package/src/wizard-init.mjs +88 -12
- package/src/wizard-task.mjs +22 -5
- package/src/ops/doctor-planner.mjs +0 -220
- package/src/ops/receipts.mjs +0 -171
- package/src/ops/swarm-actions.mjs +0 -156
- package/src/ops/task-actions.mjs +0 -214
- package/src/ops/transaction.mjs +0 -278
- package/src/ux/capabilities.mjs +0 -180
- package/src/ux/diff-viewer.mjs +0 -289
- package/src/ux/layout.mjs +0 -195
- package/src/ux/log-viewer.mjs +0 -110
- package/src/ux/palette.mjs +0 -179
- package/src/ux/queue-model.mjs +0 -206
- package/src/ux/renderer.mjs +0 -188
- package/src/ux/swarm-model.mjs +0 -146
- package/src/ux/terminal-session.mjs +0 -215
- package/src/ux/widgets.mjs +0 -323
- /package/src/{ux/key-decoder.mjs ā key-decoder.mjs} +0 -0
package/bin/init.js
CHANGED
|
@@ -8,6 +8,8 @@ import readline from "node:readline/promises";
|
|
|
8
8
|
import zlib from "node:zlib";
|
|
9
9
|
import crypto from "node:crypto";
|
|
10
10
|
import { resolveProjectCommands } from "../scripts/command-resolver.mjs";
|
|
11
|
+
import { KIT_VERSION } from "../src/version.mjs";
|
|
12
|
+
import { detectStack } from "../src/config.mjs";
|
|
11
13
|
|
|
12
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
13
15
|
const __dirname = path.dirname(__filename);
|
|
@@ -52,7 +54,7 @@ Options:
|
|
|
52
54
|
process.exit(0);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
console.log("\nš Initializing
|
|
57
|
+
console.log("\nš Initializing agent orchestrator kit...\n");
|
|
56
58
|
console.log(`š Target Directory: ${targetDir}`);
|
|
57
59
|
if (isForce) console.log("ā ļø Force mode enabled (existing files will be overwritten).");
|
|
58
60
|
|
|
@@ -129,65 +131,62 @@ for (const item of scaffolded.created) {
|
|
|
129
131
|
|
|
130
132
|
const agentDir = path.join(targetDir, ".agent");
|
|
131
133
|
|
|
132
|
-
// Scaffold .
|
|
134
|
+
// Scaffold the manifest pair.
|
|
135
|
+
//
|
|
136
|
+
// This entry point used to hand-roll a thinner `.agent/jules.yml` while
|
|
137
|
+
// `agentctl init` wrote a `.agent/config.yml` the runtime actually reads, so
|
|
138
|
+
// which of the two scaffolders you happened to run decided whether the
|
|
139
|
+
// repository had a provider, a tier and a verification profile at all. Both
|
|
140
|
+
// now go through `planInit`.
|
|
141
|
+
const { planInit } = await import("../src/wizard-init.mjs");
|
|
142
|
+
const initPlan = planInit(targetDir, {
|
|
143
|
+
testCmd: detected.testCmd,
|
|
144
|
+
buildCmd: detected.buildCmd,
|
|
145
|
+
lintCmd: detected.lintCmd,
|
|
146
|
+
baseBranch: answerBranchVal || process.env.BASE_BRANCH || undefined,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const configPath = path.join(agentDir, "config.yml");
|
|
150
|
+
if (!fs.existsSync(configPath) || isForce) {
|
|
151
|
+
fs.writeFileSync(configPath, initPlan.configYaml, "utf-8");
|
|
152
|
+
console.log(`ā
Created: .agent/config.yml (provider: ${initPlan.provider}, profile: ${initPlan.profile})`);
|
|
153
|
+
}
|
|
154
|
+
|
|
133
155
|
const yamlConfigPath = path.join(agentDir, "jules.yml");
|
|
134
156
|
if (!fs.existsSync(yamlConfigPath) || isForce) {
|
|
135
|
-
|
|
136
|
-
version: 2
|
|
137
|
-
test_cmd: "${detected.testCmd || "npm test"}"
|
|
138
|
-
build_cmd: "${detected.buildCmd || "npm run build"}"
|
|
139
|
-
forbidden_paths: [".github/**", "**/.env*", "**/*.pem", "**/lock-manager*"]
|
|
140
|
-
`;
|
|
141
|
-
fs.writeFileSync(yamlConfigPath, yamlContent, "utf-8");
|
|
157
|
+
fs.writeFileSync(yamlConfigPath, initPlan.julesYaml, "utf-8");
|
|
142
158
|
console.log("ā
Created: .agent/jules.yml");
|
|
143
159
|
}
|
|
144
160
|
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
// 3b. Generate a CI gate workflow for *this* repository's stack.
|
|
162
|
+
//
|
|
163
|
+
// This used to copy the kit's own audit workflow verbatim: a nine-way Node
|
|
164
|
+
// matrix running `npm install`, `npm test` and a script path that exists only
|
|
165
|
+
// inside the kit. In a Rust, Python or Go repository it was red on the first
|
|
166
|
+
// push for reasons that had nothing to do with that repository's code.
|
|
167
|
+
const { writeCiWorkflow } = await import("../src/ci-templates.mjs");
|
|
168
|
+
const ciRes = writeCiWorkflow(targetDir, {
|
|
169
|
+
target: "github",
|
|
170
|
+
force: isForce,
|
|
171
|
+
// `resolveProjectCommands` reports a human-readable source string; the CI
|
|
172
|
+
// generator needs the machine-readable stack id.
|
|
173
|
+
stack: detectStack(targetDir),
|
|
174
|
+
config: { baseBranch: answerBranchVal || process.env.BASE_BRANCH || "main" },
|
|
175
|
+
version: KIT_VERSION,
|
|
176
|
+
});
|
|
177
|
+
if (ciRes.ok && ciRes.written) {
|
|
178
|
+
console.log(`ā
Generated CI gate for '${ciRes.stack}': ${ciRes.file}`);
|
|
179
|
+
} else if (ciRes.ok) {
|
|
180
|
+
console.log(`ā©ļø Kept existing ${ciRes.file} (use --force to regenerate)`);
|
|
158
181
|
}
|
|
159
182
|
|
|
160
|
-
// 4.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
let copiedCount = 0;
|
|
168
|
-
let skippedCount = 0;
|
|
169
|
-
|
|
170
|
-
if (fs.existsSync(sourceScriptsDir)) {
|
|
171
|
-
const scriptFiles = fs.readdirSync(sourceScriptsDir);
|
|
172
|
-
const excludeFiles = new Set(["release.mjs"]);
|
|
173
|
-
scriptFiles.forEach((file) => {
|
|
174
|
-
if (excludeFiles.has(file)) return;
|
|
175
|
-
const srcFile = path.join(sourceScriptsDir, file);
|
|
176
|
-
const destFile = path.join(targetScriptsDir, file);
|
|
177
|
-
if (!fs.existsSync(destFile) || isForce) {
|
|
178
|
-
fs.copyFileSync(srcFile, destFile);
|
|
179
|
-
try {
|
|
180
|
-
fs.chmodSync(destFile, 0o755);
|
|
181
|
-
} catch (err) {
|
|
182
|
-
console.warn(`ā ļø Could not set executable permissions on ${file}:`, err.message);
|
|
183
|
-
}
|
|
184
|
-
copiedCount++;
|
|
185
|
-
} else {
|
|
186
|
-
skippedCount++;
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
console.log(`ā
Copied ${copiedCount} orchestration scripts to ./scripts/ (${skippedCount} skipped, use --force to overwrite)`);
|
|
190
|
-
}
|
|
183
|
+
// 4. The kit's own scripts/ directory is NOT copied here.
|
|
184
|
+
//
|
|
185
|
+
// It used to be ā twenty orchestration scripts dropped into the target repo's
|
|
186
|
+
// scripts/ folder, where they collided with the project's own files, aged
|
|
187
|
+
// independently of the installed kit, and duplicated commands that `agentctl`
|
|
188
|
+
// already exposes. Everything they did is reachable through the CLI, which is
|
|
189
|
+
// what the injected package.json entries below point at.
|
|
191
190
|
|
|
192
191
|
// 5. Optionally inject scripts into target package.json if present
|
|
193
192
|
const targetPkgPath = path.join(targetDir, "package.json");
|
|
@@ -196,18 +195,22 @@ if (fs.existsSync(targetPkgPath) && targetDir !== kitRoot) {
|
|
|
196
195
|
const pkg = JSON.parse(fs.readFileSync(targetPkgPath, "utf-8"));
|
|
197
196
|
pkg.scripts = pkg.scripts || {};
|
|
198
197
|
let updated = false;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
"
|
|
205
|
-
"
|
|
206
|
-
"
|
|
207
|
-
"
|
|
198
|
+
// Vendor-neutral names: these run `agentctl`, which dispatches to whichever
|
|
199
|
+
// provider the repository selected. `jules:*` entries from an older init are
|
|
200
|
+
// left in place ā removing scripts a project may reference in CI is not
|
|
201
|
+
// this command's business.
|
|
202
|
+
const agentScripts = {
|
|
203
|
+
"agent:gate": "agentctl check",
|
|
204
|
+
"agent:dispatch": "agentctl dispatch",
|
|
205
|
+
"agent:queue": "agentctl queue",
|
|
206
|
+
"agent:create": "agentctl task create",
|
|
207
|
+
"agent:status": "agentctl status",
|
|
208
|
+
"agent:doctor": "agentctl doctor",
|
|
209
|
+
"agent:swarm": "agentctl swarm",
|
|
210
|
+
"agent:clean": "agentctl clean"
|
|
208
211
|
};
|
|
209
212
|
|
|
210
|
-
for (const [key, val] of Object.entries(
|
|
213
|
+
for (const [key, val] of Object.entries(agentScripts)) {
|
|
211
214
|
if (!pkg.scripts[key] || isForce) {
|
|
212
215
|
pkg.scripts[key] = val;
|
|
213
216
|
updated = true;
|
|
@@ -216,7 +219,7 @@ if (fs.existsSync(targetPkgPath) && targetDir !== kitRoot) {
|
|
|
216
219
|
|
|
217
220
|
if (updated) {
|
|
218
221
|
fs.writeFileSync(targetPkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
|
|
219
|
-
console.log("ā
Added
|
|
222
|
+
console.log("ā
Added agent:* commands to package.json");
|
|
220
223
|
}
|
|
221
224
|
} catch (err) {
|
|
222
225
|
console.warn("ā ļø Failed to inject helper scripts into target package.json:", err.message);
|
|
@@ -229,7 +232,7 @@ if (scaffolded.gitignore.length > 0) {
|
|
|
229
232
|
console.log(`ā
Added ${scaffolded.gitignore.length} runtime state entries to .gitignore`);
|
|
230
233
|
}
|
|
231
234
|
|
|
232
|
-
console.log("\nš
|
|
235
|
+
console.log("\nš Agent orchestrator kit initialized.");
|
|
233
236
|
|
|
234
237
|
// 6. Generate Encoded Workspace Manifest (JULES_WEB_SETUP.md)
|
|
235
238
|
const agentState = {
|
|
@@ -272,13 +275,23 @@ ${payloadToken}
|
|
|
272
275
|
|
|
273
276
|
fs.writeFileSync(path.join(targetDir, ".agent", "JULES_WEB_SETUP.md"), setupMdContent, "utf-8");
|
|
274
277
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
278
|
+
// The encoded manifest is a Google Jules convenience ā it pastes the detected
|
|
279
|
+
// workspace into the Jules web UI. It is written unconditionally because it
|
|
280
|
+
// costs nothing and a repository can change provider later, but it is only
|
|
281
|
+
// *announced* when Jules is the provider this repo actually selected;
|
|
282
|
+
// otherwise it is one more Google-specific string in the way of someone who
|
|
283
|
+
// chose a different agent.
|
|
284
|
+
if (initPlan.provider === "jules") {
|
|
285
|
+
console.log("\nš Google Jules workspace manifest");
|
|
286
|
+
console.log(` š Paste into the Jules web UI: \x1b[36m${payloadToken}\x1b[0m`);
|
|
287
|
+
console.log(" (A copy was written to .agent/JULES_WEB_SETUP.md)");
|
|
288
|
+
} else {
|
|
289
|
+
console.log(`\n Provider: ${initPlan.provider}. A Google Jules workspace manifest was also written to .agent/JULES_WEB_SETUP.md if you ever switch.`);
|
|
290
|
+
}
|
|
279
291
|
console.log("\nNext Steps:");
|
|
280
|
-
console.log(" 1.
|
|
281
|
-
console.log(" 2.
|
|
282
|
-
console.log(" 3. Scaffold a
|
|
283
|
-
console.log(" 4. Dispatch the queue:
|
|
284
|
-
console.log(" 5. Run pre-merge
|
|
292
|
+
console.log(" 1. Pick a provider: agentctl providers (jules | claude-code | codex | gemini)");
|
|
293
|
+
console.log(" 2. Pick a gate depth: agentctl profile --list (minimal | standard | max)");
|
|
294
|
+
console.log(" 3. Scaffold a task: agentctl task create");
|
|
295
|
+
console.log(" 4. Dispatch the queue: agentctl queue");
|
|
296
|
+
console.log(" 5. Run the pre-merge gate: agentctl check\n");
|
|
297
|
+
console.log(" Not sure? Run `agentctl` with no arguments ā it reads the repo and names one step.\n");
|
package/index.mjs
CHANGED
|
@@ -36,6 +36,16 @@ export {
|
|
|
36
36
|
parseRetryAfter,
|
|
37
37
|
} from "./src/provider.mjs";
|
|
38
38
|
export { resolveRoutedProvider } from "./src/router.mjs";
|
|
39
|
+
export {
|
|
40
|
+
PROVIDER_DESCRIPTORS,
|
|
41
|
+
PROVIDER_PREFERENCE,
|
|
42
|
+
whichBinary,
|
|
43
|
+
probeProvider,
|
|
44
|
+
detectAvailableProviders,
|
|
45
|
+
suggestProvider,
|
|
46
|
+
} from "./src/provider-readiness.mjs";
|
|
47
|
+
export { ENV_ALIASES, applyEnvAliases, describeEnvVar } from "./src/env-aliases.mjs";
|
|
48
|
+
export { PROFILE_NAMES, PROFILE_DESCRIPTIONS, buildProfileStages, describeProfilePlan } from "./src/profiles.mjs";
|
|
39
49
|
export {
|
|
40
50
|
detectPolyglotStack,
|
|
41
51
|
resolveWorkspaceBoundary,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jules-orchestrator-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for Google Jules
|
|
3
|
+
"version": "0.54.0",
|
|
4
|
+
"description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for autonomous coding agents ā Google Jules, Claude Code, Codex and Gemini CLI.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/FullThrottle83/jules-orchestrator-kit.git"
|
|
@@ -63,6 +63,9 @@
|
|
|
63
63
|
"keywords": [
|
|
64
64
|
"jules",
|
|
65
65
|
"google-jules",
|
|
66
|
+
"claude-code",
|
|
67
|
+
"codex",
|
|
68
|
+
"gemini-cli",
|
|
66
69
|
"ai-agent",
|
|
67
70
|
"orchestrator",
|
|
68
71
|
"mcp",
|
package/scripts/jules-create.mjs
CHANGED
|
@@ -4,25 +4,14 @@
|
|
|
4
4
|
* Quick task file creator for .agent/jules-queue/.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import { getQueueDir } from "../src/state.mjs";
|
|
7
|
+
import { runTaskCreateWizard } from "../src/wizard-task.mjs";
|
|
10
8
|
|
|
11
|
-
const
|
|
12
|
-
const queueDir = getQueueDir();
|
|
13
|
-
const safeTitle = taskTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
14
|
-
const filepath = join(queueDir, `TASK-${Date.now()}-${safeTitle}.md`);
|
|
15
|
-
|
|
16
|
-
const boilerplate = `# ${taskTitle}
|
|
17
|
-
|
|
18
|
-
## Objective
|
|
19
|
-
[Describe task goal]
|
|
20
|
-
`;
|
|
9
|
+
const prompt = process.argv.slice(2).join(" ").trim();
|
|
21
10
|
|
|
22
11
|
try {
|
|
23
|
-
|
|
24
|
-
console.log(
|
|
12
|
+
const result = await runTaskCreateWizard(process.cwd(), prompt ? { prompt } : {});
|
|
13
|
+
console.log(`ā
Scaffolded task envelope: ${result.taskFile}`);
|
|
25
14
|
} catch (err) {
|
|
26
|
-
console.error(
|
|
15
|
+
console.error(`ā Failed to create task: ${err.message}`);
|
|
27
16
|
process.exit(1);
|
|
28
17
|
}
|
package/scripts/jules-patch.mjs
CHANGED
|
@@ -4,20 +4,28 @@
|
|
|
4
4
|
* Session patch fetching utility.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import { extractSessionPatch } from "../src/session-ops.mjs";
|
|
8
|
+
|
|
9
|
+
export async function fetchSessionPatch(sessionId, options = {}) {
|
|
8
10
|
if (!sessionId || typeof sessionId !== "string") {
|
|
9
11
|
throw new Error("Session ID is required.");
|
|
10
12
|
}
|
|
11
|
-
return
|
|
12
|
-
sessionId,
|
|
13
|
-
state: "COMPLETED",
|
|
14
|
-
title: "Patch Session",
|
|
15
|
-
hasPatch: false,
|
|
16
|
-
diff: "",
|
|
17
|
-
};
|
|
13
|
+
return await extractSessionPatch(sessionId, options);
|
|
18
14
|
}
|
|
19
15
|
|
|
20
16
|
if (process.argv[1] && process.argv[1].endsWith("jules-patch.mjs")) {
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
const sessionId = process.argv[2];
|
|
18
|
+
if (!sessionId) {
|
|
19
|
+
console.error("Usage: node scripts/jules-patch.mjs <sessionId>");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
fetchSessionPatch(sessionId)
|
|
23
|
+
.then((res) => {
|
|
24
|
+
console.log(`Patch fetch completed for ${sessionId}: ${res.patch ? "Patch found" : "No patch"}`);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
})
|
|
27
|
+
.catch((err) => {
|
|
28
|
+
console.error(`Patch fetch failed: ${err.message}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
|
23
31
|
}
|
|
@@ -8,7 +8,7 @@ const baseBranch = process.env.BASE_BRANCH || "main";
|
|
|
8
8
|
|
|
9
9
|
try {
|
|
10
10
|
const resolvedBase = resolveBase(root, baseBranch);
|
|
11
|
-
const behindStr = git(["rev-list", "--count",
|
|
11
|
+
const behindStr = git(["rev-list", "--count", `HEAD..${resolvedBase}`], { cwd: root, ignoreError: true });
|
|
12
12
|
const behindCount = parseInt(behindStr || "0", 10);
|
|
13
13
|
|
|
14
14
|
console.log(`[stale-base-check] Branch HEAD is ${behindCount} commits behind ${resolvedBase} (max allowed: ${maxBehind}).`);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate a CI job that runs the kit's gate, with the toolchain the detected
|
|
6
|
+
* stack actually needs.
|
|
7
|
+
*
|
|
8
|
+
* `init` used to copy this repository's *own* audit workflow into whatever
|
|
9
|
+
* repository it was scaffolding: a nine-way Node matrix that ran `npm install`,
|
|
10
|
+
* `npm test` and a script path that only exists here. Dropped into a Rust or
|
|
11
|
+
* Python repository it was red on the first push, for reasons that had nothing
|
|
12
|
+
* to do with that repository's code. A generated workflow is the only kind that
|
|
13
|
+
* can be correct in a repository the kit has never seen.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const CI_TARGETS = ["github", "gitlab"];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Toolchain setup per stack, expressed once and rendered per CI target.
|
|
20
|
+
*
|
|
21
|
+
* Node is installed unconditionally and is not in this table: `agentctl` is a
|
|
22
|
+
* Node CLI, so the runner needs it regardless of what the project is written
|
|
23
|
+
* in. These are the *additional* runtimes.
|
|
24
|
+
*
|
|
25
|
+
* Actions are referenced by major version tag rather than by commit SHA. A
|
|
26
|
+
* generated file is a starting point the repository owns from the moment it is
|
|
27
|
+
* written, and pinning a SHA the owner did not choose ages into a silent
|
|
28
|
+
* security debt they never agreed to ā the header tells them to pin.
|
|
29
|
+
*/
|
|
30
|
+
const STACK_TOOLCHAINS = {
|
|
31
|
+
python: { label: "Python 3.x", gh: [{ name: "Set up Python", uses: "actions/setup-python@v5", with: { "python-version": "3.x" } }], gl: "python:3" },
|
|
32
|
+
django: { label: "Python 3.x", gh: [{ name: "Set up Python", uses: "actions/setup-python@v5", with: { "python-version": "3.x" } }], gl: "python:3" },
|
|
33
|
+
go: { label: "Go (stable)", gh: [{ name: "Set up Go", uses: "actions/setup-go@v5", with: { "go-version": "stable" } }], gl: "golang:latest" },
|
|
34
|
+
cargo: { label: "Rust (preinstalled on hosted runners)", gh: [], gl: "rust:latest" },
|
|
35
|
+
maven: { label: "Temurin JDK 21", gh: [{ name: "Set up Java", uses: "actions/setup-java@v4", with: { distribution: "temurin", "java-version": "21" } }], gl: "maven:3-eclipse-temurin-21" },
|
|
36
|
+
gradle: { label: "Temurin JDK 21", gh: [{ name: "Set up Java", uses: "actions/setup-java@v4", with: { distribution: "temurin", "java-version": "21" } }], gl: "gradle:jdk21" },
|
|
37
|
+
dotnet: { label: ".NET 8", gh: [{ name: "Set up .NET", uses: "actions/setup-dotnet@v4", with: { "dotnet-version": "8.0.x" } }], gl: "mcr.microsoft.com/dotnet/sdk:8.0" },
|
|
38
|
+
php: { label: "PHP (preinstalled on hosted runners)", gh: [], gl: "php:8" },
|
|
39
|
+
laravel: { label: "PHP (preinstalled on hosted runners)", gh: [], gl: "php:8" },
|
|
40
|
+
wordpress: { label: "PHP (preinstalled on hosted runners)", gh: [], gl: "php:8" },
|
|
41
|
+
bundler: { label: "Ruby (preinstalled on hosted runners)", gh: [], gl: "ruby:3" },
|
|
42
|
+
mix: { label: "Elixir ā add your own setup step", gh: [], gl: "elixir:latest" },
|
|
43
|
+
bun: { label: "Bun", gh: [{ name: "Set up Bun", uses: "oven-sh/setup-bun@v2" }], gl: "oven/bun:latest" },
|
|
44
|
+
deno: { label: "Deno", gh: [{ name: "Set up Deno", uses: "denoland/setup-deno@v2", with: { "deno-version": "v2.x" } }], gl: "denoland/deno:latest" },
|
|
45
|
+
flutter: { label: "Flutter ā add your own setup step", gh: [], gl: "ghcr.io/cirruslabs/flutter:stable" },
|
|
46
|
+
dart: { label: "Dart ā add your own setup step", gh: [], gl: "dart:stable" },
|
|
47
|
+
swift: { label: "Swift ā add your own setup step", gh: [], gl: "swift:latest" },
|
|
48
|
+
cmake: { label: "CMake/CTest (preinstalled on hosted runners)", gh: [], gl: "gcc:latest" },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Renders one GitHub Actions step block at four-space indentation. */
|
|
52
|
+
function renderGhStep(step) {
|
|
53
|
+
const lines = [` - name: ${step.name}`, ` uses: ${step.uses}`];
|
|
54
|
+
if (step.with) {
|
|
55
|
+
lines.push(` with:`);
|
|
56
|
+
for (const [k, v] of Object.entries(step.with)) lines.push(` ${k}: "${v}"`);
|
|
57
|
+
}
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const HEADER = (stack, version) =>
|
|
62
|
+
`# Generated by jules-orchestrator-kit v${version} for a '${stack}' project.
|
|
63
|
+
#
|
|
64
|
+
# This file is yours now ā edit it freely. Two things worth doing before you
|
|
65
|
+
# rely on it:
|
|
66
|
+
# 1. Pin each third-party action to a commit SHA (a tag can be moved).
|
|
67
|
+
# 2. Check that the runtime versions below match what the project targets.
|
|
68
|
+
#
|
|
69
|
+
# Regenerate with: agentctl ci init --force
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {object} opts
|
|
74
|
+
* @param {string} opts.target - "github" | "gitlab"
|
|
75
|
+
* @param {{stack: string}} opts.stack - result of detectStack()
|
|
76
|
+
* @param {object} [opts.config] - loaded config, for base_branch
|
|
77
|
+
* @param {string} [opts.version]
|
|
78
|
+
* @returns {{ file: string, content: string, setupSummary: string }}
|
|
79
|
+
*/
|
|
80
|
+
export function buildCiWorkflow(opts = {}) {
|
|
81
|
+
const target = CI_TARGETS.includes(opts.target) ? opts.target : "github";
|
|
82
|
+
const stackId = opts.stack?.stack || "unknown";
|
|
83
|
+
const base = opts.config?.baseBranch || "main";
|
|
84
|
+
const version = opts.version || "0";
|
|
85
|
+
const toolchain = STACK_TOOLCHAINS[stackId] || null;
|
|
86
|
+
const setupSummary = toolchain ? toolchain.label : "Node only";
|
|
87
|
+
|
|
88
|
+
if (target === "gitlab") {
|
|
89
|
+
const image = toolchain?.gl || "node:20";
|
|
90
|
+
const needsNode = image !== "node:20" && !image.startsWith("node");
|
|
91
|
+
const content = `${HEADER(stackId, version)}
|
|
92
|
+
agent-gate:
|
|
93
|
+
image: ${image}
|
|
94
|
+
stage: test
|
|
95
|
+
variables:
|
|
96
|
+
GIT_DEPTH: "0"
|
|
97
|
+
rules:
|
|
98
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
99
|
+
- if: $CI_COMMIT_BRANCH == "${base}"
|
|
100
|
+
script:${
|
|
101
|
+
needsNode
|
|
102
|
+
? `
|
|
103
|
+
# agentctl is a Node CLI, so the image needs Node even when the project is not.
|
|
104
|
+
- command -v node >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq nodejs npm)`
|
|
105
|
+
: ""
|
|
106
|
+
}
|
|
107
|
+
- npx --yes jules-orchestrator-kit check --mode committed --base "origin/${base}"
|
|
108
|
+
`;
|
|
109
|
+
return { file: ".gitlab-ci.agent-gate.yml", content, setupSummary };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const extraSteps = (toolchain?.gh || []).map(renderGhStep).join("\n");
|
|
113
|
+
const content = `${HEADER(stackId, version)}
|
|
114
|
+
name: Agent Safety Gate
|
|
115
|
+
|
|
116
|
+
on:
|
|
117
|
+
pull_request:
|
|
118
|
+
branches: [ ${base} ]
|
|
119
|
+
push:
|
|
120
|
+
branches: [ ${base} ]
|
|
121
|
+
|
|
122
|
+
permissions:
|
|
123
|
+
contents: read
|
|
124
|
+
|
|
125
|
+
jobs:
|
|
126
|
+
gate:
|
|
127
|
+
runs-on: ubuntu-latest
|
|
128
|
+
timeout-minutes: 20
|
|
129
|
+
steps:
|
|
130
|
+
- name: Checkout repository
|
|
131
|
+
uses: actions/checkout@v4
|
|
132
|
+
with:
|
|
133
|
+
# The gate compares the branch against its merge base, which needs
|
|
134
|
+
# full history rather than the default single-commit fetch.
|
|
135
|
+
fetch-depth: 0
|
|
136
|
+
persist-credentials: false
|
|
137
|
+
|
|
138
|
+
# agentctl is a Node CLI: the runner needs Node whatever the project is
|
|
139
|
+
# written in.
|
|
140
|
+
- name: Set up Node.js
|
|
141
|
+
uses: actions/setup-node@v4
|
|
142
|
+
with:
|
|
143
|
+
node-version: "20"
|
|
144
|
+
${extraSteps ? extraSteps + "\n" : ""}
|
|
145
|
+
- name: Run agent safety gate
|
|
146
|
+
run: npx --yes jules-orchestrator-kit check --mode committed --base "origin/${base}"
|
|
147
|
+
`;
|
|
148
|
+
return { file: ".github/workflows/agent-gate.yml", content, setupSummary };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Write the generated workflow, refusing to clobber an existing file unless
|
|
153
|
+
* asked.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} root
|
|
156
|
+
* @param {object} [opts] - see {@link buildCiWorkflow}, plus `force` and `dryRun`
|
|
157
|
+
* @returns {{ ok: boolean, file?: string, written?: boolean, stack?: string, setupSummary?: string, content?: string, error?: string }}
|
|
158
|
+
*/
|
|
159
|
+
export function writeCiWorkflow(root, opts = {}) {
|
|
160
|
+
if (opts.target && !CI_TARGETS.includes(opts.target)) {
|
|
161
|
+
return { ok: false, error: `Unknown CI target '${opts.target}'. Choose one of: ${CI_TARGETS.join(", ")}` };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const built = buildCiWorkflow(opts);
|
|
165
|
+
const stackId = opts.stack?.stack || "unknown";
|
|
166
|
+
const abs = join(root, built.file);
|
|
167
|
+
|
|
168
|
+
if (opts.dryRun) {
|
|
169
|
+
return { ok: true, file: built.file, written: false, stack: stackId, setupSummary: built.setupSummary, content: built.content };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (existsSync(abs) && !opts.force) {
|
|
173
|
+
let identical = false;
|
|
174
|
+
try {
|
|
175
|
+
identical = readFileSync(abs, "utf-8") === built.content;
|
|
176
|
+
} catch (_) {
|
|
177
|
+
// Unreadable target: treat as different so the caller is told to --force
|
|
178
|
+
// rather than being told nothing changed.
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
file: built.file,
|
|
183
|
+
written: false,
|
|
184
|
+
identical,
|
|
185
|
+
stack: stackId,
|
|
186
|
+
setupSummary: built.setupSummary,
|
|
187
|
+
content: built.content,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
193
|
+
writeFileSync(abs, built.content, "utf-8");
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return { ok: false, error: `Could not write ${built.file}: ${err.message}` };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { ok: true, file: built.file, written: true, stack: stackId, setupSummary: built.setupSummary, content: built.content };
|
|
199
|
+
}
|