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/cli.js CHANGED
@@ -493,6 +493,7 @@ var TASK_PLAN_FILE = "docs/build-records/task-plan.json";
493
493
  var TASK_LIST_FILE = "docs/build-records/build-tasks.json";
494
494
  var TASK_PLAN_TASKS_MAX = 500;
495
495
  var TASK_PLAN_ID_MAX = 64;
496
+ var TASK_PLAN_BUILDS_NONE = "none";
496
497
  var TASK_PLAN_KINDS = ["build", "review", "spec", "walk", "check"];
497
498
  var TASK_PLAN_INTERFACE_KINDS = [
498
499
  "route",
@@ -517,7 +518,8 @@ var taskPlanTaskSchema = z2.object({
517
518
  interfaces: z2.array(planText).optional()
518
519
  }).strict().optional(),
519
520
  wholeProject: z2.literal(true).optional(),
520
- ships: z2.enum(["deploy", "release"]).optional()
521
+ ships: z2.enum(["deploy", "release"]).optional(),
522
+ builds: z2.union([z2.literal(TASK_PLAN_BUILDS_NONE), z2.array(planText)]).optional()
521
523
  }).strict().superRefine((task, ctx) => {
522
524
  if (task.reviews !== void 0 && task.kind !== "review") {
523
525
  ctx.addIssue({
@@ -564,6 +566,7 @@ var taskPlanLineText = {
564
566
  O5: (difference) => `The task list in ${TASK_LIST_FILE} does not match the plan: ${difference}. Make the two agree, then run this again.`,
565
567
  N1: (asked) => `The task list in ${TASK_LIST_FILE} is not for "${asked}", so it was not compared.`,
566
568
  O6: (phase, asked) => `The plan is for "${phase}", not "${asked}".`,
569
+ O7: (a, file, partIds) => `"${a}" does not say which parts of the system it builds. Add "builds" with the ids of the parts it builds, from ${file}: ${partIds.join(", ")}. If it builds none of them, write "builds": "${TASK_PLAN_BUILDS_NONE}".`,
567
570
  K1: (a) => `"${a}" is in no batch: no whole-project check waits for it. Add it to the dependsOn of the first whole-project check that it does not already wait for and that waits for every build task it waits for, or add a whole-project check after it.`,
568
571
  K2: (a, b) => `"${a}" and "${b}" are both whole-project checks and neither waits for the other. Make the later one wait for the earlier.`,
569
572
  K3: (b, a, c) => `"${b}" builds on "${a}" before "${c}" has checked it. Add "${c}" to the dependsOn of "${b}".`,
@@ -810,6 +813,12 @@ function refusal(code, text) {
810
813
  function isBuildTask(task) {
811
814
  return task.kind === "build" && ((task.touches?.files?.length ?? 0) > 0 || (task.touches?.interfaces?.length ?? 0) > 0);
812
815
  }
816
+ function mustNameParts(task) {
817
+ return task.kind === "build" && task.heldBy === void 0 && task.ships === void 0;
818
+ }
819
+ function namesNoPart(task) {
820
+ return task.builds === void 0 || Array.isArray(task.builds) && task.builds.length === 0;
821
+ }
813
822
  function isWholeProjectCheck(task) {
814
823
  return task.kind === "check" && task.wholeProject === true;
815
824
  }
@@ -944,6 +953,7 @@ function checkTaskPlan(plan, request) {
944
953
  }
945
954
  const tasks = plan.tasks;
946
955
  const lines = [];
956
+ const system = request.system;
947
957
  const ids = new Set(tasks.map((task) => task.id));
948
958
  const seen = /* @__PURE__ */ new Set();
949
959
  const reportedTwice = /* @__PURE__ */ new Set();
@@ -981,6 +991,9 @@ function checkTaskPlan(plan, request) {
981
991
  }
982
992
  }
983
993
  }
994
+ if (system !== void 0 && system.partIds.length > 0 && mustNameParts(task) && namesNoPart(task)) {
995
+ lines.push(refusal("O7", taskPlanLineText.O7(task.id, system.file, system.partIds)));
996
+ }
984
997
  }
985
998
  const { before, cycles } = waitsFor(tasks);
986
999
  for (const path of cycles)
@@ -1019,6 +1032,101 @@ function checkTaskPlan(plan, request) {
1019
1032
  return { lines, refused: refused3 };
1020
1033
  }
1021
1034
 
1035
+ // ../core/dist/agent-profile.js
1036
+ var AGENT_IDS = ["claude-code", "codex"];
1037
+ var CLAUDE_CODE_PROFILE = {
1038
+ id: "claude-code",
1039
+ settingsFiles: [SETTINGS_REL, LOCAL_SETTINGS_REL],
1040
+ wiring: HALFCYCLE_HOOK_WIRING,
1041
+ commands: HALFCYCLE_HOOK_COMMANDS,
1042
+ wiringPaths: HALFCYCLE_HOOK_WIRING_PATHS,
1043
+ writeCapableTools: WRITE_CAPABLE_TOOLS,
1044
+ projectRoot: "claude-project-dir",
1045
+ configFolder: "claude-config",
1046
+ mcpRegistration: { file: MCP_REGISTRATION_REL, serversKey: "mcpServers", serverKey: MCP_SERVER_KEY },
1047
+ tierModels: {
1048
+ deep: { model: "opus" },
1049
+ standard: { model: "sonnet" },
1050
+ fast: { model: "haiku" }
1051
+ }
1052
+ };
1053
+ var CODEX_HOOKS_REL = ".codex/hooks.json";
1054
+ var CODEX_CONFIG_REL = ".codex/config.toml";
1055
+ function codexHookCommand(script, args2) {
1056
+ const invocation = `cd "$(git rev-parse --show-toplevel)" && bash ./${script}`;
1057
+ return args2 === void 0 ? invocation : `${invocation} ${args2}`;
1058
+ }
1059
+ 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.";
1060
+ 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.";
1061
+ function codexHookCommandWindows(script, args2) {
1062
+ const tick = (text) => "`" + text + "`";
1063
+ const argv = [script, ...args2 === void 0 ? [] : args2.split(" ")].map(tick).join(",");
1064
+ const program = [
1065
+ "const p=require(`path`),f=require(`fs`),c=require(`child_process`);",
1066
+ "let d=process.cwd();",
1067
+ `for(;;){if(f.existsSync(p.join(d,${tick(VENDORED_BIN_REL)})))break;`,
1068
+ "const u=p.dirname(d);",
1069
+ "if(u===d){d=``;break}",
1070
+ "d=u}",
1071
+ `if(d===\`\`){console.error(${tick(CODEX_WINDOWS_NOT_SET_UP)})}else{`,
1072
+ "const g=c.spawnSync(`git`,[`--exec-path`],{encoding:`utf8`});",
1073
+ "const b=g.status===0?p.join(g.stdout.trim(),`..`,`..`,`..`,`bin`,`bash.exe`):``;",
1074
+ `if(b===\`\`||!f.existsSync(b)){console.error(${tick(CODEX_WINDOWS_NO_GIT_BASH)})}else{`,
1075
+ `const r=c.spawnSync(b,[${argv}],{cwd:d,stdio:\`inherit\`});`,
1076
+ "process.exitCode=typeof r.status===`number`?r.status:1}}"
1077
+ ].join("");
1078
+ return `node -e '${program}'`;
1079
+ }
1080
+ function codexHookRow(event, matcher, timeout, script, args2) {
1081
+ return {
1082
+ event,
1083
+ matcher,
1084
+ timeout,
1085
+ command: codexHookCommand(script, args2),
1086
+ script,
1087
+ commandWindows: codexHookCommandWindows(script, args2)
1088
+ };
1089
+ }
1090
+ var CODEX_WRITE_CAPABLE_TOOLS = ["apply_patch", "Edit", "Write"];
1091
+ var CODEX_PRE_TOOL_USE_MATCHER = "^(Bash|apply_patch)$";
1092
+ var CODEX_HOOK_WIRING = [
1093
+ codexHookRow("SessionStart", null, 5, SESSION_START_MARKER_REL),
1094
+ codexHookRow("SessionStart", null, 10, GUARD_RUNNER_REL, "refresh-credential --agent codex"),
1095
+ codexHookRow("UserPromptSubmit", null, 5, USER_PROMPT_REMINDER_REL),
1096
+ codexHookRow("UserPromptSubmit", null, 10, USER_PROMPT_SCOPE_REL, "--agent codex"),
1097
+ codexHookRow("UserPromptSubmit", null, 10, USER_PROMPT_FINDINGS_REL, "--agent codex"),
1098
+ codexHookRow("PreToolUse", CODEX_PRE_TOOL_USE_MATCHER, 10, GUARD_RUNNER_REL, "hook pre-tool-use --agent codex"),
1099
+ codexHookRow("PostToolUse", CODEX_WRITE_CAPABLE_TOOLS.join("|"), 60, GUARD_RUNNER_REL, "hook post-tool-use --agent codex"),
1100
+ codexHookRow("Stop", null, 60, GUARD_RUNNER_REL, "hook stop --agent codex"),
1101
+ codexHookRow("SubagentStop", null, 60, GUARD_RUNNER_REL, "hook subagent-stop --agent codex"),
1102
+ codexHookRow("SessionEnd", null, 3, SESSION_END_MARKER_REL)
1103
+ ];
1104
+ var RETIRED_CODEX_HOOK_COMMANDS = [];
1105
+ function codexHookCommandSet(retired) {
1106
+ return /* @__PURE__ */ new Set([...CODEX_HOOK_WIRING.map((entry) => entry.command), ...retired]);
1107
+ }
1108
+ var CODEX_PROFILE = {
1109
+ id: "codex",
1110
+ settingsFiles: [CODEX_HOOKS_REL],
1111
+ wiring: CODEX_HOOK_WIRING,
1112
+ commands: codexHookCommandSet(RETIRED_CODEX_HOOK_COMMANDS),
1113
+ wiringPaths: [.../* @__PURE__ */ new Set([CODEX_HOOKS_REL, ...CODEX_HOOK_WIRING.map((entry) => entry.script), VENDORED_BIN_REL])],
1114
+ writeCapableTools: CODEX_WRITE_CAPABLE_TOOLS,
1115
+ projectRoot: "git-toplevel",
1116
+ configFolder: "codex-home",
1117
+ mcpRegistration: { file: CODEX_CONFIG_REL, serversKey: "mcp_servers", serverKey: MCP_SERVER_KEY },
1118
+ tierModels: {
1119
+ deep: { model: "gpt-6-luna", effort: "high" },
1120
+ standard: { model: "gpt-6-luna", effort: "medium" },
1121
+ fast: { model: "gpt-6-luna", effort: "low" }
1122
+ }
1123
+ };
1124
+ var AGENT_PROFILES = {
1125
+ "claude-code": CLAUDE_CODE_PROFILE,
1126
+ codex: CODEX_PROFILE
1127
+ };
1128
+ var ALL_HOOK_COMMANDS = new Set(AGENT_IDS.flatMap((id) => [...AGENT_PROFILES[id].commands]));
1129
+
1022
1130
  // ../events/dist/result.js
1023
1131
  import { z as z3 } from "zod";
1024
1132
  var firedGuardSchema = z3.object({
@@ -2102,7 +2210,10 @@ var buildSnapshotReportResultSchema = z8.object({
2102
2210
  // Which phase the graph was recorded against.
2103
2211
  phase: buildPhaseSchema,
2104
2212
  // When it was recorded.
2105
- reportedAt: utcIso8601
2213
+ reportedAt: utcIso8601,
2214
+ // One sentence to pass on to the developer about the graph just stored. Absent
2215
+ // when there is nothing to say.
2216
+ warning: z8.string().min(1).max(400).optional()
2106
2217
  }).strict();
2107
2218
 
2108
2219
  // ../events/dist/phase-plan.js
@@ -5359,7 +5470,7 @@ function autonomyA13(accepted, carried) {
5359
5470
  }
5360
5471
  function autonomyA14(count, parts, acceptLine) {
5361
5472
  const typed = acceptLine === null ? 'say "review what was built" and type the lines it gives.' : `say "review what was built", then type ${acceptLine} to accept them with those parts, or type the reject line it gives for a task's tests.`;
5362
- return `The phase waits for you: ${String(count)} tests the agents said pass were not run by Halfcycle, so ${namesOf(parts)} wait for you too. Only you can accept or reject those tests. In your own Claude Code session, ` + typed;
5473
+ return `The phase waits for you: ${String(count)} tests the agents said pass were not run by Halfcycle, so ${namesOf(parts)} wait for you too. Only you can accept or reject those tests. In your own coding agent's session, ` + typed;
5363
5474
  }
5364
5475
  function autonomyA15(count) {
5365
5476
  return `The phase waits for you: ${String(count)} parts were built without an approved scope, and only you can decide them.`;
@@ -5863,7 +5974,14 @@ function serializeManifest(manifest) {
5863
5974
  files: [...manifest.files].sort((a, b) => byString(a.path, b.path)),
5864
5975
  createdDirs: [...manifest.createdDirs].sort(byString),
5865
5976
  leftAlone: [...manifest.leftAlone].sort(byString),
5866
- settings
5977
+ settings,
5978
+ ...manifest.codexHooks !== void 0 ? {
5979
+ codexHooks: {
5980
+ ...manifest.codexHooks,
5981
+ hookCommands: [...manifest.codexHooks.hookCommands].sort(byString),
5982
+ eventsCreated: [...manifest.codexHooks.eventsCreated].sort(byString)
5983
+ }
5984
+ } : {}
5867
5985
  };
5868
5986
  return JSON.stringify(canonicalJson2(ordered), null, 2) + "\n";
5869
5987
  }
@@ -5917,7 +6035,7 @@ function parseManifest(text) {
5917
6035
  }
5918
6036
  if (!isRecord2(raw))
5919
6037
  throw new UnreadableManifestError("it is not a JSON object");
5920
- onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore", "gitattributes"], "the manifest");
6038
+ onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "codexHooks", "codexConfig", "gitignore", "gitattributes"], "the manifest");
5921
6039
  if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
5922
6040
  throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
5923
6041
  }
@@ -5983,6 +6101,30 @@ function parseManifest(text) {
5983
6101
  ...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
5984
6102
  };
5985
6103
  }
6104
+ let codexHooks;
6105
+ if (raw["codexHooks"] !== void 0) {
6106
+ const c = raw["codexHooks"];
6107
+ if (!isRecord2(c))
6108
+ throw new UnreadableManifestError('"codexHooks" is not an object');
6109
+ onlyKeys(c, ["created", "adopted", "hookCommands", "eventsCreated", "hooksCreated"], '"codexHooks"');
6110
+ codexHooks = {
6111
+ created: bool(c["created"], "codexHooks.created"),
6112
+ adopted: bool(c["adopted"], "codexHooks.adopted"),
6113
+ hookCommands: stringArray(c["hookCommands"], "codexHooks.hookCommands"),
6114
+ eventsCreated: stringArray(c["eventsCreated"], "codexHooks.eventsCreated"),
6115
+ hooksCreated: bool(c["hooksCreated"], "codexHooks.hooksCreated")
6116
+ };
6117
+ }
6118
+ let codexConfig;
6119
+ if (raw["codexConfig"] !== void 0) {
6120
+ const c = raw["codexConfig"];
6121
+ if (!isRecord2(c))
6122
+ throw new UnreadableManifestError('"codexConfig" is not an object');
6123
+ onlyKeys(c, ["created", "block"], '"codexConfig"');
6124
+ if (typeof c["block"] !== "string")
6125
+ throw new UnreadableManifestError('"codexConfig.block" is not a string');
6126
+ codexConfig = { created: bool(c["created"], "codexConfig.created"), block: c["block"] };
6127
+ }
5986
6128
  if (raw["gitignore"] === void 0)
5987
6129
  throw new UnreadableManifestError('"gitignore" is missing');
5988
6130
  const gitignore = appendedTextRecord(raw["gitignore"], "gitignore");
@@ -6001,6 +6143,8 @@ function parseManifest(text) {
6001
6143
  leftAlone,
6002
6144
  settings,
6003
6145
  ...mcp !== void 0 ? { mcp } : {},
6146
+ ...codexHooks !== void 0 ? { codexHooks } : {},
6147
+ ...codexConfig !== void 0 ? { codexConfig } : {},
6004
6148
  gitignore,
6005
6149
  ...gitattributes !== void 0 ? { gitattributes } : {}
6006
6150
  };
@@ -6018,6 +6162,8 @@ function startManifest(previous, isMember, isMemberDir) {
6018
6162
  createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
6019
6163
  settings: previous?.settings,
6020
6164
  mcp: previous?.mcp,
6165
+ codexHooks: previous?.codexHooks,
6166
+ codexConfig: previous?.codexConfig,
6021
6167
  gitignore: previous?.gitignore,
6022
6168
  gitattributes: previous?.gitattributes
6023
6169
  };
@@ -6078,6 +6224,19 @@ function recordMcp(draft, observed) {
6078
6224
  ...entrySha256 !== void 0 ? { entrySha256 } : {}
6079
6225
  };
6080
6226
  }
6227
+ function recordCodexHooks(draft, observed) {
6228
+ const prev = draft.codexHooks;
6229
+ draft.codexHooks = prev === void 0 ? observed : {
6230
+ created: prev.created,
6231
+ adopted: prev.adopted,
6232
+ hookCommands: union(prev.hookCommands, observed.hookCommands),
6233
+ eventsCreated: union(prev.eventsCreated, observed.eventsCreated),
6234
+ hooksCreated: prev.hooksCreated
6235
+ };
6236
+ }
6237
+ function recordCodexConfig(draft, observed) {
6238
+ draft.codexConfig = { created: draft.codexConfig?.created ?? observed.created, block: observed.block };
6239
+ }
6081
6240
  function recordGitignore(draft, created, appended) {
6082
6241
  draft.gitignore = carryAppended(draft.gitignore, created, appended);
6083
6242
  }
@@ -6111,6 +6270,8 @@ function finishManifest(draft) {
6111
6270
  denyCreated: false
6112
6271
  },
6113
6272
  ...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
6273
+ ...draft.codexHooks !== void 0 ? { codexHooks: draft.codexHooks } : {},
6274
+ ...draft.codexConfig !== void 0 ? { codexConfig: draft.codexConfig } : {},
6114
6275
  gitignore: draft.gitignore ?? { created: false, appended: [] },
6115
6276
  ...draft.gitattributes !== void 0 ? { gitattributes: draft.gitattributes } : {}
6116
6277
  };
@@ -6235,6 +6396,30 @@ var PREVIEW_WORDS = Object.freeze({
6235
6396
  change: "takes out an older Halfcycle sign-in; your own lines stay as they are",
6236
6397
  leave: "an older Halfcycle sign-in that cannot be moved yet; left as it is",
6237
6398
  yours: YOURS
6399
+ },
6400
+ "codex-checks": {
6401
+ add: "Codex's checks, the same ones Halfcycle runs for Claude Code",
6402
+ change: "adds the checks Halfcycle needs; your own entries stay as they are",
6403
+ leave: "already has the checks Halfcycle needs",
6404
+ yours: YOURS
6405
+ },
6406
+ "codex-server": {
6407
+ add: "tells Codex how to reach Halfcycle's service",
6408
+ change: "adds Halfcycle's entry; your own entries stay as they are",
6409
+ leave: "already has Halfcycle's entry",
6410
+ yours: YOURS
6411
+ },
6412
+ skill: {
6413
+ add: "a skill your coding agent can run to take the next Halfcycle step",
6414
+ change: "a newer version of this Halfcycle skill",
6415
+ leave: SAME,
6416
+ yours: YOURS
6417
+ },
6418
+ "crew-profile": {
6419
+ add: "a member of your build crew, so your coding agent shows who is working",
6420
+ change: "a newer version of this build-crew member",
6421
+ leave: SAME,
6422
+ yours: YOURS
6238
6423
  }
6239
6424
  });
6240
6425
  var kindTable;
@@ -6259,13 +6444,21 @@ function kinds() {
6259
6444
  [RETIRED_MCP_HEADERS_HELPER_REL, "halfcycle-folder"],
6260
6445
  [LEGACY_CI_STANZA_REL, "halfcycle-folder"],
6261
6446
  [CAPTURED_INDEX_REL, "captured-index"],
6262
- [LEGACY_ENV_LOCAL_REL, "old-sign-in"]
6447
+ [LEGACY_ENV_LOCAL_REL, "old-sign-in"],
6448
+ [CODEX_HOOKS_REL2, "codex-checks"],
6449
+ [CODEX_CONFIG_REL2, "codex-server"]
6263
6450
  ]);
6264
6451
  return kindTable;
6265
6452
  }
6266
6453
  function previewKindOf(rel) {
6267
6454
  if (/^\.claude\/commands\/[^/]+\.md$/.test(rel))
6268
6455
  return "command";
6456
+ if (/^\.agents\/skills\/[^/]+\/SKILL\.md$/.test(rel))
6457
+ return "skill";
6458
+ if (/^\.claude\/agents\/[^/]+\.md$/.test(rel))
6459
+ return "crew-profile";
6460
+ if (/^\.codex\/agents\/[^/]+\.toml$/.test(rel))
6461
+ return "crew-profile";
6269
6462
  return kinds().get(rel);
6270
6463
  }
6271
6464
  function kindOf(rel) {
@@ -6291,6 +6484,7 @@ function planInstall(options) {
6291
6484
  const previous = readPreviousManifest(targetRepo);
6292
6485
  const dirsBefore = new Set([...writeSet.dirs].filter((dir) => existsSync3(join5(targetRepo, dir))));
6293
6486
  const { version, commands } = planCommandStubs(targetRepo, previous, writeSet);
6487
+ const codexSkills = planCodexSkills(targetRepo, previous, writeSet);
6294
6488
  const vendored = planVendoredBinary(targetRepo);
6295
6489
  let settings;
6296
6490
  try {
@@ -6304,6 +6498,8 @@ function planInstall(options) {
6304
6498
  const ciStanza = planLegacyCiStanzaRemoval(targetRepo);
6305
6499
  const mcp = planMcpRegistration(targetRepo, preview ? "pending" : credential);
6306
6500
  const mcpHelper = mcp.kind === "none" ? null : planMcpHeadersHelperRemoval(targetRepo);
6501
+ const codexHooks = planCodexHooks(targetRepo);
6502
+ const codexConfig = planCodexConfig(targetRepo, preview ? "pending" : credential);
6307
6503
  const gitignore = planGitignore(targetRepo);
6308
6504
  const gitattributes = planGitattributes(targetRepo);
6309
6505
  const identity = planProjectIdentity(targetRepo);
@@ -6315,6 +6511,7 @@ function planInstall(options) {
6315
6511
  const legacy = migrateLegacyEnvLocal(targetRepo, storedAfter);
6316
6512
  const pin = preview ? planBundlePinPreview(targetRepo, version, home) : planBundlePin(targetRepo, version, engagementId, engagementType, createdByAccountId, credential?.methodVersion);
6317
6513
  const crew = planCrewRoster(targetRepo);
6514
+ const crewProfiles = planCrewProfiles(targetRepo, previous, writeSet);
6318
6515
  const scan = { text: planBootstrapScan(targetRepo).text };
6319
6516
  const steps = {
6320
6517
  targetRepo,
@@ -6327,12 +6524,15 @@ function planInstall(options) {
6327
6524
  previous,
6328
6525
  dirsBefore,
6329
6526
  commands,
6527
+ codexSkills,
6330
6528
  vendored,
6331
6529
  settings,
6332
6530
  hooks,
6333
6531
  ciStanza,
6334
6532
  mcp,
6335
6533
  mcpHelper,
6534
+ codexHooks,
6535
+ codexConfig,
6336
6536
  gitignore,
6337
6537
  gitattributes,
6338
6538
  identity,
@@ -6340,6 +6540,7 @@ function planInstall(options) {
6340
6540
  legacy,
6341
6541
  pin,
6342
6542
  crew,
6543
+ crewProfiles,
6343
6544
  scan
6344
6545
  };
6345
6546
  const record2 = preview ? null : planInstallRecord(targetRepo, accountInstall(steps, predicted(steps)).manifestText);
@@ -6365,16 +6566,24 @@ function predicted(steps) {
6365
6566
  lands(planned.rel, planned.text, planned.outcome === "written");
6366
6567
  };
6367
6568
  steps.commands.forEach(file);
6569
+ steps.codexSkills.forEach(file);
6368
6570
  file(steps.vendored);
6369
6571
  file(steps.settings.file);
6370
6572
  steps.hooks.forEach(file);
6371
6573
  if (steps.mcp.kind === "write")
6372
6574
  lands(MCP_REGISTRATION_REL, steps.mcp.text, steps.mcp.outcome !== "skipped");
6575
+ if (steps.codexHooks.outcome === "written" && steps.codexHooks.text !== null) {
6576
+ lands(CODEX_HOOKS_REL2, steps.codexHooks.text, true);
6577
+ }
6578
+ if (steps.codexConfig.outcome === "written" && steps.codexConfig.text !== null) {
6579
+ lands(CODEX_CONFIG_REL2, steps.codexConfig.text, true);
6580
+ }
6373
6581
  if (steps.identity.after !== null)
6374
6582
  lands(PROJECT_IDENTITY_REL, steps.identity.after, steps.identity.action === "add");
6375
6583
  if (steps.pin !== null)
6376
6584
  file(steps.pin);
6377
6585
  file(steps.crew);
6586
+ steps.crewProfiles.forEach(file);
6378
6587
  if (steps.scan.text !== null)
6379
6588
  lands(BOOTSTRAP_STATE_REL, steps.scan.text, true);
6380
6589
  const mergeAfter = (planned) => planned.outcome === "written" ? planned.after : planned.before;
@@ -6432,6 +6641,7 @@ function entriesOf(steps, record2) {
6432
6641
  entries.push({ path: rel, action: there ? "change" : "add", kind: kindOf(rel), bytes: PENDING });
6433
6642
  };
6434
6643
  steps.commands.forEach(fromFile);
6644
+ steps.codexSkills.forEach(fromFile);
6435
6645
  fromFile(steps.vendored);
6436
6646
  const settings = steps.settings;
6437
6647
  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 });
@@ -6447,6 +6657,38 @@ function entriesOf(steps, record2) {
6447
6657
  entries.push({ path: MCP_REGISTRATION_REL, action: "leave", kind: "server-entry", bytes: null });
6448
6658
  }
6449
6659
  retired(RETIRED_MCP_HEADERS_HELPER_REL, steps.mcpHelper);
6660
+ const codexHooks = steps.codexHooks;
6661
+ if (codexHooks.outcome === "written") {
6662
+ entries.push({ path: CODEX_HOOKS_REL2, action: codexHooks.before === null ? "add" : "change", kind: "codex-checks", bytes: codexHooks.text });
6663
+ } else if (codexHooks.outcome === "current") {
6664
+ entries.push({ path: CODEX_HOOKS_REL2, action: "leave", kind: "codex-checks", bytes: null });
6665
+ } else {
6666
+ entries.push({ path: CODEX_HOOKS_REL2, action: "leave", kind: "codex-checks", bytes: null, yours: true });
6667
+ }
6668
+ const codexConfig = steps.codexConfig;
6669
+ switch (codexConfig.outcome) {
6670
+ case "written":
6671
+ entries.push({ path: CODEX_CONFIG_REL2, action: codexConfig.before === null ? "add" : "change", kind: "codex-server", bytes: codexConfig.text });
6672
+ break;
6673
+ case "pending":
6674
+ pending(CODEX_CONFIG_REL2);
6675
+ break;
6676
+ case "current":
6677
+ case "no-credential":
6678
+ entries.push({ path: CODEX_CONFIG_REL2, action: "leave", kind: "codex-server", bytes: null });
6679
+ break;
6680
+ case "conflict":
6681
+ case "link":
6682
+ case "refused-address":
6683
+ entries.push({
6684
+ path: CODEX_CONFIG_REL2,
6685
+ action: "leave",
6686
+ kind: "codex-server",
6687
+ bytes: null,
6688
+ ...existsSync3(join5(targetRepo, CODEX_CONFIG_REL2)) ? { yours: true } : {}
6689
+ });
6690
+ break;
6691
+ }
6450
6692
  merge(GITIGNORE_REL, steps.gitignore);
6451
6693
  merge(GITATTRIBUTES_REL, steps.gitattributes);
6452
6694
  const identity = steps.identity;
@@ -6469,6 +6711,7 @@ function entriesOf(steps, record2) {
6469
6711
  else
6470
6712
  fromFile(steps.pin);
6471
6713
  fromFile(steps.crew);
6714
+ steps.crewProfiles.forEach(fromFile);
6472
6715
  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 });
6473
6716
  if (record2 === null)
6474
6717
  pending(INSTALL_MANIFEST_REL);
@@ -6509,6 +6752,8 @@ function applyInstallPlan(plan) {
6509
6752
  }
6510
6753
  for (const command of steps.commands)
6511
6754
  putPlannedFile(targetRepo, command);
6755
+ for (const skill of steps.codexSkills)
6756
+ putPlannedFile(targetRepo, skill);
6512
6757
  putPlannedFile(targetRepo, steps.vendored);
6513
6758
  putPlannedFile(targetRepo, steps.settings.file);
6514
6759
  for (const hook of steps.hooks)
@@ -6516,6 +6761,8 @@ function applyInstallPlan(plan) {
6516
6761
  const ciStanza = steps.ciStanza === "removed" ? removePlannedRetiredFile(targetRepo, LEGACY_CI_STANZA_REL) : steps.ciStanza;
6517
6762
  putPlannedMcp(targetRepo, steps.mcp);
6518
6763
  const mcpHelper = steps.mcpHelper === "removed" ? removePlannedRetiredFile(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL) : steps.mcpHelper;
6764
+ putPlannedCodexHooks(targetRepo, steps.codexHooks);
6765
+ putPlannedCodexConfig(targetRepo, steps.codexConfig);
6519
6766
  const gitignore = putPlannedGitignore(targetRepo, steps.gitignore);
6520
6767
  const gitignorePath = join5(targetRepo, GITIGNORE_REL);
6521
6768
  const gitignoreAfter = existsSync3(gitignorePath) ? readFileSync5(gitignorePath, "utf-8") : null;
@@ -6528,6 +6775,8 @@ function applyInstallPlan(plan) {
6528
6775
  const legacy = applyLegacyMigration(targetRepo, migrateLegacyEnvLocal(targetRepo, stored));
6529
6776
  writeBundlePin(targetRepo, pin, []);
6530
6777
  putPlannedFile(targetRepo, steps.crew);
6778
+ for (const profile of steps.crewProfiles)
6779
+ putPlannedFile(targetRepo, profile);
6531
6780
  if (steps.scan.text !== null)
6532
6781
  writeBootstrapState(targetRepo, steps.scan.text);
6533
6782
  const { report, installManifest, manifestText } = accountInstall(steps, {
@@ -6557,9 +6806,15 @@ function applyInstallPlan(plan) {
6557
6806
  replacedPaths: report.replacedPaths,
6558
6807
  bootstrapScanRan: steps.scan.text !== null,
6559
6808
  legacyCredentialKept: legacy.outcome === "kept" ? { keys: legacy.unbacked, missing: legacy.missingForAdoption } : null,
6560
- notShared
6809
+ notShared,
6810
+ codex: { hooks: steps.codexHooks.outcome, config: appliedCodexConfig(steps.codexConfig.outcome) }
6561
6811
  };
6562
6812
  }
6813
+ function appliedCodexConfig(outcome) {
6814
+ if (outcome === "pending")
6815
+ throw new Error("[halfcycle] a preview cannot be applied: it is still waiting for the service.");
6816
+ return outcome;
6817
+ }
6563
6818
 
6564
6819
  // dist/mcp-endpoint.js
6565
6820
  var MCP_ENDPOINT_PATH = "/mcp";
@@ -6570,8 +6825,8 @@ function mcpEndpointUrl(origin) {
6570
6825
  // dist/node-floor.js
6571
6826
  var NODE_FLOOR_MAJOR = 18;
6572
6827
  var NODE_FLOOR_SENTENCE_HEAD = `Halfcycle needs Node ${NODE_FLOOR_MAJOR} or newer; you have `;
6573
- function nodeFloorProgram(guarded) {
6574
- return `if(+process.versions.node.split('.')[0]<${NODE_FLOOR_MAJOR}){console.error('${NODE_FLOOR_SENTENCE_HEAD}'+process.versions.node);process.exitCode=1}else{${guarded}}`;
6828
+ function nodeFloorProgram(guarded, belowFloor = "process.exitCode=1") {
6829
+ return `if(+process.versions.node.split('.')[0]<${NODE_FLOOR_MAJOR}){console.error('${NODE_FLOOR_SENTENCE_HEAD}'+process.versions.node);` + belowFloor + `}else{${guarded}}`;
6575
6830
  }
6576
6831
 
6577
6832
  // dist/merge-settings.js
@@ -6618,6 +6873,75 @@ function mergeSettings(existing, generated, retiredOurs, statusLine) {
6618
6873
  }
6619
6874
  return merged;
6620
6875
  }
6876
+ function isPlainRecord(value) {
6877
+ return value !== null && typeof value === "object" && !Array.isArray(value);
6878
+ }
6879
+ function mergeCodexHooks(existing, generated, ours) {
6880
+ const existingHooks = isPlainRecord(existing["hooks"]) ? existing["hooks"] : {};
6881
+ const events = [
6882
+ ...Object.keys(existingHooks),
6883
+ ...Object.keys(generated.hooks).filter((event) => !(event in existingHooks))
6884
+ ];
6885
+ const mergedHooks = {};
6886
+ for (const event of events) {
6887
+ const rows = generated.hooks[event] ?? [];
6888
+ const current = existingHooks[event];
6889
+ if (current === void 0) {
6890
+ mergedHooks[event] = rows.map(cloneGroup);
6891
+ continue;
6892
+ }
6893
+ if (!Array.isArray(current)) {
6894
+ mergedHooks[event] = current;
6895
+ continue;
6896
+ }
6897
+ const wanted = new Map(rows.map((row) => [row.hooks[0].command, row]));
6898
+ const placed = /* @__PURE__ */ new Set();
6899
+ const next = [];
6900
+ for (const group of current) {
6901
+ if (!isPlainRecord(group) || !Array.isArray(group["hooks"]) || !isHalfcycleEntry({ hooks: group["hooks"] }, ours)) {
6902
+ next.push(group);
6903
+ continue;
6904
+ }
6905
+ const hooks = group["hooks"];
6906
+ if (hooks.length === 1) {
6907
+ const command = hooks[0].command;
6908
+ const row = wanted.get(command);
6909
+ if (row !== void 0 && !placed.has(command)) {
6910
+ placed.add(command);
6911
+ next.push(cloneGroup(row));
6912
+ }
6913
+ continue;
6914
+ }
6915
+ const kept = [];
6916
+ for (const hook of hooks) {
6917
+ if (!isHalfcycleCommand(hook, ours)) {
6918
+ kept.push(hook);
6919
+ continue;
6920
+ }
6921
+ const command = hook.command;
6922
+ const row = wanted.get(command);
6923
+ if (row !== void 0 && !placed.has(command) && group["matcher"] === row.matcher) {
6924
+ placed.add(command);
6925
+ kept.push({ ...row.hooks[0] });
6926
+ }
6927
+ }
6928
+ if (kept.length > 0)
6929
+ next.push({ ...group, hooks: kept });
6930
+ }
6931
+ for (const row of rows) {
6932
+ if (!placed.has(row.hooks[0].command))
6933
+ next.push(cloneGroup(row));
6934
+ }
6935
+ mergedHooks[event] = next;
6936
+ }
6937
+ return { ...existing, hooks: mergedHooks };
6938
+ }
6939
+ function cloneGroup(group) {
6940
+ return {
6941
+ ...group.matcher === void 0 ? {} : { matcher: group.matcher },
6942
+ hooks: group.hooks.map((hook) => ({ ...hook }))
6943
+ };
6944
+ }
6621
6945
  function mergePermissions(existing, generated, retiredOurs) {
6622
6946
  if (existing === void 0 && generated === void 0)
6623
6947
  return void 0;
@@ -6640,6 +6964,168 @@ function mergePermissions(existing, generated, retiredOurs) {
6640
6964
  return result;
6641
6965
  }
6642
6966
 
6967
+ // dist/toml-lines.js
6968
+ var BARE_KEY_CHAR = /[A-Za-z0-9_-]/;
6969
+ var Unreadable = class extends Error {
6970
+ };
6971
+ function skipBlank(line, i) {
6972
+ while (i < line.length && (line[i] === " " || line[i] === " "))
6973
+ i += 1;
6974
+ return i;
6975
+ }
6976
+ function readKey(line, start) {
6977
+ const segments = [];
6978
+ let i = start;
6979
+ for (; ; ) {
6980
+ i = skipBlank(line, i);
6981
+ const c = line[i];
6982
+ if (c === '"') {
6983
+ const close = line.indexOf('"', i + 1);
6984
+ if (close < 0)
6985
+ throw new Unreadable();
6986
+ const segment = line.slice(i + 1, close);
6987
+ if (segment.includes("\\"))
6988
+ throw new Unreadable();
6989
+ segments.push(segment);
6990
+ i = close + 1;
6991
+ } else if (c === "'") {
6992
+ const close = line.indexOf("'", i + 1);
6993
+ if (close < 0)
6994
+ throw new Unreadable();
6995
+ segments.push(line.slice(i + 1, close));
6996
+ i = close + 1;
6997
+ } else {
6998
+ let j = i;
6999
+ while (j < line.length && BARE_KEY_CHAR.test(line[j]))
7000
+ j += 1;
7001
+ if (j === i)
7002
+ throw new Unreadable();
7003
+ segments.push(line.slice(i, j));
7004
+ i = j;
7005
+ }
7006
+ i = skipBlank(line, i);
7007
+ if (line[i] !== ".")
7008
+ return { segments, end: i };
7009
+ i += 1;
7010
+ }
7011
+ }
7012
+ function readValue(line, start, state) {
7013
+ let i = start;
7014
+ while (i < line.length) {
7015
+ if (state.multiline !== null) {
7016
+ const delimiter3 = state.multiline;
7017
+ const escapes = delimiter3 === '"""';
7018
+ while (i < line.length) {
7019
+ if (escapes && line[i] === "\\") {
7020
+ i += 2;
7021
+ continue;
7022
+ }
7023
+ if (line.startsWith(delimiter3, i)) {
7024
+ i += 3;
7025
+ let extra = 0;
7026
+ while (extra < 2 && line[i] === delimiter3[0]) {
7027
+ i += 1;
7028
+ extra += 1;
7029
+ }
7030
+ state.multiline = null;
7031
+ break;
7032
+ }
7033
+ i += 1;
7034
+ }
7035
+ continue;
7036
+ }
7037
+ const c = line[i];
7038
+ if (c === "#")
7039
+ return;
7040
+ if (c === '"' || c === "'") {
7041
+ const triple = c.repeat(3);
7042
+ if (line.startsWith(triple, i)) {
7043
+ state.multiline = triple;
7044
+ i += 3;
7045
+ continue;
7046
+ }
7047
+ let j = i + 1;
7048
+ for (; ; ) {
7049
+ if (j >= line.length)
7050
+ throw new Unreadable();
7051
+ if (c === '"' && line[j] === "\\") {
7052
+ j += 2;
7053
+ continue;
7054
+ }
7055
+ if (line[j] === c)
7056
+ break;
7057
+ j += 1;
7058
+ }
7059
+ i = j + 1;
7060
+ continue;
7061
+ }
7062
+ if (c === "[" || c === "{")
7063
+ state.depth += 1;
7064
+ else if (c === "]" || c === "}") {
7065
+ state.depth -= 1;
7066
+ if (state.depth < 0)
7067
+ throw new Unreadable();
7068
+ }
7069
+ i += 1;
7070
+ }
7071
+ }
7072
+ function scanTomlStatements(lines, skip) {
7073
+ const statements = [];
7074
+ const state = { multiline: null, depth: 0 };
7075
+ let table = [];
7076
+ let index = 0;
7077
+ try {
7078
+ for (index = 0; index < lines.length; index += 1) {
7079
+ const inValue = state.multiline !== null || state.depth > 0;
7080
+ if (skip !== void 0 && index === skip.from) {
7081
+ if (inValue)
7082
+ throw new Unreadable();
7083
+ table = skip.tableAfter;
7084
+ index = skip.to;
7085
+ continue;
7086
+ }
7087
+ let line = lines[index];
7088
+ if (index === 0 && line.startsWith("\uFEFF"))
7089
+ line = line.slice(1);
7090
+ if (inValue) {
7091
+ readValue(line, 0, state);
7092
+ continue;
7093
+ }
7094
+ let i = skipBlank(line, 0);
7095
+ if (i >= line.length || line[i] === "#")
7096
+ continue;
7097
+ if (line[i] === "[") {
7098
+ const array = line[i + 1] === "[";
7099
+ i += array ? 2 : 1;
7100
+ const key2 = readKey(line, i);
7101
+ i = key2.end;
7102
+ if (line[i] !== "]" || array && line[i + 1] !== "]")
7103
+ throw new Unreadable();
7104
+ i = skipBlank(line, i + (array ? 2 : 1));
7105
+ if (i < line.length && line[i] !== "#")
7106
+ throw new Unreadable();
7107
+ table = key2.segments;
7108
+ statements.push({ line: index, kind: array ? "array-table" : "table", path: key2.segments });
7109
+ continue;
7110
+ }
7111
+ const key = readKey(line, i);
7112
+ if (line[key.end] !== "=")
7113
+ throw new Unreadable();
7114
+ statements.push({ line: index, kind: "key", path: [...table, ...key.segments] });
7115
+ readValue(line, key.end + 1, state);
7116
+ }
7117
+ if (state.multiline !== null || state.depth > 0) {
7118
+ index = lines.length - 1;
7119
+ throw new Unreadable();
7120
+ }
7121
+ } catch (err) {
7122
+ if (err instanceof Unreadable)
7123
+ return { ok: false, line: index };
7124
+ throw err;
7125
+ }
7126
+ return { ok: true, statements };
7127
+ }
7128
+
6643
7129
  // dist/install.js
6644
7130
  function renderNotShared(finding) {
6645
7131
  const rule = `rule "${finding.pattern}" in ${finding.source}, line ${finding.line}`;
@@ -6649,6 +7135,97 @@ function renderNotShared(finding) {
6649
7135
  return `[halfcycle] Not shared: git ignores ${finding.path} (${rule}) and ${finding.others} other file${finding.others === 1 ? "" : "s"} Halfcycle set up here, so they will not reach anyone else who gets a copy of this project.`;
6650
7136
  }
6651
7137
  var ZONE_B_DIR = ".workbench";
7138
+ var CODEX_HOOKS_REL2 = AGENT_PROFILES.codex.settingsFiles[0];
7139
+ var CODEX_CONFIG_REL2 = AGENT_PROFILES.codex.mcpRegistration.file;
7140
+ var CLAUDE_AGENTS_DIR = ".claude/agents";
7141
+ var CODEX_AGENTS_DIR = ".codex/agents";
7142
+ var CREW_CALLSIGN_PATTERN = /^[A-Z][A-Z0-9]*$/;
7143
+ function crewProfileRel(agent, callsign) {
7144
+ if (!CREW_CALLSIGN_PATTERN.test(callsign)) {
7145
+ throw new Error(`[halfcycle] crew callsign "${callsign}" is not a valid profile name (must match ^[A-Z][A-Z0-9]*$).`);
7146
+ }
7147
+ return agent === "claude-code" ? `${CLAUDE_AGENTS_DIR}/${callsign}.md` : `${CODEX_AGENTS_DIR}/${callsign.toLowerCase()}.toml`;
7148
+ }
7149
+ function crewProfileTarget(targetRepo, rel) {
7150
+ const folder = rel.startsWith(`${CLAUDE_AGENTS_DIR}/`) ? CLAUDE_AGENTS_DIR : CODEX_AGENTS_DIR;
7151
+ const target = resolve(join6(targetRepo, rel));
7152
+ if (dirname3(target) !== resolve(targetRepo, folder)) {
7153
+ throw new Error(`[halfcycle] crew profile "${rel}" does not sit directly in ${folder}.`);
7154
+ }
7155
+ return target;
7156
+ }
7157
+ var CREW_ROLE_WORDS = {
7158
+ "business-analyst": "business analyst",
7159
+ architect: "architect",
7160
+ "spec-writer": "spec writer",
7161
+ builder: "builder",
7162
+ "context-docs": "context and docs writer",
7163
+ "spec-reviewer": "spec reviewer",
7164
+ "code-test-reviewer": "code and test reviewer",
7165
+ "delivery-lead": "delivery lead",
7166
+ "quality-lead": "quality lead"
7167
+ };
7168
+ var CREW_TIER_WORDS = {
7169
+ deep: "You think carefully and deeply before you act, and take the time the work needs.",
7170
+ standard: "You think each step through at a steady pace before you act.",
7171
+ fast: "You work quickly on clear, well-defined work, and say so when a task needs more thought."
7172
+ };
7173
+ 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.";
7174
+ var CREW_REVIEWER_TOOLS = "Read, Grep, Glob, Bash";
7175
+ var CREW_MEMBER_TOOLS = "Read, Edit, Write, Bash, Grep, Glob";
7176
+ function crewProfileBody(role, group, tier) {
7177
+ const lines = [`You are a ${CREW_ROLE_WORDS[role]} on this project's build crew.`, CREW_TIER_WORDS[tier]];
7178
+ if (group === "reviewer")
7179
+ lines.push(CREW_REVIEWER_WORDS);
7180
+ return `${lines.join("\n")}
7181
+ `;
7182
+ }
7183
+ function crewProfileDescription(member) {
7184
+ return `${member.callsign}, ${CREW_ROLE_WORDS[member.role]} (${member.discipline}), a member of this project's build crew.`;
7185
+ }
7186
+ function plainYamlValue(value) {
7187
+ if (/[\r\n]|: | #|^[-?:,[\]{}#&*!|>'"%@`\s]|\s$/.test(value)) {
7188
+ throw new Error(`[halfcycle] a crew profile value cannot be written as plain text: ${JSON.stringify(value)}`);
7189
+ }
7190
+ return value;
7191
+ }
7192
+ function claudeCrewProfile(member) {
7193
+ const tools = member.group === "reviewer" ? CREW_REVIEWER_TOOLS : CREW_MEMBER_TOOLS;
7194
+ return [
7195
+ "---",
7196
+ `name: ${plainYamlValue(member.callsign)}`,
7197
+ `description: ${plainYamlValue(crewProfileDescription(member))}`,
7198
+ `model: ${plainYamlValue(AGENT_PROFILES["claude-code"].tierModels[member.tier].model)}`,
7199
+ `tools: ${tools}`,
7200
+ "---",
7201
+ "",
7202
+ crewProfileBody(member.role, member.group, member.tier)
7203
+ ].join("\n");
7204
+ }
7205
+ function codexCrewProfile(member) {
7206
+ const { model, effort } = AGENT_PROFILES.codex.tierModels[member.tier];
7207
+ if (effort === void 0)
7208
+ throw new Error(`[halfcycle] Codex's ${member.tier} tier names no reasoning effort.`);
7209
+ const body = crewProfileBody(member.role, member.group, member.tier);
7210
+ if (/[\\"]|[\u0000-\u0009\u000b-\u001f\u007f]/.test(body)) {
7211
+ throw new Error("[halfcycle] a crew profile body holds a character a Codex role cannot carry as written.");
7212
+ }
7213
+ return [
7214
+ `name = ${tomlBasicString(member.callsign.toLowerCase())}`,
7215
+ `description = ${tomlBasicString(crewProfileDescription(member))}`,
7216
+ `model = ${tomlBasicString(model)}`,
7217
+ `model_reasoning_effort = ${tomlBasicString(effort)}`,
7218
+ `developer_instructions = """`,
7219
+ `${body}"""`,
7220
+ ""
7221
+ ].join("\n");
7222
+ }
7223
+ function renderCrewProfiles(roster = CREW_ROSTER) {
7224
+ return [
7225
+ ...roster.map((member) => ({ rel: crewProfileRel("claude-code", member.callsign), text: claudeCrewProfile(member) })),
7226
+ ...roster.filter((member) => member.group !== "reviewer").map((member) => ({ rel: crewProfileRel("codex", member.callsign), text: codexCrewProfile(member) }))
7227
+ ];
7228
+ }
6652
7229
  var __filename = fileURLToPath(import.meta.url);
6653
7230
  var __dirname = dirname3(__filename);
6654
7231
  function findBundleRoot(startDir) {
@@ -6700,13 +7277,23 @@ var WRITE_ALLOWLIST = [
6700
7277
  "test/fixtures/captured",
6701
7278
  MCP_REGISTRATION_REL,
6702
7279
  ".gitattributes",
6703
- ZONE_B_DIR
7280
+ ZONE_B_DIR,
7281
+ // Codex's twin of '.claude/commands' (`codex-install-target` AC4, T-14): one
7282
+ // `.agents/skills/<name>/SKILL.md` per command, same collision rule.
7283
+ ".agents/skills",
7284
+ // Codex's checks file, merged beside a developer's own entries.
7285
+ CODEX_HOOKS_REL2,
7286
+ // Codex's settings file: one marked block is added, and the rest of the file is left as it is.
7287
+ CODEX_CONFIG_REL2,
7288
+ // Each crew member's own profile, by its exact name; no other file in those folders is written.
7289
+ ...renderCrewProfiles().map((profile) => profile.rel)
6704
7290
  ];
7291
+ var CREW_PROFILE_RELS = new Set(renderCrewProfiles().map((p) => p.rel));
6705
7292
  function isAllowlisted(targetRelPath) {
6706
7293
  const normalised = targetRelPath.replace(/\\/g, "/");
6707
7294
  return WRITE_ALLOWLIST.some((prefix) => {
6708
7295
  const p = prefix.replace(/\\/g, "/");
6709
- return normalised === p || normalised.startsWith(p + "/");
7296
+ return normalised === p || !CREW_PROFILE_RELS.has(p) && normalised.startsWith(p + "/");
6710
7297
  });
6711
7298
  }
6712
7299
  var CREW_ROSTER_REL = ".halfcycle/crew.json";
@@ -6719,6 +7306,12 @@ var GITATTRIBUTES_REL = ".gitattributes";
6719
7306
  function commandStubRel(name) {
6720
7307
  return `.claude/commands/${name}.md`;
6721
7308
  }
7309
+ function skillRel(name) {
7310
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
7311
+ throw new Error(`[halfcycle] plugin.json command "${name}" is not a valid skill name (must match ^[a-z][a-z0-9-]*$).`);
7312
+ }
7313
+ return `.agents/skills/${name}/SKILL.md`;
7314
+ }
6722
7315
  var RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
6723
7316
  var RETIRED_WRITE_SET_FILES = [RETIRED_MCP_HEADERS_HELPER_REL];
6724
7317
  var RETIRED_DENY_PATTERNS = [
@@ -6737,6 +7330,9 @@ function closedWriteSet() {
6737
7330
  const commands = readPluginManifest().commands ?? [];
6738
7331
  const files = /* @__PURE__ */ new Set([
6739
7332
  ...commands.map((cmd) => commandStubRel(cmd.name)),
7333
+ // Codex's twin of the stubs (`codex-install-target` AC4, T-14), derived from the
7334
+ // same manifest array the stubs come from — one closed-set source for both.
7335
+ ...commands.map((cmd) => skillRel(cmd.name)),
6740
7336
  ...Object.keys(OWNED_GENERATED_HEADERS),
6741
7337
  VENDORED_BIN_REL,
6742
7338
  CREW_ROSTER_REL,
@@ -6745,9 +7341,12 @@ function closedWriteSet() {
6745
7341
  BOOTSTRAP_STATE_REL,
6746
7342
  CAPTURED_INDEX_REL,
6747
7343
  INSTALL_MANIFEST_REL,
7344
+ // The crew profiles (`crew-agent-profiles` AC6), from the same generator the
7345
+ // allowlist and the plan read, so uninstall's set and the writer's cannot differ.
7346
+ ...renderCrewProfiles().map((profile) => profile.rel),
6748
7347
  ...RETIRED_WRITE_SET_FILES
6749
7348
  ]);
6750
- const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL, GITATTRIBUTES_REL]);
7349
+ const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, CODEX_HOOKS_REL2, CODEX_CONFIG_REL2, GITIGNORE_REL, GITATTRIBUTES_REL]);
6751
7350
  const dirs = /* @__PURE__ */ new Set();
6752
7351
  for (const path of [...files, ...merged]) {
6753
7352
  for (let dir = posix2.dirname(path); dir !== "."; dir = posix2.dirname(dir))
@@ -6760,6 +7359,7 @@ function closedWriteSet() {
6760
7359
  dirs,
6761
7360
  ownedDirs,
6762
7361
  hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
7362
+ codexHookCommands: new Set(AGENT_PROFILES.codex.commands),
6763
7363
  denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
6764
7364
  settingsSchema: SETTINGS_SCHEMA,
6765
7365
  gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
@@ -6777,10 +7377,17 @@ function sha256OfWhatThisVersionWrites(commands) {
6777
7377
  if (existsSync4(src))
6778
7378
  out.set(rel, fileSha256(readFileSync6(src)));
6779
7379
  };
6780
- for (const cmd of commands)
6781
- fromFile(commandStubRel(cmd.name), join6(BUNDLE_ROOT, ".claude-plugin", cmd.path));
7380
+ for (const cmd of commands) {
7381
+ const srcFile = join6(BUNDLE_ROOT, ".claude-plugin", cmd.path);
7382
+ fromFile(commandStubRel(cmd.name), srcFile);
7383
+ if (existsSync4(srcFile)) {
7384
+ out.set(skillRel(cmd.name), fileSha256(Buffer.from(codexSkillContent(cmd.name, readFileSync6(srcFile, "utf-8")), "utf-8")));
7385
+ }
7386
+ }
6782
7387
  for (const [rel, content] of hookScriptsThisVersionWrites())
6783
7388
  out.set(rel, fileSha256(content));
7389
+ for (const profile of renderCrewProfiles())
7390
+ out.set(profile.rel, fileSha256(Buffer.from(profile.text, "utf-8")));
6784
7391
  fromFile(CAPTURED_INDEX_REL, capturedIndexScaffold());
6785
7392
  return out;
6786
7393
  }
@@ -6884,6 +7491,8 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
6884
7491
  const merged = [
6885
7492
  SETTINGS_REL,
6886
7493
  ...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
7494
+ ...manifest.codexHooks !== void 0 ? [CODEX_HOOKS_REL2] : [],
7495
+ ...manifest.codexConfig !== void 0 ? [CODEX_CONFIG_REL2] : [],
6887
7496
  ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : [],
6888
7497
  ...manifest.gitattributes !== void 0 && (manifest.gitattributes.created || manifest.gitattributes.appended.length > 0) ? [GITATTRIBUTES_REL] : []
6889
7498
  ];
@@ -6944,6 +7553,55 @@ function readPluginManifest() {
6944
7553
  const raw = readFileSync6(manifestPath, "utf-8");
6945
7554
  return JSON.parse(raw);
6946
7555
  }
7556
+ function parseCommandStub(raw, name) {
7557
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw);
7558
+ if (match === null) {
7559
+ throw new Error(`[halfcycle] commands/${name}.md has no frontmatter block; cannot build its Codex skill.`);
7560
+ }
7561
+ const [, frontmatter, body] = match;
7562
+ const desc = /^description:\s*(.+)$/m.exec(frontmatter);
7563
+ if (desc === null) {
7564
+ throw new Error(`[halfcycle] commands/${name}.md's frontmatter has no description; cannot build its Codex skill.`);
7565
+ }
7566
+ return { description: desc[1].trim(), body };
7567
+ }
7568
+ var TRUST_SENTENCE_WORDS = "Claude Code does not trust this folder yet, open it there and accept the workspace-trust prompt";
7569
+ var CODEX_TRUST_SENTENCE = "Codex does not trust this folder yet, open it there and trust it when asked";
7570
+ function trustSentencePattern() {
7571
+ const words = TRUST_SENTENCE_WORDS.split(" ").map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
7572
+ return new RegExp(words.join("(?: |\\n> )"));
7573
+ }
7574
+ var MCP_RECONNECT_WORDS = "run `/mcp` and reconnect it";
7575
+ var CODEX_MCP_FALLBACK = "start a new Codex session";
7576
+ function mcpReconnectPattern() {
7577
+ const words = MCP_RECONNECT_WORDS.split(" ").map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
7578
+ return new RegExp(words.join("(?: |\\n> )"));
7579
+ }
7580
+ function codexSkillBody(name, sourceBody) {
7581
+ const slashForm = "`/" + name + "`";
7582
+ if (!sourceBody.includes(slashForm)) {
7583
+ throw new Error(`[halfcycle] commands/${name}.md never names itself as ${slashForm}; cannot build its Codex skill.`);
7584
+ }
7585
+ const pattern = trustSentencePattern();
7586
+ if (!pattern.test(sourceBody)) {
7587
+ throw new Error(`[halfcycle] commands/${name}.md is missing the Claude Code trust sentence; cannot build its Codex skill.`);
7588
+ }
7589
+ const bareName = "`" + name + "`";
7590
+ const transformed = sourceBody.split(slashForm).join(bareName).replace(trustSentencePattern(), CODEX_TRUST_SENTENCE).replace(mcpReconnectPattern(), CODEX_MCP_FALLBACK);
7591
+ if (transformed.includes("/mcp")) {
7592
+ throw new Error(`[halfcycle] commands/${name}.md's Codex skill still names /mcp; cannot build.`);
7593
+ }
7594
+ return transformed;
7595
+ }
7596
+ function codexSkillContent(name, sourceRaw) {
7597
+ const { description, body } = parseCommandStub(sourceRaw, name);
7598
+ const transformed = codexSkillBody(name, body);
7599
+ return `---
7600
+ name: ${name}
7601
+ description: ${description}
7602
+ ---
7603
+ ${transformed}`;
7604
+ }
6947
7605
  function planManifestCommands(manifest, targetRepo, previous, writeSet) {
6948
7606
  const recordedFiles = new Map((previous?.files ?? []).filter((file) => writeSet.files.has(file.path)).map((file) => [file.path, file]));
6949
7607
  return (manifest.commands ?? []).map((cmd) => {
@@ -7122,6 +7780,16 @@ function generateSettingsJson() {
7122
7780
  };
7123
7781
  return JSON.stringify(settings, null, 2) + "\n";
7124
7782
  }
7783
+ function codexHooksValue() {
7784
+ const hooks = {};
7785
+ for (const { event, matcher, timeout, command, commandWindows } of AGENT_PROFILES.codex.wiring) {
7786
+ (hooks[event] ??= []).push({
7787
+ ...matcher === null ? {} : { matcher },
7788
+ hooks: [{ type: "command", command, timeout, ...commandWindows === void 0 ? {} : { commandWindows } }]
7789
+ });
7790
+ }
7791
+ return { hooks };
7792
+ }
7125
7793
  var GENERATED_GUARD_RUNNER_HEADER = "# Governance hook wrapper \u2014 generated by";
7126
7794
  function generateGuardRunnerWrapper(preFilter = resolvePreFilterShell()) {
7127
7795
  return `#!/usr/bin/env bash
@@ -7174,9 +7842,11 @@ ${preFilter.trimEnd()}
7174
7842
  # Before a tool runs, on a bash new enough for the cheap pass: read the call
7175
7843
  # once; when no risky action can be in it, answer here (the pass writes the
7176
7844
  # time line) and do not start Node. Otherwise hand the same input on below.
7845
+ # The pass is given the hook's own arguments, so it knows which coding agent's
7846
+ # input it reads, and hands on any agent's call it has no test for.
7177
7847
  if [ "$1" = hook ] && [ "$2" = pre-tool-use ] && [ "\${BASH_VERSINFO[0]:-0}" -ge 5 ]; then
7178
7848
  hc_input=$(cat 2>/dev/null) || IFS= read -r -d '' hc_input
7179
- ${PRE_FILTER_FUNCTION} && exit 0
7849
+ ${PRE_FILTER_FUNCTION} "$@" && exit 0
7180
7850
  node -e "${nodeFloorProgram("")}" || {
7181
7851
  printf '%s' "$hc_input" | halfcycle_pre_tool_use_fallback
7182
7852
  exit 0
@@ -7416,7 +8086,7 @@ exit 0
7416
8086
  var GENERATED_USER_PROMPT_SCOPE_HEADER = "# Typed scope approval \u2014 generated by";
7417
8087
  function generateUserPromptScopeHook() {
7418
8088
  return `#!/usr/bin/env bash
7419
- ${GENERATED_USER_PROMPT_SCOPE_HEADER} the Halfcycle installer, now also for the close review.
8089
+ ${GENERATED_USER_PROMPT_SCOPE_HEADER} the Halfcycle installer, now also for the close review and Codex.
7420
8090
  #
7421
8091
  # Lets the owner of this project approve a phase's scope, or send it back,
7422
8092
  # decide one part of a phase's close review, or turn builds running on their
@@ -7424,20 +8094,25 @@ ${GENERATED_USER_PROMPT_SCOPE_HEADER} the Halfcycle installer, now also for the
7424
8094
  # Code session: "approve <phase>", "send back <phase>: <note>", "accept
7425
8095
  # <phase>/<part>", "question <phase>/<part>: <words>", "carry <phase>/<part>:
7426
8096
  # <words>", "drop <phase>/<part>: <words>", "reject <phase>/<task>: <words>",
7427
- # "autonomy on" or "autonomy off".
8097
+ # "autonomy on" or "autonomy off". Also lets the person who is typing let one
8098
+ # step Halfcycle paused run once, by typing "allow" as the whole prompt.
7428
8099
  #
7429
8100
  # AN ORDINARY PROMPT STARTS NOTHING. This script reads the hook input once and
7430
8101
  # looks only at the start of the prompt's value. Unless the prompt begins, after
7431
8102
  # any whitespace (typed, or escaped in the input), with "approve ", "send back ",
7432
- # "accept ", "question ", "carry ", "drop ", "reject " or "autonomy ", in any case, it exits
7433
- # 0 with no output, and Node never starts. A quote inside any other field arrives
8103
+ # "accept ", "question ", "carry ", "drop ", "reject " or "autonomy ", in any case,
8104
+ # or is "allow" and nothing else but whitespace, it exits 0 with no output, and
8105
+ # Node never starts. A quote inside any other field arrives
7434
8106
  # escaped, so no other field (the folder the session is in, for one) can read as
7435
8107
  # the start of the prompt.
7436
8108
  #
7437
8109
  # Otherwise it finds the project root from its own location, never from the
7438
8110
  # folder the session has moved to, and hands the same input to the guard
7439
8111
  # wrapper beside it. That decides whether the line counts and prints at most
7440
- # one plain line. Nothing here ever blocks a prompt.
8112
+ # one line. Nothing here ever blocks a prompt. Any arguments this script was
8113
+ # given (a Codex row passes its agent's name) are handed on after the verb; in
8114
+ # Codex a typed line never counts, because Codex cannot tell a person's line
8115
+ # from one a program sent.
7441
8116
  #
7442
8117
  # FAILS OPEN up to that hand-over: any error before it exits 0 with no output.
7443
8118
  # After it, the wrapper's own exit stands.
@@ -7453,7 +8128,7 @@ IFS= read -r -d '' HC_INPUT || true
7453
8128
  # \\u escape, or as the UTF-8 bytes of a Unicode space. Compared byte for byte.
7454
8129
  HC_LEAD='[[:space:]]|\\\\[nrtf]|\\\\u(000[9a-d]|0020|00a0|1680|200[0-9a]|202[89f]|205f|3000|feff)'
7455
8130
  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'
7456
- 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
8131
+ 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
7457
8132
  exit 0
7458
8133
  fi
7459
8134
 
@@ -7463,16 +8138,17 @@ export CLAUDE_PROJECT_DIR="$HC_PROJECT_ROOT"
7463
8138
 
7464
8139
  trap - ERR
7465
8140
  set +e
7466
- exec bash "$HC_PROJECT_ROOT/.claude/hooks/guard-runner.sh" hook user-prompt-submit <<<"$HC_INPUT"
8141
+ exec bash "$HC_PROJECT_ROOT/.claude/hooks/guard-runner.sh" hook user-prompt-submit <<<"$HC_INPUT" "$@"
7467
8142
  `;
7468
8143
  }
7469
8144
  var GENERATED_USER_PROMPT_FINDINGS_HEADER = "# Findings hand-over \u2014 generated by";
7470
8145
  function generateUserPromptFindingsHook() {
7471
8146
  return `#!/usr/bin/env bash
7472
- ${GENERATED_USER_PROMPT_FINDINGS_HEADER} the Halfcycle installer.
8147
+ ${GENERATED_USER_PROMPT_FINDINGS_HEADER} the Halfcycle installer, now also for Codex.
7473
8148
  #
7474
8149
  # At the person's next message, before the agent starts, hands it the details of
7475
- # whatever Halfcycle's checks found at an earlier turn end, once.
8150
+ # whatever Halfcycle's checks found at an earlier turn end, once. Any arguments
8151
+ # this script was given (a Codex row passes its agent's name) are handed on.
7476
8152
  #
7477
8153
  # AN ORDINARY PROMPT STARTS NOTHING. With no findings file for this session, this
7478
8154
  # script reads its session id with a bash regex (no node) and exits 0 with no
@@ -7509,7 +8185,7 @@ export CLAUDE_PROJECT_DIR="$HC_PROJECT_ROOT"
7509
8185
 
7510
8186
  trap - ERR
7511
8187
  set +e
7512
- exec bash "$HC_PROJECT_ROOT/.claude/hooks/guard-runner.sh" hook user-prompt-findings <<<"$HC_INPUT"
8188
+ exec bash "$HC_PROJECT_ROOT/.claude/hooks/guard-runner.sh" hook user-prompt-findings <<<"$HC_INPUT" "$@"
7513
8189
  `;
7514
8190
  }
7515
8191
  var CI_STANZA_KNOWN_HEADER_HASHES = [
@@ -7588,6 +8264,160 @@ var MCP_HEADERS_HELPER_COMMAND = `node -e "${nodeFloorProgram([
7588
8264
  `console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
7589
8265
  `process.exit(1)`
7590
8266
  ].join(""))}"`;
8267
+ var CODEX_ADDRESS_PATTERN = /^https?:\/\/[A-Za-z0-9.-]+(:[0-9]{1,5})?(\/[A-Za-z0-9._-]+)*\/mcp$/;
8268
+ function codexAddressAllowed(address) {
8269
+ return CODEX_ADDRESS_PATTERN.test(address);
8270
+ }
8271
+ var CODEX_NO_CREDENTIAL_STATEMENT = `console.log(JSON.stringify({Authorization:'Bearer ${NO_CREDENTIAL_PLACEHOLDER}'}))`;
8272
+ function codexMcpHeadersHelperCommand(address) {
8273
+ if (!codexAddressAllowed(address))
8274
+ return null;
8275
+ return `node -e "${nodeFloorProgram([
8276
+ `let o=0;`,
8277
+ `const n=function(){if(o)return;o=1;${CODEX_NO_CREDENTIAL_STATEMENT}};`,
8278
+ `try{`,
8279
+ `const p=require('path'),f=require('fs'),c=require('child_process');`,
8280
+ `let d=process.cwd(),b='';`,
8281
+ `for(;;){`,
8282
+ `const t=p.join(d,'${VENDORED_BIN_REL}');`,
8283
+ `if(f.existsSync(t)){b=t;break}`,
8284
+ `const u=p.dirname(d);`,
8285
+ `if(u===d)break;`,
8286
+ `d=u}`,
8287
+ `if(b===''){`,
8288
+ `console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
8289
+ `n()}else{`,
8290
+ `const r=c.spawnSync(process.execPath,[b,'mcp-headers','--url','${address}'],{stdio:['ignore','pipe','inherit']});`,
8291
+ `if(r.status===0&&r.stdout&&r.stdout.length){o=1;process.stdout.write(r.stdout)}else{n()}}`,
8292
+ `}catch(e){n()}`
8293
+ ].join(""), CODEX_NO_CREDENTIAL_STATEMENT)}"`;
8294
+ }
8295
+ var CODEX_CONFIG_OPEN_MARKER = "# >>> Halfcycle \u2014 generated by the Halfcycle installer; replaced on every install";
8296
+ var CODEX_CONFIG_CLOSE_MARKER = "# <<< Halfcycle";
8297
+ function tomlBasicString(value) {
8298
+ if (/[\u0000-\u001f\u007f]/.test(value)) {
8299
+ throw new Error("[halfcycle] a value for Codex's settings holds a control character.");
8300
+ }
8301
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
8302
+ }
8303
+ function codexServerPath() {
8304
+ const { serversKey, serverKey } = AGENT_PROFILES.codex.mcpRegistration;
8305
+ return [serversKey, serverKey];
8306
+ }
8307
+ function generateCodexConfigBlock(address) {
8308
+ return codexConfigBlockWith(address, codexMcpHeadersHelperCommand);
8309
+ }
8310
+ function codexConfigBlockWith(address, helperFor) {
8311
+ if (!codexAddressAllowed(address))
8312
+ return null;
8313
+ const helper = helperFor(address);
8314
+ if (helper === null)
8315
+ return null;
8316
+ const [serversKey, serverKey] = codexServerPath();
8317
+ return [
8318
+ CODEX_CONFIG_OPEN_MARKER,
8319
+ `[${serversKey}.${serverKey}]`,
8320
+ `url = ${tomlBasicString(address)}`,
8321
+ `http_headers_helper = ${tomlBasicString(helper)}`,
8322
+ CODEX_CONFIG_CLOSE_MARKER
8323
+ ].join("\n");
8324
+ }
8325
+ var RETIRED_CODEX_MCP_HEADERS_HELPERS = [];
8326
+ function regeneratesCodexBlock(block, retired = RETIRED_CODEX_MCP_HEADERS_HELPERS) {
8327
+ const lines = linesOf(block);
8328
+ while (lines.length > 0 && lines[lines.length - 1] === "")
8329
+ lines.pop();
8330
+ const url = /^url = "((?:[^"\\]|\\.)*)"$/.exec(lines[2] ?? "");
8331
+ if (url === null)
8332
+ return false;
8333
+ const address = url[1].replace(/\\(["\\])/g, "$1");
8334
+ const text = lines.join("\n");
8335
+ return [codexMcpHeadersHelperCommand, ...retired].some((helperFor) => codexConfigBlockWith(address, helperFor) === text);
8336
+ }
8337
+ function locateCodexBlock(lines) {
8338
+ const opens = [];
8339
+ const closes = [];
8340
+ for (const [index, line] of lines.entries()) {
8341
+ const trimmed = line.trim();
8342
+ if (trimmed.startsWith("# >>> Halfcycle")) {
8343
+ if (line !== CODEX_CONFIG_OPEN_MARKER)
8344
+ return { kind: "broken" };
8345
+ opens.push(index);
8346
+ } else if (trimmed.startsWith("# <<< Halfcycle")) {
8347
+ if (line !== CODEX_CONFIG_CLOSE_MARKER)
8348
+ return { kind: "broken" };
8349
+ closes.push(index);
8350
+ }
8351
+ }
8352
+ if (opens.length === 0 && closes.length === 0)
8353
+ return { kind: "none" };
8354
+ if (opens.length === 1 && closes.length === 1 && opens[0] < closes[0]) {
8355
+ return { kind: "one", open: opens[0], close: closes[0] };
8356
+ }
8357
+ return { kind: "broken" };
8358
+ }
8359
+ function definesCodexServer(statement, servers, server) {
8360
+ const [first, second] = statement.path;
8361
+ if (first !== servers)
8362
+ return false;
8363
+ if (statement.kind === "table")
8364
+ return second === server;
8365
+ return statement.path.length === 1 || second === server;
8366
+ }
8367
+ function linesOf(text) {
8368
+ return text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
8369
+ }
8370
+ function codexConfigConflict(existing) {
8371
+ const lines = linesOf(existing);
8372
+ const at = locateCodexBlock(lines);
8373
+ if (at.kind === "broken")
8374
+ return "broken-markers";
8375
+ const [servers, server] = codexServerPath();
8376
+ const scan = scanTomlStatements(lines, at.kind === "one" ? { from: at.open, to: at.close, tableAfter: [servers, server] } : void 0);
8377
+ if (!scan.ok)
8378
+ return "unreadable";
8379
+ return scan.statements.some((statement) => definesCodexServer(statement, servers, server)) ? "server-named" : null;
8380
+ }
8381
+ function holdsCodexBlock(existing) {
8382
+ return locateCodexBlock(linesOf(existing)).kind === "one";
8383
+ }
8384
+ function codexBlockSpan(text) {
8385
+ const at = locateCodexBlock(linesOf(text));
8386
+ if (at.kind !== "one")
8387
+ return at.kind;
8388
+ const raw = text.split("\n");
8389
+ let start = 0;
8390
+ for (let i = 0; i < at.open; i += 1)
8391
+ start += raw[i].length + 1;
8392
+ let end = start;
8393
+ for (let i = at.open; i <= at.close; i += 1)
8394
+ end += raw[i].length + 1;
8395
+ return { start, end: Math.min(end, text.length) };
8396
+ }
8397
+ function reconcileCodexConfig(existing, block) {
8398
+ if (existing === null || existing === "") {
8399
+ const text2 = `${block}
8400
+ `;
8401
+ return { kind: "new", text: text2, appended: existing === null ? null : text2, block: text2 };
8402
+ }
8403
+ const conflict = codexConfigConflict(existing);
8404
+ if (conflict !== null)
8405
+ return { kind: "conflict", reason: conflict };
8406
+ const eol = lineEndingOf(existing);
8407
+ const blockText = block.split("\n").join(eol);
8408
+ const span = codexBlockSpan(existing);
8409
+ if (span === "broken")
8410
+ return { kind: "conflict", reason: "broken-markers" };
8411
+ if (span === "none") {
8412
+ const appended = `${existing.endsWith("\n") ? "" : eol}${eol}${blockText}${eol}`;
8413
+ return { kind: "new", text: existing + appended, appended, block: `${blockText}${eol}` };
8414
+ }
8415
+ const old = existing.slice(span.start, span.end);
8416
+ const ending = old.endsWith("\r\n") ? "\r\n" : old.endsWith("\n") ? "\n" : "";
8417
+ const inFile = `${blockText}${ending}`;
8418
+ const text = existing.slice(0, span.start) + inFile + existing.slice(span.end);
8419
+ return text === existing ? { kind: "unchanged", block: inFile } : { kind: "new", text, appended: null, block: inFile };
8420
+ }
7591
8421
  var MCP_HEADERS_KNOWN_HEADER_HASHES = [
7592
8422
  "37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
7593
8423
  "6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
@@ -8002,6 +8832,21 @@ function planCommandStubs(targetRepo, previous, writeSet) {
8002
8832
  const manifest = readPluginManifest();
8003
8833
  return { version: manifest.version, commands: planManifestCommands(manifest, targetRepo, previous, writeSet) };
8004
8834
  }
8835
+ function planCodexSkills(targetRepo, previous, writeSet) {
8836
+ const manifest = readPluginManifest();
8837
+ const recordedFiles = new Map((previous?.files ?? []).filter((file) => writeSet.files.has(file.path)).map((file) => [file.path, file]));
8838
+ return (manifest.commands ?? []).map((cmd) => {
8839
+ const srcFile = join6(BUNDLE_ROOT, ".claude-plugin", cmd.path);
8840
+ if (!existsSync4(srcFile)) {
8841
+ 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.`);
8842
+ }
8843
+ const rel = skillRel(cmd.name);
8844
+ const content = codexSkillContent(cmd.name, readFileSync6(srcFile, "utf-8"));
8845
+ const recorded = recordedFiles.get(rel);
8846
+ const planned = decideCollisionSafe(join6(targetRepo, rel), targetRepo, content, (current) => bytesAreOurs(rel, current, recorded, writeSet));
8847
+ return { ...planned, editedCollided: planned.outcome === "collided" && recorded !== void 0 };
8848
+ });
8849
+ }
8005
8850
  function planVendoredBinary(targetRepo) {
8006
8851
  return decideOwned(join6(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs"), targetRepo, readFileSync6(resolveVendoredBinary(), "utf-8"));
8007
8852
  }
@@ -8049,9 +8894,156 @@ function putPlannedMcp(targetRepo, planned) {
8049
8894
  return;
8050
8895
  writeAllowlisted(join6(targetRepo, MCP_REGISTRATION_REL), targetRepo, planned.text, []);
8051
8896
  }
8897
+ function fileInFolderPlace(root, rel) {
8898
+ for (let dir = posix2.dirname(rel); dir !== "."; dir = posix2.dirname(dir)) {
8899
+ const found = lstatOrNull(join6(root, dir));
8900
+ if (found !== null && !found.isDirectory())
8901
+ return true;
8902
+ }
8903
+ return false;
8904
+ }
8905
+ function symlinkOnPath(root, rel) {
8906
+ let current = root;
8907
+ for (const segment of rel.split("/")) {
8908
+ current = join6(current, segment);
8909
+ try {
8910
+ if (lstatSync(current).isSymbolicLink())
8911
+ return true;
8912
+ } catch {
8913
+ return false;
8914
+ }
8915
+ }
8916
+ return false;
8917
+ }
8918
+ function planCodexHooks(targetRepo) {
8919
+ const generated = codexHooksValue();
8920
+ const leave = (outcome, before2) => ({
8921
+ outcome,
8922
+ before: before2,
8923
+ existing: null,
8924
+ generated,
8925
+ text: null
8926
+ });
8927
+ if (symlinkOnPath(targetRepo, CODEX_HOOKS_REL2))
8928
+ return leave("link", null);
8929
+ const folder = lstatOrNull(join6(targetRepo, posix2.dirname(CODEX_HOOKS_REL2)));
8930
+ if (folder !== null && !folder.isDirectory())
8931
+ return leave("unreadable", null);
8932
+ const path = join6(targetRepo, CODEX_HOOKS_REL2);
8933
+ let before = null;
8934
+ if (lstatOrNull(path) !== null) {
8935
+ try {
8936
+ before = readFileSync6(path, "utf-8");
8937
+ } catch {
8938
+ return leave("unreadable", null);
8939
+ }
8940
+ }
8941
+ let existing = {};
8942
+ if (before !== null) {
8943
+ let value;
8944
+ try {
8945
+ value = JSON.parse(before);
8946
+ } catch {
8947
+ return leave("unreadable", before);
8948
+ }
8949
+ if (!isPlainObject2(value))
8950
+ return leave("unreadable", before);
8951
+ const hooks = value["hooks"];
8952
+ if (hooks !== void 0 && !isPlainObject2(hooks))
8953
+ return leave("unreadable", before);
8954
+ const ourEvents = Object.keys(generated.hooks);
8955
+ if (hooks !== void 0 && ourEvents.some((event) => event in hooks && !Array.isArray(hooks[event]))) {
8956
+ return leave("unreadable", before);
8957
+ }
8958
+ existing = value;
8959
+ }
8960
+ const merged = mergeCodexHooks(existing, generated, AGENT_PROFILES.codex.commands);
8961
+ if (before !== null && JSON.stringify(merged) === JSON.stringify(existing)) {
8962
+ return { outcome: "current", before, existing, generated, text: null };
8963
+ }
8964
+ const text = inLineEndingOf(before, JSON.stringify(merged, null, 2) + "\n");
8965
+ return { outcome: "written", before, existing: before === null ? null : existing, generated, text };
8966
+ }
8967
+ function putPlannedCodexHooks(targetRepo, planned) {
8968
+ if (planned.outcome !== "written" || planned.text === null)
8969
+ return;
8970
+ writeAllowlisted(join6(targetRepo, CODEX_HOOKS_REL2), targetRepo, planned.text, []);
8971
+ }
8972
+ function readCodexConfig(targetRepo) {
8973
+ const folder = lstatOrNull(join6(targetRepo, posix2.dirname(CODEX_CONFIG_REL2)));
8974
+ if (folder !== null && !folder.isDirectory())
8975
+ return "unreadable";
8976
+ const path = join6(targetRepo, CODEX_CONFIG_REL2);
8977
+ const stat = lstatOrNull(path);
8978
+ if (stat === null)
8979
+ return { text: null };
8980
+ if (!stat.isFile())
8981
+ return "unreadable";
8982
+ let bytes;
8983
+ try {
8984
+ bytes = readFileSync6(path);
8985
+ } catch {
8986
+ return "unreadable";
8987
+ }
8988
+ const text = bytes.toString("utf-8");
8989
+ return Buffer.from(text, "utf-8").equals(bytes) ? { text } : "unreadable";
8990
+ }
8991
+ function planCodexConfig(targetRepo, credential) {
8992
+ const plan = (outcome, extra = {}) => ({
8993
+ outcome,
8994
+ before: null,
8995
+ text: null,
8996
+ appended: null,
8997
+ inFile: null,
8998
+ conflict: null,
8999
+ blockKept: false,
9000
+ ...extra
9001
+ });
9002
+ if (credential === void 0)
9003
+ return plan("no-credential");
9004
+ if (symlinkOnPath(targetRepo, CODEX_CONFIG_REL2))
9005
+ return plan("link");
9006
+ const read = readCodexConfig(targetRepo);
9007
+ if (read === "unreadable")
9008
+ return plan("conflict", { conflict: "unreadable" });
9009
+ const before = read.text;
9010
+ if (credential === "pending") {
9011
+ const conflict = before === null ? null : codexConfigConflict(before);
9012
+ return conflict === null ? plan("pending", { before }) : plan("conflict", { before, conflict });
9013
+ }
9014
+ const block = generateCodexConfigBlock(mcpEndpointUrl(credential.mcpUrl));
9015
+ if (block === null)
9016
+ return plan("refused-address", { before, blockKept: before !== null && holdsCodexBlock(before) });
9017
+ const reconciled = reconcileCodexConfig(before, block);
9018
+ switch (reconciled.kind) {
9019
+ case "conflict":
9020
+ return plan("conflict", { before, conflict: reconciled.reason });
9021
+ case "unchanged":
9022
+ return plan("current", { before, inFile: reconciled.block });
9023
+ case "new":
9024
+ return plan("written", { before, text: reconciled.text, appended: reconciled.appended, inFile: reconciled.block });
9025
+ }
9026
+ }
9027
+ function putPlannedCodexConfig(targetRepo, planned) {
9028
+ if (planned.outcome !== "written" || planned.text === null)
9029
+ return;
9030
+ writeAllowlisted(join6(targetRepo, CODEX_CONFIG_REL2), targetRepo, planned.text, []);
9031
+ }
8052
9032
  function planCrewRoster(targetRepo) {
8053
9033
  return decideOwned(join6(targetRepo, CREW_ROSTER_REL), targetRepo, renderCrewRoster());
8054
9034
  }
9035
+ function planCrewProfiles(targetRepo, previous, writeSet) {
9036
+ const recordedFiles = new Map((previous?.files ?? []).filter((file) => writeSet.files.has(file.path)).map((file) => [file.path, file]));
9037
+ return renderCrewProfiles().map(({ rel, text }) => {
9038
+ const target = crewProfileTarget(targetRepo, rel);
9039
+ if (symlinkOnPath(targetRepo, rel) || fileInFolderPlace(targetRepo, rel)) {
9040
+ return { rel, text, outcome: "collided", replacedExisting: false, editedCollided: false };
9041
+ }
9042
+ const recorded = recordedFiles.get(rel);
9043
+ const planned = decideCollisionSafe(target, targetRepo, text, (current) => bytesAreOurs(rel, current, recorded, writeSet));
9044
+ return { ...planned, editedCollided: planned.outcome === "collided" && recorded !== void 0 };
9045
+ });
9046
+ }
8055
9047
  function planInstallRecord(targetRepo, text) {
8056
9048
  return decideOwned(join6(targetRepo, INSTALL_MANIFEST_REL), targetRepo, text);
8057
9049
  }
@@ -8075,6 +9067,12 @@ function accountInstall(steps, actual) {
8075
9067
  report.editedCollidedPaths.push(command.rel);
8076
9068
  noteFile(draft, command.rel, command.outcome, actual.bytesAfter);
8077
9069
  }
9070
+ for (const skill of steps.codexSkills) {
9071
+ recordOwnedGenerated(report, skill);
9072
+ if (skill.editedCollided)
9073
+ report.editedCollidedPaths.push(skill.rel);
9074
+ noteFile(draft, skill.rel, skill.outcome, actual.bytesAfter);
9075
+ }
8078
9076
  noteFile(draft, VENDORED_BIN_REL, recordOwnedGenerated(report, steps.vendored), actual.bytesAfter);
8079
9077
  const settings = steps.settings;
8080
9078
  recordSettingsMerge(draft, settings.existing, settings.generated, settings.preexisted, settings.merged);
@@ -8132,6 +9130,8 @@ function accountInstall(steps, actual) {
8132
9130
  if (mcp.existing !== null)
8133
9131
  recordMcpAdoptionOnly(draft, mcp.existing);
8134
9132
  }
9133
+ recordCodexHooksStep(report, draft, steps.codexHooks);
9134
+ recordCodexConfigStep(report, draft, steps.codexConfig);
8135
9135
  recordGitignoreReconcile(draft, steps.gitignore.before, actual.gitignoreAfter, writeSet);
8136
9136
  if (actual.gitignore === "failed") {
8137
9137
  report.skippedPaths.push(".gitignore (write failed \u2014 see credential refusal)");
@@ -8174,6 +9174,12 @@ function accountInstall(steps, actual) {
8174
9174
  report.writtenPaths.push(BUNDLE_PIN_REL);
8175
9175
  noteFile(draft, BUNDLE_PIN_REL, "written", actual.bytesAfter);
8176
9176
  noteFile(draft, CREW_ROSTER_REL, recordOwnedGenerated(report, steps.crew), actual.bytesAfter);
9177
+ for (const profile of steps.crewProfiles) {
9178
+ recordOwnedGenerated(report, profile);
9179
+ if (profile.editedCollided)
9180
+ report.editedCollidedPaths.push(profile.rel);
9181
+ noteFile(draft, profile.rel, profile.outcome, actual.bytesAfter);
9182
+ }
8177
9183
  if (actual.stateExistsAfter)
8178
9184
  recordPerMachineFile(draft, BOOTSTRAP_STATE_REL);
8179
9185
  for (const dir of writeSet.dirs) {
@@ -8184,11 +9190,79 @@ function accountInstall(steps, actual) {
8184
9190
  assertManifestInWriteSet(installManifest, writeSet);
8185
9191
  return { report, installManifest, manifestText: serializeManifest(installManifest) };
8186
9192
  }
9193
+ function recordCodexHooksStep(report, draft, planned) {
9194
+ switch (planned.outcome) {
9195
+ case "unreadable":
9196
+ report.skippedPaths.push(`${CODEX_HOOKS_REL2} (left as it is \u2014 it could not be read, so Codex's checks were not added)`);
9197
+ return;
9198
+ case "link":
9199
+ report.skippedPaths.push(`${CODEX_HOOKS_REL2} (left as it is \u2014 it is a link Halfcycle does not follow)`);
9200
+ return;
9201
+ case "written":
9202
+ (planned.before === null ? report.writtenPaths : report.mergedPaths).push(CODEX_HOOKS_REL2);
9203
+ break;
9204
+ case "current":
9205
+ report.skippedPaths.push(CODEX_HOOKS_REL2);
9206
+ break;
9207
+ }
9208
+ const existingHooks = planned.existing !== null && isPlainObject2(planned.existing["hooks"]) ? planned.existing["hooks"] : void 0;
9209
+ const ours = AGENT_PROFILES.codex.commands;
9210
+ const heldOurs = Object.values(existingHooks ?? {}).some((groups) => Array.isArray(groups) && groups.some((group) => isPlainObject2(group) && Array.isArray(group["hooks"]) && isHalfcycleEntry({ hooks: group["hooks"] }, ours)));
9211
+ recordCodexHooks(draft, {
9212
+ created: planned.before === null,
9213
+ adopted: draft.previous === null && heldOurs,
9214
+ hookCommands: Object.values(planned.generated.hooks).flatMap((groups) => groups.flatMap((group) => group.hooks.map((h) => h.command))),
9215
+ eventsCreated: Object.keys(planned.generated.hooks).filter((event) => !(existingHooks && event in existingHooks)),
9216
+ hooksCreated: planned.existing === null || planned.existing["hooks"] === void 0
9217
+ });
9218
+ }
9219
+ function recordCodexConfigStep(report, draft, planned) {
9220
+ const prev = draft.codexConfig;
9221
+ const separatorOf = (block) => {
9222
+ const at = block.indexOf(CODEX_CONFIG_OPEN_MARKER);
9223
+ return at > 0 ? block.slice(0, at) : "";
9224
+ };
9225
+ switch (planned.outcome) {
9226
+ case "written": {
9227
+ (planned.before === null ? report.writtenPaths : report.mergedPaths).push(CODEX_CONFIG_REL2);
9228
+ const block = planned.appended !== null ? planned.appended : planned.before === null ? planned.text : `${prev === void 0 ? "" : separatorOf(prev.block)}${planned.inFile}`;
9229
+ recordCodexConfig(draft, { created: planned.before === null, block });
9230
+ return;
9231
+ }
9232
+ case "current":
9233
+ report.skippedPaths.push(CODEX_CONFIG_REL2);
9234
+ if (prev === void 0)
9235
+ recordCodexConfig(draft, { created: false, block: planned.inFile });
9236
+ return;
9237
+ case "no-credential":
9238
+ report.skippedPaths.push(`${CODEX_CONFIG_REL2} (no MCP origin \u2014 no credential supplied)`);
9239
+ return;
9240
+ case "link":
9241
+ report.skippedPaths.push(`${CODEX_CONFIG_REL2} (left as it is \u2014 it is a link Halfcycle does not follow)`);
9242
+ return;
9243
+ case "conflict":
9244
+ 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)`);
9245
+ return;
9246
+ case "refused-address":
9247
+ 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)`);
9248
+ return;
9249
+ case "pending":
9250
+ throw new Error("[halfcycle] a plan still waiting for the service cannot be applied or recorded.");
9251
+ }
9252
+ }
8187
9253
  function recordInstallRecord(report, planned) {
8188
9254
  recordOwnedGenerated(report, planned);
8189
9255
  }
8190
9256
  var SELF_BUILD_GITIGNORE_REL = ".halfcycle/.gitignore";
8191
9257
  var SELF_BUILD_GITIGNORE_TEXT = "bin/\n";
9258
+ var SELF_BUILD_WRITE_SET = [
9259
+ BUNDLE_PIN_REL,
9260
+ VENDORED_BIN_REL,
9261
+ SELF_BUILD_GITIGNORE_REL,
9262
+ CREW_ROSTER_REL,
9263
+ MCP_REGISTRATION_REL,
9264
+ ...renderCrewProfiles().map((profile) => profile.rel)
9265
+ ];
8192
9266
  function mcpHasHalfcycleEntry(text) {
8193
9267
  const parsed = JSON.parse(text);
8194
9268
  if (parsed === null || typeof parsed !== "object")
@@ -8196,6 +9270,14 @@ function mcpHasHalfcycleEntry(text) {
8196
9270
  const servers = parsed.mcpServers;
8197
9271
  return servers !== null && typeof servers === "object" && MCP_SERVER_KEY in servers;
8198
9272
  }
9273
+ function writeMissingSelfBuildCrewProfiles(targetRepoRoot, report) {
9274
+ for (const { rel, text } of renderCrewProfiles()) {
9275
+ const target = crewProfileTarget(targetRepoRoot, rel);
9276
+ if (existsSync4(target))
9277
+ continue;
9278
+ writeAllowlisted(target, targetRepoRoot, text, report.writtenPaths);
9279
+ }
9280
+ }
8199
9281
  function installSelfBuild(options) {
8200
9282
  const { targetRepo, engagementId, credential, freshlyMinted, home } = options;
8201
9283
  assertEngagementId(engagementId);
@@ -8230,6 +9312,7 @@ function installSelfBuild(options) {
8230
9312
  if (!existsSync4(at(CREW_ROSTER_REL))) {
8231
9313
  writeCrewRoster(targetRepo, report);
8232
9314
  }
9315
+ writeMissingSelfBuildCrewProfiles(targetRepo, report);
8233
9316
  if (!existsSync4(at(BUNDLE_PIN_REL))) {
8234
9317
  writeBundlePin(targetRepo, planBundlePin(targetRepo, version, engagementId, "self-build", void 0, credential.methodVersion), report.writtenPaths);
8235
9318
  }
@@ -9543,7 +10626,7 @@ function refuseInsideProject(targetRepo) {
9543
10626
  let folder = realOrLiteral(targetRepo);
9544
10627
  for (let parent = dirname4(folder); parent !== folder; folder = parent, parent = dirname4(parent)) {
9545
10628
  if (existsSync5(join7(parent, BUNDLE_PIN_REL))) {
9546
- throw new PreCreateRefused(`${targetRepo} is inside the Halfcycle project at ${parent} \u2014 open that folder in Claude Code instead, or run "npx halfcycle" in a folder outside it.`);
10629
+ throw new PreCreateRefused(`${targetRepo} is inside the Halfcycle project at ${parent} \u2014 open that folder in your coding agent instead, or run "npx halfcycle" in a folder outside it.`);
9547
10630
  }
9548
10631
  }
9549
10632
  }
@@ -9789,7 +10872,8 @@ function renderPreview(plan, missingGitForWindows, claudeMdWords) {
9789
10872
 
9790
10873
  // dist/doctor.js
9791
10874
  import { spawnSync as spawnSync2 } from "node:child_process";
9792
- import { existsSync as existsSync7, readFileSync as readFileSync10 } from "node:fs";
10875
+ import { existsSync as existsSync7, readFileSync as readFileSync10, realpathSync as realpathSync3 } from "node:fs";
10876
+ import { homedir as homedir4 } from "node:os";
9793
10877
  import { join as join10 } from "node:path";
9794
10878
  var GUARD_RUNNER_REL2 = ".claude/hooks/guard-runner.sh";
9795
10879
  var CONNECTION_VERB = "check-connection";
@@ -9813,8 +10897,10 @@ function runDoctor(root, options) {
9813
10897
  const lines = [setUp.line, connection.signedIn, connection.connected, checksRunning, version];
9814
10898
  const failing = lines.filter((line) => !line.ok && !line.cascading).length;
9815
10899
  const closing = failing === 0 ? "Everything is working." : `${failing} of these ${failing === 1 ? "needs" : "need"} fixing; each line above says what to do.`;
10900
+ const codex = codexLine(root, options);
10901
+ const printed = codex === void 0 ? lines : [...lines.slice(0, 4), codex, ...lines.slice(4)];
9816
10902
  return {
9817
- text: [...lines.map((line) => line.text), closing].join("\n"),
10903
+ text: [...printed.map((line) => line.text), closing].join("\n"),
9818
10904
  exitCode: failing === 0 ? 0 : 1
9819
10905
  };
9820
10906
  }
@@ -10004,6 +11090,88 @@ function versionNoLine(installed, current) {
10004
11090
  }
10005
11091
  return `Up to date: no. This folder has Halfcycle ${installed}; the newest is ${current}. To update, run ${HALFCYCLE_COMMAND} here.`;
10006
11092
  }
11093
+ var CODEX_EVENT_SNAKE = {
11094
+ SessionStart: "session_start",
11095
+ UserPromptSubmit: "user_prompt_submit",
11096
+ PreToolUse: "pre_tool_use",
11097
+ PostToolUse: "post_tool_use",
11098
+ Stop: "stop",
11099
+ SubagentStop: "subagent_stop",
11100
+ SessionEnd: "session_end"
11101
+ };
11102
+ function codexLine(root, options) {
11103
+ const hooksPath = join10(root, CODEX_HOOKS_REL2);
11104
+ if (!existsSync7(hooksPath))
11105
+ return void 0;
11106
+ const positions = codexEntryPositions(hooksPath);
11107
+ if (positions === void 0) {
11108
+ return { ok: false, text: `Codex: not complete. To repair it, run ${HALFCYCLE_COMMAND} here.` };
11109
+ }
11110
+ return codexApprovalStatus(root, positions, options) === "approved" ? { ok: true, text: "Codex: set up, and its checks are approved." } : {
11111
+ ok: false,
11112
+ text: "Codex: set up, but its checks are not approved yet. Open this folder in Codex and type /hooks to approve each Halfcycle entry."
11113
+ };
11114
+ }
11115
+ function codexEntryPositions(hooksPath) {
11116
+ const parsed = readJson(hooksPath);
11117
+ if (!isObject(parsed) || !isObject(parsed["hooks"]))
11118
+ return void 0;
11119
+ const positions = [];
11120
+ for (const entry of AGENT_PROFILES.codex.wiring) {
11121
+ const groups = parsed["hooks"][entry.event];
11122
+ if (!Array.isArray(groups))
11123
+ return void 0;
11124
+ let found;
11125
+ for (let g = 0; g < groups.length && found === void 0; g += 1) {
11126
+ const group = groups[g];
11127
+ if (!isObject(group) || !Array.isArray(group["hooks"]))
11128
+ continue;
11129
+ for (let h = 0; h < group["hooks"].length; h += 1) {
11130
+ const hook = group["hooks"][h];
11131
+ if (isObject(hook) && hook["command"] === entry.command) {
11132
+ found = { group: g, hook: h };
11133
+ break;
11134
+ }
11135
+ }
11136
+ }
11137
+ if (found === void 0)
11138
+ return void 0;
11139
+ positions.push({ event: entry.event, group: found.group, hook: found.hook });
11140
+ }
11141
+ return positions;
11142
+ }
11143
+ function codexApprovalStatus(root, positions, options) {
11144
+ const codexHome = options.env["CODEX_HOME"];
11145
+ const configPath = join10(codexHome !== void 0 && codexHome !== "" ? codexHome : join10(homedir4(), ".codex"), "config.toml");
11146
+ let text;
11147
+ try {
11148
+ text = readFileSync10(configPath, "utf-8");
11149
+ } catch {
11150
+ return "not-approved";
11151
+ }
11152
+ let hooksPath;
11153
+ try {
11154
+ hooksPath = join10(realpathSync3(root), CODEX_HOOKS_REL2);
11155
+ } catch {
11156
+ return "not-approved";
11157
+ }
11158
+ const fileLines = text.split("\n");
11159
+ for (const { event, group, hook } of positions) {
11160
+ const key = `${hooksPath}:${CODEX_EVENT_SNAKE[event] ?? event}:${group}:${hook}`;
11161
+ const headers = [`[hooks.state."${key}"]`, `[hooks.state.'${key}']`];
11162
+ const start = fileLines.findIndex((line) => headers.includes(line.trim()));
11163
+ if (start === -1)
11164
+ return "not-approved";
11165
+ for (let i = start + 1; i < fileLines.length; i += 1) {
11166
+ const trimmed = fileLines[i].trim();
11167
+ if (trimmed.startsWith("["))
11168
+ break;
11169
+ if (trimmed === "enabled = false")
11170
+ return "not-approved";
11171
+ }
11172
+ }
11173
+ return "approved";
11174
+ }
10007
11175
  function notChecked(check, earlier) {
10008
11176
  return { ok: false, cascading: true, text: `${check}: not checked, because ${earlier} is not working yet.` };
10009
11177
  }
@@ -10178,19 +11346,18 @@ async function probeMcpOrigin(mcpUrl, token) {
10178
11346
 
10179
11347
  // dist/claude-version-probe.js
10180
11348
  import { execFileSync as execFileSync3 } from "node:child_process";
10181
- import { existsSync as existsSync8, realpathSync as realpathSync3 } from "node:fs";
11349
+ import { existsSync as existsSync8, realpathSync as realpathSync4 } from "node:fs";
10182
11350
  import { delimiter as delimiter2, join as join12 } from "node:path";
10183
- function probeClaudeCodeVersion(timeoutMs, deps = {}) {
11351
+ function execVersionCommand(command, timeoutMs, deps) {
10184
11352
  const platform6 = deps.platform ?? process.platform;
10185
11353
  const exec = deps.exec ?? execFileSync3;
10186
- let raw;
10187
11354
  try {
10188
- raw = platform6 === "win32" ? exec("cmd.exe", ["/d", "/s", "/c", '"claude --version"'], {
11355
+ return platform6 === "win32" ? exec("cmd.exe", ["/d", "/s", "/c", `"${command} --version"`], {
10189
11356
  encoding: "utf-8",
10190
11357
  timeout: timeoutMs,
10191
11358
  stdio: ["ignore", "pipe", "ignore"],
10192
11359
  windowsVerbatimArguments: true
10193
- }) : exec("claude", ["--version"], {
11360
+ }) : exec(command, ["--version"], {
10194
11361
  encoding: "utf-8",
10195
11362
  timeout: timeoutMs,
10196
11363
  stdio: ["ignore", "pipe", "ignore"]
@@ -10198,7 +11365,14 @@ function probeClaudeCodeVersion(timeoutMs, deps = {}) {
10198
11365
  } catch {
10199
11366
  return void 0;
10200
11367
  }
10201
- return /(\d+\.\d+\.\d+)/.exec(raw)?.[1];
11368
+ }
11369
+ function probeClaudeCodeVersion(timeoutMs, deps = {}) {
11370
+ const raw = execVersionCommand("claude", timeoutMs, deps);
11371
+ return raw === void 0 ? void 0 : /(\d+\.\d+\.\d+)/.exec(raw)?.[1];
11372
+ }
11373
+ function probeCodexVersion(timeoutMs, deps = {}) {
11374
+ const raw = execVersionCommand("codex", timeoutMs, deps);
11375
+ return raw === void 0 ? void 0 : /^codex-cli (\d+\.\d+\.\d+)/.exec(raw)?.[1];
10202
11376
  }
10203
11377
  var CLAUDE_CODE_INSTALL_PAGE = "https://claude.com/claude-code";
10204
11378
  var UPGRADE_COMMANDS = {
@@ -10219,7 +11393,7 @@ function probeClaudeCodeInstallMethod(deps = {}) {
10219
11393
  const exists = deps.exists ?? existsSync8;
10220
11394
  const realpath = deps.realpath ?? ((file) => {
10221
11395
  try {
10222
- return realpathSync3(file);
11396
+ return realpathSync4(file);
10223
11397
  } catch {
10224
11398
  return file;
10225
11399
  }
@@ -10238,6 +11412,30 @@ function probeClaudeCodeInstallMethod(deps = {}) {
10238
11412
  return void 0;
10239
11413
  }
10240
11414
 
11415
+ // dist/codex-install-lines.js
11416
+ var CODEX_APPROVAL_LINE = "[halfcycle] Using Codex? Open this folder in Codex and trust it when asked. Then type /hooks and approve each Halfcycle entry \u2014 until you do, Codex works without Halfcycle checking its edits. Then type $halfcycle-setup and answer what it asks about your project.";
11417
+ var CODEX_LINK_LINE = "[halfcycle] Codex: this project's Codex settings are a link Halfcycle does not follow, so Codex is not set up here. Codex sessions here will not reach Halfcycle.";
11418
+ var CODEX_CONFLICT_LINE = `[halfcycle] Codex: this project's Codex settings already name a "halfcycle" server, so Halfcycle did not add its own. Codex sessions here will not reach Halfcycle until that entry is removed and this is run again.`;
11419
+ var CODEX_REFUSED_ADDRESS_LINE = "[halfcycle] Codex: this project's Halfcycle address cannot be passed to Codex safely, so Codex sessions here will not reach Halfcycle.";
11420
+ var CODEX_UNREADABLE_HOOKS_LINE = "[halfcycle] Codex: this project's Codex checks file could not be read, so Halfcycle did not add its checks. Codex sessions here will not be checked until it is fixed and this is run again.";
11421
+ var CODEX_MIN_VERSION = "0.166.0";
11422
+ function codexVersionWarningLine(version) {
11423
+ return `[halfcycle] WARNING: Codex ${version} is older than ${CODEX_MIN_VERSION}, which Halfcycle needs. Update Codex, then reopen this folder.`;
11424
+ }
11425
+ function codexInstallLine(codex) {
11426
+ if (codex.hooks === "link" || codex.config === "link")
11427
+ return CODEX_LINK_LINE;
11428
+ if (codex.hooks === "unreadable")
11429
+ return CODEX_UNREADABLE_HOOKS_LINE;
11430
+ if (codex.config === "conflict")
11431
+ return CODEX_CONFLICT_LINE;
11432
+ if (codex.config === "refused-address")
11433
+ return CODEX_REFUSED_ADDRESS_LINE;
11434
+ if (codex.config === "no-credential")
11435
+ return void 0;
11436
+ return CODEX_APPROVAL_LINE;
11437
+ }
11438
+
10241
11439
  // dist/build-record/write.js
10242
11440
  import { existsSync as existsSync9, mkdirSync as mkdirSync7, rmSync as rmSync4 } from "node:fs";
10243
11441
  import { basename as basename2, join as join13, resolve as resolve3, relative as relative2, isAbsolute as isAbsolute3 } from "node:path";
@@ -10980,7 +12178,7 @@ import { existsSync as existsSync11, readFileSync as readFileSync16 } from "node
10980
12178
  import { join as join19 } from "node:path";
10981
12179
 
10982
12180
  // dist/draw/write.js
10983
- import { closeSync as closeSync2, lstatSync as lstatSync2, mkdirSync as mkdirSync8, openSync as openSync2, readFileSync as readFileSync13, readSync, realpathSync as realpathSync4, rmSync as rmSync5 } from "node:fs";
12181
+ import { closeSync as closeSync2, lstatSync as lstatSync2, mkdirSync as mkdirSync8, openSync as openSync2, readFileSync as readFileSync13, readSync, realpathSync as realpathSync5, rmSync as rmSync5 } from "node:fs";
10984
12182
  import { dirname as dirname6, isAbsolute as isAbsolute4, join as join14, relative as relative3, resolve as resolve4, sep } from "node:path";
10985
12183
  var DOCS_DIR = "docs";
10986
12184
  var DIAGRAMS_DIR = "diagrams";
@@ -11068,7 +12266,7 @@ function isInside(outer, inner) {
11068
12266
  function checkDiagramPlace(paths) {
11069
12267
  let root;
11070
12268
  try {
11071
- root = realpathSync4(paths.repoRoot);
12269
+ root = realpathSync5(paths.repoRoot);
11072
12270
  } catch {
11073
12271
  return { ok: false, why: `the folder ${paths.repoRoot} could not be opened. Check the path, then run this again.` };
11074
12272
  }
@@ -11081,7 +12279,7 @@ function checkDiagramPlace(paths) {
11081
12279
  }
11082
12280
  let real;
11083
12281
  try {
11084
- real = realpathSync4(ancestor);
12282
+ real = realpathSync5(ancestor);
11085
12283
  } catch {
11086
12284
  return {
11087
12285
  ok: false,
@@ -12706,7 +13904,7 @@ function approvalLine2(phase, number, typedApprovalOffered, pinnedVersion, shown
12706
13904
  if (!clientMeetsChatApprovalFloor(pinnedVersion)) {
12707
13905
  return then(CLIENT_UPDATE_SENTENCE, "Or the project's owner approves it in the System view now:");
12708
13906
  }
12709
- return then(`To approve it, the project's owner types \`approve ${phase}\` in their own Claude Code session. Only the owner can.`, "They can also approve it in the System view:");
13907
+ return then(`To approve it, the project's owner types \`approve ${phase}\` in their own coding agent's session. Only the owner can.`, "They can also approve it in the System view:");
12710
13908
  }
12711
13909
  function changeLines(plan, description, changed, carriedFrom = []) {
12712
13910
  const names = new Map(description.parts.map((part) => [part.id, part.name]));
@@ -12954,7 +14152,7 @@ function rv7Lines(view, claims) {
12954
14152
  if (decidable.length === 0)
12955
14153
  return ["No part waits for your decision now."];
12956
14154
  }
12957
- const lines = ["To decide, type one of these lines in your own Claude Code session:"];
14155
+ const lines = ["To decide, type one of these lines in your own coding agent's session:"];
12958
14156
  for (const part of decidable) {
12959
14157
  for (const act of reviewActsFor(part.built))
12960
14158
  lines.push(` ${typedLine(act, reference, part.part)}`);
@@ -13000,7 +14198,7 @@ var CHECKS_SUBHEADING = "An agent says these pass and Halfcycle never ran them.
13000
14198
  function uncheckedClaimLine(test) {
13001
14199
  return `Unchecked: ${test.claimedBy} says "${test.label}" passes. Halfcycle never ran it.`;
13002
14200
  }
13003
- var ANSWER_CLAIMS_BY_TYPING_LINE = "To answer them, type the reject or accept line listed above in your own Claude Code session.";
14201
+ var ANSWER_CLAIMS_BY_TYPING_LINE = "To answer them, type the reject or accept line listed above in your own coding agent's session.";
13004
14202
  function checksSectionLines(work) {
13005
14203
  const unchecked = work.tasks.flatMap((task) => task.tests.filter((t) => t.state === "unchecked"));
13006
14204
  if (unchecked.length === 0)
@@ -13421,7 +14619,7 @@ function typedReviewHintLine(offered, phase) {
13421
14619
  return `[halfcycle] ${TYPED_REVIEW_OFF_SENTENCE}`;
13422
14620
  const accept = typedAcceptLine(phase);
13423
14621
  const lines = accept === null ? "then type one decision per line, as the whole message." : `then type ${accept} as the whole message to accept every built part, or one line per part, such as ${accept}/<part>; the list gives each part's lines.`;
13424
- return `[halfcycle] The owner reviews each part in their own Claude Code session: they say "review what was built" to see the list, ${lines}`;
14622
+ return `[halfcycle] The owner reviews each part in their own coding agent's session: they say "review what was built" to see the list, ${lines}`;
13425
14623
  }
13426
14624
  function systemViewReviewLine(offered, engagementId, phase) {
13427
14625
  const address = systemViewUrl(engagementId, phase);
@@ -13430,7 +14628,7 @@ function systemViewReviewLine(offered, engagementId, phase) {
13430
14628
  if (!offered)
13431
14629
  return `[halfcycle] The owner reviews it in the System view: ${address}`;
13432
14630
  const accept = typedAcceptLine(phase);
13433
- const instead = accept === null ? "typing in their own Claude Code session" : `typing ${accept} in their own Claude Code session`;
14631
+ const instead = accept === null ? "typing in their own coding agent's session" : `typing ${accept} in their own coding agent's session`;
13434
14632
  return `[halfcycle] Or, instead of ${instead}, the owner reviews it in the System view: ${address}`;
13435
14633
  }
13436
14634
  function tasksOpenLine(phase, open, more) {
@@ -14255,6 +15453,35 @@ function readJsonFile(repoRoot, rel, parse) {
14255
15453
  }
14256
15454
  return { kind: "ok", value: parsed.data };
14257
15455
  }
15456
+ function readSystemParts(repoRoot) {
15457
+ const file = readDescriptionFile(repoRoot);
15458
+ if (file.kind === "missing")
15459
+ return { kind: "none" };
15460
+ if (file.kind === "unreadable") {
15461
+ return {
15462
+ kind: "bad",
15463
+ line: `${SYSTEM_DESCRIPTION_REL} could not be read. Check that it is a file you can open, then run this again.
15464
+ `
15465
+ };
15466
+ }
15467
+ if (file.kind === "not-json") {
15468
+ return {
15469
+ kind: "bad",
15470
+ line: `${SYSTEM_DESCRIPTION_REL} is not valid JSON (${file.error}). Fix it, then run this again.
15471
+ `
15472
+ };
15473
+ }
15474
+ const checked = checkSystemDescription(file.value);
15475
+ if (!checked.ok) {
15476
+ const n = checked.problems.length;
15477
+ return {
15478
+ kind: "bad",
15479
+ line: `${SYSTEM_DESCRIPTION_REL} has ${n} ${n === 1 ? "problem" : "problems"}, so the plan could not be checked against it:
15480
+ ` + problemLines(file.value, checked.problems)
15481
+ };
15482
+ }
15483
+ return { kind: "ok", partIds: checked.description.parts.map((part) => part.id) };
15484
+ }
14258
15485
  function runCheckTasks(repoRoot, phase, withTaskList) {
14259
15486
  const p = "halfcycle check-tasks:";
14260
15487
  const planRead = readJsonFile(repoRoot, TASK_PLAN_FILE, (raw) => taskPlanSchema.safeParse(raw));
@@ -14280,7 +15507,15 @@ function runCheckTasks(repoRoot, phase, withTaskList) {
14280
15507
  }
14281
15508
  taskList = { file: listRead.kind === "ok" ? listRead.value : null };
14282
15509
  }
14283
- const result = checkTaskPlan(plan, { phase, taskList });
15510
+ const system = readSystemParts(repoRoot);
15511
+ if (system.kind === "bad") {
15512
+ return { stdout: "", stderr: `${p} ${system.line}`, exitCode: 2 };
15513
+ }
15514
+ const result = checkTaskPlan(plan, {
15515
+ phase,
15516
+ taskList,
15517
+ ...system.kind === "ok" ? { system: { file: SYSTEM_DESCRIPTION_REL, partIds: system.partIds } } : {}
15518
+ });
14284
15519
  const stdout = result.lines.map((line) => `${line.text}
14285
15520
  `).join("");
14286
15521
  return { stdout, stderr: "", exitCode: result.refused ? 1 : 0 };
@@ -14339,7 +15574,7 @@ function runProveChild(binary, credential, repoRoot, words, deps = {}) {
14339
15574
 
14340
15575
  // dist/uninstall.js
14341
15576
  import { lstatSync as lstatSync3, readFileSync as readFileSync19, readdirSync, rmSync as rmSync6, rmdirSync, writeFileSync as writeFileSync8 } from "node:fs";
14342
- import { homedir as homedir4 } from "node:os";
15577
+ import { homedir as homedir5 } from "node:os";
14343
15578
  import { dirname as dirname7, join as join22, relative as relative6, sep as sep2 } from "node:path";
14344
15579
  var REMOVE_CREDENTIAL_FLAG = "--remove-credential";
14345
15580
  function parseUninstallArgs(args2) {
@@ -14363,19 +15598,6 @@ function lexists(path) {
14363
15598
  return false;
14364
15599
  }
14365
15600
  }
14366
- function symlinkOnPath(root, rel) {
14367
- let current = root;
14368
- for (const segment of rel.split("/")) {
14369
- current = join22(current, segment);
14370
- try {
14371
- if (lstatSync3(current).isSymbolicLink())
14372
- return true;
14373
- } catch {
14374
- return false;
14375
- }
14376
- }
14377
- return false;
14378
- }
14379
15601
  function isPlainObject3(value) {
14380
15602
  return value !== null && typeof value === "object" && !Array.isArray(value);
14381
15603
  }
@@ -14453,7 +15675,7 @@ function validate(options, rules) {
14453
15675
  throw new Refusal(`the project id in ${BUNDLE_PIN_REL} is not a valid Halfcycle project id, so it cannot name a folder on this machine. Nothing was removed, here or under ~/.halfcycle.`);
14454
15676
  }
14455
15677
  if (engagementId !== void 0) {
14456
- const expectedParent = join22(home ?? homedir4(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
15678
+ const expectedParent = join22(home ?? homedir5(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
14457
15679
  if (dirname7(engagementStateDir(engagementId, home)) !== expectedParent) {
14458
15680
  throw new Refusal(`the project id in ${BUNDLE_PIN_REL} does not name a folder under ~/.halfcycle/engagements. Nothing was removed.`);
14459
15681
  }
@@ -14464,14 +15686,16 @@ function validate(options, rules) {
14464
15686
  engagementId,
14465
15687
  idValid,
14466
15688
  settings: readMergedJson(targetRepo, rules.settings),
14467
- mcp: readMergedJson(targetRepo, rules.mcp)
15689
+ mcp: readMergedJson(targetRepo, rules.mcp),
15690
+ codexHooks: readMergedJson(targetRepo, rules.codexHooks)
14468
15691
  };
14469
15692
  }
14470
15693
  function mergedFileRules(writeSet) {
14471
15694
  const spelledIn = (spellings) => (text) => spellings.some((spelling) => text.includes(spelling));
14472
15695
  return {
14473
15696
  settings: { rel: SETTINGS_REL, holdsOurs: spelledIn([...writeSet.hookCommands].map((c) => JSON.stringify(c))) },
14474
- mcp: { rel: MCP_REGISTRATION_REL, holdsOurs: spelledIn([JSON.stringify(MCP_SERVER_KEY)]) }
15697
+ mcp: { rel: MCP_REGISTRATION_REL, holdsOurs: spelledIn([JSON.stringify(MCP_SERVER_KEY)]) },
15698
+ codexHooks: { rel: CODEX_HOOKS_REL2, holdsOurs: spelledIn([...writeSet.codexHookCommands].map((c) => JSON.stringify(c))) }
14475
15699
  };
14476
15700
  }
14477
15701
  function writeBack(targetRepo, rel, current, next, lists, name) {
@@ -14560,48 +15784,12 @@ function ourMcpServers(value) {
14560
15784
  return isPlainObject3(servers) && MCP_SERVER_KEY in servers ? servers : null;
14561
15785
  }
14562
15786
  function unmergeSettings(value, rec, writeSet) {
14563
- let changed = false;
14564
15787
  const refused3 = [];
14565
- const commands = new Set(writeSet.hookCommands);
14566
15788
  const denyHashes = rec.denyAddedSha256.filter((h) => writeSet.denyRuleSha256.has(h));
14567
15789
  if (denyHashes.length < rec.denyAddedSha256.length) {
14568
15790
  refused3.push(`${SETTINGS_REL}: a deny rule the install record names but Halfcycle never adds (it was kept)`);
14569
15791
  }
14570
- const hooks = value["hooks"];
14571
- if (isPlainObject3(hooks)) {
14572
- const ours = new Set(ourHookEntries(value, commands).map(({ entry }) => entry));
14573
- let emptiedAnEvent = false;
14574
- for (const event of Object.keys(hooks)) {
14575
- const entries = hooks[event];
14576
- if (!Array.isArray(entries))
14577
- continue;
14578
- let touched = false;
14579
- const next = [];
14580
- for (const entry of entries) {
14581
- if (!ours.has(entry)) {
14582
- next.push(entry);
14583
- continue;
14584
- }
14585
- touched = true;
14586
- const remaining = withoutHalfcycleCommands(entry, commands);
14587
- if (remaining !== null)
14588
- next.push(remaining);
14589
- }
14590
- if (touched)
14591
- changed = true;
14592
- if (next.length === 0 && (rec.eventsCreated.includes(event) || rec.adopted && touched)) {
14593
- delete hooks[event];
14594
- changed = true;
14595
- emptiedAnEvent = true;
14596
- } else if (touched) {
14597
- hooks[event] = next;
14598
- }
14599
- }
14600
- if (Object.keys(hooks).length === 0 && (rec.hooksCreated || rec.adopted && emptiedAnEvent)) {
14601
- delete value["hooks"];
14602
- changed = true;
14603
- }
14604
- }
15792
+ let changed = unmergeHooks(value, rec, new Set(writeSet.hookCommands));
14605
15793
  const permissions = value["permissions"];
14606
15794
  if (isPlainObject3(permissions) && Array.isArray(permissions["deny"])) {
14607
15795
  const deny = permissions["deny"];
@@ -14637,6 +15825,52 @@ function unmergeSettings(value, rec, writeSet) {
14637
15825
  }
14638
15826
  return { changed, refused: refused3 };
14639
15827
  }
15828
+ function unmergeHooks(value, rec, commands) {
15829
+ const hooks = value["hooks"];
15830
+ if (!isPlainObject3(hooks))
15831
+ return false;
15832
+ let changed = false;
15833
+ const ours = new Set(ourHookEntries(value, commands).map(({ entry }) => entry));
15834
+ let emptiedAnEvent = false;
15835
+ for (const event of Object.keys(hooks)) {
15836
+ const entries = hooks[event];
15837
+ if (!Array.isArray(entries))
15838
+ continue;
15839
+ let touched = false;
15840
+ const next = [];
15841
+ for (const entry of entries) {
15842
+ if (!ours.has(entry)) {
15843
+ next.push(entry);
15844
+ continue;
15845
+ }
15846
+ touched = true;
15847
+ const remaining = withoutHalfcycleCommands(entry, commands);
15848
+ if (remaining !== null)
15849
+ next.push(remaining);
15850
+ }
15851
+ if (touched)
15852
+ changed = true;
15853
+ if (next.length === 0 && (rec.eventsCreated.includes(event) || rec.adopted && touched)) {
15854
+ delete hooks[event];
15855
+ changed = true;
15856
+ emptiedAnEvent = true;
15857
+ } else if (touched) {
15858
+ hooks[event] = next;
15859
+ }
15860
+ }
15861
+ if (Object.keys(hooks).length === 0 && (rec.hooksCreated || rec.adopted && emptiedAnEvent)) {
15862
+ delete value["hooks"];
15863
+ changed = true;
15864
+ }
15865
+ return changed;
15866
+ }
15867
+ var BEFORE_UNKNOWN_CODEX_HOOKS = {
15868
+ created: false,
15869
+ adopted: true,
15870
+ hookCommands: [],
15871
+ eventsCreated: [],
15872
+ hooksCreated: false
15873
+ };
14640
15874
  function unmergeMcp(value, rec) {
14641
15875
  const servers = value["mcpServers"];
14642
15876
  if (!isPlainObject3(servers))
@@ -14744,7 +15978,8 @@ function coveredPathExists(targetRepo, line) {
14744
15978
  function selfBuildMergedFileRules() {
14745
15979
  return {
14746
15980
  settings: { rel: SETTINGS_REL, holdsOurs: () => false },
14747
- mcp: { rel: MCP_REGISTRATION_REL, holdsOurs: (text) => text.includes(JSON.stringify(MCP_SERVER_KEY)) }
15981
+ mcp: { rel: MCP_REGISTRATION_REL, holdsOurs: (text) => text.includes(JSON.stringify(MCP_SERVER_KEY)) },
15982
+ codexHooks: { rel: CODEX_HOOKS_REL2, holdsOurs: () => false }
14748
15983
  };
14749
15984
  }
14750
15985
  function removeSelfBuildOwnedFile(targetRepo, rel, lists, name) {
@@ -14798,6 +16033,33 @@ function removeSelfBuildCrewRoster(targetRepo, lists, name) {
14798
16033
  name(lists.failed, rel, rel);
14799
16034
  }
14800
16035
  }
16036
+ function removeSelfBuildCrewProfile(targetRepo, rel, text, lists, name) {
16037
+ if (symlinkOnPath(targetRepo, rel)) {
16038
+ name(lists.leftAlone, `${rel} (a symbolic link, or inside one \u2014 not followed)`, rel);
16039
+ return;
16040
+ }
16041
+ const abs = join22(targetRepo, rel);
16042
+ let bytes;
16043
+ try {
16044
+ bytes = readFileSync19(abs);
16045
+ } catch {
16046
+ return;
16047
+ }
16048
+ if (!lstatSync3(abs).isFile()) {
16049
+ name(lists.leftAlone, `${rel} (not a file)`, rel);
16050
+ return;
16051
+ }
16052
+ if (bytes.toString("utf-8") !== text) {
16053
+ name(lists.kept, rel, rel);
16054
+ return;
16055
+ }
16056
+ try {
16057
+ rmSync6(abs);
16058
+ name(lists.removed, rel, rel);
16059
+ } catch {
16060
+ name(lists.failed, rel, rel);
16061
+ }
16062
+ }
14801
16063
  function removeSelfBuildEmptyDir(targetRepo, dir, lists, name) {
14802
16064
  if (symlinkOnPath(targetRepo, dir))
14803
16065
  return;
@@ -14847,8 +16109,13 @@ function uninstallSelfBuild(options) {
14847
16109
  removeSelfBuildOwnedFile(targetRepo, rel, result, name);
14848
16110
  }
14849
16111
  removeSelfBuildCrewRoster(targetRepo, result, name);
16112
+ for (const { rel, text } of renderCrewProfiles()) {
16113
+ removeSelfBuildCrewProfile(targetRepo, rel, text, result, name);
16114
+ }
14850
16115
  removeSelfBuildEmptyDir(targetRepo, ".halfcycle/bin", result, name);
14851
16116
  removeSelfBuildEmptyDir(targetRepo, ".halfcycle", result, name);
16117
+ removeSelfBuildEmptyDir(targetRepo, ".codex/agents", result, name);
16118
+ removeSelfBuildEmptyDir(targetRepo, ".codex", result, name);
14852
16119
  const foundNothing = result.removed.length + result.restored.length + result.kept.length + result.failed.length === 0;
14853
16120
  if (!pinExisted && foundNothing) {
14854
16121
  return { outcome: "not-installed", exitCode: 0, ...result, credential: nothingToSay };
@@ -14884,6 +16151,7 @@ function uninstall(options) {
14884
16151
  const { targetRepo } = options;
14885
16152
  const manifest = checked.manifest ?? noRecord(targetRepo, writeSet);
14886
16153
  const deleteWhenEmpty = (created) => created || checked.manifest === null;
16154
+ const deleteCodexWhenEmpty = (record2) => record2 === void 0 || deleteWhenEmpty(record2.created);
14887
16155
  const named = /* @__PURE__ */ new Set();
14888
16156
  const name = (list2, text, path) => {
14889
16157
  list2.push(text);
@@ -14902,6 +16170,13 @@ function uninstall(options) {
14902
16170
  changed: unmergeMcp(value, mcpRecord).changed,
14903
16171
  adopted: false
14904
16172
  }));
16173
+ const codexRecord = manifest.codexHooks ?? BEFORE_UNKNOWN_CODEX_HOOKS;
16174
+ const codexCommands = new Set(writeSet.codexHookCommands);
16175
+ applyMergedJson(targetRepo, rules.codexHooks, checked.codexHooks, deleteCodexWhenEmpty(manifest.codexHooks), result, name, (value) => ({
16176
+ changed: unmergeHooks(value, codexRecord, codexCommands),
16177
+ adopted: false
16178
+ }));
16179
+ removeCodexConfigBlock(targetRepo, manifest.codexConfig, deleteCodexWhenEmpty(manifest.codexConfig), result, name);
14905
16180
  const deferred = /* @__PURE__ */ new Set([BUNDLE_PIN_REL, INSTALL_MANIFEST_REL]);
14906
16181
  const recorded = new Map(manifest.files.map((entry) => [entry.path, entry]));
14907
16182
  for (const path of [.../* @__PURE__ */ new Set([...recorded.keys(), ...writeSet.files])].sort()) {
@@ -14985,6 +16260,44 @@ function applyMergedJson(targetRepo, rule, file, deleteWhenEmpty, lists, name, e
14985
16260
  name(lists.leftAlone, `${rel} (Halfcycle's hooks were taken out. There is no record of this file from before the install, so a permission rule or "$schema" Halfcycle added may still be there \u2014 it cannot be told apart from your own)`, rel);
14986
16261
  }
14987
16262
  }
16263
+ function removeCodexConfigBlock(targetRepo, record2, deleteWhenEmpty, lists, name) {
16264
+ const rel = CODEX_CONFIG_REL2;
16265
+ const path = join22(targetRepo, rel);
16266
+ if (symlinkOnPath(targetRepo, rel)) {
16267
+ if (lexists(path))
16268
+ name(lists.leftAlone, `${rel} (a symbolic link, or inside one \u2014 not followed)`, rel);
16269
+ return;
16270
+ }
16271
+ let text;
16272
+ try {
16273
+ if (!lstatSync3(path).isFile())
16274
+ return;
16275
+ text = readFileSync19(path, "utf-8");
16276
+ } catch {
16277
+ return;
16278
+ }
16279
+ const span = codexBlockSpan(text);
16280
+ if (span === "none")
16281
+ return;
16282
+ const changed = () => name(lists.kept, `${rel} (Halfcycle's part of it was changed after it was written, so it was kept)`, rel);
16283
+ if (span === "broken")
16284
+ return changed();
16285
+ let cut = null;
16286
+ const at = record2 === void 0 ? -1 : record2.block.indexOf(CODEX_CONFIG_OPEN_MARKER);
16287
+ if (record2 !== void 0 && at >= 0) {
16288
+ const found = regeneratesCodexBlock(record2.block.slice(at)) ? lastOccurrence(text, record2.block) : null;
16289
+ if (found !== null)
16290
+ cut = found;
16291
+ } else if (regeneratesCodexBlock(text.slice(span.start, span.end))) {
16292
+ cut = span;
16293
+ }
16294
+ if (cut === null)
16295
+ return changed();
16296
+ const head = text.slice(0, cut.start);
16297
+ const tail = text.slice(cut.end);
16298
+ const next = head + (head !== "" && tail !== "" && !head.endsWith("\n") ? lineEndingOf(text) : "") + tail;
16299
+ writeBack(targetRepo, rel, text, next === "" && deleteWhenEmpty ? null : next, lists, name);
16300
+ }
14988
16301
  function removeInstalledFile(targetRepo, path, entry, writeSet, lists, name) {
14989
16302
  if (!writeSet.files.has(path)) {
14990
16303
  name(lists.leftAlone, `${path} (Halfcycle never writes this path, so it was not removed)`, path);
@@ -15116,7 +16429,7 @@ function filesUnder(root, rel) {
15116
16429
  return out;
15117
16430
  }
15118
16431
  function shownPath(path, home) {
15119
- const base = home ?? homedir4();
16432
+ const base = home ?? homedir5();
15120
16433
  const rel = relative6(base, path);
15121
16434
  const under = rel !== "" && !rel.startsWith("..") && !rel.startsWith(sep2);
15122
16435
  return `${under ? `~/${rel.split(sep2).join("/")}` : path}/`;
@@ -15170,7 +16483,7 @@ function renderUninstallReport(result, home) {
15170
16483
  case "nothing-to-say":
15171
16484
  break;
15172
16485
  }
15173
- out.push("[halfcycle] Your project on Halfcycle is unchanged. Restart any Claude Code session open in this project.");
16486
+ out.push("[halfcycle] Your project on Halfcycle is unchanged. Restart any coding agent session open in this project.");
15174
16487
  return { stdout: out.join("\n") + "\n", stderr: "" };
15175
16488
  }
15176
16489
 
@@ -15198,9 +16511,13 @@ function bundleVersion() {
15198
16511
  function claudeCodeVersion(deps = {}) {
15199
16512
  return probeClaudeCodeVersion(3e3, deps);
15200
16513
  }
16514
+ function codexVersion(deps = {}) {
16515
+ return probeCodexVersion(3e3, deps);
16516
+ }
15201
16517
  var REAL_PROBES = {
15202
16518
  bundleVersion,
15203
16519
  claudeCodeVersion,
16520
+ codexVersion,
15204
16521
  projectName: (dir) => {
15205
16522
  try {
15206
16523
  return deriveProjectName(dir);
@@ -15248,6 +16565,9 @@ function openingBannerContent(projectDir, env = process.env, probes = REAL_PROBE
15248
16565
  const facts = [
15249
16566
  fact("Version", version, version),
15250
16567
  fact("Claude Code", probes.claudeCodeVersion()),
16568
+ // Beside the `Claude Code` row, same rule: a probe that cannot answer omits the
16569
+ // row rather than guessing (`codex-install-target`, *The Codex version*).
16570
+ fact("Codex", probes.codexVersion()),
15251
16571
  fact("Project", project, name),
15252
16572
  fact("Status", status, installed === void 0 ? "new" : "installed"),
15253
16573
  // `Website`, NOT `Plane` — see this function's docblock. (No issue number here:
@@ -15420,8 +16740,8 @@ async function runPace(repoRoot, start, deps) {
15420
16740
 
15421
16741
  // dist/restore.js
15422
16742
  import { spawnSync as spawnSync3 } from "node:child_process";
15423
- import { closeSync as closeSync3, fchmodSync, lstatSync as lstatSync4, mkdirSync as mkdirSync11, openSync as openSync3, readdirSync as readdirSync2, readFileSync as readFileSync21, readlinkSync, realpathSync as realpathSync5, rmSync as rmSync7, symlinkSync, writeSync as writeSync2 } from "node:fs";
15424
- import { homedir as homedir5 } from "node:os";
16743
+ import { closeSync as closeSync3, fchmodSync, lstatSync as lstatSync4, mkdirSync as mkdirSync11, openSync as openSync3, readdirSync as readdirSync2, readFileSync as readFileSync21, readlinkSync, realpathSync as realpathSync6, rmSync as rmSync7, symlinkSync, writeSync as writeSync2 } from "node:fs";
16744
+ import { homedir as homedir6 } from "node:os";
15425
16745
  import { join as join24, resolve as resolve7 } from "node:path";
15426
16746
  var RESTORE_USAGE = `${HALFCYCLE_COMMAND} restore [<id>]`;
15427
16747
  function parseRestoreArgs(rest) {
@@ -15444,7 +16764,7 @@ function runRestore(cwd, id, options = {}) {
15444
16764
  if (engagementId === null) {
15445
16765
  return refused2(`Halfcycle is not set up in this project, so it has saved nothing here. To set it up, run: ${HALFCYCLE_COMMAND}`);
15446
16766
  }
15447
- const folder = join24(options.home ?? homedir5(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME, engagementId, SAVED_DIR_NAME);
16767
+ const folder = join24(options.home ?? homedir6(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME, engagementId, SAVED_DIR_NAME);
15448
16768
  const refs = savedRefs(root);
15449
16769
  if (refs === null)
15450
16770
  return refused2("Halfcycle could not read the saved copies in this project, so nothing was put back.");
@@ -15830,7 +17150,7 @@ function lstatOrNull3(p) {
15830
17150
  }
15831
17151
  function realOrSelf(p) {
15832
17152
  try {
15833
- return realpathSync5(p);
17153
+ return realpathSync6(p);
15834
17154
  } catch {
15835
17155
  return p;
15836
17156
  }
@@ -15877,6 +17197,15 @@ function reportClaudeCodeVersion(verbose2, deps = {}) {
15877
17197
  process.stdout.write(`[halfcycle] WARNING: Claude Code ${version} is older than ${MIN_HEADERS_HELPER_VERSION}, which ignores the credential helper this install wrote. The Halfcycle server will connect with no credential and every call will fail \u2014 ${upgradeInstruction}, then re-open this folder.
15878
17198
  `);
15879
17199
  }
17200
+ function reportCodexVersion(deps = {}) {
17201
+ const version = probeCodexVersion(5e3, deps);
17202
+ if (version === void 0)
17203
+ return;
17204
+ if (compareVersions(version, CODEX_MIN_VERSION) < 0) {
17205
+ process.stdout.write(`${codexVersionWarningLine(version)}
17206
+ `);
17207
+ }
17208
+ }
15880
17209
  function shortId(id) {
15881
17210
  return id.length > 10 ? `${id.slice(0, 8)}\u2026` : id;
15882
17211
  }
@@ -16310,6 +17639,7 @@ ${PROJECT_RESET_LINE}
16310
17639
  `);
16311
17640
  }
16312
17641
  reportClaudeCodeVersion(verbose);
17642
+ reportCodexVersion();
16313
17643
  const identity = actingIdentity ?? heldAnswer?.identity ?? (credential && heldAnswer === void 0 ? await describeAccount(credential.serviceUrl, credential.token) : void 0);
16314
17644
  const written = result.writtenPaths.length;
16315
17645
  const merged = result.mergedPaths.length;
@@ -16336,6 +17666,10 @@ ${PROJECT_RESET_LINE}
16336
17666
  process.stdout.write(`[halfcycle] Next: open this folder in Claude Code \u2014 accept the workspace-trust prompt, it is expected \u2014 then say "set up this project" and answer what it asks about your project
16337
17667
  `);
16338
17668
  process.stdout.write(`[halfcycle] Claude Code will then ask you to approve the new "halfcycle" MCP server. If it is the only one listed, the cursor starts on "Continue without using this MCP server" \u2014 choose "Use this MCP server" instead. If several are listed together, halfcycle is already checked \u2014 leave it that way. Either way, without it Halfcycle will not connect.
17669
+ `);
17670
+ const codexLine2 = codexInstallLine(result.codex);
17671
+ if (codexLine2 !== void 0)
17672
+ process.stdout.write(`${codexLine2}
16339
17673
  `);
16340
17674
  }
16341
17675
  const consoleUrl = credential?.consoleUrl ?? (reusedWithoutFreshAnswer ? readBundlePin(targetRepo)?.projectPage : void 0);