jules-orchestrator-kit 0.74.0 → 0.75.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.
@@ -10,7 +10,7 @@ import { existsSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { gate } from "./engine.mjs";
12
12
  import { loadConfig, parseYaml } from "./config.mjs";
13
- import { matchesGlob } from "./security.mjs";
13
+ import { matchesGlob, TAMPER_KIND_NAMES } from "./security.mjs";
14
14
 
15
15
  /** Convenience wrapper kept for the historical `matchGlob` name. */
16
16
  export function matchGlob(filepath, globPattern) {
@@ -85,6 +85,202 @@ export function auditWorktrees(opts = {}) {
85
85
  return { ok: true, worktreeDir, exists };
86
86
  }
87
87
 
88
+ /**
89
+ * Parses the labels payload GitHub Actions exposes for a pull request.
90
+ * Accepts an array, raw `toJSON(...labels)` JSON string, or a plain comma-separated string.
91
+ *
92
+ * @param {string|Array} raw
93
+ * @returns {string[]}
94
+ */
95
+ export function parseLabels(raw = "") {
96
+ if (Array.isArray(raw)) {
97
+ return raw.map((l) => (typeof l === "string" ? l : l && l.name) || "").filter(Boolean);
98
+ }
99
+ const text = String(raw || "").trim();
100
+ if (!text) return [];
101
+ try {
102
+ const parsed = JSON.parse(text);
103
+ if (Array.isArray(parsed)) {
104
+ return parsed.map((l) => (typeof l === "string" ? l : l && l.name) || "").filter(Boolean);
105
+ }
106
+ } catch (_) {}
107
+ return text.split(",").map((s) => s.trim()).filter(Boolean);
108
+ }
109
+
110
+ /**
111
+ * Resolves tamper guard waivers from PR labels bound to the exact head SHA.
112
+ *
113
+ * Test expectation waivers MUST be explicitly bound to the reviewed commit SHA
114
+ * (e.g. `allow-e:<40-hex-SHA>` or legacy `allow-test-expectation:<SHA>`).
115
+ * Short label `allow-e:<40-hex-SHA>` is 48 characters, fitting GitHub's 50-character limit.
116
+ * Unbound labels (without SHA) or stale labels (SHA mismatch) are rejected.
117
+ *
118
+ * @param {string|string[]} rawLabels - Array of labels, JSON string, or comma-separated string
119
+ * @param {string} headSha - Full 40-character commit SHA of the current PR head
120
+ * @returns {{ allowedKinds: string[], rejected: Array<{ label: string, reason: string }> }}
121
+ */
122
+ export function resolveTestWaiverFromLabels(rawLabels = "", headSha = "") {
123
+ const labels = parseLabels(rawLabels);
124
+ const allowedKinds = new Set();
125
+ const rejected = [];
126
+ const normalizedHead = String(headSha || "").trim().toLowerCase();
127
+
128
+ for (const label of labels) {
129
+ const lower = label.trim().toLowerCase();
130
+
131
+ if (!lower.startsWith("allow-test-") && !lower.startsWith("allow-e:") && lower !== "allow-e") {
132
+ continue;
133
+ }
134
+
135
+ const parts = lower.split(":");
136
+ let kind = null;
137
+ let targetSha = null;
138
+
139
+ if (parts[0] === "allow-e") {
140
+ kind = "expectation";
141
+ if (parts.length > 1) {
142
+ targetSha = parts.slice(1).join(":");
143
+ }
144
+ } else if (parts[0] === "allow-test-expectation") {
145
+ kind = "expectation";
146
+ if (parts.length > 1) {
147
+ targetSha = parts.slice(1).join(":");
148
+ }
149
+ } else if (parts[0] === "allow-test-change" || parts[0] === "allow-test-changes") {
150
+ if (parts.length === 2) {
151
+ if (TAMPER_KIND_NAMES.includes(parts[1])) {
152
+ kind = parts[1];
153
+ } else {
154
+ kind = "expectation";
155
+ targetSha = parts[1];
156
+ }
157
+ } else if (parts.length >= 3) {
158
+ kind = parts[1];
159
+ targetSha = parts.slice(2).join(":");
160
+ } else {
161
+ kind = "expectation";
162
+ }
163
+ } else {
164
+ continue;
165
+ }
166
+
167
+ if (!targetSha) {
168
+ const canonicalFmt = parts[0] === "allow-e" || parts[0] === "allow-test-expectation"
169
+ ? "allow-e:<40-hex-SHA>"
170
+ : `${parts[0]}:<40-hex-SHA>`;
171
+ rejected.push({
172
+ label,
173
+ reason: `Unbound waiver: label has no commit SHA. Must be formatted as "${canonicalFmt}".`,
174
+ });
175
+ continue;
176
+ }
177
+
178
+ if (!/^[0-9a-f]{40}$/i.test(targetSha)) {
179
+ rejected.push({
180
+ label,
181
+ reason: `Invalid commit SHA "${targetSha}": must be exact 40 hexadecimal characters. Prefix matching is not permitted.`,
182
+ });
183
+ continue;
184
+ }
185
+
186
+ if (!normalizedHead || !/^[0-9a-f]{40}$/i.test(normalizedHead)) {
187
+ rejected.push({
188
+ label,
189
+ reason: `Cannot verify binding: HEAD_SHA "${headSha}" is missing or not a valid 40-character commit SHA.`,
190
+ });
191
+ continue;
192
+ }
193
+
194
+ if (targetSha.toLowerCase() !== normalizedHead) {
195
+ rejected.push({
196
+ label,
197
+ reason: `Stale waiver: label SHA "${targetSha}" does not match current HEAD SHA "${normalizedHead}".`,
198
+ });
199
+ continue;
200
+ }
201
+
202
+ if (TAMPER_KIND_NAMES.includes(kind)) {
203
+ allowedKinds.add(kind);
204
+ } else {
205
+ rejected.push({
206
+ label,
207
+ reason: `Unknown tamper kind "${kind}". Valid kinds: ${TAMPER_KIND_NAMES.join(", ")}`,
208
+ });
209
+ }
210
+ }
211
+
212
+ return {
213
+ allowedKinds: Array.from(allowedKinds),
214
+ rejected,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Resolves protected-path bypass waivers from PR labels bound to the exact head SHA.
220
+ *
221
+ * Protected path waivers MUST be explicitly bound to the reviewed commit SHA
222
+ * (e.g. `allow-p:<40-hex-SHA>` or legacy `allow-protected-paths:<40-hex-SHA>`).
223
+ * Short label `allow-p:<40-hex-SHA>` is 48 characters, fitting GitHub's 50-character limit.
224
+ * Unbound labels (without SHA), prefix matching, or stale labels (SHA mismatch) are rejected.
225
+ *
226
+ * @param {string|string[]} rawLabels - Array of labels, JSON string, or comma-separated string
227
+ * @param {string} headSha - Full 40-character commit SHA of the current PR head
228
+ * @returns {{ ok: boolean, boundSha?: string, rejected: Array<{ label: string, reason: string }> }}
229
+ */
230
+ export function resolveProtectedWaiverFromLabels(rawLabels = "", headSha = "") {
231
+ const labels = parseLabels(rawLabels);
232
+ const normalizedHead = String(headSha || "").trim().toLowerCase();
233
+ const rejected = [];
234
+ let foundValid = false;
235
+
236
+ for (const label of labels) {
237
+ const lower = label.trim().toLowerCase();
238
+ if (!lower.startsWith("allow-protected-paths") && !lower.startsWith("allow-p:") && lower !== "allow-p") continue;
239
+
240
+ const parts = lower.split(":");
241
+ if (parts.length === 1) {
242
+ rejected.push({
243
+ label,
244
+ reason: 'Unbound waiver: label has no commit SHA. Must be formatted as "allow-p:<40-hex-SHA>".',
245
+ });
246
+ continue;
247
+ }
248
+
249
+ const targetSha = parts.slice(1).join(":");
250
+ if (!/^[0-9a-f]{40}$/i.test(targetSha)) {
251
+ rejected.push({
252
+ label,
253
+ reason: `Invalid commit SHA "${targetSha}": must be exact 40 hexadecimal characters. Prefix matching is not permitted.`,
254
+ });
255
+ continue;
256
+ }
257
+
258
+ if (!normalizedHead || !/^[0-9a-f]{40}$/i.test(normalizedHead)) {
259
+ rejected.push({
260
+ label,
261
+ reason: `Cannot verify binding: HEAD_SHA "${headSha}" is missing or not a valid 40-character commit SHA.`,
262
+ });
263
+ continue;
264
+ }
265
+
266
+ if (targetSha.toLowerCase() !== normalizedHead) {
267
+ rejected.push({
268
+ label,
269
+ reason: `Stale waiver: label SHA "${targetSha}" does not match current HEAD SHA "${normalizedHead}".`,
270
+ });
271
+ continue;
272
+ }
273
+
274
+ foundValid = true;
275
+ }
276
+
277
+ if (foundValid) {
278
+ return { ok: true, boundSha: normalizedHead, rejected };
279
+ }
280
+
281
+ return { ok: false, rejected };
282
+ }
283
+
88
284
  /**
89
285
  * Audit gate rules (scope, payload governor, secret scanning, verification suite).
90
286
  */
@@ -93,13 +289,42 @@ export async function auditGates(opts = {}) {
93
289
  const config = opts.config || loadConfig(root);
94
290
  const base = opts.base || process.env.BASE_BRANCH || config.baseBranch || "main";
95
291
 
292
+ let allowProtected =
293
+ opts.allowProtected ??
294
+ (process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "true" ||
295
+ process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "1");
296
+
297
+ const rawLabels = opts.prLabels || process.env.PR_LABELS;
298
+ const headSha = opts.headSha || process.env.HEAD_SHA;
299
+
300
+ if (!allowProtected && rawLabels) {
301
+ const waiver = resolveProtectedWaiverFromLabels(rawLabels, headSha);
302
+ if (waiver.ok) {
303
+ allowProtected = true;
304
+ }
305
+ for (const rej of waiver.rejected || []) {
306
+ console.warn(`::warning::[scope-waiver] ${rej.label}: ${rej.reason}`);
307
+ }
308
+ }
309
+
310
+ let allowTestChanges = opts.allowTestChanges || process.env.JULES_ALLOW_TEST_CHANGES;
311
+ if (!allowTestChanges && rawLabels) {
312
+ const waiver = resolveTestWaiverFromLabels(rawLabels, headSha);
313
+ if (waiver.allowedKinds.length > 0) {
314
+ allowTestChanges = waiver.allowedKinds.join(",");
315
+ }
316
+ for (const rej of waiver.rejected) {
317
+ console.warn(`::warning::[tamper-waiver] ${rej.label}: ${rej.reason}`);
318
+ }
319
+ }
320
+
96
321
  return await gate({
97
322
  root,
98
323
  config,
99
324
  base,
100
325
  fix: process.env.ALLOW_AUTO_REPAIR === "true",
101
- allowProtected: process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "true",
102
- allowTestChanges: process.env.JULES_ALLOW_TEST_CHANGES || opts.allowTestChanges,
326
+ allowProtected,
327
+ allowTestChanges,
103
328
  });
104
329
  }
105
330
 
@@ -118,6 +343,15 @@ export async function runSelfAudit(opts = {}) {
118
343
  const gatesResult = await auditGates(optsWithContext);
119
344
 
120
345
  if (!gatesResult.ok) {
346
+ // The CLI's exit-6 banner intentionally does not print diff contents.
347
+ // Surface safe finding coordinates so a legitimate change can be repaired
348
+ // without disabling the independent audit or guessing at the failure.
349
+ if (gatesResult.code === 6) {
350
+ const findings = gatesResult.phases?.find((phase) => phase.phase === "secrets")?.findings || [];
351
+ for (const finding of findings) {
352
+ console.error(`Self-audit finding: ${finding.type || "UNKNOWN"} at ${finding.file || "unattributed"}${finding.line ? `:${finding.line}` : ""}`);
353
+ }
354
+ }
121
355
  return gatesResult;
122
356
  }
123
357
 
package/src/state.mjs CHANGED
@@ -667,12 +667,17 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
667
667
  if (requested.size > 0) {
668
668
  for (const held of lockStatus(root)) {
669
669
  if (!held || held.taskId === taskId) continue;
670
- if (!isLockLive(held)) continue;
671
670
 
672
671
  const overlap = (Array.isArray(held.files) ? held.files : [])
673
672
  .map((f) => normalizePath(f))
674
673
  .filter((f) => requested.has(f));
675
674
  if (overlap.length > 0) {
675
+ if (!isLockLive(held)) {
676
+ if (held.taskId) {
677
+ try { unlinkSync(join(lockDir, `${held.taskId}.json`)); } catch (_) {}
678
+ }
679
+ continue;
680
+ }
676
681
  return {
677
682
  ok: false,
678
683
  holder: held.agent,
@@ -723,6 +728,8 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
723
728
  const existing = JSON.parse(readFileSync(lockFile, "utf-8"));
724
729
  return { ok: false, holder: existing.agent, taskId };
725
730
  } catch (_) {
731
+ // Corrupted or 0-byte existing file: unlink so the task does not stay wedged.
732
+ try { unlinkSync(lockFile); } catch (_) {}
726
733
  return { ok: false, holder: "unknown", taskId };
727
734
  }
728
735
  }
@@ -754,8 +761,9 @@ export function releaseLock(taskId, rootOrOpts = resolveRoot()) {
754
761
  return false;
755
762
  }
756
763
 
757
- export function lockStatus(rootOrOpts = resolveRoot()) {
758
- const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
764
+ export function lockStatus(rootOrOpts = resolveRoot(), opts = {}) {
765
+ const root = typeof rootOrOpts === "string" ? rootOrOpts : (rootOrOpts?.root || resolveRoot());
766
+ const activeOnly = opts?.activeOnly === true;
759
767
  const lockDir = getLockDir(root);
760
768
  const locks = [];
761
769
  try {
@@ -764,7 +772,10 @@ export function lockStatus(rootOrOpts = resolveRoot()) {
764
772
  if (file.endsWith(".json")) {
765
773
  try {
766
774
  const content = readFileSync(join(lockDir, file), "utf-8");
767
- locks.push(Object.freeze(JSON.parse(content)));
775
+ const record = JSON.parse(content);
776
+ const live = isLockLive(record);
777
+ if (activeOnly && !live) continue;
778
+ locks.push(Object.freeze({ ...record, live }));
768
779
  } catch (_) {}
769
780
  }
770
781
  }
@@ -788,6 +799,7 @@ export function isConcurrencyGroupLocked(groupName, rootOrOpts = resolveRoot(),
788
799
  const locks = lockStatus(rootOrOpts);
789
800
  for (const lock of locks) {
790
801
  if (excludeTaskId && lock.taskId === excludeTaskId) continue;
802
+ if (!isLockLive(lock)) continue;
791
803
  const lockGroup = String(lock.concurrencyGroup || lock.concurrency_group || "").trim().toLowerCase();
792
804
  if (lockGroup === targetGroup) {
793
805
  return true;
@@ -796,4 +808,88 @@ export function isConcurrencyGroupLocked(groupName, rootOrOpts = resolveRoot(),
796
808
  return false;
797
809
  }
798
810
 
811
+ /**
812
+ * Scans .agent/state/locks/ for stale, expired, or corrupted lock records.
813
+ *
814
+ * Supported queue/process model:
815
+ * - Leased locks: reaped if Date.now() >= lockExpiry(record).
816
+ * - Process-bound locks: reaped if owner PID is dead (or recycled with different start time).
817
+ * - Corrupted files: 0-byte or unparseable JSON files reaped immediately.
818
+ * - Live locks: left completely untouched.
819
+ *
820
+ * @param {string|Object} [rootOrOpts=resolveRoot()]
821
+ * @param {Object} [opts]
822
+ * @param {boolean} [opts.dryRun=false] - When true, reports stale locks without deleting them.
823
+ * @returns {{ reapedCount: number, reaped: Array<{ file: string, taskId: string, agent: string, pid: number|null, reason: "corrupted"|"expired"|"dead-pid" }> }}
824
+ */
825
+ export function reapStaleLocks(rootOrOpts = resolveRoot(), { dryRun = false } = {}) {
826
+ const root = typeof rootOrOpts === "string" ? rootOrOpts : (rootOrOpts?.root || resolveRoot());
827
+ const lockDir = getLockDir(root);
828
+
829
+ if (!existsSync(lockDir)) {
830
+ return { reapedCount: 0, reaped: [] };
831
+ }
832
+
833
+ const reaped = [];
834
+ let files = [];
835
+ try {
836
+ files = readdirSync(lockDir);
837
+ } catch (_) {
838
+ return { reapedCount: 0, reaped: [] };
839
+ }
840
+
841
+ for (const file of files) {
842
+ if (!file.endsWith(".json")) continue;
843
+ const lockPath = join(lockDir, file);
844
+ let record = null;
845
+ let corrupted = false;
846
+
847
+ try {
848
+ const content = readFileSync(lockPath, "utf-8");
849
+ if (!content || !content.trim()) {
850
+ corrupted = true;
851
+ } else {
852
+ record = JSON.parse(content);
853
+ }
854
+ } catch (_) {
855
+ corrupted = true;
856
+ }
857
+
858
+ let shouldReap = false;
859
+ let reason = "";
860
+
861
+ if (corrupted) {
862
+ shouldReap = true;
863
+ reason = "corrupted";
864
+ } else if (!isLockLive(record)) {
865
+ shouldReap = true;
866
+ if (record.leased === true) {
867
+ reason = "expired";
868
+ } else if (record.expiresAt && Date.now() >= lockExpiry(record)) {
869
+ reason = "expired";
870
+ } else {
871
+ reason = "dead-pid";
872
+ }
873
+ }
874
+
875
+ if (shouldReap) {
876
+ if (!dryRun) {
877
+ try {
878
+ unlinkSync(lockPath);
879
+ } catch (_) {}
880
+ }
881
+ reaped.push({
882
+ file,
883
+ taskId: record?.taskId || file.replace(/\.json$/, ""),
884
+ agent: record?.agent || "unknown",
885
+ pid: record?.pid ?? null,
886
+ reason,
887
+ });
888
+ }
889
+ }
890
+
891
+ return { reapedCount: reaped.length, reaped };
892
+ }
893
+
894
+
799
895
 
@@ -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) {
@@ -476,7 +476,7 @@ export async function runTaskCreateWizard(root = process.cwd(), options = {}) {
476
476
  });
477
477
 
478
478
  // Perform Gate Preflight
479
- const gateRes = await gate({ root, mode: "working-tree" });
479
+ const gateRes = await gate({ root, mode: "working-tree", allowProtected: Boolean(options.allowProtected) });
480
480
  if (!gateRes.ok && (gateRes.code === 3 || gateRes.code === 6) && !options.allowGateFailure) {
481
481
  const scopeViolations = gateRes.phases[0]?.violations || [];
482
482
  // A cold-start repository has nothing committed yet: every path init just
@@ -491,7 +491,13 @@ export async function runTaskCreateWizard(root = process.cwd(), options = {}) {
491
491
  const realViolations = scopeViolations.filter((v) => !v.file.startsWith(".agent/")) || [];
492
492
  const installArtifacts = new Set(untrackedOnboardingArtifacts(root, scopeViolations.map((v) => v.file)));
493
493
  const blockers = realViolations.filter((v) => !installArtifacts.has(v.file));
494
- if (blockers.length > 0 || gateRes.code === 6) {
494
+ if (blockers.length > 0) {
495
+ const fileList = blockers.map((b) => b.file).join(", ");
496
+ throw new Error(
497
+ `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.`
498
+ );
499
+ }
500
+ if (gateRes.code === 6) {
495
501
  throw new Error(`Gate Preflight Rejected Task: Repository contains scope or secret violations (Exit ${gateRes.code}).`);
496
502
  }
497
503
  }