halfcycle 0.3.11 → 0.3.13

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
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // dist/bin.js
4
- import { execFileSync as execFileSync3 } from "node:child_process";
5
- import { readFileSync as readFileSync8 } from "node:fs";
6
- import { join as join9 } from "node:path";
4
+ import { execFileSync as execFileSync4 } from "node:child_process";
5
+ import { readFileSync as readFileSync9 } from "node:fs";
6
+ import { join as join10 } 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";
@@ -413,6 +413,7 @@ var LOOPBACK_RESULT = {
413
413
  APPROVED: "approved",
414
414
  DECLINED: "declined"
415
415
  };
416
+ var DASHBOARD_URL = "https://app.halfcycle.ai";
416
417
  function loopbackVerificationUrl(verificationUrl, target) {
417
418
  let url;
418
419
  try {
@@ -425,6 +426,16 @@ function loopbackVerificationUrl(verificationUrl, target) {
425
426
  return url.toString();
426
427
  }
427
428
 
429
+ // ../events/dist/account-identity.js
430
+ import { z as z7 } from "zod";
431
+ var accountIdentitySchema = z7.object({
432
+ accountId: z7.string().min(1),
433
+ email: z7.string().min(1).optional()
434
+ }).strict();
435
+ function safeParseAccountIdentity(payload) {
436
+ return accountIdentitySchema.safeParse(payload);
437
+ }
438
+
428
439
  // dist/engagement-credential.js
429
440
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
430
441
  import { platform as platform2 } from "node:os";
@@ -693,6 +704,13 @@ function readRootCommit(targetRepoRoot) {
693
704
  function readRemote(targetRepoRoot) {
694
705
  return git(targetRepoRoot, ["remote", "get-url", "origin"]);
695
706
  }
707
+ function readCommitCount(targetRepoRoot) {
708
+ const out = git(targetRepoRoot, ["rev-list", "--count", "HEAD"]);
709
+ if (out === null)
710
+ return null;
711
+ const count = Number.parseInt(out, 10);
712
+ return Number.isFinite(count) ? count : null;
713
+ }
696
714
  function mintOrReadIdentity(targetRepoRoot) {
697
715
  const path = join3(targetRepoRoot, ".halfcycle", "project.json");
698
716
  if (existsSync(path)) {
@@ -1323,12 +1341,24 @@ ${header}
1323
1341
  return "failed";
1324
1342
  }
1325
1343
  }
1326
- function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, writtenPaths) {
1344
+ function previousAccountId(targetRepoRoot, engagementId) {
1345
+ try {
1346
+ const existing = readBundlePin(targetRepoRoot);
1347
+ if (existing === null || existing.engagementId !== engagementId)
1348
+ return void 0;
1349
+ return existing.accountId;
1350
+ } catch {
1351
+ return void 0;
1352
+ }
1353
+ }
1354
+ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, writtenPaths) {
1355
+ const carried = accountId ?? previousAccountId(targetRepoRoot, engagementId);
1327
1356
  const pin = {
1328
1357
  version,
1329
1358
  engagementId,
1330
1359
  engagementType,
1331
- installedAt: (/* @__PURE__ */ new Date()).toISOString()
1360
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
1361
+ ...carried !== void 0 && carried.trim() !== "" ? { accountId: carried } : {}
1332
1362
  };
1333
1363
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
1334
1364
  writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
@@ -1483,7 +1513,7 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
1483
1513
  }
1484
1514
  }
1485
1515
  async function install(options) {
1486
- const { targetRepo, engagementId, engagementType, credential, home } = options;
1516
+ const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
1487
1517
  if (!existsSync3(targetRepo)) {
1488
1518
  throw new Error(`[bundle install] Target repo does not exist: ${targetRepo}`);
1489
1519
  }
@@ -1572,7 +1602,7 @@ async function install(options) {
1572
1602
  case "absent":
1573
1603
  break;
1574
1604
  }
1575
- writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, report.writtenPaths);
1605
+ writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
1576
1606
  writeCrewRoster(targetRepo, report);
1577
1607
  const scanResult = runBootstrapScan(targetRepo);
1578
1608
  return {
@@ -1598,126 +1628,6 @@ function controlOriginNote(resolved) {
1598
1628
  return resolved.source === "environment" ? `${resolved.origin} (from HALFCYCLE_SERVICE_URL)` : `${resolved.origin} (the Halfcycle plane)`;
1599
1629
  }
1600
1630
 
1601
- // dist/create-engagement.js
1602
- var CreateEngagementRefused = class extends Error {
1603
- status;
1604
- serverMessage;
1605
- errorCode;
1606
- constructor(status, serverMessage, errorCode, message) {
1607
- super(message);
1608
- this.status = status;
1609
- this.serverMessage = serverMessage;
1610
- this.errorCode = errorCode;
1611
- this.name = "CreateEngagementRefused";
1612
- }
1613
- /**
1614
- * Is this a refusal of the CALLER's credential — the arm signing in again can fix?
1615
- *
1616
- * `PublicStartDisabled` is excluded even though it is a 403, and that exclusion is
1617
- * the whole reason this is a method rather than a status comparison at the call
1618
- * site: the flag gates the DEPLOYMENT, identically for every caller, so no
1619
- * credential and no sign-in changes the answer. Treating it as an auth refusal
1620
- * would send a developer through a browser handoff to arrive at the same 403.
1621
- *
1622
- * **THIS IS THE CREATE ROUTE'S DERIVATION AND THE JOIN CALLER MUST NOT USE IT
1623
- * (T-24).** On the join route a 403 has two causes that share a status AND a
1624
- * discriminant (`error: 'Forbidden'`), so nothing on this object can tell them
1625
- * apart: the credential may be a live engagement token carrying no account, or the
1626
- * engagement id may not resolve. Signing in again cannot fix the second, and
1627
- * treating it as though it could would discard a good credential, walk the
1628
- * developer through a browser, and arrive at the identical 403 — a remedy that
1629
- * loops. `bin.ts`'s join path therefore retries on `401` ALONE, at the call site,
1630
- * where the reason can be written down. See `joinPinnedEngagement` there.
1631
- */
1632
- get authRefused() {
1633
- if (this.errorCode === "PublicStartDisabled")
1634
- return false;
1635
- return this.status === 401 || this.status === 403;
1636
- }
1637
- };
1638
- function planeRefusal(detail) {
1639
- let body;
1640
- try {
1641
- body = JSON.parse(detail);
1642
- } catch {
1643
- return void 0;
1644
- }
1645
- if (typeof body !== "object" || body === null)
1646
- return void 0;
1647
- const candidate = body;
1648
- if (typeof candidate.message !== "string" || candidate.message.trim() === "")
1649
- return void 0;
1650
- const hasDiscriminant = typeof candidate.error === "string" || typeof candidate.statusCode === "number" || typeof candidate.status === "string";
1651
- if (!hasDiscriminant)
1652
- return void 0;
1653
- return {
1654
- message: candidate.message,
1655
- error: typeof candidate.error === "string" ? candidate.error : void 0
1656
- };
1657
- }
1658
- 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.`;
1659
- async function createEngagement(baseUrl, name, credential) {
1660
- return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements`, name ? { name } : {}, credential, { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" });
1661
- }
1662
- async function joinEngagement(baseUrl, engagementId, credential) {
1663
- return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/join`, {}, credential, {
1664
- action: "Join-engagement",
1665
- nothingHappened: "No credential was minted",
1666
- route: "POST /engagements/:engagementId/join"
1667
- });
1668
- }
1669
- async function requestEngagementValues(url, requestBody, credential, shape) {
1670
- const bearer = credential?.trim();
1671
- let res;
1672
- try {
1673
- res = await fetch(url, {
1674
- method: "POST",
1675
- headers: {
1676
- "content-type": "application/json",
1677
- ...bearer ? { authorization: `Bearer ${bearer}` } : {}
1678
- },
1679
- body: JSON.stringify(requestBody)
1680
- });
1681
- } catch (err) {
1682
- throw new Error(`[bundle install] Could not reach the Halfcycle control plane at ${url}: ${err instanceof Error ? err.message : String(err)}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
1683
- }
1684
- if (!res.ok) {
1685
- const detail = await res.text().catch(() => "");
1686
- const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
1687
- if (refusal) {
1688
- throw new CreateEngagementRefused(res.status, refusal.message, refusal.error, `[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${refusal.message} ${shape.nothingHappened}.`);
1689
- }
1690
- const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the control plane. ` : "";
1691
- throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
1692
- }
1693
- const body = await res.json().catch(() => null);
1694
- if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
1695
- throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
1696
- }
1697
- for (const [field, value] of [
1698
- ["engagementId", body.engagementId],
1699
- ["sessionToken", body.sessionToken]
1700
- ]) {
1701
- if (value.trim() === "") {
1702
- throw new Error(`[bundle install] The Halfcycle plane at ${url} answered with a blank ${field} (the field is present but empty). An install cannot proceed on it: it would be written into this engagement's credential store, read back as a configured value, and every guard evaluation would fail against it for the life of the engagement. ${shape.nothingHappened} and nothing was written. ` + CONTROL_ORIGIN_HINT);
1703
- }
1704
- }
1705
- if (typeof body.mcpUrl !== "string" || body.mcpUrl.trim() === "") {
1706
- throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its MCP server lives (no mcpUrl on the response). Without that address the install would write an MCP registration pointing nowhere, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
1707
- }
1708
- if (typeof body.guardUrl !== "string" || body.guardUrl.trim() === "") {
1709
- throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its guard service lives (no guardUrl on the response). Without that address the install would wire a guard hook that evaluates nothing, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
1710
- }
1711
- const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
1712
- return {
1713
- engagementId: body.engagementId,
1714
- sessionToken: body.sessionToken,
1715
- mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
1716
- guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
1717
- ...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
1718
- };
1719
- }
1720
-
1721
1631
  // dist/device-signin.js
1722
1632
  import { spawn } from "node:child_process";
1723
1633
  import { platform as platform3 } from "node:os";
@@ -1728,8 +1638,10 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
1728
1638
  var LOOPBACK_PORT_ENV = "HALFCYCLE_LOOPBACK_PORT";
1729
1639
  var DEFAULT_STILL_WAITING_MS = 15e3;
1730
1640
  function completionPage(result) {
1731
- const headline = result === LOOPBACK_RESULT.APPROVED ? "Signed in. You can close this tab and return to your terminal." : "Sign-in declined. Nothing was created. You can close this tab.";
1732
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Halfcycle</title></head><body style="font:16px/1.5 system-ui;padding:3rem"><p>${headline}</p></body></html>`;
1641
+ const approved = result === LOOPBACK_RESULT.APPROVED;
1642
+ const headline = approved ? "Signed in. You can close this tab and return to your terminal." : "Sign-in declined. Nothing was created. You can close this tab.";
1643
+ const successLink = approved ? `<p><a href="${DASHBOARD_URL}" style="color:#c8a24b">Go to your projects</a></p>` : "";
1644
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Halfcycle</title></head><body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0a0a0a;color:#fafafa;font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif"><div style="max-width:26rem;padding:2rem;text-align:center"><p>${headline}</p>${successLink}</div></body></html>`;
1733
1645
  }
1734
1646
  function isLoopbackPeer(remoteAddress) {
1735
1647
  if (remoteAddress === void 0)
@@ -1800,14 +1712,14 @@ async function bindLoopback(env = process.env) {
1800
1712
  else
1801
1713
  pending = callback;
1802
1714
  }));
1803
- const bindProblem = await new Promise((resolve2) => {
1715
+ const bindProblem = await new Promise((resolve4) => {
1804
1716
  const onError = (err) => {
1805
- resolve2(`${err.code ?? "bind failed"} on ${LOOPBACK_REDIRECT_HOST}:${wanted.port}`);
1717
+ resolve4(`${err.code ?? "bind failed"} on ${LOOPBACK_REDIRECT_HOST}:${wanted.port}`);
1806
1718
  };
1807
1719
  server.once("error", onError);
1808
1720
  server.listen({ host: LOOPBACK_REDIRECT_HOST, port: wanted.port }, () => {
1809
1721
  server.removeListener("error", onError);
1810
- resolve2(null);
1722
+ resolve4(null);
1811
1723
  });
1812
1724
  });
1813
1725
  if (bindProblem !== null) {
@@ -1823,9 +1735,9 @@ async function bindLoopback(env = process.env) {
1823
1735
  bound: true,
1824
1736
  target: { port: address.port, state },
1825
1737
  boundAddress: address.address,
1826
- waitForCallback: ({ timeoutMs, onStillWaiting, stillWaitingMs = DEFAULT_STILL_WAITING_MS }) => new Promise((resolve2) => {
1738
+ waitForCallback: ({ timeoutMs, onStillWaiting, stillWaitingMs = DEFAULT_STILL_WAITING_MS }) => new Promise((resolve4) => {
1827
1739
  if (pending !== null) {
1828
- resolve2(pending);
1740
+ resolve4(pending);
1829
1741
  return;
1830
1742
  }
1831
1743
  const startedAt = Date.now();
@@ -1835,7 +1747,7 @@ async function bindLoopback(env = process.env) {
1835
1747
  clearInterval(ticker);
1836
1748
  clearTimeout(timer);
1837
1749
  deliver = null;
1838
- resolve2(value);
1750
+ resolve4(value);
1839
1751
  };
1840
1752
  const timer = setTimeout(() => finish(null), Math.max(0, timeoutMs));
1841
1753
  deliver = finish;
@@ -1844,9 +1756,9 @@ async function bindLoopback(env = process.env) {
1844
1756
  };
1845
1757
  }
1846
1758
  function closeServer(server) {
1847
- return new Promise((resolve2) => {
1759
+ return new Promise((resolve4) => {
1848
1760
  server.closeAllConnections();
1849
- server.close(() => resolve2());
1761
+ server.close(() => resolve4());
1850
1762
  });
1851
1763
  }
1852
1764
 
@@ -1868,7 +1780,7 @@ function defaultWrite(text) {
1868
1780
  process.stdout.write(text);
1869
1781
  }
1870
1782
  function defaultSleep(ms) {
1871
- return new Promise((resolve2) => setTimeout(resolve2, ms));
1783
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
1872
1784
  }
1873
1785
  function browserOpenCommand(os, url) {
1874
1786
  if (os === "darwin")
@@ -1894,12 +1806,12 @@ async function openBrowser(url, env = process.env) {
1894
1806
  return { opened: false, reason: "the sign-in address is not an http(s) URL this CLI will open" };
1895
1807
  }
1896
1808
  const { command, args: args2 } = browserOpenCommand(platform3(), url);
1897
- return new Promise((resolve2) => {
1809
+ return new Promise((resolve4) => {
1898
1810
  let child;
1899
1811
  try {
1900
1812
  child = spawn(command, args2, { stdio: "ignore", env });
1901
1813
  } catch (err) {
1902
- resolve2({
1814
+ resolve4({
1903
1815
  opened: false,
1904
1816
  reason: `${command} could not be run (${err instanceof Error ? err.message : String(err)})`
1905
1817
  });
@@ -1911,7 +1823,7 @@ async function openBrowser(url, env = process.env) {
1911
1823
  return;
1912
1824
  settled = true;
1913
1825
  clearTimeout(timer);
1914
- resolve2(attempt);
1826
+ resolve4(attempt);
1915
1827
  };
1916
1828
  const timer = setTimeout(() => {
1917
1829
  child.unref();
@@ -2131,6 +2043,134 @@ async function deviceCodeSignIn(serviceUrl, started, env, deps) {
2131
2043
  };
2132
2044
  }
2133
2045
 
2046
+ // dist/create-engagement.js
2047
+ var CreateEngagementRefused = class extends Error {
2048
+ status;
2049
+ serverMessage;
2050
+ errorCode;
2051
+ constructor(status, serverMessage, errorCode, message) {
2052
+ super(message);
2053
+ this.status = status;
2054
+ this.serverMessage = serverMessage;
2055
+ this.errorCode = errorCode;
2056
+ this.name = "CreateEngagementRefused";
2057
+ }
2058
+ /**
2059
+ * Is this a refusal of the CALLER's credential — the arm signing in again can fix?
2060
+ *
2061
+ * `PublicStartDisabled` is excluded even though it is a 403, and that exclusion is
2062
+ * the whole reason this is a method rather than a status comparison at the call
2063
+ * site: the flag gates the DEPLOYMENT, identically for every caller, so no
2064
+ * credential and no sign-in changes the answer. Treating it as an auth refusal
2065
+ * would send a developer through a browser handoff to arrive at the same 403.
2066
+ *
2067
+ * **THIS IS THE CREATE ROUTE'S DERIVATION AND THE JOIN CALLER MUST NOT USE IT
2068
+ * (T-24).** On the join route a 403 has two causes that share a status AND a
2069
+ * discriminant (`error: 'Forbidden'`), so nothing on this object can tell them
2070
+ * apart: the credential may be a live engagement token carrying no account, or the
2071
+ * engagement id may not resolve. Signing in again cannot fix the second, and
2072
+ * treating it as though it could would discard a good credential, walk the
2073
+ * developer through a browser, and arrive at the identical 403 — a remedy that
2074
+ * loops. `bin.ts`'s join path therefore retries on `401` ALONE, at the call site,
2075
+ * where the reason can be written down. See `joinPinnedEngagement` there.
2076
+ */
2077
+ get authRefused() {
2078
+ if (this.errorCode === "PublicStartDisabled")
2079
+ return false;
2080
+ return this.status === 401 || this.status === 403;
2081
+ }
2082
+ };
2083
+ function planeRefusal(detail) {
2084
+ let body;
2085
+ try {
2086
+ body = JSON.parse(detail);
2087
+ } catch {
2088
+ return void 0;
2089
+ }
2090
+ if (typeof body !== "object" || body === null)
2091
+ return void 0;
2092
+ const candidate = body;
2093
+ if (typeof candidate.message !== "string" || candidate.message.trim() === "")
2094
+ return void 0;
2095
+ const hasDiscriminant = typeof candidate.error === "string" || typeof candidate.statusCode === "number" || typeof candidate.status === "string";
2096
+ if (!hasDiscriminant)
2097
+ return void 0;
2098
+ return {
2099
+ message: candidate.message,
2100
+ error: typeof candidate.error === "string" ? candidate.error : void 0
2101
+ };
2102
+ }
2103
+ 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.`;
2104
+ async function createEngagement(baseUrl, name, credential, derivedName) {
2105
+ return requestEngagementValues(
2106
+ `${baseUrl.replace(/\/+$/, "")}/engagements`,
2107
+ // A stated name wins over a guess, and the body carries at most one of them —
2108
+ // the plane applies the same ordering, so the two cannot disagree about which
2109
+ // one a row was named from.
2110
+ name ? { name } : derivedName ? { derivedName } : {},
2111
+ credential,
2112
+ { action: "Create-engagement", nothingHappened: "No engagement was created", route: "POST /engagements" }
2113
+ );
2114
+ }
2115
+ async function joinEngagement(baseUrl, engagementId, credential) {
2116
+ return requestEngagementValues(`${baseUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/join`, {}, credential, {
2117
+ action: "Join-engagement",
2118
+ nothingHappened: "No credential was minted",
2119
+ route: "POST /engagements/:engagementId/join"
2120
+ });
2121
+ }
2122
+ async function requestEngagementValues(url, requestBody, credential, shape) {
2123
+ const bearer = credential?.trim();
2124
+ let res;
2125
+ try {
2126
+ res = await fetch(url, {
2127
+ method: "POST",
2128
+ headers: {
2129
+ "content-type": "application/json",
2130
+ ...bearer ? { authorization: `Bearer ${bearer}` } : {}
2131
+ },
2132
+ body: JSON.stringify(requestBody)
2133
+ });
2134
+ } catch (err) {
2135
+ throw new Error(`[bundle install] Could not reach the Halfcycle control plane at ${url}: ${err instanceof Error ? err.message : String(err)}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
2136
+ }
2137
+ if (!res.ok) {
2138
+ const detail = await res.text().catch(() => "");
2139
+ const refusal = res.status === 404 ? void 0 : planeRefusal(detail);
2140
+ 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}.`);
2142
+ }
2143
+ const wrongOrigin = res.status === 404 ? `That address answered, but it does not serve ${shape.route} \u2014 so it is not the control plane. ` : "";
2144
+ throw new Error(`[bundle install] ${shape.action} failed: ${url} returned ${res.status}. ${wrongOrigin}${detail ? `Response: ${detail.slice(0, 300)}. ` : ""}${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
2145
+ }
2146
+ const body = await res.json().catch(() => null);
2147
+ if (!body || typeof body.engagementId !== "string" || typeof body.sessionToken !== "string") {
2148
+ throw new Error(`[bundle install] ${shape.action} response from ${url} did not carry {engagementId, sessionToken}. ${shape.nothingHappened}. ` + CONTROL_ORIGIN_HINT);
2149
+ }
2150
+ for (const [field, value] of [
2151
+ ["engagementId", body.engagementId],
2152
+ ["sessionToken", body.sessionToken]
2153
+ ]) {
2154
+ if (value.trim() === "") {
2155
+ throw new Error(`[bundle install] The Halfcycle plane at ${url} answered with a blank ${field} (the field is present but empty). An install cannot proceed on it: it would be written into this engagement's credential store, read back as a configured value, and every guard evaluation would fail against it for the life of the engagement. ${shape.nothingHappened} and nothing was written. ` + CONTROL_ORIGIN_HINT);
2156
+ }
2157
+ }
2158
+ if (typeof body.mcpUrl !== "string" || body.mcpUrl.trim() === "") {
2159
+ throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its MCP server lives (no mcpUrl on the response). Without that address the install would write an MCP registration pointing nowhere, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
2160
+ }
2161
+ if (typeof body.guardUrl !== "string" || body.guardUrl.trim() === "") {
2162
+ throw new Error(`[bundle install] The Halfcycle plane at ${url} created an engagement but did not say where its guard service lives (no guardUrl on the response). Without that address the install would wire a guard hook that evaluates nothing, so nothing was written. Either that plane is older than this installer, or ${url} is not a Halfcycle control plane.`);
2163
+ }
2164
+ const controlTelemetryUrl = typeof body.controlTelemetryUrl === "string" && body.controlTelemetryUrl.trim() !== "" ? body.controlTelemetryUrl.trim().replace(/\/+$/, "") : void 0;
2165
+ return {
2166
+ engagementId: body.engagementId,
2167
+ sessionToken: body.sessionToken,
2168
+ mcpUrl: body.mcpUrl.trim().replace(/\/+$/, ""),
2169
+ guardUrl: body.guardUrl.trim().replace(/\/+$/, ""),
2170
+ ...controlTelemetryUrl !== void 0 ? { controlTelemetryUrl } : {}
2171
+ };
2172
+ }
2173
+
2134
2174
  // dist/resolve-credential.js
2135
2175
  async function obtainCredential(serviceUrl, opts = {}) {
2136
2176
  const env = opts.env ?? process.env;
@@ -2139,8 +2179,16 @@ async function obtainCredential(serviceUrl, opts = {}) {
2139
2179
  return { credential: fromEnv, source: "environment" };
2140
2180
  const stored = readStoredCredential(serviceUrl, opts.home);
2141
2181
  if (stored) {
2142
- return { credential: stored.credential, source: "stored", accountId: stored.accountId };
2182
+ return {
2183
+ credential: stored.credential,
2184
+ source: "stored",
2185
+ accountId: stored.accountId,
2186
+ signedInAt: stored.signedInAt
2187
+ };
2143
2188
  }
2189
+ return signInAndStore(serviceUrl, opts);
2190
+ }
2191
+ async function signInAndStore(serviceUrl, opts) {
2144
2192
  const signedIn = await signIn(serviceUrl, opts);
2145
2193
  writeStoredCredential(serviceUrl, {
2146
2194
  accountId: signedIn.accountId,
@@ -2153,21 +2201,15 @@ async function obtainCredential(serviceUrl, opts = {}) {
2153
2201
  accountId: signedIn.accountId
2154
2202
  };
2155
2203
  }
2204
+ async function switchAccount(serviceUrl, opts = {}) {
2205
+ forgetStoredCredential(serviceUrl, opts.home);
2206
+ return signInAndStore(serviceUrl, opts);
2207
+ }
2156
2208
  async function replaceRefusedCredential(serviceUrl, refused, opts = {}) {
2157
2209
  if (refused.source !== "stored")
2158
2210
  return null;
2159
2211
  forgetStoredCredential(serviceUrl, opts.home);
2160
- const signedIn = await signIn(serviceUrl, opts);
2161
- writeStoredCredential(serviceUrl, {
2162
- accountId: signedIn.accountId,
2163
- credential: signedIn.credential,
2164
- expiresAt: signedIn.expiresAt
2165
- }, opts.home);
2166
- return {
2167
- credential: signedIn.credential,
2168
- source: "browser-handoff",
2169
- accountId: signedIn.accountId
2170
- };
2212
+ return signInAndStore(serviceUrl, opts);
2171
2213
  }
2172
2214
  function refusedCredentialRemedy(source) {
2173
2215
  switch (source) {
@@ -2180,6 +2222,238 @@ function refusedCredentialRemedy(source) {
2180
2222
  }
2181
2223
  }
2182
2224
 
2225
+ // dist/confirm-identity.js
2226
+ import { createInterface } from "node:readline/promises";
2227
+ var IDENTITY_TIMEOUT_MS = 5e3;
2228
+ var IDENTITY_DECLINED = "identity-declined";
2229
+ async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
2230
+ const url = `${serviceUrl.replace(/\/+$/, "")}/account`;
2231
+ const controller = new AbortController();
2232
+ const timer = setTimeout(() => controller.abort(), IDENTITY_TIMEOUT_MS);
2233
+ try {
2234
+ const res = await fetchImpl(url, {
2235
+ headers: { authorization: `Bearer ${credential}` },
2236
+ signal: controller.signal
2237
+ });
2238
+ if (!res.ok)
2239
+ return void 0;
2240
+ const parsed = safeParseAccountIdentity(await res.json());
2241
+ return parsed.success ? parsed.data : void 0;
2242
+ } catch {
2243
+ return void 0;
2244
+ } finally {
2245
+ clearTimeout(timer);
2246
+ }
2247
+ }
2248
+ function accountLabel(identity, storedAccountId) {
2249
+ const accountId = identity?.accountId ?? storedAccountId;
2250
+ if (identity?.email !== void 0 && identity.email.trim() !== "") {
2251
+ return accountId !== void 0 ? `${identity.email} (account ${accountId})` : identity.email;
2252
+ }
2253
+ if (accountId !== void 0)
2254
+ return `account ${accountId} (no email on record for it)`;
2255
+ return "the account this credential belongs to, which this plane did not name";
2256
+ }
2257
+ function onlyDate(iso) {
2258
+ if (iso === void 0)
2259
+ return void 0;
2260
+ const match = /^(\d{4}-\d{2}-\d{2})/.exec(iso);
2261
+ return match?.[1] ?? iso;
2262
+ }
2263
+ function actClause(act) {
2264
+ return act.kind === "create" ? `create a NEW project for that account, and install into ${act.targetRepo}` : `join the project this repository is pinned to (${act.engagementId}) as that account, and install into ${act.targetRepo}`;
2265
+ }
2266
+ async function defaultAsk(question) {
2267
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2268
+ try {
2269
+ return await rl.question(question);
2270
+ } finally {
2271
+ rl.close();
2272
+ process.stdin.pause();
2273
+ }
2274
+ }
2275
+ function readDecision(answer) {
2276
+ const said = answer.trim().toLowerCase();
2277
+ if (said === "y" || said === "yes")
2278
+ return "proceed";
2279
+ if (said === "s" || said === "switch")
2280
+ return "switch-account";
2281
+ return "stop";
2282
+ }
2283
+ function actingAs(credential, identity) {
2284
+ const accountId = identity?.accountId ?? credential.accountId;
2285
+ return accountId === void 0 ? credential : { ...credential, accountId };
2286
+ }
2287
+ function armScript(credential, act, label, home) {
2288
+ switch (credential.source) {
2289
+ case "browser-handoff":
2290
+ return {
2291
+ preamble: `[halfcycle] Signed in as ${label}. This install will ${actClause(act)}.
2292
+ `,
2293
+ asks: false,
2294
+ noTerminalNote: "",
2295
+ switchOpening: "",
2296
+ stopRemedy: "",
2297
+ afterSwitch: ""
2298
+ };
2299
+ case "environment":
2300
+ return {
2301
+ preamble: `[halfcycle] HALFCYCLE_TOKEN is set in this environment, and this install will use it.
2302
+ [halfcycle] Account: ${label}
2303
+ [halfcycle] About to ${actClause(act)}
2304
+ [halfcycle] A variable exported by a shell profile is not a choice about this run \u2014 if that is not your account, the project will belong to it and not to you.
2305
+ `,
2306
+ asks: true,
2307
+ // Deliberately nothing. This is the arm's DESIGNED case — a run with no browser
2308
+ // and no human — the preamble has already named the account and the variable,
2309
+ // and a line about having nobody to ask would print on every CI job forever.
2310
+ noTerminalNote: "",
2311
+ switchOpening: `[halfcycle] Signing you in through your browser \u2014 this install will use that account instead.
2312
+ `,
2313
+ stopRemedy: `Stopped before anything was created \u2014 nothing was created and nothing was written. HALFCYCLE_TOKEN in this environment is the credential for ${label}, and it takes precedence over any saved sign-in \u2014 so unset it, or set it to your own account's credential, before running this again.`,
2314
+ // The switch is real for THIS run (the new credential is what gets used) and it
2315
+ // does not persist: `obtainCredential` reads the variable first, so the next run
2316
+ // is the same variable again. Said here rather than discovered tomorrow.
2317
+ afterSwitch: `[halfcycle] HALFCYCLE_TOKEN is still set in this environment and still wins over a saved sign-in \u2014 unset it, or the next run will use it again.
2318
+ `
2319
+ };
2320
+ case "stored": {
2321
+ const savedOn = onlyDate(credential.signedInAt);
2322
+ return {
2323
+ preamble: `[halfcycle] This machine already has a Halfcycle sign-in saved, and this install will use it.
2324
+ [halfcycle] Account: ${label}
2325
+ ` + (savedOn !== void 0 ? `[halfcycle] Saved on this machine: ${savedOn}
2326
+ ` : "") + `[halfcycle] About to ${actClause(act)}
2327
+ [halfcycle] If that is not your account, the project will belong to it and not to you \u2014 you would not see the project in your own Halfcycle.
2328
+ `,
2329
+ asks: true,
2330
+ // Not a question nobody will read — a record of what happened, the two ways to
2331
+ // make the next run act as somebody else (both executable from exactly the state
2332
+ // this is printed in), and where to look for the answer once this output is gone.
2333
+ noTerminalNote: `[halfcycle] Nothing here to ask at (no terminal), so this is continuing as that account.
2334
+ [halfcycle] To run as a different one, set HALFCYCLE_TOKEN for that account, or remove ${accountStorePath(home)} and run this again with a terminal to sign in.
2335
+ [halfcycle] When this install finishes, which account it used is recorded in .halfcycle/bundle.json, so it can be checked after this output is gone.
2336
+ `,
2337
+ switchOpening: `[halfcycle] Forgetting this machine's saved sign-in and signing you in through your browser.
2338
+ `,
2339
+ stopRemedy: `Stopped before anything was created \u2014 nothing was created and nothing was written. This machine's saved sign-in is for ${label}. Run this again and answer "s" to sign in as a different account, or remove ${accountStorePath(home)} to be asked for a sign-in next time.`,
2340
+ afterSwitch: ""
2341
+ };
2342
+ }
2343
+ }
2344
+ }
2345
+ async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
2346
+ const write = deps.write ?? ((text) => process.stdout.write(text));
2347
+ const identity = await describeAccount(serviceUrl, credential.credential, deps.fetchImpl);
2348
+ const label = accountLabel(identity, credential.accountId);
2349
+ const acting = actingAs(credential, identity);
2350
+ const script = armScript(credential, act, label, deps.home);
2351
+ write(script.preamble);
2352
+ if (!script.asks)
2353
+ return acting;
2354
+ if (deps.assumeYes === true) {
2355
+ write(`[halfcycle] Continuing as that account without asking, because --yes was given.
2356
+ `);
2357
+ return acting;
2358
+ }
2359
+ const interactive = deps.interactive ?? (process.stdin.isTTY === true && !isNonInteractive(deps.env));
2360
+ if (!interactive) {
2361
+ if (script.noTerminalNote !== "")
2362
+ write(script.noTerminalNote);
2363
+ return acting;
2364
+ }
2365
+ const ask = deps.ask ?? defaultAsk;
2366
+ const decision = readDecision(await ask(`[halfcycle] Continue as that account? [y] yes [s] sign in as someone else [anything else] stop: `));
2367
+ if (decision === "proceed")
2368
+ return acting;
2369
+ if (decision === "switch-account") {
2370
+ write(script.switchOpening);
2371
+ const replacement = await switchAccount(serviceUrl, deps);
2372
+ const replacementIdentity = await describeAccount(serviceUrl, replacement.credential, deps.fetchImpl);
2373
+ write(`[halfcycle] Signed in as ${accountLabel(replacementIdentity, replacement.accountId)}. This install will ${actClause(act)}.
2374
+ `);
2375
+ if (script.afterSwitch !== "")
2376
+ write(script.afterSwitch);
2377
+ return actingAs(replacement, replacementIdentity);
2378
+ }
2379
+ throw new SignInRefused(IDENTITY_DECLINED, script.stopRemedy);
2380
+ }
2381
+
2382
+ // dist/project-name.js
2383
+ import { basename, resolve } from "node:path";
2384
+ var MAX_DERIVED_NAME_LEN = 100;
2385
+ var EMPTY_SEGMENTS = /* @__PURE__ */ new Set(["", ".", "..", "/", "\\"]);
2386
+ function repositoryNameFromRemote(remote) {
2387
+ const trimmed = remote.trim();
2388
+ if (trimmed.length === 0)
2389
+ return void 0;
2390
+ const withoutTrailingSlash = trimmed.replace(/\/+$/, "");
2391
+ const lastSeparator = Math.max(withoutTrailingSlash.lastIndexOf("/"), withoutTrailingSlash.lastIndexOf(":"));
2392
+ const segment = withoutTrailingSlash.slice(lastSeparator + 1);
2393
+ const name = segment.replace(/\.git$/i, "").trim();
2394
+ return name.length > 0 ? name : void 0;
2395
+ }
2396
+ function deriveProjectName(targetRepoRoot) {
2397
+ const remote = readRemote(targetRepoRoot);
2398
+ const fromRemote = remote ? repositoryNameFromRemote(remote) : void 0;
2399
+ const candidate = fromRemote ?? basename(resolve(targetRepoRoot));
2400
+ const name = candidate.trim();
2401
+ if (EMPTY_SEGMENTS.has(name))
2402
+ return void 0;
2403
+ return name.slice(0, MAX_DERIVED_NAME_LEN);
2404
+ }
2405
+
2406
+ // dist/own-engagement.js
2407
+ async function createOwnedEngagement(serviceUrl, targetRepo, deps = {}) {
2408
+ let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, deps), { kind: "create", targetRepo }, deps);
2409
+ for (; ; ) {
2410
+ try {
2411
+ return {
2412
+ ...await createEngagement(serviceUrl, void 0, credential.credential, deriveProjectName(targetRepo)),
2413
+ actingAccountId: credential.accountId
2414
+ };
2415
+ } catch (err) {
2416
+ if (!(err instanceof CreateEngagementRefused) || !err.authRefused)
2417
+ throw err;
2418
+ const replacement = await replaceRefusedCredential(serviceUrl, credential, deps);
2419
+ if (replacement === null) {
2420
+ throw new SignInRefused(`create-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No engagement was created.`);
2421
+ }
2422
+ process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
2423
+ `);
2424
+ credential = replacement;
2425
+ }
2426
+ }
2427
+ }
2428
+ var JOIN_REFUSED_LOCAL_REMEDY = "No credential was minted and nothing was written. This repository is pinned to that engagement by .halfcycle/bundle.json \u2014 check that the id in it is the one you meant. To start a NEW engagement here instead, remove that file and re-run.";
2429
+ async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps = {}) {
2430
+ let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, deps), { kind: "join", targetRepo, engagementId }, deps);
2431
+ for (; ; ) {
2432
+ try {
2433
+ return {
2434
+ ...await joinEngagement(serviceUrl, engagementId, credential.credential),
2435
+ actingAccountId: credential.accountId
2436
+ };
2437
+ } catch (err) {
2438
+ if (!(err instanceof CreateEngagementRefused))
2439
+ throw err;
2440
+ if (err.status === 403) {
2441
+ throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${JOIN_REFUSED_LOCAL_REMEDY}`);
2442
+ }
2443
+ if (err.status !== 401) {
2444
+ throw err;
2445
+ }
2446
+ const replacement = await replaceRefusedCredential(serviceUrl, credential, deps);
2447
+ if (replacement === null) {
2448
+ throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No credential was minted and nothing was written.`);
2449
+ }
2450
+ process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
2451
+ `);
2452
+ credential = replacement;
2453
+ }
2454
+ }
2455
+ }
2456
+
2183
2457
  // dist/mint-board-code.js
2184
2458
  async function mintBoardEnterCode(baseUrl, sessionToken) {
2185
2459
  const url = `${baseUrl.replace(/\/+$/, "")}/board/enter-codes`;
@@ -2512,14 +2786,14 @@ function renderBuildRecordMarkdown(record2) {
2512
2786
 
2513
2787
  // dist/build-record/write.js
2514
2788
  import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
2515
- import { join as join7, resolve, relative as relative2, isAbsolute } from "node:path";
2789
+ import { join as join7, resolve as resolve2, relative as relative2, isAbsolute } from "node:path";
2516
2790
  var BUILD_RECORD_DIR = join7("docs", "build-records");
2517
2791
  function serialiseRecordJson(record2) {
2518
2792
  return JSON.stringify(record2, null, 2) + "\n";
2519
2793
  }
2520
2794
  function writeBuildRecord(repoRoot, record2) {
2521
- const outDir = resolve(repoRoot, BUILD_RECORD_DIR);
2522
- const expected = resolve(repoRoot, BUILD_RECORD_DIR);
2795
+ const outDir = resolve2(repoRoot, BUILD_RECORD_DIR);
2796
+ const expected = resolve2(repoRoot, BUILD_RECORD_DIR);
2523
2797
  const rel = relative2(expected, outDir);
2524
2798
  if (rel !== "" || isAbsolute(rel)) {
2525
2799
  throw new Error(`[build-record] refusing to write outside ${BUILD_RECORD_DIR}`);
@@ -2792,8 +3066,351 @@ var PLACEHOLDERS = {
2792
3066
  "--evidence": '"\u2026"'
2793
3067
  };
2794
3068
 
3069
+ // dist/banner.js
3070
+ var MARK_FULL = {
3071
+ width: 32,
3072
+ discRows: 7,
3073
+ lines: [
3074
+ " :+oshhhhso+:",
3075
+ " :sdMMMMMMMMMMMMds:",
3076
+ " :hMMMMMMMMMMMMMMMMMMh:",
3077
+ " oMMMMMMMMMMMMMMMMMMMMMMo",
3078
+ " oMMMMMMMMMMMMMMMMMMMMMMMMo",
3079
+ " NMMMMMMMMMMMMMMMMMMMMMMMMN",
3080
+ " :ssssssssssssssssssssssssss:",
3081
+ " +dddddddddddddddddddddddddd+",
3082
+ " Nd dN",
3083
+ " oMy yMo",
3084
+ " oMh: :hMo",
3085
+ " :hMy- -yMh:",
3086
+ " :sdNys++--++syNds:",
3087
+ " :+oshhhhso+:"
3088
+ ]
3089
+ };
3090
+ var MARK_NARROW = {
3091
+ width: 24,
3092
+ discRows: 5,
3093
+ lines: [
3094
+ " -ohdMMMMdho-",
3095
+ " +dMMMMMMMMMMMMd+",
3096
+ " yMMMMMMMMMMMMMMMMy",
3097
+ " sMMMMMMMMMMMMMMMMMMs",
3098
+ " yhhhhhhhhhhhhhhhhhhy",
3099
+ " NdhhhhhhhhhhhhhhhhdN",
3100
+ " sd ds",
3101
+ " yd: :dy",
3102
+ " +dy+ +yd+",
3103
+ " -ohhhhhhhho-"
3104
+ ]
3105
+ };
3106
+ var UNICODE_GLYPHS = {
3107
+ rule: "\u2500",
3108
+ swatch: "\u2584",
3109
+ dot: "\xB7",
3110
+ dash: "\u2014"
3111
+ };
3112
+ var ASCII_GLYPHS = {
3113
+ rule: "-",
3114
+ swatch: "#",
3115
+ dot: "|",
3116
+ dash: "-"
3117
+ };
3118
+ function toAscii(text) {
3119
+ let out = text;
3120
+ for (const key of Object.keys(UNICODE_GLYPHS)) {
3121
+ out = out.split(UNICODE_GLYPHS[key]).join(ASCII_GLYPHS[key]);
3122
+ }
3123
+ return out.replace(/[^ -~]/g, "?");
3124
+ }
3125
+ var INK = {
3126
+ plain: "",
3127
+ dim: "2",
3128
+ bold: "1",
3129
+ /** Yellow — the nearest ANSI seat for the brand's gold, used for labels and the URL. */
3130
+ key: "33",
3131
+ keyBold: "33;1"
3132
+ };
3133
+ var SWATCH_SGR = ["90", "31", "32", "33", "34", "35", "36", "37"];
3134
+ var ESC = "\x1B";
3135
+ var RESET = `${ESC}[0m`;
3136
+ function paint(segments, colour) {
3137
+ return segments.map((s) => colour && s.sgr !== "" ? `${ESC}[${s.sgr}m${s.text}${RESET}` : s.text).join("");
3138
+ }
3139
+ var GAP = 4;
3140
+ var FULL_LAYOUT = { art: MARK_FULL, label: 13, rule: 28, palette: true, minColumns: 76 };
3141
+ var NARROW_LAYOUT = { art: MARK_NARROW, label: 10, rule: 20, palette: false, minColumns: 48 };
3142
+ var DEFAULT_COLUMNS = 80;
3143
+ function usableColumns(environment) {
3144
+ const columns = environment.columns;
3145
+ return columns !== void 0 && columns > 0 ? columns : DEFAULT_COLUMNS;
3146
+ }
3147
+ function isSet(value) {
3148
+ return value !== void 0 && value !== "";
3149
+ }
3150
+ function plainTextRequested(env) {
3151
+ return isSet(env["NO_COLOR"]) || env["TERM"] === "dumb";
3152
+ }
3153
+ function decideCut(environment = {}) {
3154
+ const env = environment.env ?? {};
3155
+ if (environment.quiet === true)
3156
+ return "none";
3157
+ if (environment.isTTY !== true)
3158
+ return "line";
3159
+ if (plainTextRequested(env))
3160
+ return "line";
3161
+ const columns = usableColumns(environment);
3162
+ if (columns >= FULL_LAYOUT.minColumns)
3163
+ return "full";
3164
+ if (columns >= NARROW_LAYOUT.minColumns)
3165
+ return "narrow";
3166
+ return "line";
3167
+ }
3168
+ function decideColour(environment = {}) {
3169
+ const env = environment.env ?? {};
3170
+ const force = env["FORCE_COLOR"];
3171
+ if (isSet(force))
3172
+ return force !== "0";
3173
+ if (plainTextRequested(env))
3174
+ return false;
3175
+ return environment.isTTY === true;
3176
+ }
3177
+ function decideUnicode(environment = {}) {
3178
+ const env = environment.env ?? {};
3179
+ if ((environment.platform ?? "") !== "win32") {
3180
+ const locale = env["LC_ALL"] ?? env["LC_CTYPE"] ?? env["LANG"] ?? "";
3181
+ return locale === "" || /utf-?8/i.test(locale);
3182
+ }
3183
+ return isSet(env["WT_SESSION"]) || isSet(env["TERMINUS_SUBLIME"]) || env["ConEmuTask"] === "{cmd::Cmder}" || env["TERM_PROGRAM"] === "vscode";
3184
+ }
3185
+ function padEnd(text, width) {
3186
+ return text + " ".repeat(Math.max(0, width - text.length));
3187
+ }
3188
+ function clip(text, width) {
3189
+ if (width < 8 || text.length <= width)
3190
+ return text;
3191
+ return `${text.slice(0, width - 3)}...`;
3192
+ }
3193
+ function paletteRow(swatch) {
3194
+ const row = [];
3195
+ SWATCH_SGR.forEach((sgr, i) => {
3196
+ if (i > 0)
3197
+ row.push({ text: " ", sgr: INK.plain });
3198
+ row.push({ text: swatch.repeat(2), sgr });
3199
+ });
3200
+ return row;
3201
+ }
3202
+ function factColumn(content, layout, glyphs, text, valueWidth) {
3203
+ const rows = [];
3204
+ const head = text(content.head);
3205
+ const at = head.indexOf("@");
3206
+ rows.push(at < 0 ? [{ text: head, sgr: INK.keyBold }] : [
3207
+ { text: head.slice(0, at), sgr: INK.keyBold },
3208
+ { text: "@", sgr: INK.dim },
3209
+ { text: head.slice(at + 1), sgr: INK.bold }
3210
+ ]);
3211
+ rows.push([{ text: glyphs.rule.repeat(layout.rule), sgr: INK.dim }]);
3212
+ const narrow = layout.art === MARK_NARROW;
3213
+ for (const fact2 of content.facts) {
3214
+ const raw = narrow ? fact2.narrow : fact2.value;
3215
+ if (raw === void 0)
3216
+ continue;
3217
+ rows.push([
3218
+ { text: padEnd(`${text(fact2.label)}:`, layout.label), sgr: INK.key },
3219
+ { text: clip(text(raw), valueWidth), sgr: INK.plain }
3220
+ ]);
3221
+ }
3222
+ if (layout.palette) {
3223
+ rows.push([]);
3224
+ rows.push(paletteRow(glyphs.swatch));
3225
+ }
3226
+ return rows;
3227
+ }
3228
+ function renderBlock(content, layout, environment) {
3229
+ const unicode = decideUnicode(environment);
3230
+ const colour = decideColour(environment);
3231
+ const glyphs = unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
3232
+ const text = (raw) => unicode ? raw : toAscii(raw);
3233
+ const art = layout.art;
3234
+ const valueWidth = usableColumns(environment) - (art.width + GAP + layout.label);
3235
+ const right = factColumn(content, layout, glyphs, text, valueWidth);
3236
+ const top = Math.max(0, Math.floor((art.lines.length - right.length) / 2));
3237
+ const height = Math.max(art.lines.length, right.length + top);
3238
+ const lines = [];
3239
+ for (let i = 0; i < height; i++) {
3240
+ const artLine = art.lines[i] ?? "";
3241
+ const segments = [];
3242
+ if (artLine.length > 0) {
3243
+ segments.push({ text: artLine, sgr: i < art.discRows ? INK.plain : INK.dim });
3244
+ }
3245
+ const rightRow = right[i - top];
3246
+ if (rightRow !== void 0 && rightRow.length > 0) {
3247
+ segments.push({ text: " ".repeat(Math.max(0, art.width + GAP - artLine.length)), sgr: INK.plain });
3248
+ segments.push(...rightRow);
3249
+ }
3250
+ lines.push(segments);
3251
+ }
3252
+ const tagline = layout.art === MARK_NARROW ? content.tagNarrow ?? content.tag : content.tag;
3253
+ lines.push([]);
3254
+ tagline.forEach((line, i) => {
3255
+ lines.push([{ text: ` ${text(line)}`, sgr: i === 0 ? INK.bold : INK.dim }]);
3256
+ });
3257
+ lines.push([]);
3258
+ lines.push([{ text: ` ${text(content.url)}`, sgr: INK.key }]);
3259
+ return lines.map((segments) => paint(segments, colour)).join("\n");
3260
+ }
3261
+ function renderLine(content, environment) {
3262
+ const unicode = decideUnicode(environment);
3263
+ const dot = unicode ? UNICODE_GLYPHS.dot : ASCII_GLYPHS.dot;
3264
+ const named = content.version === "" ? "Halfcycle" : `Halfcycle v${content.version}`;
3265
+ const raw = `${named} ${dot} ${content.url}`;
3266
+ return unicode ? raw : toAscii(raw);
3267
+ }
3268
+ function renderBanner(content, environment = {}) {
3269
+ switch (decideCut(environment)) {
3270
+ case "none":
3271
+ return "";
3272
+ case "line":
3273
+ return renderLine(content, environment);
3274
+ case "narrow":
3275
+ return renderBlock(content, NARROW_LAYOUT, environment);
3276
+ case "full":
3277
+ return renderBlock(content, FULL_LAYOUT, environment);
3278
+ }
3279
+ }
3280
+ function printBanner(sink, content, environment = {}) {
3281
+ const text = renderBanner(content, environment);
3282
+ if (text === "")
3283
+ return;
3284
+ sink.write(`${text}
3285
+ `);
3286
+ }
3287
+ function currentEnvironment(quiet2) {
3288
+ return {
3289
+ columns: process.stdout.columns,
3290
+ isTTY: process.stdout.isTTY === true,
3291
+ quiet: quiet2,
3292
+ env: process.env,
3293
+ platform: process.platform
3294
+ };
3295
+ }
3296
+
3297
+ // dist/banner-facts.js
3298
+ import { execFileSync as execFileSync3 } from "node:child_process";
3299
+ import { readFileSync as readFileSync8 } from "node:fs";
3300
+ import { basename as basename2, join as join9, resolve as resolve3 } from "node:path";
3301
+ var BRAND_URL = "halfcycle.ai";
3302
+ var TAGLINE = [
3303
+ "Fewer cycles.",
3304
+ "The thinking moves earlier. So does the finish."
3305
+ ];
3306
+ var TAGLINE_NARROW = [
3307
+ "Fewer cycles.",
3308
+ "The thinking moves earlier.",
3309
+ "So does the finish."
3310
+ ];
3311
+ function bundleVersion() {
3312
+ try {
3313
+ const pkg = JSON.parse(readFileSync8(join9(BUNDLE_ROOT, "package.json"), "utf-8"));
3314
+ return typeof pkg.version === "string" ? pkg.version : void 0;
3315
+ } catch {
3316
+ return void 0;
3317
+ }
3318
+ }
3319
+ function claudeCodeVersion() {
3320
+ try {
3321
+ const raw = execFileSync3("claude", ["--version"], {
3322
+ encoding: "utf-8",
3323
+ timeout: 3e3,
3324
+ stdio: ["ignore", "pipe", "ignore"]
3325
+ });
3326
+ return /(\d+\.\d+\.\d+)/.exec(raw)?.[1];
3327
+ } catch {
3328
+ return void 0;
3329
+ }
3330
+ }
3331
+ var REAL_PROBES = {
3332
+ bundleVersion,
3333
+ claudeCodeVersion,
3334
+ projectName: (dir) => {
3335
+ try {
3336
+ return deriveProjectName(dir);
3337
+ } catch {
3338
+ return void 0;
3339
+ }
3340
+ },
3341
+ commitCount: (dir) => {
3342
+ try {
3343
+ return readCommitCount(dir);
3344
+ } catch {
3345
+ return null;
3346
+ }
3347
+ },
3348
+ installedVersion: (dir) => {
3349
+ try {
3350
+ return readBundlePin(dir)?.version;
3351
+ } catch {
3352
+ return void 0;
3353
+ }
3354
+ },
3355
+ planeHost: (env) => {
3356
+ try {
3357
+ const resolved = resolveControlOrigin(env);
3358
+ return resolved.source === "default" ? BRAND_URL : new URL(resolved.origin).host;
3359
+ } catch {
3360
+ return void 0;
3361
+ }
3362
+ }
3363
+ };
3364
+ function fact(label, value, narrow) {
3365
+ if (value === void 0 || value === "")
3366
+ return void 0;
3367
+ return narrow === void 0 ? { label, value } : { label, value, narrow };
3368
+ }
3369
+ function openingBannerContent(projectDir, env = process.env, probes = REAL_PROBES) {
3370
+ const dot = UNICODE_GLYPHS.dot;
3371
+ const dash = UNICODE_GLYPHS.dash;
3372
+ const version = probes.bundleVersion();
3373
+ const name = probes.projectName(projectDir) ?? safeBasename(projectDir);
3374
+ const commits = probes.commitCount(projectDir);
3375
+ const installed = probes.installedVersion(projectDir);
3376
+ const project = commits === null ? name : `${name} ${dot} git ${dot} ${commits} ${commits === 1 ? "commit" : "commits"}`;
3377
+ const status = installed === void 0 ? `new ${dash} nothing installed yet` : `installed ${dash} v${installed}`;
3378
+ const facts = [
3379
+ fact("Version", version, version),
3380
+ fact("Claude Code", probes.claudeCodeVersion()),
3381
+ fact("Project", project, name),
3382
+ fact("Status", status, installed === void 0 ? "new" : "installed"),
3383
+ fact("Plane", probes.planeHost(env))
3384
+ ].filter((f) => f !== void 0);
3385
+ return {
3386
+ head: `halfcycle@${name}`,
3387
+ facts,
3388
+ tag: TAGLINE,
3389
+ tagNarrow: TAGLINE_NARROW,
3390
+ url: BRAND_URL,
3391
+ // `''` when even this package's own manifest could not be read. The one-line cut
3392
+ // drops the whole `vX.Y.Z` clause in that case rather than printing a bare `v`.
3393
+ version: version ?? ""
3394
+ };
3395
+ }
3396
+ function safeBasename(dir) {
3397
+ try {
3398
+ const name = basename2(resolve3(dir)).trim();
3399
+ return name === "" ? "project" : name;
3400
+ } catch {
3401
+ return "project";
3402
+ }
3403
+ }
3404
+
2795
3405
  // dist/bin.js
2796
- var args = process.argv.slice(2);
3406
+ var ASSUME_YES_FLAGS = /* @__PURE__ */ new Set(["--yes", "-y"]);
3407
+ var QUIET_FLAGS = /* @__PURE__ */ new Set(["--quiet", "-q"]);
3408
+ var VERBOSE_FLAGS = /* @__PURE__ */ new Set(["--verbose", "-v"]);
3409
+ var rawArgs = process.argv.slice(2);
3410
+ var assumeYes = rawArgs.some((a) => ASSUME_YES_FLAGS.has(a));
3411
+ var quiet = rawArgs.some((a) => QUIET_FLAGS.has(a));
3412
+ var verbose = rawArgs.some((a) => VERBOSE_FLAGS.has(a));
3413
+ var args = rawArgs.filter((a) => !ASSUME_YES_FLAGS.has(a) && !QUIET_FLAGS.has(a) && !VERBOSE_FLAGS.has(a));
2797
3414
  var MIN_HEADERS_HELPER_VERSION = "2.1.118";
2798
3415
  var MIN_HEADER_ROTATION_VERSION = "2.1.193";
2799
3416
  function compareVersions(a, b) {
@@ -2807,10 +3424,10 @@ function compareVersions(a, b) {
2807
3424
  }
2808
3425
  return 0;
2809
3426
  }
2810
- function reportClaudeCodeVersion() {
3427
+ function reportClaudeCodeVersion(verbose2) {
2811
3428
  let raw;
2812
3429
  try {
2813
- raw = execFileSync3("claude", ["--version"], {
3430
+ raw = execFileSync4("claude", ["--version"], {
2814
3431
  encoding: "utf-8",
2815
3432
  timeout: 5e3,
2816
3433
  stdio: ["ignore", "pipe", "ignore"]
@@ -2823,73 +3440,44 @@ function reportClaudeCodeVersion() {
2823
3440
  return;
2824
3441
  const version = found[1];
2825
3442
  if (compareVersions(version, MIN_HEADERS_HELPER_VERSION) >= 0) {
2826
- process.stdout.write(`[halfcycle] Claude Code ${version} detected \u2014 new enough for the credential helper.
3443
+ if (verbose2) {
3444
+ process.stdout.write(`[halfcycle] Claude Code ${version} detected \u2014 new enough for the credential helper.
2827
3445
  `);
3446
+ }
2828
3447
  return;
2829
3448
  }
2830
3449
  process.stdout.write(`[halfcycle] WARNING: Claude Code ${version} is older than ${MIN_HEADERS_HELPER_VERSION}, which ignores the credential helper this install wrote. The Halfcycle server will connect with no credential and every call will fail \u2014 upgrade Claude Code, then re-open this folder.
2831
3450
  `);
2832
3451
  }
3452
+ function shortId(id) {
3453
+ return id.length > 10 ? `${id.slice(0, 8)}\u2026` : id;
3454
+ }
2833
3455
  var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|client]
2834
3456
  install into target (default: the current directory)
3457
+ --yes / -y: use this machine's saved sign-in without being asked
2835
3458
  halfcycle check-drift <target-repo>
2836
3459
  halfcycle build-record <phase-id> [--repo <root>]
2837
3460
  halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
2838
3461
  halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
3462
+
3463
+ --quiet / -q: print no opening banner (any command)
3464
+ --verbose / -v: print full run detail (paths written, merged, skipped) on install
2839
3465
  `;
2840
3466
  function isHalfcycleMonorepo(dir) {
2841
3467
  try {
2842
- const pkg = JSON.parse(readFileSync8(join9(dir, "package.json"), "utf-8"));
3468
+ const pkg = JSON.parse(readFileSync9(join10(dir, "package.json"), "utf-8"));
2843
3469
  return pkg.name === "halfcycle-monorepo";
2844
3470
  } catch {
2845
3471
  return false;
2846
3472
  }
2847
3473
  }
2848
- async function createOwnedEngagement(serviceUrl) {
2849
- let credential = await obtainCredential(serviceUrl);
2850
- for (; ; ) {
2851
- try {
2852
- return await createEngagement(serviceUrl, void 0, credential.credential);
2853
- } catch (err) {
2854
- if (!(err instanceof CreateEngagementRefused) || !err.authRefused)
2855
- throw err;
2856
- const replacement = await replaceRefusedCredential(serviceUrl, credential);
2857
- if (replacement === null) {
2858
- throw new SignInRefused(`create-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No engagement was created.`);
2859
- }
2860
- process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
2861
- `);
2862
- credential = replacement;
2863
- }
2864
- }
2865
- }
2866
- var JOIN_REFUSED_LOCAL_REMEDY = "No credential was minted and nothing was written. This repository is pinned to that engagement by .halfcycle/bundle.json \u2014 check that the id in it is the one you meant. To start a NEW engagement here instead, remove that file and re-run.";
2867
- async function joinPinnedEngagement(serviceUrl, engagementId) {
2868
- let credential = await obtainCredential(serviceUrl);
2869
- for (; ; ) {
2870
- try {
2871
- return await joinEngagement(serviceUrl, engagementId, credential.credential);
2872
- } catch (err) {
2873
- if (!(err instanceof CreateEngagementRefused))
2874
- throw err;
2875
- if (err.status === 403) {
2876
- throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${JOIN_REFUSED_LOCAL_REMEDY}`);
2877
- }
2878
- if (err.status !== 401) {
2879
- throw err;
2880
- }
2881
- const replacement = await replaceRefusedCredential(serviceUrl, credential);
2882
- if (replacement === null) {
2883
- throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No credential was minted and nothing was written.`);
2884
- }
2885
- process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
2886
- `);
2887
- credential = replacement;
2888
- }
2889
- }
2890
- }
2891
3474
  async function main() {
2892
3475
  const [cmd, ...rest] = args;
3476
+ const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !/^(check-drift|build-record|open-phase|close-phase)$/.test(cmd);
3477
+ const installArm = cmd === "install" || cmd === void 0 || bareTarget;
3478
+ const positionals = cmd === "install" ? rest : args;
3479
+ const targetRepo = installArm ? positionals[0] ?? process.cwd() : process.cwd();
3480
+ printBanner(process.stdout, openingBannerContent(targetRepo), currentEnvironment(quiet));
2893
3481
  if (cmd === "--help" || cmd === "-h" || cmd === "help") {
2894
3482
  process.stdout.write(`halfcycle \u2014 install and drive the Halfcycle method in a repository.
2895
3483
 
@@ -2898,10 +3486,7 @@ ${USAGE}`);
2898
3486
  process.exit(0);
2899
3487
  return;
2900
3488
  }
2901
- const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !/^(check-drift|build-record|open-phase|close-phase)$/.test(cmd);
2902
- if (cmd === "install" || cmd === void 0 || bareTarget) {
2903
- const positionals = cmd === "install" ? rest : args;
2904
- const targetRepo = positionals[0] ?? process.cwd();
3489
+ if (installArm) {
2905
3490
  const engagementIdArg = positionals[1];
2906
3491
  const engagementTypeRaw = positionals[2] ?? "client";
2907
3492
  if (isHalfcycleMonorepo(targetRepo)) {
@@ -2923,18 +3508,26 @@ ${USAGE}`);
2923
3508
  try {
2924
3509
  const controlOrigin = resolveControlOrigin(process.env);
2925
3510
  const serviceUrl = controlOrigin.origin;
2926
- process.stdout.write(`[halfcycle] Halfcycle plane: ${controlOriginNote(controlOrigin)}
3511
+ if (verbose || controlOrigin.source !== "default") {
3512
+ process.stdout.write(`[halfcycle] Halfcycle plane: ${controlOriginNote(controlOrigin)}
2927
3513
  `);
3514
+ }
2928
3515
  const pinned = readPinnedEngagement(targetRepo);
2929
3516
  const requestedId = engagementIdArg ?? pinned?.engagementId;
2930
3517
  const reusable = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
2931
3518
  let engagementId;
2932
3519
  let credential;
3520
+ let actingAccountId;
3521
+ let foundLine = "";
2933
3522
  if (reusable !== void 0 && pinned !== null) {
2934
3523
  engagementId = pinned.engagementId;
2935
3524
  credential = reusable;
2936
- process.stdout.write(`[halfcycle] Re-using the engagement already pinned in this repo: ${pinned.engagementId}
3525
+ actingAccountId = readBundlePin(targetRepo)?.accountId;
3526
+ foundLine = `already set up here \u2014 reusing engagement ${shortId(pinned.engagementId)}`;
3527
+ if (verbose) {
3528
+ process.stdout.write(`[halfcycle] Re-using the engagement already pinned in this repo: ${pinned.engagementId}
2937
3529
  `);
3530
+ }
2938
3531
  if (pinned.fromLegacyEnvLocal) {
2939
3532
  process.stdout.write(`[halfcycle] Its credential is in this repository's .env.local \u2014 an install from before
2940
3533
  [halfcycle] credentials moved out of the tree. It is being copied to
@@ -2943,8 +3536,9 @@ ${USAGE}`);
2943
3536
  `);
2944
3537
  }
2945
3538
  } else if (requestedId !== void 0) {
2946
- const joined = await joinPinnedEngagement(serviceUrl, requestedId);
3539
+ const joined = await joinPinnedEngagement(serviceUrl, requestedId, targetRepo, { assumeYes });
2947
3540
  engagementId = joined.engagementId;
3541
+ actingAccountId = joined.actingAccountId;
2948
3542
  credential = {
2949
3543
  serviceUrl,
2950
3544
  token: joined.sessionToken,
@@ -2955,11 +3549,15 @@ ${USAGE}`);
2955
3549
  // an absent value, never `undefined` verbatim.
2956
3550
  controlTelemetryUrl: joined.controlTelemetryUrl
2957
3551
  };
2958
- 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.
3552
+ foundLine = `this repo is pinned to a shared project \u2014 joining it as you (${shortId(joined.engagementId)})`;
3553
+ if (verbose) {
3554
+ 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.
2959
3555
  `);
3556
+ }
2960
3557
  } else {
2961
- const created = await createOwnedEngagement(serviceUrl);
3558
+ const created = await createOwnedEngagement(serviceUrl, targetRepo, { assumeYes });
2962
3559
  engagementId = created.engagementId;
3560
+ actingAccountId = created.actingAccountId;
2963
3561
  credential = {
2964
3562
  serviceUrl,
2965
3563
  token: created.sessionToken,
@@ -2973,26 +3571,32 @@ ${USAGE}`);
2973
3571
  // emission is fail-open on that absence.
2974
3572
  controlTelemetryUrl: created.controlTelemetryUrl
2975
3573
  };
2976
- process.stdout.write(`[halfcycle] Created engagement ${created.engagementId}
3574
+ foundLine = `no Halfcycle project here yet \u2014 creating one (${shortId(created.engagementId)})`;
3575
+ if (verbose) {
3576
+ process.stdout.write(`[halfcycle] Created engagement ${created.engagementId}
2977
3577
  `);
3578
+ }
2978
3579
  }
2979
3580
  const result = await install({
2980
3581
  targetRepo,
2981
3582
  engagementId,
2982
3583
  engagementType: engagementTypeRaw,
2983
- credential
3584
+ credential,
3585
+ accountId: actingAccountId
2984
3586
  });
2985
- process.stdout.write(`[halfcycle] Installed v${result.version} into ${targetRepo}
3587
+ if (verbose) {
3588
+ process.stdout.write(`[halfcycle] Installed v${result.version} into ${targetRepo}
2986
3589
  `);
2987
- process.stdout.write(`[halfcycle] Written: ${result.writtenPaths.length} paths
3590
+ process.stdout.write(`[halfcycle] Written: ${result.writtenPaths.length} paths
2988
3591
  `);
2989
- if (result.mergedPaths.length > 0) {
2990
- process.stdout.write(`[halfcycle] Merged: ${result.mergedPaths.join(", ")}
3592
+ if (result.mergedPaths.length > 0) {
3593
+ process.stdout.write(`[halfcycle] Merged: ${result.mergedPaths.join(", ")}
2991
3594
  `);
2992
- }
2993
- if (result.skippedPaths.length > 0) {
2994
- process.stdout.write(`[halfcycle] Skipped (already present): ${result.skippedPaths.join(", ")}
3595
+ }
3596
+ if (result.skippedPaths.length > 0) {
3597
+ process.stdout.write(`[halfcycle] Skipped (already present): ${result.skippedPaths.join(", ")}
2995
3598
  `);
3599
+ }
2996
3600
  }
2997
3601
  if (result.collidedPaths.length > 0) {
2998
3602
  process.stdout.write(`[halfcycle] Collided (a name you already use \u2014 NOT overwritten): ${result.collidedPaths.join(", ")}
@@ -3010,22 +3614,37 @@ ${USAGE}`);
3010
3614
  }
3011
3615
  const probe = await probeMcpOrigin(credential.mcpUrl);
3012
3616
  if (probe.reached) {
3013
- process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
3617
+ if (verbose) {
3618
+ process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
3014
3619
  `);
3620
+ }
3015
3621
  } else {
3016
3622
  process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
3017
3623
  [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.
3018
3624
  `);
3019
3625
  }
3020
- 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.
3626
+ if (verbose) {
3627
+ 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.
3628
+ `);
3629
+ 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.
3630
+ `);
3631
+ 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).
3632
+ `);
3633
+ 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.
3634
+ `);
3635
+ }
3636
+ reportClaudeCodeVersion(verbose);
3637
+ const identity = await describeAccount(credential.serviceUrl, credential.token);
3638
+ const written = result.writtenPaths.length;
3639
+ const merged = result.mergedPaths.length;
3640
+ process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId)}
3021
3641
  `);
3022
- 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.
3642
+ process.stdout.write(`[halfcycle] Found: ${foundLine}
3023
3643
  `);
3024
- 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).
3644
+ process.stdout.write(`[halfcycle] Set up: ${written} file${written === 1 ? "" : "s"} written${merged > 0 ? ` (${merged} merged)` : ""}, guards armed on every edit
3025
3645
  `);
3026
- 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.
3646
+ 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
3027
3647
  `);
3028
- reportClaudeCodeVersion();
3029
3648
  try {
3030
3649
  const minted = await mintBoardEnterCode(credential.serviceUrl, credential.token);
3031
3650
  process.stdout.write(`[halfcycle] Your board (first visit): ${minted.boardUrl}
@@ -3050,17 +3669,17 @@ ${USAGE}`);
3050
3669
  return;
3051
3670
  }
3052
3671
  if (cmd === "check-drift") {
3053
- const targetRepo = rest[0];
3054
- if (!targetRepo) {
3672
+ const targetRepo2 = rest[0];
3673
+ if (!targetRepo2) {
3055
3674
  process.stderr.write("halfcycle-bundle: usage: halfcycle-bundle check-drift <target-repo>\n");
3056
3675
  process.exit(1);
3057
3676
  return;
3058
3677
  }
3059
3678
  try {
3060
- const { drifted, installed, current } = checkDrift(targetRepo);
3679
+ const { drifted, installed, current } = checkDrift(targetRepo2);
3061
3680
  if (drifted) {
3062
3681
  process.stdout.write(`[halfcycle-bundle] DRIFT DETECTED: installed=${installed ?? "none"} current=${current}
3063
- [halfcycle-bundle] Re-run "halfcycle-bundle install ${targetRepo}" to sync.
3682
+ [halfcycle-bundle] Re-run "halfcycle-bundle install ${targetRepo2}" to sync.
3064
3683
  `);
3065
3684
  process.exit(2);
3066
3685
  } else {
@@ -3085,12 +3704,12 @@ ${USAGE}`);
3085
3704
  }
3086
3705
  const phaseId = Number(phaseArg);
3087
3706
  try {
3088
- const inputPath = join9(repoRoot, ".workbench", "build-record", `phase-${phaseId}.input.json`);
3089
- const input = JSON.parse(readFileSync8(inputPath, "utf-8"));
3707
+ const inputPath = join10(repoRoot, ".workbench", "build-record", `phase-${phaseId}.input.json`);
3708
+ const input = JSON.parse(readFileSync9(inputPath, "utf-8"));
3090
3709
  const result = assemblePhaseBuildRecord({
3091
3710
  repoRoot,
3092
- phasesDir: join9(repoRoot, "docs", "phases"),
3093
- guardEvalLogDir: input.guardEvalLogDir ?? join9(repoRoot, ".workbench", "guard-eval-log"),
3711
+ phasesDir: join10(repoRoot, "docs", "phases"),
3712
+ guardEvalLogDir: input.guardEvalLogDir ?? join10(repoRoot, ".workbench", "guard-eval-log"),
3094
3713
  phaseId,
3095
3714
  narrated: input.narrated,
3096
3715
  touchedInvariants: input.touchedInvariants