jules-orchestrator-kit 0.8.1 → 0.9.3

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 (50) hide show
  1. package/.agent/jules-queue/README.md +4 -4
  2. package/.agent/prompts/Bolt.md +18 -0
  3. package/.agent/prompts/Overseer.md +18 -0
  4. package/.agent/prompts/Sentinel.md +18 -0
  5. package/.agent/prompts/Task_Template.md +26 -0
  6. package/.agent/rules/dynamic-guardrails.json +0 -4
  7. package/.github/workflows/jules-audit.yml +16 -5
  8. package/JULES_RULES_TEMPLATE.md +7 -2
  9. package/README.md +113 -21
  10. package/bin/agentctl.mjs +274 -0
  11. package/bin/init.js +1 -1
  12. package/index.mjs +34 -6
  13. package/package.json +22 -6
  14. package/scripts/asset-integrity-check.mjs +35 -0
  15. package/scripts/command-resolver.mjs +37 -214
  16. package/scripts/jules-cleanup.mjs +23 -174
  17. package/scripts/jules-create.mjs +12 -51
  18. package/scripts/jules-dispatch.mjs +103 -349
  19. package/scripts/jules-merge-swarm.mjs +30 -0
  20. package/scripts/jules-nightly.mjs +7 -124
  21. package/scripts/jules-patch.mjs +23 -0
  22. package/scripts/jules-queue-runner.mjs +28 -203
  23. package/scripts/jules-scan-todos.mjs +31 -125
  24. package/scripts/jules-self-audit.mjs +76 -578
  25. package/scripts/jules-status.mjs +42 -65
  26. package/scripts/jules-swarm.mjs +27 -232
  27. package/scripts/jules-webhook-receiver.mjs +70 -0
  28. package/scripts/lock-manager.mjs +11 -170
  29. package/scripts/risk-tier.mjs +29 -0
  30. package/scripts/rules-lint.mjs +20 -0
  31. package/scripts/stale-base-check.mjs +26 -0
  32. package/scripts/utils.mjs +118 -252
  33. package/scripts/validate-envelope.mjs +44 -0
  34. package/src/asset_integrity.mjs +76 -0
  35. package/src/config.mjs +325 -0
  36. package/src/engine.mjs +217 -0
  37. package/src/envelope.mjs +105 -0
  38. package/src/git.mjs +127 -0
  39. package/src/provider.mjs +179 -0
  40. package/src/risk.mjs +120 -0
  41. package/src/rules_budget.mjs +77 -0
  42. package/src/security.mjs +212 -0
  43. package/src/state.mjs +209 -0
  44. package/src/webhook.mjs +138 -0
  45. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -63
  46. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -34
  47. package/.github/social-preview.png +0 -0
  48. package/.github/workflows/agent-scope-guard.yml +0 -57
  49. package/.github/workflows/jules-nightly.yml +0 -21
  50. package/.github/workflows/publish.yml +0 -24
package/src/config.mjs ADDED
@@ -0,0 +1,325 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { execSync } from "node:child_process";
4
+
5
+ export class ConfigError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "ConfigError";
9
+ }
10
+ }
11
+
12
+ const DEFAULTS = {
13
+ version: 1,
14
+ provider: "jules",
15
+ verify: {},
16
+ scope: { deny: [], allow: [], protect: [] },
17
+ limits: {
18
+ diffKb: 75,
19
+ promptKb: 50,
20
+ dailyTasks: 300,
21
+ repairAttempts: 3,
22
+ concurrency: 1,
23
+ staggerMs: 1500,
24
+ },
25
+ isolation: "none",
26
+ runner: "local",
27
+ branchPrefix: "agent/",
28
+ baseBranch: "main",
29
+ };
30
+
31
+ export const BUILTIN_DENY = [
32
+ ".git/**",
33
+ "**/.env",
34
+ "**/.env.*",
35
+ "**/*.pem",
36
+ "**/*.key",
37
+ "**/id_rsa*",
38
+ ".agent/config.yml",
39
+ ".agent/jules.yml",
40
+ ".agent/jules-queue/**",
41
+ ".github/**",
42
+ ];
43
+
44
+ export const BUILTIN_PROTECT = [
45
+ ".agent/rules/**",
46
+ "package.json",
47
+ "Cargo.toml",
48
+ "pyproject.toml",
49
+ "go.mod",
50
+ "Makefile",
51
+ ];
52
+
53
+ const BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
54
+
55
+ function coerce(val) {
56
+ if (!val) return "";
57
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
58
+ return val.slice(1, -1);
59
+ }
60
+ if (val === "true") return true;
61
+ if (val === "false") return false;
62
+ if (!isNaN(Number(val)) && val.trim() !== "") return Number(val);
63
+ return val;
64
+ }
65
+
66
+ export function normalizePath(p) {
67
+ if (!p || typeof p !== "string") return "";
68
+ return p.split(sep).join("/").replace(/\\/g, "/");
69
+ }
70
+
71
+ export function resolveRoot(cwd = process.cwd()) {
72
+ try {
73
+ return execSync("git rev-parse --show-toplevel", { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
74
+ } catch (_) {
75
+ return resolve(cwd);
76
+ }
77
+ }
78
+
79
+ export function dedupe(arr) {
80
+ return Array.from(new Set((arr || []).filter(Boolean)));
81
+ }
82
+
83
+ /**
84
+ * Single authoritative scope normalizer. Always merges BUILTIN_DENY and BUILTIN_PROTECT.
85
+ * CRITICAL FIX FOR B1: User scope.deny will add to BUILTIN_DENY, never replace it.
86
+ */
87
+ export function normalizeScope(parsed = {}) {
88
+ const forbiddenPaths = Array.isArray(parsed.forbidden_paths)
89
+ ? parsed.forbidden_paths
90
+ : Array.isArray(parsed.scope?.deny)
91
+ ? parsed.scope.deny
92
+ : [];
93
+ const allowPaths = Array.isArray(parsed.allow_paths)
94
+ ? parsed.allow_paths
95
+ : Array.isArray(parsed.scope?.allow)
96
+ ? parsed.scope.allow
97
+ : [];
98
+ const protectPaths = Array.isArray(parsed.scope?.protect) ? parsed.scope.protect : [];
99
+
100
+ return {
101
+ deny: dedupe([...BUILTIN_DENY, ...forbiddenPaths]),
102
+ allow: dedupe(allowPaths),
103
+ protect: dedupe([...BUILTIN_PROTECT, ...protectPaths]),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Indent-stack zero-dependency YAML parser with prototype pollution protection.
109
+ */
110
+ export function parseYaml(src) {
111
+ if (!src || typeof src !== "string") return Object.create(null);
112
+ const root = Object.create(null);
113
+ const stack = [{ indent: -1, node: root }];
114
+ const lines = src.split("\n");
115
+
116
+ for (let rawLine of lines) {
117
+ const commentIdx = rawLine.indexOf("#");
118
+ const line = commentIdx !== -1 ? rawLine.slice(0, commentIdx) : rawLine;
119
+ const trimmed = line.trim();
120
+ if (!trimmed) continue;
121
+
122
+ const indent = rawLine.length - rawLine.trimStart().length;
123
+
124
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
125
+ stack.pop();
126
+ }
127
+
128
+ const top = stack[stack.length - 1];
129
+
130
+ if (trimmed.startsWith("- ")) {
131
+ if (top.key && top.parent) {
132
+ if (!Array.isArray(top.parent[top.key])) {
133
+ top.parent[top.key] = [];
134
+ }
135
+ top.parent[top.key].push(coerce(trimmed.slice(2).trim()));
136
+ } else if (Array.isArray(top.node)) {
137
+ top.node.push(coerce(trimmed.slice(2).trim()));
138
+ }
139
+ continue;
140
+ }
141
+
142
+ const eqIdx = trimmed.indexOf(":");
143
+ if (eqIdx > 0) {
144
+ const rawKey = trimmed.slice(0, eqIdx).trim();
145
+ const valStr = trimmed.slice(eqIdx + 1).trim();
146
+
147
+ if (BLOCKED_KEYS.has(rawKey)) {
148
+ throw new ConfigError(`Illegal prototype key "${rawKey}" detected in configuration`);
149
+ }
150
+
151
+ if (valStr === "") {
152
+ const child = Object.create(null);
153
+ top.node[rawKey] = child;
154
+ stack.push({ indent, node: child, key: rawKey, parent: top.node });
155
+ } else if (valStr.startsWith("[") && valStr.endsWith("]")) {
156
+ const items = valStr
157
+ .slice(1, -1)
158
+ .split(",")
159
+ .map((s) => coerce(s.trim()))
160
+ .filter((s) => s !== "");
161
+ top.node[rawKey] = items;
162
+ } else {
163
+ top.node[rawKey] = coerce(valStr);
164
+ }
165
+ }
166
+ }
167
+
168
+ return root;
169
+ }
170
+
171
+ export function detectPackageManager(root = process.cwd(), pkg = {}) {
172
+ if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
173
+ if (existsSync(join(root, "yarn.lock"))) return "yarn";
174
+ if (existsSync(join(root, "bun.lockb"))) return "bun";
175
+ if (existsSync(join(root, "package-lock.json"))) return "npm";
176
+
177
+ const pm = pkg.packageManager;
178
+ if (typeof pm === "string") {
179
+ if (pm.startsWith("pnpm")) return "pnpm";
180
+ if (pm.startsWith("yarn")) return "yarn";
181
+ if (pm.startsWith("bun")) return "bun";
182
+ if (pm.startsWith("npm")) return "npm";
183
+ }
184
+
185
+ return "npm";
186
+ }
187
+
188
+ /**
189
+ * Autodetects verification test/build commands across 16 common tech stacks.
190
+ */
191
+ export function detectStack(projectRoot = process.cwd()) {
192
+ const detectors = [
193
+ {
194
+ files: ["turbo.json"],
195
+ resolve: () => ({ testCmd: "npx turbo run test", buildCmd: "npx turbo run build", stack: "turbo" }),
196
+ },
197
+ {
198
+ files: ["pnpm-workspace.yaml"],
199
+ resolve: () => ({ testCmd: "pnpm -r test", buildCmd: "pnpm -r build", stack: "pnpm" }),
200
+ },
201
+ {
202
+ files: ["nx.json"],
203
+ resolve: () => ({ testCmd: "npx nx run-many -t test", buildCmd: "npx nx run-many -t build", stack: "nx" }),
204
+ },
205
+ {
206
+ files: ["bunfig.toml", "bun.lockb"],
207
+ resolve: () => ({ testCmd: "bun test", buildCmd: "bun run build", stack: "bun" }),
208
+ },
209
+ {
210
+ files: ["deno.json", "deno.jsonc"],
211
+ resolve: () => ({ testCmd: "deno test", buildCmd: "deno task build", stack: "deno" }),
212
+ },
213
+ {
214
+ files: ["package.json"],
215
+ resolve: (root) => {
216
+ try {
217
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
218
+ const pm = detectPackageManager(root, pkg);
219
+ const scripts = pkg.scripts || {};
220
+ let testScript = scripts.test ? `${pm} test` : "";
221
+ const buildScript = scripts.build ? `${pm} run build` : "";
222
+ return { testCmd: testScript || `${pm} test`, buildCmd: buildScript, stack: "node" };
223
+ } catch (_) {
224
+ return { testCmd: "npm test", buildCmd: "npm run build", stack: "node" };
225
+ }
226
+ },
227
+ },
228
+ {
229
+ files: ["Cargo.toml"],
230
+ resolve: () => ({ testCmd: "cargo test --workspace", buildCmd: "cargo build", stack: "cargo" }),
231
+ },
232
+ {
233
+ files: ["go.mod"],
234
+ resolve: () => ({ testCmd: "go test ./...", buildCmd: "go build ./...", stack: "go" }),
235
+ },
236
+ {
237
+ files: ["pyproject.toml", "requirements.txt", "setup.py"],
238
+ resolve: () => ({ testCmd: "pytest", buildCmd: "", stack: "python" }),
239
+ },
240
+ {
241
+ files: ["mix.exs"],
242
+ resolve: () => ({ testCmd: "mix test", buildCmd: "mix compile", stack: "mix" }),
243
+ },
244
+ {
245
+ files: ["Gemfile"],
246
+ resolve: () => ({ testCmd: "bundle exec rake test", buildCmd: "", stack: "bundler" }),
247
+ },
248
+ {
249
+ files: ["Package.swift"],
250
+ resolve: () => ({ testCmd: "swift test", buildCmd: "swift build", stack: "swift" }),
251
+ },
252
+ {
253
+ files: ["pom.xml"],
254
+ resolve: () => ({ testCmd: "mvn test", buildCmd: "mvn compile", stack: "maven" }),
255
+ },
256
+ {
257
+ files: ["build.gradle", "build.gradle.kts"],
258
+ resolve: () => ({ testCmd: "./gradlew test", buildCmd: "./gradlew assemble", stack: "gradle" }),
259
+ },
260
+ {
261
+ files: ["Makefile"],
262
+ resolve: () => ({ testCmd: "make test", buildCmd: "make build", stack: "make" }),
263
+ },
264
+ ];
265
+
266
+ for (const detector of detectors) {
267
+ if (detector.files.some((f) => existsSync(join(projectRoot, f)))) {
268
+ const res = detector.resolve(projectRoot);
269
+ if (res) return res;
270
+ }
271
+ }
272
+
273
+ return { testCmd: "", buildCmd: "", stack: "unknown" };
274
+ }
275
+
276
+ export function resolveVerify(root = process.cwd()) {
277
+ const s = detectStack(root);
278
+ return { test: s.testCmd || "", build: s.buildCmd || "" };
279
+ }
280
+
281
+ /**
282
+ * Loads and validates configuration from .agent/config.yml or .agent/jules.yml.
283
+ */
284
+ export function loadConfig(root = resolveRoot(), explicitPath = null) {
285
+ const candidates = explicitPath
286
+ ? [explicitPath]
287
+ : [join(root, ".agent/config.yml"), join(root, ".agent/jules.yml")];
288
+
289
+ const configFile = candidates.find(existsSync);
290
+ let parsed = Object.create(null);
291
+ if (configFile) {
292
+ try {
293
+ const raw = readFileSync(configFile, "utf-8");
294
+ parsed = parseYaml(raw);
295
+ } catch (err) {
296
+ if (err instanceof ConfigError) throw err;
297
+ }
298
+ }
299
+
300
+ const testCmd = parsed.test_cmd || parsed.verify?.test || "";
301
+ const buildCmd = parsed.build_cmd || parsed.verify?.build || "";
302
+ const autoVerify = resolveVerify(root);
303
+
304
+ const config = {
305
+ version: parsed.version || DEFAULTS.version,
306
+ provider: parsed.provider || DEFAULTS.provider,
307
+ verify: {
308
+ test: testCmd || autoVerify.test,
309
+ build: buildCmd || autoVerify.build,
310
+ },
311
+ scope: normalizeScope(parsed),
312
+ limits: {
313
+ ...DEFAULTS.limits,
314
+ ...(parsed.limits || {}),
315
+ },
316
+ isolation: parsed.isolation || DEFAULTS.isolation,
317
+ runner: parsed.runner || DEFAULTS.runner,
318
+ branchPrefix: parsed.branch_prefix || parsed.branchPrefix || DEFAULTS.branchPrefix,
319
+ baseBranch: parsed.base_branch || parsed.baseBranch || DEFAULTS.baseBranch,
320
+ _root: root,
321
+ _file: configFile || null,
322
+ };
323
+
324
+ return config;
325
+ }
package/src/engine.mjs ADDED
@@ -0,0 +1,217 @@
1
+ import { loadConfig, parseYaml, normalizeScope } from "./config.mjs";
2
+ import { checkScope, scanDiff, redactSecrets } from "./security.mjs";
3
+ import { changedFiles, diffBytes, diffText, showFromOrigin, runCmd } from "./git.mjs";
4
+ import { createProvider } from "./provider.mjs";
5
+ import { withBudget, appendLedger, getQueueDir, ensureDir } from "./state.mjs";
6
+ import { readdirSync, readFileSync, renameSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ /**
10
+ * Gatekeeper verification engine (Phase 1: Scope, Phase 2: Payload, Phase 3: Secrets, Phase 4: Verify Commands).
11
+ * FAILS CLOSED ON GIT OR CONFIG ERRORS.
12
+ */
13
+ export async function gate(opts = {}) {
14
+ const root = opts.root || process.cwd();
15
+ const config = opts.config || loadConfig(root);
16
+ const base = opts.base || config.baseBranch || "main";
17
+ const phases = [];
18
+
19
+ let files = [];
20
+ let bytes = 0;
21
+ let diffStr = "";
22
+
23
+ try {
24
+ files = changedFiles(root, base);
25
+ bytes = diffBytes(root, base);
26
+ diffStr = diffText(root, base);
27
+ } catch (err) {
28
+ phases.push({ phase: "git_resolution", ok: false, error: err.message });
29
+ return { ok: false, code: 1, phases, error: err.message };
30
+ }
31
+
32
+ // Phase 1: Scope Guard (Fetch trusted config strictly from origin/base using single normalizeScope)
33
+ let trustedScope = config.scope;
34
+ let trustedVerify = config.verify;
35
+
36
+ const trustedConfigRaw = showFromOrigin(root, base, ".agent/config.yml") || showFromOrigin(root, base, ".agent/jules.yml");
37
+ if (trustedConfigRaw) {
38
+ try {
39
+ const parsed = parseYaml(trustedConfigRaw);
40
+ // CRITICAL B1 FIX: normalizeScope ensures BUILTIN_DENY is ALWAYS merged with user deny rules
41
+ trustedScope = normalizeScope(parsed);
42
+ if (parsed.verify || parsed.test_cmd || parsed.build_cmd) {
43
+ trustedVerify = {
44
+ test: parsed.verify?.test || parsed.test_cmd || config.verify.test,
45
+ build: parsed.verify?.build || parsed.build_cmd || config.verify.build,
46
+ };
47
+ }
48
+ } catch (_) {}
49
+ } else {
50
+ // CRITICAL H-c FIX: Fall back strictly to normalizeScope({}) (built-ins only), never HEAD config
51
+ trustedScope = normalizeScope({});
52
+ }
53
+
54
+ const scopeResult = checkScope(files, trustedScope, {
55
+ allowProtected: opts.allowProtected || process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "true",
56
+ });
57
+
58
+ phases.push({ phase: "scope", ok: scopeResult.ok, violations: scopeResult.violations });
59
+ if (!scopeResult.ok) {
60
+ return { ok: false, code: 3, phases };
61
+ }
62
+
63
+ // Phase 2: Diff Payload Governor
64
+ const limitBytes = (config.limits.diffKb || 75) * 1024;
65
+ const payloadOk = bytes <= limitBytes;
66
+ phases.push({ phase: "payload", ok: payloadOk, bytes, limitBytes });
67
+ if (!payloadOk) {
68
+ return { ok: false, code: 5, phases };
69
+ }
70
+
71
+ // Phase 3: Diff Secret Scanner
72
+ const secretResult = scanDiff(diffStr);
73
+ phases.push({ phase: "secrets", ok: secretResult.ok, findings: secretResult.findings });
74
+ if (!secretResult.ok) {
75
+ return { ok: false, code: 6, phases };
76
+ }
77
+
78
+ // Phase 4: Automated Test & Build Verification (Uses trusted verify commands only)
79
+ const testCmd = trustedVerify.test;
80
+ const buildCmd = trustedVerify.build;
81
+ let testResult = { ok: true, status: 0 };
82
+ let buildResult = { ok: true, status: 0 };
83
+
84
+ if (testCmd) {
85
+ const res = runCmd(testCmd, { cwd: root, ignoreError: true });
86
+ testResult = { ok: res.status === 0, status: res.status, stdout: res.stdout, stderr: res.stderr, command: testCmd };
87
+ }
88
+
89
+ if (testResult.ok && buildCmd) {
90
+ const res = runCmd(buildCmd, { cwd: root, ignoreError: true });
91
+ buildResult = { ok: res.status === 0, status: res.status, stdout: res.stdout, stderr: res.stderr, command: buildCmd };
92
+ }
93
+
94
+ const verifyOk = testResult.ok && buildResult.ok;
95
+ const failingCmd = !testResult.ok ? testResult : !buildResult.ok ? buildResult : null;
96
+ phases.push({ phase: "verify", ok: verifyOk, testResult, buildResult });
97
+
98
+ if (!verifyOk && opts.fix) {
99
+ const repairs = await repair(failingCmd, { config, root, signal: opts.signal });
100
+ if (repairs.ok) {
101
+ return { ok: true, code: 0, phases, repairs };
102
+ }
103
+ return { ok: false, code: 4, phases, repairs };
104
+ }
105
+
106
+ return { ok: verifyOk, code: verifyOk ? 0 : 4, phases };
107
+ }
108
+
109
+ export async function repair(failure, opts = {}) {
110
+ const root = opts.root || process.cwd();
111
+ const config = opts.config || loadConfig(root);
112
+ const maxRetries = config.limits.repairAttempts || 3;
113
+ const provider = createProvider(config.provider, config);
114
+ const attempts = [];
115
+
116
+ let currentFailure = failure;
117
+ for (let n = 1; n <= maxRetries; n++) {
118
+ const repairPrompt = buildRepairPrompt(currentFailure, n, config);
119
+ let session;
120
+ try {
121
+ session = await withBudget(
122
+ () =>
123
+ provider.dispatch(
124
+ { id: `repair-${n}`, title: `OODA Auto-Repair Attempt ${n}`, prompt: repairPrompt },
125
+ { root, dryRun: opts.dryRun }
126
+ ),
127
+ root,
128
+ config.limits.dailyTasks
129
+ );
130
+ } catch (err) {
131
+ attempts.push({ n, ok: false, error: err.message });
132
+ break;
133
+ }
134
+
135
+ attempts.push({ n, session, ok: true });
136
+
137
+ // Re-verify after repair attempt
138
+ const gateRes = await gate({ root, config, fix: false });
139
+ if (gateRes.ok) {
140
+ return { ok: true, attempts, finalStatus: "PASSED" };
141
+ }
142
+ currentFailure = gateRes.phases.find((p) => p.phase === "verify")?.testResult || failure;
143
+ }
144
+
145
+ return { ok: false, attempts, finalStatus: "OODA_EXHAUSTED" };
146
+ }
147
+
148
+ function buildRepairPrompt(failure, attempt, _config) {
149
+ const cleanStderr = redactSecrets(failure.stderr || failure.stdout || "Unknown Error");
150
+ return `Auto-Repair Attempt #${attempt}
151
+ Command Failed: ${failure.command || "verify"}
152
+
153
+ REPAIR DIRECTIVE & STRICT INVARIANTS:
154
+ 1. Ground your fix strictly in the error log below. Do NOT guess file contents or function signatures.
155
+ 2. NO WEAKENING: You are STRICTLY FORBIDDEN from deleting tests, commenting out assertions, or weakening expectations to achieve a passing test. Leave unmet requirements RED if the requirement is valid and feature code is missing.
156
+ 3. CARRY EVIDENCE: Address the root cause directly and verify the exact fix.
157
+ 4. RE-VERIFY: Execute full verification after patching to ensure zero new regressions.
158
+
159
+ Stderr:
160
+ ${cleanStderr}`;
161
+ }
162
+
163
+ export async function dispatch(task, opts = {}) {
164
+ const root = opts.config?._root || opts.root || process.cwd();
165
+ const config = opts.config || loadConfig(root);
166
+ const provider = createProvider(config.provider, config);
167
+
168
+ // Enforce prompt size limit
169
+ const promptKb = (config.limits.promptKb || 50) * 1024;
170
+ if (task.prompt && Buffer.byteLength(task.prompt, "utf-8") > promptKb) {
171
+ throw new Error(`Task prompt exceeds maximum payload limit of ${config.limits.promptKb} KB`);
172
+ }
173
+
174
+ // Redact secrets in prompt before dispatching
175
+ const cleanPrompt = redactSecrets(task.prompt);
176
+ const cleanTask = { ...task, prompt: cleanPrompt };
177
+
178
+ return withBudget(
179
+ () => provider.dispatch(cleanTask, { root, dryRun: opts.dryRun }),
180
+ root,
181
+ config.limits.dailyTasks
182
+ );
183
+ }
184
+
185
+ export async function run(opts = {}) {
186
+ const root = opts.root || process.cwd();
187
+ const config = opts.config || loadConfig(root);
188
+ const concurrency = opts.concurrency || config.limits.concurrency || 1;
189
+ const queueDir = getQueueDir(root);
190
+ const completedDir = join(queueDir, "completed");
191
+ ensureDir(completedDir);
192
+
193
+ const files = readdirSync(queueDir).filter((f) => f.endsWith(".md"));
194
+ const results = [];
195
+
196
+ for (let i = 0; i < files.length; i += concurrency) {
197
+ const batch = files.slice(i, i + concurrency);
198
+ await Promise.all(
199
+ batch.map(async (file) => {
200
+ const srcPath = join(queueDir, file);
201
+ try {
202
+ const content = readFileSync(srcPath, "utf-8");
203
+ const task = { title: file, prompt: content };
204
+ const session = await dispatch(task, { root, config, dryRun: opts.dryRun });
205
+ const dstPath = join(completedDir, file);
206
+ renameSync(srcPath, dstPath);
207
+ appendLedger({ event: "task_completed", file, session }, root);
208
+ results.push({ file, ok: true, session });
209
+ } catch (err) {
210
+ results.push({ file, ok: false, error: err.message });
211
+ }
212
+ })
213
+ );
214
+ }
215
+
216
+ return { processed: results.length, results };
217
+ }
@@ -0,0 +1,105 @@
1
+ import { git, resolveBase } from "./git.mjs";
2
+ import { checkScope } from "./security.mjs";
3
+ import { normalizeScope } from "./config.mjs";
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ /**
8
+ * Validates a Task Envelope before dispatch or during CI triage.
9
+ * Prevents execution on fabricated premises (missing files/tests) or stale bases.
10
+ *
11
+ * @param {Object} envelope - Task envelope payload
12
+ * @param {Object} [opts] - Options (root, maxBehindCommits, maxBaseAgeHours)
13
+ * @returns {{ ok: boolean, code?: number, errors: string[], warnings: string[] }}
14
+ */
15
+ export function validateEnvelope(envelope = {}, opts = {}) {
16
+ const root = opts.root || process.cwd();
17
+ const maxBehind = opts.maxBehindCommits ?? 25;
18
+ const maxAgeHours = opts.maxBaseAgeHours ?? 24;
19
+ const errors = [];
20
+ const warnings = [];
21
+
22
+ if (!envelope || typeof envelope !== "object") {
23
+ return { ok: false, code: 1, errors: ["Envelope must be a non-null object"], warnings: [] };
24
+ }
25
+
26
+ // 1. Mandatory Fields
27
+ if (!envelope.intent || typeof envelope.intent !== "string" || !envelope.intent.trim()) {
28
+ errors.push("Envelope missing required 'intent' string.");
29
+ }
30
+
31
+ // 2. Base Commit & Freshness Check
32
+ const baseCommit = envelope.base_commit || "main";
33
+ let resolvedBase = null;
34
+ try {
35
+ resolvedBase = resolveBase(root, baseCommit);
36
+ } catch (_) {
37
+ try {
38
+ resolvedBase = resolveBase(root, "HEAD");
39
+ } catch (_) {
40
+ resolvedBase = null;
41
+ }
42
+ }
43
+
44
+ if (resolvedBase) {
45
+ try {
46
+ const behindStr = git(["rev-list", "--count", `${resolvedBase}..origin/main`], { cwd: root, ignoreError: true });
47
+ const behindCount = parseInt(behindStr || "0", 10);
48
+ if (!isNaN(behindCount) && behindCount > maxBehind) {
49
+ errors.push(`Stale base commit: base is ${behindCount} commits behind origin/main (max allowed: ${maxBehind}).`);
50
+ }
51
+
52
+ const commitTimeStr = git(["show", "-s", "--format=%ct", resolvedBase], { cwd: root, ignoreError: true });
53
+ const commitTimestamp = parseInt(commitTimeStr || "0", 10);
54
+ if (commitTimestamp > 0) {
55
+ const nowSec = Math.floor(Date.now() / 1000);
56
+ const ageHours = (nowSec - commitTimestamp) / 3600;
57
+ if (ageHours > maxAgeHours) {
58
+ warnings.push(`Base commit is ${Math.round(ageHours)}h old (exceeds recommended ${maxAgeHours}h).`);
59
+ }
60
+ }
61
+ } catch (_) {
62
+ // Git commit range checks are non-blocking if origin/main is not fetched locally
63
+ }
64
+ }
65
+
66
+ // 3. Premise Check: Referenced Paths Must Exist
67
+ if (Array.isArray(envelope.referenced_paths)) {
68
+ for (const relPath of envelope.referenced_paths) {
69
+ if (typeof relPath !== "string") continue;
70
+ const absPath = join(root, relPath);
71
+ let existsInGit = false;
72
+ if (resolvedBase) {
73
+ const catRes = git(["cat-file", "-e", `${resolvedBase}:${relPath}`], { cwd: root, ignoreError: true });
74
+ existsInGit = typeof catRes === "string" && catRes.trim() !== "";
75
+ }
76
+ const existsOnDisk = existsSync(absPath);
77
+ if (!existsInGit && !existsOnDisk) {
78
+ errors.push(`Premise failure: referenced path '${relPath}' does not exist at base '${baseCommit}' or on disk.`);
79
+ }
80
+ }
81
+ }
82
+
83
+ // 4. Allowed Paths & Protected Scope Check
84
+ if (Array.isArray(envelope.allowed_paths) && envelope.allowed_paths.length > 0) {
85
+ const scope = normalizeScope(opts.scopeConfig || {});
86
+ const scopeCheck = checkScope(envelope.allowed_paths, scope);
87
+ if (!scopeCheck.ok) {
88
+ errors.push(`Allowed paths violate protected scope: ${scopeCheck.violations.map(v => `${v.file} (${v.reason})`).join(", ")}`);
89
+ }
90
+ }
91
+
92
+ // 5. Acceptance Criteria
93
+ if (envelope.acceptance_criteria !== undefined) {
94
+ if (!Array.isArray(envelope.acceptance_criteria) || envelope.acceptance_criteria.length === 0) {
95
+ errors.push("acceptance_criteria must be a non-empty array of strings when provided.");
96
+ }
97
+ }
98
+
99
+ return {
100
+ ok: errors.length === 0,
101
+ code: errors.length === 0 ? 0 : 1,
102
+ errors,
103
+ warnings,
104
+ };
105
+ }