jules-orchestrator-kit 0.54.1 → 0.56.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/README.md +3 -1
- package/package.json +1 -1
- package/scripts/run-tests.mjs +78 -3
- package/src/config.mjs +7 -0
- package/src/engine.mjs +102 -2
- package/src/evidence.mjs +69 -2
- package/src/git.mjs +91 -21
- package/src/provider.mjs +9 -2
- package/src/security.mjs +48 -1
- package/src/session-ops.mjs +12 -0
- package/src/state.mjs +34 -0
- package/src/wizard-init.mjs +20 -0
package/README.md
CHANGED
|
@@ -110,6 +110,7 @@ repository rather than from a template.
|
|
|
110
110
|
|
|
111
111
|
| What differs | How it is resolved | Inspect / override |
|
|
112
112
|
| :--- | :--- | :--- |
|
|
113
|
+
| **Which suites to run** | A monorepo change resolves to the sub-projects it touches (`verify.scope: affected`), widening back to the root command as soon as it reaches a shared file. Off by default, on for repositories `init` detects as monorepos. | `agentctl check --json` · `verify.scope` in `.agent/config.yml` |
|
|
113
114
|
| **The stack** | `detectStack()` recognises 24+ ecosystems (Cargo, Go, Python/Django, Maven/Gradle, .NET, PHP/Laravel, Ruby, Elixir, Swift, Flutter/Dart, CMake, Bun, Deno, Node + Turbo/pnpm/Nx workspaces) and derives the setup, lint, test and build commands from the manifest it finds. | `agentctl doctor` · `verify:` in `.agent/config.yml` |
|
|
114
115
|
| **The agent** | `provider:` selects Google Jules (hosted REST), the Claude Code CLI, the Codex CLI or the Gemini CLI. Readiness means a credential for the hosted one and a binary on `PATH` for the local ones — never both. | `agentctl providers` · `agentctl init --provider <name>` |
|
|
115
116
|
| **How hard to verify** | `verify.profile` expands at load time into a stage pipeline that skips gates the runtime cannot support, and says which and why. | `agentctl profile` · `agentctl profile --set max` |
|
|
@@ -198,11 +199,12 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
|
|
|
198
199
|
* **Cross-Platform Parity:** Verified 100% green across Linux, macOS (Darwin), and Windows on Node 20, 22, and 24.
|
|
199
200
|
* **Autonomous Self-Healing Loop:** Captures test stderr/stdout, fingerprints error traces, and feeds structured context back into automated repair turns (up to 3 attempts) before human escalation.
|
|
200
201
|
* **Fail-Closed Verification:** A change that ran no verification command at all is rejected, not approved — "nothing to run" is not a pass. Repositories using only the scope and secret phases opt out explicitly with `verify.required: false`.
|
|
202
|
+
* **Anti-Tamper That Reads Semantics:** Counting assertions cannot see a value check swapped for a truthiness check. The guard tracks assertions that name an expected value, so weakening a test is a violation even when the line count is unchanged.
|
|
201
203
|
* **Binary-Aware Scanning:** Files git renders as `Binary files ... differ` are read directly for structured credentials, and their real size is charged against the diff ceiling, so a leading NUL byte cannot hide a token and a committed blob cannot walk past the payload governor.
|
|
202
204
|
* **Fail-Closed Security & Secret Redaction:** Evaluates explicit Deny rules before Allow rules against canonicalized, case-folded paths. Redacts high-entropy keys and base64-encoded credentials (such as Kubernetes `Secret` manifests).
|
|
203
205
|
* **Complexity & Cost Router:** Zero-dependency heuristic classifier (`src/router.mjs`) routing mechanical tasks to lightweight models while reserving primary models for complex refactors, with a `node --check` syntax-verification gate that transparently escalates a FAST-tier result to the primary provider if it left broken JS on disk.
|
|
204
206
|
* **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
|
|
205
|
-
* **Verified Test Suite:** Tested with **
|
|
207
|
+
* **Verified Test Suite:** Tested with **851 unit tests across 115 suites passing in < 14.0s**.
|
|
206
208
|
|
|
207
209
|
<br/>
|
|
208
210
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jules-orchestrator-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.0",
|
|
4
4
|
"description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for autonomous coding agents — Google Jules, Claude Code, Codex and Gemini CLI.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { cpus } from "node:os";
|
|
5
6
|
|
|
6
7
|
const root = process.cwd();
|
|
7
8
|
const testDir = join(root, "test");
|
|
@@ -24,10 +25,84 @@ const [major, minor] = process.versions.node.split(".").map(Number);
|
|
|
24
25
|
const supportsTestTimeout = major > 20 || (major === 20 && minor >= 6);
|
|
25
26
|
const timeoutArgs = supportsTestTimeout ? [`--test-timeout=${timeoutMs}`] : [];
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
// Node runs one test file per core by default. Several suites here spawn a
|
|
29
|
+
// verification command of their own — `npm test`, and in the monorepo fixtures
|
|
30
|
+
// an `npm test --workspaces` that fans out to one node per package — so the
|
|
31
|
+
// real peak is a multiple of the file count, not the file count. On a
|
|
32
|
+
// twelve-core laptop that saturates every core for the length of the run,
|
|
33
|
+
// which cooks the machine and makes the throughput assertions (1000 telemetry
|
|
34
|
+
// appends under 6s) fail for reasons that have nothing to do with the code.
|
|
35
|
+
//
|
|
36
|
+
// Half the cores keeps the suite comfortably parallel while leaving room for
|
|
37
|
+
// the children it spawns. JULES_TEST_CONCURRENCY overrides it — CI runners
|
|
38
|
+
// with two cores are already below this and are unaffected.
|
|
39
|
+
const cpuCount = Math.max(1, cpus().length);
|
|
40
|
+
const envConcurrency = Number(process.env.JULES_TEST_CONCURRENCY);
|
|
41
|
+
const concurrency = Number.isFinite(envConcurrency) && envConcurrency > 0
|
|
42
|
+
? Math.floor(envConcurrency)
|
|
43
|
+
: Math.max(2, Math.floor(cpuCount / 2));
|
|
44
|
+
const concurrencyArgs = supportsTestTimeout ? [`--test-concurrency=${concurrency}`] : [];
|
|
45
|
+
|
|
46
|
+
// The runner leads its own process group, and nothing outlives it.
|
|
47
|
+
//
|
|
48
|
+
// `spawnSync` kills only the direct child when a run is interrupted, and this
|
|
49
|
+
// suite's children spawn children of their own — a verification command, an
|
|
50
|
+
// `npm` that fans out to a node per workspace, a git subprocess per fixture.
|
|
51
|
+
// Interrupting a run therefore orphaned everything below the first level, and
|
|
52
|
+
// those orphans kept running and kept spawning: a laptop accumulated enough of
|
|
53
|
+
// them across several interrupted runs to exhaust 32 GB of RAM and 24 GB of
|
|
54
|
+
// swap, and two CI runners logged pages of "Terminate orphan process" at the
|
|
55
|
+
// end of a job that had already failed.
|
|
56
|
+
//
|
|
57
|
+
// Detaching makes the child a process-group leader, so one signal to the
|
|
58
|
+
// negated pid reaches the whole tree.
|
|
59
|
+
const child = spawn(process.execPath, ["--test", ...timeoutArgs, ...concurrencyArgs, ...testFiles], {
|
|
28
60
|
cwd: root,
|
|
29
61
|
stdio: "inherit",
|
|
30
62
|
env: process.env,
|
|
63
|
+
detached: process.platform !== "win32",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Kill the runner and everything it spawned.
|
|
68
|
+
*
|
|
69
|
+
* POSIX takes a signal to `-pid`, which addresses the whole process group.
|
|
70
|
+
* Windows has no such concept, so `taskkill /T` walks the tree instead.
|
|
71
|
+
*/
|
|
72
|
+
function reap(signal = "SIGKILL") {
|
|
73
|
+
if (!child.pid) return;
|
|
74
|
+
try {
|
|
75
|
+
if (process.platform === "win32") {
|
|
76
|
+
spawnSync("taskkill", ["/T", "/F", "/PID", String(child.pid)], { stdio: "ignore" });
|
|
77
|
+
} else {
|
|
78
|
+
process.kill(-child.pid, signal);
|
|
79
|
+
}
|
|
80
|
+
} catch (_) {
|
|
81
|
+
// Already gone, or never had a group. Nothing left to reap either way.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Ctrl-C, a CI cancellation and a harness teardown all arrive as signals; each
|
|
86
|
+
// must take the tree with it rather than detaching it from its parent.
|
|
87
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
88
|
+
process.on(sig, () => {
|
|
89
|
+
reap("SIGTERM");
|
|
90
|
+
// Give the group a moment to unwind, then make sure.
|
|
91
|
+
setTimeout(() => {
|
|
92
|
+
reap("SIGKILL");
|
|
93
|
+
process.exit(130);
|
|
94
|
+
}, 2000).unref();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
child.on("exit", (code, signal) => {
|
|
99
|
+
// Stragglers outlive a clean exit too: a test that spawned a server and
|
|
100
|
+
// failed before its teardown leaves it running.
|
|
101
|
+
reap("SIGKILL");
|
|
102
|
+
process.exit(signal ? 1 : code ?? 1);
|
|
31
103
|
});
|
|
32
104
|
|
|
33
|
-
|
|
105
|
+
child.on("error", (err) => {
|
|
106
|
+
console.error(`Test runner failed to start: ${err.message}`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
});
|
package/src/config.mjs
CHANGED
|
@@ -522,6 +522,13 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
|
|
|
522
522
|
teardown: rawTeardown ?? autoVerify.teardown ?? "",
|
|
523
523
|
build: rawBuild ?? autoVerify.build,
|
|
524
524
|
policy: parsed.verify?.policy ?? autoVerify.policy,
|
|
525
|
+
// "global" runs the repository's own verify commands; "affected" resolves
|
|
526
|
+
// the changed files to their sub-projects and runs only those suites. Opt-in
|
|
527
|
+
// on purpose: silently narrowing which tests run is the same class of defect
|
|
528
|
+
// as approving a change that ran none, and an existing repository's gate
|
|
529
|
+
// must not change meaning on an upgrade. `agentctl init` writes "affected"
|
|
530
|
+
// for a repository it detects as a monorepo.
|
|
531
|
+
scope: parsed.verify?.scope === "affected" ? "affected" : "global",
|
|
525
532
|
// Whether the gate may approve a change that ran no verification at all.
|
|
526
533
|
// True by default: "nothing to run" is not a pass, and a security tool that
|
|
527
534
|
// says APPROVED after checking nothing is worse than no tool. A repository
|
package/src/engine.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadConfig, parseYaml, normalizeScope } from "./config.mjs";
|
|
2
2
|
import { checkScope, scanDiff, scanBinaryPayloads, redactSecrets } from "./security.mjs";
|
|
3
|
-
import { changedFiles, diffBytes, diffText, binaryDiffEntries, showFromOrigin, runCmd } from "./git.mjs";
|
|
3
|
+
import { changedFiles, diffBytes, diffText, binaryDiffEntries, symlinkChanges, showFromOrigin, runCmd } from "./git.mjs";
|
|
4
4
|
import { createProvider, ProviderRateLimitError, ProviderUnavailableError } from "./provider.mjs";
|
|
5
5
|
import { resolveRoutedProvider } from "./router.mjs";
|
|
6
6
|
import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReservation, isConcurrencyGroupLocked, checkDailyBudget } from "./state.mjs";
|
|
@@ -20,6 +20,7 @@ import { resolveRolePrompt } from "./role-resolver.mjs";
|
|
|
20
20
|
|
|
21
21
|
import { runAssertion } from "./assertions.mjs";
|
|
22
22
|
import { buildDefaultStages } from "./profiles.mjs";
|
|
23
|
+
import { resolveWorkspaceBoundary } from "./stack-detector.mjs";
|
|
23
24
|
import {
|
|
24
25
|
computeDirectoryHash,
|
|
25
26
|
generateEvidenceManifest,
|
|
@@ -201,6 +202,7 @@ export async function gate(opts = {}) {
|
|
|
201
202
|
// Read from the base commit like every other trusted field: an
|
|
202
203
|
// uncommitted `required: false` must not be able to switch the gate off.
|
|
203
204
|
required: parsed.verify?.required !== undefined ? parsed.verify.required !== false : config.verify.required !== false,
|
|
205
|
+
scope: parsed.verify?.scope || config.verify.scope || "global",
|
|
204
206
|
timeoutMs: parsed.verify?.timeoutMs || parsed.verify?.timeout_ms || config.verify.timeoutMs,
|
|
205
207
|
};
|
|
206
208
|
}
|
|
@@ -213,9 +215,37 @@ export async function gate(opts = {}) {
|
|
|
213
215
|
}
|
|
214
216
|
}
|
|
215
217
|
|
|
216
|
-
|
|
218
|
+
// A symlink is judged by its own name, so `notes.md -> .agent/config.yml`
|
|
219
|
+
// walked straight past a deny list that names the target. Judge both: the
|
|
220
|
+
// link because it is what the diff adds, and the path it resolves to because
|
|
221
|
+
// that is what it grants reach to.
|
|
222
|
+
let symlinks = [];
|
|
223
|
+
try {
|
|
224
|
+
symlinks = symlinkChanges(root, base, mode);
|
|
225
|
+
} catch (_) {
|
|
226
|
+
// Scope must still be enforced on the ordinary file list if git cannot
|
|
227
|
+
// describe the links.
|
|
228
|
+
}
|
|
229
|
+
const scopeCandidates = [...files];
|
|
230
|
+
const symlinkTargetOf = new Map();
|
|
231
|
+
for (const { link, target } of symlinks) {
|
|
232
|
+
if (!target || scopeCandidates.includes(target)) continue;
|
|
233
|
+
scopeCandidates.push(target);
|
|
234
|
+
symlinkTargetOf.set(target, link);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const scopeResult = checkScope(scopeCandidates, trustedScope, {
|
|
217
238
|
allowProtected: opts.allowProtected || process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "true",
|
|
218
239
|
});
|
|
240
|
+
// Report the violation against the link the change actually introduced, not
|
|
241
|
+
// against a path the diff never names — the operator has to be able to find it.
|
|
242
|
+
for (const violation of scopeResult.violations || []) {
|
|
243
|
+
const link = symlinkTargetOf.get(violation.file);
|
|
244
|
+
if (!link) continue;
|
|
245
|
+
violation.reason = `${violation.reason} (reached through symlink ${link})`;
|
|
246
|
+
violation.symlink = link;
|
|
247
|
+
violation.file = link;
|
|
248
|
+
}
|
|
219
249
|
|
|
220
250
|
phases.push({ phase: "scope", ok: scopeResult.ok, violations: scopeResult.violations });
|
|
221
251
|
appendTelemetry(root, "gate_phase", { phase: "scope", ok: scopeResult.ok });
|
|
@@ -291,9 +321,52 @@ export async function gate(opts = {}) {
|
|
|
291
321
|
NODE_OPTIONS: guardNodeOptions,
|
|
292
322
|
};
|
|
293
323
|
|
|
324
|
+
// Node's test runner talks to its children through NODE_TEST_CONTEXT and
|
|
325
|
+
// NODE_CHANNEL_FD. Inherited by a verification command that is itself a
|
|
326
|
+
// `node --test` run, the child switches into child-reporter mode and its
|
|
327
|
+
// failures stop reaching the exit code — the gate then sees exit 0 and
|
|
328
|
+
// approves a change whose tests failed. src/perf.mjs already strips these for
|
|
329
|
+
// the same reason; the gate, which is the one that decides, did not.
|
|
330
|
+
for (const key of Object.keys(testEnv)) {
|
|
331
|
+
if (key.startsWith("NODE_TEST_") || key.startsWith("NODE_CHANNEL_")) delete testEnv[key];
|
|
332
|
+
}
|
|
333
|
+
|
|
294
334
|
let flakyVerdictResult = null;
|
|
295
335
|
const verifyTimeout = trustedVerify.timeoutMs || 60000;
|
|
296
336
|
|
|
337
|
+
// Monorepo scoping: run the suites the change can actually break.
|
|
338
|
+
//
|
|
339
|
+
// `resolveWorkspaceBoundary()` shipped, was drawn in the architecture
|
|
340
|
+
// diagrams and documented as a headline feature — and was called by nothing.
|
|
341
|
+
// Every change in a monorepo ran the root suite, so a one-package edit was
|
|
342
|
+
// gated on every other package's tests. It resolves the changed files to
|
|
343
|
+
// their sub-projects and composes the per-project commands.
|
|
344
|
+
//
|
|
345
|
+
// It stays opt-in (`verify.scope: affected`) because narrowing what runs is
|
|
346
|
+
// only safe when someone asked for it, and it yields to the global command
|
|
347
|
+
// whenever a shared file is touched or no sub-project command is found — a
|
|
348
|
+
// narrower run that misses the breakage is worse than a slow one.
|
|
349
|
+
let boundary = null;
|
|
350
|
+
if (trustedVerify.scope === "affected" && !(Array.isArray(trustedVerify.stages) && trustedVerify.stages.length > 0)) {
|
|
351
|
+
try {
|
|
352
|
+
boundary = resolveWorkspaceBoundary(files, root);
|
|
353
|
+
} catch (_) {
|
|
354
|
+
boundary = null;
|
|
355
|
+
}
|
|
356
|
+
if (boundary && boundary.isMonorepo && !boundary.globalFallback && boundary.testCmd) {
|
|
357
|
+
trustedVerify = {
|
|
358
|
+
...trustedVerify,
|
|
359
|
+
test: boundary.testCmd,
|
|
360
|
+
unit: boundary.testCmd,
|
|
361
|
+
build: boundary.buildCmd || trustedVerify.build,
|
|
362
|
+
};
|
|
363
|
+
appendTelemetry(root, "verify_scope_narrowed", {
|
|
364
|
+
projects: boundary.projects.map((p) => p.path),
|
|
365
|
+
testCmd: boundary.testCmd,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
297
370
|
// Build sequential execution pipeline (Setup -> Lint -> Test/Unit -> Fuzz -> Invariant -> E2E -> Build -> Server -> Teardown)
|
|
298
371
|
const stagesToRun = [];
|
|
299
372
|
if (Array.isArray(trustedVerify.stages) && trustedVerify.stages.length > 0) {
|
|
@@ -966,6 +1039,33 @@ export async function dispatch(task = {}, opts = {}) {
|
|
|
966
1039
|
|
|
967
1040
|
const cleanTask = { ...task, prompt: envelopedPrompt };
|
|
968
1041
|
|
|
1042
|
+
// Snapshot the tree before anything can change it.
|
|
1043
|
+
//
|
|
1044
|
+
// `agentctl rollback` shipped, was documented, and could never work:
|
|
1045
|
+
// createCheckpoint() was defined and called from nowhere, so the checkpoint
|
|
1046
|
+
// directory was always empty and the command answered "No checkpoints found"
|
|
1047
|
+
// to everyone who reached for it — at exactly the moment they needed it. An
|
|
1048
|
+
// exec provider edits this working tree directly, and `patch --apply` writes
|
|
1049
|
+
// into it later, so this is the last moment the pre-agent state exists.
|
|
1050
|
+
//
|
|
1051
|
+
// Never fatal: a repository that cannot be snapshotted (no git, no disk) must
|
|
1052
|
+
// still be able to dispatch. A missing checkpoint costs a rollback; a throw
|
|
1053
|
+
// here costs the task.
|
|
1054
|
+
if (!opts.dryRun && opts.checkpoint !== false) {
|
|
1055
|
+
try {
|
|
1056
|
+
const { createCheckpoint } = await import("./ops/checkpoint.mjs");
|
|
1057
|
+
const checkpointId = String(task.id || task.taskId || `dispatch-${Date.now()}`).replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
1058
|
+
const snapshot = createCheckpoint(checkpointId, { root });
|
|
1059
|
+
appendTelemetry(root, "checkpoint_created", {
|
|
1060
|
+
id: snapshot.id,
|
|
1061
|
+
headSha: snapshot.headSha,
|
|
1062
|
+
uncommittedFiles: snapshot.uncommittedFiles?.length || 0,
|
|
1063
|
+
});
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
console.warn(`⚠️ Could not create a pre-flight checkpoint (${err.message}). \`agentctl rollback\` will not be able to restore this dispatch.`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
969
1069
|
const runDispatch = () => provider.dispatch(cleanTask, { root, dryRun: opts.dryRun });
|
|
970
1070
|
|
|
971
1071
|
// An estimated ceiling may warn but must not block: refusing a dispatch the
|
package/src/evidence.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
renameSync,
|
|
12
12
|
unlinkSync,
|
|
13
13
|
} from "node:fs";
|
|
14
|
-
import { join, resolve, relative, isAbsolute, sep } from "node:path";
|
|
14
|
+
import { join, resolve, relative, isAbsolute, sep, extname } from "node:path";
|
|
15
15
|
import { createHash, randomUUID } from "node:crypto";
|
|
16
16
|
import { execSync } from "node:child_process";
|
|
17
17
|
|
|
@@ -83,6 +83,19 @@ export function findFilesRecursively(dir, baseDir = dir) {
|
|
|
83
83
|
* @param {string[]} [options.directories] - Specific directory names to scan (e.g. ['test', 'tests'])
|
|
84
84
|
* @returns {{ treeHash: string, fileCount: number, fileHashes: Record<string, string> }}
|
|
85
85
|
*/
|
|
86
|
+
/**
|
|
87
|
+
* Extensions that count as code when scanning the repository root.
|
|
88
|
+
*
|
|
89
|
+
* The directory walk above takes everything under `src/` and the test
|
|
90
|
+
* directories; this list only governs the loose files beside package.json,
|
|
91
|
+
* where a `.md` is documentation rather than something the evidence attests to.
|
|
92
|
+
*/
|
|
93
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
94
|
+
".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts", ".cts",
|
|
95
|
+
".py", ".go", ".rs", ".rb", ".php", ".java", ".kt", ".kts", ".cs", ".fs",
|
|
96
|
+
".swift", ".dart", ".ex", ".exs", ".c", ".h", ".cc", ".cpp", ".hpp", ".sol",
|
|
97
|
+
]);
|
|
98
|
+
|
|
86
99
|
export function computeDirectoryHash(root, options = {}) {
|
|
87
100
|
let fileList = [];
|
|
88
101
|
|
|
@@ -100,6 +113,29 @@ export function computeDirectoryHash(root, options = {}) {
|
|
|
100
113
|
fileList.push(...found);
|
|
101
114
|
}
|
|
102
115
|
}
|
|
116
|
+
|
|
117
|
+
// Plenty of projects keep `app.test.mjs` or `index.js` beside package.json
|
|
118
|
+
// rather than under one of the directories above, and those files were
|
|
119
|
+
// invisible to every hash computed here — a manifest could attest to a
|
|
120
|
+
// pristine test suite while the only test in the repository had been
|
|
121
|
+
// replaced with garbage.
|
|
122
|
+
//
|
|
123
|
+
// Depth one only: recursing from the root would walk node_modules and
|
|
124
|
+
// vendor trees. Source extensions only: the hash exists to bind the
|
|
125
|
+
// manifest to the code it verified, and pulling in README.md or the
|
|
126
|
+
// EVIDENCE.md this very command is about to write would make the hash churn
|
|
127
|
+
// on its own output.
|
|
128
|
+
try {
|
|
129
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
130
|
+
if (!entry.isFile()) continue;
|
|
131
|
+
if (entry.name.startsWith(".")) continue;
|
|
132
|
+
if (!SOURCE_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
|
|
133
|
+
fileList.push(normalizePath(entry.name));
|
|
134
|
+
}
|
|
135
|
+
} catch (_) {
|
|
136
|
+
// An unreadable root yields whatever the directory walk already found.
|
|
137
|
+
}
|
|
138
|
+
|
|
103
139
|
fileList = Array.from(new Set(fileList)).sort();
|
|
104
140
|
}
|
|
105
141
|
|
|
@@ -154,6 +190,7 @@ export function computeEvidenceHash(manifest) {
|
|
|
154
190
|
intent: manifest.intent,
|
|
155
191
|
provenance: manifest.provenance,
|
|
156
192
|
testIntegrity: manifest.testIntegrity,
|
|
193
|
+
...(manifest.sourceIntegrity ? { sourceIntegrity: manifest.sourceIntegrity } : {}),
|
|
157
194
|
executionRecords: manifest.executionRecords,
|
|
158
195
|
securityChecks: manifest.securityChecks,
|
|
159
196
|
...(manifest.status ? { status: manifest.status } : {}),
|
|
@@ -235,6 +272,7 @@ export function generateEvidenceManifest(root = process.cwd(), options = {}) {
|
|
|
235
272
|
// Compute test tree integrity
|
|
236
273
|
const preTestHash = options.preTestHash || null;
|
|
237
274
|
const currentTestState = computeDirectoryHash(root, { testOnly: true });
|
|
275
|
+
const currentSourceState = computeDirectoryHash(root);
|
|
238
276
|
const postTestHash = currentTestState.treeHash;
|
|
239
277
|
|
|
240
278
|
let tamperDetected = false;
|
|
@@ -278,6 +316,14 @@ export function generateEvidenceManifest(root = process.cwd(), options = {}) {
|
|
|
278
316
|
testFileCount: currentTestState.fileCount,
|
|
279
317
|
fileHashes: currentTestState.fileHashes,
|
|
280
318
|
},
|
|
319
|
+
// The manifest attested to the test files and to nothing else, so the code
|
|
320
|
+
// under test could be replaced wholesale after the fact and verification
|
|
321
|
+
// still passed. Evidence that survives the thing it attests to being
|
|
322
|
+
// rewritten is not evidence.
|
|
323
|
+
sourceIntegrity: {
|
|
324
|
+
treeHash: currentSourceState.treeHash,
|
|
325
|
+
fileCount: currentSourceState.fileCount,
|
|
326
|
+
},
|
|
281
327
|
executionRecords: options.executionRecords || [],
|
|
282
328
|
securityChecks: {
|
|
283
329
|
secretScanOk: options.secretScanOk ?? true,
|
|
@@ -466,7 +512,28 @@ export function verifyEvidenceManifest(root = process.cwd(), manifestOrPath = "m
|
|
|
466
512
|
};
|
|
467
513
|
}
|
|
468
514
|
|
|
469
|
-
// 3. Verify
|
|
515
|
+
// 3. Verify the code the manifest attests to still is that code.
|
|
516
|
+
//
|
|
517
|
+
// Without this the manifest proved only that the *tests* had not changed,
|
|
518
|
+
// so `evidence generate` followed by rewriting src/ and committing left
|
|
519
|
+
// verification reporting PASSED over an implementation nobody had checked.
|
|
520
|
+
if (manifest.sourceIntegrity?.treeHash) {
|
|
521
|
+
const currentSourceState = computeDirectoryHash(root);
|
|
522
|
+
if (currentSourceState.treeHash !== manifest.sourceIntegrity.treeHash) {
|
|
523
|
+
return {
|
|
524
|
+
ok: false,
|
|
525
|
+
reason: `Source tree has changed since this evidence was generated (${manifest.sourceIntegrity.treeHash.slice(0, 12)} → ${currentSourceState.treeHash.slice(0, 12)}); the manifest no longer attests to what is on disk`,
|
|
526
|
+
details: {
|
|
527
|
+
currentHash: currentSourceState.treeHash,
|
|
528
|
+
manifestHash: manifest.sourceIntegrity.treeHash,
|
|
529
|
+
manifestCommit: manifest.provenance?.commitSha || null,
|
|
530
|
+
currentCommit: getGitProvenance(root).commitSha,
|
|
531
|
+
},
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 4. Verify security checks
|
|
470
537
|
if (manifest.securityChecks?.secretScanOk === false) {
|
|
471
538
|
return { ok: false, reason: "Evidence manifest records secret scanning failure" };
|
|
472
539
|
}
|
package/src/git.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync, execSync } from "node:child_process";
|
|
2
|
-
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { readFileSync, existsSync, statSync, readlinkSync } from "node:fs";
|
|
3
3
|
import { join, delimiter } from "node:path";
|
|
4
|
-
import { normalizePath } from "./config.mjs";
|
|
4
|
+
import { normalizePath, canonicalizePath } from "./config.mjs";
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
export const NET_GUARD_PRELOAD_URL = new URL("./preload-net-guard.mjs", import.meta.url).href;
|
|
@@ -412,14 +412,14 @@ export function diffText(root = process.cwd(), base = "main", mode = "committed"
|
|
|
412
412
|
* @param {string} mode
|
|
413
413
|
* @returns {Array<{ file: string, bytes: number }>}
|
|
414
414
|
*/
|
|
415
|
-
export function
|
|
415
|
+
export function parseRawDiff(root = process.cwd(), base = "main", mode = "committed") {
|
|
416
416
|
const resolvedRef = resolveBase(root, base);
|
|
417
417
|
const ranges =
|
|
418
418
|
mode === "working-tree" || mode === "working"
|
|
419
419
|
? [[`${resolvedRef}...HEAD`], ["HEAD"]]
|
|
420
420
|
: [[`${resolvedRef}...HEAD`]];
|
|
421
421
|
|
|
422
|
-
const
|
|
422
|
+
const out = [];
|
|
423
423
|
for (const range of ranges) {
|
|
424
424
|
let raw = "";
|
|
425
425
|
try {
|
|
@@ -433,29 +433,99 @@ export function binaryDiffEntries(root = process.cwd(), base = "main", mode = "c
|
|
|
433
433
|
const meta = fields[i];
|
|
434
434
|
if (!meta.startsWith(":")) continue;
|
|
435
435
|
const parts = meta.slice(1).split(/\s+/);
|
|
436
|
-
const dstSha = parts[3];
|
|
437
|
-
const status = (parts[4] || "").charAt(0);
|
|
438
436
|
const file = fields[i + 1];
|
|
439
437
|
i += 1;
|
|
440
|
-
if (!file
|
|
438
|
+
if (!file) continue;
|
|
439
|
+
out.push({
|
|
440
|
+
file,
|
|
441
|
+
srcMode: parts[0] || "",
|
|
442
|
+
dstMode: parts[1] || "",
|
|
443
|
+
srcSha: parts[2] || "",
|
|
444
|
+
dstSha: parts[3] || "",
|
|
445
|
+
status: (parts[4] || "").charAt(0),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
441
451
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
452
|
+
/**
|
|
453
|
+
* Symlinks this change introduces, with the path each one points at.
|
|
454
|
+
*
|
|
455
|
+
* `checkScope` is purely lexical, by design — the paths it judges can come from
|
|
456
|
+
* a diff and need not exist on disk. But that made a symlink a hole straight
|
|
457
|
+
* through it: a link named `notes.md` pointing at `.agent/config.yml` is judged
|
|
458
|
+
* as `notes.md`, and the protected path it resolves to is never considered.
|
|
459
|
+
* Resolving here, rather than inside `checkScope`, keeps that function lexical
|
|
460
|
+
* and testable while letting the gate judge both names.
|
|
461
|
+
*
|
|
462
|
+
* The target is resolved lexically against the link's own directory: it may
|
|
463
|
+
* point outside the repository, and following it on disk would be the wrong
|
|
464
|
+
* thing to do with an untrusted path.
|
|
465
|
+
*
|
|
466
|
+
* @param {string} root
|
|
467
|
+
* @param {string} base
|
|
468
|
+
* @param {string} mode
|
|
469
|
+
* @returns {Array<{ link: string, target: string }>}
|
|
470
|
+
*/
|
|
471
|
+
export function symlinkChanges(root = process.cwd(), base = "main", mode = "committed") {
|
|
472
|
+
const SYMLINK_MODE = "120000";
|
|
473
|
+
const results = [];
|
|
474
|
+
const seen = new Set();
|
|
475
|
+
|
|
476
|
+
for (const entry of parseRawDiff(root, base, mode)) {
|
|
477
|
+
if (entry.status === "D") continue;
|
|
478
|
+
if (entry.dstMode !== SYMLINK_MODE) continue;
|
|
479
|
+
if (seen.has(entry.file)) continue;
|
|
480
|
+
seen.add(entry.file);
|
|
481
|
+
|
|
482
|
+
// A symlink's blob content is its target path.
|
|
483
|
+
let target = "";
|
|
484
|
+
if (entry.dstSha && !/^0+$/.test(entry.dstSha)) {
|
|
485
|
+
target = (git(["cat-file", "blob", entry.dstSha], { cwd: root, ignoreError: true }) || "").trim();
|
|
486
|
+
}
|
|
487
|
+
if (!target) {
|
|
488
|
+
try {
|
|
489
|
+
target = readlinkSync(join(root, entry.file));
|
|
490
|
+
} catch (_) {
|
|
491
|
+
continue;
|
|
446
492
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
493
|
+
}
|
|
494
|
+
if (!target) continue;
|
|
495
|
+
|
|
496
|
+
const linkDir = normalizePath(entry.file).split("/").slice(0, -1).join("/");
|
|
497
|
+
const resolved = normalizePath(target).startsWith("/")
|
|
498
|
+
? normalizePath(target)
|
|
499
|
+
: canonicalizePath(linkDir ? `${linkDir}/${target}` : target);
|
|
500
|
+
|
|
501
|
+
results.push({ link: entry.file, target: resolved });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return results;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function binaryDiffEntries(root = process.cwd(), base = "main", mode = "committed") {
|
|
508
|
+
const entries = new Map();
|
|
509
|
+
for (const entry of parseRawDiff(root, base, mode)) {
|
|
510
|
+
if (entry.status === "D") continue;
|
|
511
|
+
const { file, dstSha } = entry;
|
|
512
|
+
|
|
513
|
+
let bytes = 0;
|
|
514
|
+
if (dstSha && !/^0+$/.test(dstSha)) {
|
|
515
|
+
const size = git(["cat-file", "-s", dstSha], { cwd: root, ignoreError: true });
|
|
516
|
+
bytes = Number(size) || 0;
|
|
517
|
+
}
|
|
518
|
+
// An unstaged change has an all-zero destination sha; the working file is
|
|
519
|
+
// the only place its size exists.
|
|
520
|
+
if (!bytes) {
|
|
521
|
+
try {
|
|
522
|
+
bytes = statSync(join(root, file)).size;
|
|
523
|
+
} catch (_) {
|
|
524
|
+
bytes = 0;
|
|
455
525
|
}
|
|
456
|
-
// Keep the largest observation: the same path can appear in both ranges.
|
|
457
|
-
entries.set(file, Math.max(entries.get(file) || 0, bytes));
|
|
458
526
|
}
|
|
527
|
+
// Keep the largest observation: the same path can appear in both ranges.
|
|
528
|
+
entries.set(file, Math.max(entries.get(file) || 0, bytes));
|
|
459
529
|
}
|
|
460
530
|
|
|
461
531
|
// Only the paths git itself refused to render as text are relevant; a file
|
package/src/provider.mjs
CHANGED
|
@@ -673,10 +673,17 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
673
673
|
},
|
|
674
674
|
|
|
675
675
|
async getSession(sessionId, ctx = {}) {
|
|
676
|
-
if (!
|
|
676
|
+
if (!ctx || typeof ctx !== "object") ctx = {};
|
|
677
|
+
// `listSources()` reuses this method purely as an authenticated GET, and
|
|
678
|
+
// passes `customUrl` with no session to fetch. The guard rejected the
|
|
679
|
+
// empty id before the url was even looked at, so listing a repository's
|
|
680
|
+
// connected sources threw a TypeError against the live API every time —
|
|
681
|
+
// a failure no dry-run or unit test could reach, because both stop
|
|
682
|
+
// before the request. A session id is required only when the url is
|
|
683
|
+
// going to be built out of one.
|
|
684
|
+
if (!ctx.customUrl && (!sessionId || typeof sessionId !== "string")) {
|
|
677
685
|
throw new TypeError("getSession() requires a valid sessionId string");
|
|
678
686
|
}
|
|
679
|
-
if (!ctx || typeof ctx !== "object") ctx = {};
|
|
680
687
|
|
|
681
688
|
const pool = ctx.tokenPool || config.tokenPool || TokenPool.fromEnv(config);
|
|
682
689
|
const rawToken = pool.getNextToken() || process.env.JULES_API_KEY || (ctx.allowLegacyKey ? process.env.GEMINI_API_KEY : "") || "";
|
package/src/security.mjs
CHANGED
|
@@ -1120,6 +1120,24 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1120
1120
|
const ASSERTION_PATTERN = /(?:\b(?:assert(?:\.[a-zA-Z0-9_$]+)?|expect|t\.(?:assert|expect|is|equal|true|false|Errorf|Fatalf)|require\.[a-zA-Z0-9_$]+)\b|assert!|assert_eq!|assert_ne!)/i;
|
|
1121
1121
|
const isCommentLine = (str) => /^\s*(?:\/\/|\/\*|\*|#|--|;)/.test(str);
|
|
1122
1122
|
|
|
1123
|
+
// An assertion that states a *specific* expected value. Counting assertions
|
|
1124
|
+
// alone let a test be gutted while looking untouched: swapping
|
|
1125
|
+
// `assert.strictEqual(add(2,3), 5)` for `assert.ok(add(2,3) !== undefined)`
|
|
1126
|
+
// removes one and adds one, so `removed > added` stayed false and the guard
|
|
1127
|
+
// said nothing — while the suite stopped checking the answer.
|
|
1128
|
+
const SPECIFIC_ASSERTION = new RegExp(
|
|
1129
|
+
[
|
|
1130
|
+
"\\bassert(?:\\.strict)?\\.?(?:strictEqual|deepStrictEqual|deepEqual|notStrictEqual|notDeepStrictEqual|equal|notEqual|match|doesNotMatch|throws|rejects|doesNotThrow)\\s*\\(",
|
|
1131
|
+
"\\bexpect\\s*\\([^)]*\\)\\s*\\.(?:toBe|toEqual|toStrictEqual|toMatch|toMatchObject|toContain|toHaveBeenCalledWith|toThrow|toHaveLength|toBeCloseTo)\\s*\\(",
|
|
1132
|
+
"\\bassert\\.(?:equals|deepEquals|include|lengthOf)\\s*\\(",
|
|
1133
|
+
"assert_eq!|assert_ne!",
|
|
1134
|
+
"\\bt\\.(?:Errorf|Fatalf)\\s*\\(",
|
|
1135
|
+
"\\brequire\\.(?:Equal|NotEqual|Len|Contains|Error|NoError)\\s*\\(",
|
|
1136
|
+
].join("|"),
|
|
1137
|
+
"i"
|
|
1138
|
+
);
|
|
1139
|
+
const isSpecificAssertion = (str) => SPECIFIC_ASSERTION.test(str);
|
|
1140
|
+
|
|
1123
1141
|
const fileAssertions = new Map();
|
|
1124
1142
|
|
|
1125
1143
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -1151,7 +1169,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1151
1169
|
}
|
|
1152
1170
|
|
|
1153
1171
|
if (!fileAssertions.has(currentFile)) {
|
|
1154
|
-
fileAssertions.set(currentFile, { removed: [], added: 0 });
|
|
1172
|
+
fileAssertions.set(currentFile, { removed: [], added: 0, removedSpecific: [], addedSpecific: 0 });
|
|
1155
1173
|
}
|
|
1156
1174
|
const fileStats = fileAssertions.get(currentFile);
|
|
1157
1175
|
|
|
@@ -1159,6 +1177,9 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1159
1177
|
const deletedText = line.slice(1);
|
|
1160
1178
|
if (!isCommentLine(deletedText) && ASSERTION_PATTERN.test(deletedText)) {
|
|
1161
1179
|
fileStats.removed.push({ line: currentOldLineNo, text: deletedText });
|
|
1180
|
+
if (isSpecificAssertion(deletedText)) {
|
|
1181
|
+
fileStats.removedSpecific.push({ line: currentOldLineNo, text: deletedText });
|
|
1182
|
+
}
|
|
1162
1183
|
}
|
|
1163
1184
|
if (currentOldLineNo !== null) currentOldLineNo++;
|
|
1164
1185
|
} else if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
@@ -1205,6 +1226,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1205
1226
|
// If valid non-vacuous, non-commented assertion is added, increment added count
|
|
1206
1227
|
if (!isVacuous && !isCommented && !isCommentLine(addedText) && ASSERTION_PATTERN.test(addedText)) {
|
|
1207
1228
|
fileStats.added++;
|
|
1229
|
+
if (isSpecificAssertion(addedText)) fileStats.addedSpecific++;
|
|
1208
1230
|
}
|
|
1209
1231
|
|
|
1210
1232
|
if (currentNewLineNo !== null) currentNewLineNo++;
|
|
@@ -1226,6 +1248,31 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
1226
1248
|
});
|
|
1227
1249
|
}
|
|
1228
1250
|
}
|
|
1251
|
+
|
|
1252
|
+
// Replacing an assertion is not the same as keeping one. Counting totals
|
|
1253
|
+
// let a specific expectation be swapped for a vague one at no cost — one
|
|
1254
|
+
// out, one in, guard silent, suite no longer checking the answer. What must
|
|
1255
|
+
// not fall is the number of assertions that name an expected value.
|
|
1256
|
+
//
|
|
1257
|
+
// Only the *replaced* ones are reported here. An assertion deleted outright
|
|
1258
|
+
// is already an ASSERTION_REMOVAL above, and emitting both would report the
|
|
1259
|
+
// same line twice under two names.
|
|
1260
|
+
const alreadyReportedSpecific = stats.removed
|
|
1261
|
+
.slice(stats.added)
|
|
1262
|
+
.filter((item) => isSpecificAssertion(item.text)).length;
|
|
1263
|
+
const specificLost = Math.max(0, stats.removedSpecific.length - stats.addedSpecific);
|
|
1264
|
+
const weakenedCount = Math.max(0, specificLost - alreadyReportedSpecific);
|
|
1265
|
+
|
|
1266
|
+
if (weakenedCount > 0) {
|
|
1267
|
+
for (const item of stats.removedSpecific.slice(stats.addedSpecific, stats.addedSpecific + weakenedCount)) {
|
|
1268
|
+
violations.push({
|
|
1269
|
+
file,
|
|
1270
|
+
line: item.line,
|
|
1271
|
+
type: "ASSERTION_WEAKENED",
|
|
1272
|
+
reason: `Test Tamper Guard: Assertion weakened in ${file}${item.line ? `:${item.line}` : ""} — an assertion naming an expected value was replaced by one that does not: "${item.text.trim()}"`,
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1229
1276
|
}
|
|
1230
1277
|
|
|
1231
1278
|
return {
|
package/src/session-ops.mjs
CHANGED
|
@@ -160,6 +160,18 @@ export async function applySessionPatch(sessionId, opts = {}) {
|
|
|
160
160
|
|
|
161
161
|
// 2. If apply requested, execute git apply
|
|
162
162
|
if (opts.apply) {
|
|
163
|
+
// Writing an agent's patch into the working tree is the other moment a
|
|
164
|
+
// rollback target has to exist. The hosted provider works server-side, so
|
|
165
|
+
// dispatch never touched this tree — this is the first time it changes.
|
|
166
|
+
if (opts.checkpoint !== false) {
|
|
167
|
+
try {
|
|
168
|
+
const { createCheckpoint } = await import("./ops/checkpoint.mjs");
|
|
169
|
+
createCheckpoint(`patch-${String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "-")}`, { root });
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.warn(`⚠️ Could not snapshot before applying the patch (${err.message}); \`agentctl rollback\` will not cover it.`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
163
175
|
const applyRes = spawnSync("git", ["apply", "-"], {
|
|
164
176
|
cwd: root,
|
|
165
177
|
input: res.patch,
|
package/src/state.mjs
CHANGED
|
@@ -560,6 +560,40 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
560
560
|
}
|
|
561
561
|
}
|
|
562
562
|
|
|
563
|
+
// The lock file is named after the task, so `existsSync(lockFile)` above only
|
|
564
|
+
// ever asked "is this same task already running?". The `files` argument — the
|
|
565
|
+
// whole point of the call — was stored as metadata and never compared against
|
|
566
|
+
// anything, so two agents could hold locks on the same file at the same time
|
|
567
|
+
// and each be told it had exclusive access. Check the paths, not just the id.
|
|
568
|
+
const requested = new Set(
|
|
569
|
+
(Array.isArray(files) ? files : [])
|
|
570
|
+
.filter((f) => typeof f === "string" && f)
|
|
571
|
+
.map((f) => normalizePath(f))
|
|
572
|
+
);
|
|
573
|
+
if (requested.size > 0) {
|
|
574
|
+
for (const held of lockStatus(root)) {
|
|
575
|
+
if (!held || held.taskId === taskId) continue;
|
|
576
|
+
const recordedStart = held.processStartTime ?? held.starttime ?? null;
|
|
577
|
+
const stillHeld =
|
|
578
|
+
isPidAlive(held.pid, recordedStart) &&
|
|
579
|
+
!(held.acquiredAt && Date.now() - new Date(held.acquiredAt).getTime() > 7200000);
|
|
580
|
+
if (!stillHeld) continue;
|
|
581
|
+
|
|
582
|
+
const overlap = (Array.isArray(held.files) ? held.files : [])
|
|
583
|
+
.map((f) => normalizePath(f))
|
|
584
|
+
.filter((f) => requested.has(f));
|
|
585
|
+
if (overlap.length > 0) {
|
|
586
|
+
return {
|
|
587
|
+
ok: false,
|
|
588
|
+
holder: held.agent,
|
|
589
|
+
taskId: held.taskId,
|
|
590
|
+
pid: held.pid,
|
|
591
|
+
conflictingFiles: overlap,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
563
597
|
const startTime = getProcessStartTime(process.pid);
|
|
564
598
|
const concurrencyGroup = String(opts?.concurrencyGroup || opts?.concurrency_group || "").trim();
|
|
565
599
|
const payload = {
|
package/src/wizard-init.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { parseYaml, TIER_PRESETS, VENDOR_TIERS, FALLBACK_TIER } from "./config.mjs";
|
|
4
4
|
import { suggestProvider, detectAvailableProviders } from "./provider-readiness.mjs";
|
|
5
5
|
import { detectDefaultBranch } from "./git.mjs";
|
|
6
|
+
import { resolveWorkspaceBoundary } from "./stack-detector.mjs";
|
|
6
7
|
import { PROFILE_NAMES, PROFILE_DESCRIPTIONS } from "./profiles.mjs";
|
|
7
8
|
import { detectStackOracles, runVerificationProbe } from "./wizard-oracle.mjs";
|
|
8
9
|
import { select, multiSelect, input, confirm, spinner, isTTY } from "./tui.mjs";
|
|
@@ -180,6 +181,21 @@ export function planInit(root = process.cwd(), options = {}) {
|
|
|
180
181
|
// whose git chose `master`, or whose team standardised on `develop`.
|
|
181
182
|
const baseBranch = options.baseBranch || existingConfig.base_branch || detectDefaultBranch(root);
|
|
182
183
|
|
|
184
|
+
// A monorepo that runs every package's suite for a one-package change is the
|
|
185
|
+
// complaint the boundary resolver was written to answer, so a repository
|
|
186
|
+
// detected as one starts with it on. Existing repositories keep whatever they
|
|
187
|
+
// already stated; nobody's gate changes meaning because they upgraded.
|
|
188
|
+
const detectedMonorepo = (() => {
|
|
189
|
+
if (options.verifyScope) return options.verifyScope === "affected";
|
|
190
|
+
if (existingConfig.verify?.scope) return existingConfig.verify.scope === "affected";
|
|
191
|
+
try {
|
|
192
|
+
return Boolean(resolveWorkspaceBoundary([], root).isMonorepo);
|
|
193
|
+
} catch (_) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
})();
|
|
197
|
+
const verifyScope = detectedMonorepo ? "affected" : "global";
|
|
198
|
+
|
|
183
199
|
const limitsBlock = isCustomLimits
|
|
184
200
|
? `\nlimits:\n concurrency: ${limits.concurrency}\n daily_tasks: ${limits.daily_tasks}\n stagger_ms: ${limits.stagger_ms}\n diff_kb: ${limits.diff_kb}\n`
|
|
185
201
|
: "";
|
|
@@ -195,6 +211,9 @@ ${limitsBlock}
|
|
|
195
211
|
verify:
|
|
196
212
|
# minimal | standard | max — see: agentctl profile --list
|
|
197
213
|
profile: ${profile}
|
|
214
|
+
# global runs the repository's own commands; affected resolves changed files
|
|
215
|
+
# to their sub-projects and runs only those suites (monorepos)
|
|
216
|
+
scope: ${verifyScope}
|
|
198
217
|
test: "${verify.test}"
|
|
199
218
|
build: "${verify.build}"
|
|
200
219
|
lint: "${verify.lint}"
|
|
@@ -236,6 +255,7 @@ allow_paths: ${allowPaths.length > 0 ? "\n" + allowPaths.map((p) => ` - "${p}"`
|
|
|
236
255
|
provider,
|
|
237
256
|
profile,
|
|
238
257
|
baseBranch,
|
|
258
|
+
verifyScope,
|
|
239
259
|
verify,
|
|
240
260
|
limits,
|
|
241
261
|
presets: selectedPresets,
|