halfcycle 0.3.36 → 0.3.38

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/bin.js CHANGED
@@ -230,6 +230,15 @@ function looksLikeSecret(text) {
230
230
  return false;
231
231
  }
232
232
 
233
+ // ../core/dist/mcp-registration.js
234
+ var MCP_REGISTRATION_REL = ".mcp.json";
235
+ var MCP_SERVER_KEY = "halfcycle";
236
+
237
+ // ../core/dist/shell-quote.js
238
+ function quoteForShell(value) {
239
+ return `'${value.replace(/'/g, `'\\''`)}'`;
240
+ }
241
+
233
242
  // ../events/dist/result.js
234
243
  import { z as z2 } from "zod";
235
244
  var firedGuardSchema = z2.object({
@@ -602,9 +611,14 @@ var closedBySchema = z4.discriminatedUnion("kind", [
602
611
  z4.object({ kind: z4.literal("account-removed") }).strict(),
603
612
  z4.object({ kind: z4.literal("not-recorded") }).strict()
604
613
  ]);
614
+ var STEP_NAME_MAX = 200;
615
+ var stepNameSchema = plainText(STEP_NAME_MAX).refine((v) => v.trim().length > 0, {
616
+ message: "must not be blank"
617
+ });
605
618
  var unfinishedStepSchema = z4.object({
606
619
  stepId: plainText(),
607
- state: z4.enum(["parked", "failed"])
620
+ state: z4.enum(["parked", "failed"]),
621
+ name: stepNameSchema.optional()
608
622
  }).strict();
609
623
  var crossedSchema = z4.discriminatedUnion("kind", [
610
624
  z4.object({ kind: z4.literal("none") }).strict(),
@@ -1213,14 +1227,28 @@ var engagementCredentialResponseSchema = z10.object({
1213
1227
  sessionToken: z10.string(),
1214
1228
  mcpUrl: z10.string(),
1215
1229
  guardUrl: z10.string(),
1216
- controlTelemetryUrl: z10.string()
1230
+ controlTelemetryUrl: z10.string(),
1231
+ methodVersion: z10.string().optional(),
1232
+ consoleUrl: z10.string().optional()
1217
1233
  }).strict();
1218
1234
  var ciTokenExchangeResponseSchema = z10.object({
1219
1235
  token: z10.string(),
1220
1236
  expiresAt: utcIso8601,
1221
1237
  engagementId: z10.string()
1222
1238
  }).strict();
1239
+ var NO_CREDENTIAL_PLACEHOLDER = "halfcycle:no-credential";
1240
+ var NOT_THIS_ACCOUNT_PLACEHOLDER = "halfcycle:not-this-account";
1241
+ var UPDATE_CLAUDE_CODE_PLACEHOLDER = "halfcycle:update-claude-code";
1223
1242
  var SWITCH_ACCOUNT_ANSWER = 'answer "s" when asked';
1243
+ var NO_CREDENTIAL_SENTENCE = 'Halfcycle is not connected: this machine has no working Halfcycle credential for this project. Run "npx halfcycle" in the project folder to sign in again.';
1244
+ var NOT_THIS_ACCOUNT_SENTENCE = `Halfcycle is not connected: this project belongs to another Halfcycle account. To switch, run "npx halfcycle" in this project and ${SWITCH_ACCOUNT_ANSWER}.`;
1245
+ var UPDATE_CLAUDE_CODE_SENTENCE = "Halfcycle is not connected: this Claude Code version does not name the server it connects to, so no credential was sent. Update Claude Code, then reopen.";
1246
+ var NO_NODE_SENTENCE = "Halfcycle is not connected: Node is not installed, or not on the path this session uses, so nothing here is checked. Install Node, then reopen.";
1247
+ var PLACEHOLDER_SENTENCES = Object.freeze({
1248
+ [NO_CREDENTIAL_PLACEHOLDER]: NO_CREDENTIAL_SENTENCE,
1249
+ [NOT_THIS_ACCOUNT_PLACEHOLDER]: NOT_THIS_ACCOUNT_SENTENCE,
1250
+ [UPDATE_CLAUDE_CODE_PLACEHOLDER]: UPDATE_CLAUDE_CODE_SENTENCE
1251
+ });
1224
1252
 
1225
1253
  // ../events/dist/phase-record.js
1226
1254
  import { z as z11 } from "zod";
@@ -2001,7 +2029,7 @@ var WRITE_ALLOWLIST = [
2001
2029
  ".halfcycle/bin",
2002
2030
  "docs/build-records",
2003
2031
  "test/fixtures/captured",
2004
- ".mcp.json",
2032
+ MCP_REGISTRATION_REL,
2005
2033
  ".gitattributes",
2006
2034
  ZONE_B_DIR
2007
2035
  ];
@@ -2374,6 +2402,20 @@ exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
2374
2402
  }
2375
2403
  var GENERATED_SESSION_START_HEADER = "# Session-start marker \u2014 generated by";
2376
2404
  var SESSION_ID_FROM_HOOK_INPUT_SH = `SESSION_ID="$(printf '%s' "$INPUT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);process.stdout.write(typeof j.session_id==="string"?j.session_id:"")}catch{process.stdout.write("")}})' 2>/dev/null || true)"`;
2405
+ var REPO_HASH_FROM_ROOT_SH = `HASH="$(printf '%s' "$REPO_ROOT" | node -e 'const c=[];process.stdin.on("data",d=>c.push(d)).on("end",()=>{process.stdout.write(require("node:crypto").createHash("sha256").update(Buffer.concat(c)).digest("hex").slice(0,12))})' 2>/dev/null || true)"`;
2406
+ function noNodeReplyShell() {
2407
+ const reply = JSON.stringify({
2408
+ systemMessage: NO_NODE_SENTENCE,
2409
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: NO_NODE_SENTENCE }
2410
+ });
2411
+ if (reply.includes("'")) {
2412
+ throw new Error("[bundle install] the no-Node notice cannot be single-quoted; refusing to write the session-start script.");
2413
+ }
2414
+ return `if ! command -v node >/dev/null 2>&1; then
2415
+ printf '%s\\n' '${reply}'
2416
+ exit 0
2417
+ fi`;
2418
+ }
2377
2419
  function generateSessionStartMarker() {
2378
2420
  return `#!/usr/bin/env bash
2379
2421
  ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
@@ -2382,10 +2424,15 @@ ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
2382
2424
  # it began instead of only against the latest commit. The session id comes in on
2383
2425
  # stdin with the hook input; without one, the older session-less name is written
2384
2426
  # and the stop-time check compares against the latest commit as it used to.
2427
+ #
2428
+ # Without Node nothing Halfcycle installs here can run, so this says so once and
2429
+ # writes no marker.
2430
+ ${noNodeReplyShell()}
2385
2431
  INPUT="$(cat 2>/dev/null || true)"
2386
2432
  REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
2387
2433
  if [ -z "$REPO_ROOT" ]; then exit 0; fi
2388
- HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
2434
+ ${REPO_HASH_FROM_ROOT_SH}
2435
+ if [ -z "$HASH" ]; then exit 0; fi
2389
2436
  ${SESSION_ID_FROM_HOOK_INPUT_SH}
2390
2437
  MARKER_DIR="\${TMPDIR:-/tmp}"
2391
2438
  MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
@@ -2395,64 +2442,6 @@ else
2395
2442
  MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}.ref"
2396
2443
  fi
2397
2444
  git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
2398
-
2399
- # ---------------------------------------------------------------------------
2400
- # STANDING GUARD-COVERAGE STATEMENT (T-27; two arms since T-02b).
2401
- #
2402
- # A repository with no credential on this machine is UNGUARDED: the PostToolUse
2403
- # hook will find nothing to evaluate with, and it says so once per edit, after
2404
- # the edit. This says it once, up front, before anything is written \u2014 which is
2405
- # the one thing a session-start hook can do that nothing else can.
2406
- #
2407
- # TWO REASONS TO HAVE NO CREDENTIAL, TWO DIFFERENT THINGS TO SAY (T-02b,
2408
- # solo-project-ownership \u2014 outcome B). "You have not signed in here yet" is
2409
- # fixed by re-running the installer; "this is not your project" is permanent,
2410
- # and the same remedy would send the reader in a circle forever. The two are
2411
- # told apart by HALFCYCLE_ENV_NOT_THIS_ACCOUNT, set by \`halfcycle_env_file\`
2412
- # below from a machine-level marker beside the credential store.
2413
- #
2414
- # Everything below runs in a SUBSHELL: it sources a credential store, and a
2415
- # session-start hook must not leak an engagement's variables into whatever the
2416
- # editor runs next. Any failure inside it is swallowed; this script's exit
2417
- # status is 0 either way, and the marker above has already been written.
2418
- # ---------------------------------------------------------------------------
2419
- ${engagementResolutionShell()}
2420
-
2421
- (
2422
- if halfcycle_env_file "$REPO_ROOT"; then
2423
- set -a
2424
- . "$HALFCYCLE_ENV_FILE"
2425
- set +a
2426
- fi
2427
- if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
2428
- # OUTCOME B (T-02b, solo-project-ownership). This is not the missing-keys
2429
- # case below wearing different words: there is nothing to fix, and the loop
2430
- # that follows is skipped entirely rather than run and then overridden \u2014 a
2431
- # repository whose credential is absent for THIS reason must never be told
2432
- # to re-run the installer, which can never obtain one for it.
2433
- echo "[halfcycle] This project belongs to another Halfcycle account, so nothing here is checked."
2434
- echo "[halfcycle] Your work carries on exactly as it would without Halfcycle \u2014 no edit is"
2435
- echo "[halfcycle] evaluated, and nothing you do is reported as having passed."
2436
- echo "[halfcycle] Halfcycle is Solo today \u2014 one developer per project \u2014 with Team plans coming."
2437
- echo "[halfcycle] Ask the project's owner for view access."
2438
- echo "[halfcycle] If this account is wrong, sign in as the one that owns the project and run"
2439
- echo "[halfcycle] \\"npx halfcycle\\" again."
2440
- else
2441
- hc_missing=""
2442
- for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
2443
- eval "hc_value=\\\${$hc_key:-}"
2444
- if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
2445
- done
2446
- if [ -n "$hc_missing" ]; then
2447
- echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
2448
- echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
2449
- echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
2450
- echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
2451
- echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
2452
- echo "[halfcycle] and needs nothing configured first."
2453
- fi
2454
- fi
2455
- ) 2>/dev/null || true
2456
2445
  exit 0
2457
2446
  `;
2458
2447
  }
@@ -2468,7 +2457,8 @@ INPUT="$(cat 2>/dev/null || true)"
2468
2457
 
2469
2458
  REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
2470
2459
  if [ -z "$REPO_ROOT" ]; then exit 0; fi
2471
- HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
2460
+ ${REPO_HASH_FROM_ROOT_SH}
2461
+ if [ -z "$HASH" ]; then exit 0; fi
2472
2462
 
2473
2463
  # The session id arrives on stdin as part of the hook payload. Empty when absent.
2474
2464
  ${SESSION_ID_FROM_HOOK_INPUT_SH}
@@ -2588,8 +2578,6 @@ function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeade
2588
2578
  }
2589
2579
  return "removed";
2590
2580
  }
2591
- var MCP_REGISTRATION_REL = ".mcp.json";
2592
- var MCP_SERVER_KEY = "halfcycle";
2593
2581
  var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
2594
2582
  `const p=require('path'),f=require('fs'),c=require('child_process');`,
2595
2583
  `let d=process.cwd();`,
@@ -2732,9 +2720,10 @@ function previousPinForEngagement(targetRepoRoot, engagementId) {
2732
2720
  function nonEmpty(value) {
2733
2721
  return typeof value === "string" && value.trim() !== "" ? value : void 0;
2734
2722
  }
2735
- function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, writtenPaths) {
2723
+ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, methodVersion, writtenPaths) {
2736
2724
  const previous = previousPinForEngagement(targetRepoRoot, engagementId);
2737
2725
  const carried = nonEmpty(accountId) ?? nonEmpty(previous?.accountId);
2726
+ const carriedMethodVersion = nonEmpty(methodVersion) ?? nonEmpty(previous?.methodVersion);
2738
2727
  const installedAt = nonEmpty(previous?.installedAt) ?? (/* @__PURE__ */ new Date()).toISOString();
2739
2728
  const projectPage = projectPageUrl(engagementId);
2740
2729
  const pin = {
@@ -2743,6 +2732,7 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, a
2743
2732
  engagementType,
2744
2733
  installedAt,
2745
2734
  ...carried !== void 0 ? { accountId: carried } : {},
2735
+ ...carriedMethodVersion !== void 0 ? { methodVersion: carriedMethodVersion } : {},
2746
2736
  ...projectPage !== void 0 ? { projectPage } : {}
2747
2737
  };
2748
2738
  const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
@@ -2818,7 +2808,7 @@ function checkDrift(targetRepoRoot) {
2818
2808
  const pin = readBundlePin(targetRepoRoot);
2819
2809
  const installed = pin?.version ?? null;
2820
2810
  const current = manifest.version;
2821
- return { drifted: installed !== current, installed, current };
2811
+ return { drifted: installed !== current, installed, current, methodVersion: pin?.methodVersion ?? null };
2822
2812
  }
2823
2813
  function writeProjectIdentity(targetRepo) {
2824
2814
  const path = join5(targetRepo, PROJECT_IDENTITY_REL);
@@ -3049,7 +3039,7 @@ async function install(options) {
3049
3039
  case "absent":
3050
3040
  break;
3051
3041
  }
3052
- writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
3042
+ writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, credential?.methodVersion, report.writtenPaths);
3053
3043
  noteFile(draft, targetRepo, BUNDLE_PIN_REL, "written");
3054
3044
  noteFile(draft, targetRepo, CREW_ROSTER_REL, writeCrewRoster(targetRepo, report));
3055
3045
  const scanResult = runBootstrapScan(targetRepo);
@@ -3180,6 +3170,7 @@ async function bindLoopback(env = process.env, engagementId) {
3180
3170
  const state = randomBytes(32).toString("hex");
3181
3171
  let pending = null;
3182
3172
  let deliver = null;
3173
+ let abandon = null;
3183
3174
  const server = createServer(createCallbackHandler(state, (callback) => {
3184
3175
  if (deliver)
3185
3176
  deliver(callback);
@@ -3221,12 +3212,18 @@ async function bindLoopback(env = process.env, engagementId) {
3221
3212
  clearInterval(ticker);
3222
3213
  clearTimeout(timer);
3223
3214
  deliver = null;
3215
+ abandon = null;
3224
3216
  resolve4(value);
3225
3217
  };
3226
3218
  const timer = setTimeout(() => finish(null), Math.max(0, timeoutMs));
3227
3219
  deliver = finish;
3220
+ abandon = () => finish(null);
3228
3221
  }),
3229
- close: () => closeServer(server)
3222
+ close: () => {
3223
+ if (abandon !== null)
3224
+ abandon();
3225
+ return closeServer(server);
3226
+ }
3230
3227
  };
3231
3228
  }
3232
3229
  function closeServer(server) {
@@ -3260,8 +3257,13 @@ function defaultSleep(ms) {
3260
3257
  function browserOpenCommand(os, url) {
3261
3258
  if (os === "darwin")
3262
3259
  return { command: "open", args: [url] };
3263
- if (os === "win32")
3264
- return { command: "cmd", args: ["/c", "start", "", url] };
3260
+ if (os === "win32") {
3261
+ return {
3262
+ command: "cmd.exe",
3263
+ args: ["/d", "/s", "/c", `"start "" "${url}""`],
3264
+ windowsVerbatimArguments: true
3265
+ };
3266
+ }
3265
3267
  return { command: "xdg-open", args: [url] };
3266
3268
  }
3267
3269
  function isOpenableUrl(url) {
@@ -3272,19 +3274,35 @@ function isOpenableUrl(url) {
3272
3274
  return false;
3273
3275
  }
3274
3276
  }
3277
+ function hasWin32UnsafeOpenerChars(url) {
3278
+ if (url.includes('"') || url.includes("%"))
3279
+ return true;
3280
+ for (let i = 0; i < url.length; i++) {
3281
+ const code = url.charCodeAt(i);
3282
+ if (code <= 31 || code === 127)
3283
+ return true;
3284
+ }
3285
+ return false;
3286
+ }
3275
3287
  var BROWSER_OPEN_GRACE_MS = 3e3;
3276
- async function openBrowser(url, env = process.env) {
3288
+ async function openBrowser(url, env = process.env, os = platform3(), spawnImpl = spawn) {
3277
3289
  if ((env["HALFCYCLE_NO_BROWSER"] ?? "").trim() !== "") {
3278
3290
  return { opened: false, reason: "HALFCYCLE_NO_BROWSER is set in this environment" };
3279
3291
  }
3280
3292
  if (!isOpenableUrl(url)) {
3281
3293
  return { opened: false, reason: "the sign-in address is not an http(s) URL this CLI will open" };
3282
3294
  }
3283
- const { command, args: args2 } = browserOpenCommand(platform3(), url);
3295
+ if (os === "win32" && hasWin32UnsafeOpenerChars(url)) {
3296
+ return {
3297
+ opened: false,
3298
+ reason: 'the sign-in address holds a " or %, or a control character, this opener will not pass to cmd.exe on Windows'
3299
+ };
3300
+ }
3301
+ const { command, args: args2, windowsVerbatimArguments } = browserOpenCommand(os, url);
3284
3302
  return new Promise((resolve4) => {
3285
3303
  let child;
3286
3304
  try {
3287
- child = spawn(command, args2, { stdio: "ignore", env });
3305
+ child = spawnImpl(command, args2, { stdio: "ignore", env, windowsVerbatimArguments });
3288
3306
  } catch (err) {
3289
3307
  resolve4({
3290
3308
  opened: false,
@@ -3356,82 +3374,107 @@ function tryRefusal(body) {
3356
3374
  }
3357
3375
  return { status: candidate.status, message: candidate.message };
3358
3376
  }
3377
+ async function askControlOnce(tokenUrl, started) {
3378
+ let body;
3379
+ try {
3380
+ const res = await fetch(tokenUrl, {
3381
+ method: "POST",
3382
+ headers: { "content-type": "application/json" },
3383
+ body: JSON.stringify({ deviceCode: started.deviceCode })
3384
+ });
3385
+ body = await res.json().catch(() => null);
3386
+ } catch {
3387
+ return null;
3388
+ }
3389
+ const parsed = devicePollResponseSchema.safeParse(body);
3390
+ return parsed.success ? parsed.data : null;
3391
+ }
3392
+ function tokenUrlFor(serviceUrl) {
3393
+ return `${serviceUrl.replace(/\/+$/, "")}/auth/device/token`;
3394
+ }
3395
+ function localDeadline(started) {
3396
+ return new Date(started.expiresAt).getTime() + started.pollIntervalMs * DEADLINE_SLACK_INTERVALS;
3397
+ }
3359
3398
  async function pollForCredential(serviceUrl, started, deps = {}) {
3360
3399
  const write = deps.write ?? defaultWrite;
3361
3400
  const sleep = deps.sleep ?? defaultSleep;
3362
3401
  const now = deps.now ?? Date.now;
3363
- const url = `${serviceUrl.replace(/\/+$/, "")}/auth/device/token`;
3402
+ const url = tokenUrlFor(serviceUrl);
3364
3403
  const interval = started.pollIntervalMs;
3365
- const deadline = new Date(started.expiresAt).getTime() + interval * DEADLINE_SLACK_INTERVALS;
3404
+ const deadline = localDeadline(started);
3366
3405
  const startedAt = now();
3367
3406
  let announcedAt = startedAt;
3368
3407
  for (; ; ) {
3369
- let body;
3370
- let reachable = true;
3371
- try {
3372
- const res = await fetch(url, {
3373
- method: "POST",
3374
- headers: { "content-type": "application/json" },
3375
- body: JSON.stringify({ deviceCode: started.deviceCode })
3376
- });
3377
- body = await res.json().catch(() => null);
3378
- } catch {
3379
- reachable = false;
3380
- }
3381
- if (reachable) {
3382
- const parsed = devicePollResponseSchema.safeParse(body);
3383
- if (parsed.success) {
3384
- const answer = parsed.data;
3385
- if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
3386
- return answer;
3387
- if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
3388
- throw new SignInRefused(answer.status, answer.message);
3389
- }
3408
+ const answer = await askControlOnce(url, started);
3409
+ if (answer !== null) {
3410
+ if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
3411
+ return answer;
3412
+ if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
3413
+ throw new SignInRefused(answer.status, answer.message);
3390
3414
  }
3391
3415
  }
3392
3416
  if (now() >= deadline) {
3393
3417
  throw new SignInRefused(SIGN_IN_TIMED_OUT, SIGN_IN_TIMEOUT_MESSAGE);
3394
3418
  }
3395
- if (now() - announcedAt >= STILL_WAITING_MS) {
3419
+ if (now() - announcedAt >= (deps.stillWaitingMs ?? STILL_WAITING_MS)) {
3396
3420
  announcedAt = now();
3397
- write(`[halfcycle] Still waiting for the browser (${Math.round((announcedAt - startedAt) / 1e3)}s). Code: ${started.userCode}
3398
- `);
3421
+ write(stillWaitingLine(announcedAt - startedAt, started));
3399
3422
  }
3400
3423
  await sleep(interval);
3401
3424
  }
3402
3425
  }
3426
+ function stillWaitingLine(elapsedMs, started) {
3427
+ return `[halfcycle] Still waiting for the browser (${Math.round(elapsedMs / 1e3)}s). Code: ${started.userCode}
3428
+ `;
3429
+ }
3403
3430
  var LOOPBACK_TIMEOUT_MESSAGE = "Sign-in timed out: the browser never came back to this terminal. Nothing was created. Run the command again \u2014 or, if this machine cannot hand the browser back (a remote session, a container), run it with HALFCYCLE_NO_BROWSER=1 to use the code-based sign-in instead.";
3404
- var LOOPBACK_COLLECT_ATTEMPTS = 3;
3405
- async function collectDecidedCredential(serviceUrl, started, deps) {
3406
- const sleep = deps.sleep ?? defaultSleep;
3407
- const url = `${serviceUrl.replace(/\/+$/, "")}/auth/device/token`;
3408
- let lastProblem = LOOPBACK_TIMEOUT_MESSAGE;
3409
- for (let attempt = 0; attempt < LOOPBACK_COLLECT_ATTEMPTS; attempt++) {
3410
- if (attempt > 0)
3411
- await sleep(started.pollIntervalMs);
3412
- let body;
3413
- try {
3414
- const res = await fetch(url, {
3415
- method: "POST",
3416
- headers: { "content-type": "application/json" },
3417
- body: JSON.stringify({ deviceCode: started.deviceCode })
3418
- });
3419
- body = await res.json().catch(() => null);
3420
- } catch (err) {
3421
- lastProblem = `The browser finished signing you in, but this terminal could not reach the Halfcycle service to collect the credential: ${err instanceof Error ? err.message : String(err)}. Run the command again.`;
3422
- continue;
3431
+ async function pollBesideLoopback(serviceUrl, started, wake, deps) {
3432
+ const sleep = deps.sleep;
3433
+ const now = deps.now ?? Date.now;
3434
+ const url = tokenUrlFor(serviceUrl);
3435
+ const deadline = localDeadline(started);
3436
+ let arrived = false;
3437
+ let spent = false;
3438
+ let interrupt = null;
3439
+ void wake.then(() => {
3440
+ arrived = true;
3441
+ if (interrupt !== null)
3442
+ interrupt();
3443
+ });
3444
+ for (; ; ) {
3445
+ const answer = await askControlOnce(url, started);
3446
+ if (answer !== null) {
3447
+ if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
3448
+ return answer;
3449
+ if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
3450
+ throw new SignInRefused(answer.status, answer.message);
3451
+ }
3452
+ }
3453
+ if (now() >= deadline) {
3454
+ throw new SignInRefused(SIGN_IN_TIMED_OUT, LOOPBACK_TIMEOUT_MESSAGE);
3423
3455
  }
3424
- const parsed = devicePollResponseSchema.safeParse(body);
3425
- if (!parsed.success)
3456
+ if (arrived && !spent) {
3457
+ spent = true;
3426
3458
  continue;
3427
- const answer = parsed.data;
3428
- if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
3429
- return answer;
3430
- if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
3431
- throw new SignInRefused(answer.status, answer.message);
3432
3459
  }
3460
+ if (sleep === void 0) {
3461
+ await new Promise((resolve4) => {
3462
+ const timer = setTimeout(resolve4, started.pollIntervalMs);
3463
+ interrupt = () => {
3464
+ clearTimeout(timer);
3465
+ resolve4();
3466
+ };
3467
+ });
3468
+ } else {
3469
+ await new Promise((resolve4) => {
3470
+ interrupt = resolve4;
3471
+ void sleep(started.pollIntervalMs).then(resolve4);
3472
+ });
3473
+ }
3474
+ interrupt = null;
3475
+ if (arrived)
3476
+ spent = true;
3433
3477
  }
3434
- throw new SignInRefused(SIGN_IN_TIMED_OUT, lastProblem);
3435
3478
  }
3436
3479
  async function signIn(serviceUrl, deps = {}) {
3437
3480
  const write = deps.write ?? defaultWrite;
@@ -3456,24 +3499,16 @@ async function signIn(serviceUrl, deps = {}) {
3456
3499
  const url = loopbackVerificationUrl(started.verificationUrl, bind.target);
3457
3500
  const attempt = await openBrowser(url, env);
3458
3501
  if (attempt.opened) {
3459
- write(`[halfcycle] Opened your browser to finish signing in. This terminal is waiting.
3502
+ write(`[halfcycle] Opened your browser to finish signing in. Code: ${started.userCode}. This terminal is waiting.
3460
3503
  `);
3461
- const deadline = new Date(started.expiresAt).getTime() + started.pollIntervalMs * DEADLINE_SLACK_INTERVALS;
3462
- const callback = await bind.waitForCallback({
3463
- timeoutMs: deadline - now(),
3464
- onStillWaiting: (elapsed) => (
3465
- // NO CODE IN THIS LINE. The device-code path prints the code here
3466
- // because a human is comparing it; on this path there is nothing to
3467
- // compare and criterion 1 is that none is displayed AT ALL.
3468
- write(`[halfcycle] Still waiting for the browser (${Math.round(elapsed / 1e3)}s).
3469
- `)
3470
- )
3504
+ const startedAt = now();
3505
+ const redirect = bind.waitForCallback({
3506
+ timeoutMs: localDeadline(started) - startedAt,
3507
+ onStillWaiting: (elapsed) => write(stillWaitingLine(elapsed, started)),
3508
+ stillWaitingMs: deps.stillWaitingMs ?? STILL_WAITING_MS
3471
3509
  });
3510
+ const approved = await pollBesideLoopback(serviceUrl, started, redirect.then(() => void 0), deps);
3472
3511
  await closeListener();
3473
- if (callback === null) {
3474
- throw new SignInRefused(SIGN_IN_TIMED_OUT, LOOPBACK_TIMEOUT_MESSAGE);
3475
- }
3476
- const approved = await collectDecidedCredential(serviceUrl, started, deps);
3477
3512
  write(`[halfcycle] Signed in as account ${approved.accountId} \u2014 continuing.
3478
3513
  `);
3479
3514
  return {
@@ -3578,7 +3613,7 @@ function planeRefusal(detail) {
3578
3613
  reason: typeof candidate.reason === "string" ? candidate.reason : void 0
3579
3614
  };
3580
3615
  }
3581
- 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.`;
3616
+ var CONTROL_ORIGIN_HINT = `This CLI talks to ${DEFAULT_CONTROL_ORIGIN}; it must be the CONTROL origin \u2014 the one that serves /engagements. The MCP server runs at a different address, and the platform supplies that one itself; you configure neither.`;
3582
3617
  async function createEngagement(baseUrl, name, credential, derivedName) {
3583
3618
  return requestEngagementValues(
3584
3619
  `${baseUrl.replace(/\/+$/, "")}/engagements`,
@@ -3658,12 +3693,16 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
3658
3693
  }).join(", ");
3659
3694
  throw new Error(`[bundle install] ${shape.action} response from ${url} is not the credential response this CLI is built to read (${fields}). ${shape.nothingHappened}. Either that service is a different version from this installer, or ${url} is not a Halfcycle service.`);
3660
3695
  }
3696
+ const consoleUrl = typeof body.consoleUrl === "string" && body.consoleUrl.trim() !== "" ? body.consoleUrl.trim() : void 0;
3697
+ const methodVersion = typeof body.methodVersion === "string" && body.methodVersion.trim() !== "" ? body.methodVersion.trim() : void 0;
3661
3698
  return {
3662
3699
  engagementId: body.engagementId,
3663
3700
  sessionToken: body.sessionToken,
3664
3701
  mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
3665
3702
  guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
3666
- ...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
3703
+ ...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {},
3704
+ ...consoleUrl !== void 0 ? { consoleUrl } : {},
3705
+ ...methodVersion !== void 0 ? { methodVersion } : {}
3667
3706
  };
3668
3707
  }
3669
3708
 
@@ -4274,37 +4313,6 @@ async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps =
4274
4313
  }
4275
4314
  }
4276
4315
 
4277
- // dist/mint-board-code.js
4278
- async function mintBoardEnterCode(baseUrl, sessionToken) {
4279
- const url = `${baseUrl.replace(/\/+$/, "")}/board/enter-codes`;
4280
- let res;
4281
- try {
4282
- res = await fetch(url, {
4283
- method: "POST",
4284
- headers: {
4285
- "content-type": "application/json",
4286
- authorization: `Bearer ${sessionToken}`
4287
- },
4288
- body: "{}"
4289
- });
4290
- } catch (err) {
4291
- throw new Error(`[bundle install] Could not reach the Halfcycle board mint endpoint at ${url}: ${err instanceof Error ? err.message : String(err)}.`);
4292
- }
4293
- if (!res.ok) {
4294
- throw new Error(`[bundle install] Board code mint failed: ${url} returned ${res.status}.`);
4295
- }
4296
- const body = await res.json().catch(() => null);
4297
- if (!body || typeof body.enterCode !== "string" || typeof body.engagementId !== "string" || typeof body.boardUrl !== "string" || typeof body.expiresAt !== "string") {
4298
- throw new Error(`[bundle install] Board code mint response from ${url} did not carry {enterCode, engagementId, boardUrl, expiresAt}.`);
4299
- }
4300
- return {
4301
- enterCode: body.enterCode,
4302
- engagementId: body.engagementId,
4303
- boardUrl: body.boardUrl,
4304
- expiresAt: body.expiresAt
4305
- };
4306
- }
4307
-
4308
4316
  // dist/probe-mcp.js
4309
4317
  var PROBE_TIMEOUT_MS = 5e3;
4310
4318
  async function probeMcpOrigin(mcpUrl, token) {
@@ -4386,20 +4394,127 @@ import { basename as basename2, join as join6, resolve as resolve2, relative as
4386
4394
 
4387
4395
  // dist/build-record/render-v2.js
4388
4396
  var HIDDEN = /[\p{Cc}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u;
4389
- var MARKDOWN_SPECIAL = /* @__PURE__ */ new Set(["\\", "`", "*", "_", "[", "]", "<", "|", "~", "#", "&", "$"]);
4397
+ var SPACE = new RegExp("\\p{Zs}", "u");
4398
+ var WORD = /[\p{L}\p{N}]/u;
4399
+ var ASCII_PUNCTUATION = /[!-/:-@[-`{-~]/;
4400
+ var CHARACTER_REFERENCE = /^#?[A-Za-z0-9]+;/;
4401
+ var PAIRED = ["*", "_", "~", "$"];
4402
+ function codeSpanMask(cps) {
4403
+ const mask = cps.map(() => false);
4404
+ const runs = [];
4405
+ for (let i = 0; i < cps.length; ) {
4406
+ if (cps[i] !== "`") {
4407
+ i++;
4408
+ continue;
4409
+ }
4410
+ let j = i;
4411
+ while (j < cps.length && cps[j] === "`")
4412
+ j++;
4413
+ runs.push({ start: i, length: j - i });
4414
+ i = j;
4415
+ }
4416
+ for (let k = 0; k < runs.length; ) {
4417
+ const open = runs[k];
4418
+ let m = k + 1;
4419
+ while (m < runs.length && runs[m].length !== open.length)
4420
+ m++;
4421
+ if (m === runs.length) {
4422
+ k++;
4423
+ continue;
4424
+ }
4425
+ const close = runs[m];
4426
+ for (let i = open.start; i < close.start + close.length; i++)
4427
+ mask[i] = true;
4428
+ k = m + 1;
4429
+ }
4430
+ return mask;
4431
+ }
4432
+ function escapeMask(cps, inSpan) {
4433
+ const n = cps.length;
4434
+ const escape = cps.map(() => false);
4435
+ const isSpace = (i) => i >= 0 && i < n && SPACE.test(cps[i]) && !HIDDEN.test(cps[i]);
4436
+ const isWord = (i) => i >= 0 && i < n && WORD.test(cps[i]);
4437
+ const rest = (i) => cps.slice(i).join("");
4438
+ for (let i = 0; i < n; i++) {
4439
+ if (inSpan[i])
4440
+ continue;
4441
+ const ch = cps[i];
4442
+ const next = i + 1 < n ? cps[i + 1] : null;
4443
+ switch (ch) {
4444
+ case "\\":
4445
+ escape[i] = next === null || ASCII_PUNCTUATION.test(next);
4446
+ break;
4447
+ case "<":
4448
+ escape[i] = next !== null && !isSpace(i + 1) && rest(i + 1).includes(">");
4449
+ break;
4450
+ case "&":
4451
+ escape[i] = CHARACTER_REFERENCE.test(rest(i + 1));
4452
+ break;
4453
+ case "]":
4454
+ escape[i] = next === "(";
4455
+ break;
4456
+ case "[":
4457
+ case "{":
4458
+ escape[i] = (next === "+" || next === "-") && (rest(i + 2).includes(`${next}]`) || rest(i + 2).includes(`${next}}`));
4459
+ break;
4460
+ default:
4461
+ break;
4462
+ }
4463
+ }
4464
+ for (let i = 0; i < n; ) {
4465
+ if (inSpan[i] || cps[i] !== "#") {
4466
+ i++;
4467
+ continue;
4468
+ }
4469
+ let j = i;
4470
+ while (j < n && cps[j] === "#" && !inSpan[j])
4471
+ j++;
4472
+ let k = j;
4473
+ while (k < n && isSpace(k))
4474
+ k++;
4475
+ if (k === n && (i === 0 || isSpace(i - 1)))
4476
+ for (let m = i; m < j; m++)
4477
+ escape[m] = true;
4478
+ i = j;
4479
+ }
4480
+ for (const c of PAIRED) {
4481
+ const live = [];
4482
+ for (let i = 0; i < n; ) {
4483
+ if (inSpan[i] || cps[i] !== c) {
4484
+ i++;
4485
+ continue;
4486
+ }
4487
+ let j = i;
4488
+ while (j < n && cps[j] === c && !inSpan[j])
4489
+ j++;
4490
+ const inert = c !== "$" && (isSpace(i - 1) && isSpace(j) || c === "_" && isWord(i - 1) && isWord(j));
4491
+ if (!inert)
4492
+ live.push({ start: i, end: j });
4493
+ i = j;
4494
+ }
4495
+ if (live.length < 2)
4496
+ continue;
4497
+ for (const run of live)
4498
+ for (let m = run.start; m < run.end; m++)
4499
+ escape[m] = true;
4500
+ }
4501
+ return escape;
4502
+ }
4390
4503
  function printRecordText(text) {
4391
4504
  if (text === "")
4392
4505
  return "(blank)";
4506
+ const cps = [...text];
4507
+ const escape = escapeMask(cps, codeSpanMask(cps));
4393
4508
  let out = "";
4394
- for (const ch of text) {
4509
+ cps.forEach((ch, i) => {
4395
4510
  if (HIDDEN.test(ch)) {
4396
4511
  out += `U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`;
4397
- } else if (MARKDOWN_SPECIAL.has(ch)) {
4512
+ } else if (escape[i]) {
4398
4513
  out += `\\${ch}`;
4399
4514
  } else {
4400
4515
  out += ch;
4401
4516
  }
4402
- }
4517
+ });
4403
4518
  return out;
4404
4519
  }
4405
4520
  function makePrinter() {
@@ -4489,12 +4604,18 @@ function headline(observed, attested, checks) {
4489
4604
  if (problemsReported) {
4490
4605
  return "**Closed with problems reported.** Halfcycle saw nothing outstanding, and the person closing the phase reported problems found in its acceptance walk.";
4491
4606
  }
4492
- return checks === "not-observed" ? "**Closed with nothing outstanding, but no checks ran.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met, but Halfcycle has no record of any check running during this phase, so this is not a clean result." : "**Closed cleanly.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met.";
4607
+ return checks === "not-observed" ? "**Closed with nothing outstanding, but no check on record.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met, but Halfcycle has no record of any check running during this phase, so this is not a clean result." : "**Closed cleanly.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met.";
4493
4608
  case "unfinished-steps":
4494
4609
  case "clean-over-findings":
4495
- return met ? "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase stated its finish conditions were met anyway." : "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase chose to override it.";
4610
+ if (!met) {
4611
+ return "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase chose to override it.";
4612
+ }
4613
+ return problemsReported ? "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase closed it as it stood, reporting problems found in its acceptance walk." : "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase stated its finish conditions were met anyway.";
4496
4614
  case "not-recorded":
4497
- return met ? "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase stated its finish conditions were met." : "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase chose to override.";
4615
+ if (!met) {
4616
+ return "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase chose to override.";
4617
+ }
4618
+ return problemsReported ? "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase reported problems found in its acceptance walk." : "**Outstanding work not recorded.** Halfcycle has no record of what was outstanding at this close. The person closing the phase stated its finish conditions were met.";
4498
4619
  }
4499
4620
  }
4500
4621
  function closedAtText(closedAt, p) {
@@ -4513,30 +4634,38 @@ function signedInAs(closedBy, p) {
4513
4634
  return "not recorded for this close";
4514
4635
  }
4515
4636
  }
4637
+ var OUTSTANDING_LABEL = "- Outstanding work in Halfcycle's own records of this phase (the method's steps, and problems recorded against the phase): ";
4516
4638
  function outstanding(crossed, p) {
4517
4639
  switch (crossed.kind) {
4518
4640
  case "none":
4519
- return ["- Outstanding work Halfcycle could see: none."];
4641
+ return [`${OUTSTANDING_LABEL}none.`];
4520
4642
  case "not-recorded":
4521
- return ["- Outstanding work Halfcycle could see: not recorded for this close."];
4643
+ return [`${OUTSTANDING_LABEL}not recorded for this close.`];
4522
4644
  case "clean-over-findings":
4523
- return [
4524
- `- Outstanding work Halfcycle could see: ${counted(crossed.findings, "problem", "problems")} recorded against this phase.`
4525
- ];
4645
+ return [`${OUTSTANDING_LABEL}${counted(crossed.findings, "problem", "problems")} recorded against this phase.`];
4526
4646
  case "unfinished-steps":
4527
4647
  return [
4528
- `- Outstanding work Halfcycle could see: ${counted(crossed.steps.length, "step", "steps")} of the method left unfinished. Each is shown by the method's own label for it:`,
4529
- ...crossed.steps.map((step) => ` - Step: ${p.text(step.stepId)} \u2014 ${step.state === "failed" ? "failed" : "set aside unfinished"}`)
4648
+ `${OUTSTANDING_LABEL}${counted(crossed.steps.length, "step", "steps")} of the method left unfinished. ${shownBy(crossed.steps)}`,
4649
+ ...crossed.steps.map((step) => ` - Step: ${p.text(step.name ?? step.stepId)} \u2014 ${step.state === "failed" ? "failed" : "set aside unfinished"}`)
4530
4650
  ];
4531
4651
  }
4532
4652
  }
4533
- function machines(coverage) {
4653
+ function shownBy(steps) {
4654
+ const named = steps.filter((s) => s.name !== void 0).length;
4655
+ if (named === 0)
4656
+ return "Each is shown by the method's own label for it:";
4657
+ if (named === steps.length)
4658
+ return "Each is shown by the method's own name for it:";
4659
+ return "Each is shown by the method's own name for it, or by its label where the record holds no name:";
4660
+ }
4661
+ function machines(coverage, checks) {
4534
4662
  const label = "- Computers signed in to Halfcycle for this project when the phase closed: ";
4535
4663
  if (coverage === null)
4536
4664
  return `${label}not recorded.`;
4537
4665
  if (coverage.enrolled === 0)
4538
4666
  return `${label}none.`;
4539
- return `${label}${String(coverage.enrolled)}. Of these, ${String(coverage.conformant)} had reported Halfcycle's checks as fully set up.`;
4667
+ const line = `${label}${String(coverage.enrolled)}. Of these, ${String(coverage.conformant)} had reported Halfcycle's checks as fully set up.`;
4668
+ return checks === "not-observed" && coverage.conformant > 0 ? `${line} Set up is not the same as running: Halfcycle has no record of any check running during this phase.` : line;
4540
4669
  }
4541
4670
  function walkLines(walk, p) {
4542
4671
  const label = "> - Acceptance walk (the finished work tried as a user would, by someone other than its builder): ";
@@ -4556,9 +4685,15 @@ function walkLines(walk, p) {
4556
4685
  function statedOrNot(value, p) {
4557
4686
  return value === null ? "Not stated by the client." : p.text(value);
4558
4687
  }
4688
+ function decisionLine(attested) {
4689
+ if (attested.decision === "override") {
4690
+ return "> - Decision: close the phase by override, without stating its finish conditions were met.";
4691
+ }
4692
+ return attested.walk !== null && attested.walk.verdict === "defects" ? "> - Decision: close the phase as it stood, with the problems its acceptance walk found." : "> - Decision: the phase's agreed finish conditions were met.";
4693
+ }
4559
4694
  function renderStated(attested, p) {
4560
4695
  const lines = [
4561
- attested.decision === "done-when-met" ? "> - Decision: the phase's agreed finish conditions were met." : "> - Decision: close the phase by override, without stating its finish conditions were met.",
4696
+ decisionLine(attested),
4562
4697
  `> - Name given: ${attested.actor === null ? "removed with the account that made this close" : p.text(attested.actor)}`,
4563
4698
  `> - Reason given: ${attested.reason === null ? "none." : p.text(attested.reason)}`,
4564
4699
  ...walkLines(attested.walk, p),
@@ -4586,7 +4721,7 @@ function renderClose(heading, observed, attested, checks, p) {
4586
4721
  `- Closed at: ${closedAtText(observed.closedAt, p)}`,
4587
4722
  `- Signed in as: ${signedInAs(observed.closedBy, p)}`,
4588
4723
  ...outstanding(observed.crossed, p),
4589
- machines(observed.seatCoverage)
4724
+ machines(observed.seatCoverage, checks)
4590
4725
  ].join("\n"),
4591
4726
  STATED_LABEL,
4592
4727
  renderStated(attested, p)
@@ -4847,8 +4982,7 @@ async function obtainBuildRecord(credential, repoRoot, phase, io = NODE_RECORD_P
4847
4982
  };
4848
4983
  }
4849
4984
  function refetchCommand(phase) {
4850
- const quoted = /^[A-Za-z0-9._+-]+$/.test(phase) ? phase : `'${phase.replace(/'/g, `'\\''`)}'`;
4851
- return `npx halfcycle@latest build-record ${quoted}`;
4985
+ return `npx halfcycle@latest build-record ${quoteForShell(phase)}`;
4852
4986
  }
4853
4987
 
4854
4988
  // dist/open-phase.js
@@ -4857,10 +4991,12 @@ import { join as join7 } from "node:path";
4857
4991
  var OpenPhaseRefused = class extends Error {
4858
4992
  status;
4859
4993
  finding;
4860
- constructor(status, message, finding) {
4994
+ previousPhase;
4995
+ constructor(status, message, finding, previousPhase = null) {
4861
4996
  super(message);
4862
4997
  this.status = status;
4863
4998
  this.finding = finding;
4999
+ this.previousPhase = previousPhase;
4864
5000
  this.name = "OpenPhaseRefused";
4865
5001
  }
4866
5002
  };
@@ -4939,7 +5075,8 @@ async function openPhase(credential, phase, entry, home) {
4939
5075
  const body2 = await res.json().catch(() => null);
4940
5076
  const message = typeof body2?.message === "string" ? body2.message : `${url} returned ${res.status}.`;
4941
5077
  const finding = typeof body2?.finding === "string" ? body2.finding : null;
4942
- throw new OpenPhaseRefused(res.status, message, finding);
5078
+ const previousPhase = typeof body2?.previousPhase === "string" ? body2.previousPhase : null;
5079
+ throw new OpenPhaseRefused(res.status, message, finding, previousPhase);
4943
5080
  }
4944
5081
  const body = await res.json().catch(() => null);
4945
5082
  if (!body || !(typeof body.phase === "string" || body.phase === null) || !(typeof body.previousPhase === "string" || body.previousPhase === null) || typeof body.decision !== "object" || body.decision === null || typeof body.decision.decisionId !== "string") {
@@ -5165,7 +5302,7 @@ function requirementMessage(verb, flag) {
5165
5302
  function runnableInvocation(verb, phase) {
5166
5303
  const contract = contractFor(verb);
5167
5304
  const flags = (contract?.required ?? []).map((req) => `${req.flag} ${PLACEHOLDERS[req.flag] ?? '"\u2026"'}`).join(" ");
5168
- return `halfcycle ${verb} ${phase}${flags ? ` ${flags}` : ""}`;
5305
+ return `halfcycle ${verb} ${quoteForShell(phase)}${flags ? ` ${flags}` : ""}`;
5169
5306
  }
5170
5307
  var PLACEHOLDERS = {
5171
5308
  "--verdict": "<clean|defects>",
@@ -6092,7 +6229,12 @@ ${USAGE}`);
6092
6229
  // T-10 — optional; absent on a plane that has not configured
6093
6230
  // CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
6094
6231
  // an absent value, never `undefined` verbatim.
6095
- controlTelemetryUrl: joined.controlTelemetryUrl
6232
+ controlTelemetryUrl: joined.controlTelemetryUrl,
6233
+ // T-01 (Connect Phase 4 follow-ups) — optional; absent on a control
6234
+ // deployment that predates it. The join route's own stamped pin,
6235
+ // which can be older than this deployment's current default.
6236
+ consoleUrl: joined.consoleUrl,
6237
+ methodVersion: joined.methodVersion
6096
6238
  };
6097
6239
  foundLine = `this repo is pinned to one of your projects \u2014 joining it as you (${shortId(joined.engagementId)})`;
6098
6240
  }
@@ -6116,7 +6258,12 @@ ${USAGE}`);
6116
6258
  // T-10 — the FOURTH origin, optional. Absent on a plane that has not
6117
6259
  // configured CONTROL_TELEMETRY_URL yet; the runner's own telemetry
6118
6260
  // emission is fail-open on that absence.
6119
- controlTelemetryUrl: created.controlTelemetryUrl
6261
+ controlTelemetryUrl: created.controlTelemetryUrl,
6262
+ // T-01 (Connect Phase 4 follow-ups) — optional; absent on a control
6263
+ // deployment that predates it. The create route's own deployment
6264
+ // default at the moment this engagement was stamped.
6265
+ consoleUrl: created.consoleUrl,
6266
+ methodVersion: created.methodVersion
6120
6267
  };
6121
6268
  foundLine = `no Halfcycle project here yet \u2014 creating one (${shortId(created.engagementId)})`;
6122
6269
  if (verbose) {
@@ -6209,22 +6356,23 @@ ${USAGE}`);
6209
6356
  if (result.notShared)
6210
6357
  process.stdout.write(`${renderNotShared(result.notShared)}
6211
6358
  `);
6359
+ if (credential) {
6360
+ process.stdout.write("[halfcycle] Commit the files Halfcycle set up here, so every copy of this project runs the same checks.\n");
6361
+ }
6212
6362
  if (credential) {
6213
6363
  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
6214
6364
  `);
6215
- 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.
6365
+ process.stdout.write(`[halfcycle] Claude Code will then ask whether to use a new MCP server. The cursor starts on "Continue without using this MCP server" \u2014 choose to use it instead; without it Halfcycle will not connect.
6216
6366
  `);
6217
6367
  }
6218
- if (credential) {
6219
- try {
6220
- const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
6221
- 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}
6368
+ if (credential?.consoleUrl !== void 0) {
6369
+ const colour = decideColour(currentEnvironment(quiet));
6370
+ process.stdout.write(`${ink("key", `[halfcycle] Your project's console: ${credential.consoleUrl}`, colour)}
6222
6371
  `);
6223
- 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}
6372
+ }
6373
+ if (credential?.methodVersion !== void 0) {
6374
+ process.stdout.write(`[halfcycle] Method version: ${credential.methodVersion}
6224
6375
  `);
6225
- } catch {
6226
- 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");
6227
- }
6228
6376
  }
6229
6377
  process.exit(0);
6230
6378
  } catch (err) {
@@ -6271,7 +6419,9 @@ ${USAGE}`);
6271
6419
  return;
6272
6420
  }
6273
6421
  try {
6274
- const { drifted, installed, current } = checkDrift(targetRepo2);
6422
+ const { drifted, installed, current, methodVersion } = checkDrift(targetRepo2);
6423
+ process.stdout.write(`[halfcycle-bundle] Method version: ${methodVersion ?? "not recorded"}
6424
+ `);
6275
6425
  if (drifted) {
6276
6426
  process.stdout.write(`[halfcycle-bundle] DRIFT DETECTED: installed=${installed ?? "none"} current=${current}
6277
6427
  [halfcycle-bundle] Re-run "halfcycle-bundle install ${targetRepo2}" to sync.
@@ -6343,8 +6493,13 @@ ${USAGE}`);
6343
6493
  const opened = await openPhase(credential, toProjectScope ? null : phaseArg, entry);
6344
6494
  const what = opened.phase === null ? "project scope" : `phase "${opened.phase}"`;
6345
6495
  const from = opened.previousPhase === null ? "project scope" : `phase "${opened.previousPhase}"`;
6346
- process.stdout.write(`[halfcycle] Moved from ${from} to ${what}.
6496
+ if (opened.previousPhase === opened.phase) {
6497
+ process.stdout.write(opened.phase === null ? "[halfcycle] Already at project scope \u2014 nothing moved.\n" : `[halfcycle] Phase "${opened.phase}" is already open \u2014 nothing moved.
6498
+ `);
6499
+ } else {
6500
+ process.stdout.write(`[halfcycle] Moved from ${from} to ${what}.
6347
6501
  `);
6502
+ }
6348
6503
  process.stdout.write(`[halfcycle] Recorded as ${opened.decision.decision} by ${opened.decision.actor} at ${opened.decision.decidedAt}.
6349
6504
  `);
6350
6505
  if (opened.decision.blockingFinding) {
@@ -6361,8 +6516,7 @@ halfcycle open-phase: until ${opened.stampFailure.path} can be written, guard ev
6361
6516
  process.exit(0);
6362
6517
  } catch (err) {
6363
6518
  if (err instanceof InvalidPhaseIdentityError) {
6364
- process.stderr.write(`halfcycle open-phase: ${err.message}
6365
- halfcycle open-phase: a phase name has to work as a single folder name on this machine \u2014 that is what this machine files its guard evaluations under. Nothing was opened.
6519
+ process.stderr.write(`halfcycle open-phase: '${String(err.identity)}' cannot be used as a phase name \u2014 it has to work as a single folder name on this machine. Nothing was opened.
6366
6520
  `);
6367
6521
  process.exit(2);
6368
6522
  return;
@@ -6375,8 +6529,7 @@ halfcycle open-phase: a phase name has to work as a single folder name on this m
6375
6529
  `);
6376
6530
  }
6377
6531
  if (err.status === 409) {
6378
- const stuck = err.finding?.match(/^phase '([^']+)' is still open/)?.[1];
6379
- process.stderr.write(`halfcycle open-phase: close the phase first \u2014 \`${runnableInvocation("close-phase", stuck ?? "<phase>")}\` (add --finding "\u2026" once per finding when the verdict is defects) \u2014 or re-run with --override --reason "\u2026", which is recorded, not waived.
6532
+ process.stderr.write(`halfcycle open-phase: close the phase first \u2014 \`${runnableInvocation("close-phase", err.previousPhase ?? "<phase>")}\` (add --finding "\u2026" once per finding when the verdict is defects) \u2014 or re-run with --override --reason "\u2026", which is recorded, not waived.
6380
6533
  `);
6381
6534
  }
6382
6535
  process.exit(1);
@@ -6487,17 +6640,22 @@ halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard e
6487
6640
  return;
6488
6641
  }
6489
6642
  if (err instanceof ClosePhaseRefused) {
6643
+ if (err.finding) {
6644
+ process.stderr.write(`halfcycle close-phase: refused (${err.status}).
6645
+ `);
6646
+ process.stderr.write(`halfcycle close-phase: outstanding work \u2014 ${plainOutstandingFinding(err.finding)}
6647
+ `);
6648
+ process.stderr.write(`halfcycle close-phase: close it anyway with \`halfcycle close-phase ${quoteForShell(phaseArg)} --verdict ${verdict} --actor ${quoteForShell(closeActor)} --override --reason "\u2026"\` \u2014 recorded, not waived.
6649
+ `);
6650
+ process.exit(1);
6651
+ return;
6652
+ }
6490
6653
  process.stderr.write(`halfcycle close-phase: refused (${err.status}). ${err.message}
6491
6654
  `);
6492
6655
  if (err.status === 400) {
6493
6656
  process.stderr.write("halfcycle close-phase: Nothing was closed.\n");
6494
6657
  }
6495
- if (err.finding) {
6496
- process.stderr.write(`halfcycle close-phase: outstanding work \u2014 ${err.finding}
6497
- `);
6498
- process.stderr.write(`halfcycle close-phase: close it anyway with \`halfcycle close-phase ${phaseArg} --verdict ${verdict} --actor "${closeActor}" --override --reason "\u2026"\` \u2014 recorded, not waived.
6499
- `);
6500
- } else if (err.code === "PhaseAlreadyClosed") {
6658
+ if (err.code === "PhaseAlreadyClosed") {
6501
6659
  process.stderr.write("halfcycle close-phase: to close a phase again, open it again first.\nhalfcycle close-phase: To get a closed phase's Build Record: npx halfcycle@latest build-record <the phase that closed>\n");
6502
6660
  }
6503
6661
  process.exit(1);
@@ -6536,6 +6694,9 @@ function readStatementFlags(argv) {
6536
6694
  }
6537
6695
  return { thesis, outcome, deviations };
6538
6696
  }
6697
+ function plainOutstandingFinding(finding) {
6698
+ return finding.replace(/\(attempted-failed\)/g, "(attempted, and did not finish)");
6699
+ }
6539
6700
  function writtenLines(written) {
6540
6701
  return `[halfcycle] Build Record written:
6541
6702
  ${written.mdPath}