halfcycle 0.3.34 → 0.3.36

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) {
@@ -1218,6 +1220,7 @@ var ciTokenExchangeResponseSchema = z10.object({
1218
1220
  expiresAt: utcIso8601,
1219
1221
  engagementId: z10.string()
1220
1222
  }).strict();
1223
+ var SWITCH_ACCOUNT_ANSWER = 'answer "s" when asked';
1221
1224
 
1222
1225
  // ../events/dist/phase-record.js
1223
1226
  import { z as z11 } from "zod";
@@ -1322,7 +1325,6 @@ function forgetStoredCredential(serviceUrl, home) {
1322
1325
  var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
1323
1326
  var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
1324
1327
  var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
1325
- var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
1326
1328
  var InvalidEngagementIdError = class extends Error {
1327
1329
  constructor(engagementId) {
1328
1330
  const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
@@ -1640,6 +1642,15 @@ function bool(value, field) {
1640
1642
  throw new UnreadableManifestError(`"${field}" is not true or false`);
1641
1643
  return value;
1642
1644
  }
1645
+ function appendedTextRecord(value, field) {
1646
+ if (!isRecord(value))
1647
+ throw new UnreadableManifestError(`"${field}" is not an object`);
1648
+ onlyKeys(value, ["created", "appended"], `"${field}"`);
1649
+ return {
1650
+ created: bool(value["created"], `${field}.created`),
1651
+ appended: stringArray(value["appended"], `${field}.appended`)
1652
+ };
1653
+ }
1643
1654
  function onlyKeys(value, allowed, where) {
1644
1655
  const extra = Object.keys(value).filter((k) => !allowed.includes(k));
1645
1656
  if (extra.length > 0)
@@ -1654,7 +1665,7 @@ function parseManifest(text) {
1654
1665
  }
1655
1666
  if (!isRecord(raw))
1656
1667
  throw new UnreadableManifestError("it is not a JSON object");
1657
- onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
1668
+ onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore", "gitattributes"], "the manifest");
1658
1669
  if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
1659
1670
  throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
1660
1671
  }
@@ -1715,14 +1726,10 @@ function parseManifest(text) {
1715
1726
  ...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
1716
1727
  };
1717
1728
  }
1718
- const g = raw["gitignore"];
1719
- if (!isRecord(g))
1729
+ if (raw["gitignore"] === void 0)
1720
1730
  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
- };
1731
+ const gitignore = appendedTextRecord(raw["gitignore"], "gitignore");
1732
+ const gitattributes = raw["gitattributes"] === void 0 ? void 0 : appendedTextRecord(raw["gitattributes"], "gitattributes");
1726
1733
  const paths = files.map((f) => f.path);
1727
1734
  if (new Set(paths).size !== paths.length)
1728
1735
  throw new UnreadableManifestError('"files" names a path twice');
@@ -1737,7 +1744,8 @@ function parseManifest(text) {
1737
1744
  leftAlone,
1738
1745
  settings,
1739
1746
  ...mcp !== void 0 ? { mcp } : {},
1740
- gitignore
1747
+ gitignore,
1748
+ ...gitattributes !== void 0 ? { gitattributes } : {}
1741
1749
  };
1742
1750
  }
1743
1751
  function startManifest(previous, isMember, isMemberDir) {
@@ -1753,12 +1761,16 @@ function startManifest(previous, isMember, isMemberDir) {
1753
1761
  createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
1754
1762
  settings: previous?.settings,
1755
1763
  mcp: previous?.mcp,
1756
- gitignore: previous?.gitignore
1764
+ gitignore: previous?.gitignore,
1765
+ gitattributes: previous?.gitattributes
1757
1766
  };
1758
1767
  }
1759
1768
  function recordInstalledFile(draft, path, bytes) {
1760
1769
  draft.files.set(path, { path, sha256: sha256Hex(bytes) });
1761
1770
  }
1771
+ function recordRemovedFile(draft, path) {
1772
+ draft.files.delete(path);
1773
+ }
1762
1774
  function recordPerMachineFile(draft, path) {
1763
1775
  draft.files.set(path, { path, perMachine: true });
1764
1776
  }
@@ -1801,8 +1813,15 @@ function recordMcp(draft, observed) {
1801
1813
  };
1802
1814
  }
1803
1815
  function recordGitignore(draft, created, appended) {
1804
- const prev = draft.gitignore;
1805
- draft.gitignore = {
1816
+ draft.gitignore = carryAppended(draft.gitignore, created, appended);
1817
+ }
1818
+ function recordGitattributes(draft, created, appended) {
1819
+ if (draft.gitattributes === void 0 && !created && appended.every((a) => a === ""))
1820
+ return;
1821
+ draft.gitattributes = carryAppended(draft.gitattributes, created, appended);
1822
+ }
1823
+ function carryAppended(prev, created, appended) {
1824
+ return {
1806
1825
  created: prev?.created ?? created,
1807
1826
  appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
1808
1827
  };
@@ -1826,7 +1845,8 @@ function finishManifest(draft) {
1826
1845
  denyCreated: false
1827
1846
  },
1828
1847
  ...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
1829
- gitignore: draft.gitignore ?? { created: false, appended: [] }
1848
+ gitignore: draft.gitignore ?? { created: false, appended: [] },
1849
+ ...draft.gitattributes !== void 0 ? { gitattributes: draft.gitattributes } : {}
1830
1850
  };
1831
1851
  }
1832
1852
  function union(a, b) {
@@ -1977,11 +1997,12 @@ var WRITE_ALLOWLIST = [
1977
1997
  ".claude/hooks",
1978
1998
  ".claude/settings.json",
1979
1999
  ".halfcycle",
1980
- ".halfcycle/bundle.json",
2000
+ BUNDLE_PIN_REL,
1981
2001
  ".halfcycle/bin",
1982
2002
  "docs/build-records",
1983
2003
  "test/fixtures/captured",
1984
2004
  ".mcp.json",
2005
+ ".gitattributes",
1985
2006
  ZONE_B_DIR
1986
2007
  ];
1987
2008
  function isAllowlisted(targetRelPath) {
@@ -1991,15 +2012,16 @@ function isAllowlisted(targetRelPath) {
1991
2012
  return normalised === p || normalised.startsWith(p + "/");
1992
2013
  });
1993
2014
  }
1994
- var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
1995
2015
  var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
1996
2016
  var CREW_ROSTER_REL = ".halfcycle/crew.json";
1997
2017
  var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
1998
2018
  var GITIGNORE_REL = ".gitignore";
2019
+ var GITATTRIBUTES_REL = ".gitattributes";
1999
2020
  function commandStubRel(name) {
2000
2021
  return `.claude/commands/${name}.md`;
2001
2022
  }
2002
- var RETIRED_WRITE_SET_FILES = [];
2023
+ var RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
2024
+ var RETIRED_WRITE_SET_FILES = [RETIRED_MCP_HEADERS_HELPER_REL];
2003
2025
  var RETIRED_DENY_PATTERNS = [];
2004
2026
  function closedWriteSet() {
2005
2027
  const files = /* @__PURE__ */ new Set([
@@ -2014,7 +2036,7 @@ function closedWriteSet() {
2014
2036
  INSTALL_MANIFEST_REL,
2015
2037
  ...RETIRED_WRITE_SET_FILES
2016
2038
  ]);
2017
- const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
2039
+ const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL, GITATTRIBUTES_REL]);
2018
2040
  const dirs = /* @__PURE__ */ new Set();
2019
2041
  for (const path of [...files, ...merged]) {
2020
2042
  for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
@@ -2028,7 +2050,11 @@ function closedWriteSet() {
2028
2050
  ownedDirs,
2029
2051
  hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
2030
2052
  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])
2053
+ gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
2054
+ gitattributesLines: /* @__PURE__ */ new Set([
2055
+ GITATTRIBUTES_HEADER,
2056
+ ...lfRules([...Object.keys(OWNED_GENERATED_HEADERS), ...RETIRED_WRITE_SET_FILES])
2057
+ ])
2032
2058
  };
2033
2059
  }
2034
2060
  function readPreviousManifest(targetRepo) {
@@ -2092,15 +2118,19 @@ function recordMcpAdoptionOnly(draft, existingText) {
2092
2118
  }
2093
2119
  function recordGitignoreReconcile(draft, before, after) {
2094
2120
  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
- }
2121
+ const appended = appendedText(before, after);
2102
2122
  recordGitignore(draft, before === null && after !== null, [adopted, appended]);
2103
2123
  }
2124
+ function recordGitattributesReconcile(draft, before, after) {
2125
+ recordGitattributes(draft, before === null && after !== null, [appendedText(before, after)]);
2126
+ }
2127
+ function appendedText(before, after) {
2128
+ if (after === null || after === before)
2129
+ return "";
2130
+ if (before === null)
2131
+ return after;
2132
+ return after.startsWith(before) ? after.slice(before.length) : "";
2133
+ }
2104
2134
  function assertManifestInWriteSet(manifest, writeSet) {
2105
2135
  const strays = [
2106
2136
  ...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
@@ -2115,7 +2145,8 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
2115
2145
  const merged = [
2116
2146
  SETTINGS_REL,
2117
2147
  ...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
2118
- ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : []
2148
+ ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : [],
2149
+ ...manifest.gitattributes !== void 0 && (manifest.gitattributes.created || manifest.gitattributes.appended.length > 0) ? [GITATTRIBUTES_REL] : []
2119
2150
  ];
2120
2151
  const collided = new Set(collidedPaths);
2121
2152
  const dirVerdicts = /* @__PURE__ */ new Map();
@@ -2484,7 +2515,8 @@ set -e
2484
2515
 
2485
2516
  # The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
2486
2517
  # 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.
2518
+ # .mcp.json the editor loaded, which is the bug the editor's headers command
2519
+ # already paid for.
2488
2520
  HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
2489
2521
  HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
2490
2522
  HC_REGISTRATION="$HC_PROJECT_ROOT/${MCP_REGISTRATION_REL}"
@@ -2531,7 +2563,9 @@ function isTrackedAndClean(targetRepo, relPath) {
2531
2563
  return true;
2532
2564
  }
2533
2565
  function removeLegacyCiStanza(targetRepo) {
2534
- const rel = ".halfcycle/ci-stanza.yml";
2566
+ return removeRetiredGeneratedFile(targetRepo, ".halfcycle/ci-stanza.yml", 0, CI_STANZA_KNOWN_HEADER_HASHES);
2567
+ }
2568
+ function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeaderHashes) {
2535
2569
  const path = join5(targetRepo, rel);
2536
2570
  if (!existsSync3(path))
2537
2571
  return "absent";
@@ -2541,10 +2575,9 @@ function removeLegacyCiStanza(targetRepo) {
2541
2575
  } catch {
2542
2576
  return "failed";
2543
2577
  }
2544
- const headerLine = raw.split("\n")[0] ?? "";
2578
+ const headerLine = raw.split("\n")[headerLineIndex] ?? "";
2545
2579
  const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
2546
- const ours = CI_STANZA_KNOWN_HEADER_HASHES.includes(digest);
2547
- if (!ours)
2580
+ if (!knownHeaderHashes.includes(digest))
2548
2581
  return "kept-foreign";
2549
2582
  if (!isTrackedAndClean(targetRepo, rel))
2550
2583
  return "kept-uncommitted";
@@ -2557,203 +2590,33 @@ function removeLegacyCiStanza(targetRepo) {
2557
2590
  }
2558
2591
  var MCP_REGISTRATION_REL = ".mcp.json";
2559
2592
  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";
2593
+ var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
2594
+ `const p=require('path'),f=require('fs'),c=require('child_process');`,
2595
+ `let d=process.cwd();`,
2596
+ `for(;;){`,
2597
+ `const b=p.join(d,'${VENDORED_BIN_REL}');`,
2598
+ `if(f.existsSync(b)){`,
2599
+ `const r=c.spawnSync(process.execPath,[b,'mcp-headers'],{stdio:'inherit'});`,
2600
+ `process.exit(typeof r.status==='number'?r.status:1)}`,
2601
+ `const u=p.dirname(d);`,
2602
+ `if(u===d)break;`,
2603
+ `d=u}`,
2604
+ `console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
2605
+ `process.exit(1)`
2606
+ ].join("")}"`;
2607
+ var MCP_HEADERS_KNOWN_HEADER_HASHES = [
2608
+ "37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
2609
+ "6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
2610
+ ];
2611
+ function removeRetiredMcpHeadersHelper(targetRepo) {
2612
+ return removeRetiredGeneratedFile(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL, 1, MCP_HEADERS_KNOWN_HEADER_HASHES);
2613
+ }
2563
2614
  var OWNED_GENERATED_HEADERS = {
2564
2615
  ".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
2565
2616
  ".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
2566
2617
  ".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
2618
+ ".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
2569
2619
  };
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
2620
  function generateMcpRegistration(existing, mcpOrigin) {
2758
2621
  let base = { mcpServers: {} };
2759
2622
  if (existing !== null) {
@@ -2820,6 +2683,42 @@ ${header}
2820
2683
  return "failed";
2821
2684
  }
2822
2685
  }
2686
+ var GITATTRIBUTES_HEADER = "# Halfcycle \u2014 generated scripts keep LF line endings";
2687
+ function readTextOrNull(path) {
2688
+ try {
2689
+ return readFileSync5(path, "utf-8");
2690
+ } catch {
2691
+ return null;
2692
+ }
2693
+ }
2694
+ function lfRules(paths) {
2695
+ return [...paths].filter((path) => path.endsWith(".sh")).map((path) => `${path} text eol=lf`);
2696
+ }
2697
+ var REQUIRED_GITATTRIBUTES_RULES = lfRules(Object.keys(OWNED_GENERATED_HEADERS));
2698
+ function reconcileGitattributes(targetRepo) {
2699
+ const path = join5(targetRepo, GITATTRIBUTES_REL);
2700
+ const rules = REQUIRED_GITATTRIBUTES_RULES;
2701
+ const routed = [];
2702
+ try {
2703
+ if (lstatOrNull(path)?.isSymbolicLink() === true)
2704
+ return "failed";
2705
+ if (!existsSync3(path)) {
2706
+ writeAllowlisted(path, targetRepo, [GITATTRIBUTES_HEADER, ...rules].join("\n") + "\n", routed);
2707
+ return "written";
2708
+ }
2709
+ const existing = readFileSync5(path, "utf-8");
2710
+ const present = new Set(existing.split("\n").map((l) => l.trim()));
2711
+ const toAdd = rules.filter((rule) => !present.has(rule));
2712
+ if (toAdd.length === 0)
2713
+ return "skipped";
2714
+ const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
2715
+ const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join("\n") + "\n";
2716
+ writeAllowlisted(path, targetRepo, existing + suffix, routed);
2717
+ return "written";
2718
+ } catch {
2719
+ return "failed";
2720
+ }
2721
+ }
2823
2722
  function previousPinForEngagement(targetRepoRoot, engagementId) {
2824
2723
  try {
2825
2724
  const existing = readBundlePin(targetRepoRoot);
@@ -3070,9 +2969,6 @@ async function install(options) {
3070
2969
  break;
3071
2970
  }
3072
2971
  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
2972
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
3077
2973
  const existingMcp = existsSync3(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
3078
2974
  const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
@@ -3084,6 +2980,24 @@ async function install(options) {
3084
2980
  } else {
3085
2981
  report.skippedPaths.push(MCP_REGISTRATION_REL);
3086
2982
  }
2983
+ const helperRel = RETIRED_MCP_HEADERS_HELPER_REL;
2984
+ switch (removeRetiredMcpHeadersHelper(targetRepo)) {
2985
+ case "removed":
2986
+ recordRemovedFile(draft, helperRel);
2987
+ report.writtenPaths.push(`${helperRel} (REMOVED \u2014 Halfcycle no longer uses this file)`);
2988
+ break;
2989
+ case "kept-foreign":
2990
+ 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)`);
2991
+ break;
2992
+ case "kept-uncommitted":
2993
+ 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)`);
2994
+ break;
2995
+ case "failed":
2996
+ report.skippedPaths.push(`${helperRel} (could not be removed \u2014 Halfcycle no longer uses this file, DELETE IT BY HAND)`);
2997
+ break;
2998
+ case "absent":
2999
+ break;
3000
+ }
3087
3001
  } else {
3088
3002
  report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
3089
3003
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
@@ -3099,6 +3013,15 @@ async function install(options) {
3099
3013
  } else {
3100
3014
  record(report, gitignoreOutcome, ".gitignore");
3101
3015
  }
3016
+ const gitattributesPath = join5(targetRepo, GITATTRIBUTES_REL);
3017
+ const gitattributesBefore = readTextOrNull(gitattributesPath);
3018
+ const gitattributesOutcome = reconcileGitattributes(targetRepo);
3019
+ recordGitattributesReconcile(draft, gitattributesBefore, readTextOrNull(gitattributesPath));
3020
+ if (gitattributesOutcome === "failed") {
3021
+ 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)`);
3022
+ } else {
3023
+ record(report, gitattributesOutcome, GITATTRIBUTES_REL);
3024
+ }
3102
3025
  const identityOutcome = writeProjectIdentity(targetRepo);
3103
3026
  record(report, identityOutcome, PROJECT_IDENTITY_REL);
3104
3027
  noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
@@ -4214,7 +4137,7 @@ function armScript(credential, act, label, plainLabel, home) {
4214
4137
  }
4215
4138
  function identitySwitchRemedy(home, interactive) {
4216
4139
  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.`;
4140
+ 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
4141
  }
4219
4142
  async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
4220
4143
  const write = deps.write ?? ((text) => process.stdout.write(text));
@@ -4384,7 +4307,7 @@ async function mintBoardEnterCode(baseUrl, sessionToken) {
4384
4307
 
4385
4308
  // dist/probe-mcp.js
4386
4309
  var PROBE_TIMEOUT_MS = 5e3;
4387
- async function probeMcpOrigin(mcpUrl) {
4310
+ async function probeMcpOrigin(mcpUrl, token) {
4388
4311
  const url = mcpEndpointUrl(mcpUrl);
4389
4312
  const controller = new AbortController();
4390
4313
  const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
@@ -4392,32 +4315,71 @@ async function probeMcpOrigin(mcpUrl) {
4392
4315
  try {
4393
4316
  res = await fetch(url, {
4394
4317
  method: "POST",
4395
- headers: { "content-type": "application/json" },
4318
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
4396
4319
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
4320
+ redirect: "manual",
4397
4321
  signal: controller.signal
4398
4322
  });
4399
4323
  } catch (err) {
4400
4324
  return {
4401
4325
  reached: false,
4326
+ credentialRefused: false,
4402
4327
  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
4328
  };
4404
4329
  } finally {
4405
4330
  clearTimeout(timer);
4406
4331
  }
4407
4332
  if (res.status === 404) {
4408
- return { reached: false, problem: "that address answered, but it serves no /mcp endpoint" };
4333
+ return { reached: false, credentialRefused: false, problem: "that address answered, but it serves no /mcp endpoint" };
4334
+ }
4335
+ if (res.status === 401) {
4336
+ return {
4337
+ reached: false,
4338
+ credentialRefused: true,
4339
+ problem: "it answered, but refused this machine's Halfcycle credential"
4340
+ };
4341
+ }
4342
+ if (res.status >= 300 && res.status < 400) {
4343
+ return {
4344
+ reached: false,
4345
+ credentialRefused: false,
4346
+ problem: `it redirected elsewhere (${res.status}), which is not followed, so the credential was not sent on`
4347
+ };
4409
4348
  }
4410
4349
  if (!res.ok) {
4411
- return { reached: false, problem: `it answered ${res.status}` };
4350
+ return { reached: false, credentialRefused: false, problem: `it answered ${res.status}` };
4412
4351
  }
4413
4352
  const body = await res.json().catch(() => null);
4414
4353
  const name = body?.result?.serverInfo?.name;
4415
4354
  if (typeof name !== "string" || name === "") {
4416
- return { reached: false, problem: "it answered, but not as an MCP server" };
4355
+ return { reached: false, credentialRefused: false, problem: "it answered, but not as an MCP server" };
4417
4356
  }
4418
4357
  return { reached: true, serverName: name };
4419
4358
  }
4420
4359
 
4360
+ // dist/claude-version-probe.js
4361
+ import { execFileSync as execFileSync3 } from "node:child_process";
4362
+ function probeClaudeCodeVersion(timeoutMs, deps = {}) {
4363
+ const platform4 = deps.platform ?? process.platform;
4364
+ const exec = deps.exec ?? execFileSync3;
4365
+ let raw;
4366
+ try {
4367
+ raw = platform4 === "win32" ? exec("cmd.exe", ["/d", "/s", "/c", '"claude --version"'], {
4368
+ encoding: "utf-8",
4369
+ timeout: timeoutMs,
4370
+ stdio: ["ignore", "pipe", "ignore"],
4371
+ windowsVerbatimArguments: true
4372
+ }) : exec("claude", ["--version"], {
4373
+ encoding: "utf-8",
4374
+ timeout: timeoutMs,
4375
+ stdio: ["ignore", "pipe", "ignore"]
4376
+ });
4377
+ } catch {
4378
+ return void 0;
4379
+ }
4380
+ return /(\d+\.\d+\.\d+)/.exec(raw)?.[1];
4381
+ }
4382
+
4421
4383
  // dist/build-record/write.js
4422
4384
  import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, writeSync } from "node:fs";
4423
4385
  import { basename as basename2, join as join6, resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2 } from "node:path";
@@ -4516,7 +4478,7 @@ function renderCouldNotRun(entries, p) {
4516
4478
  }
4517
4479
  return lines.join("\n");
4518
4480
  }
4519
- function headline(observed, attested) {
4481
+ function headline(observed, attested, checks) {
4520
4482
  const met = attested.decision === "done-when-met";
4521
4483
  const problemsReported = attested.walk !== null && attested.walk.verdict === "defects";
4522
4484
  switch (observed.crossed.kind) {
@@ -4524,7 +4486,10 @@ function headline(observed, attested) {
4524
4486
  if (!met) {
4525
4487
  return "**Closed by override.** Halfcycle saw nothing outstanding, and the person closing the phase chose to override rather than state its finish conditions were met.";
4526
4488
  }
4527
- return problemsReported ? "**Closed with problems reported.** Halfcycle saw nothing outstanding, and the person closing the phase reported problems found in its acceptance walk." : "**Closed cleanly.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met.";
4489
+ if (problemsReported) {
4490
+ return "**Closed with problems reported.** Halfcycle saw nothing outstanding, and the person closing the phase reported problems found in its acceptance walk.";
4491
+ }
4492
+ return checks === "not-observed" ? "**Closed with nothing outstanding, but no checks ran.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met, but Halfcycle has no record of any check running during this phase, so this is not a clean result." : "**Closed cleanly.** Halfcycle saw nothing outstanding, and the person closing the phase stated its finish conditions were met.";
4528
4493
  case "unfinished-steps":
4529
4494
  case "clean-over-findings":
4530
4495
  return met ? "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase stated its finish conditions were met anyway." : "**Closed over outstanding work.** Halfcycle saw work still outstanding, and the person closing the phase chose to override it.";
@@ -4612,10 +4577,10 @@ function renderStated(attested, p) {
4612
4577
  }
4613
4578
  return lines.join("\n");
4614
4579
  }
4615
- function renderClose(heading, observed, attested, p) {
4580
+ function renderClose(heading, observed, attested, checks, p) {
4616
4581
  return [
4617
4582
  heading,
4618
- headline(observed, attested),
4583
+ headline(observed, attested, checks),
4619
4584
  OBSERVED_LABEL,
4620
4585
  [
4621
4586
  `- Closed at: ${closedAtText(observed.closedAt, p)}`,
@@ -4650,12 +4615,12 @@ function renderBuildRecordV2Markdown(record2) {
4650
4615
  ].join("\n"),
4651
4616
  total === 1 ? "This phase was closed once." : `This phase was closed ${String(total)} times. The latest close is described first; each earlier close follows, as it was recorded when it was made.`,
4652
4617
  ...renderChecks(record2.observed.checks, p),
4653
- ...renderClose(total === 1 ? "## The close" : `## The latest close (close ${String(latest.sequence)} of ${String(total)})`, latest, attestedBySequence.get(latest.sequence), p)
4618
+ ...renderClose(total === 1 ? "## The close" : `## The latest close (close ${String(latest.sequence)} of ${String(total)})`, latest, attestedBySequence.get(latest.sequence), record2.observed.checks.state, p)
4654
4619
  ];
4655
4620
  if (total > 1) {
4656
4621
  body.push("## Earlier closes");
4657
4622
  for (const close of closes.slice(0, -1)) {
4658
- body.push(...renderClose(`### Close ${String(close.sequence)} of ${String(total)}`, close, attestedBySequence.get(close.sequence), p));
4623
+ body.push(...renderClose(`### Close ${String(close.sequence)} of ${String(total)}`, close, attestedBySequence.get(close.sequence), record2.observed.checks.state, p));
4659
4624
  }
4660
4625
  }
4661
4626
  const header = [`${TITLE_PREFIX}${phase}`, HEADER_WHAT, HEADER_REGISTERS, HEADER_CHECKS, HEADER_KEPT];
@@ -4990,7 +4955,7 @@ async function openPhase(credential, phase, entry, home) {
4990
4955
  }
4991
4956
 
4992
4957
  // dist/close-phase.js
4993
- import { execFileSync as execFileSync3 } from "node:child_process";
4958
+ import { execFileSync as execFileSync4 } from "node:child_process";
4994
4959
  var NO_STATEMENTS = { thesis: null, outcome: null, deviations: [] };
4995
4960
  function readCloseCommit(repoRoot) {
4996
4961
  if (repoRoot === void 0) {
@@ -5001,7 +4966,7 @@ function readCloseCommit(repoRoot) {
5001
4966
  }
5002
4967
  let out;
5003
4968
  try {
5004
- out = execFileSync3("git", ["-C", repoRoot, "rev-parse", "HEAD"], {
4969
+ out = execFileSync4("git", ["-C", repoRoot, "rev-parse", "HEAD"], {
5005
4970
  encoding: "utf-8",
5006
4971
  env: gitEnvForNamedRepo(),
5007
4972
  stdio: ["ignore", "pipe", "ignore"]
@@ -5296,7 +5261,7 @@ function validate(options) {
5296
5261
  if (lexists(pinPath)) {
5297
5262
  try {
5298
5263
  const pin = JSON.parse(readFileSync8(pinPath, "utf-8"));
5299
- const id = isPlainObject2(pin) ? pin["engagementId"] : void 0;
5264
+ const id = isPlainObject2(pin) ? pin[PIN_ENGAGEMENT_ID_FIELD] : void 0;
5300
5265
  engagementId = typeof id === "string" && id !== "" ? id : void 0;
5301
5266
  } catch {
5302
5267
  pinReadable = false;
@@ -5438,17 +5403,33 @@ function unmergeMcp(value, rec) {
5438
5403
  }
5439
5404
  return { changed, keptEntry };
5440
5405
  }
5441
- function trimGitignore(targetRepo, text, rec, writeSet) {
5406
+ function gitignoreRules(targetRepo, writeSet) {
5407
+ return {
5408
+ rel: GITIGNORE_REL,
5409
+ header: GITIGNORE_HEADER,
5410
+ ourLines: writeSet.gitignoreLines,
5411
+ keepLine: (line) => coveredPathExists(targetRepo, line)
5412
+ };
5413
+ }
5414
+ function gitattributesRules(writeSet) {
5415
+ return {
5416
+ rel: GITATTRIBUTES_REL,
5417
+ header: GITATTRIBUTES_HEADER,
5418
+ ourLines: writeSet.gitattributesLines,
5419
+ keepLine: () => false
5420
+ };
5421
+ }
5422
+ function trimAppendedBlocks(text, rec, rules) {
5442
5423
  const keptLines = [];
5443
5424
  const foreignLines = [];
5444
5425
  let current = text;
5445
5426
  for (const block of [...rec.appended].reverse()) {
5446
5427
  const lines = block.split("\n").filter((line) => line !== "");
5447
- const foreign = lines.filter((line) => !writeSet.gitignoreLines.has(line));
5428
+ const foreign = lines.filter((line) => !rules.ourLines.has(line));
5448
5429
  foreignLines.push(...foreign);
5449
- const ours = lines.filter((line) => writeSet.gitignoreLines.has(line));
5450
- const entries = ours.filter((line) => line !== GITIGNORE_HEADER);
5451
- const kept = entries.filter((line) => coveredPathExists(targetRepo, line));
5430
+ const ours = lines.filter((line) => rules.ourLines.has(line));
5431
+ const entries = ours.filter((line) => line !== rules.header);
5432
+ const kept = entries.filter((line) => rules.keepLine(line));
5452
5433
  keptLines.push(...kept);
5453
5434
  if (kept.length === 0 && foreign.length === 0 && current.includes(block)) {
5454
5435
  const at = current.lastIndexOf(block);
@@ -5456,13 +5437,13 @@ function trimGitignore(targetRepo, text, rec, writeSet) {
5456
5437
  continue;
5457
5438
  }
5458
5439
  const separated = block.startsWith("\n") || block.includes("\n\n");
5459
- const removable = ours.filter((line) => line === GITIGNORE_HEADER ? kept.length === 0 && foreign.length === 0 : !kept.includes(line));
5440
+ const removable = ours.filter((line) => line === rules.header ? kept.length === 0 && foreign.length === 0 : !kept.includes(line));
5460
5441
  for (const line of removable) {
5461
5442
  const fileLines = current.split("\n");
5462
5443
  const at = fileLines.lastIndexOf(line);
5463
5444
  if (at < 0)
5464
5445
  continue;
5465
- const withSeparator = line === GITIGNORE_HEADER && separated && at > 0 && fileLines[at - 1] === "";
5446
+ const withSeparator = line === rules.header && separated && at > 0 && fileLines[at - 1] === "";
5466
5447
  fileLines.splice(withSeparator ? at - 1 : at, withSeparator ? 2 : 1);
5467
5448
  current = fileLines.join("\n");
5468
5449
  }
@@ -5554,7 +5535,13 @@ function uninstall(options) {
5554
5535
  continue;
5555
5536
  removeEmptyCreatedDir(targetRepo, dir, writeSet, result, name);
5556
5537
  }
5557
- trimGitignoreFile(targetRepo, manifest.gitignore, writeSet, result, name);
5538
+ const keptIgnoreLines = trimAppendedFile(targetRepo, manifest.gitignore, gitignoreRules(targetRepo, writeSet), result, name);
5539
+ for (const line of keptIgnoreLines) {
5540
+ result.keptIgnoreLines.push(`${line.trim()} \u2014 ${line.trim().replace(/^\//, "")} still exists`);
5541
+ }
5542
+ if (manifest.gitattributes !== void 0) {
5543
+ trimAppendedFile(targetRepo, manifest.gitattributes, gitattributesRules(writeSet), result, name);
5544
+ }
5558
5545
  const credential = handleCredential(options, checked);
5559
5546
  const credentialFailed = credential.kind === "failed";
5560
5547
  if (result.failed.length === 0 && !credentialFailed) {
@@ -5668,40 +5655,39 @@ function removeEmptyCreatedDir(targetRepo, dir, writeSet, lists, name) {
5668
5655
  name(lists.failed, `${dir}/`, dir);
5669
5656
  }
5670
5657
  }
5671
- function trimGitignoreFile(targetRepo, rec, writeSet, lists, name) {
5658
+ function trimAppendedFile(targetRepo, rec, rules, lists, name) {
5659
+ const { rel } = rules;
5672
5660
  if (rec.appended.length === 0)
5673
- return;
5674
- if (symlinkOnPath(targetRepo, GITIGNORE_REL)) {
5675
- name(lists.leftAlone, `${GITIGNORE_REL} (a symbolic link \u2014 not followed)`, GITIGNORE_REL);
5676
- return;
5661
+ return [];
5662
+ if (symlinkOnPath(targetRepo, rel)) {
5663
+ name(lists.leftAlone, `${rel} (a symbolic link \u2014 not followed)`, rel);
5664
+ return [];
5677
5665
  }
5678
- const path = join8(targetRepo, GITIGNORE_REL);
5666
+ const path = join8(targetRepo, rel);
5679
5667
  let before;
5680
5668
  try {
5681
5669
  before = readFileSync8(path, "utf-8");
5682
5670
  } catch {
5683
- return;
5671
+ return [];
5684
5672
  }
5685
- const { text, keptLines, foreignLines } = trimGitignore(targetRepo, before, rec, writeSet);
5673
+ const { text, keptLines, foreignLines } = trimAppendedBlocks(before, rec, rules);
5686
5674
  for (const line of foreignLines) {
5687
- name(lists.leftAlone, `${GITIGNORE_REL}: the line ${JSON.stringify(line)} (Halfcycle never writes it, so it was kept)`);
5688
- }
5689
- for (const line of keptLines) {
5690
- lists.keptIgnoreLines.push(`${line.trim()} \u2014 ${line.trim().replace(/^\//, "")} still exists`);
5675
+ name(lists.leftAlone, `${rel}: the line ${JSON.stringify(line)} (Halfcycle never writes it, so it was kept)`);
5691
5676
  }
5692
5677
  if (text === before)
5693
- return;
5678
+ return keptLines;
5694
5679
  try {
5695
5680
  if (text === "" && rec.created) {
5696
5681
  rmSync3(path);
5697
- name(lists.removed, GITIGNORE_REL, GITIGNORE_REL);
5682
+ name(lists.removed, rel, rel);
5698
5683
  } else {
5699
5684
  writeFileSync5(path, text, "utf-8");
5700
- name(lists.restored, GITIGNORE_REL, GITIGNORE_REL);
5685
+ name(lists.restored, rel, rel);
5701
5686
  }
5702
5687
  } catch {
5703
- name(lists.failed, GITIGNORE_REL, GITIGNORE_REL);
5688
+ name(lists.failed, rel, rel);
5704
5689
  }
5690
+ return keptLines;
5705
5691
  }
5706
5692
  function handleCredential(options, checked) {
5707
5693
  const { removeCredential, home } = options;
@@ -5817,7 +5803,6 @@ function renderUninstallReport(result, home) {
5817
5803
  }
5818
5804
 
5819
5805
  // dist/banner-facts.js
5820
- import { execFileSync as execFileSync4 } from "node:child_process";
5821
5806
  import { readFileSync as readFileSync9 } from "node:fs";
5822
5807
  import { basename as basename3, join as join9, resolve as resolve3 } from "node:path";
5823
5808
  var BRAND_URL = "halfcycle.ai";
@@ -5838,17 +5823,8 @@ function bundleVersion() {
5838
5823
  return void 0;
5839
5824
  }
5840
5825
  }
5841
- function claudeCodeVersion() {
5842
- try {
5843
- const raw = execFileSync4("claude", ["--version"], {
5844
- encoding: "utf-8",
5845
- timeout: 3e3,
5846
- stdio: ["ignore", "pipe", "ignore"]
5847
- });
5848
- return /(\d+\.\d+\.\d+)/.exec(raw)?.[1];
5849
- } catch {
5850
- return void 0;
5851
- }
5826
+ function claudeCodeVersion(deps = {}) {
5827
+ return probeClaudeCodeVersion(3e3, deps);
5852
5828
  }
5853
5829
  var REAL_PROBES = {
5854
5830
  bundleVersion,
@@ -5949,21 +5925,10 @@ function compareVersions(a, b) {
5949
5925
  }
5950
5926
  return 0;
5951
5927
  }
5952
- function reportClaudeCodeVersion(verbose2) {
5953
- let raw;
5954
- try {
5955
- raw = execFileSync5("claude", ["--version"], {
5956
- encoding: "utf-8",
5957
- timeout: 5e3,
5958
- stdio: ["ignore", "pipe", "ignore"]
5959
- });
5960
- } catch {
5928
+ function reportClaudeCodeVersion(verbose2, deps = {}) {
5929
+ const version = probeClaudeCodeVersion(5e3, deps);
5930
+ if (version === void 0)
5961
5931
  return;
5962
- }
5963
- const found = /(\d+\.\d+\.\d+)/.exec(raw);
5964
- if (!found)
5965
- return;
5966
- const version = found[1];
5967
5932
  if (compareVersions(version, MIN_HEADERS_HELPER_VERSION) >= 0) {
5968
5933
  if (verbose2) {
5969
5934
  process.stdout.write(`[halfcycle] Claude Code ${version} detected \u2014 new enough for the credential helper.
@@ -6005,6 +5970,15 @@ function isHalfcycleMonorepo(dir) {
6005
5970
  return false;
6006
5971
  }
6007
5972
  }
5973
+ function isHomeFolder(dir) {
5974
+ try {
5975
+ const target = realpathSync(dir);
5976
+ const home = realpathSync(homedir3());
5977
+ return process.platform === "win32" ? target.toLowerCase() === home.toLowerCase() : target === home;
5978
+ } catch {
5979
+ return false;
5980
+ }
5981
+ }
6008
5982
  var RETIRED_VERBS = /* @__PURE__ */ new Map([
6009
5983
  ["ci", '"ci" was removed \u2014 CI setup is part of your project, not Halfcycle']
6010
5984
  ]);
@@ -6044,6 +6018,12 @@ ${USAGE}`);
6044
6018
  product's own source tree \u2014 installing here would write over tracked files.
6045
6019
  To see what this CLI does: halfcycle --help
6046
6020
  To install into a real project: halfcycle <path-to-that-repo>
6021
+ `);
6022
+ process.exit(1);
6023
+ return;
6024
+ }
6025
+ if (isHomeFolder(targetRepo)) {
6026
+ process.stderr.write(`halfcycle: refusing to set up in your home folder (${targetRepo}). Run "npx halfcycle" inside a project folder.
6047
6027
  `);
6048
6028
  process.exit(1);
6049
6029
  return;
@@ -6186,12 +6166,16 @@ ${USAGE}`);
6186
6166
  `);
6187
6167
  }
6188
6168
  if (credential) {
6189
- const probe = await probeMcpOrigin(credential.mcpUrl);
6169
+ const probe = await probeMcpOrigin(credential.mcpUrl, credential.token);
6190
6170
  if (probe.reached) {
6191
6171
  if (verbose) {
6192
6172
  process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
6193
6173
  `);
6194
6174
  }
6175
+ } else if (probe.credentialRefused) {
6176
+ process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 ${probe.problem}.
6177
+ [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.
6178
+ `);
6195
6179
  } else {
6196
6180
  process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
6197
6181
  [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.
@@ -6201,11 +6185,11 @@ ${USAGE}`);
6201
6185
  if (credential && verbose) {
6202
6186
  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.
6203
6187
  `);
6204
- 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.
6188
+ 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.
6205
6189
  `);
6206
- 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).
6190
+ 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).
6207
6191
  `);
6208
- 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.
6192
+ 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.
6209
6193
  `);
6210
6194
  }
6211
6195
  if (credential === void 0 && notThisAccountMessage !== void 0) {