jules-orchestrator-kit 0.29.1 → 0.32.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 (46) hide show
  1. package/README.md +169 -86
  2. package/bin/agentctl.mjs +267 -4
  3. package/bin/mcp-server.mjs +0 -0
  4. package/index.mjs +21 -1
  5. package/package.json +1 -1
  6. package/scripts/stale-base-check.mjs +2 -2
  7. package/src/config.mjs +18 -2
  8. package/src/dag-engine.mjs +76 -0
  9. package/src/engine.mjs +331 -32
  10. package/src/envelope.mjs +32 -2
  11. package/src/execution_envelope.mjs +9 -1
  12. package/src/git.mjs +41 -0
  13. package/src/mcp.mjs +34 -0
  14. package/src/memory.mjs +148 -0
  15. package/src/ops/checkpoint.mjs +167 -0
  16. package/src/ops/command-registry.mjs +274 -0
  17. package/src/ops/doctor-planner.mjs +214 -0
  18. package/src/ops/doctor-registry.mjs +442 -0
  19. package/src/ops/ide-scaffold.mjs +139 -0
  20. package/src/ops/receipts.mjs +171 -0
  21. package/src/ops/swarm-actions.mjs +156 -0
  22. package/src/ops/task-actions.mjs +214 -0
  23. package/src/ops/tdd-generator.mjs +115 -0
  24. package/src/ops/transaction.mjs +278 -0
  25. package/src/prompt-guard.mjs +8 -1
  26. package/src/provider.mjs +164 -8
  27. package/src/remediation.mjs +164 -0
  28. package/src/security.mjs +14 -6
  29. package/src/stack-detector.mjs +15 -5
  30. package/src/state.mjs +71 -7
  31. package/src/task-optimizer.mjs +306 -0
  32. package/src/telemetry.mjs +35 -2
  33. package/src/tui.mjs +2 -6
  34. package/src/ux/capabilities.mjs +180 -0
  35. package/src/ux/diff-viewer.mjs +289 -0
  36. package/src/ux/key-decoder.mjs +281 -0
  37. package/src/ux/layout.mjs +195 -0
  38. package/src/ux/log-viewer.mjs +110 -0
  39. package/src/ux/palette.mjs +179 -0
  40. package/src/ux/queue-model.mjs +206 -0
  41. package/src/ux/renderer.mjs +188 -0
  42. package/src/ux/swarm-model.mjs +146 -0
  43. package/src/ux/terminal-session.mjs +215 -0
  44. package/src/ux/widgets.mjs +323 -0
  45. package/src/webhook.mjs +120 -0
  46. package/src/wizard-task.mjs +6 -0
@@ -0,0 +1,139 @@
1
+ import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { resolveRoot } from "../config.mjs";
4
+
5
+ export class IdeScaffoldError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "IdeScaffoldError";
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Scaffolds IDE integration configuration files for Cursor, VS Code, and Claude Desktop.
14
+ * @param {string} target - 'cursor' | 'vscode' | 'claude' | 'all'
15
+ * @param {object} [options]
16
+ * @returns {object} Summary of scaffolded files
17
+ */
18
+ export function scaffoldIdeConfig(target = "all", options = {}) {
19
+ const root = options.root || resolveRoot();
20
+ const validTargets = new Set(["cursor", "vscode", "claude", "all"]);
21
+
22
+ const normTarget = (target || "all").toLowerCase();
23
+ if (!validTargets.has(normTarget)) {
24
+ throw new IdeScaffoldError(`Invalid target '${target}'. Allowed targets: cursor, vscode, claude, all`);
25
+ }
26
+
27
+ const results = [];
28
+
29
+ // Target: Cursor (.cursor/mcp.json)
30
+ if (normTarget === "cursor" || normTarget === "all") {
31
+ const cursorDir = join(root, ".cursor");
32
+ try {
33
+ mkdirSync(cursorDir, { recursive: true });
34
+ } catch (_) {}
35
+
36
+ const cursorConfigPath = join(cursorDir, "mcp.json");
37
+ let existingConfig = {};
38
+ if (existsSync(cursorConfigPath)) {
39
+ try {
40
+ existingConfig = JSON.parse(readFileSync(cursorConfigPath, "utf-8"));
41
+ } catch (_) {}
42
+ }
43
+
44
+ const updatedCursorConfig = {
45
+ ...existingConfig,
46
+ mcpServers: {
47
+ ...(existingConfig.mcpServers || {}),
48
+ "jules-orchestrator-kit": {
49
+ command: "npx",
50
+ args: ["-y", "jules-orchestrator-kit@latest", "mcp"],
51
+ },
52
+ },
53
+ };
54
+
55
+ writeFileSync(cursorConfigPath, JSON.stringify(updatedCursorConfig, null, 2), "utf-8");
56
+ results.push({ target: "cursor", file: ".cursor/mcp.json", ok: true });
57
+ }
58
+
59
+ // Target: VS Code (.vscode/tasks.json)
60
+ if (normTarget === "vscode" || normTarget === "all") {
61
+ const vscodeDir = join(root, ".vscode");
62
+ try {
63
+ mkdirSync(vscodeDir, { recursive: true });
64
+ } catch (_) {}
65
+
66
+ const vscodeTasksPath = join(vscodeDir, "tasks.json");
67
+ let existingConfig = { version: "2.0.0", tasks: [] };
68
+ if (existsSync(vscodeTasksPath)) {
69
+ try {
70
+ existingConfig = JSON.parse(readFileSync(vscodeTasksPath, "utf-8"));
71
+ } catch (_) {}
72
+ }
73
+
74
+ const defaultTasks = [
75
+ {
76
+ label: "Jules: Run System Doctor",
77
+ type: "shell",
78
+ command: "npx agentctl doctor",
79
+ group: "test",
80
+ problemMatcher: [],
81
+ },
82
+ {
83
+ label: "Jules: Create Task Envelope",
84
+ type: "shell",
85
+ command: "npx agentctl task create",
86
+ group: "build",
87
+ problemMatcher: [],
88
+ },
89
+ {
90
+ label: "Jules: 1-Click Rollback Checkpoint",
91
+ type: "shell",
92
+ command: "npx agentctl rollback --latest",
93
+ group: "none",
94
+ problemMatcher: [],
95
+ },
96
+ ];
97
+
98
+ const currentTasks = existingConfig.tasks || [];
99
+ const updatedTasks = [...currentTasks];
100
+
101
+ for (const newTask of defaultTasks) {
102
+ if (!updatedTasks.some((t) => t.label === newTask.label)) {
103
+ updatedTasks.push(newTask);
104
+ }
105
+ }
106
+
107
+ const updatedVsCodeConfig = {
108
+ ...existingConfig,
109
+ version: existingConfig.version || "2.0.0",
110
+ tasks: updatedTasks,
111
+ };
112
+
113
+ writeFileSync(vscodeTasksPath, JSON.stringify(updatedVsCodeConfig, null, 2), "utf-8");
114
+ results.push({ target: "vscode", file: ".vscode/tasks.json", ok: true });
115
+ }
116
+
117
+ // Target: Claude Desktop (claude_desktop_config.json snippet helper)
118
+ if (normTarget === "claude" || normTarget === "all") {
119
+ const agentDir = join(root, ".agent");
120
+ try {
121
+ mkdirSync(agentDir, { recursive: true });
122
+ } catch (_) {}
123
+
124
+ const snippetPath = join(agentDir, "claude_desktop_config.snippet.json");
125
+ const claudeSnippet = {
126
+ mcpServers: {
127
+ "jules-orchestrator-kit": {
128
+ command: "npx",
129
+ args: ["-y", "jules-orchestrator-kit@latest", "mcp"],
130
+ },
131
+ },
132
+ };
133
+
134
+ writeFileSync(snippetPath, JSON.stringify(claudeSnippet, null, 2), "utf-8");
135
+ results.push({ target: "claude", file: ".agent/claude_desktop_config.snippet.json", ok: true });
136
+ }
137
+
138
+ return { ok: true, target: normTarget, results };
139
+ }
@@ -0,0 +1,171 @@
1
+ import {
2
+ readFileSync,
3
+ mkdirSync,
4
+ existsSync,
5
+ openSync,
6
+ writeSync,
7
+ fsyncSync,
8
+ closeSync,
9
+ renameSync,
10
+ unlinkSync,
11
+ } from "node:fs";
12
+ import { join, resolve, relative, isAbsolute } from "node:path";
13
+ import { createHash, randomUUID } from "node:crypto";
14
+ import { redactSecrets } from "../security.mjs";
15
+
16
+ /**
17
+ * @typedef {Object} OperationReceipt
18
+ * @property {"agentctl/operation-receipt-v1"} schema
19
+ * @property {string} receiptId
20
+ * @property {string} actionPlanId
21
+ * @property {string} kind
22
+ * @property {string} title
23
+ * @property {string} repository
24
+ * @property {string} appliedAt
25
+ * @property {"success" | "rolled-back" | "failed"} status
26
+ * @property {number} durationMs
27
+ * @property {Array<{ path: string, operation: string, hashBefore?: string, hashAfter?: string }>} mutationsApplied
28
+ * @property {Array<{ executable: string, args: string[], exitCode: number }>} effectsExecuted
29
+ * @property {string} receiptHash
30
+ */
31
+
32
+ /**
33
+ * Compute SHA-256 hash of operation receipt metadata.
34
+ * @param {Omit<OperationReceipt, "receiptHash">} receipt
35
+ * @returns {string}
36
+ */
37
+ export function computeReceiptHash(receipt) {
38
+ const payload = JSON.stringify({
39
+ schema: receipt.schema,
40
+ receiptId: receipt.receiptId,
41
+ actionPlanId: receipt.actionPlanId,
42
+ kind: receipt.kind,
43
+ title: receipt.title,
44
+ repository: receipt.repository,
45
+ status: receipt.status,
46
+ appliedAt: receipt.appliedAt,
47
+ durationMs: receipt.durationMs,
48
+ mutationsApplied: receipt.mutationsApplied,
49
+ effectsExecuted: receipt.effectsExecuted,
50
+ });
51
+ return "sha256:" + createHash("sha256").update(payload).digest("hex");
52
+ }
53
+
54
+ function redactReceiptValue(value) {
55
+ if (typeof value === "string") return redactSecrets(value);
56
+ if (Array.isArray(value)) return value.map(redactReceiptValue);
57
+ if (value && typeof value === "object") {
58
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, redactReceiptValue(child)]));
59
+ }
60
+ return value;
61
+ }
62
+
63
+ function assertReceiptId(receiptId) {
64
+ if (typeof receiptId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(receiptId)) {
65
+ throw new Error("Invalid operation receipt id");
66
+ }
67
+ }
68
+
69
+ function writeFileAtomically(filePath, content) {
70
+ const tmpPath = `${filePath}.${randomUUID()}.tmp`;
71
+ let fd;
72
+ try {
73
+ fd = openSync(tmpPath, "wx", 0o600);
74
+ writeSync(fd, content, "utf-8");
75
+ fsyncSync(fd);
76
+ closeSync(fd);
77
+ fd = undefined;
78
+ renameSync(tmpPath, filePath);
79
+ } catch (err) {
80
+ if (fd !== undefined) {
81
+ try { closeSync(fd); } catch (_) {}
82
+ }
83
+ try { unlinkSync(tmpPath); } catch (_) {}
84
+ throw err;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Create and persist an OperationReceipt atomically.
90
+ * @param {string} root
91
+ * @param {Omit<OperationReceipt, "schema" | "receiptId" | "appliedAt" | "receiptHash"> & { receiptId?: string }} data
92
+ * @returns {OperationReceipt}
93
+ */
94
+ export function createOperationReceipt(root, data) {
95
+ const receiptsDir = join(root, ".agent", "receipts");
96
+ if (!existsSync(receiptsDir)) {
97
+ mkdirSync(receiptsDir, { recursive: true });
98
+ }
99
+
100
+ const receiptId = data.receiptId || `REC-${Date.now()}-${randomUUID().slice(0, 8)}`;
101
+ assertReceiptId(receiptId);
102
+ const appliedAt = new Date().toISOString();
103
+
104
+ const baseReceipt = redactReceiptValue({
105
+ schema: /** @type {const} */ ("agentctl/operation-receipt-v1"),
106
+ receiptId,
107
+ actionPlanId: data.actionPlanId,
108
+ kind: data.kind,
109
+ title: data.title,
110
+ repository: data.repository || "local",
111
+ appliedAt,
112
+ status: data.status,
113
+ durationMs: data.durationMs || 0,
114
+ mutationsApplied: data.mutationsApplied || [],
115
+ effectsExecuted: data.effectsExecuted || [],
116
+ });
117
+
118
+ const receiptHash = computeReceiptHash(baseReceipt);
119
+ /** @type {OperationReceipt} */
120
+ const fullReceipt = {
121
+ ...baseReceipt,
122
+ receiptHash,
123
+ };
124
+
125
+ const targetPath = join(receiptsDir, `${receiptId}.json`);
126
+ writeFileAtomically(targetPath, JSON.stringify(fullReceipt, null, 2));
127
+
128
+ return fullReceipt;
129
+ }
130
+
131
+ /**
132
+ * Load and validate an OperationReceipt from disk.
133
+ * @param {string} root
134
+ * @param {string} receiptIdOrPath
135
+ * @returns {OperationReceipt}
136
+ */
137
+ export function loadOperationReceipt(root, receiptIdOrPath) {
138
+ const receiptsDir = resolve(root, ".agent", "receipts");
139
+ let targetPath;
140
+ if (typeof receiptIdOrPath !== "string" || !receiptIdOrPath) {
141
+ throw new Error("Operation receipt id or path is required");
142
+ }
143
+
144
+ if (!receiptIdOrPath.endsWith(".json")) {
145
+ assertReceiptId(receiptIdOrPath);
146
+ targetPath = join(receiptsDir, `${receiptIdOrPath}.json`);
147
+ } else {
148
+ targetPath = isAbsolute(receiptIdOrPath) ? resolve(receiptIdOrPath) : resolve(root, receiptIdOrPath);
149
+ const rel = relative(receiptsDir, targetPath);
150
+ if (rel.startsWith("..") || isAbsolute(rel)) {
151
+ throw new Error("Operation receipt path escapes the receipts directory");
152
+ }
153
+ }
154
+
155
+ if (!existsSync(targetPath)) {
156
+ throw new Error(`Operation receipt not found: ${targetPath}`);
157
+ }
158
+
159
+ const content = readFileSync(targetPath, "utf-8");
160
+ /** @type {OperationReceipt} */
161
+ const receipt = JSON.parse(content);
162
+
163
+ if (receipt.schema !== "agentctl/operation-receipt-v1") {
164
+ throw new Error(`Invalid receipt schema version: ${receipt.schema}`);
165
+ }
166
+ if (receipt.receiptHash !== computeReceiptHash(receipt)) {
167
+ throw new Error(`Invalid operation receipt hash: ${targetPath}`);
168
+ }
169
+
170
+ return receipt;
171
+ }
@@ -0,0 +1,156 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+
3
+ /**
4
+ * @typedef {import("../ux/capabilities.mjs").ActionPlan} ActionPlan
5
+ * @typedef {import("../ux/swarm-model.mjs").SwarmSlot} SwarmSlot
6
+ */
7
+
8
+ /**
9
+ * Plan swarm concurrency action into an immutable ActionPlan.
10
+ * @param {{ slots: SwarmSlot[], revision: string }} snapshot
11
+ * @param {Object} intent
12
+ * @param {string} intent.kind
13
+ * @param {string} [intent.slotId]
14
+ * @param {string} [intent.taskId]
15
+ * @param {Record<string, any>} [intent.answers]
16
+ * @returns {Promise<ActionPlan>}
17
+ */
18
+ export async function planSwarmAction(snapshot, intent) {
19
+ const planId = `PLAN-SWARM-${Date.now()}-${randomUUID().slice(0, 8)}`;
20
+
21
+ if (intent.kind === "pause-scheduler" || intent.kind === "resume-scheduler") {
22
+ const isPause = intent.kind === "pause-scheduler";
23
+ const schedulerRelPath = ".agent/state/swarm/scheduler.json";
24
+ const newContent = JSON.stringify(
25
+ {
26
+ schema: "agentctl/swarm-scheduler-v1",
27
+ paused: isPause,
28
+ updatedAt: new Date().toISOString(),
29
+ },
30
+ null,
31
+ 2
32
+ );
33
+
34
+ return {
35
+ schema: "agentctl/action-plan-v1",
36
+ id: planId,
37
+ kind: intent.kind,
38
+ title: `${isPause ? "Pause" : "Resume"} Swarm Scheduler`,
39
+ summary: `${isPause ? "Halt dispatching new tasks to swarm slots" : "Resume task dispatching to available slots"}`,
40
+ risk: "moderate",
41
+ repository: "local",
42
+ createdAt: new Date().toISOString(),
43
+ preconditions: [],
44
+ fileMutations: [
45
+ {
46
+ operation: "replace",
47
+ path: schedulerRelPath,
48
+ newContent,
49
+ },
50
+ ],
51
+ commandEffects: [],
52
+ stateTransitions: [],
53
+ preview: {
54
+ unifiedDiff: `--- a/${schedulerRelPath}\n+++ b/${schedulerRelPath}\n@@ -paused @@\n- paused: ${!isPause}\n+ paused: ${isPause}\n`,
55
+ warnings: isPause ? ["Running tasks will complete, but no new tasks will be scheduled."] : [],
56
+ estimatedImpact: [`Swarm scheduler state set to ${isPause ? "PAUSED" : "RUNNING"}`],
57
+ },
58
+ confirmation: {
59
+ mode: "keypress",
60
+ prompt: `Confirm ${isPause ? "pause" : "resume"} of swarm scheduler?`,
61
+ },
62
+ planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
63
+ };
64
+ }
65
+
66
+ if (intent.kind === "cancel-slot-task") {
67
+ const slotId = intent.slotId;
68
+ const slot = (snapshot.slots || []).find((s) => s.id === slotId || s.taskId === intent.taskId);
69
+ if (!slot) {
70
+ throw new Error(`Target slot ${slotId || intent.taskId} not found.`);
71
+ }
72
+
73
+ const slotRelPath = `.agent/state/swarm/${slot.id}.json`;
74
+ const newContent = JSON.stringify(
75
+ {
76
+ id: slot.id,
77
+ state: "draining",
78
+ taskId: slot.taskId,
79
+ cancelledAt: new Date().toISOString(),
80
+ },
81
+ null,
82
+ 2
83
+ );
84
+
85
+ return {
86
+ schema: "agentctl/action-plan-v1",
87
+ id: planId,
88
+ kind: "swarm.cancel-slot-task",
89
+ title: `Cancel active slot task ${slot.taskId || slot.id}`,
90
+ summary: `Send cancellation signal to slot ${slot.id} (Task ${slot.taskId || "N/A"})`,
91
+ risk: "high",
92
+ repository: "local",
93
+ createdAt: new Date().toISOString(),
94
+ preconditions: [],
95
+ fileMutations: [
96
+ {
97
+ operation: "replace",
98
+ path: slotRelPath,
99
+ newContent,
100
+ },
101
+ ],
102
+ commandEffects: [],
103
+ stateTransitions: [{ taskId: slot.taskId, from: slot.state, to: "cancelled" }],
104
+ preview: {
105
+ unifiedDiff: `--- a/${slotRelPath}\n+++ b/${slotRelPath}\n@@ -state @@\n- state: ${slot.state}\n+ state: draining\n`,
106
+ warnings: ["Cancelling a running worker process will terminate its active attempt."],
107
+ estimatedImpact: [`Cancels execution in slot ${slot.id}`],
108
+ },
109
+ confirmation: {
110
+ mode: "typed-task-id",
111
+ prompt: `Type '${slot.taskId || slot.id}' to confirm cancellation:`,
112
+ expected: slot.taskId || slot.id,
113
+ },
114
+ planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
115
+ };
116
+ }
117
+
118
+ if (intent.kind === "prune-stale-slots") {
119
+ const staleSlots = (snapshot.slots || []).filter((s) => s.state === "stale");
120
+ if (staleSlots.length === 0) {
121
+ throw new Error("No stale slots found to prune.");
122
+ }
123
+
124
+ const fileMutations = staleSlots.map((s) => ({
125
+ operation: /** @type {const} */ ("delete"),
126
+ path: `.agent/state/swarm/${s.id}.json`,
127
+ }));
128
+
129
+ return {
130
+ schema: "agentctl/action-plan-v1",
131
+ id: planId,
132
+ kind: "swarm.prune-stale-slots",
133
+ title: `Prune ${staleSlots.length} stale swarm slot(s)`,
134
+ summary: `Remove stale slot state files for dead worker process PIDs`,
135
+ risk: "low",
136
+ repository: "local",
137
+ createdAt: new Date().toISOString(),
138
+ preconditions: [],
139
+ fileMutations,
140
+ commandEffects: [],
141
+ stateTransitions: [],
142
+ preview: {
143
+ unifiedDiff: staleSlots.map((s) => `--- a/.agent/state/swarm/${s.id}.json\n+++ /dev/null\n`).join("\n"),
144
+ warnings: [],
145
+ estimatedImpact: [`Prunes ${staleSlots.length} stale slot file(s)`],
146
+ },
147
+ confirmation: {
148
+ mode: "keypress",
149
+ prompt: `Prune ${staleSlots.length} stale slot(s)?`,
150
+ },
151
+ planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
152
+ };
153
+ }
154
+
155
+ throw new Error(`Unsupported swarm action intent: ${intent.kind}`);
156
+ }
@@ -0,0 +1,214 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { redactSecrets } from "../security.mjs";
3
+
4
+ function deepFreeze(value) {
5
+ if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
6
+ for (const child of Object.values(value)) deepFreeze(child);
7
+ return Object.freeze(value);
8
+ }
9
+
10
+ function finalizePlan(plan) {
11
+ const { planHash: _oldHash, ...unsigned } = plan;
12
+ const planHash = "sha256:" + createHash("sha256").update(JSON.stringify(unsigned)).digest("hex");
13
+ return deepFreeze({ ...unsigned, planHash });
14
+ }
15
+
16
+ /**
17
+ * @typedef {import("../ux/capabilities.mjs").ActionPlan} ActionPlan
18
+ * @typedef {import("../ux/queue-model.mjs").QueueSnapshot} QueueSnapshot
19
+ */
20
+
21
+ /**
22
+ * Plan task lifecycle action into an immutable ActionPlan.
23
+ * @param {QueueSnapshot} snapshot
24
+ * @param {Object} intent
25
+ * @param {string} intent.kind
26
+ * @param {string} intent.taskId
27
+ * @param {Record<string, any>} [intent.answers]
28
+ * @returns {Promise<ActionPlan>}
29
+ */
30
+ export async function planTaskAction(snapshot, intent = {}) {
31
+ const taskId = typeof intent.taskId === "string" ? intent.taskId : "";
32
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(taskId)) {
33
+ throw new Error("Task id must be a safe queue identifier.");
34
+ }
35
+ const task = (snapshot?.tasks || []).find((t) => t.id === taskId);
36
+ if (!task) {
37
+ throw new Error(`Task ${taskId} not found in queue snapshot.`);
38
+ }
39
+
40
+ const root = snapshot.repository;
41
+ const planId = `PLAN-TASK-${Date.now()}-${randomUUID().slice(0, 8)}`;
42
+ const sidecarRelPath = `.agent/jules-queue/${taskId}.state.json`;
43
+
44
+ if (intent.kind === "retry") {
45
+ const allowedStates = ["failed", "deferred", "quarantined", "cancelled"];
46
+ if (!allowedStates.includes(task.state)) {
47
+ throw new Error(`Task ${taskId} in state '${task.state}' cannot be retried.`);
48
+ }
49
+
50
+ const nextAttempt = (Number.isFinite(task.attempt) ? task.attempt : 0) + 1;
51
+ const sidecarContent = JSON.stringify(
52
+ {
53
+ schema: "agentctl/task-state-v1",
54
+ taskId,
55
+ revision: (Number.isFinite(task.stateRevision) ? task.stateRevision : 0) + 1,
56
+ state: "pending",
57
+ attempt: nextAttempt,
58
+ maxAttempts: task.maxAttempts,
59
+ createdAt: task.createdAt,
60
+ updatedAt: new Date().toISOString(),
61
+ },
62
+ null,
63
+ 2
64
+ );
65
+
66
+ return finalizePlan({
67
+ schema: "agentctl/action-plan-v1",
68
+ id: planId,
69
+ kind: "task.retry",
70
+ title: `Retry task ${taskId}`,
71
+ summary: `Reset state to pending and increment attempt count to ${nextAttempt}`,
72
+ risk: "moderate",
73
+ repository: root,
74
+ createdAt: new Date().toISOString(),
75
+ preconditions: [
76
+ {
77
+ kind: "file-hash",
78
+ target: sidecarRelPath,
79
+ expected: task.stateHash, // Checked prior state hash if sidecar exists
80
+ },
81
+ ],
82
+ fileMutations: [
83
+ {
84
+ operation: "replace",
85
+ path: sidecarRelPath,
86
+ newContent: sidecarContent,
87
+ },
88
+ ],
89
+ commandEffects: [],
90
+ stateTransitions: [{ taskId, from: task.state, to: "pending" }],
91
+ preview: {
92
+ unifiedDiff: `--- a/${sidecarRelPath}\n+++ b/${sidecarRelPath}\n@@ -state @@\n- state: ${task.state}\n+ state: pending\n- attempt: ${task.attempt}\n+ attempt: ${nextAttempt}\n`,
93
+ warnings: [],
94
+ estimatedImpact: [`Resets ${taskId} to pending state (Attempt ${nextAttempt})`],
95
+ },
96
+ confirmation: {
97
+ mode: "keypress",
98
+ prompt: `Confirm retry of ${taskId} (Attempt ${nextAttempt})?`,
99
+ },
100
+ planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
101
+ });
102
+ }
103
+
104
+ if (intent.kind === "quarantine-override") {
105
+ if (task.state !== "quarantined") {
106
+ throw new Error(`Task ${taskId} is not in quarantined state.`);
107
+ }
108
+
109
+ const reason = redactSecrets(String(intent.answers?.reason || "").trim());
110
+ if (!reason) {
111
+ throw new Error("Quarantine override requires a non-empty reason.");
112
+ }
113
+
114
+ const sidecarContent = JSON.stringify(
115
+ {
116
+ schema: "agentctl/task-state-v1",
117
+ taskId,
118
+ revision: (Number.isFinite(task.stateRevision) ? task.stateRevision : 0) + 1,
119
+ state: "pending",
120
+ attempt: task.attempt,
121
+ maxAttempts: task.maxAttempts,
122
+ quarantineOverrideReason: reason,
123
+ updatedAt: new Date().toISOString(),
124
+ },
125
+ null,
126
+ 2
127
+ );
128
+
129
+ return finalizePlan({
130
+ schema: "agentctl/action-plan-v1",
131
+ id: planId,
132
+ kind: "task.quarantine-override",
133
+ title: `Override quarantine for ${taskId}`,
134
+ summary: `Bypass quarantine with reason: "${reason}"`,
135
+ risk: "high",
136
+ repository: root,
137
+ createdAt: new Date().toISOString(),
138
+ preconditions: [
139
+ {
140
+ kind: "file-hash",
141
+ target: sidecarRelPath,
142
+ expected: task.stateHash,
143
+ },
144
+ ],
145
+ fileMutations: [
146
+ {
147
+ operation: "replace",
148
+ path: sidecarRelPath,
149
+ newContent: sidecarContent,
150
+ },
151
+ ],
152
+ commandEffects: [],
153
+ stateTransitions: [{ taskId, from: "quarantined", to: "pending" }],
154
+ preview: {
155
+ unifiedDiff: `--- a/${sidecarRelPath}\n+++ b/${sidecarRelPath}\n@@ -state @@\n- state: quarantined\n+ state: pending\n+ overrideReason: "${reason}"\n`,
156
+ warnings: ["Bypassing quarantine filter requires verified operator review."],
157
+ estimatedImpact: [`Re-enables ${taskId} for dispatch`],
158
+ },
159
+ confirmation: {
160
+ mode: "typed-task-id",
161
+ prompt: `Type '${taskId}' to confirm quarantine override:`,
162
+ expected: taskId,
163
+ requireReason: true,
164
+ },
165
+ planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
166
+ });
167
+ }
168
+
169
+ if (intent.kind === "delete-task") {
170
+ if (task.state === "running" || task.state === "verifying") {
171
+ throw new Error(`Task ${taskId} is currently active (${task.state}) and cannot be deleted.`);
172
+ }
173
+
174
+ const trashRelPath = `.agent/jules-queue/.trash/${taskId}.md`;
175
+ return finalizePlan({
176
+ schema: "agentctl/action-plan-v1",
177
+ id: planId,
178
+ kind: "task.delete",
179
+ title: `Delete task ${taskId}`,
180
+ summary: `Move task file ${task.taskFile} to trash directory`,
181
+ risk: "moderate",
182
+ repository: root,
183
+ createdAt: new Date().toISOString(),
184
+ preconditions: [
185
+ {
186
+ kind: "file-hash",
187
+ target: task.taskFile,
188
+ expected: task.envelopeHash,
189
+ },
190
+ ],
191
+ fileMutations: [
192
+ {
193
+ operation: "move",
194
+ path: trashRelPath,
195
+ fromPath: task.taskFile,
196
+ },
197
+ ],
198
+ commandEffects: [],
199
+ stateTransitions: [{ taskId, from: task.state, to: "cancelled" }],
200
+ preview: {
201
+ unifiedDiff: `--- a/${task.taskFile}\n+++ /dev/null\n`,
202
+ warnings: [],
203
+ estimatedImpact: [`Moves ${taskId} to trash`],
204
+ },
205
+ confirmation: {
206
+ mode: "keypress",
207
+ prompt: `Delete task ${taskId}?`,
208
+ },
209
+ planHash: "sha256:" + createHash("sha256").update(planId).digest("hex"),
210
+ });
211
+ }
212
+
213
+ throw new Error(`Unsupported task action intent: ${intent.kind}`);
214
+ }