halfcycle 0.3.35 → 0.3.37

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,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // dist/bin.js
4
- import { execFileSync as execFileSync5 } from "node:child_process";
5
- import { existsSync as existsSync5, readFileSync as readFileSync10, statSync } from "node:fs";
4
+ import { existsSync as existsSync5, readFileSync as readFileSync10, realpathSync, statSync } from "node:fs";
5
+ import { homedir as homedir3 } from "node:os";
6
6
  import { join as join10 } from "node:path";
7
7
 
8
8
  // dist/install.js
@@ -35,6 +35,8 @@ var ENGAGEMENTS_DIR_NAME = "engagements";
35
35
  var ENGAGEMENT_ENV_FILENAME = "env";
36
36
  var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
37
37
  var ACCOUNT_STORE_FILENAME = "account.json";
38
+ var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
39
+ var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
38
40
  var ENGAGEMENT_ID_PATTERN = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
39
41
  var ENGAGEMENT_ID_SHAPE = new RegExp(ENGAGEMENT_ID_PATTERN);
40
42
  function isEngagementId(value) {
@@ -228,6 +230,10 @@ function looksLikeSecret(text) {
228
230
  return false;
229
231
  }
230
232
 
233
+ // ../core/dist/mcp-registration.js
234
+ var MCP_REGISTRATION_REL = ".mcp.json";
235
+ var MCP_SERVER_KEY = "halfcycle";
236
+
231
237
  // ../events/dist/result.js
232
238
  import { z as z2 } from "zod";
233
239
  var firedGuardSchema = z2.object({
@@ -1218,6 +1224,19 @@ var ciTokenExchangeResponseSchema = z10.object({
1218
1224
  expiresAt: utcIso8601,
1219
1225
  engagementId: z10.string()
1220
1226
  }).strict();
1227
+ var NO_CREDENTIAL_PLACEHOLDER = "halfcycle:no-credential";
1228
+ var NOT_THIS_ACCOUNT_PLACEHOLDER = "halfcycle:not-this-account";
1229
+ var UPDATE_CLAUDE_CODE_PLACEHOLDER = "halfcycle:update-claude-code";
1230
+ var SWITCH_ACCOUNT_ANSWER = 'answer "s" when asked';
1231
+ var NO_CREDENTIAL_SENTENCE = 'Halfcycle is not connected: this machine has no working Halfcycle credential for this project. Run "npx halfcycle" in the project folder to sign in again.';
1232
+ var NOT_THIS_ACCOUNT_SENTENCE = `Halfcycle is not connected: this project belongs to another Halfcycle account. To switch, run "npx halfcycle" in this project and ${SWITCH_ACCOUNT_ANSWER}.`;
1233
+ var UPDATE_CLAUDE_CODE_SENTENCE = "Halfcycle is not connected: this Claude Code version does not name the server it connects to, so no credential was sent. Update Claude Code, then reopen.";
1234
+ var NO_NODE_SENTENCE = "Halfcycle is not connected: Node is not installed, or not on the path this session uses, so nothing here is checked. Install Node, then reopen.";
1235
+ var PLACEHOLDER_SENTENCES = Object.freeze({
1236
+ [NO_CREDENTIAL_PLACEHOLDER]: NO_CREDENTIAL_SENTENCE,
1237
+ [NOT_THIS_ACCOUNT_PLACEHOLDER]: NOT_THIS_ACCOUNT_SENTENCE,
1238
+ [UPDATE_CLAUDE_CODE_PLACEHOLDER]: UPDATE_CLAUDE_CODE_SENTENCE
1239
+ });
1221
1240
 
1222
1241
  // ../events/dist/phase-record.js
1223
1242
  import { z as z11 } from "zod";
@@ -1322,7 +1341,6 @@ function forgetStoredCredential(serviceUrl, home) {
1322
1341
  var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
1323
1342
  var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
1324
1343
  var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
1325
- var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
1326
1344
  var InvalidEngagementIdError = class extends Error {
1327
1345
  constructor(engagementId) {
1328
1346
  const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
@@ -1640,6 +1658,15 @@ function bool(value, field) {
1640
1658
  throw new UnreadableManifestError(`"${field}" is not true or false`);
1641
1659
  return value;
1642
1660
  }
1661
+ function appendedTextRecord(value, field) {
1662
+ if (!isRecord(value))
1663
+ throw new UnreadableManifestError(`"${field}" is not an object`);
1664
+ onlyKeys(value, ["created", "appended"], `"${field}"`);
1665
+ return {
1666
+ created: bool(value["created"], `${field}.created`),
1667
+ appended: stringArray(value["appended"], `${field}.appended`)
1668
+ };
1669
+ }
1643
1670
  function onlyKeys(value, allowed, where) {
1644
1671
  const extra = Object.keys(value).filter((k) => !allowed.includes(k));
1645
1672
  if (extra.length > 0)
@@ -1654,7 +1681,7 @@ function parseManifest(text) {
1654
1681
  }
1655
1682
  if (!isRecord(raw))
1656
1683
  throw new UnreadableManifestError("it is not a JSON object");
1657
- onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
1684
+ onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore", "gitattributes"], "the manifest");
1658
1685
  if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
1659
1686
  throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
1660
1687
  }
@@ -1715,14 +1742,10 @@ function parseManifest(text) {
1715
1742
  ...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
1716
1743
  };
1717
1744
  }
1718
- const g = raw["gitignore"];
1719
- if (!isRecord(g))
1745
+ if (raw["gitignore"] === void 0)
1720
1746
  throw new UnreadableManifestError('"gitignore" is missing');
1721
- onlyKeys(g, ["created", "appended"], '"gitignore"');
1722
- const gitignore = {
1723
- created: bool(g["created"], "gitignore.created"),
1724
- appended: stringArray(g["appended"], "gitignore.appended")
1725
- };
1747
+ const gitignore = appendedTextRecord(raw["gitignore"], "gitignore");
1748
+ const gitattributes = raw["gitattributes"] === void 0 ? void 0 : appendedTextRecord(raw["gitattributes"], "gitattributes");
1726
1749
  const paths = files.map((f) => f.path);
1727
1750
  if (new Set(paths).size !== paths.length)
1728
1751
  throw new UnreadableManifestError('"files" names a path twice');
@@ -1737,7 +1760,8 @@ function parseManifest(text) {
1737
1760
  leftAlone,
1738
1761
  settings,
1739
1762
  ...mcp !== void 0 ? { mcp } : {},
1740
- gitignore
1763
+ gitignore,
1764
+ ...gitattributes !== void 0 ? { gitattributes } : {}
1741
1765
  };
1742
1766
  }
1743
1767
  function startManifest(previous, isMember, isMemberDir) {
@@ -1753,12 +1777,16 @@ function startManifest(previous, isMember, isMemberDir) {
1753
1777
  createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
1754
1778
  settings: previous?.settings,
1755
1779
  mcp: previous?.mcp,
1756
- gitignore: previous?.gitignore
1780
+ gitignore: previous?.gitignore,
1781
+ gitattributes: previous?.gitattributes
1757
1782
  };
1758
1783
  }
1759
1784
  function recordInstalledFile(draft, path, bytes) {
1760
1785
  draft.files.set(path, { path, sha256: sha256Hex(bytes) });
1761
1786
  }
1787
+ function recordRemovedFile(draft, path) {
1788
+ draft.files.delete(path);
1789
+ }
1762
1790
  function recordPerMachineFile(draft, path) {
1763
1791
  draft.files.set(path, { path, perMachine: true });
1764
1792
  }
@@ -1801,8 +1829,15 @@ function recordMcp(draft, observed) {
1801
1829
  };
1802
1830
  }
1803
1831
  function recordGitignore(draft, created, appended) {
1804
- const prev = draft.gitignore;
1805
- draft.gitignore = {
1832
+ draft.gitignore = carryAppended(draft.gitignore, created, appended);
1833
+ }
1834
+ function recordGitattributes(draft, created, appended) {
1835
+ if (draft.gitattributes === void 0 && !created && appended.every((a) => a === ""))
1836
+ return;
1837
+ draft.gitattributes = carryAppended(draft.gitattributes, created, appended);
1838
+ }
1839
+ function carryAppended(prev, created, appended) {
1840
+ return {
1806
1841
  created: prev?.created ?? created,
1807
1842
  appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
1808
1843
  };
@@ -1826,7 +1861,8 @@ function finishManifest(draft) {
1826
1861
  denyCreated: false
1827
1862
  },
1828
1863
  ...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
1829
- gitignore: draft.gitignore ?? { created: false, appended: [] }
1864
+ gitignore: draft.gitignore ?? { created: false, appended: [] },
1865
+ ...draft.gitattributes !== void 0 ? { gitattributes: draft.gitattributes } : {}
1830
1866
  };
1831
1867
  }
1832
1868
  function union(a, b) {
@@ -1977,11 +2013,12 @@ var WRITE_ALLOWLIST = [
1977
2013
  ".claude/hooks",
1978
2014
  ".claude/settings.json",
1979
2015
  ".halfcycle",
1980
- ".halfcycle/bundle.json",
2016
+ BUNDLE_PIN_REL,
1981
2017
  ".halfcycle/bin",
1982
2018
  "docs/build-records",
1983
2019
  "test/fixtures/captured",
1984
- ".mcp.json",
2020
+ MCP_REGISTRATION_REL,
2021
+ ".gitattributes",
1985
2022
  ZONE_B_DIR
1986
2023
  ];
1987
2024
  function isAllowlisted(targetRelPath) {
@@ -1991,15 +2028,16 @@ function isAllowlisted(targetRelPath) {
1991
2028
  return normalised === p || normalised.startsWith(p + "/");
1992
2029
  });
1993
2030
  }
1994
- var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
1995
2031
  var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
1996
2032
  var CREW_ROSTER_REL = ".halfcycle/crew.json";
1997
2033
  var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
1998
2034
  var GITIGNORE_REL = ".gitignore";
2035
+ var GITATTRIBUTES_REL = ".gitattributes";
1999
2036
  function commandStubRel(name) {
2000
2037
  return `.claude/commands/${name}.md`;
2001
2038
  }
2002
- var RETIRED_WRITE_SET_FILES = [];
2039
+ var RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
2040
+ var RETIRED_WRITE_SET_FILES = [RETIRED_MCP_HEADERS_HELPER_REL];
2003
2041
  var RETIRED_DENY_PATTERNS = [];
2004
2042
  function closedWriteSet() {
2005
2043
  const files = /* @__PURE__ */ new Set([
@@ -2014,7 +2052,7 @@ function closedWriteSet() {
2014
2052
  INSTALL_MANIFEST_REL,
2015
2053
  ...RETIRED_WRITE_SET_FILES
2016
2054
  ]);
2017
- const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
2055
+ const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL, GITATTRIBUTES_REL]);
2018
2056
  const dirs = /* @__PURE__ */ new Set();
2019
2057
  for (const path of [...files, ...merged]) {
2020
2058
  for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
@@ -2028,7 +2066,11 @@ function closedWriteSet() {
2028
2066
  ownedDirs,
2029
2067
  hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
2030
2068
  denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
2031
- gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL])
2069
+ gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
2070
+ gitattributesLines: /* @__PURE__ */ new Set([
2071
+ GITATTRIBUTES_HEADER,
2072
+ ...lfRules([...Object.keys(OWNED_GENERATED_HEADERS), ...RETIRED_WRITE_SET_FILES])
2073
+ ])
2032
2074
  };
2033
2075
  }
2034
2076
  function readPreviousManifest(targetRepo) {
@@ -2092,15 +2134,19 @@ function recordMcpAdoptionOnly(draft, existingText) {
2092
2134
  }
2093
2135
  function recordGitignoreReconcile(draft, before, after) {
2094
2136
  const adopted = draft.gitignore === void 0 && before !== null ? adoptOlderGitignoreBlock(before) : "";
2095
- let appended = "";
2096
- if (after !== null && after !== before) {
2097
- if (before === null)
2098
- appended = after;
2099
- else if (after.startsWith(before))
2100
- appended = after.slice(before.length);
2101
- }
2137
+ const appended = appendedText(before, after);
2102
2138
  recordGitignore(draft, before === null && after !== null, [adopted, appended]);
2103
2139
  }
2140
+ function recordGitattributesReconcile(draft, before, after) {
2141
+ recordGitattributes(draft, before === null && after !== null, [appendedText(before, after)]);
2142
+ }
2143
+ function appendedText(before, after) {
2144
+ if (after === null || after === before)
2145
+ return "";
2146
+ if (before === null)
2147
+ return after;
2148
+ return after.startsWith(before) ? after.slice(before.length) : "";
2149
+ }
2104
2150
  function assertManifestInWriteSet(manifest, writeSet) {
2105
2151
  const strays = [
2106
2152
  ...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
@@ -2115,7 +2161,8 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
2115
2161
  const merged = [
2116
2162
  SETTINGS_REL,
2117
2163
  ...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
2118
- ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : []
2164
+ ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : [],
2165
+ ...manifest.gitattributes !== void 0 && (manifest.gitattributes.created || manifest.gitattributes.appended.length > 0) ? [GITATTRIBUTES_REL] : []
2119
2166
  ];
2120
2167
  const collided = new Set(collidedPaths);
2121
2168
  const dirVerdicts = /* @__PURE__ */ new Map();
@@ -2343,6 +2390,20 @@ exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
2343
2390
  }
2344
2391
  var GENERATED_SESSION_START_HEADER = "# Session-start marker \u2014 generated by";
2345
2392
  var SESSION_ID_FROM_HOOK_INPUT_SH = `SESSION_ID="$(printf '%s' "$INPUT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);process.stdout.write(typeof j.session_id==="string"?j.session_id:"")}catch{process.stdout.write("")}})' 2>/dev/null || true)"`;
2393
+ var REPO_HASH_FROM_ROOT_SH = `HASH="$(printf '%s' "$REPO_ROOT" | node -e 'const c=[];process.stdin.on("data",d=>c.push(d)).on("end",()=>{process.stdout.write(require("node:crypto").createHash("sha256").update(Buffer.concat(c)).digest("hex").slice(0,12))})' 2>/dev/null || true)"`;
2394
+ function noNodeReplyShell() {
2395
+ const reply = JSON.stringify({
2396
+ systemMessage: NO_NODE_SENTENCE,
2397
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: NO_NODE_SENTENCE }
2398
+ });
2399
+ if (reply.includes("'")) {
2400
+ throw new Error("[bundle install] the no-Node notice cannot be single-quoted; refusing to write the session-start script.");
2401
+ }
2402
+ return `if ! command -v node >/dev/null 2>&1; then
2403
+ printf '%s\\n' '${reply}'
2404
+ exit 0
2405
+ fi`;
2406
+ }
2346
2407
  function generateSessionStartMarker() {
2347
2408
  return `#!/usr/bin/env bash
2348
2409
  ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
@@ -2351,10 +2412,15 @@ ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
2351
2412
  # it began instead of only against the latest commit. The session id comes in on
2352
2413
  # stdin with the hook input; without one, the older session-less name is written
2353
2414
  # and the stop-time check compares against the latest commit as it used to.
2415
+ #
2416
+ # Without Node nothing Halfcycle installs here can run, so this says so once and
2417
+ # writes no marker.
2418
+ ${noNodeReplyShell()}
2354
2419
  INPUT="$(cat 2>/dev/null || true)"
2355
2420
  REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
2356
2421
  if [ -z "$REPO_ROOT" ]; then exit 0; fi
2357
- HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
2422
+ ${REPO_HASH_FROM_ROOT_SH}
2423
+ if [ -z "$HASH" ]; then exit 0; fi
2358
2424
  ${SESSION_ID_FROM_HOOK_INPUT_SH}
2359
2425
  MARKER_DIR="\${TMPDIR:-/tmp}"
2360
2426
  MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
@@ -2364,64 +2430,6 @@ else
2364
2430
  MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}.ref"
2365
2431
  fi
2366
2432
  git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
2367
-
2368
- # ---------------------------------------------------------------------------
2369
- # STANDING GUARD-COVERAGE STATEMENT (T-27; two arms since T-02b).
2370
- #
2371
- # A repository with no credential on this machine is UNGUARDED: the PostToolUse
2372
- # hook will find nothing to evaluate with, and it says so once per edit, after
2373
- # the edit. This says it once, up front, before anything is written \u2014 which is
2374
- # the one thing a session-start hook can do that nothing else can.
2375
- #
2376
- # TWO REASONS TO HAVE NO CREDENTIAL, TWO DIFFERENT THINGS TO SAY (T-02b,
2377
- # solo-project-ownership \u2014 outcome B). "You have not signed in here yet" is
2378
- # fixed by re-running the installer; "this is not your project" is permanent,
2379
- # and the same remedy would send the reader in a circle forever. The two are
2380
- # told apart by HALFCYCLE_ENV_NOT_THIS_ACCOUNT, set by \`halfcycle_env_file\`
2381
- # below from a machine-level marker beside the credential store.
2382
- #
2383
- # Everything below runs in a SUBSHELL: it sources a credential store, and a
2384
- # session-start hook must not leak an engagement's variables into whatever the
2385
- # editor runs next. Any failure inside it is swallowed; this script's exit
2386
- # status is 0 either way, and the marker above has already been written.
2387
- # ---------------------------------------------------------------------------
2388
- ${engagementResolutionShell()}
2389
-
2390
- (
2391
- if halfcycle_env_file "$REPO_ROOT"; then
2392
- set -a
2393
- . "$HALFCYCLE_ENV_FILE"
2394
- set +a
2395
- fi
2396
- if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
2397
- # OUTCOME B (T-02b, solo-project-ownership). This is not the missing-keys
2398
- # case below wearing different words: there is nothing to fix, and the loop
2399
- # that follows is skipped entirely rather than run and then overridden \u2014 a
2400
- # repository whose credential is absent for THIS reason must never be told
2401
- # to re-run the installer, which can never obtain one for it.
2402
- echo "[halfcycle] This project belongs to another Halfcycle account, so nothing here is checked."
2403
- echo "[halfcycle] Your work carries on exactly as it would without Halfcycle \u2014 no edit is"
2404
- echo "[halfcycle] evaluated, and nothing you do is reported as having passed."
2405
- echo "[halfcycle] Halfcycle is Solo today \u2014 one developer per project \u2014 with Team plans coming."
2406
- echo "[halfcycle] Ask the project's owner for view access."
2407
- echo "[halfcycle] If this account is wrong, sign in as the one that owns the project and run"
2408
- echo "[halfcycle] \\"npx halfcycle\\" again."
2409
- else
2410
- hc_missing=""
2411
- for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
2412
- eval "hc_value=\\\${$hc_key:-}"
2413
- if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
2414
- done
2415
- if [ -n "$hc_missing" ]; then
2416
- echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
2417
- echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
2418
- echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
2419
- echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
2420
- echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
2421
- echo "[halfcycle] and needs nothing configured first."
2422
- fi
2423
- fi
2424
- ) 2>/dev/null || true
2425
2433
  exit 0
2426
2434
  `;
2427
2435
  }
@@ -2437,7 +2445,8 @@ INPUT="$(cat 2>/dev/null || true)"
2437
2445
 
2438
2446
  REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
2439
2447
  if [ -z "$REPO_ROOT" ]; then exit 0; fi
2440
- HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
2448
+ ${REPO_HASH_FROM_ROOT_SH}
2449
+ if [ -z "$HASH" ]; then exit 0; fi
2441
2450
 
2442
2451
  # The session id arrives on stdin as part of the hook payload. Empty when absent.
2443
2452
  ${SESSION_ID_FROM_HOOK_INPUT_SH}
@@ -2484,7 +2493,8 @@ set -e
2484
2493
 
2485
2494
  # The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
2486
2495
  # session's cwd \u2014 a session started in a subdirectory must find the same root
2487
- # .mcp.json the editor loaded, which is the bug mcp-headers.sh already paid for.
2496
+ # .mcp.json the editor loaded, which is the bug the editor's headers command
2497
+ # already paid for.
2488
2498
  HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
2489
2499
  HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
2490
2500
  HC_REGISTRATION="$HC_PROJECT_ROOT/${MCP_REGISTRATION_REL}"
@@ -2531,7 +2541,9 @@ function isTrackedAndClean(targetRepo, relPath) {
2531
2541
  return true;
2532
2542
  }
2533
2543
  function removeLegacyCiStanza(targetRepo) {
2534
- const rel = ".halfcycle/ci-stanza.yml";
2544
+ return removeRetiredGeneratedFile(targetRepo, ".halfcycle/ci-stanza.yml", 0, CI_STANZA_KNOWN_HEADER_HASHES);
2545
+ }
2546
+ function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeaderHashes) {
2535
2547
  const path = join5(targetRepo, rel);
2536
2548
  if (!existsSync3(path))
2537
2549
  return "absent";
@@ -2541,10 +2553,9 @@ function removeLegacyCiStanza(targetRepo) {
2541
2553
  } catch {
2542
2554
  return "failed";
2543
2555
  }
2544
- const headerLine = raw.split("\n")[0] ?? "";
2556
+ const headerLine = raw.split("\n")[headerLineIndex] ?? "";
2545
2557
  const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
2546
- const ours = CI_STANZA_KNOWN_HEADER_HASHES.includes(digest);
2547
- if (!ours)
2558
+ if (!knownHeaderHashes.includes(digest))
2548
2559
  return "kept-foreign";
2549
2560
  if (!isTrackedAndClean(targetRepo, rel))
2550
2561
  return "kept-uncommitted";
@@ -2555,205 +2566,33 @@ function removeLegacyCiStanza(targetRepo) {
2555
2566
  }
2556
2567
  return "removed";
2557
2568
  }
2558
- var MCP_REGISTRATION_REL = ".mcp.json";
2559
- var MCP_SERVER_KEY = "halfcycle";
2560
- var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
2561
- var MCP_HEADERS_HELPER_COMMAND = `/bin/sh -c 'd=$(pwd); while [ ! -f "$d/${MCP_HEADERS_HELPER_REL}" ] && [ "$d" != / ]; do d=$(dirname "$d"); done; [ -f "$d/${MCP_HEADERS_HELPER_REL}" ] || { echo "halfcycle: ${MCP_HEADERS_HELPER_REL} not found at or above $(pwd); re-run npx halfcycle" >&2; exit 1; }; exec /bin/sh "$d/${MCP_HEADERS_HELPER_REL}"'`;
2562
- var GENERATED_MCP_HEADERS_HEADER = "# Halfcycle MCP connection headers \u2014 generated by";
2569
+ var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
2570
+ `const p=require('path'),f=require('fs'),c=require('child_process');`,
2571
+ `let d=process.cwd();`,
2572
+ `for(;;){`,
2573
+ `const b=p.join(d,'${VENDORED_BIN_REL}');`,
2574
+ `if(f.existsSync(b)){`,
2575
+ `const r=c.spawnSync(process.execPath,[b,'mcp-headers'],{stdio:'inherit'});`,
2576
+ `process.exit(typeof r.status==='number'?r.status:1)}`,
2577
+ `const u=p.dirname(d);`,
2578
+ `if(u===d)break;`,
2579
+ `d=u}`,
2580
+ `console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
2581
+ `process.exit(1)`
2582
+ ].join("")}"`;
2583
+ var MCP_HEADERS_KNOWN_HEADER_HASHES = [
2584
+ "37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
2585
+ "6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
2586
+ ];
2587
+ function removeRetiredMcpHeadersHelper(targetRepo) {
2588
+ return removeRetiredGeneratedFile(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL, 1, MCP_HEADERS_KNOWN_HEADER_HASHES);
2589
+ }
2563
2590
  var OWNED_GENERATED_HEADERS = {
2564
2591
  ".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
2565
2592
  ".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
2566
2593
  ".claude/hooks/session-end-marker.sh": GENERATED_SESSION_END_HEADER,
2567
- ".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER,
2568
- ".halfcycle/mcp-headers.sh": GENERATED_MCP_HEADERS_HEADER
2594
+ ".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
2569
2595
  };
2570
- function generateMcpHeadersHelper() {
2571
- return `#!/bin/sh
2572
- ${GENERATED_MCP_HEADERS_HEADER} the Halfcycle installer.
2573
- #
2574
- # Claude Code runs this at MCP connection time, from the session's cwd, and merges
2575
- # its stdout into the connection headers for the "halfcycle" server in .mcp.json.
2576
- #
2577
- # It exists because \${VAR} expansion in .mcp.json reads the PROCESS environment
2578
- # only: nothing loads a credential into a session, so a referenced
2579
- # \${HALFCYCLE_TOKEN} expanded to nothing and the server was silently absent from
2580
- # the tool list.
2581
- #
2582
- # This file is tracked and holds NO secret. NEITHER DOES ANY OTHER FILE IN THIS
2583
- # REPOSITORY (T-09): the credential lives at machine level, outside every checkout,
2584
- # and this script READS one line out of it (never sources \u2014 a credential store is
2585
- # not code).
2586
- set -eu
2587
-
2588
- # The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
2589
- # caller's working directory. Claude Code runs the helper from the SESSION's cwd,
2590
- # and a session started in a subdirectory still finds the root .mcp.json \u2014 so a
2591
- # relative read here silently produced no credential for anyone who started the
2592
- # session in any subdirectory of a monorepo.
2593
- SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
2594
- PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
2595
-
2596
- ${engagementResolutionShell()}
2597
-
2598
- # Read one KEY's value out of the credential store into HC_VALUE, in the CALLER's
2599
- # shell (call it as a statement, never inside $(...)). Last assignment wins,
2600
- # matching the shell's own \`.\` semantics. An optional leading \`export \` is
2601
- # tolerated because a hand-edited store may use one.
2602
- halfcycle_store_value() {
2603
- HC_VALUE=""
2604
- hc_line=$(grep "^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}$2=" "$1" | tail -n 1)
2605
- hc_v=\${hc_line#*"$2="}
2606
- hc_v=$(printf '%s' "$hc_v" | tr -d '\\r')
2607
-
2608
- # Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
2609
- # writers use the same shq: this installer and Studio's provider), because
2610
- # guard-runner.sh SOURCES the same file and an unquoted value carrying a space
2611
- # would execute its own remainder. This reader greps rather than sources, so it
2612
- # has to undo the quoting itself \u2014 and it must land on the same value the
2613
- # sourcing consumer gets, or one file has two answers.
2614
- case $hc_v in
2615
- '"'*'"') hc_v=\${hc_v#'"'}; hc_v=\${hc_v%'"'} ;;
2616
- "'"*"'")
2617
- hc_v=\${hc_v#"'"}; hc_v=\${hc_v%"'"}
2618
- # \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
2619
- # through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
2620
- # the CI platform) and BSD sed (macOS), feeding each the script from a file so
2621
- # no shell quoting is in the way:
2622
- #
2623
- # s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
2624
- # s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
2625
- #
2626
- # A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
2627
- # does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
2628
- # and the token then travels with four stray characters in it. The bracket
2629
- # expression makes the backslash literal by a construction POSIX does define.
2630
- hc_v=$(printf '%s' "$hc_v" | sed "s/'[\\]''/'/g") ;;
2631
- esac
2632
- HC_VALUE=$hc_v
2633
- }
2634
-
2635
- # The scheme and host[:port] of a URL, lowercased \u2014 or NOTHING when the URL holds
2636
- # a space or a control character anywhere. That refusal is the load-bearing part:
2637
- # a URL parser drops tabs and newlines before it reads the host, so
2638
- # "https://ours<newline>@elsewhere/" is a request to "elsewhere", while a
2639
- # line-by-line reading here would see only "https://ours". The host ends at the
2640
- # first / ? # or backslash, which is where a URL parser ends it for http(s).
2641
- halfcycle_origin() {
2642
- hc_u=$1
2643
- if [ "$(printf '%s' "$hc_u" | tr -d '[:cntrl:][:space:]')" != "$hc_u" ]; then
2644
- return 0
2645
- fi
2646
- printf '%s\\n' "$hc_u" | sed -n 's|^\\([A-Za-z][A-Za-z0-9+.-]*://[^/?#\\\\]*\\).*$|\\1|p' | tr '[:upper:]' '[:lower:]'
2647
- }
2648
-
2649
- # HALFCYCLE_TOKEN in the environment WINS, and it is the CI arm: a job with no
2650
- # browser and no per-user store exports the credential, and a stale store on a
2651
- # long-lived runner must not silently win over it. Same order, and the same reason,
2652
- # as the CLI's own resolver (\`resolve-credential.ts\`). The server it may be sent
2653
- # to is NOT taken from the environment: it is read from this machine's store in
2654
- # both arms, so a token supplied this way still goes only where this machine was
2655
- # set up to send it.
2656
- # The pin names an id that is not a project id. Re-running the installer against
2657
- # that pin refuses it too, so the one remedy that works is a fresh pin.
2658
- halfcycle_bad_id_message() {
2659
- echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names an engagement id that is not a Halfcycle project id," >&2
2660
- echo "halfcycle: so no credential was read. Delete that file and run \\"npx halfcycle\\" again: this repository" >&2
2661
- echo "halfcycle: is then set up as a new Halfcycle project." >&2
2662
- }
2663
-
2664
- TOKEN=\${HALFCYCLE_TOKEN:-}
2665
- MCP_URL=""
2666
-
2667
- if [ -n "$TOKEN" ]; then
2668
- if halfcycle_env_file "$PROJECT_ROOT"; then
2669
- halfcycle_store_value "$HALFCYCLE_ENV_FILE" HALFCYCLE_MCP_URL
2670
- MCP_URL=$HC_VALUE
2671
- fi
2672
- else
2673
- if ! halfcycle_env_file "$PROJECT_ROOT"; then
2674
- case $HALFCYCLE_ENV_PROBLEM in
2675
- no-pin)
2676
- echo "halfcycle: no .halfcycle/bundle.json in $PROJECT_ROOT, so this repository has no Halfcycle engagement." >&2
2677
- echo "halfcycle: run \\"npx halfcycle\\" here to install one." >&2 ;;
2678
- no-id)
2679
- echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names no engagement id." >&2
2680
- echo "halfcycle: run \\"npx halfcycle\\" here to rewrite it." >&2 ;;
2681
- bad-id)
2682
- halfcycle_bad_id_message ;;
2683
- no-home)
2684
- echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
2685
- *)
2686
- # OUTCOME B (T-02b, solo-project-ownership) is a \`no-credential\` PROBLEM
2687
- # with a REASON attached, checked here rather than as a fifth case value
2688
- # \u2014 see \`engagementResolutionShell\`'s own docblock for why. Re-running
2689
- # this installer can never obtain a credential for this reason, so it is
2690
- # the one sentence in this file that must not say to.
2691
- if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
2692
- echo "halfcycle: this project belongs to another Halfcycle account, so this machine has no" >&2
2693
- echo "halfcycle: credential for it and the Halfcycle tools are not available here. Nothing is" >&2
2694
- echo "halfcycle: broken \u2014 ask the project's owner for view access, or sign in as the account" >&2
2695
- echo "halfcycle: that owns it and run \\"npx halfcycle\\" again." >&2
2696
- else
2697
- echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
2698
- 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
2699
- fi ;;
2700
- esac
2701
- exit 1
2702
- fi
2703
- ENV_FILE="$HALFCYCLE_ENV_FILE"
2704
-
2705
- halfcycle_store_value "$ENV_FILE" HALFCYCLE_TOKEN
2706
- TOKEN=$HC_VALUE
2707
- if [ -z "$TOKEN" ]; then
2708
- echo "halfcycle: HALFCYCLE_TOKEN is absent or empty in $ENV_FILE." >&2
2709
- echo "halfcycle: run \\"npx halfcycle\\" in this repository to rewrite this machine's credential." >&2
2710
- exit 1
2711
- fi
2712
- halfcycle_store_value "$ENV_FILE" HALFCYCLE_MCP_URL
2713
- MCP_URL=$HC_VALUE
2714
- fi
2715
-
2716
- # THE TOKEN GOES ONLY TO THIS MACHINE'S HALFCYCLE SERVER. .mcp.json is a tracked
2717
- # file: anyone who can land a commit can change the server's url, and this script
2718
- # would then hand the credential to whatever it names. Claude Code tells the helper
2719
- # which url it is about to connect to (CLAUDE_CODE_MCP_SERVER_URL); the server this
2720
- # machine was set up against is recorded OUTSIDE the repository, beside the
2721
- # credential. The two must share scheme, host and port, or nothing is printed.
2722
- # No url at all is a refusal too: sending the credential without knowing where it
2723
- # is going is the thing this check exists to stop.
2724
- REQUESTED=\${CLAUDE_CODE_MCP_SERVER_URL:-}
2725
- if [ -z "$REQUESTED" ]; then
2726
- echo "halfcycle: Claude Code did not say which server it is connecting to, so the Halfcycle credential was not sent." >&2
2727
- echo "halfcycle: update Claude Code, then reconnect." >&2
2728
- exit 1
2729
- fi
2730
- if [ -z "$MCP_URL" ] && [ "\${HALFCYCLE_ENV_PROBLEM:-}" = "bad-id" ]; then
2731
- halfcycle_bad_id_message
2732
- exit 1
2733
- fi
2734
- if [ -z "$MCP_URL" ]; then
2735
- echo "halfcycle: this machine has no Halfcycle server address recorded for this repository, so the credential was not sent." >&2
2736
- echo "halfcycle: run \\"npx halfcycle\\" in this repository on this machine to record it." >&2
2737
- exit 1
2738
- fi
2739
- WANT=$(halfcycle_origin "$MCP_URL")
2740
- GOT=$(halfcycle_origin "$REQUESTED")
2741
- if [ -z "$WANT" ] || [ "$GOT" != "$WANT" ]; then
2742
- SHOWN=$(printf '%s' "$REQUESTED" | tr -cd '[:graph:]' | cut -c1-200)
2743
- echo "halfcycle: .mcp.json asks for this repository's Halfcycle credential to be sent to $SHOWN," >&2
2744
- echo "halfcycle: which is not this machine's Halfcycle server (\${WANT:-none recorded}). The credential was NOT sent." >&2
2745
- echo "halfcycle: if nobody meant to change .mcp.json, treat that change as suspect; \\"npx halfcycle\\" restores the entry." >&2
2746
- exit 1
2747
- fi
2748
-
2749
- # JSON-escape: backslash first, then double quote. A token carrying either would
2750
- # otherwise emit invalid JSON, and invalid JSON is dropped SILENTLY \u2014 the
2751
- # connection succeeds carrying no Authorization header at all.
2752
- ESCAPED=$(printf '%s' "$TOKEN" | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g')
2753
-
2754
- printf '{"Authorization":"Bearer %s"}\\n' "$ESCAPED"
2755
- `;
2756
- }
2757
2596
  function generateMcpRegistration(existing, mcpOrigin) {
2758
2597
  let base = { mcpServers: {} };
2759
2598
  if (existing !== null) {
@@ -2820,6 +2659,42 @@ ${header}
2820
2659
  return "failed";
2821
2660
  }
2822
2661
  }
2662
+ var GITATTRIBUTES_HEADER = "# Halfcycle \u2014 generated scripts keep LF line endings";
2663
+ function readTextOrNull(path) {
2664
+ try {
2665
+ return readFileSync5(path, "utf-8");
2666
+ } catch {
2667
+ return null;
2668
+ }
2669
+ }
2670
+ function lfRules(paths) {
2671
+ return [...paths].filter((path) => path.endsWith(".sh")).map((path) => `${path} text eol=lf`);
2672
+ }
2673
+ var REQUIRED_GITATTRIBUTES_RULES = lfRules(Object.keys(OWNED_GENERATED_HEADERS));
2674
+ function reconcileGitattributes(targetRepo) {
2675
+ const path = join5(targetRepo, GITATTRIBUTES_REL);
2676
+ const rules = REQUIRED_GITATTRIBUTES_RULES;
2677
+ const routed = [];
2678
+ try {
2679
+ if (lstatOrNull(path)?.isSymbolicLink() === true)
2680
+ return "failed";
2681
+ if (!existsSync3(path)) {
2682
+ writeAllowlisted(path, targetRepo, [GITATTRIBUTES_HEADER, ...rules].join("\n") + "\n", routed);
2683
+ return "written";
2684
+ }
2685
+ const existing = readFileSync5(path, "utf-8");
2686
+ const present = new Set(existing.split("\n").map((l) => l.trim()));
2687
+ const toAdd = rules.filter((rule) => !present.has(rule));
2688
+ if (toAdd.length === 0)
2689
+ return "skipped";
2690
+ const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
2691
+ const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join("\n") + "\n";
2692
+ writeAllowlisted(path, targetRepo, existing + suffix, routed);
2693
+ return "written";
2694
+ } catch {
2695
+ return "failed";
2696
+ }
2697
+ }
2823
2698
  function previousPinForEngagement(targetRepoRoot, engagementId) {
2824
2699
  try {
2825
2700
  const existing = readBundlePin(targetRepoRoot);
@@ -3070,9 +2945,6 @@ async function install(options) {
3070
2945
  break;
3071
2946
  }
3072
2947
  if (credential) {
3073
- const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
3074
- const helperOutcome = recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
3075
- noteFile(draft, targetRepo, MCP_HEADERS_HELPER_REL, helperOutcome);
3076
2948
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
3077
2949
  const existingMcp = existsSync3(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
3078
2950
  const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
@@ -3084,6 +2956,24 @@ async function install(options) {
3084
2956
  } else {
3085
2957
  report.skippedPaths.push(MCP_REGISTRATION_REL);
3086
2958
  }
2959
+ const helperRel = RETIRED_MCP_HEADERS_HELPER_REL;
2960
+ switch (removeRetiredMcpHeadersHelper(targetRepo)) {
2961
+ case "removed":
2962
+ recordRemovedFile(draft, helperRel);
2963
+ report.writtenPaths.push(`${helperRel} (REMOVED \u2014 Halfcycle no longer uses this file)`);
2964
+ break;
2965
+ case "kept-foreign":
2966
+ report.skippedPaths.push(`${helperRel} (KEPT \u2014 Halfcycle no longer uses this file, and it is not one Halfcycle generated byte for byte, so it was left alone; delete it if nothing of yours needs it)`);
2967
+ break;
2968
+ case "kept-uncommitted":
2969
+ report.skippedPaths.push(`${helperRel} (KEPT \u2014 Halfcycle no longer uses this file, but it is not a clean, committed copy in this repository, so it was left alone rather than risk losing an edit; delete it when you are ready)`);
2970
+ break;
2971
+ case "failed":
2972
+ report.skippedPaths.push(`${helperRel} (could not be removed \u2014 Halfcycle no longer uses this file, DELETE IT BY HAND)`);
2973
+ break;
2974
+ case "absent":
2975
+ break;
2976
+ }
3087
2977
  } else {
3088
2978
  report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
3089
2979
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
@@ -3099,6 +2989,15 @@ async function install(options) {
3099
2989
  } else {
3100
2990
  record(report, gitignoreOutcome, ".gitignore");
3101
2991
  }
2992
+ const gitattributesPath = join5(targetRepo, GITATTRIBUTES_REL);
2993
+ const gitattributesBefore = readTextOrNull(gitattributesPath);
2994
+ const gitattributesOutcome = reconcileGitattributes(targetRepo);
2995
+ recordGitattributesReconcile(draft, gitattributesBefore, readTextOrNull(gitattributesPath));
2996
+ if (gitattributesOutcome === "failed") {
2997
+ report.skippedPaths.push(`${GITATTRIBUTES_REL} (write failed \u2014 a clone made with core.autocrlf=true may check the hook scripts out with CRLF line endings)`);
2998
+ } else {
2999
+ record(report, gitattributesOutcome, GITATTRIBUTES_REL);
3000
+ }
3102
3001
  const identityOutcome = writeProjectIdentity(targetRepo);
3103
3002
  record(report, identityOutcome, PROJECT_IDENTITY_REL);
3104
3003
  noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
@@ -3257,6 +3156,7 @@ async function bindLoopback(env = process.env, engagementId) {
3257
3156
  const state = randomBytes(32).toString("hex");
3258
3157
  let pending = null;
3259
3158
  let deliver = null;
3159
+ let abandon = null;
3260
3160
  const server = createServer(createCallbackHandler(state, (callback) => {
3261
3161
  if (deliver)
3262
3162
  deliver(callback);
@@ -3298,12 +3198,18 @@ async function bindLoopback(env = process.env, engagementId) {
3298
3198
  clearInterval(ticker);
3299
3199
  clearTimeout(timer);
3300
3200
  deliver = null;
3201
+ abandon = null;
3301
3202
  resolve4(value);
3302
3203
  };
3303
3204
  const timer = setTimeout(() => finish(null), Math.max(0, timeoutMs));
3304
3205
  deliver = finish;
3206
+ abandon = () => finish(null);
3305
3207
  }),
3306
- close: () => closeServer(server)
3208
+ close: () => {
3209
+ if (abandon !== null)
3210
+ abandon();
3211
+ return closeServer(server);
3212
+ }
3307
3213
  };
3308
3214
  }
3309
3215
  function closeServer(server) {
@@ -3337,8 +3243,13 @@ function defaultSleep(ms) {
3337
3243
  function browserOpenCommand(os, url) {
3338
3244
  if (os === "darwin")
3339
3245
  return { command: "open", args: [url] };
3340
- if (os === "win32")
3341
- return { command: "cmd", args: ["/c", "start", "", url] };
3246
+ if (os === "win32") {
3247
+ return {
3248
+ command: "cmd.exe",
3249
+ args: ["/d", "/s", "/c", `"start "" "${url}""`],
3250
+ windowsVerbatimArguments: true
3251
+ };
3252
+ }
3342
3253
  return { command: "xdg-open", args: [url] };
3343
3254
  }
3344
3255
  function isOpenableUrl(url) {
@@ -3349,19 +3260,35 @@ function isOpenableUrl(url) {
3349
3260
  return false;
3350
3261
  }
3351
3262
  }
3263
+ function hasWin32UnsafeOpenerChars(url) {
3264
+ if (url.includes('"') || url.includes("%"))
3265
+ return true;
3266
+ for (let i = 0; i < url.length; i++) {
3267
+ const code = url.charCodeAt(i);
3268
+ if (code <= 31 || code === 127)
3269
+ return true;
3270
+ }
3271
+ return false;
3272
+ }
3352
3273
  var BROWSER_OPEN_GRACE_MS = 3e3;
3353
- async function openBrowser(url, env = process.env) {
3274
+ async function openBrowser(url, env = process.env, os = platform3(), spawnImpl = spawn) {
3354
3275
  if ((env["HALFCYCLE_NO_BROWSER"] ?? "").trim() !== "") {
3355
3276
  return { opened: false, reason: "HALFCYCLE_NO_BROWSER is set in this environment" };
3356
3277
  }
3357
3278
  if (!isOpenableUrl(url)) {
3358
3279
  return { opened: false, reason: "the sign-in address is not an http(s) URL this CLI will open" };
3359
3280
  }
3360
- const { command, args: args2 } = browserOpenCommand(platform3(), url);
3281
+ if (os === "win32" && hasWin32UnsafeOpenerChars(url)) {
3282
+ return {
3283
+ opened: false,
3284
+ reason: 'the sign-in address holds a " or %, or a control character, this opener will not pass to cmd.exe on Windows'
3285
+ };
3286
+ }
3287
+ const { command, args: args2, windowsVerbatimArguments } = browserOpenCommand(os, url);
3361
3288
  return new Promise((resolve4) => {
3362
3289
  let child;
3363
3290
  try {
3364
- child = spawn(command, args2, { stdio: "ignore", env });
3291
+ child = spawnImpl(command, args2, { stdio: "ignore", env, windowsVerbatimArguments });
3365
3292
  } catch (err) {
3366
3293
  resolve4({
3367
3294
  opened: false,
@@ -3433,82 +3360,107 @@ function tryRefusal(body) {
3433
3360
  }
3434
3361
  return { status: candidate.status, message: candidate.message };
3435
3362
  }
3363
+ async function askControlOnce(tokenUrl, started) {
3364
+ let body;
3365
+ try {
3366
+ const res = await fetch(tokenUrl, {
3367
+ method: "POST",
3368
+ headers: { "content-type": "application/json" },
3369
+ body: JSON.stringify({ deviceCode: started.deviceCode })
3370
+ });
3371
+ body = await res.json().catch(() => null);
3372
+ } catch {
3373
+ return null;
3374
+ }
3375
+ const parsed = devicePollResponseSchema.safeParse(body);
3376
+ return parsed.success ? parsed.data : null;
3377
+ }
3378
+ function tokenUrlFor(serviceUrl) {
3379
+ return `${serviceUrl.replace(/\/+$/, "")}/auth/device/token`;
3380
+ }
3381
+ function localDeadline(started) {
3382
+ return new Date(started.expiresAt).getTime() + started.pollIntervalMs * DEADLINE_SLACK_INTERVALS;
3383
+ }
3436
3384
  async function pollForCredential(serviceUrl, started, deps = {}) {
3437
3385
  const write = deps.write ?? defaultWrite;
3438
3386
  const sleep = deps.sleep ?? defaultSleep;
3439
3387
  const now = deps.now ?? Date.now;
3440
- const url = `${serviceUrl.replace(/\/+$/, "")}/auth/device/token`;
3388
+ const url = tokenUrlFor(serviceUrl);
3441
3389
  const interval = started.pollIntervalMs;
3442
- const deadline = new Date(started.expiresAt).getTime() + interval * DEADLINE_SLACK_INTERVALS;
3390
+ const deadline = localDeadline(started);
3443
3391
  const startedAt = now();
3444
3392
  let announcedAt = startedAt;
3445
3393
  for (; ; ) {
3446
- let body;
3447
- let reachable = true;
3448
- try {
3449
- const res = await fetch(url, {
3450
- method: "POST",
3451
- headers: { "content-type": "application/json" },
3452
- body: JSON.stringify({ deviceCode: started.deviceCode })
3453
- });
3454
- body = await res.json().catch(() => null);
3455
- } catch {
3456
- reachable = false;
3457
- }
3458
- if (reachable) {
3459
- const parsed = devicePollResponseSchema.safeParse(body);
3460
- if (parsed.success) {
3461
- const answer = parsed.data;
3462
- if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
3463
- return answer;
3464
- if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
3465
- throw new SignInRefused(answer.status, answer.message);
3466
- }
3394
+ const answer = await askControlOnce(url, started);
3395
+ if (answer !== null) {
3396
+ if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
3397
+ return answer;
3398
+ if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
3399
+ throw new SignInRefused(answer.status, answer.message);
3467
3400
  }
3468
3401
  }
3469
3402
  if (now() >= deadline) {
3470
3403
  throw new SignInRefused(SIGN_IN_TIMED_OUT, SIGN_IN_TIMEOUT_MESSAGE);
3471
3404
  }
3472
- if (now() - announcedAt >= STILL_WAITING_MS) {
3405
+ if (now() - announcedAt >= (deps.stillWaitingMs ?? STILL_WAITING_MS)) {
3473
3406
  announcedAt = now();
3474
- write(`[halfcycle] Still waiting for the browser (${Math.round((announcedAt - startedAt) / 1e3)}s). Code: ${started.userCode}
3475
- `);
3407
+ write(stillWaitingLine(announcedAt - startedAt, started));
3476
3408
  }
3477
3409
  await sleep(interval);
3478
3410
  }
3479
3411
  }
3412
+ function stillWaitingLine(elapsedMs, started) {
3413
+ return `[halfcycle] Still waiting for the browser (${Math.round(elapsedMs / 1e3)}s). Code: ${started.userCode}
3414
+ `;
3415
+ }
3480
3416
  var LOOPBACK_TIMEOUT_MESSAGE = "Sign-in timed out: the browser never came back to this terminal. Nothing was created. Run the command again \u2014 or, if this machine cannot hand the browser back (a remote session, a container), run it with HALFCYCLE_NO_BROWSER=1 to use the code-based sign-in instead.";
3481
- var LOOPBACK_COLLECT_ATTEMPTS = 3;
3482
- async function collectDecidedCredential(serviceUrl, started, deps) {
3483
- const sleep = deps.sleep ?? defaultSleep;
3484
- const url = `${serviceUrl.replace(/\/+$/, "")}/auth/device/token`;
3485
- let lastProblem = LOOPBACK_TIMEOUT_MESSAGE;
3486
- for (let attempt = 0; attempt < LOOPBACK_COLLECT_ATTEMPTS; attempt++) {
3487
- if (attempt > 0)
3488
- await sleep(started.pollIntervalMs);
3489
- let body;
3490
- try {
3491
- const res = await fetch(url, {
3492
- method: "POST",
3493
- headers: { "content-type": "application/json" },
3494
- body: JSON.stringify({ deviceCode: started.deviceCode })
3495
- });
3496
- body = await res.json().catch(() => null);
3497
- } catch (err) {
3498
- lastProblem = `The browser finished signing you in, but this terminal could not reach the Halfcycle service to collect the credential: ${err instanceof Error ? err.message : String(err)}. Run the command again.`;
3499
- continue;
3417
+ async function pollBesideLoopback(serviceUrl, started, wake, deps) {
3418
+ const sleep = deps.sleep;
3419
+ const now = deps.now ?? Date.now;
3420
+ const url = tokenUrlFor(serviceUrl);
3421
+ const deadline = localDeadline(started);
3422
+ let arrived = false;
3423
+ let spent = false;
3424
+ let interrupt = null;
3425
+ void wake.then(() => {
3426
+ arrived = true;
3427
+ if (interrupt !== null)
3428
+ interrupt();
3429
+ });
3430
+ for (; ; ) {
3431
+ const answer = await askControlOnce(url, started);
3432
+ if (answer !== null) {
3433
+ if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
3434
+ return answer;
3435
+ if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
3436
+ throw new SignInRefused(answer.status, answer.message);
3437
+ }
3500
3438
  }
3501
- const parsed = devicePollResponseSchema.safeParse(body);
3502
- if (!parsed.success)
3439
+ if (now() >= deadline) {
3440
+ throw new SignInRefused(SIGN_IN_TIMED_OUT, LOOPBACK_TIMEOUT_MESSAGE);
3441
+ }
3442
+ if (arrived && !spent) {
3443
+ spent = true;
3503
3444
  continue;
3504
- const answer = parsed.data;
3505
- if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
3506
- return answer;
3507
- if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
3508
- throw new SignInRefused(answer.status, answer.message);
3509
3445
  }
3446
+ if (sleep === void 0) {
3447
+ await new Promise((resolve4) => {
3448
+ const timer = setTimeout(resolve4, started.pollIntervalMs);
3449
+ interrupt = () => {
3450
+ clearTimeout(timer);
3451
+ resolve4();
3452
+ };
3453
+ });
3454
+ } else {
3455
+ await new Promise((resolve4) => {
3456
+ interrupt = resolve4;
3457
+ void sleep(started.pollIntervalMs).then(resolve4);
3458
+ });
3459
+ }
3460
+ interrupt = null;
3461
+ if (arrived)
3462
+ spent = true;
3510
3463
  }
3511
- throw new SignInRefused(SIGN_IN_TIMED_OUT, lastProblem);
3512
3464
  }
3513
3465
  async function signIn(serviceUrl, deps = {}) {
3514
3466
  const write = deps.write ?? defaultWrite;
@@ -3533,24 +3485,16 @@ async function signIn(serviceUrl, deps = {}) {
3533
3485
  const url = loopbackVerificationUrl(started.verificationUrl, bind.target);
3534
3486
  const attempt = await openBrowser(url, env);
3535
3487
  if (attempt.opened) {
3536
- write(`[halfcycle] Opened your browser to finish signing in. This terminal is waiting.
3488
+ write(`[halfcycle] Opened your browser to finish signing in. Code: ${started.userCode}. This terminal is waiting.
3537
3489
  `);
3538
- const deadline = new Date(started.expiresAt).getTime() + started.pollIntervalMs * DEADLINE_SLACK_INTERVALS;
3539
- const callback = await bind.waitForCallback({
3540
- timeoutMs: deadline - now(),
3541
- onStillWaiting: (elapsed) => (
3542
- // NO CODE IN THIS LINE. The device-code path prints the code here
3543
- // because a human is comparing it; on this path there is nothing to
3544
- // compare and criterion 1 is that none is displayed AT ALL.
3545
- write(`[halfcycle] Still waiting for the browser (${Math.round(elapsed / 1e3)}s).
3546
- `)
3547
- )
3490
+ const startedAt = now();
3491
+ const redirect = bind.waitForCallback({
3492
+ timeoutMs: localDeadline(started) - startedAt,
3493
+ onStillWaiting: (elapsed) => write(stillWaitingLine(elapsed, started)),
3494
+ stillWaitingMs: deps.stillWaitingMs ?? STILL_WAITING_MS
3548
3495
  });
3496
+ const approved = await pollBesideLoopback(serviceUrl, started, redirect.then(() => void 0), deps);
3549
3497
  await closeListener();
3550
- if (callback === null) {
3551
- throw new SignInRefused(SIGN_IN_TIMED_OUT, LOOPBACK_TIMEOUT_MESSAGE);
3552
- }
3553
- const approved = await collectDecidedCredential(serviceUrl, started, deps);
3554
3498
  write(`[halfcycle] Signed in as account ${approved.accountId} \u2014 continuing.
3555
3499
  `);
3556
3500
  return {
@@ -4214,7 +4158,7 @@ function armScript(credential, act, label, plainLabel, home) {
4214
4158
  }
4215
4159
  function identitySwitchRemedy(home, interactive) {
4216
4160
  const setOrRemove = `set HALFCYCLE_TOKEN for that account, or remove ${accountStorePath(home)}`;
4217
- return interactive ? `To act as a different account: run this again and answer "s" when asked, ${setOrRemove} to be asked for a sign-in next time.` : `To act as a different account: ${setOrRemove} and run this again with a terminal to sign in.`;
4161
+ return interactive ? `To act as a different account: run this again and ${SWITCH_ACCOUNT_ANSWER}, ${setOrRemove} to be asked for a sign-in next time.` : `To act as a different account: ${setOrRemove} and run this again with a terminal to sign in.`;
4218
4162
  }
4219
4163
  async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
4220
4164
  const write = deps.write ?? ((text) => process.stdout.write(text));
@@ -4384,7 +4328,7 @@ async function mintBoardEnterCode(baseUrl, sessionToken) {
4384
4328
 
4385
4329
  // dist/probe-mcp.js
4386
4330
  var PROBE_TIMEOUT_MS = 5e3;
4387
- async function probeMcpOrigin(mcpUrl) {
4331
+ async function probeMcpOrigin(mcpUrl, token) {
4388
4332
  const url = mcpEndpointUrl(mcpUrl);
4389
4333
  const controller = new AbortController();
4390
4334
  const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
@@ -4392,32 +4336,71 @@ async function probeMcpOrigin(mcpUrl) {
4392
4336
  try {
4393
4337
  res = await fetch(url, {
4394
4338
  method: "POST",
4395
- headers: { "content-type": "application/json" },
4339
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
4396
4340
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
4341
+ redirect: "manual",
4397
4342
  signal: controller.signal
4398
4343
  });
4399
4344
  } catch (err) {
4400
4345
  return {
4401
4346
  reached: false,
4347
+ credentialRefused: false,
4402
4348
  problem: controller.signal.aborted ? `it did not answer within ${PROBE_TIMEOUT_MS / 1e3} seconds` : `it could not be reached (${err instanceof Error ? err.message : String(err)})`
4403
4349
  };
4404
4350
  } finally {
4405
4351
  clearTimeout(timer);
4406
4352
  }
4407
4353
  if (res.status === 404) {
4408
- return { reached: false, problem: "that address answered, but it serves no /mcp endpoint" };
4354
+ return { reached: false, credentialRefused: false, problem: "that address answered, but it serves no /mcp endpoint" };
4355
+ }
4356
+ if (res.status === 401) {
4357
+ return {
4358
+ reached: false,
4359
+ credentialRefused: true,
4360
+ problem: "it answered, but refused this machine's Halfcycle credential"
4361
+ };
4362
+ }
4363
+ if (res.status >= 300 && res.status < 400) {
4364
+ return {
4365
+ reached: false,
4366
+ credentialRefused: false,
4367
+ problem: `it redirected elsewhere (${res.status}), which is not followed, so the credential was not sent on`
4368
+ };
4409
4369
  }
4410
4370
  if (!res.ok) {
4411
- return { reached: false, problem: `it answered ${res.status}` };
4371
+ return { reached: false, credentialRefused: false, problem: `it answered ${res.status}` };
4412
4372
  }
4413
4373
  const body = await res.json().catch(() => null);
4414
4374
  const name = body?.result?.serverInfo?.name;
4415
4375
  if (typeof name !== "string" || name === "") {
4416
- return { reached: false, problem: "it answered, but not as an MCP server" };
4376
+ return { reached: false, credentialRefused: false, problem: "it answered, but not as an MCP server" };
4417
4377
  }
4418
4378
  return { reached: true, serverName: name };
4419
4379
  }
4420
4380
 
4381
+ // dist/claude-version-probe.js
4382
+ import { execFileSync as execFileSync3 } from "node:child_process";
4383
+ function probeClaudeCodeVersion(timeoutMs, deps = {}) {
4384
+ const platform4 = deps.platform ?? process.platform;
4385
+ const exec = deps.exec ?? execFileSync3;
4386
+ let raw;
4387
+ try {
4388
+ raw = platform4 === "win32" ? exec("cmd.exe", ["/d", "/s", "/c", '"claude --version"'], {
4389
+ encoding: "utf-8",
4390
+ timeout: timeoutMs,
4391
+ stdio: ["ignore", "pipe", "ignore"],
4392
+ windowsVerbatimArguments: true
4393
+ }) : exec("claude", ["--version"], {
4394
+ encoding: "utf-8",
4395
+ timeout: timeoutMs,
4396
+ stdio: ["ignore", "pipe", "ignore"]
4397
+ });
4398
+ } catch {
4399
+ return void 0;
4400
+ }
4401
+ return /(\d+\.\d+\.\d+)/.exec(raw)?.[1];
4402
+ }
4403
+
4421
4404
  // dist/build-record/write.js
4422
4405
  import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, writeSync } from "node:fs";
4423
4406
  import { basename as basename2, join as join6, resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2 } from "node:path";
@@ -4993,7 +4976,7 @@ async function openPhase(credential, phase, entry, home) {
4993
4976
  }
4994
4977
 
4995
4978
  // dist/close-phase.js
4996
- import { execFileSync as execFileSync3 } from "node:child_process";
4979
+ import { execFileSync as execFileSync4 } from "node:child_process";
4997
4980
  var NO_STATEMENTS = { thesis: null, outcome: null, deviations: [] };
4998
4981
  function readCloseCommit(repoRoot) {
4999
4982
  if (repoRoot === void 0) {
@@ -5004,7 +4987,7 @@ function readCloseCommit(repoRoot) {
5004
4987
  }
5005
4988
  let out;
5006
4989
  try {
5007
- out = execFileSync3("git", ["-C", repoRoot, "rev-parse", "HEAD"], {
4990
+ out = execFileSync4("git", ["-C", repoRoot, "rev-parse", "HEAD"], {
5008
4991
  encoding: "utf-8",
5009
4992
  env: gitEnvForNamedRepo(),
5010
4993
  stdio: ["ignore", "pipe", "ignore"]
@@ -5299,7 +5282,7 @@ function validate(options) {
5299
5282
  if (lexists(pinPath)) {
5300
5283
  try {
5301
5284
  const pin = JSON.parse(readFileSync8(pinPath, "utf-8"));
5302
- const id = isPlainObject2(pin) ? pin["engagementId"] : void 0;
5285
+ const id = isPlainObject2(pin) ? pin[PIN_ENGAGEMENT_ID_FIELD] : void 0;
5303
5286
  engagementId = typeof id === "string" && id !== "" ? id : void 0;
5304
5287
  } catch {
5305
5288
  pinReadable = false;
@@ -5441,17 +5424,33 @@ function unmergeMcp(value, rec) {
5441
5424
  }
5442
5425
  return { changed, keptEntry };
5443
5426
  }
5444
- function trimGitignore(targetRepo, text, rec, writeSet) {
5427
+ function gitignoreRules(targetRepo, writeSet) {
5428
+ return {
5429
+ rel: GITIGNORE_REL,
5430
+ header: GITIGNORE_HEADER,
5431
+ ourLines: writeSet.gitignoreLines,
5432
+ keepLine: (line) => coveredPathExists(targetRepo, line)
5433
+ };
5434
+ }
5435
+ function gitattributesRules(writeSet) {
5436
+ return {
5437
+ rel: GITATTRIBUTES_REL,
5438
+ header: GITATTRIBUTES_HEADER,
5439
+ ourLines: writeSet.gitattributesLines,
5440
+ keepLine: () => false
5441
+ };
5442
+ }
5443
+ function trimAppendedBlocks(text, rec, rules) {
5445
5444
  const keptLines = [];
5446
5445
  const foreignLines = [];
5447
5446
  let current = text;
5448
5447
  for (const block of [...rec.appended].reverse()) {
5449
5448
  const lines = block.split("\n").filter((line) => line !== "");
5450
- const foreign = lines.filter((line) => !writeSet.gitignoreLines.has(line));
5449
+ const foreign = lines.filter((line) => !rules.ourLines.has(line));
5451
5450
  foreignLines.push(...foreign);
5452
- const ours = lines.filter((line) => writeSet.gitignoreLines.has(line));
5453
- const entries = ours.filter((line) => line !== GITIGNORE_HEADER);
5454
- const kept = entries.filter((line) => coveredPathExists(targetRepo, line));
5451
+ const ours = lines.filter((line) => rules.ourLines.has(line));
5452
+ const entries = ours.filter((line) => line !== rules.header);
5453
+ const kept = entries.filter((line) => rules.keepLine(line));
5455
5454
  keptLines.push(...kept);
5456
5455
  if (kept.length === 0 && foreign.length === 0 && current.includes(block)) {
5457
5456
  const at = current.lastIndexOf(block);
@@ -5459,13 +5458,13 @@ function trimGitignore(targetRepo, text, rec, writeSet) {
5459
5458
  continue;
5460
5459
  }
5461
5460
  const separated = block.startsWith("\n") || block.includes("\n\n");
5462
- const removable = ours.filter((line) => line === GITIGNORE_HEADER ? kept.length === 0 && foreign.length === 0 : !kept.includes(line));
5461
+ const removable = ours.filter((line) => line === rules.header ? kept.length === 0 && foreign.length === 0 : !kept.includes(line));
5463
5462
  for (const line of removable) {
5464
5463
  const fileLines = current.split("\n");
5465
5464
  const at = fileLines.lastIndexOf(line);
5466
5465
  if (at < 0)
5467
5466
  continue;
5468
- const withSeparator = line === GITIGNORE_HEADER && separated && at > 0 && fileLines[at - 1] === "";
5467
+ const withSeparator = line === rules.header && separated && at > 0 && fileLines[at - 1] === "";
5469
5468
  fileLines.splice(withSeparator ? at - 1 : at, withSeparator ? 2 : 1);
5470
5469
  current = fileLines.join("\n");
5471
5470
  }
@@ -5557,7 +5556,13 @@ function uninstall(options) {
5557
5556
  continue;
5558
5557
  removeEmptyCreatedDir(targetRepo, dir, writeSet, result, name);
5559
5558
  }
5560
- trimGitignoreFile(targetRepo, manifest.gitignore, writeSet, result, name);
5559
+ const keptIgnoreLines = trimAppendedFile(targetRepo, manifest.gitignore, gitignoreRules(targetRepo, writeSet), result, name);
5560
+ for (const line of keptIgnoreLines) {
5561
+ result.keptIgnoreLines.push(`${line.trim()} \u2014 ${line.trim().replace(/^\//, "")} still exists`);
5562
+ }
5563
+ if (manifest.gitattributes !== void 0) {
5564
+ trimAppendedFile(targetRepo, manifest.gitattributes, gitattributesRules(writeSet), result, name);
5565
+ }
5561
5566
  const credential = handleCredential(options, checked);
5562
5567
  const credentialFailed = credential.kind === "failed";
5563
5568
  if (result.failed.length === 0 && !credentialFailed) {
@@ -5671,40 +5676,39 @@ function removeEmptyCreatedDir(targetRepo, dir, writeSet, lists, name) {
5671
5676
  name(lists.failed, `${dir}/`, dir);
5672
5677
  }
5673
5678
  }
5674
- function trimGitignoreFile(targetRepo, rec, writeSet, lists, name) {
5679
+ function trimAppendedFile(targetRepo, rec, rules, lists, name) {
5680
+ const { rel } = rules;
5675
5681
  if (rec.appended.length === 0)
5676
- return;
5677
- if (symlinkOnPath(targetRepo, GITIGNORE_REL)) {
5678
- name(lists.leftAlone, `${GITIGNORE_REL} (a symbolic link \u2014 not followed)`, GITIGNORE_REL);
5679
- return;
5682
+ return [];
5683
+ if (symlinkOnPath(targetRepo, rel)) {
5684
+ name(lists.leftAlone, `${rel} (a symbolic link \u2014 not followed)`, rel);
5685
+ return [];
5680
5686
  }
5681
- const path = join8(targetRepo, GITIGNORE_REL);
5687
+ const path = join8(targetRepo, rel);
5682
5688
  let before;
5683
5689
  try {
5684
5690
  before = readFileSync8(path, "utf-8");
5685
5691
  } catch {
5686
- return;
5692
+ return [];
5687
5693
  }
5688
- const { text, keptLines, foreignLines } = trimGitignore(targetRepo, before, rec, writeSet);
5694
+ const { text, keptLines, foreignLines } = trimAppendedBlocks(before, rec, rules);
5689
5695
  for (const line of foreignLines) {
5690
- name(lists.leftAlone, `${GITIGNORE_REL}: the line ${JSON.stringify(line)} (Halfcycle never writes it, so it was kept)`);
5691
- }
5692
- for (const line of keptLines) {
5693
- lists.keptIgnoreLines.push(`${line.trim()} \u2014 ${line.trim().replace(/^\//, "")} still exists`);
5696
+ name(lists.leftAlone, `${rel}: the line ${JSON.stringify(line)} (Halfcycle never writes it, so it was kept)`);
5694
5697
  }
5695
5698
  if (text === before)
5696
- return;
5699
+ return keptLines;
5697
5700
  try {
5698
5701
  if (text === "" && rec.created) {
5699
5702
  rmSync3(path);
5700
- name(lists.removed, GITIGNORE_REL, GITIGNORE_REL);
5703
+ name(lists.removed, rel, rel);
5701
5704
  } else {
5702
5705
  writeFileSync5(path, text, "utf-8");
5703
- name(lists.restored, GITIGNORE_REL, GITIGNORE_REL);
5706
+ name(lists.restored, rel, rel);
5704
5707
  }
5705
5708
  } catch {
5706
- name(lists.failed, GITIGNORE_REL, GITIGNORE_REL);
5709
+ name(lists.failed, rel, rel);
5707
5710
  }
5711
+ return keptLines;
5708
5712
  }
5709
5713
  function handleCredential(options, checked) {
5710
5714
  const { removeCredential, home } = options;
@@ -5820,7 +5824,6 @@ function renderUninstallReport(result, home) {
5820
5824
  }
5821
5825
 
5822
5826
  // dist/banner-facts.js
5823
- import { execFileSync as execFileSync4 } from "node:child_process";
5824
5827
  import { readFileSync as readFileSync9 } from "node:fs";
5825
5828
  import { basename as basename3, join as join9, resolve as resolve3 } from "node:path";
5826
5829
  var BRAND_URL = "halfcycle.ai";
@@ -5841,17 +5844,8 @@ function bundleVersion() {
5841
5844
  return void 0;
5842
5845
  }
5843
5846
  }
5844
- function claudeCodeVersion() {
5845
- try {
5846
- const raw = execFileSync4("claude", ["--version"], {
5847
- encoding: "utf-8",
5848
- timeout: 3e3,
5849
- stdio: ["ignore", "pipe", "ignore"]
5850
- });
5851
- return /(\d+\.\d+\.\d+)/.exec(raw)?.[1];
5852
- } catch {
5853
- return void 0;
5854
- }
5847
+ function claudeCodeVersion(deps = {}) {
5848
+ return probeClaudeCodeVersion(3e3, deps);
5855
5849
  }
5856
5850
  var REAL_PROBES = {
5857
5851
  bundleVersion,
@@ -5952,21 +5946,10 @@ function compareVersions(a, b) {
5952
5946
  }
5953
5947
  return 0;
5954
5948
  }
5955
- function reportClaudeCodeVersion(verbose2) {
5956
- let raw;
5957
- try {
5958
- raw = execFileSync5("claude", ["--version"], {
5959
- encoding: "utf-8",
5960
- timeout: 5e3,
5961
- stdio: ["ignore", "pipe", "ignore"]
5962
- });
5963
- } catch {
5964
- return;
5965
- }
5966
- const found = /(\d+\.\d+\.\d+)/.exec(raw);
5967
- if (!found)
5949
+ function reportClaudeCodeVersion(verbose2, deps = {}) {
5950
+ const version = probeClaudeCodeVersion(5e3, deps);
5951
+ if (version === void 0)
5968
5952
  return;
5969
- const version = found[1];
5970
5953
  if (compareVersions(version, MIN_HEADERS_HELPER_VERSION) >= 0) {
5971
5954
  if (verbose2) {
5972
5955
  process.stdout.write(`[halfcycle] Claude Code ${version} detected \u2014 new enough for the credential helper.
@@ -6008,6 +5991,15 @@ function isHalfcycleMonorepo(dir) {
6008
5991
  return false;
6009
5992
  }
6010
5993
  }
5994
+ function isHomeFolder(dir) {
5995
+ try {
5996
+ const target = realpathSync(dir);
5997
+ const home = realpathSync(homedir3());
5998
+ return process.platform === "win32" ? target.toLowerCase() === home.toLowerCase() : target === home;
5999
+ } catch {
6000
+ return false;
6001
+ }
6002
+ }
6011
6003
  var RETIRED_VERBS = /* @__PURE__ */ new Map([
6012
6004
  ["ci", '"ci" was removed \u2014 CI setup is part of your project, not Halfcycle']
6013
6005
  ]);
@@ -6047,6 +6039,12 @@ ${USAGE}`);
6047
6039
  product's own source tree \u2014 installing here would write over tracked files.
6048
6040
  To see what this CLI does: halfcycle --help
6049
6041
  To install into a real project: halfcycle <path-to-that-repo>
6042
+ `);
6043
+ process.exit(1);
6044
+ return;
6045
+ }
6046
+ if (isHomeFolder(targetRepo)) {
6047
+ process.stderr.write(`halfcycle: refusing to set up in your home folder (${targetRepo}). Run "npx halfcycle" inside a project folder.
6050
6048
  `);
6051
6049
  process.exit(1);
6052
6050
  return;
@@ -6189,12 +6187,16 @@ ${USAGE}`);
6189
6187
  `);
6190
6188
  }
6191
6189
  if (credential) {
6192
- const probe = await probeMcpOrigin(credential.mcpUrl);
6190
+ const probe = await probeMcpOrigin(credential.mcpUrl, credential.token);
6193
6191
  if (probe.reached) {
6194
6192
  if (verbose) {
6195
6193
  process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
6196
6194
  `);
6197
6195
  }
6196
+ } else if (probe.credentialRefused) {
6197
+ process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 ${probe.problem}.
6198
+ [halfcycle] The address is right, but the Halfcycle commands will have no method context until it accepts this machine's credential. Re-run "npx halfcycle" in this folder; if it says this again, the refusal is on Halfcycle's side, not in this project.
6199
+ `);
6198
6200
  } else {
6199
6201
  process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
6200
6202
  [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.
@@ -6204,11 +6206,11 @@ ${USAGE}`);
6204
6206
  if (credential && verbose) {
6205
6207
  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.
6206
6208
  `);
6207
- 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.
6209
+ 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 the Halfcycle runner in ${VENDORED_BIN_REL}, so there is no token in this tree to commit and nothing for you to export.
6208
6210
  `);
6209
- 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).
6211
+ process.stdout.write(`[halfcycle] Expect a workspace trust prompt the first time you open this folder \u2014 the connection runs a command from this project, 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).
6210
6212
  `);
6211
- 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.
6213
+ process.stdout.write(`[halfcycle] If Halfcycle reads as not connected, or every call returns 401, the connection could not read GUARD_SERVICE_TOKEN from ${engagementEnvPath(engagementId)} \u2014 re-run this installer.
6212
6214
  `);
6213
6215
  }
6214
6216
  if (credential === void 0 && notThisAccountMessage !== void 0) {
@@ -6228,10 +6230,13 @@ ${USAGE}`);
6228
6230
  if (result.notShared)
6229
6231
  process.stdout.write(`${renderNotShared(result.notShared)}
6230
6232
  `);
6233
+ if (credential) {
6234
+ process.stdout.write("[halfcycle] Commit the files Halfcycle set up here, so every copy of this project runs the same checks.\n");
6235
+ }
6231
6236
  if (credential) {
6232
6237
  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
6233
6238
  `);
6234
- 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.
6239
+ process.stdout.write(`[halfcycle] Claude Code will then ask whether to use a new MCP server. The cursor starts on "Continue without using this MCP server" \u2014 choose to use it instead; without it Halfcycle will not connect.
6235
6240
  `);
6236
6241
  }
6237
6242
  if (credential) {