halfcycle 0.3.7 → 0.3.8

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halfcycle",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Halfcycle Method bundle — resolution-stub slash commands, remote method-delivery registration, and governance-hook wiring for a Halfcycle engagement repo. It ships NO worker role files: a project's roles are authored from that project's own recorded decisions at orchestration kickoff (FX-2, W3-F-27).",
5
5
  "commands": [
6
6
  { "name": "halfcycle-setup", "path": "../commands/halfcycle-setup.md" },
@@ -14608,7 +14608,31 @@ var firedGuardSchema = external_exports.object({
14608
14608
  guardId: external_exports.string(),
14609
14609
  patternRef: external_exports.string(),
14610
14610
  severity: severitySchema,
14611
- explanation: external_exports.string()
14611
+ explanation: external_exports.string(),
14612
+ /**
14613
+ * The repo-relative path of the file this firing was decided on. The matcher
14614
+ * has the file live at the moment it decides to fire; this is the field that
14615
+ * carries the answer out. It is the CLIENT's own coordinate — the wire type
14616
+ * still has no field for a matcher body, a channel, a version, a scope or a
14617
+ * guard's provenance, and physically cannot carry one.
14618
+ *
14619
+ * THE PRESENCE RULE, WHICH LIVES HERE AND NOT IN A CONSUMER'S PROSE: every
14620
+ * firing produced from this contract version onward carries `path`, so on the
14621
+ * WIRE, absence means exactly one thing — a response from a guard service
14622
+ * older than this contract. It is declared optional because a client's guard
14623
+ * runner is built from its own checkout and calls a service that may be older
14624
+ * than it; a required field would make that pairing unparseable.
14625
+ *
14626
+ * The rule does NOT transfer to a store that persists a firing, where an
14627
+ * absent path has a second legitimate meaning: a row written before the
14628
+ * column existed. A persisted NULL is therefore not a writer bug.
14629
+ *
14630
+ * NO SHAPE CONSTRAINT. This value is whatever the changed tree called the
14631
+ * file. A consumer that maps it onto a coordinate with its own path rule must
14632
+ * safe-parse it and decide what to do with a refusal, rather than assume the
14633
+ * two shapes agree.
14634
+ */
14635
+ path: external_exports.string().optional()
14612
14636
  }).strict();
14613
14637
  var resultEnvelopeSchema = external_exports.object({
14614
14638
  guardsFired: external_exports.array(firedGuardSchema),
@@ -16018,6 +16042,52 @@ This is not a service outage. It means the guard has never run here \u2014 not o
16018
16042
  ${remedy}
16019
16043
  `;
16020
16044
  }
16045
+ var CREDENTIAL_REJECTED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
16046
+ function classifyFailure(failure) {
16047
+ if (failure.kind === "contract-error") {
16048
+ return {
16049
+ kind: "contract",
16050
+ message: failure.message,
16051
+ label: "contract error",
16052
+ reason: `contract error: ${failure.message}`
16053
+ };
16054
+ }
16055
+ if (failure.kind === "wire-error" && CREDENTIAL_REJECTED_STATUSES.has(failure.statusCode)) {
16056
+ const label = `credential rejected (${failure.statusCode})`;
16057
+ return {
16058
+ kind: "credential",
16059
+ statusCode: failure.statusCode,
16060
+ message: failure.message,
16061
+ label,
16062
+ reason: `${label}: ${failure.message}`
16063
+ };
16064
+ }
16065
+ return {
16066
+ kind: "service",
16067
+ message: failure.message,
16068
+ // Unchanged wording for this class ON PURPOSE — a genuine outage still says
16069
+ // exactly what it said before, and still fails open locally. This task moved
16070
+ // ONE condition out of this branch; it did not redefine the branch.
16071
+ label: "infrastructure failure",
16072
+ reason: `infrastructure failure: ${failure.message}`
16073
+ };
16074
+ }
16075
+ var CREDENTIAL_REJECTED_EXIT_TOOL_USE = UNCONFIGURED_EXIT_TOOL_USE;
16076
+ var CREDENTIAL_REJECTED_EXIT_STOP_FAMILY = UNCONFIGURED_EXIT_STOP_FAMILY;
16077
+ function credentialRejectedMessage(statusCode, serviceMessage, remedy, label = "[Halfcycle]") {
16078
+ const forbidden = statusCode === 403;
16079
+ const statusName = forbidden ? "Forbidden" : "Unauthorized";
16080
+ const cause = forbidden ? `this credential is not authorised for the engagement this repository is sending` : `the credential this repository is sending is not one it accepts`;
16081
+ return `${label} GUARD CREDENTIAL REJECTED \u2014 the guard service answered and refused this credential.
16082
+ No evaluation was performed: the service returned ${statusCode} ${statusName} \u2014 ${serviceMessage}
16083
+ This is not a service outage. The guard service is up and replying; ${cause}, so this change was not checked.
16084
+ ${remedy}
16085
+ `;
16086
+ }
16087
+ function emitCredentialRejected(statusCode, serviceMessage, exitCode) {
16088
+ writeSync(2, credentialRejectedMessage(statusCode, serviceMessage, HOOK_REMEDY));
16089
+ process.exitCode = exitCode;
16090
+ }
16021
16091
 
16022
16092
  // dist/ci.js
16023
16093
  async function runCi() {
@@ -16071,21 +16141,24 @@ async function runCi() {
16071
16141
  changeSet
16072
16142
  });
16073
16143
  if (!clientResult.ok) {
16074
- const isInfra = clientResult.kind === "infra-error" || clientResult.kind === "wire-error";
16075
- const failureReason = isInfra ? `infrastructure failure: ${clientResult.message}` : `contract error: ${clientResult.message}`;
16144
+ const failure = classifyFailure(clientResult);
16076
16145
  await emitRunRecord({
16077
16146
  engagementId: guardEngagementId,
16078
16147
  runType: "ci",
16079
16148
  outcome: outcomeForClientFailure(clientResult.kind),
16080
- failureReason,
16149
+ failureReason: failure.reason,
16081
16150
  changeSet
16082
16151
  });
16083
- if (isInfra) {
16084
- process.stderr.write(`[Halfcycle CI] Infrastructure failure: Guard service unavailable \u2014 ${clientResult.message}
16152
+ if (failure.kind === "credential") {
16153
+ process.stderr.write(credentialRejectedMessage(failure.statusCode, failure.message, CI_REMEDY, "[Halfcycle CI]"));
16154
+ return 1;
16155
+ }
16156
+ if (failure.kind === "service") {
16157
+ process.stderr.write(`[Halfcycle CI] Infrastructure failure: Guard service unavailable \u2014 ${failure.message}
16085
16158
  This is NOT a guard violation. Fix the infrastructure issue and re-run.
16086
16159
  `);
16087
16160
  } else {
16088
- process.stderr.write(`[Halfcycle CI] Infrastructure failure (contract error): ${clientResult.message}
16161
+ process.stderr.write(`[Halfcycle CI] Infrastructure failure (contract error): ${failure.message}
16089
16162
  `);
16090
16163
  }
16091
16164
  return 1;
@@ -16189,16 +16262,19 @@ async function runPostToolUse() {
16189
16262
  changeSet
16190
16263
  });
16191
16264
  if (!clientResult.ok) {
16192
- const isInfra = clientResult.kind === "infra-error" || clientResult.kind === "wire-error";
16193
- const errorLabel = isInfra ? "infrastructure failure" : "contract error";
16265
+ const failure = classifyFailure(clientResult);
16194
16266
  await emitRunRecord({
16195
16267
  engagementId: guardEngagementId,
16196
16268
  runType: "hook",
16197
16269
  outcome: outcomeForClientFailure(clientResult.kind),
16198
- failureReason: `${errorLabel}: ${clientResult.message}`,
16270
+ failureReason: failure.reason,
16199
16271
  changeSet
16200
16272
  });
16201
- emitInfraWarning(`${errorLabel}: ${clientResult.message}`);
16273
+ if (failure.kind === "credential") {
16274
+ emitCredentialRejected(failure.statusCode, failure.message, CREDENTIAL_REJECTED_EXIT_TOOL_USE);
16275
+ return;
16276
+ }
16277
+ emitInfraWarning(failure.reason);
16202
16278
  return;
16203
16279
  }
16204
16280
  const telemetryConfig = readTelemetryConfig();
@@ -16442,17 +16518,20 @@ async function runSessionDiff(input, kind) {
16442
16518
  changeSet
16443
16519
  });
16444
16520
  if (!clientResult.ok) {
16445
- const isInfra = clientResult.kind === "infra-error" || clientResult.kind === "wire-error";
16446
- const errorLabel = isInfra ? "infrastructure failure" : "contract error";
16521
+ const failure = classifyFailure(clientResult);
16447
16522
  await emitRunRecord({
16448
16523
  engagementId: guardEngagementId,
16449
16524
  runType: "hook",
16450
16525
  outcome: outcomeForClientFailure(clientResult.kind),
16451
- failureReason: `${errorLabel}: ${clientResult.message}`,
16526
+ failureReason: failure.reason,
16452
16527
  changeSet,
16453
16528
  diffBase
16454
16529
  });
16455
- emitInfraWarning(`${errorLabel}: ${clientResult.message}`);
16530
+ if (failure.kind === "credential") {
16531
+ emitCredentialRejected(failure.statusCode, failure.message, CREDENTIAL_REJECTED_EXIT_STOP_FAMILY);
16532
+ return;
16533
+ }
16534
+ emitInfraWarning(failure.reason);
16456
16535
  return;
16457
16536
  }
16458
16537
  const telemetryConfig = readTelemetryConfig();
package/dist/bin.js CHANGED
@@ -375,7 +375,31 @@ var firedGuardSchema = z2.object({
375
375
  guardId: z2.string(),
376
376
  patternRef: z2.string(),
377
377
  severity: severitySchema,
378
- explanation: z2.string()
378
+ explanation: z2.string(),
379
+ /**
380
+ * The repo-relative path of the file this firing was decided on. The matcher
381
+ * has the file live at the moment it decides to fire; this is the field that
382
+ * carries the answer out. It is the CLIENT's own coordinate — the wire type
383
+ * still has no field for a matcher body, a channel, a version, a scope or a
384
+ * guard's provenance, and physically cannot carry one.
385
+ *
386
+ * THE PRESENCE RULE, WHICH LIVES HERE AND NOT IN A CONSUMER'S PROSE: every
387
+ * firing produced from this contract version onward carries `path`, so on the
388
+ * WIRE, absence means exactly one thing — a response from a guard service
389
+ * older than this contract. It is declared optional because a client's guard
390
+ * runner is built from its own checkout and calls a service that may be older
391
+ * than it; a required field would make that pairing unparseable.
392
+ *
393
+ * The rule does NOT transfer to a store that persists a firing, where an
394
+ * absent path has a second legitimate meaning: a row written before the
395
+ * column existed. A persisted NULL is therefore not a writer bug.
396
+ *
397
+ * NO SHAPE CONSTRAINT. This value is whatever the changed tree called the
398
+ * file. A consumer that maps it onto a coordinate with its own path rule must
399
+ * safe-parse it and decide what to do with a refusal, rather than assume the
400
+ * two shapes agree.
401
+ */
402
+ path: z2.string().optional()
379
403
  }).strict();
380
404
  var resultEnvelopeSchema = z2.object({
381
405
  guardsFired: z2.array(firedGuardSchema),
@@ -489,6 +513,7 @@ var acceptanceOutcomeSchema = z4.object({
489
513
  findings: z4.number().int().nonnegative(),
490
514
  findingsDetail: z4.array(z4.object({ summary: z4.string() }).strict())
491
515
  }).strict();
516
+ var PHASE_CLOSE_REASON_MAX = 1900;
492
517
  var phaseCloseDecisionSchema = z4.discriminatedUnion("decision", [
493
518
  z4.object({
494
519
  decision: z4.literal("done-when-met"),
@@ -499,8 +524,13 @@ var phaseCloseDecisionSchema = z4.discriminatedUnion("decision", [
499
524
  decision: z4.literal("override"),
500
525
  /** Who is closing it anyway. */
501
526
  actor: z4.string(),
502
- /** Why, in their own words. */
503
- reason: z4.string()
527
+ /**
528
+ * Why, in their own words. Bounded: the reason is kept on the phase's
529
+ * permanent record of its own close, and that record refuses anything
530
+ * longer — so an over-long reason is refused here, before the close is
531
+ * attempted, rather than failing the close itself.
532
+ */
533
+ reason: z4.string().max(PHASE_CLOSE_REASON_MAX)
504
534
  }).strict()
505
535
  ]);
506
536
  var acceptPhaseRequestSchema = z4.object({
@@ -586,7 +616,18 @@ var DEVICE_AUTH_STATUS = {
586
616
  /** Confirm: the shared confirmation secret was missing or wrong. 401. */
587
617
  UNAUTHORIZED: "unauthorized",
588
618
  /** Confirm: the code was already approved, declined or claimed. 409. */
589
- ALREADY_DECIDED: "already-decided"
619
+ ALREADY_DECIDED: "already-decided",
620
+ /**
621
+ * Confirm: an approval named a signed-in identity whose account has not accepted
622
+ * the terms of service and privacy policy currently in force (T-18, #588). 400.
623
+ *
624
+ * NOT BOUND TO THE DEVICE CODE. Unlike every other refusal in this const, this one
625
+ * says nothing about the code itself — the code is still `pending` after this
626
+ * response, exactly as it was before the request, so the SAME userCode can be
627
+ * confirmed again once the caller has recorded acceptance. `deviceConfirmRequestSchema`'s
628
+ * `acceptTerms` is how a second confirm says it did.
629
+ */
630
+ TERMS_REQUIRED: "terms-required"
590
631
  };
591
632
  var DEVICE_POLL_REFUSALS = [
592
633
  DEVICE_AUTH_STATUS.DECLINED,
@@ -619,7 +660,8 @@ var deviceAuthRefusalSchema = z5.object({
619
660
  DEVICE_AUTH_STATUS.IDENTITY_REQUIRED,
620
661
  DEVICE_AUTH_STATUS.NOT_CONFIGURED,
621
662
  DEVICE_AUTH_STATUS.UNAUTHORIZED,
622
- DEVICE_AUTH_STATUS.ALREADY_DECIDED
663
+ DEVICE_AUTH_STATUS.ALREADY_DECIDED,
664
+ DEVICE_AUTH_STATUS.TERMS_REQUIRED
623
665
  ]),
624
666
  message: z5.string().min(1)
625
667
  }).strict();
@@ -632,7 +674,8 @@ var deviceConfirmRequestSchema = z5.object({
632
674
  userCode: z5.string().min(1),
633
675
  decision: z5.enum(["approve", "decline"]),
634
676
  externalAuthId: z5.string().min(1).optional(),
635
- email: z5.string().optional()
677
+ email: z5.string().optional(),
678
+ acceptTerms: z5.boolean().optional()
636
679
  }).strict();
637
680
  var deviceConfirmRecordedSchema = z5.object({
638
681
  status: z5.literal(DEVICE_AUTH_STATUS.RECORDED),
@@ -801,6 +844,18 @@ function writeCollisionSafe(targetAbsPath, targetRepoRoot, content) {
801
844
  writeFileSync4(targetAbsPath, content, "utf-8");
802
845
  return "written";
803
846
  }
847
+ function writeOwned(targetAbsPath, targetRepoRoot, content) {
848
+ const rel = relative(targetRepoRoot, targetAbsPath).replace(/\\/g, "/");
849
+ if (!isAllowlisted(rel)) {
850
+ throw new Error(`[bundle install] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
851
+ }
852
+ if (existsSync3(targetAbsPath) && readFileSync5(targetAbsPath, "utf-8") === content) {
853
+ return "skipped";
854
+ }
855
+ mkdirSync4(dirname2(targetAbsPath), { recursive: true });
856
+ writeFileSync4(targetAbsPath, content, "utf-8");
857
+ return "written";
858
+ }
804
859
  function record(report, outcome, rel) {
805
860
  const bucket = {
806
861
  written: report.writtenPaths,
@@ -921,6 +976,42 @@ if [ -z "$REPO_ROOT" ]; then exit 0; fi
921
976
  HASH="$(echo -n "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
922
977
  MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
923
978
  git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
979
+
980
+ # ---------------------------------------------------------------------------
981
+ # STANDING GUARD-COVERAGE STATEMENT (T-27).
982
+ #
983
+ # A repository with no credential on this machine is UNGUARDED: the PostToolUse
984
+ # hook will find nothing to evaluate with, and it says so once per edit, after
985
+ # the edit. This says it once, up front, before anything is written \u2014 which is
986
+ # the one thing a session-start hook can do that nothing else can.
987
+ #
988
+ # Everything below runs in a SUBSHELL: it sources a credential store, and a
989
+ # session-start hook must not leak an engagement's variables into whatever the
990
+ # editor runs next. Any failure inside it is swallowed; this script's exit
991
+ # status is 0 either way, and the marker above has already been written.
992
+ # ---------------------------------------------------------------------------
993
+ ${engagementResolutionShell()}
994
+
995
+ (
996
+ if halfcycle_env_file "$REPO_ROOT"; then
997
+ set -a
998
+ . "$HALFCYCLE_ENV_FILE"
999
+ set +a
1000
+ fi
1001
+ hc_missing=""
1002
+ for hc_key in ${GUARD_ENV_KEYS.join(" ")}; do
1003
+ eval "hc_value=\\\${$hc_key:-}"
1004
+ if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
1005
+ done
1006
+ if [ -n "$hc_missing" ]; then
1007
+ echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
1008
+ echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
1009
+ echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
1010
+ echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
1011
+ echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
1012
+ echo "[halfcycle] and needs nothing configured first."
1013
+ fi
1014
+ ) 2>/dev/null || true
924
1015
  exit 0
925
1016
  `;
926
1017
  }
@@ -934,6 +1025,13 @@ function generateUserPromptReminderHook() {
934
1025
  # one home: RULE_SENTENCE_PLAIN in the method repo's own position-house-rule
935
1026
  # test governs it \u2014 reword there and here together, in the same commit.
936
1027
  #
1028
+ # SILENT WHEN THE TOOL IT NAMES IS NOT THERE. The sentence tells the session to
1029
+ # call halfcycle_resolve, which arrives over the Halfcycle MCP server registered
1030
+ # in .mcp.json. If this repository carries no such registration, the instruction
1031
+ # cannot be followed, and a per-prompt instruction that cannot be followed is
1032
+ # worse than silence. The standing "no coverage" statement belongs in the
1033
+ # session-start banner, which says it once; this hook just stops talking.
1034
+ #
937
1035
  # FAILS OPEN. This hook's stdout is always plain text, never JSON, so it
938
1036
  # cannot form a {"decision":"block",...} body (UserPromptSubmit's only way to
939
1037
  # hold the turn). But a write failure here (a closed stdout, say) must still
@@ -941,6 +1039,26 @@ function generateUserPromptReminderHook() {
941
1039
  # than propagate.
942
1040
  trap 'exit 0' ERR
943
1041
  set -e
1042
+
1043
+ # The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
1044
+ # session's cwd \u2014 a session started in a subdirectory must find the same root
1045
+ # .mcp.json the editor loaded, which is the bug mcp-headers.sh already paid for.
1046
+ HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
1047
+ HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
1048
+ HC_REGISTRATION="$HC_PROJECT_ROOT/${MCP_REGISTRATION_REL}"
1049
+
1050
+ # Presence of the FILE is not enough \u2014 a developer's own .mcp.json with no
1051
+ # Halfcycle server in it registers no halfcycle_resolve. \`grep\` is guarded with
1052
+ # \`|| true\` because a non-match is a status, not an error, and \`set -e\` would
1053
+ # otherwise route it through the trap (same outcome here, but by accident).
1054
+ HC_REGISTERED=""
1055
+ if [ -f "$HC_REGISTRATION" ]; then
1056
+ HC_REGISTERED=$(tr -d '\\n' < "$HC_REGISTRATION" | grep -c '"${MCP_SERVER_KEY}"[[:space:]]*:' || true)
1057
+ fi
1058
+ if [ -z "$HC_REGISTERED" ] || [ "$HC_REGISTERED" = "0" ]; then
1059
+ exit 0
1060
+ fi
1061
+
944
1062
  printf '%s\\n' '${USER_PROMPT_REMINDER_SENTENCE}'
945
1063
  exit 0
946
1064
  `;
@@ -1022,8 +1140,13 @@ GUARD_ENGAGEMENT_ID=<argType:runtime>
1022
1140
  # TWO ORIGINS, TWO SERVICES (T-22). HALFCYCLE_SERVICE_URL is the Halfcycle
1023
1141
  # CONTROL plane \u2014 it serves engagement creation and the board's first-visit
1024
1142
  # code. HALFCYCLE_MCP_URL is the method-delivery service, which serves /mcp and
1025
- # nothing else here. You configure the first; the platform reports the second on
1026
- # create, so it is written for you and .mcp.json carries the composed address.
1143
+ # nothing else here. The platform reports the second on create, so it is written
1144
+ # for you and .mcp.json carries the composed address.
1145
+ #
1146
+ # NEITHER IS SOMETHING YOU NEED TO SET (T-27). The installer talks to Halfcycle's
1147
+ # own control plane by default; HALFCYCLE_SERVICE_URL OVERRIDES that address and
1148
+ # exists for a self-hosted or development plane. It is not a prerequisite for
1149
+ # \`npx halfcycle\`, and nothing in the product asks you to find its value.
1027
1150
  HALFCYCLE_SERVICE_URL=<argType:runtime>
1028
1151
  HALFCYCLE_MCP_URL=<argType:runtime>
1029
1152
  HALFCYCLE_TOKEN=<argType:runtime>
@@ -1036,6 +1159,7 @@ CONTROL_TELEMETRY_URL=<argType:runtime>
1036
1159
  `;
1037
1160
  }
1038
1161
  var MCP_REGISTRATION_REL = ".mcp.json";
1162
+ var MCP_SERVER_KEY = "halfcycle";
1039
1163
  var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
1040
1164
  var MCP_HEADERS_HELPER_COMMAND = `/bin/sh -c 'd=$(pwd); while [ ! -f "$d/${MCP_HEADERS_HELPER_REL}" ] && [ "$d" != / ]; do d=$(dirname "$d"); done; [ -f "$d/${MCP_HEADERS_HELPER_REL}" ] || { echo "halfcycle: ${MCP_HEADERS_HELPER_REL} not found at or above $(pwd); re-run npx halfcycle" >&2; exit 1; }; exec /bin/sh "$d/${MCP_HEADERS_HELPER_REL}"'`;
1041
1165
  function generateMcpHeadersHelper() {
@@ -1148,7 +1272,7 @@ function generateMcpRegistration(existing, mcpOrigin) {
1148
1272
  if (typeof base.mcpServers !== "object" || base.mcpServers === null) {
1149
1273
  base.mcpServers = {};
1150
1274
  }
1151
- base.mcpServers["halfcycle"] = {
1275
+ base.mcpServers[MCP_SERVER_KEY] = {
1152
1276
  type: "http",
1153
1277
  url: mcpEndpointUrl(mcpOrigin),
1154
1278
  headersHelper: MCP_HEADERS_HELPER_COMMAND
@@ -1370,7 +1494,7 @@ async function install(options) {
1370
1494
  }
1371
1495
  const vendoredBinSrc = resolveVendoredBinary();
1372
1496
  const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
1373
- record(report, writeCollisionSafe(vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8")), ".halfcycle/bin/bin.bundle.mjs");
1497
+ record(report, writeOwned(vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8")), ".halfcycle/bin/bin.bundle.mjs");
1374
1498
  const settingsPath = join5(targetRepo, ".claude", "settings.json");
1375
1499
  const settingsPreexisted = existsSync3(settingsPath);
1376
1500
  const generatedSettings = JSON.parse(generateSettingsJson());
@@ -1451,6 +1575,18 @@ async function install(options) {
1451
1575
  };
1452
1576
  }
1453
1577
 
1578
+ // dist/control-origin.js
1579
+ var DEFAULT_CONTROL_ORIGIN = "https://control.halfcycle.ai";
1580
+ function resolveControlOrigin(env = process.env) {
1581
+ const configured = env["HALFCYCLE_SERVICE_URL"]?.trim();
1582
+ if (configured)
1583
+ return { origin: configured.replace(/\/+$/, ""), source: "environment" };
1584
+ return { origin: DEFAULT_CONTROL_ORIGIN, source: "default" };
1585
+ }
1586
+ function controlOriginNote(resolved) {
1587
+ return resolved.source === "environment" ? `${resolved.origin} (from HALFCYCLE_SERVICE_URL)` : `${resolved.origin} (the Halfcycle plane; set HALFCYCLE_SERVICE_URL to use another)`;
1588
+ }
1589
+
1454
1590
  // dist/create-engagement.js
1455
1591
  var CreateEngagementRefused = class extends Error {
1456
1592
  status;
@@ -1508,7 +1644,7 @@ function planeRefusal(detail) {
1508
1644
  error: typeof candidate.error === "string" ? candidate.error : void 0
1509
1645
  };
1510
1646
  }
1511
- var CONTROL_ORIGIN_HINT = "HALFCYCLE_SERVICE_URL must be the Halfcycle CONTROL origin \u2014 the one that serves /engagements and /board/enter-codes. The MCP server runs at a different address, and the platform supplies that one itself; you do not configure it.";
1647
+ var CONTROL_ORIGIN_HINT = `With nothing configured this CLI talks to ${DEFAULT_CONTROL_ORIGIN}; HALFCYCLE_SERVICE_URL overrides that, and is only for a self-hosted or development plane. Whichever is in use, it must be the CONTROL origin \u2014 the one that serves /engagements and /board/enter-codes. The MCP server runs at a different address, and the platform supplies that one itself; you do not configure it.`;
1512
1648
  async function createEngagement(baseUrl, name, credential) {
1513
1649
  return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements`, name ? { name } : {}, credential, { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" });
1514
1650
  }
@@ -2500,7 +2636,7 @@ async function closePhase(credential, phase, outcome, close) {
2500
2636
  }
2501
2637
  const parsedClose = phaseCloseDecisionSchema.safeParse(close);
2502
2638
  if (!parsedClose.success) {
2503
- throw new Error(`[halfcycle] That is not a phase-close decision this platform accepts: ${parsedClose.error.issues.map((i) => i.message).join("; ")}.`);
2639
+ throw new Error(`[halfcycle] That is not a phase-close decision this platform accepts: ${parsedClose.error.issues.map((i) => `${i.path.join(".") || "(decision)"}: ${i.message}`).join("; ")}.`);
2504
2640
  }
2505
2641
  const url = `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/accept`;
2506
2642
  let res;
@@ -2774,45 +2910,43 @@ ${USAGE}`);
2774
2910
  return;
2775
2911
  }
2776
2912
  try {
2777
- const serviceUrl = process.env.HALFCYCLE_SERVICE_URL?.trim();
2778
- const pinned = engagementIdArg === void 0 ? readPinnedEngagement(targetRepo) : null;
2779
- let engagementId = engagementIdArg ?? pinned?.engagementId ?? "engagement-001";
2913
+ const controlOrigin = resolveControlOrigin(process.env);
2914
+ const serviceUrl = controlOrigin.origin;
2915
+ process.stdout.write(`[halfcycle] Halfcycle plane: ${controlOriginNote(controlOrigin)}
2916
+ `);
2917
+ const pinned = readPinnedEngagement(targetRepo);
2918
+ const requestedId = engagementIdArg ?? pinned?.engagementId;
2919
+ const reusable = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
2920
+ let engagementId;
2780
2921
  let credential;
2781
- if (pinned) {
2782
- credential = pinned.credential;
2922
+ if (reusable !== void 0 && pinned !== null) {
2923
+ engagementId = pinned.engagementId;
2924
+ credential = reusable;
2783
2925
  process.stdout.write(`[halfcycle] Re-using the engagement already pinned in this repo: ${pinned.engagementId}
2784
2926
  `);
2785
- if (credential === void 0 && serviceUrl) {
2786
- const joined = await joinPinnedEngagement(serviceUrl, pinned.engagementId);
2787
- credential = {
2788
- serviceUrl,
2789
- token: joined.sessionToken,
2790
- mcpUrl: joined.mcpUrl,
2791
- guardUrl: joined.guardUrl,
2792
- // T-10 — optional; absent on a plane that has not configured
2793
- // CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
2794
- // an absent value, never `undefined` verbatim.
2795
- controlTelemetryUrl: joined.controlTelemetryUrl
2796
- };
2797
- process.stdout.write(`[halfcycle] Joined engagement ${joined.engagementId} \u2014 this credential is yours. Everyone else's keeps working; nobody was signed out and nothing was pasted.
2798
- `);
2799
- } else if (credential === void 0) {
2800
- process.stdout.write(`[halfcycle] This machine holds no credential for it, and HALFCYCLE_SERVICE_URL is not
2801
- [halfcycle] set \u2014 so there is no plane to ask for one. Nothing will be minted, and the
2802
- [halfcycle] board link and MCP calls will not work until it is. Looked in
2803
- [halfcycle] ${engagementEnvPath(pinned.engagementId)}.
2804
- [halfcycle] Set that variable and re-run to get a credential of your own for this
2805
- [halfcycle] engagement. To start a NEW engagement instead, remove
2806
- [halfcycle] .halfcycle/bundle.json first.
2807
- `);
2808
- } else if (pinned.fromLegacyEnvLocal) {
2927
+ if (pinned.fromLegacyEnvLocal) {
2809
2928
  process.stdout.write(`[halfcycle] Its credential is in this repository's .env.local \u2014 an install from before
2810
2929
  [halfcycle] credentials moved out of the tree. It is being copied to
2811
2930
  [halfcycle] ${engagementEnvPath(pinned.engagementId)} and taken back out of .env.local.
2812
2931
  [halfcycle] Anything else that file holds is untouched.
2813
2932
  `);
2814
2933
  }
2815
- } else if (serviceUrl && engagementIdArg === void 0) {
2934
+ } else if (requestedId !== void 0) {
2935
+ const joined = await joinPinnedEngagement(serviceUrl, requestedId);
2936
+ engagementId = joined.engagementId;
2937
+ credential = {
2938
+ serviceUrl,
2939
+ token: joined.sessionToken,
2940
+ mcpUrl: joined.mcpUrl,
2941
+ guardUrl: joined.guardUrl,
2942
+ // T-10 — optional; absent on a plane that has not configured
2943
+ // CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
2944
+ // an absent value, never `undefined` verbatim.
2945
+ controlTelemetryUrl: joined.controlTelemetryUrl
2946
+ };
2947
+ process.stdout.write(`[halfcycle] Joined engagement ${joined.engagementId} \u2014 this credential is yours. Everyone else's keeps working; nobody was signed out and nothing was pasted.
2948
+ `);
2949
+ } else {
2816
2950
  const created = await createOwnedEngagement(serviceUrl);
2817
2951
  engagementId = created.engagementId;
2818
2952
  credential = {
@@ -2863,44 +2997,32 @@ ${USAGE}`);
2863
2997
  [halfcycle] ${missing.length === 1 ? "it" : "them"}) and re-run, and the credential moves out of the tree.
2864
2998
  `);
2865
2999
  }
2866
- if (credential) {
2867
- const probe = await probeMcpOrigin(credential.mcpUrl);
2868
- if (probe.reached) {
2869
- process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
2870
- `);
2871
- } else {
2872
- process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
2873
- [halfcycle] The Halfcycle commands will have no method context until that address answers. This is the SECOND of two origins and the platform supplied it, so it is not your HALFCYCLE_SERVICE_URL (${credential.serviceUrl}) that is wrong \u2014 that one just worked. It is recorded as HALFCYCLE_MCP_URL in this engagement's credential store; re-run this installer once the service is up.
2874
- `);
2875
- }
2876
- }
2877
- if (credential) {
2878
- process.stdout.write(`[halfcycle] Guard evaluation: ON \u2014 the hook evaluates against ${credential.guardUrl}. Credentials (${GUARD_ENV_KEYS.join(", ")}) are in ${engagementEnvPath(engagementId)}, outside this repository; the hook loads them itself, so you do not export anything and nothing here can be committed.
3000
+ const probe = await probeMcpOrigin(credential.mcpUrl);
3001
+ if (probe.reached) {
3002
+ process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
2879
3003
  `);
2880
3004
  } else {
2881
- const remedy = pinned ? `Set HALFCYCLE_SERVICE_URL to the Halfcycle CONTROL origin and re-run \u2014 this repo is pinned to engagement ${pinned.engagementId}, and with a plane to ask, the installer mints you a credential of your own for it, at ${engagementEnvPath(pinned.engagementId)}. To start a NEW engagement here instead, remove .halfcycle/bundle.json and re-run.` : engagementIdArg !== void 0 ? `This install named engagement ${engagementIdArg} explicitly, which skips the create that mints a credential. This repo is now pinned to it, so re-run \`npx halfcycle\` with no id and the installer will mint you a credential of your own for it. To start a NEW engagement instead, remove .halfcycle/bundle.json and re-run.` : `Set HALFCYCLE_SERVICE_URL to the Halfcycle CONTROL origin and re-run this installer.`;
2882
- process.stdout.write(`[halfcycle] Guard evaluation: OFF \u2014 this install had no credential, so no guard will run in this repository and edits are unguarded. ${remedy}
3005
+ process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
3006
+ [halfcycle] The Halfcycle commands will have no method context until that address answers. This is the SECOND of two origins and the platform supplied it, so it is not the CONTROL origin (${credential.serviceUrl}) that is wrong \u2014 that one just worked. It is recorded as HALFCYCLE_MCP_URL in this engagement's credential store; re-run this installer once the service is up.
2883
3007
  `);
2884
3008
  }
2885
- if (credential) {
2886
- process.stdout.write(`[halfcycle] Your credential is at ${engagementEnvPath(engagementId)} \u2014 outside this repository, readable only by you. .mcp.json reads it at connection time through .halfcycle/mcp-headers.sh, so there is no token in this tree to commit and nothing for you to export.
3009
+ process.stdout.write(`[halfcycle] Guard evaluation: ON \u2014 the hook evaluates against ${credential.guardUrl}. Credentials (${GUARD_ENV_KEYS.join(", ")}) are in ${engagementEnvPath(engagementId)}, outside this repository; the hook loads them itself, so you do not export anything and nothing here can be committed.
2887
3010
  `);
2888
- process.stdout.write(`[halfcycle] Expect a workspace trust prompt the first time you open this folder \u2014 the helper is a shell command, and Claude Code will not run it until you accept. Needs Claude Code ${MIN_HEADERS_HELPER_VERSION} or newer (${MIN_HEADER_ROTATION_VERSION}+ to re-authenticate a rotated token without restarting).
3011
+ process.stdout.write(`[halfcycle] Your credential is at ${engagementEnvPath(engagementId)} \u2014 outside this repository, readable only by you. .mcp.json reads it at connection time through .halfcycle/mcp-headers.sh, so there is no token in this tree to commit and nothing for you to export.
2889
3012
  `);
2890
- process.stdout.write(`[halfcycle] If Halfcycle tools appear but every call returns 401, the helper could not read HALFCYCLE_TOKEN from ${engagementEnvPath(engagementId)} \u2014 re-run this installer.
3013
+ process.stdout.write(`[halfcycle] Expect a workspace trust prompt the first time you open this folder \u2014 the helper is a shell command, and Claude Code will not run it until you accept. Needs Claude Code ${MIN_HEADERS_HELPER_VERSION} or newer (${MIN_HEADER_ROTATION_VERSION}+ to re-authenticate a rotated token without restarting).
2891
3014
  `);
2892
- reportClaudeCodeVersion();
2893
- }
2894
- if (credential) {
2895
- try {
2896
- const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
2897
- process.stdout.write(`[halfcycle] Your board (first visit): ${minted.boardUrl}
3015
+ process.stdout.write(`[halfcycle] If Halfcycle tools appear but every call returns 401, the helper could not read HALFCYCLE_TOKEN from ${engagementEnvPath(engagementId)} \u2014 re-run this installer.
2898
3016
  `);
2899
- process.stdout.write(`[halfcycle] First-visit code \u2014 single use, expires ${minted.expiresAt}; paste it on that page, and re-run this installer for a fresh one: ${minted.enterCode}
3017
+ reportClaudeCodeVersion();
3018
+ try {
3019
+ const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
3020
+ process.stdout.write(`[halfcycle] Your board (first visit): ${minted.boardUrl}
2900
3021
  `);
2901
- } catch {
2902
- process.stdout.write("[halfcycle] Your board: no first-visit code was issued just now \u2014 re-run this installer to get one.\n");
2903
- }
3022
+ process.stdout.write(`[halfcycle] First-visit code \u2014 single use, expires ${minted.expiresAt}; paste it on that page, and re-run this installer for a fresh one: ${minted.enterCode}
3023
+ `);
3024
+ } catch {
3025
+ process.stdout.write("[halfcycle] Your board: no first-visit code was issued just now \u2014 re-run this installer to get one.\n");
2904
3026
  }
2905
3027
  process.exit(0);
2906
3028
  } catch (err) {