jules-orchestrator-kit 0.74.0 → 0.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.75.0] - 2026-09-28
11
+
12
+ ### Added
13
+ - **Interactive Terminal Navigation Hub (`src/ops/tui-menu.mjs`)**: Added zero-external-dependency terminal UI hub accessible via `agentctl menu`, `tui`, `ui`, or `-i`, using Node.js ESM built-ins (`readline`, ANSI cursor escape sequences). Supports arrow-key navigation, hotkey selection, command preview, contextual execution, and direct handoff to interactive wizards.
14
+ - **Fuzzy Typo Correction & Suggestions (`bin/agentctl.mjs`)**: Implemented Levenshtein-distance suggestion engine for unrecognized CLI commands, surfacing closest matches (e.g. `agentctl dsipatch` suggests `dispatch`).
15
+ - **Actionable Scope Diagnostics & Protected Scope Flag (`src/wizard-task.mjs`)**: Added `--allow-protected` flag to `agentctl task create` alongside detailed diagnostic breakdowns of which paths are protected (e.g. `package.json`, `.agent/protected-paths.json`) when task objectives touch sensitive repository infrastructure.
16
+ - **Task Payload Preview on Dry-Run (`bin/agentctl.mjs`, `src/engine.mjs`)**: `agentctl dispatch --dry-run` now prints formatted task payloads, verification commands, and risk tier assignments before execution.
17
+ - **Maintainer Approval Path for Test Changes (`.github/workflows/agent-scope-guard.yml`, `scripts/ci-scope-guard.mjs`)**: Established narrow maintainer approval protocol (`allow-p:<HEAD_SHA>`, `allow-e:<HEAD_SHA>`) allowing verified modifications to test expectations or protected paths under explicit commit-bound review.
18
+
19
+ ### Fixed
20
+ - **Goal Evidence Enforcement (`src/engine.mjs`)**: Disallowed silent early termination on goal tasks lacking explicit verification evidence.
21
+ - **Test Environment Variable Bleed (`src/engine.mjs`)**: Sanitized test execution environment to prevent PR label bypass tokens from leaking into child test runs during gate evaluation.
22
+
10
23
  ## [0.74.0] - 2026-09-19
11
24
 
12
25
  ### Added
package/README.md CHANGED
@@ -17,7 +17,7 @@ You can also run local checks without connecting an agent provider.
17
17
  `agentctl repair` explicitly for repair workflows. Provider output and passing
18
18
  checks still need review before merging.
19
19
 
20
- The current release is **v0.74.0**, a pre-1.0 release. A long-term stability policy is a
20
+ The current release is **v0.75.0**, a pre-1.0 release. A long-term stability policy is a
21
21
  [v1.0 goal](https://github.com/FullThrottle83/jules-orchestrator-kit/blob/main/ROADMAP_V1.md).
22
22
 
23
23
  ## Quickstart
@@ -174,7 +174,7 @@ npm run package-integrity
174
174
  npm run guard-reach
175
175
  ```
176
176
 
177
- The recorded baseline is **1553 unit tests across 204 suites**. Doc-sync compares
177
+ The recorded baseline is **1650 unit tests across 210 suites**. Doc-sync compares
178
178
  that count with an actual run. Counts do not establish correctness for every
179
179
  provider or project. See
180
180
  [contributing](https://github.com/FullThrottle83/jules-orchestrator-kit/blob/main/CONTRIBUTING.md)
package/bin/agentctl.mjs CHANGED
@@ -5,10 +5,11 @@ import { readFileSync, existsSync, readdirSync, statSync, renameSync, mkdirSync
5
5
  import { join, resolve, relative } from "node:path";
6
6
  import { applyEnvAliases } from "../src/env-aliases.mjs";
7
7
  import { selectFailureOutput } from "../src/ops/verify-output.mjs";
8
+ import { diagnoseVerifyFailure } from "../src/ops/toolchain-diagnostics.mjs";
8
9
  import { loadConfig, resolveRoot, detectStack, bootstrapZeroTestRepo } from "../src/config.mjs";
9
10
  import { gate, dispatch, run, isTaskFile } from "../src/engine.mjs";
10
- import { acquireLock, releaseLock, lockStatus, getQueueDir } from "../src/state.mjs";
11
- import { parseEnvelopeHeader } from "../src/envelope.mjs";
11
+ import { acquireLock, releaseLock, reapStaleLocks, lockStatus, getQueueDir } from "../src/state.mjs";
12
+ import { parseEnvelopeHeader, validateEnvelope } from "../src/envelope.mjs";
12
13
  import { worktreePrune } from "../src/git.mjs";
13
14
  import { reapOrphanedIntents, reapStaleMutexDirs } from "../src/journal.mjs";
14
15
  import { KIT_VERSION } from "../src/version.mjs";
@@ -89,7 +90,8 @@ export function printHelp() {
89
90
  lines.push(" --prompt-file, -f Read the prompt from a file (-f is --fix on task optimize)");
90
91
  lines.push(" --role, -r Specify specialist agent role (auditor | performance | security | hygiene | resilience | types | debugger | testing | e2e | database | docs | a11y)");
91
92
  lines.push(" --tier Force routing tier when router.enabled (fast | complex) — see .agent/config.yml router:");
92
- lines.push(" --check-premise Verify task goal/oracle passes locally before burning API budget");
93
+ lines.push(" --check-premise Skip only when an explicit --goal-check proves the task complete");
94
+ lines.push(" --goal-check <cmd> Objective-specific command; distinct from --verify-cmd");
93
95
  lines.push(" --dag Execute queue tasks via DAG dependency resolution");
94
96
  lines.push(" --dry-run, -d Simulate action without making API calls or modifying git");
95
97
  lines.push(" --mode, -m Gate evaluation mode (working-tree | committed | staged)");
@@ -268,6 +270,11 @@ async function main() {
268
270
  // tool; a newcomer cannot tell which entry is step one, and guessing wrong
269
271
  // costs them a confusing failure instead of a hint.
270
272
  if (!command) {
273
+ if (args.includes("-i") || args.includes("--interactive")) {
274
+ const { runTuiMenu } = await import("../src/ops/tui-menu.mjs");
275
+ await runTuiMenu(resolveRoot());
276
+ process.exit(0);
277
+ }
271
278
  const { resolveNextStep, renderNextStep } = await import("../src/ops/next-step.mjs");
272
279
  const cwd = process.cwd();
273
280
  let budgetLine = "";
@@ -308,9 +315,14 @@ async function main() {
308
315
  // Init is a configuration boundary, not an operational maintenance command.
309
316
  // In particular, --dry-run must not create .agent/ indirectly by reaping
310
317
  // journal intents or mutex directories before init itself gets control.
311
- if (command !== "init") {
318
+ // init, uninstall and migrate must not create kit state as a side-effect of
319
+ // startup maintenance — dry-run and "nothing installed" paths need a clean tree.
320
+ if (command !== "init" && command !== "uninstall" && command !== "migrate") {
312
321
  reapOrphanedIntents(root);
313
322
  reapStaleMutexDirs(root);
323
+ if (command !== "lock") {
324
+ reapStaleLocks(root);
325
+ }
314
326
  }
315
327
  const config = loadConfig(root);
316
328
 
@@ -332,6 +344,7 @@ async function main() {
332
344
  "auto-approve-plans": { type: "boolean" },
333
345
  "auto-approve": { type: "boolean" },
334
346
  "check-premise": { type: "boolean" },
347
+ "goal-check": { type: "string" },
335
348
  idempotent: { type: "boolean" },
336
349
  author: { type: "string" },
337
350
  "verify-cmd": { type: "string", short: "v" },
@@ -394,6 +407,7 @@ async function main() {
394
407
  autoPr: values["auto-pr"],
395
408
  requirePlanApproval: autoApprove ? false : values["require-plan-approval"],
396
409
  checkPremise: values["check-premise"] || values.idempotent,
410
+ goalCheck: values["goal-check"],
397
411
  author: values.author,
398
412
  verifyCmd: values["verify-cmd"] || values.verify,
399
413
  };
@@ -413,7 +427,7 @@ async function main() {
413
427
  } else {
414
428
  if (session.status === "ALREADY_SATISFIED" || session.skipped) {
415
429
  console.log(`\n⚡ Task Already Satisfied (skipped dispatch):`);
416
- console.log(` Reason: ${session.reason || "Verification oracle already passing on base branch."}`);
430
+ console.log(` Reason: ${session.reason || "Explicit goal check passed on a clean revision."}`);
417
431
  } else if (values["dry-run"]) {
418
432
  // The dry run reached the provider adapter and stopped short of the
419
433
  // call. Printing the same "Dispatched Successfully!" banner as a
@@ -424,9 +438,16 @@ async function main() {
424
438
  console.log(` Title : ${task.title}`);
425
439
  console.log(` Provider : ${session.provider || config.provider || "jules"}`);
426
440
  if (task.role) console.log(` Role : ${task.role}`);
441
+ if (task.branch) console.log(` Branch : ${task.branch}`);
442
+ if (task.verifyCmd) console.log(` Verify Cmd : ${task.verifyCmd}`);
427
443
  if (session._routeTier) {
428
444
  console.log(` Router Tier : ${session._routeTier} (${session._routeReason || "n/a"})`);
429
445
  }
446
+ if (task.prompt) {
447
+ console.log(`\n--- Task Payload Preview ---`);
448
+ console.log(String(task.prompt).trim());
449
+ console.log(`----------------------------`);
450
+ }
430
451
  console.log(`\n Re-run without --dry-run to dispatch for real.`);
431
452
  } else {
432
453
  console.log(`\n✅ Task Dispatched Successfully!`);
@@ -690,9 +711,14 @@ async function main() {
690
711
  console.log(` • Or set it by hand: verify.test in ${config._file || ".agent/config.yml"}`);
691
712
  console.log(` • Scope- and secret-scanning only, on purpose? Set verify.required: false there.\n`);
692
713
  } else if (failedPhase === "verify" || failedPhase === "evidence") {
714
+ const verifyFailure =
715
+ res.phases.find((p) => (p.phase === "verify" || p.phase === "evidence") && !p.ok)?.failure || {};
716
+ const diagnosis = diagnoseVerifyFailure(verifyFailure);
693
717
  console.log(`💡 Remediation Hint (Exit ${res.code} Verification Failed):`);
694
- console.log(` • The stage above exited non-zero. Reproduce it locally, then re-run the gate.`);
695
- console.log(` • To start an explicit repair workflow, pipe the failing command's output to: agentctl repair\n`);
718
+ for (const line of diagnosis.lines) {
719
+ console.log(` • ${line}`);
720
+ }
721
+ console.log("");
696
722
  } else if (res.code === 3) {
697
723
  // The same violation has two very different causes. Right after
698
724
  // `init`, every offending path is a file the tool itself just wrote
@@ -1038,17 +1064,53 @@ async function main() {
1038
1064
  }
1039
1065
  process.exit(0);
1040
1066
  } else {
1041
- console.log(`\n🔧 Dispatching OODA Repair Loop for captured failure trace...`);
1067
+ if (!values.json) {
1068
+ console.log(`\n🔧 Dispatching OODA Repair Loop for captured failure trace...`);
1069
+ }
1042
1070
  const repairRes = await repair(
1043
1071
  { stderr: cleanTrace, command: values.cmd || "verify" },
1044
1072
  { root, dryRun: values["dry-run"], author: values.author }
1045
1073
  );
1046
1074
 
1047
1075
  if (values.json) {
1048
- console.log(JSON.stringify(repairRes, null, 2));
1076
+ process.stdout.write(JSON.stringify(repairRes, null, 2) + "\n", () => {
1077
+ process.exit(repairRes.ok ? 0 : 1);
1078
+ });
1079
+ setTimeout(() => process.exit(repairRes.ok ? 0 : 1), 1000).unref();
1080
+ break;
1049
1081
  } else {
1050
1082
  console.log(`------------------------------------------------------------------`);
1051
1083
  console.log(`Repair Status: ${repairRes.ok ? "RESOLVED (Exit 0)" : `FAILED (${repairRes.finalStatus} — Exit 1)`}`);
1084
+ if (!repairRes.ok && Array.isArray(repairRes.attempts) && repairRes.attempts.length > 0) {
1085
+ console.log(`\nRepair attempt review:`);
1086
+ if (repairRes.repairDir) {
1087
+ console.log(` Retained under: ${repairRes.repairDir}`);
1088
+ }
1089
+ for (const attempt of repairRes.attempts) {
1090
+ const phase =
1091
+ attempt.phase ||
1092
+ attempt.diagnostics?.phase ||
1093
+ (attempt.verified === true ? "verified" : attempt.verified === false ? "verify" : "unknown");
1094
+ const errRaw = attempt.error || attempt.diagnostics?.error || "";
1095
+ const concise = String(errRaw).split("\n")[0].slice(0, 200);
1096
+ const diff = typeof attempt.diff === "string" ? attempt.diff : "";
1097
+ const fileCount = [...diff.matchAll(/^diff --git /gm)].length;
1098
+ const lineCount = diff
1099
+ .split("\n")
1100
+ .filter((line) => (line.startsWith("+") && !line.startsWith("+++")) || (line.startsWith("-") && !line.startsWith("---")))
1101
+ .length;
1102
+ console.log(` #${attempt.n}: phase=${phase} ok=${attempt.ok}${concise ? ` error=${JSON.stringify(concise)}` : ""}`);
1103
+ if (attempt.diff !== undefined) {
1104
+ console.log(` diff: ${fileCount} file(s), ${lineCount} changed line(s)`);
1105
+ }
1106
+ if (attempt.diagnostics?.patchPath) {
1107
+ console.log(` patch: ${attempt.diagnostics.patchPath}`);
1108
+ }
1109
+ if (attempt.diagnostics?.diagnosticsPath) {
1110
+ console.log(` diagnostics: ${attempt.diagnostics.diagnosticsPath}`);
1111
+ }
1112
+ }
1113
+ }
1052
1114
  }
1053
1115
 
1054
1116
  process.exit(repairRes.ok ? 0 : 1);
@@ -1517,6 +1579,7 @@ async function main() {
1517
1579
  options: {
1518
1580
  ttl: { type: "string" },
1519
1581
  pid: { type: "string" },
1582
+ "dry-run": { type: "boolean", short: "d" },
1520
1583
  json: { type: "boolean", short: "j" },
1521
1584
  },
1522
1585
  allowPositionals: true,
@@ -1581,22 +1644,49 @@ async function main() {
1581
1644
  console.log(`❌ Lock for ${taskId} not found or release failed`);
1582
1645
  process.exit(1);
1583
1646
  }
1647
+ } else if (action === "reap" || action === "prune") {
1648
+ const dryRun = Boolean(lockValues["dry-run"]);
1649
+ const res = reapStaleLocks(root, { dryRun });
1650
+ if (lockValues.json) {
1651
+ console.log(JSON.stringify(res, null, 2));
1652
+ process.exit(0);
1653
+ }
1654
+ if (res.reapedCount === 0) {
1655
+ console.log("✅ No stale locks found.");
1656
+ } else {
1657
+ const verb = dryRun ? "Would reap" : "Reaped";
1658
+ console.log(`✅ ${verb} ${res.reapedCount} stale lock(s):`);
1659
+ for (const item of res.reaped) {
1660
+ console.log(` • ${item.taskId || item.file} (${item.reason})`);
1661
+ }
1662
+ }
1663
+ process.exit(0);
1584
1664
  } else {
1585
- const locks = lockStatus(root);
1665
+ const allLocks = lockStatus(root);
1666
+ const activeLocks = allLocks.filter((l) => l.live);
1667
+ const staleLocks = allLocks.filter((l) => !l.live);
1586
1668
  if (lockValues.json) {
1587
- console.log(JSON.stringify({ ok: true, locks }, null, 2));
1669
+ console.log(JSON.stringify({ ok: true, locks: allLocks, activeCount: activeLocks.length, staleCount: staleLocks.length }, null, 2));
1588
1670
  process.exit(0);
1589
1671
  }
1590
- if (locks.length === 0) {
1672
+ if (allLocks.length === 0) {
1591
1673
  console.log("Active Locks: 0 (no held locks)");
1592
1674
  } else {
1593
- console.log(`Active Locks (${locks.length}):`);
1594
- for (const l of locks) {
1675
+ console.log(`Active Locks (${activeLocks.length}):`);
1676
+ for (const l of activeLocks) {
1595
1677
  const files = Array.isArray(l.files) ? l.files.join(", ") : (l.files || "");
1596
1678
  const expires = l.expiresAt ? `expires ${l.expiresAt}` : (l.ownerPid ? `pid ${l.ownerPid}` : "active");
1597
1679
  console.log(` • taskId: ${l.taskId || l.id} [${l.agent || "unknown"}] (${expires})`);
1598
1680
  if (files) console.log(` Paths: ${files}`);
1599
1681
  }
1682
+ if (staleLocks.length > 0) {
1683
+ console.log(`\nStale Locks (${staleLocks.length}):`);
1684
+ for (const l of staleLocks) {
1685
+ const reason = l.leased ? "expired lease" : `dead pid ${l.pid || "unknown"}`;
1686
+ console.log(` • taskId: ${l.taskId || l.id} [${l.agent || "unknown"}] (${reason})`);
1687
+ }
1688
+ console.log(` 💡 Run \`agentctl lock reap\` to clean up stale locks.`);
1689
+ }
1600
1690
  }
1601
1691
  }
1602
1692
  process.exit(0);
@@ -1921,6 +2011,15 @@ async function main() {
1921
2011
  break;
1922
2012
  }
1923
2013
 
2014
+ case "menu":
2015
+ case "tui":
2016
+ case "ui": {
2017
+ const { runTuiMenu } = await import("../src/ops/tui-menu.mjs");
2018
+ await runTuiMenu(root);
2019
+ process.exit(0);
2020
+ break;
2021
+ }
2022
+
1924
2023
  case "init": {
1925
2024
  const { values } = parseArgs({
1926
2025
  args: args.slice(1),
@@ -2099,6 +2198,7 @@ async function main() {
2099
2198
  yes: { type: "boolean", short: "y" },
2100
2199
  json: { type: "boolean", short: "j" },
2101
2200
  "dry-run": { type: "boolean", short: "d" },
2201
+ "allow-protected": { type: "boolean" },
2102
2202
  },
2103
2203
  allowPositionals: true,
2104
2204
  });
@@ -2129,6 +2229,7 @@ async function main() {
2129
2229
  repoless: values.repoless,
2130
2230
  interactive: isInteractive,
2131
2231
  dryRun: values["dry-run"],
2232
+ allowProtected: Boolean(values["allow-protected"]),
2132
2233
  });
2133
2234
 
2134
2235
  if (values.json) {
@@ -2255,8 +2356,87 @@ async function main() {
2255
2356
  console.log(`--------------------------------------------------\n`);
2256
2357
  }
2257
2358
  process.exit(analysis.isFalsifiable ? 0 : 1);
2359
+ } else if (subCommand === "validate") {
2360
+ const { values, positionals } = parseArgs({
2361
+ args: args.slice(2),
2362
+ options: {
2363
+ json: { type: "boolean", short: "j" },
2364
+ },
2365
+ allowPositionals: true,
2366
+ });
2367
+
2368
+ const target = positionals[0];
2369
+ if (!target) {
2370
+ console.error("Usage: agentctl task validate <path-to-envelope.md|json> [--json]");
2371
+ process.exit(1);
2372
+ }
2373
+
2374
+ const targetPath = resolve(root, target);
2375
+ if (!existsSync(targetPath)) {
2376
+ console.error(`Error: Envelope file not found: ${target}`);
2377
+ process.exit(1);
2378
+ }
2379
+
2380
+ let payload = null;
2381
+ try {
2382
+ const content = readFileSync(targetPath, "utf-8");
2383
+ if (targetPath.endsWith(".json") || target.toLowerCase().endsWith(".json")) {
2384
+ payload = JSON.parse(content);
2385
+ } else {
2386
+ payload = parseEnvelopeHeader(content);
2387
+ if (!payload || typeof payload !== "object") {
2388
+ try {
2389
+ payload = JSON.parse(content);
2390
+ } catch {
2391
+ payload = null;
2392
+ }
2393
+ }
2394
+ }
2395
+ } catch (err) {
2396
+ console.error(`Error: Failed to read or parse envelope: ${err.message}`);
2397
+ process.exit(1);
2398
+ }
2399
+
2400
+ if (!payload || typeof payload !== "object") {
2401
+ console.error("❌ TASK ENVELOPE VALIDATION FAILED:");
2402
+ console.error(" - File does not contain a valid JSON payload or task envelope frontmatter.");
2403
+ process.exit(1);
2404
+ }
2405
+
2406
+ const res = validateEnvelope(payload, { root });
2407
+ const result = {
2408
+ ok: res.ok === true,
2409
+ file: target,
2410
+ errors: Array.isArray(res.errors) ? res.errors : [],
2411
+ warnings: Array.isArray(res.warnings) ? res.warnings : [],
2412
+ };
2413
+
2414
+ if (values.json) {
2415
+ console.log(JSON.stringify(result, null, 2));
2416
+ } else if (!result.ok) {
2417
+ console.error("❌ TASK ENVELOPE VALIDATION FAILED:");
2418
+ for (const err of result.errors) {
2419
+ console.error(` - ${err}`);
2420
+ }
2421
+ if (result.warnings.length > 0) {
2422
+ console.warn("⚠️ TASK ENVELOPE WARNINGS:");
2423
+ for (const w of result.warnings) {
2424
+ console.warn(` - ${w}`);
2425
+ }
2426
+ }
2427
+ } else {
2428
+ if (result.warnings.length > 0) {
2429
+ console.warn("⚠️ TASK ENVELOPE WARNINGS:");
2430
+ for (const w of result.warnings) {
2431
+ console.warn(` - ${w}`);
2432
+ }
2433
+ }
2434
+ console.log(`✅ Task envelope validated successfully: ${target}`);
2435
+ }
2436
+
2437
+ process.exit(result.ok ? 0 : 1);
2258
2438
  } else {
2259
- console.error(`Unknown task subcommand '${subCommand}'. Supported: agentctl task create, agentctl task optimize, agentctl task template`);
2439
+ console.error(`Unknown task subcommand '${subCommand}'. Supported: agentctl task create, agentctl task optimize, agentctl task template, agentctl task validate`);
2260
2440
  process.exit(1);
2261
2441
  }
2262
2442
  break;
@@ -2279,7 +2459,7 @@ async function main() {
2279
2459
  version: VERSION,
2280
2460
  root,
2281
2461
  pendingTasks: files.length,
2282
- activeLocks: lockStatus(root).length,
2462
+ activeLocks: lockStatus(root, { activeOnly: true }).length,
2283
2463
  budget,
2284
2464
  }, null, 2));
2285
2465
  process.exit(0);
@@ -2288,7 +2468,7 @@ async function main() {
2288
2468
  console.log(`--------------------------------------------------`);
2289
2469
  console.log(` Project Root : ${root}`);
2290
2470
  console.log(` Pending Tasks : ${files.length}`);
2291
- console.log(` Active VFS Locks : ${lockStatus(root).length}`);
2471
+ console.log(` Active VFS Locks : ${lockStatus(root, { activeOnly: true }).length}`);
2292
2472
  console.log(` Daily Budget : ${formatBudgetLine(budget)}`);
2293
2473
  console.log(`--------------------------------------------------\n`);
2294
2474
  process.exit(0);
@@ -3332,10 +3512,182 @@ async function main() {
3332
3512
  break;
3333
3513
  }
3334
3514
 
3335
- default:
3336
- console.error(`Unknown command: ${command}`);
3337
- printHelp();
3515
+ case "migrate": {
3516
+ const { values } = parseArgs({
3517
+ args: args.slice(1),
3518
+ options: {
3519
+ "dry-run": { type: "boolean", short: "d" },
3520
+ yes: { type: "boolean", short: "y" },
3521
+ json: { type: "boolean", short: "j" },
3522
+ },
3523
+ allowPositionals: true,
3524
+ });
3525
+
3526
+ const dryRun = Boolean(values["dry-run"]);
3527
+ const confirmed = Boolean(values.yes);
3528
+ const asJson = Boolean(values.json);
3529
+
3530
+ if (!dryRun && !confirmed) {
3531
+ if (asJson) {
3532
+ console.error(JSON.stringify({
3533
+ ok: false,
3534
+ error: "Refusing to migrate without --yes. Pass --dry-run to preview, or --yes to confirm.",
3535
+ }));
3536
+ } else {
3537
+ console.error("❌ Refusing to migrate without --yes. Pass --dry-run to preview, or --yes to confirm.");
3538
+ }
3539
+ process.exit(1);
3540
+ }
3541
+
3542
+ const { executeMigration } = await import("../src/migrate.mjs");
3543
+ const res = executeMigration(root, { dryRun });
3544
+
3545
+ if (!res.ok) {
3546
+ if (asJson) {
3547
+ console.log(JSON.stringify(res, null, 2));
3548
+ } else {
3549
+ console.error(`❌ ${res.error || "Migration failed."}`);
3550
+ }
3551
+ process.exit(1);
3552
+ }
3553
+
3554
+ if (asJson) {
3555
+ const payload = {
3556
+ ok: true,
3557
+ dryRun: Boolean(res.dryRun),
3558
+ migrated: res.migrated,
3559
+ warnings: res.warnings || [],
3560
+ };
3561
+ if (res.message) payload.message = res.message;
3562
+ console.log(JSON.stringify(payload, null, 2));
3563
+ process.exit(0);
3564
+ }
3565
+
3566
+ if (res.migrated.length === 0 && (!res.warnings || res.warnings.length === 0)) {
3567
+ console.log(`✅ ${res.message || "Repository is already in canonical v1 format."}`);
3568
+ process.exit(0);
3569
+ }
3570
+
3571
+ if (dryRun) {
3572
+ console.log("🧪 Dry run — no files modified.");
3573
+ if (res.migrated.length === 0) {
3574
+ console.log(" Nothing to migrate.");
3575
+ } else {
3576
+ console.log(" Would migrate:");
3577
+ for (const item of res.migrated) {
3578
+ console.log(` - [${item.type}] ${item.path}`);
3579
+ for (const change of item.changes || []) console.log(` • ${change}`);
3580
+ }
3581
+ }
3582
+ } else {
3583
+ console.log("✅ Migration applied.");
3584
+ for (const item of res.migrated) {
3585
+ console.log(` - [${item.type}] ${item.path}`);
3586
+ for (const change of item.changes || []) console.log(` • ${change}`);
3587
+ }
3588
+ }
3589
+
3590
+ if (res.warnings?.length) {
3591
+ console.log("⚠️ Warnings:");
3592
+ for (const w of res.warnings) console.log(` - ${w}`);
3593
+ }
3594
+
3595
+ process.exit(0);
3596
+ break;
3597
+ }
3598
+
3599
+ case "uninstall": {
3600
+ const { values } = parseArgs({
3601
+ args: args.slice(1),
3602
+ options: {
3603
+ "dry-run": { type: "boolean", short: "d" },
3604
+ yes: { type: "boolean", short: "y" },
3605
+ force: { type: "boolean", short: "f" },
3606
+ json: { type: "boolean", short: "j" },
3607
+ },
3608
+ allowPositionals: true,
3609
+ });
3610
+
3611
+ const dryRun = Boolean(values["dry-run"]);
3612
+ const confirmed = Boolean(values.yes);
3613
+ const force = Boolean(values.force);
3614
+ const asJson = Boolean(values.json);
3615
+
3616
+ if (!dryRun && !confirmed) {
3617
+ if (asJson) {
3618
+ console.error(JSON.stringify({
3619
+ ok: false,
3620
+ error: "Refusing to uninstall without --yes. Pass --dry-run to preview, or --yes to confirm.",
3621
+ }));
3622
+ } else {
3623
+ console.error("❌ Refusing to uninstall without --yes. Pass --dry-run to preview, or --yes to confirm.");
3624
+ }
3625
+ process.exit(1);
3626
+ }
3627
+
3628
+ const { uninstallKit } = await import("../src/uninstall.mjs");
3629
+ const res = uninstallKit(root, { dryRun, force });
3630
+
3631
+ if (!res.ok) {
3632
+ if (asJson) {
3633
+ console.log(JSON.stringify(res, null, 2));
3634
+ } else {
3635
+ console.error(`❌ ${res.error || "Uninstall failed."}`);
3636
+ }
3637
+ process.exit(1);
3638
+ }
3639
+
3640
+ if (asJson) {
3641
+ if (dryRun) {
3642
+ console.log(JSON.stringify({ ok: true, dryRun: true, willRemove: res.willRemove }, null, 2));
3643
+ } else {
3644
+ console.log(JSON.stringify({ ok: true, removed: res.removed }, null, 2));
3645
+ }
3646
+ process.exit(0);
3647
+ }
3648
+
3649
+ if (dryRun) {
3650
+ console.log("🧪 Dry run — no files modified.");
3651
+ console.log(" Would remove / clean:");
3652
+ for (const item of res.willRemove) console.log(` - ${item}`);
3653
+ if (force && (!res.willRemove.some((p) => p.startsWith(".agent/rules") || p.startsWith(".agent/prompts") || p.startsWith(".agent/workflows") || p.startsWith(".agent/jules-queue")))) {
3654
+ console.log(" (no legacy scaffold directories present)");
3655
+ }
3656
+ } else {
3657
+ console.log("✅ Kit configuration uninstalled.");
3658
+ console.log(" Removed / cleaned:");
3659
+ for (const item of res.removed) console.log(` - ${item}`);
3660
+ }
3661
+ process.exit(0);
3662
+ break;
3663
+ }
3664
+
3665
+ default: {
3666
+ const knownTopCommands = [
3667
+ ...new Set(
3668
+ COMMAND_REGISTRY.flatMap((c) => [c.path[0], ...(c.shortcuts || [])]).filter(Boolean)
3669
+ ),
3670
+ "help",
3671
+ "version",
3672
+ ];
3673
+ const { levenshteinDistance } = await import("../src/task-optimizer.mjs");
3674
+ let closest = null;
3675
+ let minDistance = Infinity;
3676
+ for (const known of knownTopCommands) {
3677
+ const dist = levenshteinDistance(command.toLowerCase(), known.toLowerCase());
3678
+ if (dist < minDistance) {
3679
+ minDistance = dist;
3680
+ closest = known;
3681
+ }
3682
+ }
3683
+ if (closest && minDistance <= 3) {
3684
+ console.error(`Unknown command: "${command}". Did you mean "${closest}"?`);
3685
+ } else {
3686
+ console.error(`Unknown command: "${command}".`);
3687
+ }
3688
+ console.error(`Run 'agentctl --help' to see all available commands.`);
3338
3689
  process.exit(1);
3690
+ }
3339
3691
  }
3340
3692
  }
3341
3693
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.74.0",
3
+ "version": "0.75.0",
4
4
  "description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for autonomous coding agents \u2014 Google Jules, Claude Code, Codex and Gemini CLI.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,8 +22,12 @@ import { normalizePath } from "../src/config.mjs";
22
22
  const EXIT_SCOPE_VIOLATION = 3;
23
23
  const EXIT_ERROR = 1;
24
24
 
25
- /** Label that lets a human consciously land a protected-path change. */
26
- export const BYPASS_LABEL = "allow-protected-paths";
25
+ /**
26
+ * Primary label prefix that lets a human consciously land a protected-path change bound to commit SHA.
27
+ * Conforms to GitHub's 50-character maximum label limit: "allow-p:<40-hex-SHA>" is 48 characters.
28
+ */
29
+ export const BYPASS_LABEL = "allow-p";
30
+ export const LEGACY_BYPASS_LABEL = "allow-protected-paths";
27
31
 
28
32
  function gitShow(ref, path, cwd) {
29
33
  return execFileSync("git", ["show", `${ref}:${path}`], {
@@ -101,7 +105,18 @@ export function listChangedFiles(opts = {}) {
101
105
  */
102
106
  export function evaluateScopeGuard(files = [], patterns = [], opts = {}) {
103
107
  const labels = (opts.labels || []).map((l) => String(l).toLowerCase().trim());
104
- const bypassed = labels.includes(BYPASS_LABEL);
108
+ const headSha = String(opts.headSha || "").trim().toLowerCase();
109
+
110
+ let bypassed = false;
111
+ if (headSha) {
112
+ if (/^[0-9a-f]{40}$/i.test(headSha)) {
113
+ const boundLabel = `${BYPASS_LABEL}:${headSha}`;
114
+ const legacyBoundLabel = `${LEGACY_BYPASS_LABEL}:${headSha}`;
115
+ bypassed = labels.includes(boundLabel) || labels.includes(legacyBoundLabel);
116
+ }
117
+ } else {
118
+ bypassed = labels.includes(BYPASS_LABEL) || labels.includes(LEGACY_BYPASS_LABEL);
119
+ }
105
120
 
106
121
  // Matching always runs at full strength; the label only decides whether a
107
122
  // match blocks. Passing `allowProtected` into checkScope instead would make
@@ -152,17 +167,25 @@ function main() {
152
167
  }
153
168
 
154
169
  const labels = parseLabels(process.env.PR_LABELS);
155
- const result = evaluateScopeGuard(files, patterns, { labels });
170
+ const result = evaluateScopeGuard(files, patterns, { labels, headSha });
156
171
 
157
172
  console.log(`Protected patterns (${patterns.length}): ${patterns.join(", ")}`);
158
173
  console.log(`Changed files (${files.length}):`);
159
174
  for (const f of files) console.log(` ${f}`);
160
175
 
176
+ const labelName = headSha ? `${BYPASS_LABEL}:${headSha}` : BYPASS_LABEL;
177
+ const legacyLabelName = headSha ? `${LEGACY_BYPASS_LABEL}:${headSha}` : LEGACY_BYPASS_LABEL;
178
+ const activeLabel = labels.includes(labelName)
179
+ ? labelName
180
+ : labels.includes(legacyLabelName)
181
+ ? legacyLabelName
182
+ : labelName;
183
+
161
184
  if (result.bypassed && result.violations.length > 0) {
162
185
  for (const v of result.violations) {
163
- console.log(`::warning file=${v.file}::Protected path modified under "${BYPASS_LABEL}": ${v.reason}`);
186
+ console.log(`::warning file=${v.file}::Protected path modified under "${activeLabel}": ${v.reason}`);
164
187
  }
165
- console.log(`\nLabel "${BYPASS_LABEL}" is present — ${result.violations.length} protected-path match(es) allowed by human review.`);
188
+ console.log(`\nLabel "${activeLabel}" is present — ${result.violations.length} protected-path match(es) allowed by human review.`);
166
189
  process.exit(0);
167
190
  }
168
191
 
@@ -176,7 +199,7 @@ function main() {
176
199
  }
177
200
  console.error(
178
201
  `::error::PR modifies ${result.violations.length} protected file(s). ` +
179
- `Apply the "${BYPASS_LABEL}" label after human review to land this intentionally.`
202
+ `Apply the "${labelName}" label after human review to land this intentionally.`
180
203
  );
181
204
  process.exit(EXIT_SCOPE_VIOLATION);
182
205
  }