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.
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/bin/agentctl.mjs +372 -20
- package/package.json +1 -1
- package/scripts/ci-scope-guard.mjs +30 -7
- package/src/bidi-guard.mjs +190 -15
- package/src/config.mjs +40 -1
- package/src/engine.mjs +184 -47
- package/src/git.mjs +15 -0
- package/src/migrate.mjs +493 -0
- package/src/ops/command-registry.mjs +91 -4
- package/src/ops/doctor-registry.mjs +184 -32
- package/src/ops/pr-harvest.mjs +22 -2
- package/src/ops/toolchain-diagnostics.mjs +253 -0
- package/src/ops/tui-menu.mjs +192 -0
- package/src/process-tree.mjs +146 -0
- package/src/scaffold.mjs +49 -1
- package/src/scope-guard.mjs +73 -5
- package/src/secret-scanner.mjs +1 -1
- package/src/security.mjs +37 -30
- package/src/self-audit.mjs +237 -3
- package/src/state.mjs +100 -4
- package/src/uninstall.mjs +227 -0
- package/src/wizard-init.mjs +11 -0
- package/src/wizard-task.mjs +8 -2
package/src/self-audit.mjs
CHANGED
|
@@ -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
|
|
102
|
-
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
|
-
|
|
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
|
+
}
|
package/src/wizard-init.mjs
CHANGED
|
@@ -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) {
|
package/src/wizard-task.mjs
CHANGED
|
@@ -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
|
|
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
|
}
|