halfcycle 0.4.17 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -4851,7 +4962,8 @@ var chatReviewAcceptRequestSchema = z30.object({
4851
4962
  act: z30.literal("accept"),
4852
4963
  reference: phaseIdentitySchema,
4853
4964
  reviewVersion: z30.number().int().min(0),
4854
- parts: z30.array(systemPartIdSchema).max(REVIEW_PARTS_MAX)
4965
+ parts: z30.array(systemPartIdSchema).max(REVIEW_PARTS_MAX),
4966
+ waitingClaims: z30.literal(true).optional()
4855
4967
  }).strict();
4856
4968
  function notedRequest(act) {
4857
4969
  return z30.object({
@@ -4866,12 +4978,20 @@ var chatReviewQuestionRequestSchema = notedRequest("question");
4866
4978
  var chatReviewSendBackRequestSchema = notedRequest("send-back");
4867
4979
  var chatReviewCarryRequestSchema = notedRequest("carry");
4868
4980
  var chatReviewDropRequestSchema = notedRequest("drop");
4981
+ var chatReviewRejectRequestSchema = z30.object({
4982
+ act: z30.literal("reject"),
4983
+ reference: phaseIdentitySchema,
4984
+ reviewVersion: z30.number().int().min(0),
4985
+ task: buildTaskIdSchema,
4986
+ note: reviewNoteWireSchema
4987
+ }).strict();
4869
4988
  var chatReviewRequestSchema = z30.discriminatedUnion("act", [
4870
4989
  chatReviewAcceptRequestSchema,
4871
4990
  chatReviewQuestionRequestSchema,
4872
4991
  chatReviewSendBackRequestSchema,
4873
4992
  chatReviewCarryRequestSchema,
4874
- chatReviewDropRequestSchema
4993
+ chatReviewDropRequestSchema,
4994
+ chatReviewRejectRequestSchema
4875
4995
  ]);
4876
4996
  function bareOutcome2(outcome) {
4877
4997
  return z30.object({ outcome: z30.literal(outcome) }).strict();
@@ -4884,7 +5004,8 @@ var chatReviewResultSchema = z30.discriminatedUnion("outcome", [
4884
5004
  ...reviewDecisionResultSchema.options.filter(notTheConsoleNotDecidable),
4885
5005
  bareOutcome2("show-first"),
4886
5006
  bareOutcome2("not-offered"),
4887
- chatAmbiguousSchema
5007
+ chatAmbiguousSchema,
5008
+ bareOutcome2("nothing-waiting")
4888
5009
  ]);
4889
5010
  var reviewAnswerRequestSchema = z30.object({
4890
5011
  part: systemPartIdSchema,
@@ -5348,8 +5469,8 @@ function autonomyA13(accepted, carried) {
5348
5469
  return `Your autonomy setting accepted the ${String(accepted)} built parts and carried ${String(carried)} unbuilt parts forward. The phase is closed.`;
5349
5470
  }
5350
5471
  function autonomyA14(count, parts, acceptLine) {
5351
- const typed = acceptLine === null ? 'say "review what was built" and type the lines it gives.' : `say "review what was built", then type ${acceptLine}.`;
5352
- 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 the console's review; that cannot be done by typing in chat. Then decide those parts there, or in your own Claude Code session: ` + typed;
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.`;
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 Claude Code session, ` + typed;
5353
5474
  }
5354
5475
  function autonomyA15(count) {
5355
5476
  return `The phase waits for you: ${String(count)} parts were built without an approved scope, and only you can decide them.`;
@@ -5853,7 +5974,14 @@ function serializeManifest(manifest) {
5853
5974
  files: [...manifest.files].sort((a, b) => byString(a.path, b.path)),
5854
5975
  createdDirs: [...manifest.createdDirs].sort(byString),
5855
5976
  leftAlone: [...manifest.leftAlone].sort(byString),
5856
- 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
+ } : {}
5857
5985
  };
5858
5986
  return JSON.stringify(canonicalJson2(ordered), null, 2) + "\n";
5859
5987
  }
@@ -5907,7 +6035,7 @@ function parseManifest(text) {
5907
6035
  }
5908
6036
  if (!isRecord2(raw))
5909
6037
  throw new UnreadableManifestError("it is not a JSON object");
5910
- 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");
5911
6039
  if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
5912
6040
  throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
5913
6041
  }
@@ -5973,6 +6101,30 @@ function parseManifest(text) {
5973
6101
  ...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
5974
6102
  };
5975
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
+ }
5976
6128
  if (raw["gitignore"] === void 0)
5977
6129
  throw new UnreadableManifestError('"gitignore" is missing');
5978
6130
  const gitignore = appendedTextRecord(raw["gitignore"], "gitignore");
@@ -5991,6 +6143,8 @@ function parseManifest(text) {
5991
6143
  leftAlone,
5992
6144
  settings,
5993
6145
  ...mcp !== void 0 ? { mcp } : {},
6146
+ ...codexHooks !== void 0 ? { codexHooks } : {},
6147
+ ...codexConfig !== void 0 ? { codexConfig } : {},
5994
6148
  gitignore,
5995
6149
  ...gitattributes !== void 0 ? { gitattributes } : {}
5996
6150
  };
@@ -6008,6 +6162,8 @@ function startManifest(previous, isMember, isMemberDir) {
6008
6162
  createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
6009
6163
  settings: previous?.settings,
6010
6164
  mcp: previous?.mcp,
6165
+ codexHooks: previous?.codexHooks,
6166
+ codexConfig: previous?.codexConfig,
6011
6167
  gitignore: previous?.gitignore,
6012
6168
  gitattributes: previous?.gitattributes
6013
6169
  };
@@ -6068,6 +6224,19 @@ function recordMcp(draft, observed) {
6068
6224
  ...entrySha256 !== void 0 ? { entrySha256 } : {}
6069
6225
  };
6070
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
+ }
6071
6240
  function recordGitignore(draft, created, appended) {
6072
6241
  draft.gitignore = carryAppended(draft.gitignore, created, appended);
6073
6242
  }
@@ -6101,6 +6270,8 @@ function finishManifest(draft) {
6101
6270
  denyCreated: false
6102
6271
  },
6103
6272
  ...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
6273
+ ...draft.codexHooks !== void 0 ? { codexHooks: draft.codexHooks } : {},
6274
+ ...draft.codexConfig !== void 0 ? { codexConfig: draft.codexConfig } : {},
6104
6275
  gitignore: draft.gitignore ?? { created: false, appended: [] },
6105
6276
  ...draft.gitattributes !== void 0 ? { gitattributes: draft.gitattributes } : {}
6106
6277
  };
@@ -6225,6 +6396,30 @@ var PREVIEW_WORDS = Object.freeze({
6225
6396
  change: "takes out an older Halfcycle sign-in; your own lines stay as they are",
6226
6397
  leave: "an older Halfcycle sign-in that cannot be moved yet; left as it is",
6227
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
6228
6423
  }
6229
6424
  });
6230
6425
  var kindTable;
@@ -6249,13 +6444,21 @@ function kinds() {
6249
6444
  [RETIRED_MCP_HEADERS_HELPER_REL, "halfcycle-folder"],
6250
6445
  [LEGACY_CI_STANZA_REL, "halfcycle-folder"],
6251
6446
  [CAPTURED_INDEX_REL, "captured-index"],
6252
- [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"]
6253
6450
  ]);
6254
6451
  return kindTable;
6255
6452
  }
6256
6453
  function previewKindOf(rel) {
6257
6454
  if (/^\.claude\/commands\/[^/]+\.md$/.test(rel))
6258
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";
6259
6462
  return kinds().get(rel);
6260
6463
  }
6261
6464
  function kindOf(rel) {
@@ -6281,6 +6484,7 @@ function planInstall(options) {
6281
6484
  const previous = readPreviousManifest(targetRepo);
6282
6485
  const dirsBefore = new Set([...writeSet.dirs].filter((dir) => existsSync3(join5(targetRepo, dir))));
6283
6486
  const { version, commands } = planCommandStubs(targetRepo, previous, writeSet);
6487
+ const codexSkills = planCodexSkills(targetRepo, previous, writeSet);
6284
6488
  const vendored = planVendoredBinary(targetRepo);
6285
6489
  let settings;
6286
6490
  try {
@@ -6294,6 +6498,8 @@ function planInstall(options) {
6294
6498
  const ciStanza = planLegacyCiStanzaRemoval(targetRepo);
6295
6499
  const mcp = planMcpRegistration(targetRepo, preview ? "pending" : credential);
6296
6500
  const mcpHelper = mcp.kind === "none" ? null : planMcpHeadersHelperRemoval(targetRepo);
6501
+ const codexHooks = planCodexHooks(targetRepo);
6502
+ const codexConfig = planCodexConfig(targetRepo, preview ? "pending" : credential);
6297
6503
  const gitignore = planGitignore(targetRepo);
6298
6504
  const gitattributes = planGitattributes(targetRepo);
6299
6505
  const identity = planProjectIdentity(targetRepo);
@@ -6305,6 +6511,7 @@ function planInstall(options) {
6305
6511
  const legacy = migrateLegacyEnvLocal(targetRepo, storedAfter);
6306
6512
  const pin = preview ? planBundlePinPreview(targetRepo, version, home) : planBundlePin(targetRepo, version, engagementId, engagementType, createdByAccountId, credential?.methodVersion);
6307
6513
  const crew = planCrewRoster(targetRepo);
6514
+ const crewProfiles = planCrewProfiles(targetRepo, previous, writeSet);
6308
6515
  const scan = { text: planBootstrapScan(targetRepo).text };
6309
6516
  const steps = {
6310
6517
  targetRepo,
@@ -6317,12 +6524,15 @@ function planInstall(options) {
6317
6524
  previous,
6318
6525
  dirsBefore,
6319
6526
  commands,
6527
+ codexSkills,
6320
6528
  vendored,
6321
6529
  settings,
6322
6530
  hooks,
6323
6531
  ciStanza,
6324
6532
  mcp,
6325
6533
  mcpHelper,
6534
+ codexHooks,
6535
+ codexConfig,
6326
6536
  gitignore,
6327
6537
  gitattributes,
6328
6538
  identity,
@@ -6330,6 +6540,7 @@ function planInstall(options) {
6330
6540
  legacy,
6331
6541
  pin,
6332
6542
  crew,
6543
+ crewProfiles,
6333
6544
  scan
6334
6545
  };
6335
6546
  const record2 = preview ? null : planInstallRecord(targetRepo, accountInstall(steps, predicted(steps)).manifestText);
@@ -6355,16 +6566,24 @@ function predicted(steps) {
6355
6566
  lands(planned.rel, planned.text, planned.outcome === "written");
6356
6567
  };
6357
6568
  steps.commands.forEach(file);
6569
+ steps.codexSkills.forEach(file);
6358
6570
  file(steps.vendored);
6359
6571
  file(steps.settings.file);
6360
6572
  steps.hooks.forEach(file);
6361
6573
  if (steps.mcp.kind === "write")
6362
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
+ }
6363
6581
  if (steps.identity.after !== null)
6364
6582
  lands(PROJECT_IDENTITY_REL, steps.identity.after, steps.identity.action === "add");
6365
6583
  if (steps.pin !== null)
6366
6584
  file(steps.pin);
6367
6585
  file(steps.crew);
6586
+ steps.crewProfiles.forEach(file);
6368
6587
  if (steps.scan.text !== null)
6369
6588
  lands(BOOTSTRAP_STATE_REL, steps.scan.text, true);
6370
6589
  const mergeAfter = (planned) => planned.outcome === "written" ? planned.after : planned.before;
@@ -6422,6 +6641,7 @@ function entriesOf(steps, record2) {
6422
6641
  entries.push({ path: rel, action: there ? "change" : "add", kind: kindOf(rel), bytes: PENDING });
6423
6642
  };
6424
6643
  steps.commands.forEach(fromFile);
6644
+ steps.codexSkills.forEach(fromFile);
6425
6645
  fromFile(steps.vendored);
6426
6646
  const settings = steps.settings;
6427
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 });
@@ -6437,6 +6657,38 @@ function entriesOf(steps, record2) {
6437
6657
  entries.push({ path: MCP_REGISTRATION_REL, action: "leave", kind: "server-entry", bytes: null });
6438
6658
  }
6439
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
+ }
6440
6692
  merge(GITIGNORE_REL, steps.gitignore);
6441
6693
  merge(GITATTRIBUTES_REL, steps.gitattributes);
6442
6694
  const identity = steps.identity;
@@ -6459,6 +6711,7 @@ function entriesOf(steps, record2) {
6459
6711
  else
6460
6712
  fromFile(steps.pin);
6461
6713
  fromFile(steps.crew);
6714
+ steps.crewProfiles.forEach(fromFile);
6462
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 });
6463
6716
  if (record2 === null)
6464
6717
  pending(INSTALL_MANIFEST_REL);
@@ -6499,6 +6752,8 @@ function applyInstallPlan(plan) {
6499
6752
  }
6500
6753
  for (const command of steps.commands)
6501
6754
  putPlannedFile(targetRepo, command);
6755
+ for (const skill of steps.codexSkills)
6756
+ putPlannedFile(targetRepo, skill);
6502
6757
  putPlannedFile(targetRepo, steps.vendored);
6503
6758
  putPlannedFile(targetRepo, steps.settings.file);
6504
6759
  for (const hook of steps.hooks)
@@ -6506,6 +6761,8 @@ function applyInstallPlan(plan) {
6506
6761
  const ciStanza = steps.ciStanza === "removed" ? removePlannedRetiredFile(targetRepo, LEGACY_CI_STANZA_REL) : steps.ciStanza;
6507
6762
  putPlannedMcp(targetRepo, steps.mcp);
6508
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);
6509
6766
  const gitignore = putPlannedGitignore(targetRepo, steps.gitignore);
6510
6767
  const gitignorePath = join5(targetRepo, GITIGNORE_REL);
6511
6768
  const gitignoreAfter = existsSync3(gitignorePath) ? readFileSync5(gitignorePath, "utf-8") : null;
@@ -6518,6 +6775,8 @@ function applyInstallPlan(plan) {
6518
6775
  const legacy = applyLegacyMigration(targetRepo, migrateLegacyEnvLocal(targetRepo, stored));
6519
6776
  writeBundlePin(targetRepo, pin, []);
6520
6777
  putPlannedFile(targetRepo, steps.crew);
6778
+ for (const profile of steps.crewProfiles)
6779
+ putPlannedFile(targetRepo, profile);
6521
6780
  if (steps.scan.text !== null)
6522
6781
  writeBootstrapState(targetRepo, steps.scan.text);
6523
6782
  const { report, installManifest, manifestText } = accountInstall(steps, {
@@ -6547,9 +6806,15 @@ function applyInstallPlan(plan) {
6547
6806
  replacedPaths: report.replacedPaths,
6548
6807
  bootstrapScanRan: steps.scan.text !== null,
6549
6808
  legacyCredentialKept: legacy.outcome === "kept" ? { keys: legacy.unbacked, missing: legacy.missingForAdoption } : null,
6550
- notShared
6809
+ notShared,
6810
+ codex: { hooks: steps.codexHooks.outcome, config: appliedCodexConfig(steps.codexConfig.outcome) }
6551
6811
  };
6552
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
+ }
6553
6818
 
6554
6819
  // dist/mcp-endpoint.js
6555
6820
  var MCP_ENDPOINT_PATH = "/mcp";
@@ -6560,8 +6825,8 @@ function mcpEndpointUrl(origin) {
6560
6825
  // dist/node-floor.js
6561
6826
  var NODE_FLOOR_MAJOR = 18;
6562
6827
  var NODE_FLOOR_SENTENCE_HEAD = `Halfcycle needs Node ${NODE_FLOOR_MAJOR} or newer; you have `;
6563
- function nodeFloorProgram(guarded) {
6564
- 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}}`;
6565
6830
  }
6566
6831
 
6567
6832
  // dist/merge-settings.js
@@ -6608,6 +6873,75 @@ function mergeSettings(existing, generated, retiredOurs, statusLine) {
6608
6873
  }
6609
6874
  return merged;
6610
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
+ }
6611
6945
  function mergePermissions(existing, generated, retiredOurs) {
6612
6946
  if (existing === void 0 && generated === void 0)
6613
6947
  return void 0;
@@ -6630,6 +6964,168 @@ function mergePermissions(existing, generated, retiredOurs) {
6630
6964
  return result;
6631
6965
  }
6632
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
+
6633
7129
  // dist/install.js
6634
7130
  function renderNotShared(finding) {
6635
7131
  const rule = `rule "${finding.pattern}" in ${finding.source}, line ${finding.line}`;
@@ -6639,6 +7135,97 @@ function renderNotShared(finding) {
6639
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.`;
6640
7136
  }
6641
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
+ }
6642
7229
  var __filename = fileURLToPath(import.meta.url);
6643
7230
  var __dirname = dirname3(__filename);
6644
7231
  function findBundleRoot(startDir) {
@@ -6690,13 +7277,23 @@ var WRITE_ALLOWLIST = [
6690
7277
  "test/fixtures/captured",
6691
7278
  MCP_REGISTRATION_REL,
6692
7279
  ".gitattributes",
6693
- 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)
6694
7290
  ];
7291
+ var CREW_PROFILE_RELS = new Set(renderCrewProfiles().map((p) => p.rel));
6695
7292
  function isAllowlisted(targetRelPath) {
6696
7293
  const normalised = targetRelPath.replace(/\\/g, "/");
6697
7294
  return WRITE_ALLOWLIST.some((prefix) => {
6698
7295
  const p = prefix.replace(/\\/g, "/");
6699
- return normalised === p || normalised.startsWith(p + "/");
7296
+ return normalised === p || !CREW_PROFILE_RELS.has(p) && normalised.startsWith(p + "/");
6700
7297
  });
6701
7298
  }
6702
7299
  var CREW_ROSTER_REL = ".halfcycle/crew.json";
@@ -6709,6 +7306,12 @@ var GITATTRIBUTES_REL = ".gitattributes";
6709
7306
  function commandStubRel(name) {
6710
7307
  return `.claude/commands/${name}.md`;
6711
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
+ }
6712
7315
  var RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
6713
7316
  var RETIRED_WRITE_SET_FILES = [RETIRED_MCP_HEADERS_HELPER_REL];
6714
7317
  var RETIRED_DENY_PATTERNS = [
@@ -6727,6 +7330,9 @@ function closedWriteSet() {
6727
7330
  const commands = readPluginManifest().commands ?? [];
6728
7331
  const files = /* @__PURE__ */ new Set([
6729
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)),
6730
7336
  ...Object.keys(OWNED_GENERATED_HEADERS),
6731
7337
  VENDORED_BIN_REL,
6732
7338
  CREW_ROSTER_REL,
@@ -6735,9 +7341,12 @@ function closedWriteSet() {
6735
7341
  BOOTSTRAP_STATE_REL,
6736
7342
  CAPTURED_INDEX_REL,
6737
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),
6738
7347
  ...RETIRED_WRITE_SET_FILES
6739
7348
  ]);
6740
- 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]);
6741
7350
  const dirs = /* @__PURE__ */ new Set();
6742
7351
  for (const path of [...files, ...merged]) {
6743
7352
  for (let dir = posix2.dirname(path); dir !== "."; dir = posix2.dirname(dir))
@@ -6750,6 +7359,7 @@ function closedWriteSet() {
6750
7359
  dirs,
6751
7360
  ownedDirs,
6752
7361
  hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
7362
+ codexHookCommands: new Set(AGENT_PROFILES.codex.commands),
6753
7363
  denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
6754
7364
  settingsSchema: SETTINGS_SCHEMA,
6755
7365
  gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
@@ -6767,10 +7377,17 @@ function sha256OfWhatThisVersionWrites(commands) {
6767
7377
  if (existsSync4(src))
6768
7378
  out.set(rel, fileSha256(readFileSync6(src)));
6769
7379
  };
6770
- for (const cmd of commands)
6771
- 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
+ }
6772
7387
  for (const [rel, content] of hookScriptsThisVersionWrites())
6773
7388
  out.set(rel, fileSha256(content));
7389
+ for (const profile of renderCrewProfiles())
7390
+ out.set(profile.rel, fileSha256(Buffer.from(profile.text, "utf-8")));
6774
7391
  fromFile(CAPTURED_INDEX_REL, capturedIndexScaffold());
6775
7392
  return out;
6776
7393
  }
@@ -6874,6 +7491,8 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
6874
7491
  const merged = [
6875
7492
  SETTINGS_REL,
6876
7493
  ...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
7494
+ ...manifest.codexHooks !== void 0 ? [CODEX_HOOKS_REL2] : [],
7495
+ ...manifest.codexConfig !== void 0 ? [CODEX_CONFIG_REL2] : [],
6877
7496
  ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : [],
6878
7497
  ...manifest.gitattributes !== void 0 && (manifest.gitattributes.created || manifest.gitattributes.appended.length > 0) ? [GITATTRIBUTES_REL] : []
6879
7498
  ];
@@ -6934,6 +7553,55 @@ function readPluginManifest() {
6934
7553
  const raw = readFileSync6(manifestPath, "utf-8");
6935
7554
  return JSON.parse(raw);
6936
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
+ }
6937
7605
  function planManifestCommands(manifest, targetRepo, previous, writeSet) {
6938
7606
  const recordedFiles = new Map((previous?.files ?? []).filter((file) => writeSet.files.has(file.path)).map((file) => [file.path, file]));
6939
7607
  return (manifest.commands ?? []).map((cmd) => {
@@ -7112,6 +7780,16 @@ function generateSettingsJson() {
7112
7780
  };
7113
7781
  return JSON.stringify(settings, null, 2) + "\n";
7114
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
+ }
7115
7793
  var GENERATED_GUARD_RUNNER_HEADER = "# Governance hook wrapper \u2014 generated by";
7116
7794
  function generateGuardRunnerWrapper(preFilter = resolvePreFilterShell()) {
7117
7795
  return `#!/usr/bin/env bash
@@ -7164,9 +7842,11 @@ ${preFilter.trimEnd()}
7164
7842
  # Before a tool runs, on a bash new enough for the cheap pass: read the call
7165
7843
  # once; when no risky action can be in it, answer here (the pass writes the
7166
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.
7167
7847
  if [ "$1" = hook ] && [ "$2" = pre-tool-use ] && [ "\${BASH_VERSINFO[0]:-0}" -ge 5 ]; then
7168
7848
  hc_input=$(cat 2>/dev/null) || IFS= read -r -d '' hc_input
7169
- ${PRE_FILTER_FUNCTION} && exit 0
7849
+ ${PRE_FILTER_FUNCTION} "$@" && exit 0
7170
7850
  node -e "${nodeFloorProgram("")}" || {
7171
7851
  printf '%s' "$hc_input" | halfcycle_pre_tool_use_fallback
7172
7852
  exit 0
@@ -7406,27 +8086,33 @@ exit 0
7406
8086
  var GENERATED_USER_PROMPT_SCOPE_HEADER = "# Typed scope approval \u2014 generated by";
7407
8087
  function generateUserPromptScopeHook() {
7408
8088
  return `#!/usr/bin/env bash
7409
- ${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.
7410
8090
  #
7411
8091
  # Lets the owner of this project approve a phase's scope, or send it back,
7412
8092
  # decide one part of a phase's close review, or turn builds running on their
7413
8093
  # own on or off, by typing one line as the whole prompt in their own Claude
7414
8094
  # Code session: "approve <phase>", "send back <phase>: <note>", "accept
7415
8095
  # <phase>/<part>", "question <phase>/<part>: <words>", "carry <phase>/<part>:
7416
- # <words>", "drop <phase>/<part>: <words>", "autonomy on" or "autonomy off".
8096
+ # <words>", "drop <phase>/<part>: <words>", "reject <phase>/<task>: <words>",
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.
7417
8099
  #
7418
8100
  # AN ORDINARY PROMPT STARTS NOTHING. This script reads the hook input once and
7419
8101
  # looks only at the start of the prompt's value. Unless the prompt begins, after
7420
8102
  # any whitespace (typed, or escaped in the input), with "approve ", "send back ",
7421
- # "accept ", "question ", "carry ", "drop " or "autonomy ", in any case, it exits
7422
- # 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
7423
8106
  # escaped, so no other field (the folder the session is in, for one) can read as
7424
8107
  # the start of the prompt.
7425
8108
  #
7426
8109
  # Otherwise it finds the project root from its own location, never from the
7427
8110
  # folder the session has moved to, and hands the same input to the guard
7428
8111
  # wrapper beside it. That decides whether the line counts and prints at most
7429
- # 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.
7430
8116
  #
7431
8117
  # FAILS OPEN up to that hand-over: any error before it exits 0 with no output.
7432
8118
  # After it, the wrapper's own exit stands.
@@ -7442,7 +8128,7 @@ IFS= read -r -d '' HC_INPUT || true
7442
8128
  # \\u escape, or as the UTF-8 bytes of a Unicode space. Compared byte for byte.
7443
8129
  HC_LEAD='[[:space:]]|\\\\[nrtf]|\\\\u(000[9a-d]|0020|00a0|1680|200[0-9a]|202[89f]|205f|3000|feff)'
7444
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'
7445
- if ! printf '%s' "$HC_INPUT" | LC_ALL=C grep -Eiq "\\"prompt\\"[[:space:]]*:[[:space:]]*\\"($HC_LEAD)*(approve|send back|accept|question|carry|drop|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
7446
8132
  exit 0
7447
8133
  fi
7448
8134
 
@@ -7452,16 +8138,17 @@ export CLAUDE_PROJECT_DIR="$HC_PROJECT_ROOT"
7452
8138
 
7453
8139
  trap - ERR
7454
8140
  set +e
7455
- 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" "$@"
7456
8142
  `;
7457
8143
  }
7458
8144
  var GENERATED_USER_PROMPT_FINDINGS_HEADER = "# Findings hand-over \u2014 generated by";
7459
8145
  function generateUserPromptFindingsHook() {
7460
8146
  return `#!/usr/bin/env bash
7461
- ${GENERATED_USER_PROMPT_FINDINGS_HEADER} the Halfcycle installer.
8147
+ ${GENERATED_USER_PROMPT_FINDINGS_HEADER} the Halfcycle installer, now also for Codex.
7462
8148
  #
7463
8149
  # At the person's next message, before the agent starts, hands it the details of
7464
- # 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.
7465
8152
  #
7466
8153
  # AN ORDINARY PROMPT STARTS NOTHING. With no findings file for this session, this
7467
8154
  # script reads its session id with a bash regex (no node) and exits 0 with no
@@ -7498,7 +8185,7 @@ export CLAUDE_PROJECT_DIR="$HC_PROJECT_ROOT"
7498
8185
 
7499
8186
  trap - ERR
7500
8187
  set +e
7501
- 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" "$@"
7502
8189
  `;
7503
8190
  }
7504
8191
  var CI_STANZA_KNOWN_HEADER_HASHES = [
@@ -7577,6 +8264,160 @@ var MCP_HEADERS_HELPER_COMMAND = `node -e "${nodeFloorProgram([
7577
8264
  `console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
7578
8265
  `process.exit(1)`
7579
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
+ }
7580
8421
  var MCP_HEADERS_KNOWN_HEADER_HASHES = [
7581
8422
  "37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
7582
8423
  "6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
@@ -7991,6 +8832,21 @@ function planCommandStubs(targetRepo, previous, writeSet) {
7991
8832
  const manifest = readPluginManifest();
7992
8833
  return { version: manifest.version, commands: planManifestCommands(manifest, targetRepo, previous, writeSet) };
7993
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
+ }
7994
8850
  function planVendoredBinary(targetRepo) {
7995
8851
  return decideOwned(join6(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs"), targetRepo, readFileSync6(resolveVendoredBinary(), "utf-8"));
7996
8852
  }
@@ -8038,9 +8894,156 @@ function putPlannedMcp(targetRepo, planned) {
8038
8894
  return;
8039
8895
  writeAllowlisted(join6(targetRepo, MCP_REGISTRATION_REL), targetRepo, planned.text, []);
8040
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
+ }
8041
9032
  function planCrewRoster(targetRepo) {
8042
9033
  return decideOwned(join6(targetRepo, CREW_ROSTER_REL), targetRepo, renderCrewRoster());
8043
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
+ }
8044
9047
  function planInstallRecord(targetRepo, text) {
8045
9048
  return decideOwned(join6(targetRepo, INSTALL_MANIFEST_REL), targetRepo, text);
8046
9049
  }
@@ -8064,6 +9067,12 @@ function accountInstall(steps, actual) {
8064
9067
  report.editedCollidedPaths.push(command.rel);
8065
9068
  noteFile(draft, command.rel, command.outcome, actual.bytesAfter);
8066
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
+ }
8067
9076
  noteFile(draft, VENDORED_BIN_REL, recordOwnedGenerated(report, steps.vendored), actual.bytesAfter);
8068
9077
  const settings = steps.settings;
8069
9078
  recordSettingsMerge(draft, settings.existing, settings.generated, settings.preexisted, settings.merged);
@@ -8121,6 +9130,8 @@ function accountInstall(steps, actual) {
8121
9130
  if (mcp.existing !== null)
8122
9131
  recordMcpAdoptionOnly(draft, mcp.existing);
8123
9132
  }
9133
+ recordCodexHooksStep(report, draft, steps.codexHooks);
9134
+ recordCodexConfigStep(report, draft, steps.codexConfig);
8124
9135
  recordGitignoreReconcile(draft, steps.gitignore.before, actual.gitignoreAfter, writeSet);
8125
9136
  if (actual.gitignore === "failed") {
8126
9137
  report.skippedPaths.push(".gitignore (write failed \u2014 see credential refusal)");
@@ -8163,6 +9174,12 @@ function accountInstall(steps, actual) {
8163
9174
  report.writtenPaths.push(BUNDLE_PIN_REL);
8164
9175
  noteFile(draft, BUNDLE_PIN_REL, "written", actual.bytesAfter);
8165
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
+ }
8166
9183
  if (actual.stateExistsAfter)
8167
9184
  recordPerMachineFile(draft, BOOTSTRAP_STATE_REL);
8168
9185
  for (const dir of writeSet.dirs) {
@@ -8173,11 +9190,79 @@ function accountInstall(steps, actual) {
8173
9190
  assertManifestInWriteSet(installManifest, writeSet);
8174
9191
  return { report, installManifest, manifestText: serializeManifest(installManifest) };
8175
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
+ }
8176
9253
  function recordInstallRecord(report, planned) {
8177
9254
  recordOwnedGenerated(report, planned);
8178
9255
  }
8179
9256
  var SELF_BUILD_GITIGNORE_REL = ".halfcycle/.gitignore";
8180
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
+ ];
8181
9266
  function mcpHasHalfcycleEntry(text) {
8182
9267
  const parsed = JSON.parse(text);
8183
9268
  if (parsed === null || typeof parsed !== "object")
@@ -8185,6 +9270,14 @@ function mcpHasHalfcycleEntry(text) {
8185
9270
  const servers = parsed.mcpServers;
8186
9271
  return servers !== null && typeof servers === "object" && MCP_SERVER_KEY in servers;
8187
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
+ }
8188
9281
  function installSelfBuild(options) {
8189
9282
  const { targetRepo, engagementId, credential, freshlyMinted, home } = options;
8190
9283
  assertEngagementId(engagementId);
@@ -8219,6 +9312,7 @@ function installSelfBuild(options) {
8219
9312
  if (!existsSync4(at(CREW_ROSTER_REL))) {
8220
9313
  writeCrewRoster(targetRepo, report);
8221
9314
  }
9315
+ writeMissingSelfBuildCrewProfiles(targetRepo, report);
8222
9316
  if (!existsSync4(at(BUNDLE_PIN_REL))) {
8223
9317
  writeBundlePin(targetRepo, planBundlePin(targetRepo, version, engagementId, "self-build", void 0, credential.methodVersion), report.writtenPaths);
8224
9318
  }
@@ -9778,7 +10872,8 @@ function renderPreview(plan, missingGitForWindows, claudeMdWords) {
9778
10872
 
9779
10873
  // dist/doctor.js
9780
10874
  import { spawnSync as spawnSync2 } from "node:child_process";
9781
- 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";
9782
10877
  import { join as join10 } from "node:path";
9783
10878
  var GUARD_RUNNER_REL2 = ".claude/hooks/guard-runner.sh";
9784
10879
  var CONNECTION_VERB = "check-connection";
@@ -9802,8 +10897,10 @@ function runDoctor(root, options) {
9802
10897
  const lines = [setUp.line, connection.signedIn, connection.connected, checksRunning, version];
9803
10898
  const failing = lines.filter((line) => !line.ok && !line.cascading).length;
9804
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)];
9805
10902
  return {
9806
- text: [...lines.map((line) => line.text), closing].join("\n"),
10903
+ text: [...printed.map((line) => line.text), closing].join("\n"),
9807
10904
  exitCode: failing === 0 ? 0 : 1
9808
10905
  };
9809
10906
  }
@@ -9993,6 +11090,88 @@ function versionNoLine(installed, current) {
9993
11090
  }
9994
11091
  return `Up to date: no. This folder has Halfcycle ${installed}; the newest is ${current}. To update, run ${HALFCYCLE_COMMAND} here.`;
9995
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
+ }
9996
11175
  function notChecked(check, earlier) {
9997
11176
  return { ok: false, cascading: true, text: `${check}: not checked, because ${earlier} is not working yet.` };
9998
11177
  }
@@ -10167,19 +11346,18 @@ async function probeMcpOrigin(mcpUrl, token) {
10167
11346
 
10168
11347
  // dist/claude-version-probe.js
10169
11348
  import { execFileSync as execFileSync3 } from "node:child_process";
10170
- import { existsSync as existsSync8, realpathSync as realpathSync3 } from "node:fs";
11349
+ import { existsSync as existsSync8, realpathSync as realpathSync4 } from "node:fs";
10171
11350
  import { delimiter as delimiter2, join as join12 } from "node:path";
10172
- function probeClaudeCodeVersion(timeoutMs, deps = {}) {
11351
+ function execVersionCommand(command, timeoutMs, deps) {
10173
11352
  const platform6 = deps.platform ?? process.platform;
10174
11353
  const exec = deps.exec ?? execFileSync3;
10175
- let raw;
10176
11354
  try {
10177
- raw = platform6 === "win32" ? exec("cmd.exe", ["/d", "/s", "/c", '"claude --version"'], {
11355
+ return platform6 === "win32" ? exec("cmd.exe", ["/d", "/s", "/c", `"${command} --version"`], {
10178
11356
  encoding: "utf-8",
10179
11357
  timeout: timeoutMs,
10180
11358
  stdio: ["ignore", "pipe", "ignore"],
10181
11359
  windowsVerbatimArguments: true
10182
- }) : exec("claude", ["--version"], {
11360
+ }) : exec(command, ["--version"], {
10183
11361
  encoding: "utf-8",
10184
11362
  timeout: timeoutMs,
10185
11363
  stdio: ["ignore", "pipe", "ignore"]
@@ -10187,7 +11365,14 @@ function probeClaudeCodeVersion(timeoutMs, deps = {}) {
10187
11365
  } catch {
10188
11366
  return void 0;
10189
11367
  }
10190
- 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];
10191
11376
  }
10192
11377
  var CLAUDE_CODE_INSTALL_PAGE = "https://claude.com/claude-code";
10193
11378
  var UPGRADE_COMMANDS = {
@@ -10208,7 +11393,7 @@ function probeClaudeCodeInstallMethod(deps = {}) {
10208
11393
  const exists = deps.exists ?? existsSync8;
10209
11394
  const realpath = deps.realpath ?? ((file) => {
10210
11395
  try {
10211
- return realpathSync3(file);
11396
+ return realpathSync4(file);
10212
11397
  } catch {
10213
11398
  return file;
10214
11399
  }
@@ -10227,6 +11412,30 @@ function probeClaudeCodeInstallMethod(deps = {}) {
10227
11412
  return void 0;
10228
11413
  }
10229
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
+
10230
11439
  // dist/build-record/write.js
10231
11440
  import { existsSync as existsSync9, mkdirSync as mkdirSync7, rmSync as rmSync4 } from "node:fs";
10232
11441
  import { basename as basename2, join as join13, resolve as resolve3, relative as relative2, isAbsolute as isAbsolute3 } from "node:path";
@@ -10969,7 +12178,7 @@ import { existsSync as existsSync11, readFileSync as readFileSync16 } from "node
10969
12178
  import { join as join19 } from "node:path";
10970
12179
 
10971
12180
  // dist/draw/write.js
10972
- 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";
10973
12182
  import { dirname as dirname6, isAbsolute as isAbsolute4, join as join14, relative as relative3, resolve as resolve4, sep } from "node:path";
10974
12183
  var DOCS_DIR = "docs";
10975
12184
  var DIAGRAMS_DIR = "diagrams";
@@ -11057,7 +12266,7 @@ function isInside(outer, inner) {
11057
12266
  function checkDiagramPlace(paths) {
11058
12267
  let root;
11059
12268
  try {
11060
- root = realpathSync4(paths.repoRoot);
12269
+ root = realpathSync5(paths.repoRoot);
11061
12270
  } catch {
11062
12271
  return { ok: false, why: `the folder ${paths.repoRoot} could not be opened. Check the path, then run this again.` };
11063
12272
  }
@@ -11070,7 +12279,7 @@ function checkDiagramPlace(paths) {
11070
12279
  }
11071
12280
  let real;
11072
12281
  try {
11073
- real = realpathSync4(ancestor);
12282
+ real = realpathSync5(ancestor);
11074
12283
  } catch {
11075
12284
  return {
11076
12285
  ok: false,
@@ -12927,23 +14136,36 @@ function typedLine(act, reference, part) {
12927
14136
  return `drop ${reference}/${part}: <why>`;
12928
14137
  }
12929
14138
  }
12930
- function rv7Lines(view) {
14139
+ function rv7aLine(reference) {
14140
+ return `The line accept ${reference} also accepts every check nobody ran that is listed below. Reject a task's checks first if you do not accept them.`;
14141
+ }
14142
+ function tasksWithWaitingClaims(claims) {
14143
+ return [...new Set(claims.filter((c) => c.answer === null).map((c) => c.task))];
14144
+ }
14145
+ function rv7Lines(view, claims) {
12931
14146
  const reference = typedPhaseReference(view.phase, view.number);
12932
- if (view.parts.length === 0)
12933
- return [`This phase has no parts to review. To close it, type: accept ${reference}`];
14147
+ const claimTasks = tasksWithWaitingClaims(claims);
12934
14148
  const decidable = view.parts.filter((p) => p.decidable);
12935
- if (decidable.length === 0)
12936
- return ["No part waits for your decision now."];
14149
+ if (claimTasks.length === 0) {
14150
+ if (view.parts.length === 0)
14151
+ return [`This phase has no parts to review. To close it, type: accept ${reference}`];
14152
+ if (decidable.length === 0)
14153
+ return ["No part waits for your decision now."];
14154
+ }
12937
14155
  const lines = ["To decide, type one of these lines in your own Claude Code session:"];
12938
14156
  for (const part of decidable) {
12939
14157
  for (const act of reviewActsFor(part.built))
12940
14158
  lines.push(` ${typedLine(act, reference, part.part)}`);
12941
14159
  }
12942
- if (decidable.some((p) => p.built))
14160
+ for (const task of claimTasks)
14161
+ lines.push(` reject ${reference}/${task}: <why>`);
14162
+ if (decidable.some((p) => p.built) || claimTasks.length > 0)
12943
14163
  lines.push(` accept ${reference}`);
14164
+ if (claimTasks.length > 0)
14165
+ lines.push(rv7aLine(reference));
12944
14166
  return lines;
12945
14167
  }
12946
- function reviewListingLines(view) {
14168
+ function reviewListingLines(view, claims = []) {
12947
14169
  const lines = [rv1Line(view)];
12948
14170
  if (!view.counted)
12949
14171
  lines.push(RV1A_LINE);
@@ -12951,7 +14173,7 @@ function reviewListingLines(view) {
12951
14173
  lines.push(...observedPhaseLines(view.observed));
12952
14174
  for (const part of view.parts)
12953
14175
  lines.push(...partLines(part, view));
12954
- lines.push(...rv7Lines(view));
14176
+ lines.push(...rv7Lines(view, claims));
12955
14177
  return lines;
12956
14178
  }
12957
14179
  var CALLS_HEADING2 = "Calls made without asking you";
@@ -12976,21 +14198,17 @@ var CHECKS_SUBHEADING = "An agent says these pass and Halfcycle never ran them.
12976
14198
  function uncheckedClaimLine(test) {
12977
14199
  return `Unchecked: ${test.claimedBy} says "${test.label}" passes. Halfcycle never ran it.`;
12978
14200
  }
12979
- function answerClaimsInSystemViewLine(engagementId, phase) {
12980
- const address = systemViewUrl(engagementId, phase);
12981
- const only = "Accept or reject each one in the System view; this cannot be done by typing in chat";
12982
- return address === void 0 ? `${only}.` : `${only}: ${address}`;
12983
- }
12984
- function checksSectionLines(work, engagementId) {
14201
+ var ANSWER_CLAIMS_BY_TYPING_LINE = "To answer them, type the reject or accept line listed above in your own Claude Code session.";
14202
+ function checksSectionLines(work) {
12985
14203
  const unchecked = work.tasks.flatMap((task) => task.tests.filter((t) => t.state === "unchecked"));
12986
14204
  if (unchecked.length === 0)
12987
14205
  return [];
12988
- return [CHECKS_HEADING, CHECKS_SUBHEADING, ...unchecked.map(uncheckedClaimLine), answerClaimsInSystemViewLine(engagementId, work.phase)];
14206
+ return [CHECKS_HEADING, CHECKS_SUBHEADING, ...unchecked.map(uncheckedClaimLine), ANSWER_CLAIMS_BY_TYPING_LINE];
12989
14207
  }
12990
- function workSectionLines(work, engagementId) {
14208
+ function workSectionLines(work, _engagementId) {
12991
14209
  if (work === null)
12992
14210
  return [];
12993
- return [...callsSectionLines(work.calls), ...checksSectionLines(work, engagementId)];
14211
+ return [...callsSectionLines(work.calls), ...checksSectionLines(work)];
12994
14212
  }
12995
14213
  function rv8Line(view) {
12996
14214
  if (view.state === "closed") {
@@ -13132,7 +14350,8 @@ async function runReviewShow(repoRoot, phase, deps) {
13132
14350
  if (view.state !== "open")
13133
14351
  return refused(rv8Line(view));
13134
14352
  const work = await deps.readWork(credential, phase);
13135
- const stdout = `${[...reviewListingLines(view), ...workSectionLines(work.kind === "work" ? work.work : null, credential.engagementId)].join("\n")}
14353
+ const phaseWork = work.kind === "work" ? work.work : null;
14354
+ const stdout = `${[...reviewListingLines(view, phaseWork?.uncheckedClaims ?? []), ...workSectionLines(phaseWork, credential.engagementId)].join("\n")}
13136
14355
  `;
13137
14356
  const written = deps.recordShown(credential.engagementId, shownReviewEntry(view, deps.now().toISOString()));
13138
14357
  if (!written.ok)
@@ -14234,6 +15453,35 @@ function readJsonFile(repoRoot, rel, parse) {
14234
15453
  }
14235
15454
  return { kind: "ok", value: parsed.data };
14236
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
+ }
14237
15485
  function runCheckTasks(repoRoot, phase, withTaskList) {
14238
15486
  const p = "halfcycle check-tasks:";
14239
15487
  const planRead = readJsonFile(repoRoot, TASK_PLAN_FILE, (raw) => taskPlanSchema.safeParse(raw));
@@ -14259,7 +15507,15 @@ function runCheckTasks(repoRoot, phase, withTaskList) {
14259
15507
  }
14260
15508
  taskList = { file: listRead.kind === "ok" ? listRead.value : null };
14261
15509
  }
14262
- 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
+ });
14263
15519
  const stdout = result.lines.map((line) => `${line.text}
14264
15520
  `).join("");
14265
15521
  return { stdout, stderr: "", exitCode: result.refused ? 1 : 0 };
@@ -14318,7 +15574,7 @@ function runProveChild(binary, credential, repoRoot, words, deps = {}) {
14318
15574
 
14319
15575
  // dist/uninstall.js
14320
15576
  import { lstatSync as lstatSync3, readFileSync as readFileSync19, readdirSync, rmSync as rmSync6, rmdirSync, writeFileSync as writeFileSync8 } from "node:fs";
14321
- import { homedir as homedir4 } from "node:os";
15577
+ import { homedir as homedir5 } from "node:os";
14322
15578
  import { dirname as dirname7, join as join22, relative as relative6, sep as sep2 } from "node:path";
14323
15579
  var REMOVE_CREDENTIAL_FLAG = "--remove-credential";
14324
15580
  function parseUninstallArgs(args2) {
@@ -14342,19 +15598,6 @@ function lexists(path) {
14342
15598
  return false;
14343
15599
  }
14344
15600
  }
14345
- function symlinkOnPath(root, rel) {
14346
- let current = root;
14347
- for (const segment of rel.split("/")) {
14348
- current = join22(current, segment);
14349
- try {
14350
- if (lstatSync3(current).isSymbolicLink())
14351
- return true;
14352
- } catch {
14353
- return false;
14354
- }
14355
- }
14356
- return false;
14357
- }
14358
15601
  function isPlainObject3(value) {
14359
15602
  return value !== null && typeof value === "object" && !Array.isArray(value);
14360
15603
  }
@@ -14432,7 +15675,7 @@ function validate(options, rules) {
14432
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.`);
14433
15676
  }
14434
15677
  if (engagementId !== void 0) {
14435
- const expectedParent = join22(home ?? homedir4(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
15678
+ const expectedParent = join22(home ?? homedir5(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
14436
15679
  if (dirname7(engagementStateDir(engagementId, home)) !== expectedParent) {
14437
15680
  throw new Refusal(`the project id in ${BUNDLE_PIN_REL} does not name a folder under ~/.halfcycle/engagements. Nothing was removed.`);
14438
15681
  }
@@ -14443,14 +15686,16 @@ function validate(options, rules) {
14443
15686
  engagementId,
14444
15687
  idValid,
14445
15688
  settings: readMergedJson(targetRepo, rules.settings),
14446
- mcp: readMergedJson(targetRepo, rules.mcp)
15689
+ mcp: readMergedJson(targetRepo, rules.mcp),
15690
+ codexHooks: readMergedJson(targetRepo, rules.codexHooks)
14447
15691
  };
14448
15692
  }
14449
15693
  function mergedFileRules(writeSet) {
14450
15694
  const spelledIn = (spellings) => (text) => spellings.some((spelling) => text.includes(spelling));
14451
15695
  return {
14452
15696
  settings: { rel: SETTINGS_REL, holdsOurs: spelledIn([...writeSet.hookCommands].map((c) => JSON.stringify(c))) },
14453
- 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))) }
14454
15699
  };
14455
15700
  }
14456
15701
  function writeBack(targetRepo, rel, current, next, lists, name) {
@@ -14539,48 +15784,12 @@ function ourMcpServers(value) {
14539
15784
  return isPlainObject3(servers) && MCP_SERVER_KEY in servers ? servers : null;
14540
15785
  }
14541
15786
  function unmergeSettings(value, rec, writeSet) {
14542
- let changed = false;
14543
15787
  const refused3 = [];
14544
- const commands = new Set(writeSet.hookCommands);
14545
15788
  const denyHashes = rec.denyAddedSha256.filter((h) => writeSet.denyRuleSha256.has(h));
14546
15789
  if (denyHashes.length < rec.denyAddedSha256.length) {
14547
15790
  refused3.push(`${SETTINGS_REL}: a deny rule the install record names but Halfcycle never adds (it was kept)`);
14548
15791
  }
14549
- const hooks = value["hooks"];
14550
- if (isPlainObject3(hooks)) {
14551
- const ours = new Set(ourHookEntries(value, commands).map(({ entry }) => entry));
14552
- let emptiedAnEvent = false;
14553
- for (const event of Object.keys(hooks)) {
14554
- const entries = hooks[event];
14555
- if (!Array.isArray(entries))
14556
- continue;
14557
- let touched = false;
14558
- const next = [];
14559
- for (const entry of entries) {
14560
- if (!ours.has(entry)) {
14561
- next.push(entry);
14562
- continue;
14563
- }
14564
- touched = true;
14565
- const remaining = withoutHalfcycleCommands(entry, commands);
14566
- if (remaining !== null)
14567
- next.push(remaining);
14568
- }
14569
- if (touched)
14570
- changed = true;
14571
- if (next.length === 0 && (rec.eventsCreated.includes(event) || rec.adopted && touched)) {
14572
- delete hooks[event];
14573
- changed = true;
14574
- emptiedAnEvent = true;
14575
- } else if (touched) {
14576
- hooks[event] = next;
14577
- }
14578
- }
14579
- if (Object.keys(hooks).length === 0 && (rec.hooksCreated || rec.adopted && emptiedAnEvent)) {
14580
- delete value["hooks"];
14581
- changed = true;
14582
- }
14583
- }
15792
+ let changed = unmergeHooks(value, rec, new Set(writeSet.hookCommands));
14584
15793
  const permissions = value["permissions"];
14585
15794
  if (isPlainObject3(permissions) && Array.isArray(permissions["deny"])) {
14586
15795
  const deny = permissions["deny"];
@@ -14616,6 +15825,52 @@ function unmergeSettings(value, rec, writeSet) {
14616
15825
  }
14617
15826
  return { changed, refused: refused3 };
14618
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
+ };
14619
15874
  function unmergeMcp(value, rec) {
14620
15875
  const servers = value["mcpServers"];
14621
15876
  if (!isPlainObject3(servers))
@@ -14723,7 +15978,8 @@ function coveredPathExists(targetRepo, line) {
14723
15978
  function selfBuildMergedFileRules() {
14724
15979
  return {
14725
15980
  settings: { rel: SETTINGS_REL, holdsOurs: () => false },
14726
- 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 }
14727
15983
  };
14728
15984
  }
14729
15985
  function removeSelfBuildOwnedFile(targetRepo, rel, lists, name) {
@@ -14777,6 +16033,33 @@ function removeSelfBuildCrewRoster(targetRepo, lists, name) {
14777
16033
  name(lists.failed, rel, rel);
14778
16034
  }
14779
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
+ }
14780
16063
  function removeSelfBuildEmptyDir(targetRepo, dir, lists, name) {
14781
16064
  if (symlinkOnPath(targetRepo, dir))
14782
16065
  return;
@@ -14826,8 +16109,13 @@ function uninstallSelfBuild(options) {
14826
16109
  removeSelfBuildOwnedFile(targetRepo, rel, result, name);
14827
16110
  }
14828
16111
  removeSelfBuildCrewRoster(targetRepo, result, name);
16112
+ for (const { rel, text } of renderCrewProfiles()) {
16113
+ removeSelfBuildCrewProfile(targetRepo, rel, text, result, name);
16114
+ }
14829
16115
  removeSelfBuildEmptyDir(targetRepo, ".halfcycle/bin", result, name);
14830
16116
  removeSelfBuildEmptyDir(targetRepo, ".halfcycle", result, name);
16117
+ removeSelfBuildEmptyDir(targetRepo, ".codex/agents", result, name);
16118
+ removeSelfBuildEmptyDir(targetRepo, ".codex", result, name);
14831
16119
  const foundNothing = result.removed.length + result.restored.length + result.kept.length + result.failed.length === 0;
14832
16120
  if (!pinExisted && foundNothing) {
14833
16121
  return { outcome: "not-installed", exitCode: 0, ...result, credential: nothingToSay };
@@ -14863,6 +16151,7 @@ function uninstall(options) {
14863
16151
  const { targetRepo } = options;
14864
16152
  const manifest = checked.manifest ?? noRecord(targetRepo, writeSet);
14865
16153
  const deleteWhenEmpty = (created) => created || checked.manifest === null;
16154
+ const deleteCodexWhenEmpty = (record2) => record2 === void 0 || deleteWhenEmpty(record2.created);
14866
16155
  const named = /* @__PURE__ */ new Set();
14867
16156
  const name = (list2, text, path) => {
14868
16157
  list2.push(text);
@@ -14881,6 +16170,13 @@ function uninstall(options) {
14881
16170
  changed: unmergeMcp(value, mcpRecord).changed,
14882
16171
  adopted: false
14883
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);
14884
16180
  const deferred = /* @__PURE__ */ new Set([BUNDLE_PIN_REL, INSTALL_MANIFEST_REL]);
14885
16181
  const recorded = new Map(manifest.files.map((entry) => [entry.path, entry]));
14886
16182
  for (const path of [.../* @__PURE__ */ new Set([...recorded.keys(), ...writeSet.files])].sort()) {
@@ -14964,6 +16260,44 @@ function applyMergedJson(targetRepo, rule, file, deleteWhenEmpty, lists, name, e
14964
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);
14965
16261
  }
14966
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
+ }
14967
16301
  function removeInstalledFile(targetRepo, path, entry, writeSet, lists, name) {
14968
16302
  if (!writeSet.files.has(path)) {
14969
16303
  name(lists.leftAlone, `${path} (Halfcycle never writes this path, so it was not removed)`, path);
@@ -15095,7 +16429,7 @@ function filesUnder(root, rel) {
15095
16429
  return out;
15096
16430
  }
15097
16431
  function shownPath(path, home) {
15098
- const base = home ?? homedir4();
16432
+ const base = home ?? homedir5();
15099
16433
  const rel = relative6(base, path);
15100
16434
  const under = rel !== "" && !rel.startsWith("..") && !rel.startsWith(sep2);
15101
16435
  return `${under ? `~/${rel.split(sep2).join("/")}` : path}/`;
@@ -15177,9 +16511,13 @@ function bundleVersion() {
15177
16511
  function claudeCodeVersion(deps = {}) {
15178
16512
  return probeClaudeCodeVersion(3e3, deps);
15179
16513
  }
16514
+ function codexVersion(deps = {}) {
16515
+ return probeCodexVersion(3e3, deps);
16516
+ }
15180
16517
  var REAL_PROBES = {
15181
16518
  bundleVersion,
15182
16519
  claudeCodeVersion,
16520
+ codexVersion,
15183
16521
  projectName: (dir) => {
15184
16522
  try {
15185
16523
  return deriveProjectName(dir);
@@ -15227,6 +16565,9 @@ function openingBannerContent(projectDir, env = process.env, probes = REAL_PROBE
15227
16565
  const facts = [
15228
16566
  fact("Version", version, version),
15229
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()),
15230
16571
  fact("Project", project, name),
15231
16572
  fact("Status", status, installed === void 0 ? "new" : "installed"),
15232
16573
  // `Website`, NOT `Plane` — see this function's docblock. (No issue number here:
@@ -15399,8 +16740,8 @@ async function runPace(repoRoot, start, deps) {
15399
16740
 
15400
16741
  // dist/restore.js
15401
16742
  import { spawnSync as spawnSync3 } from "node:child_process";
15402
- 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";
15403
- 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";
15404
16745
  import { join as join24, resolve as resolve7 } from "node:path";
15405
16746
  var RESTORE_USAGE = `${HALFCYCLE_COMMAND} restore [<id>]`;
15406
16747
  function parseRestoreArgs(rest) {
@@ -15423,7 +16764,7 @@ function runRestore(cwd, id, options = {}) {
15423
16764
  if (engagementId === null) {
15424
16765
  return refused2(`Halfcycle is not set up in this project, so it has saved nothing here. To set it up, run: ${HALFCYCLE_COMMAND}`);
15425
16766
  }
15426
- 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);
15427
16768
  const refs = savedRefs(root);
15428
16769
  if (refs === null)
15429
16770
  return refused2("Halfcycle could not read the saved copies in this project, so nothing was put back.");
@@ -15809,7 +17150,7 @@ function lstatOrNull3(p) {
15809
17150
  }
15810
17151
  function realOrSelf(p) {
15811
17152
  try {
15812
- return realpathSync5(p);
17153
+ return realpathSync6(p);
15813
17154
  } catch {
15814
17155
  return p;
15815
17156
  }
@@ -15856,6 +17197,15 @@ function reportClaudeCodeVersion(verbose2, deps = {}) {
15856
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.
15857
17198
  `);
15858
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
+ }
15859
17209
  function shortId(id) {
15860
17210
  return id.length > 10 ? `${id.slice(0, 8)}\u2026` : id;
15861
17211
  }
@@ -16289,6 +17639,7 @@ ${PROJECT_RESET_LINE}
16289
17639
  `);
16290
17640
  }
16291
17641
  reportClaudeCodeVersion(verbose);
17642
+ reportCodexVersion();
16292
17643
  const identity = actingIdentity ?? heldAnswer?.identity ?? (credential && heldAnswer === void 0 ? await describeAccount(credential.serviceUrl, credential.token) : void 0);
16293
17644
  const written = result.writtenPaths.length;
16294
17645
  const merged = result.mergedPaths.length;
@@ -16315,6 +17666,10 @@ ${PROJECT_RESET_LINE}
16315
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
16316
17667
  `);
16317
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}
16318
17673
  `);
16319
17674
  }
16320
17675
  const consoleUrl = credential?.consoleUrl ?? (reusedWithoutFreshAnswer ? readBundlePin(targetRepo)?.projectPage : void 0);