jules-orchestrator-kit 0.74.0 → 0.75.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Undo a default `agentctl init`: remove kit-owned config and the runtime
3
+ * `.gitignore` block. Optional `--force` also removes legacy scaffold dirs.
4
+ *
5
+ * See docs/uninstall.md — this deliberately does not delete AGENTS.md,
6
+ * SPEC.md, or other paths that may contain project-owned content.
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, rmdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import {
12
+ RUNTIME_GITIGNORE_HEADER,
13
+ stripRuntimeGitignoreBlock,
14
+ } from "./scaffold.mjs";
15
+
16
+ /** Legacy scaffold directories owned by `init --force` / older 0.x releases. */
17
+ export const FORCE_REMOVE_DIRS = [
18
+ ".agent/rules",
19
+ ".agent/prompts",
20
+ ".agent/workflows",
21
+ ".agent/jules-queue",
22
+ ];
23
+
24
+ /**
25
+ * @param {string} content
26
+ * @returns {boolean}
27
+ */
28
+ export function looksLikeKitConfig(content) {
29
+ const text = String(content);
30
+ if (text.includes("Agent Orchestrator Kit Config")) return true;
31
+ // Minimal shape init writes: a versioned manifest with a provider key.
32
+ return /^version:\s*\d+/m.test(text) && /^provider:\s*\S+/m.test(text);
33
+ }
34
+
35
+ /**
36
+ * @param {string} root
37
+ * @returns {{ installed: boolean, hasConfig: boolean, hasGitignoreBlock: boolean, configPath: string|null }}
38
+ */
39
+ export function detectKitInstallation(root) {
40
+ const configPath = join(root, ".agent", "config.yml");
41
+ let hasConfig = false;
42
+ if (existsSync(configPath)) {
43
+ try {
44
+ hasConfig = looksLikeKitConfig(readFileSync(configPath, "utf-8"));
45
+ } catch (_) {
46
+ hasConfig = false;
47
+ }
48
+ }
49
+
50
+ let hasGitignoreBlock = false;
51
+ const gitignorePath = join(root, ".gitignore");
52
+ if (existsSync(gitignorePath)) {
53
+ try {
54
+ hasGitignoreBlock = readFileSync(gitignorePath, "utf-8").includes(RUNTIME_GITIGNORE_HEADER);
55
+ } catch (_) {
56
+ hasGitignoreBlock = false;
57
+ }
58
+ }
59
+
60
+ return {
61
+ installed: hasConfig || hasGitignoreBlock,
62
+ hasConfig,
63
+ hasGitignoreBlock,
64
+ configPath: hasConfig ? ".agent/config.yml" : null,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * @param {string} root
70
+ * @param {{ force?: boolean }} [options]
71
+ * @returns {{
72
+ * ok: boolean,
73
+ * installed: boolean,
74
+ * willRemove: string[],
75
+ * willCleanGitignore: boolean,
76
+ * forceDirs: string[],
77
+ * error?: string,
78
+ * }}
79
+ */
80
+ export function planUninstall(root, options = {}) {
81
+ const force = Boolean(options.force);
82
+ const detection = detectKitInstallation(root);
83
+ if (!detection.installed) {
84
+ return {
85
+ ok: false,
86
+ installed: false,
87
+ willRemove: [],
88
+ willCleanGitignore: false,
89
+ forceDirs: [],
90
+ error: "No kit installation detected (missing .agent/config.yml and runtime gitignore block).",
91
+ };
92
+ }
93
+
94
+ const willRemove = [];
95
+ if (detection.hasConfig) willRemove.push(".agent/config.yml");
96
+
97
+ const forceDirs = [];
98
+ if (force) {
99
+ for (const rel of FORCE_REMOVE_DIRS) {
100
+ if (existsSync(join(root, rel))) forceDirs.push(rel);
101
+ }
102
+ willRemove.push(...forceDirs);
103
+ }
104
+
105
+ const willCleanGitignore = detection.hasGitignoreBlock;
106
+ if (willCleanGitignore) willRemove.push(".gitignore");
107
+
108
+ // `.agent/` itself is only removed when it would be empty after the planned
109
+ // deletions — reported so dry-run matches real execution.
110
+ const agentDir = join(root, ".agent");
111
+ if (existsSync(agentDir) && wouldAgentDirBeEmpty(root, {
112
+ removeConfig: detection.hasConfig,
113
+ forceDirs,
114
+ })) {
115
+ willRemove.push(".agent");
116
+ }
117
+
118
+ return {
119
+ ok: true,
120
+ installed: true,
121
+ willRemove: [...new Set(willRemove)],
122
+ willCleanGitignore,
123
+ forceDirs,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * @param {string} root
129
+ * @param {{ removeConfig: boolean, forceDirs: string[] }} planned
130
+ */
131
+ function wouldAgentDirBeEmpty(root, planned) {
132
+ const agentDir = join(root, ".agent");
133
+ if (!existsSync(agentDir)) return false;
134
+ let entries;
135
+ try {
136
+ entries = readdirSync(agentDir);
137
+ } catch (_) {
138
+ return false;
139
+ }
140
+ const removing = new Set();
141
+ if (planned.removeConfig) removing.add("config.yml");
142
+ for (const rel of planned.forceDirs) {
143
+ if (rel.startsWith(".agent/")) removing.add(rel.slice(".agent/".length).split("/")[0]);
144
+ }
145
+ return entries.every((name) => removing.has(name));
146
+ }
147
+
148
+ /**
149
+ * @param {string} root
150
+ * @param {{ force?: boolean, dryRun?: boolean }} [options]
151
+ * @returns {{
152
+ * ok: boolean,
153
+ * dryRun: boolean,
154
+ * installed: boolean,
155
+ * willRemove?: string[],
156
+ * removed?: string[],
157
+ * willCleanGitignore?: boolean,
158
+ * cleanedGitignore?: boolean,
159
+ * error?: string,
160
+ * }}
161
+ */
162
+ export function uninstallKit(root, options = {}) {
163
+ const dryRun = Boolean(options.dryRun);
164
+ const force = Boolean(options.force);
165
+ const plan = planUninstall(root, { force });
166
+ if (!plan.ok) {
167
+ return { ok: false, dryRun, installed: false, error: plan.error };
168
+ }
169
+
170
+ if (dryRun) {
171
+ return {
172
+ ok: true,
173
+ dryRun: true,
174
+ installed: true,
175
+ willRemove: plan.willRemove,
176
+ willCleanGitignore: plan.willCleanGitignore,
177
+ };
178
+ }
179
+
180
+ const removed = [];
181
+
182
+ const configAbs = join(root, ".agent", "config.yml");
183
+ if (plan.willRemove.includes(".agent/config.yml") && existsSync(configAbs)) {
184
+ rmSync(configAbs, { force: true });
185
+ removed.push(".agent/config.yml");
186
+ }
187
+
188
+ for (const rel of plan.forceDirs) {
189
+ const abs = join(root, rel);
190
+ if (existsSync(abs)) {
191
+ rmSync(abs, { recursive: true, force: true });
192
+ removed.push(rel);
193
+ }
194
+ }
195
+
196
+ let cleanedGitignore = false;
197
+ const gitignorePath = join(root, ".gitignore");
198
+ if (plan.willCleanGitignore && existsSync(gitignorePath)) {
199
+ const current = readFileSync(gitignorePath, "utf-8");
200
+ const { text, stripped } = stripRuntimeGitignoreBlock(current);
201
+ if (stripped) {
202
+ writeFileSync(gitignorePath, text, "utf-8");
203
+ cleanedGitignore = true;
204
+ removed.push(".gitignore");
205
+ }
206
+ }
207
+
208
+ const agentDir = join(root, ".agent");
209
+ if (existsSync(agentDir)) {
210
+ try {
211
+ if (readdirSync(agentDir).length === 0) {
212
+ rmdirSync(agentDir);
213
+ removed.push(".agent");
214
+ }
215
+ } catch (_) {
216
+ // Leave the directory if it is not empty or not removable.
217
+ }
218
+ }
219
+
220
+ return {
221
+ ok: true,
222
+ dryRun: false,
223
+ installed: true,
224
+ removed: [...new Set(removed)],
225
+ cleanedGitignore,
226
+ };
227
+ }
@@ -122,6 +122,17 @@ export function planInit(root = process.cwd(), options = {}) {
122
122
  // scaffolded limits always match what the runtime will later enforce.
123
123
  const tierPresetLimits = TIER_PROFILES[tierName] || TIER_PROFILES[FALLBACK_TIER];
124
124
 
125
+ // Same fail-closed rule as tier: an explicit `--profile` that is not one of
126
+ // PROFILE_NAMES must not silently become `standard`. Guessing wrong used to
127
+ // scaffold the everyday gate under a name the operator never chose.
128
+ if (options.profile && !PROFILE_NAMES.includes(String(options.profile).toLowerCase())) {
129
+ const rawProfile = String(options.profile).toLowerCase();
130
+ if (validTiers.includes(rawProfile)) {
131
+ throw new Error(`Invalid profile "${options.profile}". Valid profiles: ${PROFILE_NAMES.join(", ")}. Did you mean '--tier ${options.profile}'?`);
132
+ }
133
+ throw new Error(`Invalid profile "${options.profile}". Valid profiles: ${PROFILE_NAMES.join(", ")}.`);
134
+ }
135
+
125
136
  // Preserve existing config if present (ignored when pristine requested)
126
137
  let existingConfig = {};
127
138
  if (!options.pristine) {
@@ -1,5 +1,5 @@
1
1
  import { writeFileSync, mkdirSync, existsSync } from "node:fs";
2
- import { resolve, join } from "node:path";
2
+ import { resolve } from "node:path";
3
3
  import { untrackedOnboardingArtifacts } from "./git.mjs";
4
4
  import { loadConfig } from "./config.mjs";
5
5
  import { gate } from "./engine.mjs";
@@ -220,33 +220,22 @@ export function planTaskCreate(root = process.cwd(), inputObj = {}) {
220
220
  const rawId = inputObj.id || `TASK-${Date.now().toString(36).toUpperCase()}`;
221
221
  const taskId = String(rawId).replace(/[^a-zA-Z0-9_-]/g, "_");
222
222
 
223
- const hasAstro = existsSync(join(root, "astro.config.mjs")) || existsSync(join(root, "astro.config.ts"));
224
- const hasWrangler =
225
- existsSync(join(root, "wrangler.jsonc")) ||
226
- existsSync(join(root, "wrangler.json")) ||
227
- existsSync(join(root, "wrangler.toml"));
228
-
223
+ const configInvariants = Array.isArray(config.invariants) ? config.invariants : [];
229
224
  const autoInvariants = [
230
225
  "Preserve existing signatures, surrounding logic, and coding style.",
231
226
  "Do not modify files outside scope.allow.",
227
+ ...configInvariants,
232
228
  ];
233
- if (hasAstro) {
234
- autoInvariants.push("Zero-JS runtime-princip: skicka noll onödig klient-JS i src/ och använd plana <img> framför Astro <Image>.");
235
- }
236
- if (hasWrangler) {
237
- autoInvariants.push("Edge runtime constraints: inga otillåtna Node-imports (t.ex. node:fs, node:path, sharp) i workerd runtime-kod.");
238
- }
239
229
  const invariants = Array.isArray(inputObj.invariants) && inputObj.invariants.length > 0
240
230
  ? inputObj.invariants
241
231
  : autoInvariants;
242
232
 
243
- const autoMcpDirectives = [];
244
- if (hasAstro) autoMcpDirectives.push("astro-docs");
245
- if (hasWrangler) autoMcpDirectives.push("cloudflare-docs");
246
- if (existsSync(join(root, "emdash.config.ts"))) autoMcpDirectives.push("emdash-docs");
247
- if (autoMcpDirectives.length > 0) autoMcpDirectives.push("context7");
248
-
249
- const mcpDirectives = inputObj.mcpDirectives || inputObj.mcp_directives || (autoMcpDirectives.length > 0 ? autoMcpDirectives : undefined);
233
+ const configDirectives = Array.isArray(config.mcp_directives)
234
+ ? config.mcp_directives
235
+ : Array.isArray(config.mcpDirectives)
236
+ ? config.mcpDirectives
237
+ : [];
238
+ const mcpDirectives = inputObj.mcpDirectives || inputObj.mcp_directives || (configDirectives.length > 0 ? configDirectives : undefined);
250
239
  const mcpDirectiveLine = mcpDirectives && mcpDirectives.length > 0
251
240
  ? `MCP DIRECTIVE: Mandating pre-execution documentation lookup via [${mcpDirectives.join(" | ")}] before modifying code.\n\n`
252
241
  : "";
@@ -476,7 +465,7 @@ export async function runTaskCreateWizard(root = process.cwd(), options = {}) {
476
465
  });
477
466
 
478
467
  // Perform Gate Preflight
479
- const gateRes = await gate({ root, mode: "working-tree" });
468
+ const gateRes = await gate({ root, mode: "working-tree", allowProtected: Boolean(options.allowProtected) });
480
469
  if (!gateRes.ok && (gateRes.code === 3 || gateRes.code === 6) && !options.allowGateFailure) {
481
470
  const scopeViolations = gateRes.phases[0]?.violations || [];
482
471
  // A cold-start repository has nothing committed yet: every path init just
@@ -491,7 +480,13 @@ export async function runTaskCreateWizard(root = process.cwd(), options = {}) {
491
480
  const realViolations = scopeViolations.filter((v) => !v.file.startsWith(".agent/")) || [];
492
481
  const installArtifacts = new Set(untrackedOnboardingArtifacts(root, scopeViolations.map((v) => v.file)));
493
482
  const blockers = realViolations.filter((v) => !installArtifacts.has(v.file));
494
- if (blockers.length > 0 || gateRes.code === 6) {
483
+ if (blockers.length > 0) {
484
+ const fileList = blockers.map((b) => b.file).join(", ");
485
+ throw new Error(
486
+ `Gate Preflight Rejected Task: Repository contains scope violations in protected/denied paths: ${fileList} (Exit 3).\n To permit protected paths for this task, re-run with --allow-protected.`
487
+ );
488
+ }
489
+ if (gateRes.code === 6) {
495
490
  throw new Error(`Gate Preflight Rejected Task: Repository contains scope or secret violations (Exit ${gateRes.code}).`);
496
491
  }
497
492
  }