lazycodex-ai 5.1.11 → 5.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/cli/index.js +43 -16
  2. package/dist/cli-node/index.js +43 -16
  3. package/package.json +1 -1
  4. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  5. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  6. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  7. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  8. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  9. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  10. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  11. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  12. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  13. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  14. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  15. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  16. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.5.md +1 -1
  17. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +1 -1
  18. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-6.md +1 -1
  19. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  20. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  21. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  22. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  23. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  24. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  25. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  26. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  27. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  28. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  29. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +89 -28
  30. package/packages/omo-codex/plugin/components/ulw-loop/dist/registered-agent-roles.d.ts +1 -0
  31. package/packages/omo-codex/plugin/components/ulw-loop/dist/registered-agent-roles.js +61 -0
  32. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.js +2 -1
  33. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-role-guard.d.ts +1 -1
  34. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-role-guard.js +6 -2
  35. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  36. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  37. package/packages/omo-codex/plugin/components/ulw-loop/src/registered-agent-roles.ts +60 -0
  38. package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-guard.ts +2 -1
  39. package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-role-guard.ts +8 -2
  40. package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-role-matrix.test.ts +1 -0
  41. package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-role-registered.test.ts +128 -0
  42. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  43. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  44. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  45. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  46. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  47. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  48. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  49. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  50. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  51. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  52. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  53. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  54. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  55. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  56. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  57. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  58. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  59. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  63. package/packages/omo-codex/plugin/package-lock.json +12 -12
  64. package/packages/omo-codex/plugin/package.json +1 -1
  65. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
  66. package/packages/omo-codex/plugin/test/aggregate-plugin-fixture.mjs +4 -3
  67. package/packages/omo-codex/plugin/test/aggregate.test.mjs +2 -2
  68. package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
  69. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +1 -1
@@ -4360,25 +4360,82 @@ function readAll(stdin) {
4360
4360
 
4361
4361
  // components/ulw-loop/src/spawn-guard.ts
4362
4362
  import { mkdirSync as mkdirSync3 } from "node:fs";
4363
- import { join as join7 } from "node:path";
4363
+ import { join as join8 } from "node:path";
4364
+
4365
+ // components/ulw-loop/src/registered-agent-roles.ts
4366
+ import { readdirSync as readdirSync3, readFileSync as readFileSync6 } from "node:fs";
4367
+ import { homedir } from "node:os";
4368
+ import { basename, dirname as dirname3, join as join6, resolve as resolve5 } from "node:path";
4369
+ var NAME_LINE = /^\s*name\s*=\s*"([^"\n]+)"\s*(?:#.*)?$/m;
4370
+ var AGENT_TABLE = /^\s*\[agents\.(?:"([^"\n]+)"|([A-Za-z0-9_-]+))\]\s*(?:#.*)?$/gm;
4371
+ function readText(path) {
4372
+ try {
4373
+ return readFileSync6(path, "utf8");
4374
+ } catch {
4375
+ return null;
4376
+ }
4377
+ }
4378
+ function standaloneRoleNames(agentsDir) {
4379
+ let entries;
4380
+ try {
4381
+ entries = readdirSync3(agentsDir);
4382
+ } catch {
4383
+ return [];
4384
+ }
4385
+ const names = [];
4386
+ for (const entry of entries) {
4387
+ if (!entry.endsWith(".toml"))
4388
+ continue;
4389
+ const text = readText(join6(agentsDir, entry));
4390
+ if (text === null)
4391
+ continue;
4392
+ names.push(text.match(NAME_LINE)?.[1] ?? basename(entry, ".toml"));
4393
+ }
4394
+ return names;
4395
+ }
4396
+ function configTableRoleNames(configPath) {
4397
+ const text = readText(configPath);
4398
+ if (text === null)
4399
+ return [];
4400
+ return [...text.matchAll(AGENT_TABLE)].map((match) => match[1] ?? match[2] ?? "").filter(Boolean);
4401
+ }
4402
+ function codexDirs(cwd, env) {
4403
+ const dirs = [resolve5(env["CODEX_HOME"]?.trim() || join6(homedir(), ".codex"))];
4404
+ for (let dir = resolve5(cwd);; dir = dirname3(dir)) {
4405
+ dirs.push(join6(dir, ".codex"));
4406
+ if (dirname3(dir) === dir)
4407
+ break;
4408
+ }
4409
+ return [...new Set(dirs)];
4410
+ }
4411
+ function registeredAgentRoles(cwd, env = process.env) {
4412
+ const names = new Set;
4413
+ for (const dir of codexDirs(cwd, env)) {
4414
+ for (const name of standaloneRoleNames(join6(dir, "agents")))
4415
+ names.add(name);
4416
+ for (const name of configTableRoleNames(join6(dir, "config.toml")))
4417
+ names.add(name);
4418
+ }
4419
+ return names;
4420
+ }
4364
4421
 
4365
4422
  // components/ulw-loop/src/spawn-budget-io.ts
4366
4423
  import { randomBytes } from "node:crypto";
4367
- import { existsSync as existsSync7, readFileSync as readFileSync6, renameSync as renameSync2, statSync as statSync3, writeFileSync as writeFileSync2 } from "node:fs";
4368
- import { dirname as dirname3, join as join6 } from "node:path";
4424
+ import { existsSync as existsSync7, readFileSync as readFileSync7, renameSync as renameSync2, statSync as statSync3, writeFileSync as writeFileSync2 } from "node:fs";
4425
+ import { dirname as dirname4, join as join7 } from "node:path";
4369
4426
  function readAdmissionBreaker(sessionId) {
4370
4427
  const dataDir = process.env["PLUGIN_DATA"];
4371
4428
  if (typeof dataDir !== "string")
4372
4429
  return null;
4373
4430
  try {
4374
- const value = JSON.parse(readFileSync6(join6(dataDir, "spawn-breaker", `${sessionId}.json`), "utf8"));
4431
+ const value = JSON.parse(readFileSync7(join7(dataDir, "spawn-breaker", `${sessionId}.json`), "utf8"));
4375
4432
  return typeof value === "object" && value !== null && "reason" in value && typeof value.reason === "string" ? value.reason : "capacity limit";
4376
4433
  } catch {
4377
4434
  return null;
4378
4435
  }
4379
4436
  }
4380
4437
  function atomicWriteJson(targetPath, data) {
4381
- const tmp = join6(dirname3(targetPath), `.tmp-${randomBytes(6).toString("hex")}`);
4438
+ const tmp = join7(dirname4(targetPath), `.tmp-${randomBytes(6).toString("hex")}`);
4382
4439
  writeFileSync2(tmp, JSON.stringify(data));
4383
4440
  renameSync2(tmp, targetPath);
4384
4441
  }
@@ -4393,7 +4450,7 @@ function isNonEmptyFile(path) {
4393
4450
  }
4394
4451
  function readCount(counterPath) {
4395
4452
  try {
4396
- const parsed = JSON.parse(readFileSync6(counterPath, "utf8"));
4453
+ const parsed = JSON.parse(readFileSync7(counterPath, "utf8"));
4397
4454
  return typeof parsed === "object" && parsed !== null && "count" in parsed && typeof parsed.count === "number" && parsed.count >= 0 ? parsed.count : 0;
4398
4455
  } catch (error) {
4399
4456
  if (error instanceof Error)
@@ -4403,7 +4460,7 @@ function readCount(counterPath) {
4403
4460
  }
4404
4461
  function readCounts(counterPath) {
4405
4462
  try {
4406
- const parsed = JSON.parse(readFileSync6(counterPath, "utf8"));
4463
+ const parsed = JSON.parse(readFileSync7(counterPath, "utf8"));
4407
4464
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
4408
4465
  return {};
4409
4466
  const counts = {};
@@ -4433,11 +4490,15 @@ var LAZYCODEX_SPAWN_ROLES = new Set([
4433
4490
  "momus",
4434
4491
  "plan"
4435
4492
  ]);
4436
- function spawnRoleDenial(input) {
4493
+ function spawnRoleDenial(input, registeredRoles = () => new Set) {
4437
4494
  const role = typeof input === "object" && input !== null && "agent_type" in input ? input.agent_type : undefined;
4438
4495
  if (typeof role === "string" && LAZYCODEX_SPAWN_ROLES.has(role))
4439
4496
  return null;
4440
- return `LazyCodex requires an explicit registered agent_type: ${[...LAZYCODEX_SPAWN_ROLES].join(", ")}. Received ${JSON.stringify(role) ?? "no agent_type"}. Use the matching role and fork_turns: "none" (V2) or fork_context: false (V1), unless full history is deliberately required. The hook cannot see the tool schema; if agent_type is unavailable, stop and report incompatible role routing rather than spawning a generic agent. Describing a role in message does not select its TOML.`;
4497
+ const registered = typeof role === "string" && role.trim() !== "" ? registeredRoles() : new Set;
4498
+ if (typeof role === "string" && registered.has(role))
4499
+ return null;
4500
+ const known = [...new Set([...LAZYCODEX_SPAWN_ROLES, ...registered])];
4501
+ return `LazyCodex requires an explicit registered agent_type: ${known.join(", ")}. Received ${JSON.stringify(role) ?? "no agent_type"}. Use the matching role and fork_turns: "none" (V2) or fork_context: false (V1), unless full history is deliberately required. The hook cannot see the tool schema; if agent_type is unavailable, stop and report incompatible role routing rather than spawning a generic agent. Describing a role in message does not select its TOML.`;
4441
4502
  }
4442
4503
 
4443
4504
  // components/ulw-loop/src/spawn-guard.ts
@@ -4461,7 +4522,7 @@ function applySpawnGuards(payload, options = {}) {
4461
4522
  if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name))
4462
4523
  return "";
4463
4524
  if (resolveToolkitSurface() === "lazycodex") {
4464
- const reason = spawnRoleDenial(payload.tool_input);
4525
+ const reason = spawnRoleDenial(payload.tool_input, () => registeredAgentRoles(payload.cwd));
4465
4526
  if (reason !== null)
4466
4527
  return deny(reason);
4467
4528
  }
@@ -4514,10 +4575,10 @@ async function runSpawnAdmissionRecorderCli(stdin, stdout) {
4514
4575
  const dataDir = process.env["PLUGIN_DATA"];
4515
4576
  if (typeof dataDir !== "string" || typeof payload["session_id"] !== "string")
4516
4577
  return;
4517
- const markerDir = join7(dataDir, "spawn-breaker");
4578
+ const markerDir = join8(dataDir, "spawn-breaker");
4518
4579
  try {
4519
4580
  mkdirSync3(markerDir, { recursive: true });
4520
- atomicWriteJson(join7(markerDir, `${payload["session_id"]}.json`), {
4581
+ atomicWriteJson(join8(markerDir, `${payload["session_id"]}.json`), {
4521
4582
  reason: response,
4522
4583
  at: new Date().toISOString()
4523
4584
  });
@@ -4546,7 +4607,7 @@ async function runSpawnGuardCli(stdin, stdout) {
4546
4607
  }
4547
4608
  }
4548
4609
  function peekFanOutBudget(stateDir) {
4549
- const counterPath = join7(stateDir, "spawn-count.json");
4610
+ const counterPath = join8(stateDir, "spawn-count.json");
4550
4611
  const count = readCount(counterPath) + 1;
4551
4612
  const limit = fanOutLimit();
4552
4613
  if (count <= limit)
@@ -4554,7 +4615,7 @@ function peekFanOutBudget(stateDir) {
4554
4615
  return `ulw-loop spawn fan-out cap reached (${count}/${limit}). Consolidate work into the agents already running, or raise OMO_SPAWN_FANOUT_LIMIT if this volume is intentional.`;
4555
4616
  }
4556
4617
  function consumeFanOutBudget(stateDir) {
4557
- const counterPath = join7(stateDir, "spawn-count.json");
4618
+ const counterPath = join8(stateDir, "spawn-count.json");
4558
4619
  const count = readCount(counterPath) + 1;
4559
4620
  atomicWriteJson(counterPath, { count });
4560
4621
  const limit = fanOutLimit();
@@ -4569,7 +4630,7 @@ function consumeReviewSpawnBudget(payload, plan, stateDir) {
4569
4630
  const goal = plan.goals.find((candidate) => candidate.id === plan.activeGoalId) ?? plan.goals.find((candidate) => isFinalRunCompletionCandidate(plan, candidate));
4570
4631
  if (goal === undefined)
4571
4632
  return null;
4572
- const counterPath = join7(stateDir, "review-spawn-counts.json");
4633
+ const counterPath = join8(stateDir, "review-spawn-counts.json");
4573
4634
  const limit = reviewSpawnLimit();
4574
4635
  const counts = readCounts(counterPath);
4575
4636
  const key = `${agentType}:${goal.id}:a${goal.attempt}`;
@@ -4594,13 +4655,13 @@ function missingGateArtifact(payload, plan) {
4594
4655
  const attemptDir = ulwLoopAttemptEvidenceDir(goal.id, goal.attempt, scope);
4595
4656
  for (const name of requiredArtifacts) {
4596
4657
  const relative = `${attemptDir}/${name}`;
4597
- if (!isNonEmptyFile(join7(payload.cwd, relative)))
4658
+ if (!isNonEmptyFile(join8(payload.cwd, relative)))
4598
4659
  return relative;
4599
4660
  }
4600
4661
  return null;
4601
4662
  }
4602
4663
  const manualQa = `.omo/evidence/${goal.id}-manual-qa.md`;
4603
- return isNonEmptyFile(join7(payload.cwd, manualQa)) ? null : manualQa;
4664
+ return isNonEmptyFile(join8(payload.cwd, manualQa)) ? null : manualQa;
4604
4665
  }
4605
4666
  function isGateReviewerSpawn(toolInput) {
4606
4667
  const agentType = reviewAgentType(toolInput);
@@ -4684,8 +4745,8 @@ function readPlan(repoRoot, sessionId) {
4684
4745
  }
4685
4746
 
4686
4747
  // components/ulw-loop/src/stop-resume-hook.ts
4687
- import { existsSync as existsSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "node:fs";
4688
- import { isAbsolute as isAbsolute3, join as join8, resolve as resolve5, sep as sep3 } from "node:path";
4748
+ import { existsSync as existsSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "node:fs";
4749
+ import { isAbsolute as isAbsolute3, join as join9, resolve as resolve6, sep as sep3 } from "node:path";
4689
4750
  var RESUME_CAP = 2;
4690
4751
  var CONTEXT_PRESSURE_MARKERS2 = [
4691
4752
  "context compacted",
@@ -4753,8 +4814,8 @@ function consumeResumeBudgetLocked(lockPath, stateDir, goalId) {
4753
4814
  }
4754
4815
  function consumeResumeBudget(stateDir, goalId) {
4755
4816
  const ledgerLineCount = readLedgerAt(stateDir).length;
4756
- const counterPath = resolve5(stateDir, `auto-resume-${goalId}.json`);
4757
- const stuckPath = resolve5(stateDir, `auto-resume-${goalId}.stuck`);
4817
+ const counterPath = resolve6(stateDir, `auto-resume-${goalId}.json`);
4818
+ const stuckPath = resolve6(stateDir, `auto-resume-${goalId}.stuck`);
4758
4819
  if (!isInsideDir(stateDir, counterPath) || !isInsideDir(stateDir, stuckPath))
4759
4820
  return false;
4760
4821
  const previous = readCounter(counterPath);
@@ -4768,7 +4829,7 @@ function consumeResumeBudget(stateDir, goalId) {
4768
4829
  return true;
4769
4830
  }
4770
4831
  function isInsideDir(dir, candidate) {
4771
- return candidate.startsWith(resolve5(dir) + sep3);
4832
+ return candidate.startsWith(resolve6(dir) + sep3);
4772
4833
  }
4773
4834
  function renderResumeDirective(plan, goal, sessionId) {
4774
4835
  const normalized = normalizeUlwLoopSessionId(sessionId);
@@ -4796,7 +4857,7 @@ function readCounter(counterPath) {
4796
4857
  try {
4797
4858
  if (!existsSync8(counterPath))
4798
4859
  return null;
4799
- const parsed = JSON.parse(readFileSync7(counterPath, "utf8"));
4860
+ const parsed = JSON.parse(readFileSync8(counterPath, "utf8"));
4800
4861
  if (typeof parsed["count"] !== "number" || typeof parsed["ledgerLineCount"] !== "number")
4801
4862
  return null;
4802
4863
  return { count: parsed["count"], ledgerLineCount: parsed["ledgerLineCount"] };
@@ -4808,7 +4869,7 @@ function readCounter(counterPath) {
4808
4869
  }
4809
4870
  function boulderContinuationWillFire(cwd, sessionId) {
4810
4871
  try {
4811
- const raw = JSON.parse(readFileSync7(join8(cwd, ".omo", "boulder.json"), "utf8"));
4872
+ const raw = JSON.parse(readFileSync8(join9(cwd, ".omo", "boulder.json"), "utf8"));
4812
4873
  const works = raw["works"];
4813
4874
  const entries = typeof works === "object" && works !== null ? Object.values(works) : [raw];
4814
4875
  return entries.some((work) => {
@@ -4827,7 +4888,7 @@ function boulderContinuationWillFire(cwd, sessionId) {
4827
4888
  }
4828
4889
  function transcriptShowsContextPressure(transcriptPath) {
4829
4890
  try {
4830
- const transcript = readFileSync7(transcriptPath, "utf8").toLowerCase();
4891
+ const transcript = readFileSync8(transcriptPath, "utf8").toLowerCase();
4831
4892
  return CONTEXT_PRESSURE_MARKERS2.some((marker) => transcript.includes(marker));
4832
4893
  } catch (error) {
4833
4894
  if (error instanceof Error)
@@ -4839,12 +4900,12 @@ function boulderPlanHasChecklist(cwd, entry) {
4839
4900
  const activePlan = entry["active_plan"];
4840
4901
  if (typeof activePlan !== "string" || activePlan.trim().length === 0)
4841
4902
  return false;
4842
- const planPath = isAbsolute3(activePlan) ? activePlan : join8(cwd, activePlan);
4903
+ const planPath = isAbsolute3(activePlan) ? activePlan : join9(cwd, activePlan);
4843
4904
  const worktree = entry["worktree_path"];
4844
- const candidates = typeof worktree === "string" && worktree.trim().length > 0 && !isAbsolute3(activePlan) ? [join8(isAbsolute3(worktree) ? worktree : join8(cwd, worktree), activePlan), planPath] : [planPath];
4905
+ const candidates = typeof worktree === "string" && worktree.trim().length > 0 && !isAbsolute3(activePlan) ? [join9(isAbsolute3(worktree) ? worktree : join9(cwd, worktree), activePlan), planPath] : [planPath];
4845
4906
  for (const candidate of candidates) {
4846
4907
  try {
4847
- return readFileSync7(candidate, "utf8").split(/\r?\n/).some((line) => line.startsWith("- [ ] ") || line.startsWith("- [x] ") || line.startsWith("- [X] "));
4908
+ return readFileSync8(candidate, "utf8").split(/\r?\n/).some((line) => line.startsWith("- [ ] ") || line.startsWith("- [x] ") || line.startsWith("- [X] "));
4848
4909
  } catch (error) {
4849
4910
  if (!(error instanceof Error))
4850
4911
  throw error;
@@ -0,0 +1 @@
1
+ export declare function registeredAgentRoles(cwd: string, env?: NodeJS.ProcessEnv): ReadonlySet<string>;
@@ -0,0 +1,61 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ // Codex registers a role from a standalone `agents/*.toml` (keyed by its `name`, else the
5
+ // file stem) or from an `[agents.<name>]` table in config.toml. It reads both under
6
+ // CODEX_HOME and under a project's `.codex/`. Codex still validates the role itself (trust,
7
+ // required fields); this only answers "does a role file declare this name".
8
+ const NAME_LINE = /^\s*name\s*=\s*"([^"\n]+)"\s*(?:#.*)?$/m;
9
+ const AGENT_TABLE = /^\s*\[agents\.(?:"([^"\n]+)"|([A-Za-z0-9_-]+))\]\s*(?:#.*)?$/gm;
10
+ function readText(path) {
11
+ try {
12
+ return readFileSync(path, "utf8");
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ function standaloneRoleNames(agentsDir) {
19
+ let entries;
20
+ try {
21
+ entries = readdirSync(agentsDir);
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ const names = [];
27
+ for (const entry of entries) {
28
+ if (!entry.endsWith(".toml"))
29
+ continue;
30
+ const text = readText(join(agentsDir, entry));
31
+ if (text === null)
32
+ continue;
33
+ names.push(text.match(NAME_LINE)?.[1] ?? basename(entry, ".toml"));
34
+ }
35
+ return names;
36
+ }
37
+ function configTableRoleNames(configPath) {
38
+ const text = readText(configPath);
39
+ if (text === null)
40
+ return [];
41
+ return [...text.matchAll(AGENT_TABLE)].map((match) => match[1] ?? match[2] ?? "").filter(Boolean);
42
+ }
43
+ function codexDirs(cwd, env) {
44
+ const dirs = [resolve(env["CODEX_HOME"]?.trim() || join(homedir(), ".codex"))];
45
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
46
+ dirs.push(join(dir, ".codex"));
47
+ if (dirname(dir) === dir)
48
+ break;
49
+ }
50
+ return [...new Set(dirs)];
51
+ }
52
+ export function registeredAgentRoles(cwd, env = process.env) {
53
+ const names = new Set();
54
+ for (const dir of codexDirs(cwd, env)) {
55
+ for (const name of standaloneRoleNames(join(dir, "agents")))
56
+ names.add(name);
57
+ for (const name of configTableRoleNames(join(dir, "config.toml")))
58
+ names.add(name);
59
+ }
60
+ return names;
61
+ }
@@ -4,6 +4,7 @@ import { parsePreToolUsePayload } from "./codex-hook.js";
4
4
  import { isFinalRunCompletionCandidate } from "./goal-status.js";
5
5
  import { ulwLoopAttemptEvidenceDir, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
6
6
  import { readUlwLoopPlanSync } from "./plan-io.js";
7
+ import { registeredAgentRoles } from "./registered-agent-roles.js";
7
8
  import { atomicWriteJson, isNonEmptyFile, readAdmissionBreaker, readCount, readCounts } from "./spawn-budget-io.js";
8
9
  import { spawnRoleDenial } from "./spawn-role-guard.js";
9
10
  import { isStateLockTimeout, withStateLockSync } from "./state-lock.js";
@@ -31,7 +32,7 @@ export function applySpawnGuards(payload, options = {}) {
31
32
  if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name))
32
33
  return "";
33
34
  if (resolveToolkitSurface() === "lazycodex") {
34
- const reason = spawnRoleDenial(payload.tool_input);
35
+ const reason = spawnRoleDenial(payload.tool_input, () => registeredAgentRoles(payload.cwd));
35
36
  if (reason !== null)
36
37
  return deny(reason);
37
38
  }
@@ -1,2 +1,2 @@
1
1
  export declare const LAZYCODEX_SPAWN_ROLES: ReadonlySet<string>;
2
- export declare function spawnRoleDenial(input: unknown): string | null;
2
+ export declare function spawnRoleDenial(input: unknown, registeredRoles?: () => ReadonlySet<string>): string | null;
@@ -14,9 +14,13 @@ export const LAZYCODEX_SPAWN_ROLES = new Set([
14
14
  "momus",
15
15
  "plan",
16
16
  ]);
17
- export function spawnRoleDenial(input) {
17
+ export function spawnRoleDenial(input, registeredRoles = () => new Set()) {
18
18
  const role = typeof input === "object" && input !== null && "agent_type" in input ? input.agent_type : undefined;
19
19
  if (typeof role === "string" && LAZYCODEX_SPAWN_ROLES.has(role))
20
20
  return null;
21
- return `LazyCodex requires an explicit registered agent_type: ${[...LAZYCODEX_SPAWN_ROLES].join(", ")}. Received ${JSON.stringify(role) ?? "no agent_type"}. Use the matching role and fork_turns: "none" (V2) or fork_context: false (V1), unless full history is deliberately required. The hook cannot see the tool schema; if agent_type is unavailable, stop and report incompatible role routing rather than spawning a generic agent. Describing a role in message does not select its TOML.`;
21
+ const registered = typeof role === "string" && role.trim() !== "" ? registeredRoles() : new Set();
22
+ if (typeof role === "string" && registered.has(role))
23
+ return null;
24
+ const known = [...new Set([...LAZYCODEX_SPAWN_ROLES, ...registered])];
25
+ return `LazyCodex requires an explicit registered agent_type: ${known.join(", ")}. Received ${JSON.stringify(role) ?? "no agent_type"}. Use the matching role and fork_turns: "none" (V2) or fork_context: false (V1), unless full history is deliberately required. The hook cannot see the tool schema; if agent_type is unavailable, stop and report incompatible role routing rather than spawning a generic agent. Describing a role in message does not select its TOML.`;
22
26
  }
@@ -7,7 +7,7 @@
7
7
  "type": "command",
8
8
  "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit --with-ultrawork",
9
9
  "timeout": 10,
10
- "statusMessage": "(OmO 5.1.11) Checking Ulw-Loop Steering"
10
+ "statusMessage": "(OmO 5.1.13) Checking Ulw-Loop Steering"
11
11
  }
12
12
  ]
13
13
  }
@@ -20,7 +20,7 @@
20
20
  "type": "command",
21
21
  "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use-spawn",
22
22
  "timeout": 5,
23
- "statusMessage": "(OmO 5.1.11) Recording Spawn Admission"
23
+ "statusMessage": "(OmO 5.1.13) Recording Spawn Admission"
24
24
  }
25
25
  ]
26
26
  }
@@ -33,7 +33,7 @@
33
33
  "type": "command",
34
34
  "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook pre-tool-use",
35
35
  "timeout": 5,
36
- "statusMessage": "(OmO 5.1.11) Enforcing Unlimited Ulw-Loop Budget"
36
+ "statusMessage": "(OmO 5.1.13) Enforcing Unlimited Ulw-Loop Budget"
37
37
  }
38
38
  ]
39
39
  },
@@ -44,7 +44,7 @@
44
44
  "type": "command",
45
45
  "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook pre-tool-use-spawn",
46
46
  "timeout": 5,
47
- "statusMessage": "(OmO 5.1.11) Guarding Ulw-Loop Spawns"
47
+ "statusMessage": "(OmO 5.1.13) Guarding Ulw-Loop Spawns"
48
48
  }
49
49
  ]
50
50
  }
@@ -56,7 +56,7 @@
56
56
  "type": "command",
57
57
  "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook stop",
58
58
  "timeout": 10,
59
- "statusMessage": "(OmO 5.1.11) Checking Ulw-Loop Resume"
59
+ "statusMessage": "(OmO 5.1.13) Checking Ulw-Loop Resume"
60
60
  }
61
61
  ]
62
62
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/codex-ulw-loop",
3
- "version": "5.1.11",
3
+ "version": "5.1.13",
4
4
  "description": "Codex plugin: durable repo-native multi-goal orchestration with embedded success criteria and observable evidence audit.",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.12.1",
@@ -0,0 +1,60 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+
5
+ // Codex registers a role from a standalone `agents/*.toml` (keyed by its `name`, else the
6
+ // file stem) or from an `[agents.<name>]` table in config.toml. It reads both under
7
+ // CODEX_HOME and under a project's `.codex/`. Codex still validates the role itself (trust,
8
+ // required fields); this only answers "does a role file declare this name".
9
+
10
+ const NAME_LINE = /^\s*name\s*=\s*"([^"\n]+)"\s*(?:#.*)?$/m;
11
+ const AGENT_TABLE = /^\s*\[agents\.(?:"([^"\n]+)"|([A-Za-z0-9_-]+))\]\s*(?:#.*)?$/gm;
12
+
13
+ function readText(path: string): string | null {
14
+ try {
15
+ return readFileSync(path, "utf8");
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function standaloneRoleNames(agentsDir: string): string[] {
22
+ let entries: string[];
23
+ try {
24
+ entries = readdirSync(agentsDir);
25
+ } catch {
26
+ return [];
27
+ }
28
+ const names: string[] = [];
29
+ for (const entry of entries) {
30
+ if (!entry.endsWith(".toml")) continue;
31
+ const text = readText(join(agentsDir, entry));
32
+ if (text === null) continue;
33
+ names.push(text.match(NAME_LINE)?.[1] ?? basename(entry, ".toml"));
34
+ }
35
+ return names;
36
+ }
37
+
38
+ function configTableRoleNames(configPath: string): string[] {
39
+ const text = readText(configPath);
40
+ if (text === null) return [];
41
+ return [...text.matchAll(AGENT_TABLE)].map((match) => match[1] ?? match[2] ?? "").filter(Boolean);
42
+ }
43
+
44
+ function codexDirs(cwd: string, env: NodeJS.ProcessEnv): string[] {
45
+ const dirs = [resolve(env["CODEX_HOME"]?.trim() || join(homedir(), ".codex"))];
46
+ for (let dir = resolve(cwd); ; dir = dirname(dir)) {
47
+ dirs.push(join(dir, ".codex"));
48
+ if (dirname(dir) === dir) break;
49
+ }
50
+ return [...new Set(dirs)];
51
+ }
52
+
53
+ export function registeredAgentRoles(cwd: string, env: NodeJS.ProcessEnv = process.env): ReadonlySet<string> {
54
+ const names = new Set<string>();
55
+ for (const dir of codexDirs(cwd, env)) {
56
+ for (const name of standaloneRoleNames(join(dir, "agents"))) names.add(name);
57
+ for (const name of configTableRoleNames(join(dir, "config.toml"))) names.add(name);
58
+ }
59
+ return names;
60
+ }
@@ -6,6 +6,7 @@ import { parsePreToolUsePayload } from "./codex-hook.js";
6
6
  import { isFinalRunCompletionCandidate } from "./goal-status.js";
7
7
  import { ulwLoopAttemptEvidenceDir, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
8
8
  import { readUlwLoopPlanSync } from "./plan-io.js";
9
+ import { registeredAgentRoles } from "./registered-agent-roles.js";
9
10
  import { atomicWriteJson, isNonEmptyFile, readAdmissionBreaker, readCount, readCounts } from "./spawn-budget-io.js";
10
11
  import { spawnRoleDenial } from "./spawn-role-guard.js";
11
12
  import { isStateLockTimeout, type StateLockOptions, withStateLockSync } from "./state-lock.js";
@@ -47,7 +48,7 @@ export interface SpawnGuardOptions {
47
48
  export function applySpawnGuards(payload: PreToolUsePayload, options: SpawnGuardOptions = {}): string {
48
49
  if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name)) return "";
49
50
  if (resolveToolkitSurface() === "lazycodex") {
50
- const reason = spawnRoleDenial(payload.tool_input);
51
+ const reason = spawnRoleDenial(payload.tool_input, () => registeredAgentRoles(payload.cwd));
51
52
  if (reason !== null) return deny(reason);
52
53
  }
53
54
  return applySpawnBudgetGuards(payload, options);
@@ -15,8 +15,14 @@ export const LAZYCODEX_SPAWN_ROLES: ReadonlySet<string> = new Set([
15
15
  "plan",
16
16
  ]);
17
17
 
18
- export function spawnRoleDenial(input: unknown): string | null {
18
+ export function spawnRoleDenial(
19
+ input: unknown,
20
+ registeredRoles: () => ReadonlySet<string> = () => new Set(),
21
+ ): string | null {
19
22
  const role = typeof input === "object" && input !== null && "agent_type" in input ? input.agent_type : undefined;
20
23
  if (typeof role === "string" && LAZYCODEX_SPAWN_ROLES.has(role)) return null;
21
- return `LazyCodex requires an explicit registered agent_type: ${[...LAZYCODEX_SPAWN_ROLES].join(", ")}. Received ${JSON.stringify(role) ?? "no agent_type"}. Use the matching role and fork_turns: "none" (V2) or fork_context: false (V1), unless full history is deliberately required. The hook cannot see the tool schema; if agent_type is unavailable, stop and report incompatible role routing rather than spawning a generic agent. Describing a role in message does not select its TOML.`;
24
+ const registered = typeof role === "string" && role.trim() !== "" ? registeredRoles() : new Set<string>();
25
+ if (typeof role === "string" && registered.has(role)) return null;
26
+ const known = [...new Set([...LAZYCODEX_SPAWN_ROLES, ...registered])];
27
+ return `LazyCodex requires an explicit registered agent_type: ${known.join(", ")}. Received ${JSON.stringify(role) ?? "no agent_type"}. Use the matching role and fork_turns: "none" (V2) or fork_context: false (V1), unless full history is deliberately required. The hook cannot see the tool schema; if agent_type is unavailable, stop and report incompatible role routing rather than spawning a generic agent. Describing a role in message does not select its TOML.`;
22
28
  }
@@ -11,6 +11,7 @@ beforeEach(async () => {
11
11
  cwd = await mkdtemp(join(tmpdir(), "spawn-role-matrix-"));
12
12
  vi.stubEnv("OMO_AGENT_TOOLKIT_SURFACE", "lazycodex");
13
13
  vi.stubEnv("PLUGIN_DATA", join(cwd, "data"));
14
+ vi.stubEnv("CODEX_HOME", join(cwd, "codex-home"));
14
15
  });
15
16
  afterEach(async () => {
16
17
  vi.unstubAllEnvs();
@@ -0,0 +1,128 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { applySpawnGuards } from "../src/spawn-guard.js";
6
+
7
+ let root: string;
8
+ let project: string;
9
+ let codexHome: string;
10
+
11
+ beforeEach(async () => {
12
+ root = await mkdtemp(join(tmpdir(), "spawn-role-registered-"));
13
+ project = join(root, "project");
14
+ codexHome = join(root, "codex-home");
15
+ await mkdir(join(project, "src", "deep"), { recursive: true });
16
+ vi.stubEnv("OMO_AGENT_TOOLKIT_SURFACE", "lazycodex");
17
+ vi.stubEnv("PLUGIN_DATA", join(root, "data"));
18
+ vi.stubEnv("CODEX_HOME", codexHome);
19
+ });
20
+ afterEach(async () => {
21
+ vi.unstubAllEnvs();
22
+ await rm(root, { recursive: true, force: true });
23
+ });
24
+
25
+ async function role(dir: string, file: string, name?: string): Promise<void> {
26
+ await mkdir(dir, { recursive: true });
27
+ const header = name === undefined ? "" : `name = "${name}"\n`;
28
+ await writeFile(join(dir, file), `${header}description = "custom role"\ndeveloper_instructions = "Do the work."\n`);
29
+ }
30
+
31
+ function spawn(cwd: string, input: Record<string, unknown>): string {
32
+ return applySpawnGuards({
33
+ cwd,
34
+ hook_event_name: "PreToolUse",
35
+ model: "test",
36
+ permission_mode: "default",
37
+ session_id: "registered",
38
+ tool_name: "spawn_agent",
39
+ tool_input: {
40
+ task_name: "check",
41
+ message: "Perform the assigned read-only analysis.",
42
+ fork_turns: "none",
43
+ ...input,
44
+ },
45
+ tool_use_id: "call",
46
+ transcript_path: null,
47
+ turn_id: "turn",
48
+ });
49
+ }
50
+
51
+ function denial(output: string): string | null {
52
+ if (output === "") return null;
53
+ const parsed: { hookSpecificOutput: { permissionDecision: string; permissionDecisionReason: string } } =
54
+ JSON.parse(output);
55
+ expect(parsed.hookSpecificOutput.permissionDecision).toBe("deny");
56
+ return parsed.hookSpecificOutput.permissionDecisionReason;
57
+ }
58
+
59
+ describe("#given custom roles registered outside the bundle", () => {
60
+ describe("#when a project registers them in .codex/agents", () => {
61
+ it("#then each registered role spawns, including from a nested working directory", async () => {
62
+ const agents = join(project, ".codex", "agents");
63
+ for (const name of ["cashflow_planner", "debt_planner", "wealth_planner", "finance_reviewer"])
64
+ await role(agents, `${name}.toml`, name);
65
+
66
+ for (const name of ["cashflow_planner", "debt_planner", "wealth_planner", "finance_reviewer"]) {
67
+ expect(denial(spawn(project, { agent_type: name }))).toBeNull();
68
+ expect(denial(spawn(join(project, "src", "deep"), { agent_type: name }))).toBeNull();
69
+ }
70
+ });
71
+
72
+ it("#then the role is keyed by its declared name, not the file name", async () => {
73
+ await role(join(project, ".codex", "agents"), "planner-file.toml", "wealth_planner");
74
+
75
+ expect(denial(spawn(project, { agent_type: "wealth_planner" }))).toBeNull();
76
+ expect(denial(spawn(project, { agent_type: "planner-file" }))).toContain('Received "planner-file"');
77
+ });
78
+
79
+ it("#then a role file without a name field registers under its file name", async () => {
80
+ await role(join(project, ".codex", "agents"), "cashflow_planner.toml");
81
+
82
+ expect(denial(spawn(project, { agent_type: "cashflow_planner" }))).toBeNull();
83
+ });
84
+ });
85
+
86
+ describe("#when they are registered under CODEX_HOME", () => {
87
+ it("#then a standalone agents/*.toml role and a config.toml [agents.<name>] role both spawn", async () => {
88
+ await role(join(codexHome, "agents"), "finance_reviewer.toml", "finance_reviewer");
89
+ await writeFile(
90
+ join(codexHome, "config.toml"),
91
+ '[agents]\nmax_threads = 4\n\n[agents.debt_planner]\nconfig_file = "./agents/debt.toml"\n',
92
+ );
93
+
94
+ expect(denial(spawn(project, { agent_type: "finance_reviewer" }))).toBeNull();
95
+ expect(denial(spawn(project, { agent_type: "debt_planner" }))).toBeNull();
96
+ });
97
+ });
98
+ });
99
+
100
+ describe("#given the guard's purpose of never spawning an unregistered or generic agent", () => {
101
+ it("#when the role has no role file anywhere #then it is still denied and the message lists the registered roles", async () => {
102
+ await role(join(project, ".codex", "agents"), "cashflow_planner.toml", "cashflow_planner");
103
+
104
+ const reason = denial(spawn(project, { agent_type: "worker" }));
105
+
106
+ expect(reason).toContain('Received "worker"');
107
+ expect(reason).toContain("cashflow_planner");
108
+ expect(reason).toContain("lazycodex-worker-medium");
109
+ });
110
+
111
+ it("#when agent_type is missing #then it is denied even with custom roles registered", async () => {
112
+ await role(join(project, ".codex", "agents"), "cashflow_planner.toml", "cashflow_planner");
113
+
114
+ expect(denial(spawn(project, {}))).toContain("Received no agent_type");
115
+ });
116
+
117
+ it("#when a role is registered only in a different project #then this project still denies it", async () => {
118
+ const other = join(root, "other-project");
119
+ await role(join(other, ".codex", "agents"), "cashflow_planner.toml", "cashflow_planner");
120
+
121
+ expect(denial(spawn(project, { agent_type: "cashflow_planner" }))).toContain('Received "cashflow_planner"');
122
+ });
123
+
124
+ it("#when the agents directory is unreadable or absent #then bundled roles still spawn", async () => {
125
+ expect(denial(spawn(project, { agent_type: "lazycodex-worker-medium" }))).toBeNull();
126
+ expect(denial(spawn(project, { agent_type: "explorer" }))).toBeNull();
127
+ });
128
+ });