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.
Files changed (43) hide show
  1. package/README.md +68 -8
  2. package/bin/agentctl.mjs +327 -24
  3. package/bin/init.js +87 -74
  4. package/index.mjs +10 -0
  5. package/package.json +5 -2
  6. package/scripts/jules-create.mjs +5 -16
  7. package/scripts/jules-patch.mjs +18 -10
  8. package/scripts/stale-base-check.mjs +1 -1
  9. package/src/ci-templates.mjs +199 -0
  10. package/src/config-edit.mjs +138 -0
  11. package/src/config.mjs +39 -11
  12. package/src/engine.mjs +2 -21
  13. package/src/env-aliases.mjs +80 -0
  14. package/src/git.mjs +79 -0
  15. package/src/mcp.mjs +19 -1
  16. package/src/ops/cli-intent.mjs +29 -0
  17. package/src/ops/command-registry.mjs +19 -10
  18. package/src/ops/doctor-registry.mjs +75 -32
  19. package/src/ops/ide-scaffold.mjs +22 -16
  20. package/src/ops/next-step.mjs +38 -7
  21. package/src/ops/verify-output.mjs +38 -0
  22. package/src/profiles.mjs +188 -0
  23. package/src/provider-readiness.mjs +284 -0
  24. package/src/security.mjs +42 -1
  25. package/src/tui.mjs +14 -3
  26. package/src/wizard-init.mjs +88 -12
  27. package/src/wizard-task.mjs +22 -5
  28. package/src/ops/doctor-planner.mjs +0 -220
  29. package/src/ops/receipts.mjs +0 -171
  30. package/src/ops/swarm-actions.mjs +0 -156
  31. package/src/ops/task-actions.mjs +0 -214
  32. package/src/ops/transaction.mjs +0 -278
  33. package/src/ux/capabilities.mjs +0 -180
  34. package/src/ux/diff-viewer.mjs +0 -289
  35. package/src/ux/layout.mjs +0 -195
  36. package/src/ux/log-viewer.mjs +0 -110
  37. package/src/ux/palette.mjs +0 -179
  38. package/src/ux/queue-model.mjs +0 -206
  39. package/src/ux/renderer.mjs +0 -188
  40. package/src/ux/swarm-model.mjs +0 -146
  41. package/src/ux/terminal-session.mjs +0 -215
  42. package/src/ux/widgets.mjs +0 -323
  43. /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 Google Jules Orchestration Kit...\n");
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 .agent/jules.yml
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
- const yamlContent = `# Google Jules Repository Configuration (Version 2)
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
- // Scaffold .github/workflows/jules-audit.yml
146
- const githubWorkflowsDir = path.join(targetDir, ".github/workflows");
147
- const auditWfSource = path.join(kitRoot, ".github/workflows/jules-audit.yml");
148
- const auditWfTarget = path.join(githubWorkflowsDir, "jules-audit.yml");
149
-
150
- if (fs.existsSync(auditWfSource)) {
151
- if (!fs.existsSync(githubWorkflowsDir)) {
152
- fs.mkdirSync(githubWorkflowsDir, { recursive: true });
153
- }
154
- if (!fs.existsSync(auditWfTarget) || isForce) {
155
- fs.copyFileSync(auditWfSource, auditWfTarget);
156
- console.log("āœ… Added GitHub Actions workflow: .github/workflows/jules-audit.yml");
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. Copy scripts/ directory with PER-FILE existence guard
161
- const targetScriptsDir = path.join(targetDir, "scripts");
162
- if (!fs.existsSync(targetScriptsDir)) {
163
- fs.mkdirSync(targetScriptsDir, { recursive: true });
164
- }
165
-
166
- const sourceScriptsDir = path.join(kitRoot, "scripts");
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
- const julesScripts = {
200
- "jules:dispatch": "agentctl dispatch",
201
- "jules:queue": "agentctl queue",
202
- "jules:create": "agentctl task create",
203
- "jules:status": "agentctl status",
204
- "jules:audit": "agentctl gate",
205
- "jules:scan": "agentctl scan",
206
- "jules:swarm": "agentctl swarm",
207
- "jules:nightly": "agentctl clean"
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(julesScripts)) {
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 Jules commands to package.json");
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šŸŽ‰ Google Jules Orchestration Kit successfully initialized!");
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
- console.log("\nšŸ”— Google Jules Setup Complete");
276
- console.log(" Your local agent configurations are ready.");
277
- console.log(` šŸ‘‰ Encoded Workspace Manifest: \x1b[36m${payloadToken}\x1b[0m`);
278
- console.log("\n (A backup was written to .agent/JULES_WEB_SETUP.md)");
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. See official documentation at https://jules.google");
281
- console.log(" 2. Set environment variables: JULES_REPO=\"owner/repo\"");
282
- console.log(" 3. Scaffold a new task: npm run jules:create \"My Feature\"");
283
- console.log(" 4. Dispatch the queue: npm run jules:queue");
284
- console.log(" 5. Run pre-merge audit: npm run jules:audit\n");
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.52.8",
4
- "description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for Google Jules (jules) autonomous agents.",
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",
@@ -4,25 +4,14 @@
4
4
  * Quick task file creator for .agent/jules-queue/.
5
5
  */
6
6
 
7
- import { writeFileSync } from "node:fs";
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 taskTitle = process.argv[2] || "New Task";
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
- writeFileSync(filepath, boilerplate, "utf-8");
24
- console.log(`[Shim] Scaffolded new task: ${filepath}`);
12
+ const result = await runTaskCreateWizard(process.cwd(), prompt ? { prompt } : {});
13
+ console.log(`āœ… Scaffolded task envelope: ${result.taskFile}`);
25
14
  } catch (err) {
26
- console.error(`[Shim] Failed to create task: ${err.message}`);
15
+ console.error(`āŒ Failed to create task: ${err.message}`);
27
16
  process.exit(1);
28
17
  }
@@ -4,20 +4,28 @@
4
4
  * Session patch fetching utility.
5
5
  */
6
6
 
7
- export async function fetchSessionPatch(sessionId, _options = {}) {
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
- console.log("Patch fetch completed.");
22
- process.exit(0);
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", `${resolvedBase}..HEAD`], { cwd: root, ignoreError: true });
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
+ }