oh-my-opencode 5.0.0-beta.60 → 5.0.0-beta.62

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 (83) hide show
  1. package/dist/cli/index.js +418 -330
  2. package/dist/cli-node/index.js +418 -330
  3. package/dist/index.js +14 -14
  4. package/package.json +14 -14
  5. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  6. package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +6885 -249
  7. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  8. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  9. package/packages/omo-codex/plugin/components/bootstrap/src/setup.ts +7 -1
  10. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  11. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  12. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  13. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  14. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  15. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  16. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  17. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  18. package/packages/omo-codex/plugin/components/lsp/package.json +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/dist/cli.js +20 -11
  26. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  27. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  28. package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
  29. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  30. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  31. package/packages/omo-codex/plugin/components/ulw-loop/CHANGELOG.md +2 -0
  32. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +20 -11
  33. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +58 -5
  34. package/packages/omo-codex/plugin/components/ulw-loop/dist/sdk/factory.js +17 -1
  35. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.d.ts +1 -0
  36. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.js +21 -4
  37. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-role-guard.d.ts +2 -0
  38. package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-role-guard.js +22 -0
  39. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +7 -7
  40. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  41. package/packages/omo-codex/plugin/components/ulw-loop/src/sdk/factory.ts +25 -1
  42. package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-guard.ts +21 -3
  43. package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-role-guard.ts +22 -0
  44. package/packages/omo-codex/plugin/components/ulw-loop/test/sdk-driver-states.test.ts +145 -0
  45. package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-guard.test.ts +7 -1
  46. package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-role-matrix.test.ts +113 -0
  47. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  48. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  49. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  50. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  51. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  52. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  53. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  54. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  55. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  56. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  57. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  58. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  59. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  63. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  65. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  66. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  67. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  68. package/packages/omo-codex/plugin/package-lock.json +12 -12
  69. package/packages/omo-codex/plugin/package.json +1 -1
  70. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +3 -3
  71. package/packages/omo-codex/plugin/skills/init-deep/SKILL.md +3 -3
  72. package/packages/omo-codex/plugin/skills/refactor/SKILL.md +3 -3
  73. package/packages/omo-codex/plugin/skills/remove-ai-slops/SKILL.md +3 -3
  74. package/packages/omo-codex/plugin/skills/review-work/SKILL.md +3 -3
  75. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +20 -11
  76. package/packages/omo-codex/plugin/skills/ulw-execute/SKILL.md +3 -3
  77. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +3 -3
  78. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +3 -3
  79. package/packages/omo-codex/plugin/skills/visual-qa/SKILL.md +3 -3
  80. package/packages/omo-codex/plugin/test/bootstrap-default-role.test.mjs +66 -0
  81. package/packages/omo-codex/plugin/test/component-hook-contract-cases.mjs +13 -1
  82. package/packages/omo-codex/scripts/install-dist/install-local.mjs +7058 -417
  83. package/packages/prompts-core/prompts/ultrawork/codex.md +20 -11
@@ -3059,6 +3059,15 @@ var ULW_LOOP_MANIFEST = {
3059
3059
  };
3060
3060
 
3061
3061
  // components/ulw-loop/src/sdk/factory.ts
3062
+ function validateCodexGoalJson(raw) {
3063
+ if (raw === undefined)
3064
+ return;
3065
+ try {
3066
+ JSON.parse(raw);
3067
+ } catch (error) {
3068
+ throw new UlwLoopError(`Invalid codexGoal: ${error instanceof Error ? error.message : "not valid JSON"}`, "ULW_LOOP_CODEX_GOAL_JSON_INVALID", { cause: error });
3069
+ }
3070
+ }
3062
3071
  function validateContext(context) {
3063
3072
  if (!context.cwd.trim())
3064
3073
  throw new UlwLoopError("cwd is required.", "ULW_LOOP_CWD_REQUIRED");
@@ -3088,6 +3097,10 @@ function nextActionsFrom(result) {
3088
3097
  return [];
3089
3098
  return result.nextActions.filter((action) => typeof action === "string").slice(0, 8);
3090
3099
  }
3100
+ function checkpointWithValidatedSnapshot(context, scope, args) {
3101
+ validateCodexGoalJson(args.codexGoalJson);
3102
+ return checkpointUlwLoop(context.cwd, args, scope, { surface: context.surface });
3103
+ }
3091
3104
  function createAgentToolkit(context, deps = {}) {
3092
3105
  validateContext(context);
3093
3106
  const scope = { sessionId: context.sessionId };
@@ -3148,7 +3161,7 @@ function createAgentToolkit(context, deps = {}) {
3148
3161
  };
3149
3162
  }),
3150
3163
  completeGoals: (args = {}) => invoke("complete-goals", () => startNextUlwLoop(context.cwd, args, scope)),
3151
- checkpoint: (args) => invoke("checkpoint", () => args.printTemplate === true ? checkpointTemplate(context.cwd, scope, args.goalId, { surface: context.surface }) : checkpointUlwLoop(context.cwd, args, scope, { surface: context.surface })),
3164
+ checkpoint: (args) => invoke("checkpoint", () => args.printTemplate === true ? checkpointTemplate(context.cwd, scope, args.goalId, { surface: context.surface }) : checkpointWithValidatedSnapshot(context, scope, args)),
3152
3165
  steer: (args) => invoke("steer", () => steerUlwLoop(context.cwd, args, scope)),
3153
3166
  addGoal: (args) => invoke("add-goal", () => addUlwLoopGoal(context.cwd, args, scope)),
3154
3167
  criteria: (args) => invoke("criteria", async () => {
@@ -3809,7 +3822,36 @@ function readAll(stdin) {
3809
3822
  import { randomBytes } from "node:crypto";
3810
3823
  import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync5, renameSync, statSync as statSync3, writeFileSync } from "node:fs";
3811
3824
  import { dirname as dirname3, join as join3 } from "node:path";
3812
- var SPAWN_TOOL_TOKENS = new Set(["spawn_agent", "collaborationspawn_agent", "collaboration.spawn_agent"]);
3825
+
3826
+ // components/ulw-loop/src/spawn-role-guard.ts
3827
+ var LAZYCODEX_SPAWN_ROLES = new Set([
3828
+ "explorer",
3829
+ "lazycodex-clone-fidelity-reviewer",
3830
+ "lazycodex-code-reviewer",
3831
+ "lazycodex-gate-reviewer",
3832
+ "lazycodex-qa-executor",
3833
+ "lazycodex-worker-high",
3834
+ "lazycodex-worker-low",
3835
+ "lazycodex-worker-medium",
3836
+ "librarian",
3837
+ "metis",
3838
+ "momus",
3839
+ "plan"
3840
+ ]);
3841
+ function spawnRoleDenial(input) {
3842
+ const role = typeof input === "object" && input !== null && "agent_type" in input ? input.agent_type : undefined;
3843
+ if (typeof role === "string" && LAZYCODEX_SPAWN_ROLES.has(role))
3844
+ return null;
3845
+ 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.`;
3846
+ }
3847
+
3848
+ // components/ulw-loop/src/spawn-guard.ts
3849
+ var SPAWN_TOOL_TOKENS = new Set([
3850
+ "spawn_agent",
3851
+ "multi_agent_v1.spawn_agent",
3852
+ "collaborationspawn_agent",
3853
+ "collaboration.spawn_agent"
3854
+ ]);
3813
3855
  var DEFAULT_FANOUT_LIMIT = 24;
3814
3856
  var DEFAULT_REVIEW_SPAWN_LIMIT = 3;
3815
3857
  var GATE_MESSAGE_PATTERN = /lazycodex-gate-reviewer|omo-senpi-gate-reviewer|final gate review/i;
@@ -3820,6 +3862,16 @@ var REVIEW_AGENT_TYPES = [
3820
3862
  ];
3821
3863
  var REVIEW_AGENT_TYPE_SET = new Set(REVIEW_AGENT_TYPES);
3822
3864
  function applySpawnGuards(payload, options = {}) {
3865
+ if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name))
3866
+ return "";
3867
+ if (resolveToolkitSurface() === "lazycodex") {
3868
+ const reason = spawnRoleDenial(payload.tool_input);
3869
+ if (reason !== null)
3870
+ return deny(reason);
3871
+ }
3872
+ return applySpawnBudgetGuards(payload, options);
3873
+ }
3874
+ function applySpawnBudgetGuards(payload, options = {}) {
3823
3875
  if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name))
3824
3876
  return "";
3825
3877
  const breaker = readAdmissionBreaker(payload.session_id);
@@ -3886,14 +3938,15 @@ async function runSpawnGuardCli(stdin, stdout) {
3886
3938
  for await (const chunk of stdin)
3887
3939
  chunks.push(Buffer.from(chunk));
3888
3940
  const payload = parsePreToolUsePayload(Buffer.concat(chunks).toString("utf8"));
3889
- if (payload === null)
3941
+ if (payload === null) {
3942
+ stdout.write(deny("LazyCodex spawn guard received an invalid hook payload; role routing was not verified."));
3890
3943
  return;
3944
+ }
3891
3945
  const output = applySpawnGuards(payload);
3892
3946
  if (output.length > 0)
3893
3947
  stdout.write(output);
3894
3948
  } catch (error) {
3895
- if (error instanceof Error)
3896
- return;
3949
+ stdout.write(deny(`LazyCodex spawn guard failed: ${error instanceof Error ? error.message : String(error)}`));
3897
3950
  }
3898
3951
  }
3899
3952
  function peekFanOutBudget(stateDir) {
@@ -9,6 +9,18 @@ import { UlwLoopError } from "../runtime.js";
9
9
  import { statusNextActions } from "../status-next-actions.js";
10
10
  import { steerUlwLoop } from "../steering.js";
11
11
  import { ULW_LOOP_MANIFEST } from "./manifest.js";
12
+ // The SDK takes inline values only, so a snapshot that is not JSON is a caller error - normalize it
13
+ // to the same stable code the CLI reports instead of letting it surface as a generic failure.
14
+ function validateCodexGoalJson(raw) {
15
+ if (raw === undefined)
16
+ return;
17
+ try {
18
+ JSON.parse(raw);
19
+ }
20
+ catch (error) {
21
+ throw new UlwLoopError(`Invalid codexGoal: ${error instanceof Error ? error.message : "not valid JSON"}`, "ULW_LOOP_CODEX_GOAL_JSON_INVALID", { cause: error });
22
+ }
23
+ }
12
24
  function validateContext(context) {
13
25
  if (!context.cwd.trim())
14
26
  throw new UlwLoopError("cwd is required.", "ULW_LOOP_CWD_REQUIRED");
@@ -42,6 +54,10 @@ function nextActionsFrom(result) {
42
54
  return [];
43
55
  return result.nextActions.filter((action) => typeof action === "string").slice(0, 8);
44
56
  }
57
+ function checkpointWithValidatedSnapshot(context, scope, args) {
58
+ validateCodexGoalJson(args.codexGoalJson);
59
+ return checkpointUlwLoop(context.cwd, args, scope, { surface: context.surface });
60
+ }
45
61
  export function createAgentToolkit(context, deps = {}) {
46
62
  validateContext(context);
47
63
  const scope = { sessionId: context.sessionId };
@@ -108,7 +124,7 @@ export function createAgentToolkit(context, deps = {}) {
108
124
  completeGoals: (args = {}) => invoke("complete-goals", () => startNextUlwLoop(context.cwd, args, scope)),
109
125
  checkpoint: (args) => invoke("checkpoint", () => args.printTemplate === true
110
126
  ? checkpointTemplate(context.cwd, scope, args.goalId, { surface: context.surface })
111
- : checkpointUlwLoop(context.cwd, args, scope, { surface: context.surface })),
127
+ : checkpointWithValidatedSnapshot(context, scope, args)),
112
128
  steer: (args) => invoke("steer", () => steerUlwLoop(context.cwd, args, scope)),
113
129
  addGoal: (args) => invoke("add-goal", () => addUlwLoopGoal(context.cwd, args, scope)),
114
130
  criteria: (args) => invoke("criteria", async () => {
@@ -4,5 +4,6 @@ export interface SpawnGuardOptions {
4
4
  readonly lockTimeoutMs?: number;
5
5
  }
6
6
  export declare function applySpawnGuards(payload: PreToolUsePayload, options?: SpawnGuardOptions): string;
7
+ export declare function applySpawnBudgetGuards(payload: PreToolUsePayload, options?: SpawnGuardOptions): string;
7
8
  export declare function runSpawnAdmissionRecorderCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise<void>;
8
9
  export declare function runSpawnGuardCli(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): Promise<void>;
@@ -4,12 +4,18 @@ import { dirname, join } from "node:path";
4
4
  import { parsePreToolUsePayload } from "./codex-hook.js";
5
5
  import { isFinalRunCompletionCandidate } from "./goal-status.js";
6
6
  import { ulwLoopAttemptEvidenceDir, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
7
+ import { spawnRoleDenial } from "./spawn-role-guard.js";
7
8
  import { isStateLockTimeout, withStateLockSync } from "./state-lock.js";
8
9
  import { GATE_REVIEWER_AGENT_NAMES, REVIEWER_ROLES_BY_SURFACE, resolveToolkitSurface, reviewerRolesFor, } from "./surface.js";
9
10
  // spawn_agent = v1; collaborationspawn_agent = the delimiter-free flattened v2
10
11
  // hook token from codex-rs hook_names.rs; collaboration.spawn_agent = the
11
12
  // dotted token observed live in the task-1 probe (hook-tool-tokens.txt).
12
- const SPAWN_TOOL_TOKENS = new Set(["spawn_agent", "collaborationspawn_agent", "collaboration.spawn_agent"]);
13
+ const SPAWN_TOOL_TOKENS = new Set([
14
+ "spawn_agent",
15
+ "multi_agent_v1.spawn_agent",
16
+ "collaborationspawn_agent",
17
+ "collaboration.spawn_agent",
18
+ ]);
13
19
  export const DEFAULT_FANOUT_LIMIT = 24;
14
20
  const DEFAULT_REVIEW_SPAWN_LIMIT = 3;
15
21
  const GATE_MESSAGE_PATTERN = /lazycodex-gate-reviewer|omo-senpi-gate-reviewer|final gate review/i;
@@ -20,6 +26,16 @@ const REVIEW_AGENT_TYPES = [
20
26
  ];
21
27
  const REVIEW_AGENT_TYPE_SET = new Set(REVIEW_AGENT_TYPES);
22
28
  export function applySpawnGuards(payload, options = {}) {
29
+ if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name))
30
+ return "";
31
+ if (resolveToolkitSurface() === "lazycodex") {
32
+ const reason = spawnRoleDenial(payload.tool_input);
33
+ if (reason !== null)
34
+ return deny(reason);
35
+ }
36
+ return applySpawnBudgetGuards(payload, options);
37
+ }
38
+ export function applySpawnBudgetGuards(payload, options = {}) {
23
39
  if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name))
24
40
  return "";
25
41
  const breaker = readAdmissionBreaker(payload.session_id);
@@ -93,15 +109,16 @@ export async function runSpawnGuardCli(stdin, stdout) {
93
109
  for await (const chunk of stdin)
94
110
  chunks.push(Buffer.from(chunk));
95
111
  const payload = parsePreToolUsePayload(Buffer.concat(chunks).toString("utf8"));
96
- if (payload === null)
112
+ if (payload === null) {
113
+ stdout.write(deny("LazyCodex spawn guard received an invalid hook payload; role routing was not verified."));
97
114
  return;
115
+ }
98
116
  const output = applySpawnGuards(payload);
99
117
  if (output.length > 0)
100
118
  stdout.write(output);
101
119
  }
102
120
  catch (error) {
103
- if (error instanceof Error)
104
- return;
121
+ stdout.write(deny(`LazyCodex spawn guard failed: ${error instanceof Error ? error.message : String(error)}`));
105
122
  }
106
123
  }
107
124
  // Read-only fan-out eligibility check. Returns a denial reason when the next
@@ -0,0 +1,2 @@
1
+ export declare const LAZYCODEX_SPAWN_ROLES: ReadonlySet<string>;
2
+ export declare function spawnRoleDenial(input: unknown): string | null;
@@ -0,0 +1,22 @@
1
+ // Explicit selectors, pinned by a test to the bundled role TOMLs. The generated
2
+ // default is a non-fork fallback, not a selector: a user can opt out of owning it.
3
+ export const LAZYCODEX_SPAWN_ROLES = new Set([
4
+ "explorer",
5
+ "lazycodex-clone-fidelity-reviewer",
6
+ "lazycodex-code-reviewer",
7
+ "lazycodex-gate-reviewer",
8
+ "lazycodex-qa-executor",
9
+ "lazycodex-worker-high",
10
+ "lazycodex-worker-low",
11
+ "lazycodex-worker-medium",
12
+ "librarian",
13
+ "metis",
14
+ "momus",
15
+ "plan",
16
+ ]);
17
+ export function spawnRoleDenial(input) {
18
+ const role = typeof input === "object" && input !== null && "agent_type" in input ? input.agent_type : undefined;
19
+ if (typeof role === "string" && LAZYCODEX_SPAWN_ROLES.has(role))
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.`;
22
+ }
@@ -7,20 +7,20 @@
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.0.0-beta.60) Checking Ulw-Loop Steering"
10
+ "statusMessage": "(OmO 5.0.0-beta.62) Checking Ulw-Loop Steering"
11
11
  }
12
12
  ]
13
13
  }
14
14
  ],
15
15
  "PostToolUse": [
16
16
  {
17
- "matcher": "^(spawn_agent|collaborationspawn_agent|collaboration\\.spawn_agent)$",
17
+ "matcher": "^(spawn_agent|multi_agent_v1\\.spawn_agent|collaborationspawn_agent|collaboration\\.spawn_agent)$",
18
18
  "hooks": [
19
19
  {
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.0.0-beta.60) Recording Spawn Admission"
23
+ "statusMessage": "(OmO 5.0.0-beta.62) Recording Spawn Admission"
24
24
  }
25
25
  ]
26
26
  }
@@ -33,18 +33,18 @@
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.0.0-beta.60) Enforcing Unlimited Ulw-Loop Budget"
36
+ "statusMessage": "(OmO 5.0.0-beta.62) Enforcing Unlimited Ulw-Loop Budget"
37
37
  }
38
38
  ]
39
39
  },
40
40
  {
41
- "matcher": "^(spawn_agent|collaborationspawn_agent|collaboration\\.spawn_agent)$",
41
+ "matcher": "^(spawn_agent|multi_agent_v1\\.spawn_agent|collaborationspawn_agent|collaboration\\.spawn_agent)$",
42
42
  "hooks": [
43
43
  {
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.0.0-beta.60) Guarding Ulw-Loop Spawns"
47
+ "statusMessage": "(OmO 5.0.0-beta.62) 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.0.0-beta.60) Checking Ulw-Loop Resume"
59
+ "statusMessage": "(OmO 5.0.0-beta.62) 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.0.0-beta.60",
3
+ "version": "5.0.0-beta.62",
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",
@@ -24,6 +24,21 @@ import type {
24
24
  ToolkitUnknownRequest,
25
25
  } from "./types.js";
26
26
 
27
+ // The SDK takes inline values only, so a snapshot that is not JSON is a caller error - normalize it
28
+ // to the same stable code the CLI reports instead of letting it surface as a generic failure.
29
+ function validateCodexGoalJson(raw: string | undefined): void {
30
+ if (raw === undefined) return;
31
+ try {
32
+ JSON.parse(raw);
33
+ } catch (error) {
34
+ throw new UlwLoopError(
35
+ `Invalid codexGoal: ${error instanceof Error ? error.message : "not valid JSON"}`,
36
+ "ULW_LOOP_CODEX_GOAL_JSON_INVALID",
37
+ { cause: error },
38
+ );
39
+ }
40
+ }
41
+
27
42
  function validateContext(context: ToolkitContext): void {
28
43
  if (!context.cwd.trim()) throw new UlwLoopError("cwd is required.", "ULW_LOOP_CWD_REQUIRED");
29
44
  if (!context.sessionId.trim())
@@ -63,6 +78,15 @@ function nextActionsFrom(result: object): readonly string[] {
63
78
  return result.nextActions.filter((action): action is string => typeof action === "string").slice(0, 8);
64
79
  }
65
80
 
81
+ function checkpointWithValidatedSnapshot(
82
+ context: ToolkitContext,
83
+ scope: UlwLoopScope,
84
+ args: Exclude<CheckpointArgs, { readonly printTemplate: true }>,
85
+ ): ReturnType<typeof checkpointUlwLoop> {
86
+ validateCodexGoalJson(args.codexGoalJson);
87
+ return checkpointUlwLoop(context.cwd, args, scope, { surface: context.surface });
88
+ }
89
+
66
90
  export function createAgentToolkit(context: ToolkitContext, deps: AgentToolkitDependencies = {}): AgentToolkit {
67
91
  validateContext(context);
68
92
  const scope: UlwLoopScope = { sessionId: context.sessionId };
@@ -139,7 +163,7 @@ export function createAgentToolkit(context: ToolkitContext, deps: AgentToolkitDe
139
163
  invoke("checkpoint", () =>
140
164
  args.printTemplate === true
141
165
  ? checkpointTemplate(context.cwd, scope, args.goalId, { surface: context.surface })
142
- : checkpointUlwLoop(context.cwd, args, scope, { surface: context.surface }),
166
+ : checkpointWithValidatedSnapshot(context, scope, args),
143
167
  ),
144
168
  steer: (args) => invoke("steer", () => steerUlwLoop(context.cwd, args, scope)),
145
169
  addGoal: (args) => invoke("add-goal", () => addUlwLoopGoal(context.cwd, args, scope)),
@@ -6,6 +6,7 @@ import type { PreToolUsePayload } from "./codex-hook.js";
6
6
  import { parsePreToolUsePayload } from "./codex-hook.js";
7
7
  import { isFinalRunCompletionCandidate } from "./goal-status.js";
8
8
  import { ulwLoopAttemptEvidenceDir, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
9
+ import { spawnRoleDenial } from "./spawn-role-guard.js";
9
10
  import { isStateLockTimeout, type StateLockOptions, withStateLockSync } from "./state-lock.js";
10
11
  import {
11
12
  GATE_REVIEWER_AGENT_NAMES,
@@ -18,7 +19,12 @@ import type { UlwLoopPlan } from "./types.js";
18
19
  // spawn_agent = v1; collaborationspawn_agent = the delimiter-free flattened v2
19
20
  // hook token from codex-rs hook_names.rs; collaboration.spawn_agent = the
20
21
  // dotted token observed live in the task-1 probe (hook-tool-tokens.txt).
21
- const SPAWN_TOOL_TOKENS = new Set(["spawn_agent", "collaborationspawn_agent", "collaboration.spawn_agent"]);
22
+ const SPAWN_TOOL_TOKENS = new Set([
23
+ "spawn_agent",
24
+ "multi_agent_v1.spawn_agent",
25
+ "collaborationspawn_agent",
26
+ "collaboration.spawn_agent",
27
+ ]);
22
28
  export const DEFAULT_FANOUT_LIMIT = 24;
23
29
  const DEFAULT_REVIEW_SPAWN_LIMIT = 3;
24
30
  const GATE_MESSAGE_PATTERN = /lazycodex-gate-reviewer|omo-senpi-gate-reviewer|final gate review/i;
@@ -34,6 +40,15 @@ export interface SpawnGuardOptions {
34
40
  }
35
41
 
36
42
  export function applySpawnGuards(payload: PreToolUsePayload, options: SpawnGuardOptions = {}): string {
43
+ if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name)) return "";
44
+ if (resolveToolkitSurface() === "lazycodex") {
45
+ const reason = spawnRoleDenial(payload.tool_input);
46
+ if (reason !== null) return deny(reason);
47
+ }
48
+ return applySpawnBudgetGuards(payload, options);
49
+ }
50
+
51
+ export function applySpawnBudgetGuards(payload: PreToolUsePayload, options: SpawnGuardOptions = {}): string {
37
52
  if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name)) return "";
38
53
  const breaker = readAdmissionBreaker(payload.session_id);
39
54
  if (breaker !== null)
@@ -110,11 +125,14 @@ export async function runSpawnGuardCli(stdin: NodeJS.ReadableStream, stdout: Nod
110
125
  const chunks: Buffer[] = [];
111
126
  for await (const chunk of stdin) chunks.push(Buffer.from(chunk));
112
127
  const payload = parsePreToolUsePayload(Buffer.concat(chunks).toString("utf8"));
113
- if (payload === null) return;
128
+ if (payload === null) {
129
+ stdout.write(deny("LazyCodex spawn guard received an invalid hook payload; role routing was not verified."));
130
+ return;
131
+ }
114
132
  const output = applySpawnGuards(payload);
115
133
  if (output.length > 0) stdout.write(output);
116
134
  } catch (error) {
117
- if (error instanceof Error) return;
135
+ stdout.write(deny(`LazyCodex spawn guard failed: ${error instanceof Error ? error.message : String(error)}`));
118
136
  }
119
137
  }
120
138
 
@@ -0,0 +1,22 @@
1
+ // Explicit selectors, pinned by a test to the bundled role TOMLs. The generated
2
+ // default is a non-fork fallback, not a selector: a user can opt out of owning it.
3
+ export const LAZYCODEX_SPAWN_ROLES: ReadonlySet<string> = new Set([
4
+ "explorer",
5
+ "lazycodex-clone-fidelity-reviewer",
6
+ "lazycodex-code-reviewer",
7
+ "lazycodex-gate-reviewer",
8
+ "lazycodex-qa-executor",
9
+ "lazycodex-worker-high",
10
+ "lazycodex-worker-low",
11
+ "lazycodex-worker-medium",
12
+ "librarian",
13
+ "metis",
14
+ "momus",
15
+ "plan",
16
+ ]);
17
+
18
+ export function spawnRoleDenial(input: unknown): string | null {
19
+ const role = typeof input === "object" && input !== null && "agent_type" in input ? input.agent_type : undefined;
20
+ 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.`;
22
+ }
@@ -0,0 +1,145 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { readUlwLoopPlan } from "../src/plan-io.js";
7
+ import { createAgentToolkit } from "../src/sdk.js";
8
+
9
+ const workDirs: string[] = [];
10
+
11
+ afterEach(async () => {
12
+ for (const dir of workDirs.splice(0)) await rm(dir, { recursive: true, force: true });
13
+ });
14
+
15
+ interface Seeded {
16
+ readonly cwd: string;
17
+ readonly sessionId: string;
18
+ readonly goalId: string;
19
+ readonly objective: string;
20
+ }
21
+
22
+ // One fixture shape for every driver state: a real plan whose first goal is ready to close, so the
23
+ // only thing under test is what the driver snapshot does to the checkpoint.
24
+ async function seed(sessionId: string): Promise<Seeded> {
25
+ const cwd = await mkdtemp(join(tmpdir(), "ulw-driver-"));
26
+ workDirs.push(cwd);
27
+ const toolkit = createAgentToolkit({ cwd, sessionId, surface: "omo-senpi" });
28
+ const created = await toolkit.createGoals({ brief: "- alpha goal\n- beta goal" });
29
+ if (!created.ok) throw new Error("fixture could not create goals");
30
+ const started = await toolkit.completeGoals({});
31
+ if (!started.ok || started.operation !== "complete-goals" || "done" in started.result)
32
+ throw new Error("fixture could not start a goal");
33
+ const goalId = started.result.goal.id;
34
+ const criteria = await toolkit.criteria({ goalId });
35
+ if (!criteria.ok || criteria.operation !== "criteria") throw new Error("fixture has no criteria");
36
+ for (const criterion of criteria.result.criteria) {
37
+ const recorded = await toolkit.recordEvidence({
38
+ goalId,
39
+ criterionId: criterion.id,
40
+ status: "pass",
41
+ evidence: "driver-state fixture proof",
42
+ });
43
+ if (!recorded.ok) throw new Error("fixture could not record evidence");
44
+ }
45
+ const plan = await readUlwLoopPlan(cwd, { sessionId });
46
+ return { cwd, sessionId, goalId, objective: plan.codexObjective ?? "" };
47
+ }
48
+
49
+ async function closeWith(seeded: Seeded, snapshot?: Record<string, unknown>) {
50
+ const toolkit = createAgentToolkit({ cwd: seeded.cwd, sessionId: seeded.sessionId, surface: "omo-senpi" });
51
+ return toolkit.checkpoint({
52
+ goalId: seeded.goalId,
53
+ status: "complete",
54
+ evidence: "driver-state checkpoint",
55
+ ...(snapshot === undefined ? {} : { codexGoalJson: JSON.stringify(snapshot) }),
56
+ });
57
+ }
58
+
59
+ describe("SDK driver-goal states", () => {
60
+ describe("#given no driver snapshot", () => {
61
+ it("#when the goal is checkpointed #then it completes and advises creating the driver goal", async () => {
62
+ const seeded = await seed("driver-absent");
63
+
64
+ const closed = await closeWith(seeded);
65
+
66
+ expect(closed.ok).toBe(true);
67
+ if (closed.ok) expect(closed.nextActions.join(" ")).toContain("create_goal");
68
+ });
69
+ });
70
+
71
+ describe("#given an active driver carrying the plan objective", () => {
72
+ it("#when the goal is checkpointed #then it completes with no objective difference reported", async () => {
73
+ const seeded = await seed("driver-active");
74
+
75
+ const closed = await closeWith(seeded, { goal: { objective: seeded.objective, status: "active" } });
76
+
77
+ expect(closed.ok).toBe(true);
78
+ if (closed.ok) {
79
+ expect([...(closed.warnings ?? []), ...closed.nextActions].join(" ")).not.toContain(
80
+ "driver_objective_differs",
81
+ );
82
+ }
83
+ });
84
+ });
85
+
86
+ describe("#given a driver completed before the plan finished", () => {
87
+ it("#when the goal is checkpointed #then it completes and advises re-creating the driver", async () => {
88
+ const seeded = await seed("driver-early");
89
+
90
+ const closed = await closeWith(seeded, { goal: { objective: seeded.objective, status: "complete" } });
91
+
92
+ expect(closed.ok).toBe(true);
93
+ if (closed.ok) expect(closed.nextActions.join(" ")).toContain("create_goal");
94
+ });
95
+ });
96
+
97
+ describe("#given a driver the harness has limited", () => {
98
+ for (const status of ["paused", "usage_limited", "budget_limited"] as const) {
99
+ it(`#when the driver is ${status} #then the checkpoint still completes and advises resuming it`, async () => {
100
+ const seeded = await seed(`driver-${status}`);
101
+
102
+ const closed = await closeWith(seeded, { goal: { objective: seeded.objective, status } });
103
+
104
+ expect(closed.ok).toBe(true);
105
+ if (closed.ok) expect(closed.nextActions.join(" ")).toContain("/goal resume");
106
+ });
107
+ }
108
+ });
109
+
110
+ describe("#given a driver whose objective differs from the plan", () => {
111
+ it("#when the goal is checkpointed #then the difference is reported, never a rejection", async () => {
112
+ const seeded = await seed("driver-differs");
113
+
114
+ const closed = await closeWith(seeded, { goal: { objective: "a different objective", status: "active" } });
115
+
116
+ expect(closed.ok).toBe(true);
117
+ if (closed.ok) {
118
+ expect([...(closed.warnings ?? []), ...closed.nextActions].join(" ")).toContain("driver_objective_differs");
119
+ }
120
+ });
121
+ });
122
+
123
+ describe("#given snapshot input that is neither JSON nor a readable path", () => {
124
+ it("#when the goal is checkpointed #then the typed error is returned and the goal stays open", async () => {
125
+ const seeded = await seed("driver-malformed");
126
+
127
+ const closed = await closeWith(seeded);
128
+ expect(closed.ok).toBe(true);
129
+
130
+ const second = await seed("driver-malformed-2");
131
+ const toolkit = createAgentToolkit({ cwd: second.cwd, sessionId: second.sessionId, surface: "omo-senpi" });
132
+ const rejected = await toolkit.checkpoint({
133
+ goalId: second.goalId,
134
+ status: "complete",
135
+ evidence: "driver-state checkpoint",
136
+ codexGoalJson: "not json and not a path",
137
+ });
138
+
139
+ expect(rejected.ok).toBe(false);
140
+ if (!rejected.ok) expect(rejected.error.code).toBe("ULW_LOOP_CODEX_GOAL_JSON_INVALID");
141
+ const plan = await readUlwLoopPlan(second.cwd, { sessionId: second.sessionId });
142
+ expect(plan.goals.find((goal) => goal.id === second.goalId)?.status).not.toBe("complete");
143
+ });
144
+ });
145
+ });
@@ -6,7 +6,13 @@ import { Readable, Writable } from "node:stream";
6
6
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
7
 
8
8
  import type { PreToolUsePayload } from "../src/codex-hook.ts";
9
- import { applySpawnGuards, DEFAULT_FANOUT_LIMIT, runSpawnAdmissionRecorderCli } from "../src/spawn-guard.ts";
9
+ // Budget/artifact behavior is independent of role admission; the native entry
10
+ // point and its composition are covered in spawn-role-matrix.test.ts.
11
+ import {
12
+ applySpawnBudgetGuards as applySpawnGuards,
13
+ DEFAULT_FANOUT_LIMIT,
14
+ runSpawnAdmissionRecorderCli,
15
+ } from "../src/spawn-guard.ts";
10
16
 
11
17
  let workDir: string;
12
18
  let originalLimit: string | undefined;