halfcycle 0.3.18 → 0.3.19

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.18",
3
+ "version": "0.3.19",
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
  {
package/README.md CHANGED
@@ -42,7 +42,7 @@ A method document cannot fix that, because a document cannot interrupt you. This
42
42
 
43
43
  Credit where it is due, and it is due. GStack, Matt Pocock's skills, GitHub Spec Kit, BMAD, OpenSpec and claude-flow are genuinely good, and between them they taught this whole category that the bottleneck is discipline rather than model intelligence. We took two things from them deliberately and say so: the one-question-at-a-time interrogation shape, and an install that is one command and then stays out of your way.
44
44
 
45
- If you are one person on a two week project, install one of those instead. They are excellent at exactly that, and you may not need any of this yet.
45
+ The objection this usually meets is that it must be too much for a small project. It would be, if there were one setting. A folder you download has exactly one, so whoever wrote it had to choose who to disappoint, and it is usually the small project that gets buried and quietly drops the process in week three. This reads the work instead and sizes itself to it, which is the section further down.
46
46
 
47
47
  Writing the process down was never the hard part. What a folder you download structurally cannot have is these four things.
48
48
 
@@ -39,6 +39,9 @@ function readConfig() {
39
39
  }
40
40
  };
41
41
  }
42
+ function readNeverConfiguredReason() {
43
+ return process.env["HALFCYCLE_NOT_THIS_ACCOUNT"] === "1" ? "not-this-account" : "no-credential-yet";
44
+ }
42
45
  function readExcerptConfig() {
43
46
  const raw = process.env["HALFCYCLE_SOURCE_EXCERPTS"];
44
47
  const enabled = (raw ?? "").trim().toLowerCase() === "true";
@@ -14781,6 +14784,8 @@ function renderPhaseSegment(identity) {
14781
14784
  return `${PHASE_SEGMENT_PREFIX}${identity}`;
14782
14785
  }
14783
14786
  function describeIdentity(identity) {
14787
+ if (identity === "")
14788
+ return "(empty)";
14784
14789
  if (typeof identity === "string")
14785
14790
  return identity;
14786
14791
  if (identity === null)
@@ -16194,8 +16199,8 @@ This is not a service outage: the guard service answered. The suspect is this re
16194
16199
  }
16195
16200
  var UNCONFIGURED_EXIT_TOOL_USE = 2;
16196
16201
  var UNCONFIGURED_EXIT_STOP_FAMILY = 1;
16197
- function emitNeverConfigured(missing, exitCode) {
16198
- writeSync(2, neverConfiguredMessage(missing, HOOK_REMEDY));
16202
+ function emitNeverConfigured(missing, exitCode, reason) {
16203
+ writeSync(2, neverConfiguredReport(missing, reason));
16199
16204
  process.exitCode = exitCode;
16200
16205
  }
16201
16206
  var HOOK_REMEDY = `Fix it by running "npx halfcycle" in this repository: it may open your browser to sign in first, then writes this engagement's credentials to ~/.halfcycle \u2014 outside the repository \u2014 and the hook loads them itself.`;
@@ -16207,6 +16212,18 @@ This is not a service outage. It means the guard has never run here \u2014 not o
16207
16212
  ${remedy}
16208
16213
  `;
16209
16214
  }
16215
+ function notThisAccountReport(label) {
16216
+ return `${label} This project belongs to another Halfcycle account. Halfcycle is Solo today \u2014 one person works on a project at a time \u2014 so no credential exists for this machine and none will be issued. Nothing here needs fixing; this copy simply is not checked. Ask the project's owner for view access, or \u2014 if this account is wrong \u2014 sign in as the one that owns the project and run "npx halfcycle" again.
16217
+ `;
16218
+ }
16219
+ function neverConfiguredReport(missing, reason, label = "[Halfcycle]") {
16220
+ switch (reason) {
16221
+ case "no-credential-yet":
16222
+ return neverConfiguredMessage(missing, HOOK_REMEDY, label);
16223
+ case "not-this-account":
16224
+ return notThisAccountReport(label);
16225
+ }
16226
+ }
16210
16227
  var CREDENTIAL_REJECTED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
16211
16228
  function classifyFailure(failure) {
16212
16229
  if (failure.kind === "contract-error") {
@@ -16430,7 +16447,7 @@ async function runPostToolUse() {
16430
16447
  const configResult = readConfig();
16431
16448
  if (!configResult.config) {
16432
16449
  await emitRunRecord({ runType: "hook", outcome: "unconfigured", changeSet });
16433
- emitNeverConfigured(configResult.missing, UNCONFIGURED_EXIT_TOOL_USE);
16450
+ emitNeverConfigured(configResult.missing, UNCONFIGURED_EXIT_TOOL_USE, readNeverConfiguredReason());
16434
16451
  return;
16435
16452
  }
16436
16453
  const { guardServiceUrl, guardServiceToken, guardEngagementId } = configResult.config;
@@ -16696,7 +16713,7 @@ async function runSessionDiff(input, kind) {
16696
16713
  const configResult = readConfig();
16697
16714
  if (!configResult.config) {
16698
16715
  await emitRunRecord({ runType: "hook", outcome: "unconfigured", changeSet, diffBase });
16699
- emitNeverConfigured(configResult.missing, UNCONFIGURED_EXIT_STOP_FAMILY);
16716
+ emitNeverConfigured(configResult.missing, UNCONFIGURED_EXIT_STOP_FAMILY, readNeverConfiguredReason());
16700
16717
  return;
16701
16718
  }
16702
16719
  const { guardServiceUrl, guardServiceToken, guardEngagementId } = configResult.config;
package/dist/bin.js CHANGED
@@ -441,11 +441,26 @@ function loopbackVerificationUrl(verificationUrl, target) {
441
441
  return url.toString();
442
442
  }
443
443
 
444
- // ../events/dist/account-identity.js
444
+ // ../events/dist/join-refusal.js
445
445
  import { z as z7 } from "zod";
446
- var accountIdentitySchema = z7.object({
447
- accountId: z7.string().min(1),
448
- email: z7.string().min(1).optional()
446
+ var NOT_THIS_ACCOUNT_REASON = "not-this-account";
447
+ var JOIN_REFUSAL_STATUS = 403;
448
+ var notThisAccountRefusalSchema = z7.object({
449
+ statusCode: z7.literal(JOIN_REFUSAL_STATUS),
450
+ error: z7.string(),
451
+ message: z7.string(),
452
+ reason: z7.literal(NOT_THIS_ACCOUNT_REASON)
453
+ }).strict();
454
+ var joinRefusalSchema = z7.union([notThisAccountRefusalSchema, wireErrorSchema]);
455
+ function isNotThisAccountRefusal(body) {
456
+ return typeof body === "object" && body !== null && body.reason === NOT_THIS_ACCOUNT_REASON;
457
+ }
458
+
459
+ // ../events/dist/account-identity.js
460
+ import { z as z8 } from "zod";
461
+ var accountIdentitySchema = z8.object({
462
+ accountId: z8.string().min(1),
463
+ email: z8.string().min(1).optional()
449
464
  }).strict();
450
465
  function safeParseAccountIdentity(payload) {
451
466
  return accountIdentitySchema.safeParse(payload);
@@ -495,6 +510,8 @@ function renderPhaseSegment(identity) {
495
510
  return `${PHASE_SEGMENT_PREFIX}${identity}`;
496
511
  }
497
512
  function describeIdentity(identity) {
513
+ if (identity === "")
514
+ return "(empty)";
498
515
  if (typeof identity === "string")
499
516
  return identity;
500
517
  if (identity === null)
@@ -595,6 +612,18 @@ function engagementStateDir(engagementId, home) {
595
612
  function engagementEnvPath(engagementId, home) {
596
613
  return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
597
614
  }
615
+ var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
616
+ function notThisAccountMarkerPath(engagementId, home) {
617
+ return join2(engagementStateDir(engagementId, home), NOT_THIS_ACCOUNT_MARKER_FILENAME);
618
+ }
619
+ function writeNotThisAccountMarker(engagementId, home) {
620
+ const dir = engagementStateDir(engagementId, home);
621
+ mkdirSync2(dir, { recursive: true, mode: 448 });
622
+ const path = notThisAccountMarkerPath(engagementId, home);
623
+ writeFileSync2(path, `${(/* @__PURE__ */ new Date()).toISOString()}
624
+ `, { mode: 384 });
625
+ applyOwnerOnly(path, dir);
626
+ }
598
627
  var GUARD_ENV_KEYS = [
599
628
  "GUARD_SERVICE_URL",
600
629
  "GUARD_SERVICE_TOKEN",
@@ -736,14 +765,15 @@ function engagementResolutionShell() {
736
765
  # desktop session's PATH, so the id is cut out with sed \u2014 after flattening the
737
766
  # file, because sed is line-oriented and the pin's line breaks are not a contract.
738
767
  #
739
- # Sets HALFCYCLE_ENV_FILE, HALFCYCLE_ENV_ENGAGEMENT and HALFCYCLE_ENV_PROBLEM in
740
- # the CALLER's shell and returns a status. Call it as \`if halfcycle_env_file \u2026\`,
741
- # never as \`$(halfcycle_env_file \u2026)\` \u2014 a command substitution is a subshell and
742
- # would discard everything it set.
768
+ # Sets HALFCYCLE_ENV_FILE, HALFCYCLE_ENV_ENGAGEMENT, HALFCYCLE_ENV_PROBLEM and
769
+ # HALFCYCLE_ENV_NOT_THIS_ACCOUNT in the CALLER's shell and returns a status. Call
770
+ # it as \`if halfcycle_env_file \u2026\`, never as \`$(halfcycle_env_file \u2026)\` \u2014 a
771
+ # command substitution is a subshell and would discard everything it set.
743
772
  halfcycle_env_file() {
744
773
  HALFCYCLE_ENV_FILE=""
745
774
  HALFCYCLE_ENV_ENGAGEMENT=""
746
775
  HALFCYCLE_ENV_PROBLEM=""
776
+ HALFCYCLE_ENV_NOT_THIS_ACCOUNT=""
747
777
  hc_pin="$1/.halfcycle/bundle.json"
748
778
  if [ ! -f "$hc_pin" ]; then
749
779
  HALFCYCLE_ENV_PROBLEM="no-pin"
@@ -759,6 +789,9 @@ halfcycle_env_file() {
759
789
  HALFCYCLE_ENV_PROBLEM="no-home"
760
790
  return 1
761
791
  fi
792
+ if [ -f "$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${NOT_THIS_ACCOUNT_MARKER_FILENAME}" ]; then
793
+ HALFCYCLE_ENV_NOT_THIS_ACCOUNT=1
794
+ fi
762
795
  hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
763
796
  if [ ! -f "$hc_env" ]; then
764
797
  HALFCYCLE_ENV_PROBLEM="no-credential"
@@ -1130,6 +1163,13 @@ if halfcycle_env_file "$PROJECT_DIR"; then
1130
1163
  set +a
1131
1164
  fi
1132
1165
 
1166
+ # Pass along WHY this machine has no credential, as a plain variable \u2014 the
1167
+ # marker check above already answered it, and this is the one bit of that
1168
+ # answer the runner binary needs to say the right thing on every edit.
1169
+ if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
1170
+ export HALFCYCLE_NOT_THIS_ACCOUNT=1
1171
+ fi
1172
+
1133
1173
  exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
1134
1174
  `;
1135
1175
  }
@@ -1145,13 +1185,20 @@ MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
1145
1185
  git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
1146
1186
 
1147
1187
  # ---------------------------------------------------------------------------
1148
- # STANDING GUARD-COVERAGE STATEMENT (T-27).
1188
+ # STANDING GUARD-COVERAGE STATEMENT (T-27; two arms since T-02b).
1149
1189
  #
1150
1190
  # A repository with no credential on this machine is UNGUARDED: the PostToolUse
1151
1191
  # hook will find nothing to evaluate with, and it says so once per edit, after
1152
1192
  # the edit. This says it once, up front, before anything is written \u2014 which is
1153
1193
  # the one thing a session-start hook can do that nothing else can.
1154
1194
  #
1195
+ # TWO REASONS TO HAVE NO CREDENTIAL, TWO DIFFERENT THINGS TO SAY (T-02b,
1196
+ # solo-project-ownership \u2014 outcome B). "You have not signed in here yet" is
1197
+ # fixed by re-running the installer; "this is not your project" is permanent,
1198
+ # and the same remedy would send the reader in a circle forever. The two are
1199
+ # told apart by HALFCYCLE_ENV_NOT_THIS_ACCOUNT, set by \`halfcycle_env_file\`
1200
+ # below from a machine-level marker beside the credential store.
1201
+ #
1155
1202
  # Everything below runs in a SUBSHELL: it sources a credential store, and a
1156
1203
  # session-start hook must not leak an engagement's variables into whatever the
1157
1204
  # editor runs next. Any failure inside it is swallowed; this script's exit
@@ -1165,18 +1212,33 @@ ${engagementResolutionShell()}
1165
1212
  . "$HALFCYCLE_ENV_FILE"
1166
1213
  set +a
1167
1214
  fi
1168
- hc_missing=""
1169
- for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
1170
- eval "hc_value=\\\${$hc_key:-}"
1171
- if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
1172
- done
1173
- if [ -n "$hc_missing" ]; then
1174
- echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
1175
- echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
1176
- echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
1177
- echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
1178
- echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
1179
- echo "[halfcycle] and needs nothing configured first."
1215
+ if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
1216
+ # OUTCOME B (T-02b, solo-project-ownership). This is not the missing-keys
1217
+ # case below wearing different words: there is nothing to fix, and the loop
1218
+ # that follows is skipped entirely rather than run and then overridden \u2014 a
1219
+ # repository whose credential is absent for THIS reason must never be told
1220
+ # to re-run the installer, which can never obtain one for it.
1221
+ echo "[halfcycle] This project belongs to another Halfcycle account, so nothing here is checked."
1222
+ echo "[halfcycle] Your work carries on exactly as it would without Halfcycle \u2014 no edit is"
1223
+ echo "[halfcycle] evaluated, and nothing you do is reported as having passed."
1224
+ echo "[halfcycle] Halfcycle is Solo today \u2014 one developer per project \u2014 with Team plans coming."
1225
+ echo "[halfcycle] Ask the project's owner for view access."
1226
+ echo "[halfcycle] If this account is wrong, sign in as the one that owns the project and run"
1227
+ echo "[halfcycle] \\"npx halfcycle\\" again."
1228
+ else
1229
+ hc_missing=""
1230
+ for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
1231
+ eval "hc_value=\\\${$hc_key:-}"
1232
+ if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
1233
+ done
1234
+ if [ -n "$hc_missing" ]; then
1235
+ echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
1236
+ echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
1237
+ echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
1238
+ echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
1239
+ echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
1240
+ echo "[halfcycle] and needs nothing configured first."
1241
+ fi
1180
1242
  fi
1181
1243
  ) 2>/dev/null || true
1182
1244
  exit 0
@@ -1336,8 +1398,20 @@ if [ -z "$TOKEN" ]; then
1336
1398
  no-home)
1337
1399
  echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
1338
1400
  *)
1339
- echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
1340
- echo "halfcycle: run \\"npx halfcycle\\" in this repository \u2014 it will sign you in through your browser if needed, and write the credential to $HOME/.halfcycle, outside this repository." >&2 ;;
1401
+ # OUTCOME B (T-02b, solo-project-ownership) is a \`no-credential\` PROBLEM
1402
+ # with a REASON attached, checked here rather than as a fifth case value
1403
+ # \u2014 see \`engagementResolutionShell\`'s own docblock for why. Re-running
1404
+ # this installer can never obtain a credential for this reason, so it is
1405
+ # the one sentence in this file that must not say to.
1406
+ if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
1407
+ echo "halfcycle: this project belongs to another Halfcycle account, so this machine has no" >&2
1408
+ echo "halfcycle: credential for it and the Halfcycle tools are not available here. Nothing is" >&2
1409
+ echo "halfcycle: broken \u2014 ask the project's owner for view access, or sign in as the account" >&2
1410
+ echo "halfcycle: that owns it and run \\"npx halfcycle\\" again." >&2
1411
+ else
1412
+ echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
1413
+ echo "halfcycle: run \\"npx halfcycle\\" in this repository \u2014 it will sign you in through your browser if needed, and write the credential to $HOME/.halfcycle, outside this repository." >&2
1414
+ fi ;;
1341
1415
  esac
1342
1416
  exit 1
1343
1417
  fi
@@ -2142,11 +2216,13 @@ var CreateEngagementRefused = class extends Error {
2142
2216
  status;
2143
2217
  serverMessage;
2144
2218
  errorCode;
2145
- constructor(status, serverMessage, errorCode, message) {
2219
+ reason;
2220
+ constructor(status, serverMessage, errorCode, reason, message) {
2146
2221
  super(message);
2147
2222
  this.status = status;
2148
2223
  this.serverMessage = serverMessage;
2149
2224
  this.errorCode = errorCode;
2225
+ this.reason = reason;
2150
2226
  this.name = "CreateEngagementRefused";
2151
2227
  }
2152
2228
  /**
@@ -2191,7 +2267,8 @@ function planeRefusal(detail) {
2191
2267
  return void 0;
2192
2268
  return {
2193
2269
  message: candidate.message,
2194
- error: typeof candidate.error === "string" ? candidate.error : void 0
2270
+ error: typeof candidate.error === "string" ? candidate.error : void 0,
2271
+ reason: typeof candidate.reason === "string" ? candidate.reason : void 0
2195
2272
  };
2196
2273
  }
2197
2274
  var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}; 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 configure neither.`;
@@ -2232,7 +2309,7 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
2232
2309
  const detail = await res.text().catch(() => "");
2233
2310
  const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
2234
2311
  if (refusal) {
2235
- throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
2312
+ throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, refusal.reason, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
2236
2313
  }
2237
2314
  const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the Halfcycle service. ` : "";
2238
2315
  throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
@@ -2713,6 +2790,9 @@ function armScript(credential, act, label, plainLabel, home) {
2713
2790
  }
2714
2791
  }
2715
2792
  }
2793
+ function identitySwitchRemedy(home) {
2794
+ return `To act as a different account: run this again and answer "s" when asked, set HALFCYCLE_TOKEN for that account, or remove ${accountStorePath(home)} to be asked for a sign-in next time.`;
2795
+ }
2716
2796
  async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
2717
2797
  const write = deps.write ?? ((text) => process.stdout.write(text));
2718
2798
  const colour = deps.colour ?? decideColour({ isTTY: process.stdout.isTTY === true, env: deps.env ?? process.env });
@@ -2778,6 +2858,9 @@ function deriveProjectName(targetRepoRoot) {
2778
2858
  }
2779
2859
 
2780
2860
  // dist/own-engagement.js
2861
+ function isOutcomeBRefusal(result) {
2862
+ return "outcome" in result && result.outcome === "not-this-account";
2863
+ }
2781
2864
  async function createOwnedEngagement(serviceUrl, targetRepo, deps = {}) {
2782
2865
  let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, deps), { kind: "create", targetRepo }, deps);
2783
2866
  for (; ; ) {
@@ -2812,6 +2895,13 @@ async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps =
2812
2895
  if (!(err instanceof CreateEngagementRefused))
2813
2896
  throw err;
2814
2897
  if (err.status === 403) {
2898
+ if (isNotThisAccountRefusal({ reason: err.reason })) {
2899
+ return {
2900
+ outcome: "not-this-account",
2901
+ message: err.serverMessage,
2902
+ actingAccountId: credential.accountId
2903
+ };
2904
+ }
2815
2905
  throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${JOIN_REFUSED_LOCAL_REMEDY}`);
2816
2906
  }
2817
2907
  if (err.status !== 401) {
@@ -3806,6 +3896,7 @@ ${USAGE}`);
3806
3896
  const reusable = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
3807
3897
  let engagementId;
3808
3898
  let credential;
3899
+ let notThisAccountMessage;
3809
3900
  let actingAccountId;
3810
3901
  let foundLine = "";
3811
3902
  if (reusable !== void 0 && pinned !== null) {
@@ -3826,21 +3917,30 @@ ${USAGE}`);
3826
3917
  }
3827
3918
  } else if (requestedId !== void 0) {
3828
3919
  const joined = await joinPinnedEngagement(serviceUrl, requestedId, targetRepo, { assumeYes, verbose });
3829
- engagementId = joined.engagementId;
3830
- actingAccountId = joined.actingAccountId;
3831
- credential = {
3832
- serviceUrl,
3833
- token: joined.sessionToken,
3834
- mcpUrl: joined.mcpUrl,
3835
- guardUrl: joined.guardUrl,
3836
- // T-10 — optional; absent on a plane that has not configured
3837
- // CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
3838
- // an absent value, never `undefined` verbatim.
3839
- controlTelemetryUrl: joined.controlTelemetryUrl
3840
- };
3841
- foundLine = `this repo is pinned to a shared project \u2014 joining it as you (${shortId(joined.engagementId)})`;
3842
- if (verbose) {
3843
- 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.
3920
+ if (isOutcomeBRefusal(joined)) {
3921
+ engagementId = requestedId;
3922
+ actingAccountId = joined.actingAccountId;
3923
+ credential = void 0;
3924
+ notThisAccountMessage = joined.message;
3925
+ foundLine = `this repo is pinned to a project that belongs to a different Halfcycle account`;
3926
+ writeNotThisAccountMarker(engagementId);
3927
+ } else {
3928
+ engagementId = joined.engagementId;
3929
+ actingAccountId = joined.actingAccountId;
3930
+ credential = {
3931
+ serviceUrl,
3932
+ token: joined.sessionToken,
3933
+ mcpUrl: joined.mcpUrl,
3934
+ guardUrl: joined.guardUrl,
3935
+ // T-10 — optional; absent on a plane that has not configured
3936
+ // CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
3937
+ // an absent value, never `undefined` verbatim.
3938
+ controlTelemetryUrl: joined.controlTelemetryUrl
3939
+ };
3940
+ foundLine = `this repo is pinned to a shared project \u2014 joining it as you (${shortId(joined.engagementId)})`;
3941
+ }
3942
+ if (verbose && credential !== void 0) {
3943
+ process.stdout.write(`[halfcycle] Joined engagement ${engagementId} \u2014 this credential is yours. Everyone else's keeps working; nobody was signed out and nothing was pasted.
3844
3944
  `);
3845
3945
  }
3846
3946
  } else {
@@ -3901,18 +4001,20 @@ ${USAGE}`);
3901
4001
  [halfcycle] ${missing.length === 1 ? "it" : "them"}) and re-run, and the credential moves out of the tree.
3902
4002
  `);
3903
4003
  }
3904
- const probe = await probeMcpOrigin(credential.mcpUrl);
3905
- if (probe.reached) {
3906
- if (verbose) {
3907
- process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
4004
+ if (credential) {
4005
+ const probe = await probeMcpOrigin(credential.mcpUrl);
4006
+ if (probe.reached) {
4007
+ if (verbose) {
4008
+ process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
3908
4009
  `);
3909
- }
3910
- } else {
3911
- process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
4010
+ }
4011
+ } else {
4012
+ process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
3912
4013
  [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.
3913
4014
  `);
4015
+ }
3914
4016
  }
3915
- if (verbose) {
4017
+ if (credential && verbose) {
3916
4018
  process.stdout.write(`[halfcycle] Guard evaluation: ON \u2014 the hook evaluates against ${credential.guardUrl}. Credentials (${GUARD_COVERAGE_REQUIRED_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.
3917
4019
  `);
3918
4020
  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.
@@ -3920,30 +4022,38 @@ ${USAGE}`);
3920
4022
  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).
3921
4023
  `);
3922
4024
  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.
4025
+ `);
4026
+ }
4027
+ if (credential === void 0 && notThisAccountMessage !== void 0) {
4028
+ process.stdout.write(`[halfcycle] ${notThisAccountMessage} Halfcycle is Solo today \u2014 one developer per project \u2014 with Team plans coming. Your work carries on exactly as it would without Halfcycle: no edit is evaluated, and nothing you do here is reported as having passed. Ask the project's owner for view access. ${identitySwitchRemedy()}
3923
4029
  `);
3924
4030
  }
3925
4031
  reportClaudeCodeVersion(verbose);
3926
- const identity = await describeAccount(credential.serviceUrl, credential.token);
4032
+ const identity = credential ? await describeAccount(credential.serviceUrl, credential.token) : void 0;
3927
4033
  const written = result.writtenPaths.length;
3928
4034
  const merged = result.mergedPaths.length;
3929
4035
  process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId, verbose)}
3930
4036
  `);
3931
4037
  process.stdout.write(`[halfcycle] Found: ${foundLine}
3932
4038
  `);
3933
- process.stdout.write(`[halfcycle] Set up: ${written} file${written === 1 ? "" : "s"} written${merged > 0 ? ` (${merged} merged)` : ""}, guards armed on every edit
4039
+ process.stdout.write(`[halfcycle] Set up: ${written} file${written === 1 ? "" : "s"} written${merged > 0 ? ` (${merged} merged)` : ""}, ${credential ? "guards armed on every edit" : "not checked \u2014 this copy is not this account\u2019s project"}
3934
4040
  `);
3935
- process.stdout.write(`[halfcycle] Next: open this folder in Claude Code \u2014 accept the workspace-trust prompt, it is expected \u2014 then run /halfcycle-setup and answer what it asks about your project
4041
+ if (credential) {
4042
+ process.stdout.write(`[halfcycle] Next: open this folder in Claude Code \u2014 accept the workspace-trust prompt, it is expected \u2014 then run /halfcycle-setup and answer what it asks about your project
3936
4043
  `);
3937
- process.stdout.write(`[halfcycle] Claude Code will also ask permission the first time Halfcycle needs to look up your next step. Choose allow \u2014 without it nothing can run.
4044
+ process.stdout.write(`[halfcycle] Claude Code will also ask permission the first time Halfcycle needs to look up your next step. Choose allow \u2014 without it nothing can run.
3938
4045
  `);
3939
- try {
3940
- const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
3941
- process.stdout.write(`[halfcycle] Your project's page \u2014 how it is going, the agents working on it, and your other projects (first visit): ${minted.boardUrl}
4046
+ }
4047
+ if (credential) {
4048
+ try {
4049
+ const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
4050
+ process.stdout.write(`[halfcycle] Your project's page \u2014 how it is going, the agents working on it, and your other projects (first visit): ${minted.boardUrl}
3942
4051
  `);
3943
- 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}
4052
+ 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}
3944
4053
  `);
3945
- } catch {
3946
- process.stdout.write("[halfcycle] Your project's page: no first-visit code was issued just now \u2014 re-run this installer to get one.\n");
4054
+ } catch {
4055
+ process.stdout.write("[halfcycle] Your project's page: no first-visit code was issued just now \u2014 re-run this installer to get one.\n");
4056
+ }
3947
4057
  }
3948
4058
  process.exit(0);
3949
4059
  } catch (err) {
@@ -3988,13 +4098,14 @@ ${USAGE}`);
3988
4098
  if (cmd === "build-record") {
3989
4099
  const phaseArg = rest[0];
3990
4100
  const repoRoot = rest.includes("--repo") ? rest[rest.indexOf("--repo") + 1] : process.cwd();
3991
- if (!phaseArg) {
4101
+ if (phaseArg === void 0) {
3992
4102
  process.stderr.write("halfcycle: usage: halfcycle build-record <phase> [--repo <root>]\n");
3993
4103
  process.exit(1);
3994
4104
  return;
3995
4105
  }
3996
4106
  const phaseId = phaseArg;
3997
4107
  try {
4108
+ assertValidPhaseIdentity(phaseId, BUILD_RECORD_DIR);
3998
4109
  const inputPath = join11(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phaseId)}.input.json`);
3999
4110
  const input = JSON.parse(readFileSync9(inputPath, "utf-8"));
4000
4111
  const result = assemblePhaseBuildRecord({