halfcycle 0.3.17 → 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.
package/dist/bin.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // dist/bin.js
4
4
  import { execFileSync as execFileSync4 } from "node:child_process";
5
5
  import { readFileSync as readFileSync9 } from "node:fs";
6
- import { join as join10 } from "node:path";
6
+ import { join as join11 } from "node:path";
7
7
 
8
8
  // dist/install.js
9
9
  import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
@@ -61,11 +61,26 @@ var firedGuardSchema = z2.object({
61
61
  */
62
62
  path: z2.string().optional()
63
63
  }).strict();
64
+ var notRunCheckSchema = z2.object({
65
+ /** Which check did not run, by the same reference a fired check carries. */
66
+ patternRef: z2.string(),
67
+ /**
68
+ * Why it could not run, in plain words — what this client did not send, or
69
+ * that the check's kind is not implemented yet. Never the check's own rule.
70
+ */
71
+ reason: z2.string()
72
+ }).strict();
64
73
  var resultEnvelopeSchema = z2.object({
65
74
  guardsFired: z2.array(firedGuardSchema),
66
75
  severity: severitySchema.nullable(),
67
76
  blocking: z2.boolean(),
68
- explanation: z2.string()
77
+ explanation: z2.string(),
78
+ /**
79
+ * The checks that could not run on this edit, each named with the reason.
80
+ * Absent when every check ran — so an evaluation where nothing was skipped
81
+ * looks exactly as it always has, and an absent list is never an empty one.
82
+ */
83
+ notRun: z2.array(notRunCheckSchema).optional()
69
84
  }).strict();
70
85
  var wireErrorSchema = z2.object({
71
86
  statusCode: z2.number(),
@@ -426,16 +441,92 @@ function loopbackVerificationUrl(verificationUrl, target) {
426
441
  return url.toString();
427
442
  }
428
443
 
429
- // ../events/dist/account-identity.js
444
+ // ../events/dist/join-refusal.js
430
445
  import { z as z7 } from "zod";
431
- var accountIdentitySchema = z7.object({
432
- accountId: z7.string().min(1),
433
- 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()
434
464
  }).strict();
435
465
  function safeParseAccountIdentity(payload) {
436
466
  return accountIdentitySchema.safeParse(payload);
437
467
  }
438
468
 
469
+ // ../events/dist/phase-identity.js
470
+ var PHASE_SEGMENT_PREFIX = "phase-";
471
+ function isValidPhaseIdentity(identity) {
472
+ if (typeof identity === "number")
473
+ return Number.isInteger(identity) && identity >= 0;
474
+ if (typeof identity !== "string")
475
+ return false;
476
+ if (identity === "")
477
+ return false;
478
+ if (identity.includes("/"))
479
+ return false;
480
+ if (identity.includes("\\"))
481
+ return false;
482
+ if (identity.includes("\0"))
483
+ return false;
484
+ if (identity.startsWith("."))
485
+ return false;
486
+ return true;
487
+ }
488
+ var InvalidPhaseIdentityError = class extends Error {
489
+ /** The value that was refused, exactly as supplied. */
490
+ identity;
491
+ constructor(identity, allowedLocation) {
492
+ const subject = allowedLocation === void 0 ? "a file" : "a Build Record";
493
+ const where = allowedLocation === void 0 ? "" : `
494
+ Records are written only into ${allowedLocation.replace(/\\/g, "/")}/. Nothing was written.`;
495
+ super(`that phase identity cannot name ${subject}.
496
+ identity: ${describeIdentity(identity)}` + where);
497
+ this.name = "InvalidPhaseIdentityError";
498
+ this.identity = identity;
499
+ }
500
+ };
501
+ function assertValidPhaseIdentity(identity, allowedLocation) {
502
+ if (!isValidPhaseIdentity(identity)) {
503
+ throw new InvalidPhaseIdentityError(identity, allowedLocation);
504
+ }
505
+ }
506
+ function renderPhaseSegment(identity) {
507
+ if (!isValidPhaseIdentity(identity)) {
508
+ throw new InvalidPhaseIdentityError(identity);
509
+ }
510
+ return `${PHASE_SEGMENT_PREFIX}${identity}`;
511
+ }
512
+ function describeIdentity(identity) {
513
+ if (identity === "")
514
+ return "(empty)";
515
+ if (typeof identity === "string")
516
+ return identity;
517
+ if (identity === null)
518
+ return "null";
519
+ if (identity === void 0)
520
+ return "none supplied";
521
+ if (typeof identity === "number" || typeof identity === "boolean")
522
+ return String(identity);
523
+ if (Array.isArray(identity))
524
+ return "(a list)";
525
+ if (typeof identity === "object")
526
+ return "(an object)";
527
+ return `(a ${typeof identity})`;
528
+ }
529
+
439
530
  // dist/engagement-credential.js
440
531
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
441
532
  import { platform as platform2 } from "node:os";
@@ -521,6 +612,18 @@ function engagementStateDir(engagementId, home) {
521
612
  function engagementEnvPath(engagementId, home) {
522
613
  return join2(engagementStateDir(engagementId, home), ENV_FILENAME);
523
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
+ }
524
627
  var GUARD_ENV_KEYS = [
525
628
  "GUARD_SERVICE_URL",
526
629
  "GUARD_SERVICE_TOKEN",
@@ -535,6 +638,7 @@ var ENGAGEMENT_ENV_KEYS = [
535
638
  ...GUARD_ENV_KEYS,
536
639
  "CONTROL_TELEMETRY_URL"
537
640
  ];
641
+ var PHASE_STAMP_ENV_KEY = "HALFCYCLE_PHASE";
538
642
  var ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
539
643
  function shq(value) {
540
644
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -562,33 +666,50 @@ function parseEnvText(raw) {
562
666
  return out;
563
667
  }
564
668
  function reconcileEnvText(existing, values, keys = ENGAGEMENT_ENV_KEYS) {
565
- const desired = new Map(keys.map((k) => [k, values[k] ?? ""]));
669
+ const desired = new Map(keys.map((k) => {
670
+ const value = values[k];
671
+ return [k, value === void 0 ? "" : value];
672
+ }));
566
673
  const line = (k) => `${k}=${shq(desired.get(k) ?? "")}`;
674
+ const written = keys.filter((k) => desired.get(k) !== null);
567
675
  if (existing === null) {
676
+ if (written.length === 0)
677
+ return "";
568
678
  return `${ENV_HEADER}
569
- ` + keys.map(line).join("\n") + "\n";
679
+ ` + written.map(line).join("\n") + "\n";
570
680
  }
571
681
  const seen = /* @__PURE__ */ new Set();
572
- const out = existing.split("\n").map((existingLine) => {
682
+ const out = [];
683
+ for (const existingLine of existing.split("\n")) {
573
684
  const eq = existingLine.indexOf("=");
574
- if (eq === -1)
575
- return existingLine;
685
+ if (eq === -1) {
686
+ out.push(existingLine);
687
+ continue;
688
+ }
576
689
  const lhs = existingLine.slice(0, eq);
577
690
  const exported = /^\s*export\s+/.exec(lhs);
578
691
  const prefix = exported === null ? "" : exported[0];
579
692
  const key = lhs.slice(prefix.length).trim();
580
- if (desired.has(key)) {
581
- seen.add(key);
582
- return `${prefix}${line(key)}`;
693
+ if (!desired.has(key)) {
694
+ out.push(existingLine);
695
+ continue;
583
696
  }
584
- return existingLine;
585
- });
586
- const missing = keys.filter((k) => !seen.has(k));
697
+ seen.add(key);
698
+ if (desired.get(key) === null)
699
+ continue;
700
+ out.push(`${prefix}${line(key)}`);
701
+ }
702
+ const missing = written.filter((k) => !seen.has(k));
587
703
  if (missing.length > 0) {
704
+ const header = existing.includes(ENV_HEADER) ? "" : `${ENV_HEADER}
705
+ `;
706
+ const block = header + missing.map(line).join("\n");
588
707
  const trailingBlank = out.length > 0 && out[out.length - 1] === "";
589
- const prefix = trailingBlank ? "" : existing.endsWith("\n") ? "" : "\n";
590
- out.push(`${prefix}${ENV_HEADER}
591
- ` + missing.map(line).join("\n"));
708
+ if (trailingBlank)
709
+ out.splice(out.length - 1, 0, block);
710
+ else
711
+ out.push(`
712
+ ${block}`);
592
713
  }
593
714
  let result = out.join("\n");
594
715
  if (existing.endsWith("\n") && !result.endsWith("\n"))
@@ -602,17 +723,19 @@ function readEngagementEnv(engagementId, home) {
602
723
  return null;
603
724
  }
604
725
  }
605
- function writeEngagementEnv(engagementId, values, home) {
726
+ function writeEngagementEnv(engagementId, values, home, keys = ENGAGEMENT_ENV_KEYS) {
606
727
  const path = engagementEnvPath(engagementId, home);
607
728
  const dir = engagementStateDir(engagementId, home);
608
- mkdirSync2(dir, { recursive: true, mode: 448 });
609
729
  let existing;
610
730
  try {
611
731
  existing = readFileSync2(path, "utf-8");
612
732
  } catch {
613
733
  existing = null;
614
734
  }
615
- const reconciled = reconcileEnvText(existing, values);
735
+ const reconciled = reconcileEnvText(existing, values, keys);
736
+ if (existing === null && reconciled === "")
737
+ return "skipped";
738
+ mkdirSync2(dir, { recursive: true, mode: 448 });
616
739
  const outcome = existing === reconciled ? "skipped" : "written";
617
740
  if (outcome === "written") {
618
741
  writeFileSync2(path, reconciled, { mode: 384 });
@@ -642,14 +765,15 @@ function engagementResolutionShell() {
642
765
  # desktop session's PATH, so the id is cut out with sed \u2014 after flattening the
643
766
  # file, because sed is line-oriented and the pin's line breaks are not a contract.
644
767
  #
645
- # Sets HALFCYCLE_ENV_FILE, HALFCYCLE_ENV_ENGAGEMENT and HALFCYCLE_ENV_PROBLEM in
646
- # the CALLER's shell and returns a status. Call it as \`if halfcycle_env_file \u2026\`,
647
- # never as \`$(halfcycle_env_file \u2026)\` \u2014 a command substitution is a subshell and
648
- # 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.
649
772
  halfcycle_env_file() {
650
773
  HALFCYCLE_ENV_FILE=""
651
774
  HALFCYCLE_ENV_ENGAGEMENT=""
652
775
  HALFCYCLE_ENV_PROBLEM=""
776
+ HALFCYCLE_ENV_NOT_THIS_ACCOUNT=""
653
777
  hc_pin="$1/.halfcycle/bundle.json"
654
778
  if [ ! -f "$hc_pin" ]; then
655
779
  HALFCYCLE_ENV_PROBLEM="no-pin"
@@ -665,6 +789,9 @@ halfcycle_env_file() {
665
789
  HALFCYCLE_ENV_PROBLEM="no-home"
666
790
  return 1
667
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
668
795
  hc_env="$HOME/.halfcycle/${ENGAGEMENTS_DIR}/$hc_id/${ENV_FILENAME}"
669
796
  if [ ! -f "$hc_env" ]; then
670
797
  HALFCYCLE_ENV_PROBLEM="no-credential"
@@ -1036,6 +1163,13 @@ if halfcycle_env_file "$PROJECT_DIR"; then
1036
1163
  set +a
1037
1164
  fi
1038
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
+
1039
1173
  exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
1040
1174
  `;
1041
1175
  }
@@ -1051,13 +1185,20 @@ MARKER_FILE="\${TMPDIR%/}/halfcycle-session-\${HASH}.ref"
1051
1185
  git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
1052
1186
 
1053
1187
  # ---------------------------------------------------------------------------
1054
- # STANDING GUARD-COVERAGE STATEMENT (T-27).
1188
+ # STANDING GUARD-COVERAGE STATEMENT (T-27; two arms since T-02b).
1055
1189
  #
1056
1190
  # A repository with no credential on this machine is UNGUARDED: the PostToolUse
1057
1191
  # hook will find nothing to evaluate with, and it says so once per edit, after
1058
1192
  # the edit. This says it once, up front, before anything is written \u2014 which is
1059
1193
  # the one thing a session-start hook can do that nothing else can.
1060
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
+ #
1061
1202
  # Everything below runs in a SUBSHELL: it sources a credential store, and a
1062
1203
  # session-start hook must not leak an engagement's variables into whatever the
1063
1204
  # editor runs next. Any failure inside it is swallowed; this script's exit
@@ -1071,18 +1212,33 @@ ${engagementResolutionShell()}
1071
1212
  . "$HALFCYCLE_ENV_FILE"
1072
1213
  set +a
1073
1214
  fi
1074
- hc_missing=""
1075
- for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
1076
- eval "hc_value=\\\${$hc_key:-}"
1077
- if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
1078
- done
1079
- if [ -n "$hc_missing" ]; then
1080
- echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
1081
- echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
1082
- echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
1083
- echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
1084
- echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
1085
- 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
1086
1242
  fi
1087
1243
  ) 2>/dev/null || true
1088
1244
  exit 0
@@ -1242,8 +1398,20 @@ if [ -z "$TOKEN" ]; then
1242
1398
  no-home)
1243
1399
  echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
1244
1400
  *)
1245
- echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
1246
- 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 ;;
1247
1415
  esac
1248
1416
  exit 1
1249
1417
  fi
@@ -2048,11 +2216,13 @@ var CreateEngagementRefused = class extends Error {
2048
2216
  status;
2049
2217
  serverMessage;
2050
2218
  errorCode;
2051
- constructor(status, serverMessage, errorCode, message) {
2219
+ reason;
2220
+ constructor(status, serverMessage, errorCode, reason, message) {
2052
2221
  super(message);
2053
2222
  this.status = status;
2054
2223
  this.serverMessage = serverMessage;
2055
2224
  this.errorCode = errorCode;
2225
+ this.reason = reason;
2056
2226
  this.name = "CreateEngagementRefused";
2057
2227
  }
2058
2228
  /**
@@ -2097,7 +2267,8 @@ function planeRefusal(detail) {
2097
2267
  return void 0;
2098
2268
  return {
2099
2269
  message: candidate.message,
2100
- 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
2101
2272
  };
2102
2273
  }
2103
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.`;
@@ -2138,7 +2309,7 @@ async function requestEngagementValues(url, requestBody, credential, shape) {
2138
2309
  const detail = await res.text().catch(() => "");
2139
2310
  const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
2140
2311
  if (refusal) {
2141
- 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}.`);
2142
2313
  }
2143
2314
  const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the Halfcycle service. ` : "";
2144
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);
@@ -2619,6 +2790,9 @@ function armScript(credential, act, label, plainLabel, home) {
2619
2790
  }
2620
2791
  }
2621
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
+ }
2622
2796
  async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
2623
2797
  const write = deps.write ?? ((text) => process.stdout.write(text));
2624
2798
  const colour = deps.colour ?? decideColour({ isTTY: process.stdout.isTTY === true, env: deps.env ?? process.env });
@@ -2684,6 +2858,9 @@ function deriveProjectName(targetRepoRoot) {
2684
2858
  }
2685
2859
 
2686
2860
  // dist/own-engagement.js
2861
+ function isOutcomeBRefusal(result) {
2862
+ return "outcome" in result && result.outcome === "not-this-account";
2863
+ }
2687
2864
  async function createOwnedEngagement(serviceUrl, targetRepo, deps = {}) {
2688
2865
  let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, deps), { kind: "create", targetRepo }, deps);
2689
2866
  for (; ; ) {
@@ -2718,6 +2895,13 @@ async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps =
2718
2895
  if (!(err instanceof CreateEngagementRefused))
2719
2896
  throw err;
2720
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
+ }
2721
2905
  throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${JOIN_REFUSED_LOCAL_REMEDY}`);
2722
2906
  }
2723
2907
  if (err.status !== 401) {
@@ -2816,8 +3000,203 @@ function projectSeedGuard(fired, includeExplanation) {
2816
3000
  // dist/build-record/sources.js
2817
3001
  import { readFileSync as readFileSync6, existsSync as existsSync4 } from "node:fs";
2818
3002
  import { createHash } from "node:crypto";
2819
- import { join as join6 } from "node:path";
3003
+ import { join as join8 } from "node:path";
3004
+
3005
+ // dist/build-record/close-record.js
2820
3006
  import { execFileSync as execFileSync2 } from "node:child_process";
3007
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
3008
+ import { dirname as dirname3, join as join6 } from "node:path";
3009
+ var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
3010
+ var BUILD_RECORD_ZONE_B_DIR = join6(ZONE_B_DIR, "build-record");
3011
+ var InvalidCloseRecordError = class extends Error {
3012
+ constructor(path, detail) {
3013
+ super(`[build-record] the phase close record at ${path} cannot be read \u2014 ${detail}`);
3014
+ this.name = "InvalidCloseRecordError";
3015
+ }
3016
+ };
3017
+ function closeRecordPath(repoRoot, phase) {
3018
+ assertValidPhaseIdentity(phase, BUILD_RECORD_ZONE_B_DIR);
3019
+ return join6(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phase)}.close.json`);
3020
+ }
3021
+ function resolveCloseAtHead(repoRoot) {
3022
+ try {
3023
+ const closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
3024
+ encoding: "utf-8",
3025
+ stdio: ["ignore", "pipe", "ignore"]
3026
+ }).trim();
3027
+ const closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
3028
+ encoding: "utf-8",
3029
+ stdio: ["ignore", "pipe", "ignore"]
3030
+ }).trim();
3031
+ if (closeCommit === "" || closedDate === "")
3032
+ return null;
3033
+ return { closeCommit, closedDate };
3034
+ } catch {
3035
+ return null;
3036
+ }
3037
+ }
3038
+ function writeCloseRecord(repoRoot, phase) {
3039
+ let path;
3040
+ try {
3041
+ path = closeRecordPath(repoRoot, phase);
3042
+ } catch (err) {
3043
+ return { recorded: false, path: null, reason: err instanceof Error ? err.message : String(err) };
3044
+ }
3045
+ const at = resolveCloseAtHead(repoRoot);
3046
+ if (at === null) {
3047
+ return {
3048
+ recorded: false,
3049
+ path,
3050
+ reason: `the commit could not be read from ${repoRoot} \u2014 there is no git available, no repository there, or no commit in it yet`
3051
+ };
3052
+ }
3053
+ const record2 = {
3054
+ format: CLOSE_RECORD_FORMAT,
3055
+ phase,
3056
+ closeCommit: at.closeCommit,
3057
+ closedDate: at.closedDate
3058
+ };
3059
+ try {
3060
+ mkdirSync5(dirname3(path), { recursive: true });
3061
+ writeFileSync5(path, JSON.stringify(record2, null, 2) + "\n", "utf-8");
3062
+ } catch (err) {
3063
+ return { recorded: false, path, reason: err instanceof Error ? err.message : String(err) };
3064
+ }
3065
+ return { recorded: true, path, closeCommit: at.closeCommit, closedDate: at.closedDate };
3066
+ }
3067
+ function parseCloseRecord(path, parsed) {
3068
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
3069
+ throw new InvalidCloseRecordError(path, "it does not hold a record");
3070
+ }
3071
+ const record2 = parsed;
3072
+ if (record2.format !== CLOSE_RECORD_FORMAT) {
3073
+ throw new InvalidCloseRecordError(path, `it is not a ${CLOSE_RECORD_FORMAT} record (it says ${JSON.stringify(record2.format)})`);
3074
+ }
3075
+ const commit = record2.closeCommit;
3076
+ const date = record2.closedDate;
3077
+ const missing = [];
3078
+ if (typeof commit !== "string" || commit.trim() === "")
3079
+ missing.push("closeCommit");
3080
+ if (typeof date !== "string" || date.trim() === "")
3081
+ missing.push("closedDate");
3082
+ if (missing.length > 0) {
3083
+ throw new InvalidCloseRecordError(path, `${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} not recorded in it. A close records the commit and its date together or records neither, so a file carrying one of them was edited by hand.`);
3084
+ }
3085
+ if (typeof record2.phase !== "string" && typeof record2.phase !== "number") {
3086
+ throw new InvalidCloseRecordError(path, "it does not say which phase closed");
3087
+ }
3088
+ return {
3089
+ format: CLOSE_RECORD_FORMAT,
3090
+ phase: record2.phase,
3091
+ closeCommit: commit,
3092
+ closedDate: date
3093
+ };
3094
+ }
3095
+
3096
+ // dist/build-record/write.js
3097
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs";
3098
+ import { basename as basename2, join as join7, resolve as resolve2, relative as relative2, isAbsolute } from "node:path";
3099
+
3100
+ // dist/build-record/template.js
3101
+ function severityLabel(seed) {
3102
+ return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
3103
+ }
3104
+ var NOT_RECORDED = "not recorded";
3105
+ function renderCloseLine(record2) {
3106
+ const status = `**Status:** ${record2.status.toUpperCase()}`;
3107
+ const closed = `**Closed:** ${record2.closedDate ?? NOT_RECORDED}`;
3108
+ const commit = record2.closeCommit === null ? `**Close commit:** ${NOT_RECORDED}` : `**Close commit:** \`${record2.closeCommit}\``;
3109
+ return `${status} \xB7 ${closed} \xB7 ${commit}`;
3110
+ }
3111
+ function renderCloseProvenance(record2) {
3112
+ return record2.closeCommit === null && record2.closedDate === null ? "*The close date and the commit were not recorded when this phase closed, so this record does not name them. Nothing has been substituted for them.*" : "*The close date and the commit are the client's own assertion about their own repository, written down when the phase was closed. Halfcycle did not observe them and does not check them.*";
3113
+ }
3114
+ function renderDoneWhenRows(record2) {
3115
+ const rows = [];
3116
+ for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
3117
+ const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
3118
+ rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
3119
+ }
3120
+ return rows.join("\n");
3121
+ }
3122
+ function renderBuildRecordMarkdown(record2) {
3123
+ const { phase } = record2;
3124
+ const companion = `${renderPhaseSegment(phase.id)}.json`;
3125
+ const guardsList = record2.seedGuards.map(severityLabel).join(", ");
3126
+ const lines = [
3127
+ // Fixed prose (vault-canonical template) + projected title slots.
3128
+ `# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
3129
+ "",
3130
+ `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
3131
+ "",
3132
+ "> This Build Record was assembled automatically at phase close by the method bundle (`packages/bundle`). Every data field below is projected from the companion JSON \u2014 no record content is hand-authored.",
3133
+ "",
3134
+ `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
3135
+ `**Phase:** ${phase.id} \u2014 ${phase.name}`,
3136
+ renderCloseLine(record2),
3137
+ `**Companion:** [\`${companion}\`](${companion})`,
3138
+ "",
3139
+ renderCloseProvenance(record2),
3140
+ "",
3141
+ "## What this phase proved",
3142
+ "",
3143
+ `${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
3144
+ "",
3145
+ "## Delivered",
3146
+ "",
3147
+ `- **Packages:** ${record2.delivered.packages.join(", ")}`,
3148
+ `- **Services:** ${record2.delivered.services.join(", ")}`,
3149
+ `- **Tools:** ${record2.delivered.tools.join(", ")}`,
3150
+ `- **Dogfood:** ${record2.delivered.dogfood}`,
3151
+ "",
3152
+ guardsList ? `Guards evaluated (INV-007-safe results): ${guardsList}. *(Guard \`channel\`/\`version\` are guard-asset metadata, excluded from the v1 record \u2014 INV-007/INV-001.)*` : "No guards fired during this phase.",
3153
+ "",
3154
+ `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
3155
+ "",
3156
+ `**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
3157
+ "",
3158
+ "| Done-when | Result | Evidence |",
3159
+ "|---|---|---|",
3160
+ renderDoneWhenRows(record2),
3161
+ ""
3162
+ ];
3163
+ if (record2.acceptance.inv002ProdBypassProbe) {
3164
+ lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
3165
+ }
3166
+ lines.push("## Gates that earned their keep", "", `**${record2.gatesEarnedKeep.defectsCaughtPreHuman}** defects were caught by the gates before the human walk; the walk itself found **${record2.gatesEarnedKeep.bugsReachingHumanWalk}**. Named: ${record2.gatesEarnedKeep.named.join("; ")}.`, "", "## Instruments", "", `- **Marginal-cost self-accounting:** ${record2.instruments.marginalCostSelfAccounting}`, `- **COE add-rate:** seam-new ${record2.instruments.coeAddRate.seamNew}, seam-repeat ${record2.instruments.coeAddRate.seamRepeat}, model-limitation ${record2.instruments.coeAddRate.modelLimitation}. ${record2.instruments.coeAddRate.notes}`, "", "## Invariants exercised", "", record2.invariantsExercised.join(", "), "", "## Deviations & decisions recorded", "", ...record2.deviations.map((d) => `- ${d}`), "", "## Tasks", "", `- **Planned:** ${record2.tasks.planned} \xB7 **Landed:** ${record2.tasks.landed} \xB7 **Cancelled:** ${record2.tasks.cancelled.length > 0 ? record2.tasks.cancelled.join(", ") : "none"}`, "");
3167
+ return lines.join("\n");
3168
+ }
3169
+
3170
+ // dist/build-record/write.js
3171
+ var BUILD_RECORD_DIR = join7("docs", "build-records");
3172
+ function serialiseRecordJson(record2) {
3173
+ return JSON.stringify(record2, null, 2) + "\n";
3174
+ }
3175
+ function assertInsideOutDir(outDir, candidate, identity) {
3176
+ const rel = relative2(outDir, resolve2(candidate));
3177
+ const isPlainFilename = rel !== "" && rel !== ".." && !isAbsolute(rel) && rel === basename2(rel);
3178
+ if (!isPlainFilename) {
3179
+ throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
3180
+ }
3181
+ }
3182
+ function writeBuildRecord(repoRoot, record2) {
3183
+ const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
3184
+ const identity = record2.phase.id;
3185
+ if (!isValidPhaseIdentity(identity)) {
3186
+ throw new InvalidPhaseIdentityError(identity, BUILD_RECORD_DIR);
3187
+ }
3188
+ const segment = renderPhaseSegment(identity);
3189
+ const jsonPath = join7(outDir, `${segment}.json`);
3190
+ const mdPath = join7(outDir, `${segment}.md`);
3191
+ assertInsideOutDir(outDir, jsonPath, identity);
3192
+ assertInsideOutDir(outDir, mdPath, identity);
3193
+ mkdirSync6(outDir, { recursive: true });
3194
+ writeFileSync6(jsonPath, serialiseRecordJson(record2), "utf-8");
3195
+ writeFileSync6(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
3196
+ return { jsonPath, mdPath };
3197
+ }
3198
+
3199
+ // dist/build-record/sources.js
2821
3200
  var MissingSourceError = class extends Error {
2822
3201
  constructor(sourceName, detail) {
2823
3202
  super(`[build-record] missing required source: ${sourceName} \u2014 ${detail}`);
@@ -2873,7 +3252,7 @@ function syntheticRunId(record2) {
2873
3252
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
2874
3253
  }
2875
3254
  function readGuardEvalLog(logDir, phaseId) {
2876
- const logPath = join6(logDir, `phase-${phaseId}.jsonl`);
3255
+ const logPath = join8(logDir, `${renderPhaseSegment(phaseId)}.jsonl`);
2877
3256
  if (!existsSync4(logPath)) {
2878
3257
  throw new MissingSourceError("guard-eval log", `no runner guard-eval log at ${logPath} for phase ${phaseId}`);
2879
3258
  }
@@ -2889,7 +3268,7 @@ function readGuardEvalLog(logDir, phaseId) {
2889
3268
  return runs;
2890
3269
  }
2891
3270
  function readOrchestrationState(phasesDir, phaseId) {
2892
- const statePath = join6(phasesDir, `phase-${phaseId}-orchestration-state.json`);
3271
+ const statePath = join8(phasesDir, `${renderPhaseSegment(phaseId)}-orchestration-state.json`);
2893
3272
  if (!existsSync4(statePath)) {
2894
3273
  throw new MissingSourceError("orchestration state", `no orchestration-state file at ${statePath} for phase ${phaseId}`);
2895
3274
  }
@@ -2906,22 +3285,21 @@ function readOrchestrationState(phasesDir, phaseId) {
2906
3285
  }
2907
3286
  return { tasks };
2908
3287
  }
2909
- function readGitClose(repoRoot) {
2910
- let closeCommit;
2911
- let closedDate;
3288
+ function readPhaseClose(repoRoot, phaseId) {
3289
+ const path = closeRecordPath(repoRoot, phaseId);
3290
+ if (!existsSync4(path))
3291
+ return { closeCommit: null, closedDate: null };
3292
+ let parsed;
2912
3293
  try {
2913
- closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
2914
- encoding: "utf-8"
2915
- }).trim();
2916
- closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], { encoding: "utf-8" }).trim();
3294
+ parsed = JSON.parse(readFileSync6(path, "utf-8"));
2917
3295
  } catch (err) {
2918
- throw new MissingSourceError("git HEAD", `cannot resolve close commit/date in ${repoRoot}: ${err instanceof Error ? err.message : String(err)}`);
3296
+ throw new InvalidCloseRecordError(path, `it is not readable JSON: ${err instanceof Error ? err.message : String(err)}`);
3297
+ }
3298
+ const record2 = parseCloseRecord(path, parsed);
3299
+ if (renderPhaseSegment(record2.phase) !== renderPhaseSegment(phaseId)) {
3300
+ throw new InvalidCloseRecordError(path, `it records the close of ${String(record2.phase)}, not of ${String(phaseId)}`);
2919
3301
  }
2920
- if (!closeCommit)
2921
- throw new MissingSourceError("git HEAD", `empty commit sha in ${repoRoot}`);
2922
- if (!closedDate)
2923
- throw new MissingSourceError("git HEAD", `empty commit date in ${repoRoot}`);
2924
- return { closeCommit, closedDate };
3302
+ return { closeCommit: record2.closeCommit, closedDate: record2.closedDate };
2925
3303
  }
2926
3304
  function validateNarrated(narrated) {
2927
3305
  const req = (v, name) => {
@@ -2932,6 +3310,7 @@ function validateNarrated(narrated) {
2932
3310
  req(narrated.engagement, "engagement");
2933
3311
  req(narrated.engagementType, "engagementType");
2934
3312
  req(narrated.phase?.name, "phase.name");
3313
+ assertValidPhaseIdentity(narrated.phase?.id, BUILD_RECORD_DIR);
2935
3314
  req(narrated.status, "status");
2936
3315
  req(narrated.thesis, "thesis");
2937
3316
  req(narrated.delivered, "delivered");
@@ -2947,7 +3326,7 @@ function validateNarrated(narrated) {
2947
3326
  function loadSources(opts) {
2948
3327
  const orchestration = readOrchestrationState(opts.phasesDir, opts.phaseId);
2949
3328
  const guardEvalRuns = readGuardEvalLog(opts.guardEvalLogDir, opts.phaseId);
2950
- const git2 = readGitClose(opts.repoRoot);
3329
+ const close = readPhaseClose(opts.repoRoot, opts.phaseId);
2951
3330
  const narrated = validateNarrated(opts.narrated);
2952
3331
  if (!Array.isArray(opts.touchedInvariants)) {
2953
3332
  throw new MissingSourceError("feature-spec Touches", "touchedInvariants must be an array");
@@ -2955,7 +3334,7 @@ function loadSources(opts) {
2955
3334
  return {
2956
3335
  orchestration,
2957
3336
  narrated,
2958
- git: git2,
3337
+ close,
2959
3338
  guardEvalRuns,
2960
3339
  touchedInvariants: opts.touchedInvariants
2961
3340
  };
@@ -2985,15 +3364,15 @@ function deriveInvariantsExercised(touched) {
2985
3364
  return [...new Set(touched)].sort((a, b) => a.localeCompare(b));
2986
3365
  }
2987
3366
  function assembleBuildRecord(sources) {
2988
- const { narrated, git: git2, orchestration } = sources;
3367
+ const { narrated, close, orchestration } = sources;
2989
3368
  return {
2990
3369
  format: BUILD_RECORD_FORMAT,
2991
3370
  engagement: narrated.engagement,
2992
3371
  engagementType: narrated.engagementType,
2993
3372
  phase: { id: narrated.phase.id, name: narrated.phase.name },
2994
3373
  status: narrated.status,
2995
- closedDate: git2.closedDate,
2996
- closeCommit: git2.closeCommit,
3374
+ closedDate: close.closedDate,
3375
+ closeCommit: close.closeCommit,
2997
3376
  thesis: narrated.thesis,
2998
3377
  thesisHeld: deriveThesisHeld(narrated.acceptance),
2999
3378
  delivered: narrated.delivered,
@@ -3007,85 +3386,6 @@ function assembleBuildRecord(sources) {
3007
3386
  };
3008
3387
  }
3009
3388
 
3010
- // dist/build-record/template.js
3011
- function severityLabel(seed) {
3012
- return `\`${seed.patternRef}\` (severity \`${seed.severity}\`)`;
3013
- }
3014
- function renderDoneWhenRows(record2) {
3015
- const rows = [];
3016
- for (const [key, dw] of Object.entries(record2.acceptance.doneWhen)) {
3017
- const mark = dw.result === "pass" ? "\u2705" : dw.result === "dropped" ? "\u2014" : dw.result;
3018
- rows.push(`| ${key} | ${mark} | ${dw.detail} |`);
3019
- }
3020
- return rows.join("\n");
3021
- }
3022
- function renderBuildRecordMarkdown(record2) {
3023
- const { phase } = record2;
3024
- const guardsList = record2.seedGuards.map(severityLabel).join(", ");
3025
- const lines = [
3026
- // Fixed prose (vault-canonical template) + projected title slots.
3027
- `# Build Record \u2014 Phase ${phase.id}: ${phase.name}`,
3028
- "",
3029
- `**Format:** \`${record2.format}\` (markdown + JSON pair; portable, readable without Halfcycle systems)`,
3030
- "",
3031
- "> This Build Record was assembled automatically at phase close by the method bundle (`packages/bundle`). Every data field below is projected from the companion JSON \u2014 no record content is hand-authored.",
3032
- "",
3033
- `**Engagement:** ${record2.engagement} (${record2.engagementType})`,
3034
- `**Phase:** ${phase.id} \u2014 ${phase.name}`,
3035
- `**Status:** ${record2.status.toUpperCase()} \xB7 **Closed:** ${record2.closedDate} \xB7 **Close commit:** \`${record2.closeCommit}\``,
3036
- `**Companion:** [\`phase-${phase.id}.json\`](phase-${phase.id}.json)`,
3037
- "",
3038
- "## What this phase proved",
3039
- "",
3040
- `${record2.thesis} **Thesis held: ${record2.thesisHeld ? "yes" : "no"}.**`,
3041
- "",
3042
- "## Delivered",
3043
- "",
3044
- `- **Packages:** ${record2.delivered.packages.join(", ")}`,
3045
- `- **Services:** ${record2.delivered.services.join(", ")}`,
3046
- `- **Tools:** ${record2.delivered.tools.join(", ")}`,
3047
- `- **Dogfood:** ${record2.delivered.dogfood}`,
3048
- "",
3049
- guardsList ? `Guards evaluated (INV-007-safe results): ${guardsList}. *(Guard \`channel\`/\`version\` are guard-asset metadata, excluded from the v1 record \u2014 INV-007/INV-001.)*` : "No guards fired during this phase.",
3050
- "",
3051
- `## Acceptance (independent walk \u2014 walker ${record2.acceptance.walker})`,
3052
- "",
3053
- `**Verdict:** ${record2.acceptance.verdict} \xB7 **Findings:** ${record2.acceptance.findings}`,
3054
- "",
3055
- "| Done-when | Result | Evidence |",
3056
- "|---|---|---|",
3057
- renderDoneWhenRows(record2),
3058
- ""
3059
- ];
3060
- if (record2.acceptance.inv002ProdBypassProbe) {
3061
- lines.push(`**INV-002 production bypass probe:** ${record2.acceptance.inv002ProdBypassProbe.result} \u2014 ${record2.acceptance.inv002ProdBypassProbe.detail}`, "");
3062
- }
3063
- lines.push("## Gates that earned their keep", "", `**${record2.gatesEarnedKeep.defectsCaughtPreHuman}** defects were caught by the gates before the human walk; the walk itself found **${record2.gatesEarnedKeep.bugsReachingHumanWalk}**. Named: ${record2.gatesEarnedKeep.named.join("; ")}.`, "", "## Instruments", "", `- **Marginal-cost self-accounting:** ${record2.instruments.marginalCostSelfAccounting}`, `- **COE add-rate:** seam-new ${record2.instruments.coeAddRate.seamNew}, seam-repeat ${record2.instruments.coeAddRate.seamRepeat}, model-limitation ${record2.instruments.coeAddRate.modelLimitation}. ${record2.instruments.coeAddRate.notes}`, "", "## Invariants exercised", "", record2.invariantsExercised.join(", "), "", "## Deviations & decisions recorded", "", ...record2.deviations.map((d) => `- ${d}`), "", "## Tasks", "", `- **Planned:** ${record2.tasks.planned} \xB7 **Landed:** ${record2.tasks.landed} \xB7 **Cancelled:** ${record2.tasks.cancelled.length > 0 ? record2.tasks.cancelled.join(", ") : "none"}`, "");
3064
- return lines.join("\n");
3065
- }
3066
-
3067
- // dist/build-record/write.js
3068
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
3069
- import { join as join7, resolve as resolve2, relative as relative2, isAbsolute } from "node:path";
3070
- var BUILD_RECORD_DIR = join7("docs", "build-records");
3071
- function serialiseRecordJson(record2) {
3072
- return JSON.stringify(record2, null, 2) + "\n";
3073
- }
3074
- function writeBuildRecord(repoRoot, record2) {
3075
- const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
3076
- const expected = resolve2(repoRoot, BUILD_RECORD_DIR);
3077
- const rel = relative2(expected, outDir);
3078
- if (rel !== "" || isAbsolute(rel)) {
3079
- throw new Error(`[build-record] refusing to write outside ${BUILD_RECORD_DIR}`);
3080
- }
3081
- mkdirSync5(outDir, { recursive: true });
3082
- const jsonPath = join7(outDir, `phase-${record2.phase.id}.json`);
3083
- const mdPath = join7(outDir, `phase-${record2.phase.id}.md`);
3084
- writeFileSync5(jsonPath, serialiseRecordJson(record2), "utf-8");
3085
- writeFileSync5(mdPath, renderBuildRecordMarkdown(record2), "utf-8");
3086
- return { jsonPath, mdPath };
3087
- }
3088
-
3089
3389
  // dist/build-record/index.js
3090
3390
  function assemblePhaseBuildRecord(opts) {
3091
3391
  const sources = loadSources({
@@ -3102,7 +3402,7 @@ function assemblePhaseBuildRecord(opts) {
3102
3402
 
3103
3403
  // dist/open-phase.js
3104
3404
  import { readFileSync as readFileSync7 } from "node:fs";
3105
- import { join as join8 } from "node:path";
3405
+ import { join as join9 } from "node:path";
3106
3406
  var OpenPhaseRefused = class extends Error {
3107
3407
  status;
3108
3408
  finding;
@@ -3121,7 +3421,7 @@ var OPEN_PHASE_ENV_KEYS = [
3121
3421
  function readRepoCredential(repoRoot, env = process.env, home) {
3122
3422
  const pinned = readPinnedEngagementId(repoRoot);
3123
3423
  const fromStore = pinned === null ? null : readEngagementEnv(pinned, home);
3124
- const looked = pinned === null ? `${join8(repoRoot, ".halfcycle", "bundle.json")} (which names no engagement)` : engagementEnvPath(pinned, home);
3424
+ const looked = pinned === null ? `${join9(repoRoot, ".halfcycle", "bundle.json")} (which names no engagement)` : engagementEnvPath(pinned, home);
3125
3425
  const values = {};
3126
3426
  for (const key of OPEN_PHASE_ENV_KEYS) {
3127
3427
  const value = (env[key] ?? fromStore?.[key] ?? "").trim();
@@ -3138,18 +3438,33 @@ function readRepoCredential(repoRoot, env = process.env, home) {
3138
3438
  }
3139
3439
  function readPinnedEngagementId(repoRoot) {
3140
3440
  try {
3141
- const pin = JSON.parse(readFileSync7(join8(repoRoot, ".halfcycle", "bundle.json"), "utf-8"));
3441
+ const pin = JSON.parse(readFileSync7(join9(repoRoot, ".halfcycle", "bundle.json"), "utf-8"));
3142
3442
  const id = pin[PIN_ENGAGEMENT_ID_FIELD];
3143
3443
  return typeof id === "string" && id !== "" ? id : null;
3144
3444
  } catch {
3145
3445
  return null;
3146
3446
  }
3147
3447
  }
3148
- async function openPhase(credential, phase, entry) {
3448
+ function writePhaseStamp(engagementId, phase, home) {
3449
+ const path = engagementEnvPath(engagementId, home);
3450
+ if (phase !== null && !isValidPhaseIdentity(phase)) {
3451
+ return { path, reason: new InvalidPhaseIdentityError(phase).message };
3452
+ }
3453
+ try {
3454
+ writeEngagementEnv(engagementId, { [PHASE_STAMP_ENV_KEY]: phase }, home, [PHASE_STAMP_ENV_KEY]);
3455
+ return null;
3456
+ } catch (err) {
3457
+ return { path, reason: err instanceof Error ? err.message : String(err) };
3458
+ }
3459
+ }
3460
+ async function openPhase(credential, phase, entry, home) {
3149
3461
  const parsed = phaseEntryDecisionSchema.safeParse(entry);
3150
3462
  if (!parsed.success) {
3151
3463
  throw new Error(`[halfcycle] The entry decision is not a decision this platform accepts: ${parsed.error.issues.map((i) => i.message).join("; ")}.`);
3152
3464
  }
3465
+ if (phase !== null && !isValidPhaseIdentity(phase)) {
3466
+ throw new InvalidPhaseIdentityError(phase);
3467
+ }
3153
3468
  const url = `${credential.serviceUrl}/engagements/${encodeURIComponent(credential.engagementId)}/phase`;
3154
3469
  let res;
3155
3470
  try {
@@ -3174,9 +3489,11 @@ async function openPhase(credential, phase, entry) {
3174
3489
  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") {
3175
3490
  throw new Error(`[halfcycle] The response from ${url} did not carry { phase, previousPhase, decision }. The phase may or may not have moved \u2014 read the engagement's decision history before retrying.`);
3176
3491
  }
3492
+ const stampFailure = writePhaseStamp(credential.engagementId, body.phase, home);
3177
3493
  return {
3178
3494
  phase: body.phase,
3179
3495
  previousPhase: body.previousPhase,
3496
+ stampFailure,
3180
3497
  decision: body.decision
3181
3498
  };
3182
3499
  }
@@ -3194,7 +3511,7 @@ var ClosePhaseRefused = class extends Error {
3194
3511
  this.name = "ClosePhaseRefused";
3195
3512
  }
3196
3513
  };
3197
- async function closePhase(credential, phase, outcome, close) {
3514
+ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
3198
3515
  const parsed = acceptanceOutcomeSchema.safeParse(outcome);
3199
3516
  if (!parsed.success) {
3200
3517
  throw new Error(`[halfcycle] That is not an acceptance outcome this platform accepts: ${parsed.error.issues.map((i) => i.message).join("; ")}.`);
@@ -3228,7 +3545,13 @@ async function closePhase(credential, phase, outcome, close) {
3228
3545
  if (!body || typeof body.engagementId !== "string" || typeof body.status !== "string") {
3229
3546
  throw new Error(`[halfcycle] The response from ${url} did not carry { engagementId, status }. The phase may or may not have closed \u2014 read the engagement's status before retrying.`);
3230
3547
  }
3231
- return { engagementId: body.engagementId, status: body.status };
3548
+ const closeRecord = repoRoot === void 0 ? {
3549
+ recorded: false,
3550
+ path: null,
3551
+ reason: "no repository was named for this close, so there was nowhere to read a commit from"
3552
+ } : writeCloseRecord(repoRoot, phase);
3553
+ const stampFailure = writePhaseStamp(credential.engagementId, null, home);
3554
+ return { engagementId: body.engagementId, status: body.status, stampFailure, closeRecord };
3232
3555
  }
3233
3556
 
3234
3557
  // dist/cli-contract.js
@@ -3349,7 +3672,7 @@ var PLACEHOLDERS = {
3349
3672
  // dist/banner-facts.js
3350
3673
  import { execFileSync as execFileSync3 } from "node:child_process";
3351
3674
  import { readFileSync as readFileSync8 } from "node:fs";
3352
- import { basename as basename2, join as join9, resolve as resolve3 } from "node:path";
3675
+ import { basename as basename3, join as join10, resolve as resolve3 } from "node:path";
3353
3676
  var BRAND_URL = "halfcycle.ai";
3354
3677
  var TAGLINE = [
3355
3678
  "Fewer cycles.",
@@ -3362,7 +3685,7 @@ var TAGLINE_NARROW = [
3362
3685
  ];
3363
3686
  function bundleVersion() {
3364
3687
  try {
3365
- const pkg = JSON.parse(readFileSync8(join9(BUNDLE_ROOT, "package.json"), "utf-8"));
3688
+ const pkg = JSON.parse(readFileSync8(join10(BUNDLE_ROOT, "package.json"), "utf-8"));
3366
3689
  return typeof pkg.version === "string" ? pkg.version : void 0;
3367
3690
  } catch {
3368
3691
  return void 0;
@@ -3451,7 +3774,7 @@ function openingBannerContent(projectDir, env = process.env, probes = REAL_PROBE
3451
3774
  }
3452
3775
  function safeBasename(dir) {
3453
3776
  try {
3454
- const name = basename2(resolve3(dir)).trim();
3777
+ const name = basename3(resolve3(dir)).trim();
3455
3778
  return name === "" ? "project" : name;
3456
3779
  } catch {
3457
3780
  return "project";
@@ -3512,7 +3835,7 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
3512
3835
  install into target (default: the current directory)
3513
3836
  --yes / -y: use this machine's saved sign-in without being asked
3514
3837
  halfcycle check-drift <target-repo>
3515
- halfcycle build-record <phase-id> [--repo <root>]
3838
+ halfcycle build-record <phase> [--repo <root>]
3516
3839
  halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
3517
3840
  halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
3518
3841
 
@@ -3521,7 +3844,7 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
3521
3844
  `;
3522
3845
  function isHalfcycleMonorepo(dir) {
3523
3846
  try {
3524
- const pkg = JSON.parse(readFileSync9(join10(dir, "package.json"), "utf-8"));
3847
+ const pkg = JSON.parse(readFileSync9(join11(dir, "package.json"), "utf-8"));
3525
3848
  return pkg.name === "halfcycle-monorepo";
3526
3849
  } catch {
3527
3850
  return false;
@@ -3573,6 +3896,7 @@ ${USAGE}`);
3573
3896
  const reusable = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
3574
3897
  let engagementId;
3575
3898
  let credential;
3899
+ let notThisAccountMessage;
3576
3900
  let actingAccountId;
3577
3901
  let foundLine = "";
3578
3902
  if (reusable !== void 0 && pinned !== null) {
@@ -3593,21 +3917,30 @@ ${USAGE}`);
3593
3917
  }
3594
3918
  } else if (requestedId !== void 0) {
3595
3919
  const joined = await joinPinnedEngagement(serviceUrl, requestedId, targetRepo, { assumeYes, verbose });
3596
- engagementId = joined.engagementId;
3597
- actingAccountId = joined.actingAccountId;
3598
- credential = {
3599
- serviceUrl,
3600
- token: joined.sessionToken,
3601
- mcpUrl: joined.mcpUrl,
3602
- guardUrl: joined.guardUrl,
3603
- // T-10 — optional; absent on a plane that has not configured
3604
- // CONTROL_TELEMETRY_URL yet. `writeEngagementCredential` writes '' for
3605
- // an absent value, never `undefined` verbatim.
3606
- controlTelemetryUrl: joined.controlTelemetryUrl
3607
- };
3608
- foundLine = `this repo is pinned to a shared project \u2014 joining it as you (${shortId(joined.engagementId)})`;
3609
- if (verbose) {
3610
- 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.
3611
3944
  `);
3612
3945
  }
3613
3946
  } else {
@@ -3668,18 +4001,20 @@ ${USAGE}`);
3668
4001
  [halfcycle] ${missing.length === 1 ? "it" : "them"}) and re-run, and the credential moves out of the tree.
3669
4002
  `);
3670
4003
  }
3671
- const probe = await probeMcpOrigin(credential.mcpUrl);
3672
- if (probe.reached) {
3673
- if (verbose) {
3674
- 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})
3675
4009
  `);
3676
- }
3677
- } else {
3678
- 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}.
3679
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.
3680
4014
  `);
4015
+ }
3681
4016
  }
3682
- if (verbose) {
4017
+ if (credential && verbose) {
3683
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.
3684
4019
  `);
3685
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.
@@ -3687,30 +4022,38 @@ ${USAGE}`);
3687
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).
3688
4023
  `);
3689
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()}
3690
4029
  `);
3691
4030
  }
3692
4031
  reportClaudeCodeVersion(verbose);
3693
- const identity = await describeAccount(credential.serviceUrl, credential.token);
4032
+ const identity = credential ? await describeAccount(credential.serviceUrl, credential.token) : void 0;
3694
4033
  const written = result.writtenPaths.length;
3695
4034
  const merged = result.mergedPaths.length;
3696
4035
  process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId, verbose)}
3697
4036
  `);
3698
4037
  process.stdout.write(`[halfcycle] Found: ${foundLine}
3699
4038
  `);
3700
- 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"}
3701
4040
  `);
3702
- 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
3703
4043
  `);
3704
- 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.
3705
4045
  `);
3706
- try {
3707
- const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
3708
- 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}
3709
4051
  `);
3710
- 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}
3711
4053
  `);
3712
- } catch {
3713
- 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
+ }
3714
4057
  }
3715
4058
  process.exit(0);
3716
4059
  } catch (err) {
@@ -3755,30 +4098,31 @@ ${USAGE}`);
3755
4098
  if (cmd === "build-record") {
3756
4099
  const phaseArg = rest[0];
3757
4100
  const repoRoot = rest.includes("--repo") ? rest[rest.indexOf("--repo") + 1] : process.cwd();
3758
- if (!phaseArg || !/^\d+$/.test(phaseArg)) {
3759
- process.stderr.write("halfcycle-bundle: usage: halfcycle-bundle build-record <phase-id> [--repo <root>]\n");
4101
+ if (phaseArg === void 0) {
4102
+ process.stderr.write("halfcycle: usage: halfcycle build-record <phase> [--repo <root>]\n");
3760
4103
  process.exit(1);
3761
4104
  return;
3762
4105
  }
3763
- const phaseId = Number(phaseArg);
4106
+ const phaseId = phaseArg;
3764
4107
  try {
3765
- const inputPath = join10(repoRoot, ".workbench", "build-record", `phase-${phaseId}.input.json`);
4108
+ assertValidPhaseIdentity(phaseId, BUILD_RECORD_DIR);
4109
+ const inputPath = join11(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phaseId)}.input.json`);
3766
4110
  const input = JSON.parse(readFileSync9(inputPath, "utf-8"));
3767
4111
  const result = assemblePhaseBuildRecord({
3768
4112
  repoRoot,
3769
- phasesDir: join10(repoRoot, "docs", "phases"),
3770
- guardEvalLogDir: input.guardEvalLogDir ?? join10(repoRoot, ".workbench", "guard-eval-log"),
4113
+ phasesDir: join11(repoRoot, "docs", "phases"),
4114
+ guardEvalLogDir: input.guardEvalLogDir ?? join11(repoRoot, ".workbench", "guard-eval-log"),
3771
4115
  phaseId,
3772
4116
  narrated: input.narrated,
3773
4117
  touchedInvariants: input.touchedInvariants
3774
4118
  });
3775
- process.stdout.write(`[halfcycle-bundle] Build Record written:
4119
+ process.stdout.write(`[halfcycle] Build Record written:
3776
4120
  ${result.jsonPath}
3777
4121
  ${result.mdPath}
3778
4122
  `);
3779
4123
  process.exit(0);
3780
4124
  } catch (err) {
3781
- process.stderr.write(`halfcycle-bundle build-record failed: ${err instanceof Error ? err.message : String(err)}
4125
+ process.stderr.write(`halfcycle build-record failed: ${err instanceof Error ? err.message : String(err)}
3782
4126
  `);
3783
4127
  process.exit(1);
3784
4128
  }
@@ -3820,8 +4164,22 @@ ${USAGE}`);
3820
4164
  process.stdout.write(`[halfcycle] This decision CROSSED: ${opened.decision.blockingFinding}
3821
4165
  `);
3822
4166
  }
4167
+ if (opened.stampFailure) {
4168
+ process.stderr.write(`halfcycle open-phase: the phase moved, but this machine's copy of it could not be written: ${opened.stampFailure.reason}
4169
+ halfcycle open-phase: until ${opened.stampFailure.path} can be written, guard evaluations here are filed under whatever phase that file still names. Fix the file and re-run this command \u2014 re-running it is safe.
4170
+ `);
4171
+ process.exit(1);
4172
+ return;
4173
+ }
3823
4174
  process.exit(0);
3824
4175
  } catch (err) {
4176
+ if (err instanceof InvalidPhaseIdentityError) {
4177
+ process.stderr.write(`halfcycle open-phase: ${err.message}
4178
+ 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.
4179
+ `);
4180
+ process.exit(2);
4181
+ return;
4182
+ }
3825
4183
  if (err instanceof OpenPhaseRefused) {
3826
4184
  process.stderr.write(`halfcycle open-phase: refused (${err.status}). ${err.message}
3827
4185
  `);
@@ -3901,15 +4259,30 @@ ${USAGE}`);
3901
4259
  verdict,
3902
4260
  findings: findingsDetail.length,
3903
4261
  findingsDetail
3904
- }, closeDecision);
4262
+ }, closeDecision, void 0, repoRoot);
3905
4263
  process.stdout.write(`[halfcycle] Phase "${phaseArg}" closed as ${verdict}${findingsDetail.length > 0 ? ` with ${findingsDetail.length} finding(s)` : ""} by ${closeDecision.actor}. The engagement is now ${closed.status}.
3906
4264
  `);
3907
4265
  if (closeDecision.decision === "override") {
3908
4266
  process.stdout.write(`[halfcycle] This close was an OVERRIDE: ${closeDecision.reason}
4267
+ `);
4268
+ }
4269
+ if (closed.closeRecord.recorded) {
4270
+ process.stdout.write(`[halfcycle] Closed at commit ${closed.closeRecord.closeCommit} (${closed.closeRecord.closedDate}). The Build Record for this phase will name it.
4271
+ `);
4272
+ } else {
4273
+ process.stderr.write(`halfcycle close-phase: the phase closed, but the commit it closed at was not recorded on this machine: ${closed.closeRecord.reason}
4274
+ halfcycle close-phase: this phase's Build Record will say the close commit and the close date were not recorded.
3909
4275
  `);
3910
4276
  }
3911
4277
  process.stdout.write(`[halfcycle] Open the next phase with: halfcycle open-phase <phase> --actor "you" --evidence "\u2026"
3912
4278
  `);
4279
+ if (closed.stampFailure) {
4280
+ process.stderr.write(`halfcycle close-phase: the phase closed, but this machine's record of it could not be cleared: ${closed.stampFailure.reason}
4281
+ halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard evaluations here are still filed under the phase you just closed.
4282
+ `);
4283
+ process.exit(1);
4284
+ return;
4285
+ }
3913
4286
  process.exit(0);
3914
4287
  } catch (err) {
3915
4288
  if (err instanceof ClosePhaseRefused) {