jules-orchestrator-kit 0.63.0 → 0.65.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 CHANGED
@@ -49,12 +49,15 @@
49
49
  <a id="quickstart"></a>
50
50
  ## Quickstart
51
51
 
52
- Get running in any repository in 3 commands (zero configuration required):
52
+ Get running in any repository in 3 commands. `init` asks seven questions and
53
+ fills in a sensible answer for each; `--yes` accepts all of them, detects the
54
+ stack, and probes the test command it picked before writing it down.
53
55
 
54
56
  ```bash
55
57
  # 1. Scaffold config, AGENTS.md, role prompts and guardrails
56
58
  # (auto-detects Python, Rust, Go, Node, PHP, etc.)
57
- npx jules-orchestrator-kit init
59
+ # Drop --yes to choose provider, plan, profile and workflows yourself.
60
+ npx jules-orchestrator-kit init --yes
58
61
  ```
59
62
 
60
63
  ```bash
@@ -204,7 +207,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
204
207
  * **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).
205
208
  * **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.
206
209
  * **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
207
- * **Verified Test Suite:** Tested with **1015 unit tests across 145 suites passing in < 15.0s**.
210
+ * **Verified Test Suite:** Tested with **1109 unit tests across 154 suites passing in < 15.0s**.
208
211
 
209
212
  <br/>
210
213
 
package/bin/agentctl.mjs CHANGED
@@ -429,6 +429,11 @@ async function main() {
429
429
  if (p.violations) {
430
430
  p.violations.forEach((v) => console.log(` - Violation: ${v.file} (Rule: ${v.rule})`));
431
431
  }
432
+ if (p.setup) {
433
+ console.log(` - Setup: accepted ${p.setup.length} gate scaffold file(s) this repository did not have yet`);
434
+ p.setup.forEach((f) => console.log(` ${f}`));
435
+ console.log(` Commit them to the base branch and the full protect rules apply from then on.`);
436
+ }
432
437
  if (p.findings) {
433
438
  p.findings.forEach((f) => console.log(` - [${f.severity}] ${f.type}: ${f.description}`));
434
439
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.63.0",
3
+ "version": "0.65.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",
@@ -56,7 +56,8 @@
56
56
  "jules:doc-sync": "node scripts/doc-sync-check.mjs",
57
57
  "release": "node scripts/release.mjs",
58
58
  "guard-reach": "node scripts/guard-reach-check.mjs",
59
- "lint": "eslint ."
59
+ "lint": "eslint .",
60
+ "package-integrity": "node scripts/package-integrity-check.mjs"
60
61
  },
61
62
  "engines": {
62
63
  "node": ">=20.0.0"
@@ -27,7 +27,7 @@
27
27
  *
28
28
  * Three steps, all in-process, no dependencies, well under a second:
29
29
  *
30
- * 1. POLICY — the hand-written witness table in test/fixtures/guard-policy.mjs
30
+ * 1. POLICY — the hand-written witness table in src/guard-policy.mjs
31
31
  * must hold. It is derived from what the tool advertises, never
32
32
  * from the regexes that implement it.
33
33
  * 2. CANARIES — every known-bad input must produce the finding it names. A
@@ -51,15 +51,26 @@ import {
51
51
  TAMPER_CANARIES,
52
52
  PREDICATE_MUTANTS,
53
53
  EMPTY_RUN_CANARIES,
54
+ COUNTED_RUN_CANARIES,
54
55
  SCOPE_CANARIES,
55
- } from "../test/fixtures/guard-policy.mjs";
56
+ INNOCENT_EDITS,
57
+ UNREADABLE_DIALECTS,
58
+ } from "../src/guard-policy.mjs";
56
59
 
57
- /** Build a unified diff for one canary. */
60
+ /**
61
+ * Build a unified diff for one canary.
62
+ *
63
+ * The context line carries the comment syntax of the file's own language.
64
+ * A `//` line in a `.py` fixture is not a comment, and a fixture that lies
65
+ * about the language under test measures the fixture rather than the guard —
66
+ * which is how two false results were once read as two defects.
67
+ */
58
68
  function canaryDiff(c) {
59
- const lines = [`--- a/${c.file}`, `+++ b/${c.file}`, "@@ -1,20 +1,20 @@", " // context"];
69
+ const ctx = c.context || "// context";
70
+ const lines = [`--- a/${c.file}`, `+++ b/${c.file}`, "@@ -1,20 +1,20 @@", ` ${ctx}`];
60
71
  for (const l of c.removed) lines.push(`-${l}`);
61
72
  for (const l of c.added) lines.push(`+${l}`);
62
- lines.push(" // context");
73
+ lines.push(` ${ctx}`);
63
74
  return lines.join("\n");
64
75
  }
65
76
 
@@ -95,6 +106,17 @@ const add = (name, ok, detail) => {
95
106
 
96
107
  {
97
108
  const missed = EMPTY_RUN_CANARIES.filter((c) => parseCollectedTests(c.output, "").count !== 0);
109
+ const undercounted = COUNTED_RUN_CANARIES.filter((c) => {
110
+ const n = parseCollectedTests(c.output, "").count;
111
+ return n === null || n < c.atLeast;
112
+ });
113
+ add(
114
+ "policy: a stated count is never read as empty",
115
+ undercounted.length === 0,
116
+ undercounted.length
117
+ ? undercounted.map((c) => `${c.id} (${c.why})`).join("; ")
118
+ : `${COUNTED_RUN_CANARIES.length} healthy runs counted, not rejected`
119
+ );
98
120
  add(
99
121
  "policy: empty-run detection",
100
122
  missed.length === 0,
@@ -107,6 +129,7 @@ const canaryResults = new Map();
107
129
  {
108
130
  const silent = [];
109
131
  const noDenominator = [];
132
+ const noAssertions = [];
110
133
  for (const c of TAMPER_CANARIES) {
111
134
  const res = checkTestTampering(canaryDiff(c));
112
135
  const hit = (res.violations || []).some((v) => v.type === c.expect);
@@ -114,12 +137,51 @@ const canaryResults = new Map();
114
137
  if (!hit) silent.push(`${c.id} expected ${c.expect}, got ${JSON.stringify((res.violations || []).map((v) => v.type))}`);
115
138
  // A finding with no denominator is the shape this script exists to reject.
116
139
  if (hit && !(res.inputsSeen > 0)) noDenominator.push(c.id);
140
+ // Counting lines was not enough: a JUnit diff reported one input examined
141
+ // and a clean PASS while every assertion in it went unrecognised. A rule
142
+ // about assertions has to say how many assertions it actually read.
143
+ if (hit && c.expect !== "TEST_SKIP_INJECTION" && !(res.assertionsSeen > 0)) {
144
+ noAssertions.push(`${c.id} (${res.assertionsSeen} assertions parsed)`);
145
+ }
117
146
  }
118
147
  add("canaries: every tamper rule still fires", silent.length === 0, silent.length ? silent.join("; ") : `${TAMPER_CANARIES.length} canaries red as required`);
119
148
  add("canaries: every finding carries a denominator", noDenominator.length === 0, noDenominator.length ? noDenominator.join(", ") : "inputsSeen > 0 on every hit");
149
+ add("canaries: assertion rules parsed an assertion", noAssertions.length === 0, noAssertions.length ? noAssertions.join(", ") : "assertionsSeen > 0 on every assertion finding");
150
+ }
151
+
152
+ // --- 3. The opposite failure: flagging what is innocent ---------------------
153
+ {
154
+ const noisy = [];
155
+ for (const e of INNOCENT_EDITS) {
156
+ const res = checkTestTampering(canaryDiff(e));
157
+ const types = (res.violations || []).map((v) => v.type);
158
+ if (types.length > 0) noisy.push(`${e.id} → ${JSON.stringify(types)} (${e.why})`);
159
+ }
160
+ add(
161
+ "innocent edits stay silent",
162
+ noisy.length === 0,
163
+ noisy.length
164
+ ? noisy.join("; ")
165
+ : `${INNOCENT_EDITS.length} ordinary edits produce no finding`
166
+ );
167
+ }
168
+
169
+ {
170
+ const quiet = [];
171
+ for (const d of UNREADABLE_DIALECTS) {
172
+ const res = checkTestTampering(canaryDiff(d));
173
+ if (res.status !== "UNREADABLE") quiet.push(`${d.id} → ${res.status} (${d.why})`);
174
+ }
175
+ add(
176
+ "an unparsable dialect says so",
177
+ quiet.length === 0,
178
+ quiet.length
179
+ ? `${quiet.join("; ")} — coverage ending is fine, ending silently is not`
180
+ : `${UNREADABLE_DIALECTS.length} unsupported dialects reported, not passed`
181
+ );
120
182
  }
121
183
 
122
- // --- 3. Predicate mutants ---------------------------------------------------
184
+ // --- 4. Predicate mutants ---------------------------------------------------
123
185
  {
124
186
  const survivors = [];
125
187
  for (const mutant of PREDICATE_MUTANTS) {
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Does the package we publish actually work?
5
+ *
6
+ * Every test in this repository runs against the source tree, where every
7
+ * file is present by definition. What users install is a tarball built from
8
+ * the `files` list in package.json, and nothing checked that the two agreed.
9
+ *
10
+ * They did not. v0.63.0 shipped `scripts/guard-reach-check.mjs` — the check
11
+ * whose entire purpose is to prove no guard has silently gone missing —
12
+ * while leaving behind the policy contract it imports. Unpacked and run, it
13
+ * threw ERR_MODULE_NOT_FOUND. The CLI was fine, 1015 tests were green, nine
14
+ * CI cells passed, and the published artefact still had a hole in it,
15
+ * because every one of those signals was measured somewhere the file existed.
16
+ *
17
+ * This asks the packer what it would ship, and then resolves the import
18
+ * graph inside that answer.
19
+ *
20
+ * Usage: node scripts/package-integrity-check.mjs [--json]
21
+ * Exit codes: 0 = the tarball is self-contained, 1 = it is not.
22
+ */
23
+
24
+ import { execFileSync } from "node:child_process";
25
+ import { readFileSync, existsSync } from "node:fs";
26
+ import { resolve, dirname, relative, sep } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ import { IMPORT_EXTRACTION_CASES } from "../src/guard-policy.mjs";
30
+
31
+ const root = fileURLToPath(new URL("..", import.meta.url));
32
+
33
+ /**
34
+ * Mark every character that sits inside a string literal or a comment.
35
+ *
36
+ * Without this, an import quoted *inside* a fixture string reads as an
37
+ * import of the file itself — this check's first run reported six broken
38
+ * imports in the policy contract, all of them example text. A guard that
39
+ * cries wolf about its own fixtures gets switched off in a week.
40
+ *
41
+ * Approximate on purpose, and biased on purpose: a regex literal holding a
42
+ * quote can open a phantom string, so the counts reported below exist to
43
+ * make a mask that swallowed the file visible rather than silent.
44
+ */
45
+ function stringMask(src) {
46
+ const mask = new Uint8Array(src.length);
47
+ let i = 0;
48
+ let quote = null;
49
+ let comment = null;
50
+ while (i < src.length) {
51
+ const c = src[i];
52
+ const d = src[i + 1];
53
+ if (comment === "line") {
54
+ if (c === "\n") comment = null;
55
+ else mask[i] = 1;
56
+ i++;
57
+ continue;
58
+ }
59
+ if (comment === "block") {
60
+ mask[i] = 1;
61
+ if (c === "*" && d === "/") {
62
+ mask[i + 1] = 1;
63
+ comment = null;
64
+ i += 2;
65
+ continue;
66
+ }
67
+ i++;
68
+ continue;
69
+ }
70
+ if (quote !== null) {
71
+ mask[i] = 1;
72
+ if (c === "\\") {
73
+ if (i + 1 < src.length) mask[i + 1] = 1;
74
+ i += 2;
75
+ continue;
76
+ }
77
+ if (c === quote) quote = null;
78
+ i++;
79
+ continue;
80
+ }
81
+ if (c === "/" && d === "/") { comment = "line"; mask[i] = 1; i++; continue; }
82
+ if (c === "/" && d === "*") { comment = "block"; mask[i] = 1; i++; continue; }
83
+ // A regex literal holding a quote — `/["']/` — opens a string that never
84
+ // closes, and everything after it is misread. This file's own subject
85
+ // matter is regexes full of quote characters, so the mask desynchronised
86
+ // and a `require("./calc")` written inside a comment was reported as a
87
+ // missing module. Deciding regex-versus-division on the previous token is
88
+ // the same approximation the diff scanner already makes.
89
+ if (c === "/") {
90
+ let k = i - 1;
91
+ while (k >= 0 && /\s/.test(src[k])) k--;
92
+ const prev = k >= 0 ? src[k] : "";
93
+ if (prev === "" || "(,=:[!&|?{};+-*%~^<>".includes(prev)) {
94
+ mask[i] = 1;
95
+ let j = i + 1;
96
+ let cls = false;
97
+ while (j < src.length) {
98
+ const e = src[j];
99
+ mask[j] = 1;
100
+ if (e === "\\") { if (j + 1 < src.length) mask[j + 1] = 1; j += 2; continue; }
101
+ if (e === "[") cls = true;
102
+ else if (e === "]") cls = false;
103
+ else if (e === "/" && !cls) { j++; break; }
104
+ else if (e === "\n") break;
105
+ j++;
106
+ }
107
+ i = j;
108
+ continue;
109
+ }
110
+ }
111
+ if (c === '"' || c === "'" || c === "`") { quote = c; mask[i] = 1; i++; continue; }
112
+ i++;
113
+ }
114
+ return mask;
115
+ }
116
+
117
+ /**
118
+ * Every module specifier in a source file.
119
+ *
120
+ * Deliberately newline-tolerant. A matcher bounded by `[^;\n]*?` cannot see
121
+ * a multi-line named import, which is exactly the import that was missing
122
+ * from the tarball, so the first version of this check certified the broken
123
+ * package as sound. The extraction cases in the policy contract exist to
124
+ * keep that from being a private mistake twice.
125
+ */
126
+ export function extractSpecifiers(src, { skipQuoted = true } = {}) {
127
+ const mask = skipQuoted ? stringMask(src) : null;
128
+ const found = new Set();
129
+ const collect = (re) => {
130
+ for (const m of src.matchAll(re)) {
131
+ // What decides is the token immediately before the specifier — the
132
+ // `from`, or the `(` of a call. In a real import it is code; in a
133
+ // fixture it is the middle of a string. Testing the *keyword* instead
134
+ // was not enough: `export const CASES = [` at the top of a file
135
+ // matched lazily forward into the first `from "…"` inside an example,
136
+ // so a genuine keyword lent its authority to quoted text.
137
+ if (mask) {
138
+ let j = m.indices[1][0] - 2;
139
+ while (j >= 0 && /\s/.test(src[j])) j--;
140
+ if (j >= 0 && mask[j]) continue;
141
+ }
142
+ found.add(m[1]);
143
+ }
144
+ };
145
+ collect(/(?:^|[\s;}])(?:import|export)\s[\s\S]{0,500}?from\s*["']([^"']+)["']/gd);
146
+ collect(/(?:^|[\s;}])import\s*["']([^"']+)["']/gd);
147
+ collect(/\bimport\s*\(\s*["']([^"']+)["']/gd);
148
+ collect(/\brequire\s*\(\s*["']([^"']+)["']/gd);
149
+ return [...found];
150
+ }
151
+
152
+ /**
153
+ * Run every integrity check and return the result.
154
+ *
155
+ * Exported as a function rather than run on import: a module that checks the
156
+ * package must not exit the process of anything that merely imports it.
157
+ */
158
+ export function checkPackageIntegrity() {
159
+ const failures = [];
160
+ const checks = [];
161
+ const add = (name, ok, detail) => {
162
+ checks.push({ name, ok, detail });
163
+ if (!ok) failures.push(`${name}: ${detail}`);
164
+ };
165
+
166
+ // --- 1. The extractor must be able to see what it claims to look for -------
167
+ {
168
+ const wrong = [];
169
+ for (const c of IMPORT_EXTRACTION_CASES) {
170
+ const got = extractSpecifiers(c.src).sort();
171
+ const want = [...c.expect].sort();
172
+ if (JSON.stringify(got) !== JSON.stringify(want)) {
173
+ wrong.push(`${c.id}: found ${JSON.stringify(got)}, contract says ${JSON.stringify(want)}`);
174
+ }
175
+ }
176
+ add("extractor: every import form is visible", wrong.length === 0, wrong.length ? wrong.join("; ") : `${IMPORT_EXTRACTION_CASES.length} forms found`);
177
+ }
178
+
179
+ // --- 2. Ask the packer what it would actually ship -------------------------
180
+ let shipped = new Set();
181
+ try {
182
+ const out = execFileSync("npm", ["pack", "--dry-run", "--json"], { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
183
+ shipped = new Set(JSON.parse(out)[0].files.map((f) => f.path.split(/[\\/]/).join("/")));
184
+ } catch (err) {
185
+ add("packer: `npm pack --dry-run` answers", false, err.message);
186
+ }
187
+ add("packer: the tarball is not empty", shipped.size > 0, `${shipped.size} files`);
188
+
189
+ // --- 3. Resolve the import graph inside the tarball ------------------------
190
+ {
191
+ const broken = [];
192
+ let resolved = 0;
193
+ let scanned = 0;
194
+
195
+ for (const file of shipped) {
196
+ if (!/\.(mjs|cjs|js)$/.test(file)) continue;
197
+ let src;
198
+ try {
199
+ src = readFileSync(resolve(root, file), "utf-8");
200
+ } catch {
201
+ continue;
202
+ }
203
+ scanned++;
204
+ for (const spec of extractSpecifiers(src)) {
205
+ if (!spec.startsWith(".")) continue; // bare and node: specifiers are not ours to resolve
206
+ resolved++;
207
+ const target = relative(root, resolve(dirname(resolve(root, file)), spec)).split(sep).join("/");
208
+ if (!shipped.has(target)) {
209
+ broken.push(`${file} imports ${spec} — ${target} is not in the tarball (on disk: ${existsSync(resolve(root, target)) ? "yes" : "no"})`);
210
+ }
211
+ }
212
+ }
213
+
214
+ add(
215
+ "tarball: every relative import resolves",
216
+ broken.length === 0,
217
+ broken.length ? broken.join("; ") : `${resolved} relative imports across ${scanned} shipped modules`
218
+ );
219
+ // A resolver that resolved nothing would report the same clean line.
220
+ add("tarball: the graph was actually walked", resolved > 0 && scanned > 0, `${scanned} modules, ${resolved} relative imports`);
221
+ }
222
+
223
+ // --- 4. Every advertised entry point has to be in the box ------------------
224
+ {
225
+ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf-8"));
226
+ const entries = [...Object.values(pkg.bin || {}), pkg.main, pkg.module].filter(Boolean);
227
+ const missing = entries.map((e) => e.replace(/^\.\//, "")).filter((e) => !shipped.has(e));
228
+ add("entry points: every bin and main ships", missing.length === 0, missing.length ? missing.join(", ") : `${entries.length} entry points present`);
229
+ }
230
+
231
+ return { ok: failures.length === 0, checks, failures };
232
+ }
233
+
234
+ const isMain = process.argv[1] && process.argv[1].endsWith("package-integrity-check.mjs");
235
+ if (isMain) {
236
+ const { ok, checks: rows, failures: bad } = checkPackageIntegrity();
237
+ if (process.argv.includes("--json")) {
238
+ console.log(JSON.stringify({ ok, checks: rows }, null, 2));
239
+ } else {
240
+ console.log("\n📦 Package Integrity Check (what we actually publish)");
241
+ console.log("-------------------------------------------------------");
242
+ for (const c of rows) console.log(` ${c.ok ? "✅" : "❌"} ${c.name.padEnd(46)} ${c.detail}`);
243
+ console.log("-------------------------------------------------------");
244
+ console.log(
245
+ ok
246
+ ? "✅ The published package is self-contained.\n"
247
+ : `\n❌ ${bad.length} problem(s). The tarball is not what the source tree looks like.\n`
248
+ );
249
+ }
250
+ process.exit(ok ? 0 : 1);
251
+ }
@@ -58,6 +58,22 @@ try {
58
58
  process.exit(1);
59
59
  }
60
60
 
61
+ // Step 1a proved the guards work here. Here is not where users run them.
62
+ //
63
+ // Every signal so far was measured in the source tree, where every file
64
+ // exists by construction. What ships is a tarball built from the `files`
65
+ // list, and v0.63.0 published the guard-reach check without the policy
66
+ // contract it imports — green suite, green matrix, green release, and a
67
+ // module that threw ERR_MODULE_NOT_FOUND the moment anyone installed it.
68
+ console.log("1a-2. Verifying the package we would publish is self-contained...");
69
+ try {
70
+ execSync("node scripts/package-integrity-check.mjs", { cwd: root, stdio: "inherit" });
71
+ console.log("");
72
+ } catch (_) {
73
+ console.error("\n❌ Release Aborted: the tarball is not what the source tree looks like.");
74
+ process.exit(1);
75
+ }
76
+
61
77
  // 1b. Documentation / version consistency gate (blocking).
62
78
  console.log("1b. Verifying documentation is in sync with package.json & test suite...");
63
79
  {
@@ -561,12 +561,28 @@ export function assertTestIntegrity(config = {}, root = process.cwd()) {
561
561
  const res = checkTestTampering(diffStr, config);
562
562
  const diagnostics = (res.violations || []).map((v) => v.reason);
563
563
 
564
+ // A clean result from a guard that could not read the dialect is not a
565
+ // clean result, and it must not be reported as one. This does not fail the
566
+ // check — an unlisted assertion library is the user's normal, not their
567
+ // fault — but they get to know the guard is not covering them.
568
+ for (const u of res.unreadable || []) {
569
+ diagnostics.push(
570
+ `Test Tamper Guard: ${u.count} assertion-shaped line(s) in ${u.file} matched no known dialect, ` +
571
+ `so this file was not checked for tampering (e.g. ${JSON.stringify(u.samples[0])}). ` +
572
+ `The guard reports what it could not read rather than passing silently.`
573
+ );
574
+ }
575
+
564
576
  return {
565
577
  ok: res.ok,
566
578
  violations: res.violations || [],
567
579
  diagnostics,
568
580
  metrics: {
569
581
  violationCount: res.violations?.length || 0,
582
+ assertionsSeen: res.assertionsSeen ?? 0,
583
+ filesSeen: res.filesSeen ?? 0,
584
+ unreadableFiles: (res.unreadable || []).length,
585
+ status: res.status,
570
586
  },
571
587
  };
572
588
  }
package/src/engine.mjs CHANGED
@@ -204,6 +204,16 @@ export async function gate(opts = {}) {
204
204
  // Read from the base commit like every other trusted field: an
205
205
  // uncommitted `required: false` must not be able to switch the gate off.
206
206
  required: parsed.verify?.required !== undefined ? parsed.verify.required !== false : config.verify.required !== false,
207
+ // The floor the collection check applies. Omitting it here meant
208
+ // `verify.minTests` was silently dropped and always defaulted to 1
209
+ // — while the failure message told the operator to set exactly
210
+ // that. A remediation hint that does nothing is worse than none.
211
+ minTests:
212
+ parsed.verify?.minTests !== undefined
213
+ ? parsed.verify.minTests
214
+ : parsed.verify?.min_tests !== undefined
215
+ ? parsed.verify.min_tests
216
+ : config.verify.minTests,
207
217
  scope: parsed.verify?.scope || config.verify.scope || "global",
208
218
  timeoutMs: parsed.verify?.timeoutMs || parsed.verify?.timeout_ms || config.verify.timeoutMs,
209
219
  };
@@ -249,7 +259,45 @@ export async function gate(opts = {}) {
249
259
  violation.file = link;
250
260
  }
251
261
 
252
- phases.push({ phase: "scope", ok: scopeResult.ok, violations: scopeResult.violations });
262
+ // Bootstrap: the files that bring a repository under the gate are not agent
263
+ // edits to the gate.
264
+ //
265
+ // `init` writes `.agent/**` and then tells the user to commit it. Doing
266
+ // exactly that produced Exit 3 on the very first run, because the base
267
+ // branch does not have the commit yet and every scaffolded path matches
268
+ // BUILTIN_PROTECT or BUILTIN_DENY. The advice printed alongside it was
269
+ // `--allow-protected` — so a newcomer's first lesson was how to switch the
270
+ // scope guard off. A gate that refuses its own installation is not strict,
271
+ // it is broken.
272
+ //
273
+ // Narrow on purpose, and only where it cannot weaken anything: the base
274
+ // commit must have no gate config at all — in which case `trustedScope` is
275
+ // already built-ins only and nothing in the added files is trusted — and
276
+ // every violating path must be scaffold that the base does not have. A
277
+ // repository already under the gate keeps the full rule, so an agent still
278
+ // cannot touch the policy it is governed by.
279
+ let acceptedScaffold = [];
280
+ if (!scopeResult.ok && !trustedConfigRaw) {
281
+ const violations = scopeResult.violations || [];
282
+ const isScaffold = (f) => typeof f === "string" && f.replace(/\\/g, "/").startsWith(".agent/");
283
+ if (
284
+ violations.length > 0 &&
285
+ violations.every((v) => isScaffold(v.file) && showFromOrigin(root, base, v.file) === null)
286
+ ) {
287
+ acceptedScaffold = violations.map((v) => v.file);
288
+ scopeResult.violations = [];
289
+ scopeResult.ok = true;
290
+ }
291
+ }
292
+
293
+ phases.push({
294
+ phase: "scope",
295
+ ok: scopeResult.ok,
296
+ violations: scopeResult.violations,
297
+ // Reported, never silent: the operator has to see that the gate accepted
298
+ // files it would otherwise have blocked, and why.
299
+ ...(acceptedScaffold.length > 0 ? { setup: acceptedScaffold } : {}),
300
+ });
253
301
  appendTelemetry(root, "gate_phase", { phase: "scope", ok: scopeResult.ok });
254
302
  if (progressBus && progressToken) {
255
303
  progressBus.reportProgress(progressToken, 25, 100, "Phase 1/4: Scope Guard verification complete");