halfcycle 0.4.18 → 0.5.1

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/dist/index.js CHANGED
@@ -115,6 +115,7 @@ ${block}`);
115
115
 
116
116
  // ../core/dist/hook-wiring.js
117
117
  var SETTINGS_REL = ".claude/settings.json";
118
+ var LOCAL_SETTINGS_REL = ".claude/settings.local.json";
118
119
  var VENDORED_BIN_REL = ".halfcycle/bin/bin.bundle.mjs";
119
120
  var PRE_FILTER_SHELL_FILENAME = "pre-tool-use-filter.sh";
120
121
  var PRE_FILTER_FUNCTION = "halfcycle_pt_skip";
@@ -280,6 +281,7 @@ var HOOK_REMEDY = `Run ${SIGN_IN_COMMAND} in the project folder to sign in.`;
280
281
  import { z as z2 } from "zod";
281
282
  var TASK_PLAN_TASKS_MAX = 500;
282
283
  var TASK_PLAN_ID_MAX = 64;
284
+ var TASK_PLAN_BUILDS_NONE = "none";
283
285
  var TASK_PLAN_KINDS = ["build", "review", "spec", "walk", "check"];
284
286
  var CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
285
287
  var planText = z2.string().refine((text) => !CONTROL_CHARACTER.test(text), { message: "holds a control character" });
@@ -295,7 +297,8 @@ var taskPlanTaskSchema = z2.object({
295
297
  interfaces: z2.array(planText).optional()
296
298
  }).strict().optional(),
297
299
  wholeProject: z2.literal(true).optional(),
298
- ships: z2.enum(["deploy", "release"]).optional()
300
+ ships: z2.enum(["deploy", "release"]).optional(),
301
+ builds: z2.union([z2.literal(TASK_PLAN_BUILDS_NONE), z2.array(planText)]).optional()
299
302
  }).strict().superRefine((task, ctx) => {
300
303
  if (task.reviews !== void 0 && task.kind !== "review") {
301
304
  ctx.addIssue({
@@ -332,6 +335,101 @@ var taskListFileSchema = z2.union([
332
335
  z2.array(z2.unknown())
333
336
  ]);
334
337
 
338
+ // ../core/dist/agent-profile.js
339
+ var AGENT_IDS = ["claude-code", "codex"];
340
+ var CLAUDE_CODE_PROFILE = {
341
+ id: "claude-code",
342
+ settingsFiles: [SETTINGS_REL, LOCAL_SETTINGS_REL],
343
+ wiring: HALFCYCLE_HOOK_WIRING,
344
+ commands: HALFCYCLE_HOOK_COMMANDS,
345
+ wiringPaths: HALFCYCLE_HOOK_WIRING_PATHS,
346
+ writeCapableTools: WRITE_CAPABLE_TOOLS,
347
+ projectRoot: "claude-project-dir",
348
+ configFolder: "claude-config",
349
+ mcpRegistration: { file: MCP_REGISTRATION_REL, serversKey: "mcpServers", serverKey: MCP_SERVER_KEY },
350
+ tierModels: {
351
+ deep: { model: "opus" },
352
+ standard: { model: "sonnet" },
353
+ fast: { model: "haiku" }
354
+ }
355
+ };
356
+ var CODEX_HOOKS_REL = ".codex/hooks.json";
357
+ var CODEX_CONFIG_REL = ".codex/config.toml";
358
+ function codexHookCommand(script, args) {
359
+ const invocation = `cd "$(git rev-parse --show-toplevel)" && bash ./${script}`;
360
+ return args === void 0 ? invocation : `${invocation} ${args}`;
361
+ }
362
+ var CODEX_WINDOWS_NOT_SET_UP = "Halfcycle checks did not run because Halfcycle is not set up in this folder or any folder above it; run npx halfcycle in the project folder.";
363
+ var CODEX_WINDOWS_NO_GIT_BASH = "Halfcycle checks did not run because Git for Windows was not found; install Git for Windows, then open a new session.";
364
+ function codexHookCommandWindows(script, args) {
365
+ const tick = (text) => "`" + text + "`";
366
+ const argv = [script, ...args === void 0 ? [] : args.split(" ")].map(tick).join(",");
367
+ const program = [
368
+ "const p=require(`path`),f=require(`fs`),c=require(`child_process`);",
369
+ "let d=process.cwd();",
370
+ `for(;;){if(f.existsSync(p.join(d,${tick(VENDORED_BIN_REL)})))break;`,
371
+ "const u=p.dirname(d);",
372
+ "if(u===d){d=``;break}",
373
+ "d=u}",
374
+ `if(d===\`\`){console.error(${tick(CODEX_WINDOWS_NOT_SET_UP)})}else{`,
375
+ "const g=c.spawnSync(`git`,[`--exec-path`],{encoding:`utf8`});",
376
+ "const b=g.status===0?p.join(g.stdout.trim(),`..`,`..`,`..`,`bin`,`bash.exe`):``;",
377
+ `if(b===\`\`||!f.existsSync(b)){console.error(${tick(CODEX_WINDOWS_NO_GIT_BASH)})}else{`,
378
+ `const r=c.spawnSync(b,[${argv}],{cwd:d,stdio:\`inherit\`});`,
379
+ "process.exitCode=typeof r.status===`number`?r.status:1}}"
380
+ ].join("");
381
+ return `node -e '${program}'`;
382
+ }
383
+ function codexHookRow(event, matcher, timeout, script, args) {
384
+ return {
385
+ event,
386
+ matcher,
387
+ timeout,
388
+ command: codexHookCommand(script, args),
389
+ script,
390
+ commandWindows: codexHookCommandWindows(script, args)
391
+ };
392
+ }
393
+ var CODEX_WRITE_CAPABLE_TOOLS = ["apply_patch", "Edit", "Write"];
394
+ var CODEX_PRE_TOOL_USE_MATCHER = "^(Bash|apply_patch)$";
395
+ var CODEX_HOOK_WIRING = [
396
+ codexHookRow("SessionStart", null, 5, SESSION_START_MARKER_REL),
397
+ codexHookRow("SessionStart", null, 10, GUARD_RUNNER_REL, "refresh-credential --agent codex"),
398
+ codexHookRow("UserPromptSubmit", null, 5, USER_PROMPT_REMINDER_REL),
399
+ codexHookRow("UserPromptSubmit", null, 10, USER_PROMPT_SCOPE_REL, "--agent codex"),
400
+ codexHookRow("UserPromptSubmit", null, 10, USER_PROMPT_FINDINGS_REL, "--agent codex"),
401
+ codexHookRow("PreToolUse", CODEX_PRE_TOOL_USE_MATCHER, 10, GUARD_RUNNER_REL, "hook pre-tool-use --agent codex"),
402
+ codexHookRow("PostToolUse", CODEX_WRITE_CAPABLE_TOOLS.join("|"), 60, GUARD_RUNNER_REL, "hook post-tool-use --agent codex"),
403
+ codexHookRow("Stop", null, 60, GUARD_RUNNER_REL, "hook stop --agent codex"),
404
+ codexHookRow("SubagentStop", null, 60, GUARD_RUNNER_REL, "hook subagent-stop --agent codex"),
405
+ codexHookRow("SessionEnd", null, 3, SESSION_END_MARKER_REL)
406
+ ];
407
+ var RETIRED_CODEX_HOOK_COMMANDS = [];
408
+ function codexHookCommandSet(retired) {
409
+ return /* @__PURE__ */ new Set([...CODEX_HOOK_WIRING.map((entry) => entry.command), ...retired]);
410
+ }
411
+ var CODEX_PROFILE = {
412
+ id: "codex",
413
+ settingsFiles: [CODEX_HOOKS_REL],
414
+ wiring: CODEX_HOOK_WIRING,
415
+ commands: codexHookCommandSet(RETIRED_CODEX_HOOK_COMMANDS),
416
+ wiringPaths: [.../* @__PURE__ */ new Set([CODEX_HOOKS_REL, ...CODEX_HOOK_WIRING.map((entry) => entry.script), VENDORED_BIN_REL])],
417
+ writeCapableTools: CODEX_WRITE_CAPABLE_TOOLS,
418
+ projectRoot: "git-toplevel",
419
+ configFolder: "codex-home",
420
+ mcpRegistration: { file: CODEX_CONFIG_REL, serversKey: "mcp_servers", serverKey: MCP_SERVER_KEY },
421
+ tierModels: {
422
+ deep: { model: "gpt-6-luna", effort: "high" },
423
+ standard: { model: "gpt-6-luna", effort: "medium" },
424
+ fast: { model: "gpt-6-luna", effort: "low" }
425
+ }
426
+ };
427
+ var AGENT_PROFILES = {
428
+ "claude-code": CLAUDE_CODE_PROFILE,
429
+ codex: CODEX_PROFILE
430
+ };
431
+ var ALL_HOOK_COMMANDS = new Set(AGENT_IDS.flatMap((id) => [...AGENT_PROFILES[id].commands]));
432
+
335
433
  // ../events/dist/result.js
336
434
  import { z as z3 } from "zod";
337
435
  var firedGuardSchema = z3.object({
@@ -1195,7 +1293,10 @@ var buildSnapshotReportResultSchema = z8.object({
1195
1293
  // Which phase the graph was recorded against.
1196
1294
  phase: buildPhaseSchema,
1197
1295
  // When it was recorded.
1198
- reportedAt: utcIso8601
1296
+ reportedAt: utcIso8601,
1297
+ // One sentence to pass on to the developer about the graph just stored. Absent
1298
+ // when there is nothing to say.
1299
+ warning: z8.string().min(1).max(400).optional()
1199
1300
  }).strict();
1200
1301
 
1201
1302
  // ../events/dist/phase-plan.js
@@ -3632,7 +3733,14 @@ function serializeManifest(manifest) {
3632
3733
  files: [...manifest.files].sort((a, b) => byString(a.path, b.path)),
3633
3734
  createdDirs: [...manifest.createdDirs].sort(byString),
3634
3735
  leftAlone: [...manifest.leftAlone].sort(byString),
3635
- settings
3736
+ settings,
3737
+ ...manifest.codexHooks !== void 0 ? {
3738
+ codexHooks: {
3739
+ ...manifest.codexHooks,
3740
+ hookCommands: [...manifest.codexHooks.hookCommands].sort(byString),
3741
+ eventsCreated: [...manifest.codexHooks.eventsCreated].sort(byString)
3742
+ }
3743
+ } : {}
3636
3744
  };
3637
3745
  return JSON.stringify(canonicalJson(ordered), null, 2) + "\n";
3638
3746
  }
@@ -3686,7 +3794,7 @@ function parseManifest(text) {
3686
3794
  }
3687
3795
  if (!isRecord(raw))
3688
3796
  throw new UnreadableManifestError("it is not a JSON object");
3689
- onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore", "gitattributes"], "the manifest");
3797
+ onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "codexHooks", "codexConfig", "gitignore", "gitattributes"], "the manifest");
3690
3798
  if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
3691
3799
  throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
3692
3800
  }
@@ -3752,6 +3860,30 @@ function parseManifest(text) {
3752
3860
  ...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
3753
3861
  };
3754
3862
  }
3863
+ let codexHooks;
3864
+ if (raw["codexHooks"] !== void 0) {
3865
+ const c = raw["codexHooks"];
3866
+ if (!isRecord(c))
3867
+ throw new UnreadableManifestError('"codexHooks" is not an object');
3868
+ onlyKeys(c, ["created", "adopted", "hookCommands", "eventsCreated", "hooksCreated"], '"codexHooks"');
3869
+ codexHooks = {
3870
+ created: bool(c["created"], "codexHooks.created"),
3871
+ adopted: bool(c["adopted"], "codexHooks.adopted"),
3872
+ hookCommands: stringArray(c["hookCommands"], "codexHooks.hookCommands"),
3873
+ eventsCreated: stringArray(c["eventsCreated"], "codexHooks.eventsCreated"),
3874
+ hooksCreated: bool(c["hooksCreated"], "codexHooks.hooksCreated")
3875
+ };
3876
+ }
3877
+ let codexConfig;
3878
+ if (raw["codexConfig"] !== void 0) {
3879
+ const c = raw["codexConfig"];
3880
+ if (!isRecord(c))
3881
+ throw new UnreadableManifestError('"codexConfig" is not an object');
3882
+ onlyKeys(c, ["created", "block"], '"codexConfig"');
3883
+ if (typeof c["block"] !== "string")
3884
+ throw new UnreadableManifestError('"codexConfig.block" is not a string');
3885
+ codexConfig = { created: bool(c["created"], "codexConfig.created"), block: c["block"] };
3886
+ }
3755
3887
  if (raw["gitignore"] === void 0)
3756
3888
  throw new UnreadableManifestError('"gitignore" is missing');
3757
3889
  const gitignore = appendedTextRecord(raw["gitignore"], "gitignore");
@@ -3770,6 +3902,8 @@ function parseManifest(text) {
3770
3902
  leftAlone,
3771
3903
  settings,
3772
3904
  ...mcp !== void 0 ? { mcp } : {},
3905
+ ...codexHooks !== void 0 ? { codexHooks } : {},
3906
+ ...codexConfig !== void 0 ? { codexConfig } : {},
3773
3907
  gitignore,
3774
3908
  ...gitattributes !== void 0 ? { gitattributes } : {}
3775
3909
  };
@@ -3787,6 +3921,8 @@ function startManifest(previous, isMember, isMemberDir) {
3787
3921
  createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
3788
3922
  settings: previous?.settings,
3789
3923
  mcp: previous?.mcp,
3924
+ codexHooks: previous?.codexHooks,
3925
+ codexConfig: previous?.codexConfig,
3790
3926
  gitignore: previous?.gitignore,
3791
3927
  gitattributes: previous?.gitattributes
3792
3928
  };
@@ -3847,6 +3983,19 @@ function recordMcp(draft, observed) {
3847
3983
  ...entrySha256 !== void 0 ? { entrySha256 } : {}
3848
3984
  };
3849
3985
  }
3986
+ function recordCodexHooks(draft, observed) {
3987
+ const prev = draft.codexHooks;
3988
+ draft.codexHooks = prev === void 0 ? observed : {
3989
+ created: prev.created,
3990
+ adopted: prev.adopted,
3991
+ hookCommands: union(prev.hookCommands, observed.hookCommands),
3992
+ eventsCreated: union(prev.eventsCreated, observed.eventsCreated),
3993
+ hooksCreated: prev.hooksCreated
3994
+ };
3995
+ }
3996
+ function recordCodexConfig(draft, observed) {
3997
+ draft.codexConfig = { created: draft.codexConfig?.created ?? observed.created, block: observed.block };
3998
+ }
3850
3999
  function recordGitignore(draft, created, appended) {
3851
4000
  draft.gitignore = carryAppended(draft.gitignore, created, appended);
3852
4001
  }
@@ -3880,6 +4029,8 @@ function finishManifest(draft) {
3880
4029
  denyCreated: false
3881
4030
  },
3882
4031
  ...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
4032
+ ...draft.codexHooks !== void 0 ? { codexHooks: draft.codexHooks } : {},
4033
+ ...draft.codexConfig !== void 0 ? { codexConfig: draft.codexConfig } : {},
3883
4034
  gitignore: draft.gitignore ?? { created: false, appended: [] },
3884
4035
  ...draft.gitattributes !== void 0 ? { gitattributes: draft.gitattributes } : {}
3885
4036
  };
@@ -4025,6 +4176,30 @@ var PREVIEW_WORDS = Object.freeze({
4025
4176
  change: "takes out an older Halfcycle sign-in; your own lines stay as they are",
4026
4177
  leave: "an older Halfcycle sign-in that cannot be moved yet; left as it is",
4027
4178
  yours: YOURS
4179
+ },
4180
+ "codex-checks": {
4181
+ add: "Codex's checks, the same ones Halfcycle runs for Claude Code",
4182
+ change: "adds the checks Halfcycle needs; your own entries stay as they are",
4183
+ leave: "already has the checks Halfcycle needs",
4184
+ yours: YOURS
4185
+ },
4186
+ "codex-server": {
4187
+ add: "tells Codex how to reach Halfcycle's service",
4188
+ change: "adds Halfcycle's entry; your own entries stay as they are",
4189
+ leave: "already has Halfcycle's entry",
4190
+ yours: YOURS
4191
+ },
4192
+ skill: {
4193
+ add: "a skill your coding agent can run to take the next Halfcycle step",
4194
+ change: "a newer version of this Halfcycle skill",
4195
+ leave: SAME,
4196
+ yours: YOURS
4197
+ },
4198
+ "crew-profile": {
4199
+ add: "a member of your build crew, so your coding agent shows who is working",
4200
+ change: "a newer version of this build-crew member",
4201
+ leave: SAME,
4202
+ yours: YOURS
4028
4203
  }
4029
4204
  });
4030
4205
  var kindTable;
@@ -4049,13 +4224,21 @@ function kinds() {
4049
4224
  [RETIRED_MCP_HEADERS_HELPER_REL, "halfcycle-folder"],
4050
4225
  [LEGACY_CI_STANZA_REL, "halfcycle-folder"],
4051
4226
  [CAPTURED_INDEX_REL, "captured-index"],
4052
- [LEGACY_ENV_LOCAL_REL, "old-sign-in"]
4227
+ [LEGACY_ENV_LOCAL_REL, "old-sign-in"],
4228
+ [CODEX_HOOKS_REL2, "codex-checks"],
4229
+ [CODEX_CONFIG_REL2, "codex-server"]
4053
4230
  ]);
4054
4231
  return kindTable;
4055
4232
  }
4056
4233
  function previewKindOf(rel) {
4057
4234
  if (/^\.claude\/commands\/[^/]+\.md$/.test(rel))
4058
4235
  return "command";
4236
+ if (/^\.agents\/skills\/[^/]+\/SKILL\.md$/.test(rel))
4237
+ return "skill";
4238
+ if (/^\.claude\/agents\/[^/]+\.md$/.test(rel))
4239
+ return "crew-profile";
4240
+ if (/^\.codex\/agents\/[^/]+\.toml$/.test(rel))
4241
+ return "crew-profile";
4059
4242
  return kinds().get(rel);
4060
4243
  }
4061
4244
  function kindOf(rel) {
@@ -4081,6 +4264,7 @@ function planInstall(options) {
4081
4264
  const previous = readPreviousManifest(targetRepo);
4082
4265
  const dirsBefore = new Set([...writeSet.dirs].filter((dir) => existsSync3(join5(targetRepo, dir))));
4083
4266
  const { version, commands } = planCommandStubs(targetRepo, previous, writeSet);
4267
+ const codexSkills = planCodexSkills(targetRepo, previous, writeSet);
4084
4268
  const vendored = planVendoredBinary(targetRepo);
4085
4269
  let settings;
4086
4270
  try {
@@ -4094,6 +4278,8 @@ function planInstall(options) {
4094
4278
  const ciStanza = planLegacyCiStanzaRemoval(targetRepo);
4095
4279
  const mcp = planMcpRegistration(targetRepo, preview ? "pending" : credential);
4096
4280
  const mcpHelper = mcp.kind === "none" ? null : planMcpHeadersHelperRemoval(targetRepo);
4281
+ const codexHooks = planCodexHooks(targetRepo);
4282
+ const codexConfig = planCodexConfig(targetRepo, preview ? "pending" : credential);
4097
4283
  const gitignore = planGitignore(targetRepo);
4098
4284
  const gitattributes = planGitattributes(targetRepo);
4099
4285
  const identity = planProjectIdentity(targetRepo);
@@ -4105,6 +4291,7 @@ function planInstall(options) {
4105
4291
  const legacy = migrateLegacyEnvLocal(targetRepo, storedAfter);
4106
4292
  const pin = preview ? planBundlePinPreview(targetRepo, version, home) : planBundlePin(targetRepo, version, engagementId, engagementType, createdByAccountId, credential?.methodVersion);
4107
4293
  const crew = planCrewRoster(targetRepo);
4294
+ const crewProfiles = planCrewProfiles(targetRepo, previous, writeSet);
4108
4295
  const scan = { text: planBootstrapScan(targetRepo).text };
4109
4296
  const steps = {
4110
4297
  targetRepo,
@@ -4117,12 +4304,15 @@ function planInstall(options) {
4117
4304
  previous,
4118
4305
  dirsBefore,
4119
4306
  commands,
4307
+ codexSkills,
4120
4308
  vendored,
4121
4309
  settings,
4122
4310
  hooks,
4123
4311
  ciStanza,
4124
4312
  mcp,
4125
4313
  mcpHelper,
4314
+ codexHooks,
4315
+ codexConfig,
4126
4316
  gitignore,
4127
4317
  gitattributes,
4128
4318
  identity,
@@ -4130,6 +4320,7 @@ function planInstall(options) {
4130
4320
  legacy,
4131
4321
  pin,
4132
4322
  crew,
4323
+ crewProfiles,
4133
4324
  scan
4134
4325
  };
4135
4326
  const record2 = preview ? null : planInstallRecord(targetRepo, accountInstall(steps, predicted(steps)).manifestText);
@@ -4155,16 +4346,24 @@ function predicted(steps) {
4155
4346
  lands(planned.rel, planned.text, planned.outcome === "written");
4156
4347
  };
4157
4348
  steps.commands.forEach(file);
4349
+ steps.codexSkills.forEach(file);
4158
4350
  file(steps.vendored);
4159
4351
  file(steps.settings.file);
4160
4352
  steps.hooks.forEach(file);
4161
4353
  if (steps.mcp.kind === "write")
4162
4354
  lands(MCP_REGISTRATION_REL, steps.mcp.text, steps.mcp.outcome !== "skipped");
4355
+ if (steps.codexHooks.outcome === "written" && steps.codexHooks.text !== null) {
4356
+ lands(CODEX_HOOKS_REL2, steps.codexHooks.text, true);
4357
+ }
4358
+ if (steps.codexConfig.outcome === "written" && steps.codexConfig.text !== null) {
4359
+ lands(CODEX_CONFIG_REL2, steps.codexConfig.text, true);
4360
+ }
4163
4361
  if (steps.identity.after !== null)
4164
4362
  lands(PROJECT_IDENTITY_REL, steps.identity.after, steps.identity.action === "add");
4165
4363
  if (steps.pin !== null)
4166
4364
  file(steps.pin);
4167
4365
  file(steps.crew);
4366
+ steps.crewProfiles.forEach(file);
4168
4367
  if (steps.scan.text !== null)
4169
4368
  lands(BOOTSTRAP_STATE_REL, steps.scan.text, true);
4170
4369
  const mergeAfter = (planned) => planned.outcome === "written" ? planned.after : planned.before;
@@ -4222,6 +4421,7 @@ function entriesOf(steps, record2) {
4222
4421
  entries.push({ path: rel, action: there ? "change" : "add", kind: kindOf(rel), bytes: PENDING });
4223
4422
  };
4224
4423
  steps.commands.forEach(fromFile);
4424
+ steps.codexSkills.forEach(fromFile);
4225
4425
  fromFile(steps.vendored);
4226
4426
  const settings = steps.settings;
4227
4427
  entries.push(settings.file.outcome === "written" ? { path: SETTINGS_REL, action: settings.preexisted ? "change" : "add", kind: "settings", bytes: settings.file.text } : { path: SETTINGS_REL, action: "leave", kind: "settings", bytes: null });
@@ -4237,6 +4437,38 @@ function entriesOf(steps, record2) {
4237
4437
  entries.push({ path: MCP_REGISTRATION_REL, action: "leave", kind: "server-entry", bytes: null });
4238
4438
  }
4239
4439
  retired(RETIRED_MCP_HEADERS_HELPER_REL, steps.mcpHelper);
4440
+ const codexHooks = steps.codexHooks;
4441
+ if (codexHooks.outcome === "written") {
4442
+ entries.push({ path: CODEX_HOOKS_REL2, action: codexHooks.before === null ? "add" : "change", kind: "codex-checks", bytes: codexHooks.text });
4443
+ } else if (codexHooks.outcome === "current") {
4444
+ entries.push({ path: CODEX_HOOKS_REL2, action: "leave", kind: "codex-checks", bytes: null });
4445
+ } else {
4446
+ entries.push({ path: CODEX_HOOKS_REL2, action: "leave", kind: "codex-checks", bytes: null, yours: true });
4447
+ }
4448
+ const codexConfig = steps.codexConfig;
4449
+ switch (codexConfig.outcome) {
4450
+ case "written":
4451
+ entries.push({ path: CODEX_CONFIG_REL2, action: codexConfig.before === null ? "add" : "change", kind: "codex-server", bytes: codexConfig.text });
4452
+ break;
4453
+ case "pending":
4454
+ pending(CODEX_CONFIG_REL2);
4455
+ break;
4456
+ case "current":
4457
+ case "no-credential":
4458
+ entries.push({ path: CODEX_CONFIG_REL2, action: "leave", kind: "codex-server", bytes: null });
4459
+ break;
4460
+ case "conflict":
4461
+ case "link":
4462
+ case "refused-address":
4463
+ entries.push({
4464
+ path: CODEX_CONFIG_REL2,
4465
+ action: "leave",
4466
+ kind: "codex-server",
4467
+ bytes: null,
4468
+ ...existsSync3(join5(targetRepo, CODEX_CONFIG_REL2)) ? { yours: true } : {}
4469
+ });
4470
+ break;
4471
+ }
4240
4472
  merge(GITIGNORE_REL, steps.gitignore);
4241
4473
  merge(GITATTRIBUTES_REL, steps.gitattributes);
4242
4474
  const identity = steps.identity;
@@ -4259,6 +4491,7 @@ function entriesOf(steps, record2) {
4259
4491
  else
4260
4492
  fromFile(steps.pin);
4261
4493
  fromFile(steps.crew);
4494
+ steps.crewProfiles.forEach(fromFile);
4262
4495
  entries.push(steps.scan.text !== null ? { path: BOOTSTRAP_STATE_REL, action: "add", kind: "halfcycle-folder", bytes: steps.scan.text } : { path: BOOTSTRAP_STATE_REL, action: "leave", kind: "halfcycle-folder", bytes: null });
4263
4496
  if (record2 === null)
4264
4497
  pending(INSTALL_MANIFEST_REL);
@@ -4299,6 +4532,8 @@ function applyInstallPlan(plan) {
4299
4532
  }
4300
4533
  for (const command of steps.commands)
4301
4534
  putPlannedFile(targetRepo, command);
4535
+ for (const skill of steps.codexSkills)
4536
+ putPlannedFile(targetRepo, skill);
4302
4537
  putPlannedFile(targetRepo, steps.vendored);
4303
4538
  putPlannedFile(targetRepo, steps.settings.file);
4304
4539
  for (const hook of steps.hooks)
@@ -4306,6 +4541,8 @@ function applyInstallPlan(plan) {
4306
4541
  const ciStanza = steps.ciStanza === "removed" ? removePlannedRetiredFile(targetRepo, LEGACY_CI_STANZA_REL) : steps.ciStanza;
4307
4542
  putPlannedMcp(targetRepo, steps.mcp);
4308
4543
  const mcpHelper = steps.mcpHelper === "removed" ? removePlannedRetiredFile(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL) : steps.mcpHelper;
4544
+ putPlannedCodexHooks(targetRepo, steps.codexHooks);
4545
+ putPlannedCodexConfig(targetRepo, steps.codexConfig);
4309
4546
  const gitignore = putPlannedGitignore(targetRepo, steps.gitignore);
4310
4547
  const gitignorePath = join5(targetRepo, GITIGNORE_REL);
4311
4548
  const gitignoreAfter = existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null;
@@ -4318,6 +4555,8 @@ function applyInstallPlan(plan) {
4318
4555
  const legacy = applyLegacyMigration(targetRepo, migrateLegacyEnvLocal(targetRepo, stored));
4319
4556
  writeBundlePin(targetRepo, pin, []);
4320
4557
  putPlannedFile(targetRepo, steps.crew);
4558
+ for (const profile of steps.crewProfiles)
4559
+ putPlannedFile(targetRepo, profile);
4321
4560
  if (steps.scan.text !== null)
4322
4561
  writeBootstrapState(targetRepo, steps.scan.text);
4323
4562
  const { report, installManifest, manifestText } = accountInstall(steps, {
@@ -4347,9 +4586,15 @@ function applyInstallPlan(plan) {
4347
4586
  replacedPaths: report.replacedPaths,
4348
4587
  bootstrapScanRan: steps.scan.text !== null,
4349
4588
  legacyCredentialKept: legacy.outcome === "kept" ? { keys: legacy.unbacked, missing: legacy.missingForAdoption } : null,
4350
- notShared
4589
+ notShared,
4590
+ codex: { hooks: steps.codexHooks.outcome, config: appliedCodexConfig(steps.codexConfig.outcome) }
4351
4591
  };
4352
4592
  }
4593
+ function appliedCodexConfig(outcome) {
4594
+ if (outcome === "pending")
4595
+ throw new Error("[halfcycle] a preview cannot be applied: it is still waiting for the service.");
4596
+ return outcome;
4597
+ }
4353
4598
 
4354
4599
  // dist/mcp-endpoint.js
4355
4600
  var MCP_ENDPOINT_PATH = "/mcp";
@@ -4360,8 +4605,8 @@ function mcpEndpointUrl(origin) {
4360
4605
  // dist/node-floor.js
4361
4606
  var NODE_FLOOR_MAJOR = 18;
4362
4607
  var NODE_FLOOR_SENTENCE_HEAD = `Halfcycle needs Node ${NODE_FLOOR_MAJOR} or newer; you have `;
4363
- function nodeFloorProgram(guarded) {
4364
- return `if(+process.versions.node.split('.')[0]<${NODE_FLOOR_MAJOR}){console.error('${NODE_FLOOR_SENTENCE_HEAD}'+process.versions.node);process.exitCode=1}else{${guarded}}`;
4608
+ function nodeFloorProgram(guarded, belowFloor = "process.exitCode=1") {
4609
+ return `if(+process.versions.node.split('.')[0]<${NODE_FLOOR_MAJOR}){console.error('${NODE_FLOOR_SENTENCE_HEAD}'+process.versions.node);` + belowFloor + `}else{${guarded}}`;
4365
4610
  }
4366
4611
 
4367
4612
  // dist/merge-settings.js
@@ -4408,6 +4653,75 @@ function mergeSettings(existing, generated, retiredOurs, statusLine) {
4408
4653
  }
4409
4654
  return merged;
4410
4655
  }
4656
+ function isPlainRecord(value) {
4657
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4658
+ }
4659
+ function mergeCodexHooks(existing, generated, ours) {
4660
+ const existingHooks = isPlainRecord(existing["hooks"]) ? existing["hooks"] : {};
4661
+ const events = [
4662
+ ...Object.keys(existingHooks),
4663
+ ...Object.keys(generated.hooks).filter((event) => !(event in existingHooks))
4664
+ ];
4665
+ const mergedHooks = {};
4666
+ for (const event of events) {
4667
+ const rows = generated.hooks[event] ?? [];
4668
+ const current = existingHooks[event];
4669
+ if (current === void 0) {
4670
+ mergedHooks[event] = rows.map(cloneGroup);
4671
+ continue;
4672
+ }
4673
+ if (!Array.isArray(current)) {
4674
+ mergedHooks[event] = current;
4675
+ continue;
4676
+ }
4677
+ const wanted = new Map(rows.map((row) => [row.hooks[0].command, row]));
4678
+ const placed = /* @__PURE__ */ new Set();
4679
+ const next = [];
4680
+ for (const group of current) {
4681
+ if (!isPlainRecord(group) || !Array.isArray(group["hooks"]) || !isHalfcycleEntry({ hooks: group["hooks"] }, ours)) {
4682
+ next.push(group);
4683
+ continue;
4684
+ }
4685
+ const hooks = group["hooks"];
4686
+ if (hooks.length === 1) {
4687
+ const command = hooks[0].command;
4688
+ const row = wanted.get(command);
4689
+ if (row !== void 0 && !placed.has(command)) {
4690
+ placed.add(command);
4691
+ next.push(cloneGroup(row));
4692
+ }
4693
+ continue;
4694
+ }
4695
+ const kept = [];
4696
+ for (const hook of hooks) {
4697
+ if (!isHalfcycleCommand(hook, ours)) {
4698
+ kept.push(hook);
4699
+ continue;
4700
+ }
4701
+ const command = hook.command;
4702
+ const row = wanted.get(command);
4703
+ if (row !== void 0 && !placed.has(command) && group["matcher"] === row.matcher) {
4704
+ placed.add(command);
4705
+ kept.push({ ...row.hooks[0] });
4706
+ }
4707
+ }
4708
+ if (kept.length > 0)
4709
+ next.push({ ...group, hooks: kept });
4710
+ }
4711
+ for (const row of rows) {
4712
+ if (!placed.has(row.hooks[0].command))
4713
+ next.push(cloneGroup(row));
4714
+ }
4715
+ mergedHooks[event] = next;
4716
+ }
4717
+ return { ...existing, hooks: mergedHooks };
4718
+ }
4719
+ function cloneGroup(group) {
4720
+ return {
4721
+ ...group.matcher === void 0 ? {} : { matcher: group.matcher },
4722
+ hooks: group.hooks.map((hook) => ({ ...hook }))
4723
+ };
4724
+ }
4411
4725
  function mergePermissions(existing, generated, retiredOurs) {
4412
4726
  if (existing === void 0 && generated === void 0)
4413
4727
  return void 0;
@@ -4430,8 +4744,261 @@ function mergePermissions(existing, generated, retiredOurs) {
4430
4744
  return result;
4431
4745
  }
4432
4746
 
4747
+ // dist/toml-lines.js
4748
+ var BARE_KEY_CHAR = /[A-Za-z0-9_-]/;
4749
+ var Unreadable = class extends Error {
4750
+ };
4751
+ function skipBlank(line, i) {
4752
+ while (i < line.length && (line[i] === " " || line[i] === " "))
4753
+ i += 1;
4754
+ return i;
4755
+ }
4756
+ function readKey(line, start) {
4757
+ const segments = [];
4758
+ let i = start;
4759
+ for (; ; ) {
4760
+ i = skipBlank(line, i);
4761
+ const c = line[i];
4762
+ if (c === '"') {
4763
+ const close = line.indexOf('"', i + 1);
4764
+ if (close < 0)
4765
+ throw new Unreadable();
4766
+ const segment = line.slice(i + 1, close);
4767
+ if (segment.includes("\\"))
4768
+ throw new Unreadable();
4769
+ segments.push(segment);
4770
+ i = close + 1;
4771
+ } else if (c === "'") {
4772
+ const close = line.indexOf("'", i + 1);
4773
+ if (close < 0)
4774
+ throw new Unreadable();
4775
+ segments.push(line.slice(i + 1, close));
4776
+ i = close + 1;
4777
+ } else {
4778
+ let j = i;
4779
+ while (j < line.length && BARE_KEY_CHAR.test(line[j]))
4780
+ j += 1;
4781
+ if (j === i)
4782
+ throw new Unreadable();
4783
+ segments.push(line.slice(i, j));
4784
+ i = j;
4785
+ }
4786
+ i = skipBlank(line, i);
4787
+ if (line[i] !== ".")
4788
+ return { segments, end: i };
4789
+ i += 1;
4790
+ }
4791
+ }
4792
+ function readValue(line, start, state) {
4793
+ let i = start;
4794
+ while (i < line.length) {
4795
+ if (state.multiline !== null) {
4796
+ const delimiter = state.multiline;
4797
+ const escapes = delimiter === '"""';
4798
+ while (i < line.length) {
4799
+ if (escapes && line[i] === "\\") {
4800
+ i += 2;
4801
+ continue;
4802
+ }
4803
+ if (line.startsWith(delimiter, i)) {
4804
+ i += 3;
4805
+ let extra = 0;
4806
+ while (extra < 2 && line[i] === delimiter[0]) {
4807
+ i += 1;
4808
+ extra += 1;
4809
+ }
4810
+ state.multiline = null;
4811
+ break;
4812
+ }
4813
+ i += 1;
4814
+ }
4815
+ continue;
4816
+ }
4817
+ const c = line[i];
4818
+ if (c === "#")
4819
+ return;
4820
+ if (c === '"' || c === "'") {
4821
+ const triple = c.repeat(3);
4822
+ if (line.startsWith(triple, i)) {
4823
+ state.multiline = triple;
4824
+ i += 3;
4825
+ continue;
4826
+ }
4827
+ let j = i + 1;
4828
+ for (; ; ) {
4829
+ if (j >= line.length)
4830
+ throw new Unreadable();
4831
+ if (c === '"' && line[j] === "\\") {
4832
+ j += 2;
4833
+ continue;
4834
+ }
4835
+ if (line[j] === c)
4836
+ break;
4837
+ j += 1;
4838
+ }
4839
+ i = j + 1;
4840
+ continue;
4841
+ }
4842
+ if (c === "[" || c === "{")
4843
+ state.depth += 1;
4844
+ else if (c === "]" || c === "}") {
4845
+ state.depth -= 1;
4846
+ if (state.depth < 0)
4847
+ throw new Unreadable();
4848
+ }
4849
+ i += 1;
4850
+ }
4851
+ }
4852
+ function scanTomlStatements(lines, skip) {
4853
+ const statements = [];
4854
+ const state = { multiline: null, depth: 0 };
4855
+ let table = [];
4856
+ let index = 0;
4857
+ try {
4858
+ for (index = 0; index < lines.length; index += 1) {
4859
+ const inValue = state.multiline !== null || state.depth > 0;
4860
+ if (skip !== void 0 && index === skip.from) {
4861
+ if (inValue)
4862
+ throw new Unreadable();
4863
+ table = skip.tableAfter;
4864
+ index = skip.to;
4865
+ continue;
4866
+ }
4867
+ let line = lines[index];
4868
+ if (index === 0 && line.startsWith("\uFEFF"))
4869
+ line = line.slice(1);
4870
+ if (inValue) {
4871
+ readValue(line, 0, state);
4872
+ continue;
4873
+ }
4874
+ let i = skipBlank(line, 0);
4875
+ if (i >= line.length || line[i] === "#")
4876
+ continue;
4877
+ if (line[i] === "[") {
4878
+ const array = line[i + 1] === "[";
4879
+ i += array ? 2 : 1;
4880
+ const key2 = readKey(line, i);
4881
+ i = key2.end;
4882
+ if (line[i] !== "]" || array && line[i + 1] !== "]")
4883
+ throw new Unreadable();
4884
+ i = skipBlank(line, i + (array ? 2 : 1));
4885
+ if (i < line.length && line[i] !== "#")
4886
+ throw new Unreadable();
4887
+ table = key2.segments;
4888
+ statements.push({ line: index, kind: array ? "array-table" : "table", path: key2.segments });
4889
+ continue;
4890
+ }
4891
+ const key = readKey(line, i);
4892
+ if (line[key.end] !== "=")
4893
+ throw new Unreadable();
4894
+ statements.push({ line: index, kind: "key", path: [...table, ...key.segments] });
4895
+ readValue(line, key.end + 1, state);
4896
+ }
4897
+ if (state.multiline !== null || state.depth > 0) {
4898
+ index = lines.length - 1;
4899
+ throw new Unreadable();
4900
+ }
4901
+ } catch (err) {
4902
+ if (err instanceof Unreadable)
4903
+ return { ok: false, line: index };
4904
+ throw err;
4905
+ }
4906
+ return { ok: true, statements };
4907
+ }
4908
+
4433
4909
  // dist/install.js
4434
4910
  var ZONE_B_DIR = ".workbench";
4911
+ var CODEX_HOOKS_REL2 = AGENT_PROFILES.codex.settingsFiles[0];
4912
+ var CODEX_CONFIG_REL2 = AGENT_PROFILES.codex.mcpRegistration.file;
4913
+ var CLAUDE_AGENTS_DIR = ".claude/agents";
4914
+ var CODEX_AGENTS_DIR = ".codex/agents";
4915
+ var CREW_CALLSIGN_PATTERN = /^[A-Z][A-Z0-9]*$/;
4916
+ function crewProfileRel(agent, callsign) {
4917
+ if (!CREW_CALLSIGN_PATTERN.test(callsign)) {
4918
+ throw new Error(`[halfcycle] crew callsign "${callsign}" is not a valid profile name (must match ^[A-Z][A-Z0-9]*$).`);
4919
+ }
4920
+ return agent === "claude-code" ? `${CLAUDE_AGENTS_DIR}/${callsign}.md` : `${CODEX_AGENTS_DIR}/${callsign.toLowerCase()}.toml`;
4921
+ }
4922
+ function crewProfileTarget(targetRepo, rel) {
4923
+ const folder = rel.startsWith(`${CLAUDE_AGENTS_DIR}/`) ? CLAUDE_AGENTS_DIR : CODEX_AGENTS_DIR;
4924
+ const target = resolve(join6(targetRepo, rel));
4925
+ if (dirname3(target) !== resolve(targetRepo, folder)) {
4926
+ throw new Error(`[halfcycle] crew profile "${rel}" does not sit directly in ${folder}.`);
4927
+ }
4928
+ return target;
4929
+ }
4930
+ var CREW_ROLE_WORDS = {
4931
+ "business-analyst": "business analyst",
4932
+ architect: "architect",
4933
+ "spec-writer": "spec writer",
4934
+ builder: "builder",
4935
+ "context-docs": "context and docs writer",
4936
+ "spec-reviewer": "spec reviewer",
4937
+ "code-test-reviewer": "code and test reviewer",
4938
+ "delivery-lead": "delivery lead",
4939
+ "quality-lead": "quality lead"
4940
+ };
4941
+ var CREW_TIER_WORDS = {
4942
+ deep: "You think carefully and deeply before you act, and take the time the work needs.",
4943
+ standard: "You think each step through at a steady pace before you act.",
4944
+ fast: "You work quickly on clear, well-defined work, and say so when a task needs more thought."
4945
+ };
4946
+ var CREW_REVIEWER_WORDS = "You check the work you are given and never change it: you read it, run what you need to, and report what you find.";
4947
+ var CREW_REVIEWER_TOOLS = "Read, Grep, Glob, Bash";
4948
+ var CREW_MEMBER_TOOLS = "Read, Edit, Write, Bash, Grep, Glob";
4949
+ function crewProfileBody(role, group, tier) {
4950
+ const lines = [`You are a ${CREW_ROLE_WORDS[role]} on this project's build crew.`, CREW_TIER_WORDS[tier]];
4951
+ if (group === "reviewer")
4952
+ lines.push(CREW_REVIEWER_WORDS);
4953
+ return `${lines.join("\n")}
4954
+ `;
4955
+ }
4956
+ function crewProfileDescription(member) {
4957
+ return `${member.callsign}, ${CREW_ROLE_WORDS[member.role]} (${member.discipline}), a member of this project's build crew.`;
4958
+ }
4959
+ function plainYamlValue(value) {
4960
+ if (/[\r\n]|: | #|^[-?:,[\]{}#&*!|>'"%@`\s]|\s$/.test(value)) {
4961
+ throw new Error(`[halfcycle] a crew profile value cannot be written as plain text: ${JSON.stringify(value)}`);
4962
+ }
4963
+ return value;
4964
+ }
4965
+ function claudeCrewProfile(member) {
4966
+ const tools = member.group === "reviewer" ? CREW_REVIEWER_TOOLS : CREW_MEMBER_TOOLS;
4967
+ return [
4968
+ "---",
4969
+ `name: ${plainYamlValue(member.callsign)}`,
4970
+ `description: ${plainYamlValue(crewProfileDescription(member))}`,
4971
+ `model: ${plainYamlValue(AGENT_PROFILES["claude-code"].tierModels[member.tier].model)}`,
4972
+ `tools: ${tools}`,
4973
+ "---",
4974
+ "",
4975
+ crewProfileBody(member.role, member.group, member.tier)
4976
+ ].join("\n");
4977
+ }
4978
+ function codexCrewProfile(member) {
4979
+ const { model, effort } = AGENT_PROFILES.codex.tierModels[member.tier];
4980
+ if (effort === void 0)
4981
+ throw new Error(`[halfcycle] Codex's ${member.tier} tier names no reasoning effort.`);
4982
+ const body = crewProfileBody(member.role, member.group, member.tier);
4983
+ if (/[\\"]|[\u0000-\u0009\u000b-\u001f\u007f]/.test(body)) {
4984
+ throw new Error("[halfcycle] a crew profile body holds a character a Codex role cannot carry as written.");
4985
+ }
4986
+ return [
4987
+ `name = ${tomlBasicString(member.callsign.toLowerCase())}`,
4988
+ `description = ${tomlBasicString(crewProfileDescription(member))}`,
4989
+ `model = ${tomlBasicString(model)}`,
4990
+ `model_reasoning_effort = ${tomlBasicString(effort)}`,
4991
+ `developer_instructions = """`,
4992
+ `${body}"""`,
4993
+ ""
4994
+ ].join("\n");
4995
+ }
4996
+ function renderCrewProfiles(roster = CREW_ROSTER) {
4997
+ return [
4998
+ ...roster.map((member) => ({ rel: crewProfileRel("claude-code", member.callsign), text: claudeCrewProfile(member) })),
4999
+ ...roster.filter((member) => member.group !== "reviewer").map((member) => ({ rel: crewProfileRel("codex", member.callsign), text: codexCrewProfile(member) }))
5000
+ ];
5001
+ }
4435
5002
  var __filename = fileURLToPath(import.meta.url);
4436
5003
  var __dirname = dirname3(__filename);
4437
5004
  function findBundleRoot(startDir) {
@@ -4483,13 +5050,23 @@ var WRITE_ALLOWLIST = [
4483
5050
  "test/fixtures/captured",
4484
5051
  MCP_REGISTRATION_REL,
4485
5052
  ".gitattributes",
4486
- ZONE_B_DIR
5053
+ ZONE_B_DIR,
5054
+ // Codex's twin of '.claude/commands' (`codex-install-target` AC4, T-14): one
5055
+ // `.agents/skills/<name>/SKILL.md` per command, same collision rule.
5056
+ ".agents/skills",
5057
+ // Codex's checks file, merged beside a developer's own entries.
5058
+ CODEX_HOOKS_REL2,
5059
+ // Codex's settings file: one marked block is added, and the rest of the file is left as it is.
5060
+ CODEX_CONFIG_REL2,
5061
+ // Each crew member's own profile, by its exact name; no other file in those folders is written.
5062
+ ...renderCrewProfiles().map((profile) => profile.rel)
4487
5063
  ];
5064
+ var CREW_PROFILE_RELS = new Set(renderCrewProfiles().map((p) => p.rel));
4488
5065
  function isAllowlisted(targetRelPath) {
4489
5066
  const normalised = targetRelPath.replace(/\\/g, "/");
4490
5067
  return WRITE_ALLOWLIST.some((prefix) => {
4491
5068
  const p = prefix.replace(/\\/g, "/");
4492
- return normalised === p || normalised.startsWith(p + "/");
5069
+ return normalised === p || !CREW_PROFILE_RELS.has(p) && normalised.startsWith(p + "/");
4493
5070
  });
4494
5071
  }
4495
5072
  var CREW_ROSTER_REL = ".halfcycle/crew.json";
@@ -4502,6 +5079,12 @@ var GITATTRIBUTES_REL = ".gitattributes";
4502
5079
  function commandStubRel(name) {
4503
5080
  return `.claude/commands/${name}.md`;
4504
5081
  }
5082
+ function skillRel(name) {
5083
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
5084
+ throw new Error(`[halfcycle] plugin.json command "${name}" is not a valid skill name (must match ^[a-z][a-z0-9-]*$).`);
5085
+ }
5086
+ return `.agents/skills/${name}/SKILL.md`;
5087
+ }
4505
5088
  var RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
4506
5089
  var RETIRED_WRITE_SET_FILES = [RETIRED_MCP_HEADERS_HELPER_REL];
4507
5090
  var RETIRED_DENY_PATTERNS = [
@@ -4520,6 +5103,9 @@ function closedWriteSet() {
4520
5103
  const commands = readPluginManifest().commands ?? [];
4521
5104
  const files = /* @__PURE__ */ new Set([
4522
5105
  ...commands.map((cmd) => commandStubRel(cmd.name)),
5106
+ // Codex's twin of the stubs (`codex-install-target` AC4, T-14), derived from the
5107
+ // same manifest array the stubs come from — one closed-set source for both.
5108
+ ...commands.map((cmd) => skillRel(cmd.name)),
4523
5109
  ...Object.keys(OWNED_GENERATED_HEADERS),
4524
5110
  VENDORED_BIN_REL,
4525
5111
  CREW_ROSTER_REL,
@@ -4528,9 +5114,12 @@ function closedWriteSet() {
4528
5114
  BOOTSTRAP_STATE_REL,
4529
5115
  CAPTURED_INDEX_REL,
4530
5116
  INSTALL_MANIFEST_REL,
5117
+ // The crew profiles (`crew-agent-profiles` AC6), from the same generator the
5118
+ // allowlist and the plan read, so uninstall's set and the writer's cannot differ.
5119
+ ...renderCrewProfiles().map((profile) => profile.rel),
4531
5120
  ...RETIRED_WRITE_SET_FILES
4532
5121
  ]);
4533
- const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL, GITATTRIBUTES_REL]);
5122
+ const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, CODEX_HOOKS_REL2, CODEX_CONFIG_REL2, GITIGNORE_REL, GITATTRIBUTES_REL]);
4534
5123
  const dirs = /* @__PURE__ */ new Set();
4535
5124
  for (const path of [...files, ...merged]) {
4536
5125
  for (let dir = posix2.dirname(path); dir !== "."; dir = posix2.dirname(dir))
@@ -4543,6 +5132,7 @@ function closedWriteSet() {
4543
5132
  dirs,
4544
5133
  ownedDirs,
4545
5134
  hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
5135
+ codexHookCommands: new Set(AGENT_PROFILES.codex.commands),
4546
5136
  denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
4547
5137
  settingsSchema: SETTINGS_SCHEMA,
4548
5138
  gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
@@ -4560,10 +5150,17 @@ function sha256OfWhatThisVersionWrites(commands) {
4560
5150
  if (existsSync4(src))
4561
5151
  out.set(rel, fileSha256(readFileSync5(src)));
4562
5152
  };
4563
- for (const cmd of commands)
4564
- fromFile(commandStubRel(cmd.name), join6(BUNDLE_ROOT, ".claude-plugin", cmd.path));
5153
+ for (const cmd of commands) {
5154
+ const srcFile = join6(BUNDLE_ROOT, ".claude-plugin", cmd.path);
5155
+ fromFile(commandStubRel(cmd.name), srcFile);
5156
+ if (existsSync4(srcFile)) {
5157
+ out.set(skillRel(cmd.name), fileSha256(Buffer.from(codexSkillContent(cmd.name, readFileSync5(srcFile, "utf-8")), "utf-8")));
5158
+ }
5159
+ }
4565
5160
  for (const [rel, content] of hookScriptsThisVersionWrites())
4566
5161
  out.set(rel, fileSha256(content));
5162
+ for (const profile of renderCrewProfiles())
5163
+ out.set(profile.rel, fileSha256(Buffer.from(profile.text, "utf-8")));
4567
5164
  fromFile(CAPTURED_INDEX_REL, capturedIndexScaffold());
4568
5165
  return out;
4569
5166
  }
@@ -4667,6 +5264,8 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
4667
5264
  const merged = [
4668
5265
  SETTINGS_REL,
4669
5266
  ...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
5267
+ ...manifest.codexHooks !== void 0 ? [CODEX_HOOKS_REL2] : [],
5268
+ ...manifest.codexConfig !== void 0 ? [CODEX_CONFIG_REL2] : [],
4670
5269
  ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : [],
4671
5270
  ...manifest.gitattributes !== void 0 && (manifest.gitattributes.created || manifest.gitattributes.appended.length > 0) ? [GITATTRIBUTES_REL] : []
4672
5271
  ];
@@ -4727,6 +5326,55 @@ function readPluginManifest() {
4727
5326
  const raw = readFileSync5(manifestPath, "utf-8");
4728
5327
  return JSON.parse(raw);
4729
5328
  }
5329
+ function parseCommandStub(raw, name) {
5330
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw);
5331
+ if (match === null) {
5332
+ throw new Error(`[halfcycle] commands/${name}.md has no frontmatter block; cannot build its Codex skill.`);
5333
+ }
5334
+ const [, frontmatter, body] = match;
5335
+ const desc = /^description:\s*(.+)$/m.exec(frontmatter);
5336
+ if (desc === null) {
5337
+ throw new Error(`[halfcycle] commands/${name}.md's frontmatter has no description; cannot build its Codex skill.`);
5338
+ }
5339
+ return { description: desc[1].trim(), body };
5340
+ }
5341
+ var TRUST_SENTENCE_WORDS = "Claude Code does not trust this folder yet, open it there and accept the workspace-trust prompt";
5342
+ var CODEX_TRUST_SENTENCE = "Codex does not trust this folder yet, open it there and trust it when asked";
5343
+ function trustSentencePattern() {
5344
+ const words = TRUST_SENTENCE_WORDS.split(" ").map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
5345
+ return new RegExp(words.join("(?: |\\n> )"));
5346
+ }
5347
+ var MCP_RECONNECT_WORDS = "run `/mcp` and reconnect it";
5348
+ var CODEX_MCP_FALLBACK = "start a new Codex session";
5349
+ function mcpReconnectPattern() {
5350
+ const words = MCP_RECONNECT_WORDS.split(" ").map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
5351
+ return new RegExp(words.join("(?: |\\n> )"));
5352
+ }
5353
+ function codexSkillBody(name, sourceBody) {
5354
+ const slashForm = "`/" + name + "`";
5355
+ if (!sourceBody.includes(slashForm)) {
5356
+ throw new Error(`[halfcycle] commands/${name}.md never names itself as ${slashForm}; cannot build its Codex skill.`);
5357
+ }
5358
+ const pattern = trustSentencePattern();
5359
+ if (!pattern.test(sourceBody)) {
5360
+ throw new Error(`[halfcycle] commands/${name}.md is missing the Claude Code trust sentence; cannot build its Codex skill.`);
5361
+ }
5362
+ const bareName = "`" + name + "`";
5363
+ const transformed = sourceBody.split(slashForm).join(bareName).replace(trustSentencePattern(), CODEX_TRUST_SENTENCE).replace(mcpReconnectPattern(), CODEX_MCP_FALLBACK);
5364
+ if (transformed.includes("/mcp")) {
5365
+ throw new Error(`[halfcycle] commands/${name}.md's Codex skill still names /mcp; cannot build.`);
5366
+ }
5367
+ return transformed;
5368
+ }
5369
+ function codexSkillContent(name, sourceRaw) {
5370
+ const { description, body } = parseCommandStub(sourceRaw, name);
5371
+ const transformed = codexSkillBody(name, body);
5372
+ return `---
5373
+ name: ${name}
5374
+ description: ${description}
5375
+ ---
5376
+ ${transformed}`;
5377
+ }
4730
5378
  function planManifestCommands(manifest, targetRepo, previous, writeSet) {
4731
5379
  const recordedFiles = new Map((previous?.files ?? []).filter((file) => writeSet.files.has(file.path)).map((file) => [file.path, file]));
4732
5380
  return (manifest.commands ?? []).map((cmd) => {
@@ -4892,6 +5540,16 @@ function generateSettingsJson() {
4892
5540
  };
4893
5541
  return JSON.stringify(settings, null, 2) + "\n";
4894
5542
  }
5543
+ function codexHooksValue() {
5544
+ const hooks = {};
5545
+ for (const { event, matcher, timeout, command, commandWindows } of AGENT_PROFILES.codex.wiring) {
5546
+ (hooks[event] ??= []).push({
5547
+ ...matcher === null ? {} : { matcher },
5548
+ hooks: [{ type: "command", command, timeout, ...commandWindows === void 0 ? {} : { commandWindows } }]
5549
+ });
5550
+ }
5551
+ return { hooks };
5552
+ }
4895
5553
  var GENERATED_GUARD_RUNNER_HEADER = "# Governance hook wrapper \u2014 generated by";
4896
5554
  function generateGuardRunnerWrapper(preFilter = resolvePreFilterShell()) {
4897
5555
  return `#!/usr/bin/env bash
@@ -4944,9 +5602,11 @@ ${preFilter.trimEnd()}
4944
5602
  # Before a tool runs, on a bash new enough for the cheap pass: read the call
4945
5603
  # once; when no risky action can be in it, answer here (the pass writes the
4946
5604
  # time line) and do not start Node. Otherwise hand the same input on below.
5605
+ # The pass is given the hook's own arguments, so it knows which coding agent's
5606
+ # input it reads, and hands on any agent's call it has no test for.
4947
5607
  if [ "$1" = hook ] && [ "$2" = pre-tool-use ] && [ "\${BASH_VERSINFO[0]:-0}" -ge 5 ]; then
4948
5608
  hc_input=$(cat 2>/dev/null) || IFS= read -r -d '' hc_input
4949
- ${PRE_FILTER_FUNCTION} && exit 0
5609
+ ${PRE_FILTER_FUNCTION} "$@" && exit 0
4950
5610
  node -e "${nodeFloorProgram("")}" || {
4951
5611
  printf '%s' "$hc_input" | halfcycle_pre_tool_use_fallback
4952
5612
  exit 0
@@ -5186,7 +5846,7 @@ exit 0
5186
5846
  var GENERATED_USER_PROMPT_SCOPE_HEADER = "# Typed scope approval \u2014 generated by";
5187
5847
  function generateUserPromptScopeHook() {
5188
5848
  return `#!/usr/bin/env bash
5189
- ${GENERATED_USER_PROMPT_SCOPE_HEADER} the Halfcycle installer, now also for the close review.
5849
+ ${GENERATED_USER_PROMPT_SCOPE_HEADER} the Halfcycle installer, now also for the close review and Codex.
5190
5850
  #
5191
5851
  # Lets the owner of this project approve a phase's scope, or send it back,
5192
5852
  # decide one part of a phase's close review, or turn builds running on their
@@ -5194,20 +5854,25 @@ ${GENERATED_USER_PROMPT_SCOPE_HEADER} the Halfcycle installer, now also for the
5194
5854
  # Code session: "approve <phase>", "send back <phase>: <note>", "accept
5195
5855
  # <phase>/<part>", "question <phase>/<part>: <words>", "carry <phase>/<part>:
5196
5856
  # <words>", "drop <phase>/<part>: <words>", "reject <phase>/<task>: <words>",
5197
- # "autonomy on" or "autonomy off".
5857
+ # "autonomy on" or "autonomy off". Also lets the person who is typing let one
5858
+ # step Halfcycle paused run once, by typing "allow" as the whole prompt.
5198
5859
  #
5199
5860
  # AN ORDINARY PROMPT STARTS NOTHING. This script reads the hook input once and
5200
5861
  # looks only at the start of the prompt's value. Unless the prompt begins, after
5201
5862
  # any whitespace (typed, or escaped in the input), with "approve ", "send back ",
5202
- # "accept ", "question ", "carry ", "drop ", "reject " or "autonomy ", in any case, it exits
5203
- # 0 with no output, and Node never starts. A quote inside any other field arrives
5863
+ # "accept ", "question ", "carry ", "drop ", "reject " or "autonomy ", in any case,
5864
+ # or is "allow" and nothing else but whitespace, it exits 0 with no output, and
5865
+ # Node never starts. A quote inside any other field arrives
5204
5866
  # escaped, so no other field (the folder the session is in, for one) can read as
5205
5867
  # the start of the prompt.
5206
5868
  #
5207
5869
  # Otherwise it finds the project root from its own location, never from the
5208
5870
  # folder the session has moved to, and hands the same input to the guard
5209
5871
  # wrapper beside it. That decides whether the line counts and prints at most
5210
- # one plain line. Nothing here ever blocks a prompt.
5872
+ # one line. Nothing here ever blocks a prompt. Any arguments this script was
5873
+ # given (a Codex row passes its agent's name) are handed on after the verb; in
5874
+ # Codex a typed line never counts, because Codex cannot tell a person's line
5875
+ # from one a program sent.
5211
5876
  #
5212
5877
  # FAILS OPEN up to that hand-over: any error before it exits 0 with no output.
5213
5878
  # After it, the wrapper's own exit stands.
@@ -5223,7 +5888,7 @@ IFS= read -r -d '' HC_INPUT || true
5223
5888
  # \\u escape, or as the UTF-8 bytes of a Unicode space. Compared byte for byte.
5224
5889
  HC_LEAD='[[:space:]]|\\\\[nrtf]|\\\\u(000[9a-d]|0020|00a0|1680|200[0-9a]|202[89f]|205f|3000|feff)'
5225
5890
  HC_LEAD="$HC_LEAD|"$'\\xc2\\xa0|\\xe1\\x9a\\x80|\\xe2\\x80[\\x80-\\x8a\\xa8\\xa9\\xaf]|\\xe2\\x81\\x9f|\\xe3\\x80\\x80|\\xef\\xbb\\xbf'
5226
- if ! printf '%s' "$HC_INPUT" | LC_ALL=C grep -Eiq "\\"prompt\\"[[:space:]]*:[[:space:]]*\\"($HC_LEAD)*(approve|send back|accept|question|carry|drop|reject|autonomy) "; then
5891
+ if ! printf '%s' "$HC_INPUT" | LC_ALL=C grep -Eiq "\\"prompt\\"[[:space:]]*:[[:space:]]*\\"($HC_LEAD)*((approve|send back|accept|question|carry|drop|reject|autonomy) |allow($HC_LEAD)*\\")"; then
5227
5892
  exit 0
5228
5893
  fi
5229
5894
 
@@ -5233,16 +5898,17 @@ export CLAUDE_PROJECT_DIR="$HC_PROJECT_ROOT"
5233
5898
 
5234
5899
  trap - ERR
5235
5900
  set +e
5236
- exec bash "$HC_PROJECT_ROOT/.claude/hooks/guard-runner.sh" hook user-prompt-submit <<<"$HC_INPUT"
5901
+ exec bash "$HC_PROJECT_ROOT/.claude/hooks/guard-runner.sh" hook user-prompt-submit <<<"$HC_INPUT" "$@"
5237
5902
  `;
5238
5903
  }
5239
5904
  var GENERATED_USER_PROMPT_FINDINGS_HEADER = "# Findings hand-over \u2014 generated by";
5240
5905
  function generateUserPromptFindingsHook() {
5241
5906
  return `#!/usr/bin/env bash
5242
- ${GENERATED_USER_PROMPT_FINDINGS_HEADER} the Halfcycle installer.
5907
+ ${GENERATED_USER_PROMPT_FINDINGS_HEADER} the Halfcycle installer, now also for Codex.
5243
5908
  #
5244
5909
  # At the person's next message, before the agent starts, hands it the details of
5245
- # whatever Halfcycle's checks found at an earlier turn end, once.
5910
+ # whatever Halfcycle's checks found at an earlier turn end, once. Any arguments
5911
+ # this script was given (a Codex row passes its agent's name) are handed on.
5246
5912
  #
5247
5913
  # AN ORDINARY PROMPT STARTS NOTHING. With no findings file for this session, this
5248
5914
  # script reads its session id with a bash regex (no node) and exits 0 with no
@@ -5279,7 +5945,7 @@ export CLAUDE_PROJECT_DIR="$HC_PROJECT_ROOT"
5279
5945
 
5280
5946
  trap - ERR
5281
5947
  set +e
5282
- exec bash "$HC_PROJECT_ROOT/.claude/hooks/guard-runner.sh" hook user-prompt-findings <<<"$HC_INPUT"
5948
+ exec bash "$HC_PROJECT_ROOT/.claude/hooks/guard-runner.sh" hook user-prompt-findings <<<"$HC_INPUT" "$@"
5283
5949
  `;
5284
5950
  }
5285
5951
  var CI_STANZA_KNOWN_HEADER_HASHES = [
@@ -5351,6 +6017,148 @@ var MCP_HEADERS_HELPER_COMMAND = `node -e "${nodeFloorProgram([
5351
6017
  `console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
5352
6018
  `process.exit(1)`
5353
6019
  ].join(""))}"`;
6020
+ var CODEX_ADDRESS_PATTERN = /^https?:\/\/[A-Za-z0-9.-]+(:[0-9]{1,5})?(\/[A-Za-z0-9._-]+)*\/mcp$/;
6021
+ function codexAddressAllowed(address) {
6022
+ return CODEX_ADDRESS_PATTERN.test(address);
6023
+ }
6024
+ var CODEX_NO_CREDENTIAL_STATEMENT = `console.log(JSON.stringify({Authorization:'Bearer ${NO_CREDENTIAL_PLACEHOLDER}'}))`;
6025
+ function codexMcpHeadersHelperCommand(address) {
6026
+ if (!codexAddressAllowed(address))
6027
+ return null;
6028
+ return `node -e "${nodeFloorProgram([
6029
+ `let o=0;`,
6030
+ `const n=function(){if(o)return;o=1;${CODEX_NO_CREDENTIAL_STATEMENT}};`,
6031
+ `try{`,
6032
+ `const p=require('path'),f=require('fs'),c=require('child_process');`,
6033
+ `let d=process.cwd(),b='';`,
6034
+ `for(;;){`,
6035
+ `const t=p.join(d,'${VENDORED_BIN_REL}');`,
6036
+ `if(f.existsSync(t)){b=t;break}`,
6037
+ `const u=p.dirname(d);`,
6038
+ `if(u===d)break;`,
6039
+ `d=u}`,
6040
+ `if(b===''){`,
6041
+ `console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
6042
+ `n()}else{`,
6043
+ `const r=c.spawnSync(process.execPath,[b,'mcp-headers','--url','${address}'],{stdio:['ignore','pipe','inherit']});`,
6044
+ `if(r.status===0&&r.stdout&&r.stdout.length){o=1;process.stdout.write(r.stdout)}else{n()}}`,
6045
+ `}catch(e){n()}`
6046
+ ].join(""), CODEX_NO_CREDENTIAL_STATEMENT)}"`;
6047
+ }
6048
+ var CODEX_CONFIG_OPEN_MARKER = "# >>> Halfcycle \u2014 generated by the Halfcycle installer; replaced on every install";
6049
+ var CODEX_CONFIG_CLOSE_MARKER = "# <<< Halfcycle";
6050
+ function tomlBasicString(value) {
6051
+ if (/[\u0000-\u001f\u007f]/.test(value)) {
6052
+ throw new Error("[halfcycle] a value for Codex's settings holds a control character.");
6053
+ }
6054
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
6055
+ }
6056
+ function codexServerPath() {
6057
+ const { serversKey, serverKey } = AGENT_PROFILES.codex.mcpRegistration;
6058
+ return [serversKey, serverKey];
6059
+ }
6060
+ function generateCodexConfigBlock(address) {
6061
+ return codexConfigBlockWith(address, codexMcpHeadersHelperCommand);
6062
+ }
6063
+ function codexConfigBlockWith(address, helperFor) {
6064
+ if (!codexAddressAllowed(address))
6065
+ return null;
6066
+ const helper = helperFor(address);
6067
+ if (helper === null)
6068
+ return null;
6069
+ const [serversKey, serverKey] = codexServerPath();
6070
+ return [
6071
+ CODEX_CONFIG_OPEN_MARKER,
6072
+ `[${serversKey}.${serverKey}]`,
6073
+ `url = ${tomlBasicString(address)}`,
6074
+ `http_headers_helper = ${tomlBasicString(helper)}`,
6075
+ CODEX_CONFIG_CLOSE_MARKER
6076
+ ].join("\n");
6077
+ }
6078
+ function locateCodexBlock(lines) {
6079
+ const opens = [];
6080
+ const closes = [];
6081
+ for (const [index, line] of lines.entries()) {
6082
+ const trimmed = line.trim();
6083
+ if (trimmed.startsWith("# >>> Halfcycle")) {
6084
+ if (line !== CODEX_CONFIG_OPEN_MARKER)
6085
+ return { kind: "broken" };
6086
+ opens.push(index);
6087
+ } else if (trimmed.startsWith("# <<< Halfcycle")) {
6088
+ if (line !== CODEX_CONFIG_CLOSE_MARKER)
6089
+ return { kind: "broken" };
6090
+ closes.push(index);
6091
+ }
6092
+ }
6093
+ if (opens.length === 0 && closes.length === 0)
6094
+ return { kind: "none" };
6095
+ if (opens.length === 1 && closes.length === 1 && opens[0] < closes[0]) {
6096
+ return { kind: "one", open: opens[0], close: closes[0] };
6097
+ }
6098
+ return { kind: "broken" };
6099
+ }
6100
+ function definesCodexServer(statement, servers, server) {
6101
+ const [first, second] = statement.path;
6102
+ if (first !== servers)
6103
+ return false;
6104
+ if (statement.kind === "table")
6105
+ return second === server;
6106
+ return statement.path.length === 1 || second === server;
6107
+ }
6108
+ function linesOf(text) {
6109
+ return text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
6110
+ }
6111
+ function codexConfigConflict(existing) {
6112
+ const lines = linesOf(existing);
6113
+ const at = locateCodexBlock(lines);
6114
+ if (at.kind === "broken")
6115
+ return "broken-markers";
6116
+ const [servers, server] = codexServerPath();
6117
+ const scan = scanTomlStatements(lines, at.kind === "one" ? { from: at.open, to: at.close, tableAfter: [servers, server] } : void 0);
6118
+ if (!scan.ok)
6119
+ return "unreadable";
6120
+ return scan.statements.some((statement) => definesCodexServer(statement, servers, server)) ? "server-named" : null;
6121
+ }
6122
+ function holdsCodexBlock(existing) {
6123
+ return locateCodexBlock(linesOf(existing)).kind === "one";
6124
+ }
6125
+ function codexBlockSpan(text) {
6126
+ const at = locateCodexBlock(linesOf(text));
6127
+ if (at.kind !== "one")
6128
+ return at.kind;
6129
+ const raw = text.split("\n");
6130
+ let start = 0;
6131
+ for (let i = 0; i < at.open; i += 1)
6132
+ start += raw[i].length + 1;
6133
+ let end = start;
6134
+ for (let i = at.open; i <= at.close; i += 1)
6135
+ end += raw[i].length + 1;
6136
+ return { start, end: Math.min(end, text.length) };
6137
+ }
6138
+ function reconcileCodexConfig(existing, block) {
6139
+ if (existing === null || existing === "") {
6140
+ const text2 = `${block}
6141
+ `;
6142
+ return { kind: "new", text: text2, appended: existing === null ? null : text2, block: text2 };
6143
+ }
6144
+ const conflict = codexConfigConflict(existing);
6145
+ if (conflict !== null)
6146
+ return { kind: "conflict", reason: conflict };
6147
+ const eol = lineEndingOf(existing);
6148
+ const blockText = block.split("\n").join(eol);
6149
+ const span = codexBlockSpan(existing);
6150
+ if (span === "broken")
6151
+ return { kind: "conflict", reason: "broken-markers" };
6152
+ if (span === "none") {
6153
+ const appended = `${existing.endsWith("\n") ? "" : eol}${eol}${blockText}${eol}`;
6154
+ return { kind: "new", text: existing + appended, appended, block: `${blockText}${eol}` };
6155
+ }
6156
+ const old = existing.slice(span.start, span.end);
6157
+ const ending = old.endsWith("\r\n") ? "\r\n" : old.endsWith("\n") ? "\n" : "";
6158
+ const inFile = `${blockText}${ending}`;
6159
+ const text = existing.slice(0, span.start) + inFile + existing.slice(span.end);
6160
+ return text === existing ? { kind: "unchanged", block: inFile } : { kind: "new", text, appended: null, block: inFile };
6161
+ }
5354
6162
  var MCP_HEADERS_KNOWN_HEADER_HASHES = [
5355
6163
  "37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
5356
6164
  "6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
@@ -5738,6 +6546,21 @@ function planCommandStubs(targetRepo, previous, writeSet) {
5738
6546
  const manifest = readPluginManifest();
5739
6547
  return { version: manifest.version, commands: planManifestCommands(manifest, targetRepo, previous, writeSet) };
5740
6548
  }
6549
+ function planCodexSkills(targetRepo, previous, writeSet) {
6550
+ const manifest = readPluginManifest();
6551
+ const recordedFiles = new Map((previous?.files ?? []).filter((file) => writeSet.files.has(file.path)).map((file) => [file.path, file]));
6552
+ return (manifest.commands ?? []).map((cmd) => {
6553
+ const srcFile = join6(BUNDLE_ROOT, ".claude-plugin", cmd.path);
6554
+ if (!existsSync4(srcFile)) {
6555
+ throw new Error(`[halfcycle] plugin.json declares command "${cmd.name}" at "${cmd.path}", but no file exists there (${srcFile}). The manifest and commands/ must agree.`);
6556
+ }
6557
+ const rel = skillRel(cmd.name);
6558
+ const content = codexSkillContent(cmd.name, readFileSync5(srcFile, "utf-8"));
6559
+ const recorded = recordedFiles.get(rel);
6560
+ const planned = decideCollisionSafe(join6(targetRepo, rel), targetRepo, content, (current) => bytesAreOurs(rel, current, recorded, writeSet));
6561
+ return { ...planned, editedCollided: planned.outcome === "collided" && recorded !== void 0 };
6562
+ });
6563
+ }
5741
6564
  function planVendoredBinary(targetRepo) {
5742
6565
  return decideOwned(join6(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs"), targetRepo, readFileSync5(resolveVendoredBinary(), "utf-8"));
5743
6566
  }
@@ -5785,9 +6608,156 @@ function putPlannedMcp(targetRepo, planned) {
5785
6608
  return;
5786
6609
  writeAllowlisted(join6(targetRepo, MCP_REGISTRATION_REL), targetRepo, planned.text, []);
5787
6610
  }
6611
+ function fileInFolderPlace(root, rel) {
6612
+ for (let dir = posix2.dirname(rel); dir !== "."; dir = posix2.dirname(dir)) {
6613
+ const found = lstatOrNull(join6(root, dir));
6614
+ if (found !== null && !found.isDirectory())
6615
+ return true;
6616
+ }
6617
+ return false;
6618
+ }
6619
+ function symlinkOnPath(root, rel) {
6620
+ let current = root;
6621
+ for (const segment of rel.split("/")) {
6622
+ current = join6(current, segment);
6623
+ try {
6624
+ if (lstatSync(current).isSymbolicLink())
6625
+ return true;
6626
+ } catch {
6627
+ return false;
6628
+ }
6629
+ }
6630
+ return false;
6631
+ }
6632
+ function planCodexHooks(targetRepo) {
6633
+ const generated = codexHooksValue();
6634
+ const leave = (outcome, before2) => ({
6635
+ outcome,
6636
+ before: before2,
6637
+ existing: null,
6638
+ generated,
6639
+ text: null
6640
+ });
6641
+ if (symlinkOnPath(targetRepo, CODEX_HOOKS_REL2))
6642
+ return leave("link", null);
6643
+ const folder = lstatOrNull(join6(targetRepo, posix2.dirname(CODEX_HOOKS_REL2)));
6644
+ if (folder !== null && !folder.isDirectory())
6645
+ return leave("unreadable", null);
6646
+ const path = join6(targetRepo, CODEX_HOOKS_REL2);
6647
+ let before = null;
6648
+ if (lstatOrNull(path) !== null) {
6649
+ try {
6650
+ before = readFileSync5(path, "utf-8");
6651
+ } catch {
6652
+ return leave("unreadable", null);
6653
+ }
6654
+ }
6655
+ let existing = {};
6656
+ if (before !== null) {
6657
+ let value;
6658
+ try {
6659
+ value = JSON.parse(before);
6660
+ } catch {
6661
+ return leave("unreadable", before);
6662
+ }
6663
+ if (!isPlainObject(value))
6664
+ return leave("unreadable", before);
6665
+ const hooks = value["hooks"];
6666
+ if (hooks !== void 0 && !isPlainObject(hooks))
6667
+ return leave("unreadable", before);
6668
+ const ourEvents = Object.keys(generated.hooks);
6669
+ if (hooks !== void 0 && ourEvents.some((event) => event in hooks && !Array.isArray(hooks[event]))) {
6670
+ return leave("unreadable", before);
6671
+ }
6672
+ existing = value;
6673
+ }
6674
+ const merged = mergeCodexHooks(existing, generated, AGENT_PROFILES.codex.commands);
6675
+ if (before !== null && JSON.stringify(merged) === JSON.stringify(existing)) {
6676
+ return { outcome: "current", before, existing, generated, text: null };
6677
+ }
6678
+ const text = inLineEndingOf(before, JSON.stringify(merged, null, 2) + "\n");
6679
+ return { outcome: "written", before, existing: before === null ? null : existing, generated, text };
6680
+ }
6681
+ function putPlannedCodexHooks(targetRepo, planned) {
6682
+ if (planned.outcome !== "written" || planned.text === null)
6683
+ return;
6684
+ writeAllowlisted(join6(targetRepo, CODEX_HOOKS_REL2), targetRepo, planned.text, []);
6685
+ }
6686
+ function readCodexConfig(targetRepo) {
6687
+ const folder = lstatOrNull(join6(targetRepo, posix2.dirname(CODEX_CONFIG_REL2)));
6688
+ if (folder !== null && !folder.isDirectory())
6689
+ return "unreadable";
6690
+ const path = join6(targetRepo, CODEX_CONFIG_REL2);
6691
+ const stat = lstatOrNull(path);
6692
+ if (stat === null)
6693
+ return { text: null };
6694
+ if (!stat.isFile())
6695
+ return "unreadable";
6696
+ let bytes;
6697
+ try {
6698
+ bytes = readFileSync5(path);
6699
+ } catch {
6700
+ return "unreadable";
6701
+ }
6702
+ const text = bytes.toString("utf-8");
6703
+ return Buffer.from(text, "utf-8").equals(bytes) ? { text } : "unreadable";
6704
+ }
6705
+ function planCodexConfig(targetRepo, credential) {
6706
+ const plan = (outcome, extra = {}) => ({
6707
+ outcome,
6708
+ before: null,
6709
+ text: null,
6710
+ appended: null,
6711
+ inFile: null,
6712
+ conflict: null,
6713
+ blockKept: false,
6714
+ ...extra
6715
+ });
6716
+ if (credential === void 0)
6717
+ return plan("no-credential");
6718
+ if (symlinkOnPath(targetRepo, CODEX_CONFIG_REL2))
6719
+ return plan("link");
6720
+ const read = readCodexConfig(targetRepo);
6721
+ if (read === "unreadable")
6722
+ return plan("conflict", { conflict: "unreadable" });
6723
+ const before = read.text;
6724
+ if (credential === "pending") {
6725
+ const conflict = before === null ? null : codexConfigConflict(before);
6726
+ return conflict === null ? plan("pending", { before }) : plan("conflict", { before, conflict });
6727
+ }
6728
+ const block = generateCodexConfigBlock(mcpEndpointUrl(credential.mcpUrl));
6729
+ if (block === null)
6730
+ return plan("refused-address", { before, blockKept: before !== null && holdsCodexBlock(before) });
6731
+ const reconciled = reconcileCodexConfig(before, block);
6732
+ switch (reconciled.kind) {
6733
+ case "conflict":
6734
+ return plan("conflict", { before, conflict: reconciled.reason });
6735
+ case "unchanged":
6736
+ return plan("current", { before, inFile: reconciled.block });
6737
+ case "new":
6738
+ return plan("written", { before, text: reconciled.text, appended: reconciled.appended, inFile: reconciled.block });
6739
+ }
6740
+ }
6741
+ function putPlannedCodexConfig(targetRepo, planned) {
6742
+ if (planned.outcome !== "written" || planned.text === null)
6743
+ return;
6744
+ writeAllowlisted(join6(targetRepo, CODEX_CONFIG_REL2), targetRepo, planned.text, []);
6745
+ }
5788
6746
  function planCrewRoster(targetRepo) {
5789
6747
  return decideOwned(join6(targetRepo, CREW_ROSTER_REL), targetRepo, renderCrewRoster());
5790
6748
  }
6749
+ function planCrewProfiles(targetRepo, previous, writeSet) {
6750
+ const recordedFiles = new Map((previous?.files ?? []).filter((file) => writeSet.files.has(file.path)).map((file) => [file.path, file]));
6751
+ return renderCrewProfiles().map(({ rel, text }) => {
6752
+ const target = crewProfileTarget(targetRepo, rel);
6753
+ if (symlinkOnPath(targetRepo, rel) || fileInFolderPlace(targetRepo, rel)) {
6754
+ return { rel, text, outcome: "collided", replacedExisting: false, editedCollided: false };
6755
+ }
6756
+ const recorded = recordedFiles.get(rel);
6757
+ const planned = decideCollisionSafe(target, targetRepo, text, (current) => bytesAreOurs(rel, current, recorded, writeSet));
6758
+ return { ...planned, editedCollided: planned.outcome === "collided" && recorded !== void 0 };
6759
+ });
6760
+ }
5791
6761
  function planInstallRecord(targetRepo, text) {
5792
6762
  return decideOwned(join6(targetRepo, INSTALL_MANIFEST_REL), targetRepo, text);
5793
6763
  }
@@ -5811,6 +6781,12 @@ function accountInstall(steps, actual) {
5811
6781
  report.editedCollidedPaths.push(command.rel);
5812
6782
  noteFile(draft, command.rel, command.outcome, actual.bytesAfter);
5813
6783
  }
6784
+ for (const skill of steps.codexSkills) {
6785
+ recordOwnedGenerated(report, skill);
6786
+ if (skill.editedCollided)
6787
+ report.editedCollidedPaths.push(skill.rel);
6788
+ noteFile(draft, skill.rel, skill.outcome, actual.bytesAfter);
6789
+ }
5814
6790
  noteFile(draft, VENDORED_BIN_REL, recordOwnedGenerated(report, steps.vendored), actual.bytesAfter);
5815
6791
  const settings = steps.settings;
5816
6792
  recordSettingsMerge(draft, settings.existing, settings.generated, settings.preexisted, settings.merged);
@@ -5868,6 +6844,8 @@ function accountInstall(steps, actual) {
5868
6844
  if (mcp.existing !== null)
5869
6845
  recordMcpAdoptionOnly(draft, mcp.existing);
5870
6846
  }
6847
+ recordCodexHooksStep(report, draft, steps.codexHooks);
6848
+ recordCodexConfigStep(report, draft, steps.codexConfig);
5871
6849
  recordGitignoreReconcile(draft, steps.gitignore.before, actual.gitignoreAfter, writeSet);
5872
6850
  if (actual.gitignore === "failed") {
5873
6851
  report.skippedPaths.push(".gitignore (write failed \u2014 see credential refusal)");
@@ -5910,6 +6888,12 @@ function accountInstall(steps, actual) {
5910
6888
  report.writtenPaths.push(BUNDLE_PIN_REL);
5911
6889
  noteFile(draft, BUNDLE_PIN_REL, "written", actual.bytesAfter);
5912
6890
  noteFile(draft, CREW_ROSTER_REL, recordOwnedGenerated(report, steps.crew), actual.bytesAfter);
6891
+ for (const profile of steps.crewProfiles) {
6892
+ recordOwnedGenerated(report, profile);
6893
+ if (profile.editedCollided)
6894
+ report.editedCollidedPaths.push(profile.rel);
6895
+ noteFile(draft, profile.rel, profile.outcome, actual.bytesAfter);
6896
+ }
5913
6897
  if (actual.stateExistsAfter)
5914
6898
  recordPerMachineFile(draft, BOOTSTRAP_STATE_REL);
5915
6899
  for (const dir of writeSet.dirs) {
@@ -5920,9 +6904,78 @@ function accountInstall(steps, actual) {
5920
6904
  assertManifestInWriteSet(installManifest, writeSet);
5921
6905
  return { report, installManifest, manifestText: serializeManifest(installManifest) };
5922
6906
  }
6907
+ function recordCodexHooksStep(report, draft, planned) {
6908
+ switch (planned.outcome) {
6909
+ case "unreadable":
6910
+ report.skippedPaths.push(`${CODEX_HOOKS_REL2} (left as it is \u2014 it could not be read, so Codex's checks were not added)`);
6911
+ return;
6912
+ case "link":
6913
+ report.skippedPaths.push(`${CODEX_HOOKS_REL2} (left as it is \u2014 it is a link Halfcycle does not follow)`);
6914
+ return;
6915
+ case "written":
6916
+ (planned.before === null ? report.writtenPaths : report.mergedPaths).push(CODEX_HOOKS_REL2);
6917
+ break;
6918
+ case "current":
6919
+ report.skippedPaths.push(CODEX_HOOKS_REL2);
6920
+ break;
6921
+ }
6922
+ const existingHooks = planned.existing !== null && isPlainObject(planned.existing["hooks"]) ? planned.existing["hooks"] : void 0;
6923
+ const ours = AGENT_PROFILES.codex.commands;
6924
+ const heldOurs = Object.values(existingHooks ?? {}).some((groups) => Array.isArray(groups) && groups.some((group) => isPlainObject(group) && Array.isArray(group["hooks"]) && isHalfcycleEntry({ hooks: group["hooks"] }, ours)));
6925
+ recordCodexHooks(draft, {
6926
+ created: planned.before === null,
6927
+ adopted: draft.previous === null && heldOurs,
6928
+ hookCommands: Object.values(planned.generated.hooks).flatMap((groups) => groups.flatMap((group) => group.hooks.map((h) => h.command))),
6929
+ eventsCreated: Object.keys(planned.generated.hooks).filter((event) => !(existingHooks && event in existingHooks)),
6930
+ hooksCreated: planned.existing === null || planned.existing["hooks"] === void 0
6931
+ });
6932
+ }
6933
+ function recordCodexConfigStep(report, draft, planned) {
6934
+ const prev = draft.codexConfig;
6935
+ const separatorOf = (block) => {
6936
+ const at = block.indexOf(CODEX_CONFIG_OPEN_MARKER);
6937
+ return at > 0 ? block.slice(0, at) : "";
6938
+ };
6939
+ switch (planned.outcome) {
6940
+ case "written": {
6941
+ (planned.before === null ? report.writtenPaths : report.mergedPaths).push(CODEX_CONFIG_REL2);
6942
+ const block = planned.appended !== null ? planned.appended : planned.before === null ? planned.text : `${prev === void 0 ? "" : separatorOf(prev.block)}${planned.inFile}`;
6943
+ recordCodexConfig(draft, { created: planned.before === null, block });
6944
+ return;
6945
+ }
6946
+ case "current":
6947
+ report.skippedPaths.push(CODEX_CONFIG_REL2);
6948
+ if (prev === void 0)
6949
+ recordCodexConfig(draft, { created: false, block: planned.inFile });
6950
+ return;
6951
+ case "no-credential":
6952
+ report.skippedPaths.push(`${CODEX_CONFIG_REL2} (no MCP origin \u2014 no credential supplied)`);
6953
+ return;
6954
+ case "link":
6955
+ report.skippedPaths.push(`${CODEX_CONFIG_REL2} (left as it is \u2014 it is a link Halfcycle does not follow)`);
6956
+ return;
6957
+ case "conflict":
6958
+ report.skippedPaths.push(planned.conflict === "server-named" ? `${CODEX_CONFIG_REL2} (left as it is \u2014 it already names a "halfcycle" server, so Halfcycle did not add its own)` : planned.conflict === "broken-markers" ? `${CODEX_CONFIG_REL2} (left as it is \u2014 the lines marking Halfcycle's part of it are incomplete or repeated)` : `${CODEX_CONFIG_REL2} (left as it is \u2014 it could not be read, so Halfcycle did not add its entry)`);
6959
+ return;
6960
+ case "refused-address":
6961
+ report.skippedPaths.push(planned.blockKept ? `${CODEX_CONFIG_REL2} (left as it is \u2014 the Halfcycle address cannot be passed to Codex safely; Halfcycle's existing entry in it was kept unchanged)` : `${CODEX_CONFIG_REL2} (not written \u2014 the Halfcycle address cannot be passed to Codex safely)`);
6962
+ return;
6963
+ case "pending":
6964
+ throw new Error("[halfcycle] a plan still waiting for the service cannot be applied or recorded.");
6965
+ }
6966
+ }
5923
6967
  function recordInstallRecord(report, planned) {
5924
6968
  recordOwnedGenerated(report, planned);
5925
6969
  }
6970
+ var SELF_BUILD_GITIGNORE_REL = ".halfcycle/.gitignore";
6971
+ var SELF_BUILD_WRITE_SET = [
6972
+ BUNDLE_PIN_REL,
6973
+ VENDORED_BIN_REL,
6974
+ SELF_BUILD_GITIGNORE_REL,
6975
+ CREW_ROSTER_REL,
6976
+ MCP_REGISTRATION_REL,
6977
+ ...renderCrewProfiles().map((profile) => profile.rel)
6978
+ ];
5926
6979
 
5927
6980
  // dist/control-origin.js
5928
6981
  var DEFAULT_CONTROL_ORIGIN = "https://control.halfcycle.ai";