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/index.js CHANGED
@@ -27,6 +27,8 @@ var HALFCYCLE_DIR_NAME = ".halfcycle";
27
27
  var ENGAGEMENTS_DIR_NAME = "engagements";
28
28
  var ENGAGEMENT_ENV_FILENAME = "env";
29
29
  var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
30
+ var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
31
+ var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
30
32
  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}$";
31
33
  var ENGAGEMENT_ID_SHAPE = new RegExp(ENGAGEMENT_ID_PATTERN);
32
34
  function isEngagementId(value) {
@@ -203,6 +205,10 @@ function looksLikeSecret(text) {
203
205
  return false;
204
206
  }
205
207
 
208
+ // ../core/dist/mcp-registration.js
209
+ var MCP_REGISTRATION_REL = ".mcp.json";
210
+ var MCP_SERVER_KEY = "halfcycle";
211
+
206
212
  // ../events/dist/result.js
207
213
  import { z as z2 } from "zod";
208
214
  var firedGuardSchema = z2.object({
@@ -999,6 +1005,19 @@ var ciTokenExchangeResponseSchema = z7.object({
999
1005
  expiresAt: utcIso8601,
1000
1006
  engagementId: z7.string()
1001
1007
  }).strict();
1008
+ var NO_CREDENTIAL_PLACEHOLDER = "halfcycle:no-credential";
1009
+ var NOT_THIS_ACCOUNT_PLACEHOLDER = "halfcycle:not-this-account";
1010
+ var UPDATE_CLAUDE_CODE_PLACEHOLDER = "halfcycle:update-claude-code";
1011
+ var SWITCH_ACCOUNT_ANSWER = 'answer "s" when asked';
1012
+ 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.';
1013
+ 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}.`;
1014
+ 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.";
1015
+ 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.";
1016
+ var PLACEHOLDER_SENTENCES = Object.freeze({
1017
+ [NO_CREDENTIAL_PLACEHOLDER]: NO_CREDENTIAL_SENTENCE,
1018
+ [NOT_THIS_ACCOUNT_PLACEHOLDER]: NOT_THIS_ACCOUNT_SENTENCE,
1019
+ [UPDATE_CLAUDE_CODE_PLACEHOLDER]: UPDATE_CLAUDE_CODE_SENTENCE
1020
+ });
1002
1021
 
1003
1022
  // ../events/dist/phase-record.js
1004
1023
  import { z as z8 } from "zod";
@@ -1053,7 +1072,6 @@ function halfcycleHome(home) {
1053
1072
  var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
1054
1073
  var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
1055
1074
  var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
1056
- var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
1057
1075
  var InvalidEngagementIdError = class extends Error {
1058
1076
  constructor(engagementId) {
1059
1077
  const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
@@ -1352,6 +1370,15 @@ function bool(value, field) {
1352
1370
  throw new UnreadableManifestError(`"${field}" is not true or false`);
1353
1371
  return value;
1354
1372
  }
1373
+ function appendedTextRecord(value, field) {
1374
+ if (!isRecord(value))
1375
+ throw new UnreadableManifestError(`"${field}" is not an object`);
1376
+ onlyKeys(value, ["created", "appended"], `"${field}"`);
1377
+ return {
1378
+ created: bool(value["created"], `${field}.created`),
1379
+ appended: stringArray(value["appended"], `${field}.appended`)
1380
+ };
1381
+ }
1355
1382
  function onlyKeys(value, allowed, where) {
1356
1383
  const extra = Object.keys(value).filter((k) => !allowed.includes(k));
1357
1384
  if (extra.length > 0)
@@ -1366,7 +1393,7 @@ function parseManifest(text) {
1366
1393
  }
1367
1394
  if (!isRecord(raw))
1368
1395
  throw new UnreadableManifestError("it is not a JSON object");
1369
- onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
1396
+ onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore", "gitattributes"], "the manifest");
1370
1397
  if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
1371
1398
  throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
1372
1399
  }
@@ -1427,14 +1454,10 @@ function parseManifest(text) {
1427
1454
  ...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
1428
1455
  };
1429
1456
  }
1430
- const g = raw["gitignore"];
1431
- if (!isRecord(g))
1457
+ if (raw["gitignore"] === void 0)
1432
1458
  throw new UnreadableManifestError('"gitignore" is missing');
1433
- onlyKeys(g, ["created", "appended"], '"gitignore"');
1434
- const gitignore = {
1435
- created: bool(g["created"], "gitignore.created"),
1436
- appended: stringArray(g["appended"], "gitignore.appended")
1437
- };
1459
+ const gitignore = appendedTextRecord(raw["gitignore"], "gitignore");
1460
+ const gitattributes = raw["gitattributes"] === void 0 ? void 0 : appendedTextRecord(raw["gitattributes"], "gitattributes");
1438
1461
  const paths = files.map((f) => f.path);
1439
1462
  if (new Set(paths).size !== paths.length)
1440
1463
  throw new UnreadableManifestError('"files" names a path twice');
@@ -1449,7 +1472,8 @@ function parseManifest(text) {
1449
1472
  leftAlone,
1450
1473
  settings,
1451
1474
  ...mcp !== void 0 ? { mcp } : {},
1452
- gitignore
1475
+ gitignore,
1476
+ ...gitattributes !== void 0 ? { gitattributes } : {}
1453
1477
  };
1454
1478
  }
1455
1479
  function startManifest(previous, isMember, isMemberDir) {
@@ -1465,12 +1489,16 @@ function startManifest(previous, isMember, isMemberDir) {
1465
1489
  createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
1466
1490
  settings: previous?.settings,
1467
1491
  mcp: previous?.mcp,
1468
- gitignore: previous?.gitignore
1492
+ gitignore: previous?.gitignore,
1493
+ gitattributes: previous?.gitattributes
1469
1494
  };
1470
1495
  }
1471
1496
  function recordInstalledFile(draft, path, bytes) {
1472
1497
  draft.files.set(path, { path, sha256: sha256Hex(bytes) });
1473
1498
  }
1499
+ function recordRemovedFile(draft, path) {
1500
+ draft.files.delete(path);
1501
+ }
1474
1502
  function recordPerMachineFile(draft, path) {
1475
1503
  draft.files.set(path, { path, perMachine: true });
1476
1504
  }
@@ -1513,8 +1541,15 @@ function recordMcp(draft, observed) {
1513
1541
  };
1514
1542
  }
1515
1543
  function recordGitignore(draft, created, appended) {
1516
- const prev = draft.gitignore;
1517
- draft.gitignore = {
1544
+ draft.gitignore = carryAppended(draft.gitignore, created, appended);
1545
+ }
1546
+ function recordGitattributes(draft, created, appended) {
1547
+ if (draft.gitattributes === void 0 && !created && appended.every((a) => a === ""))
1548
+ return;
1549
+ draft.gitattributes = carryAppended(draft.gitattributes, created, appended);
1550
+ }
1551
+ function carryAppended(prev, created, appended) {
1552
+ return {
1518
1553
  created: prev?.created ?? created,
1519
1554
  appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
1520
1555
  };
@@ -1538,7 +1573,8 @@ function finishManifest(draft) {
1538
1573
  denyCreated: false
1539
1574
  },
1540
1575
  ...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
1541
- gitignore: draft.gitignore ?? { created: false, appended: [] }
1576
+ gitignore: draft.gitignore ?? { created: false, appended: [] },
1577
+ ...draft.gitattributes !== void 0 ? { gitattributes: draft.gitattributes } : {}
1542
1578
  };
1543
1579
  }
1544
1580
  function union(a, b) {
@@ -1697,11 +1733,12 @@ var WRITE_ALLOWLIST = [
1697
1733
  ".claude/hooks",
1698
1734
  ".claude/settings.json",
1699
1735
  ".halfcycle",
1700
- ".halfcycle/bundle.json",
1736
+ BUNDLE_PIN_REL,
1701
1737
  ".halfcycle/bin",
1702
1738
  "docs/build-records",
1703
1739
  "test/fixtures/captured",
1704
- ".mcp.json",
1740
+ MCP_REGISTRATION_REL,
1741
+ ".gitattributes",
1705
1742
  ZONE_B_DIR
1706
1743
  ];
1707
1744
  function isAllowlisted(targetRelPath) {
@@ -1711,15 +1748,16 @@ function isAllowlisted(targetRelPath) {
1711
1748
  return normalised === p || normalised.startsWith(p + "/");
1712
1749
  });
1713
1750
  }
1714
- var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
1715
1751
  var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
1716
1752
  var CREW_ROSTER_REL = ".halfcycle/crew.json";
1717
1753
  var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
1718
1754
  var GITIGNORE_REL = ".gitignore";
1755
+ var GITATTRIBUTES_REL = ".gitattributes";
1719
1756
  function commandStubRel(name) {
1720
1757
  return `.claude/commands/${name}.md`;
1721
1758
  }
1722
- var RETIRED_WRITE_SET_FILES = [];
1759
+ var RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
1760
+ var RETIRED_WRITE_SET_FILES = [RETIRED_MCP_HEADERS_HELPER_REL];
1723
1761
  var RETIRED_DENY_PATTERNS = [];
1724
1762
  function closedWriteSet() {
1725
1763
  const files = /* @__PURE__ */ new Set([
@@ -1734,7 +1772,7 @@ function closedWriteSet() {
1734
1772
  INSTALL_MANIFEST_REL,
1735
1773
  ...RETIRED_WRITE_SET_FILES
1736
1774
  ]);
1737
- const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
1775
+ const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL, GITATTRIBUTES_REL]);
1738
1776
  const dirs = /* @__PURE__ */ new Set();
1739
1777
  for (const path of [...files, ...merged]) {
1740
1778
  for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
@@ -1748,7 +1786,11 @@ function closedWriteSet() {
1748
1786
  ownedDirs,
1749
1787
  hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
1750
1788
  denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
1751
- gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL])
1789
+ gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
1790
+ gitattributesLines: /* @__PURE__ */ new Set([
1791
+ GITATTRIBUTES_HEADER,
1792
+ ...lfRules([...Object.keys(OWNED_GENERATED_HEADERS), ...RETIRED_WRITE_SET_FILES])
1793
+ ])
1752
1794
  };
1753
1795
  }
1754
1796
  function readPreviousManifest(targetRepo) {
@@ -1812,15 +1854,19 @@ function recordMcpAdoptionOnly(draft, existingText) {
1812
1854
  }
1813
1855
  function recordGitignoreReconcile(draft, before, after) {
1814
1856
  const adopted = draft.gitignore === void 0 && before !== null ? adoptOlderGitignoreBlock(before) : "";
1815
- let appended = "";
1816
- if (after !== null && after !== before) {
1817
- if (before === null)
1818
- appended = after;
1819
- else if (after.startsWith(before))
1820
- appended = after.slice(before.length);
1821
- }
1857
+ const appended = appendedText(before, after);
1822
1858
  recordGitignore(draft, before === null && after !== null, [adopted, appended]);
1823
1859
  }
1860
+ function recordGitattributesReconcile(draft, before, after) {
1861
+ recordGitattributes(draft, before === null && after !== null, [appendedText(before, after)]);
1862
+ }
1863
+ function appendedText(before, after) {
1864
+ if (after === null || after === before)
1865
+ return "";
1866
+ if (before === null)
1867
+ return after;
1868
+ return after.startsWith(before) ? after.slice(before.length) : "";
1869
+ }
1824
1870
  function assertManifestInWriteSet(manifest, writeSet) {
1825
1871
  const strays = [
1826
1872
  ...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
@@ -1835,7 +1881,8 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
1835
1881
  const merged = [
1836
1882
  SETTINGS_REL,
1837
1883
  ...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
1838
- ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : []
1884
+ ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : [],
1885
+ ...manifest.gitattributes !== void 0 && (manifest.gitattributes.created || manifest.gitattributes.appended.length > 0) ? [GITATTRIBUTES_REL] : []
1839
1886
  ];
1840
1887
  const collided = new Set(collidedPaths);
1841
1888
  const dirVerdicts = /* @__PURE__ */ new Map();
@@ -2063,6 +2110,20 @@ exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
2063
2110
  }
2064
2111
  var GENERATED_SESSION_START_HEADER = "# Session-start marker \u2014 generated by";
2065
2112
  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)"`;
2113
+ 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)"`;
2114
+ function noNodeReplyShell() {
2115
+ const reply = JSON.stringify({
2116
+ systemMessage: NO_NODE_SENTENCE,
2117
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: NO_NODE_SENTENCE }
2118
+ });
2119
+ if (reply.includes("'")) {
2120
+ throw new Error("[bundle install] the no-Node notice cannot be single-quoted; refusing to write the session-start script.");
2121
+ }
2122
+ return `if ! command -v node >/dev/null 2>&1; then
2123
+ printf '%s\\n' '${reply}'
2124
+ exit 0
2125
+ fi`;
2126
+ }
2066
2127
  function generateSessionStartMarker() {
2067
2128
  return `#!/usr/bin/env bash
2068
2129
  ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
@@ -2071,10 +2132,15 @@ ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
2071
2132
  # it began instead of only against the latest commit. The session id comes in on
2072
2133
  # stdin with the hook input; without one, the older session-less name is written
2073
2134
  # and the stop-time check compares against the latest commit as it used to.
2135
+ #
2136
+ # Without Node nothing Halfcycle installs here can run, so this says so once and
2137
+ # writes no marker.
2138
+ ${noNodeReplyShell()}
2074
2139
  INPUT="$(cat 2>/dev/null || true)"
2075
2140
  REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
2076
2141
  if [ -z "$REPO_ROOT" ]; then exit 0; fi
2077
- HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
2142
+ ${REPO_HASH_FROM_ROOT_SH}
2143
+ if [ -z "$HASH" ]; then exit 0; fi
2078
2144
  ${SESSION_ID_FROM_HOOK_INPUT_SH}
2079
2145
  MARKER_DIR="\${TMPDIR:-/tmp}"
2080
2146
  MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
@@ -2084,64 +2150,6 @@ else
2084
2150
  MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}.ref"
2085
2151
  fi
2086
2152
  git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
2087
-
2088
- # ---------------------------------------------------------------------------
2089
- # STANDING GUARD-COVERAGE STATEMENT (T-27; two arms since T-02b).
2090
- #
2091
- # A repository with no credential on this machine is UNGUARDED: the PostToolUse
2092
- # hook will find nothing to evaluate with, and it says so once per edit, after
2093
- # the edit. This says it once, up front, before anything is written \u2014 which is
2094
- # the one thing a session-start hook can do that nothing else can.
2095
- #
2096
- # TWO REASONS TO HAVE NO CREDENTIAL, TWO DIFFERENT THINGS TO SAY (T-02b,
2097
- # solo-project-ownership \u2014 outcome B). "You have not signed in here yet" is
2098
- # fixed by re-running the installer; "this is not your project" is permanent,
2099
- # and the same remedy would send the reader in a circle forever. The two are
2100
- # told apart by HALFCYCLE_ENV_NOT_THIS_ACCOUNT, set by \`halfcycle_env_file\`
2101
- # below from a machine-level marker beside the credential store.
2102
- #
2103
- # Everything below runs in a SUBSHELL: it sources a credential store, and a
2104
- # session-start hook must not leak an engagement's variables into whatever the
2105
- # editor runs next. Any failure inside it is swallowed; this script's exit
2106
- # status is 0 either way, and the marker above has already been written.
2107
- # ---------------------------------------------------------------------------
2108
- ${engagementResolutionShell()}
2109
-
2110
- (
2111
- if halfcycle_env_file "$REPO_ROOT"; then
2112
- set -a
2113
- . "$HALFCYCLE_ENV_FILE"
2114
- set +a
2115
- fi
2116
- if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
2117
- # OUTCOME B (T-02b, solo-project-ownership). This is not the missing-keys
2118
- # case below wearing different words: there is nothing to fix, and the loop
2119
- # that follows is skipped entirely rather than run and then overridden \u2014 a
2120
- # repository whose credential is absent for THIS reason must never be told
2121
- # to re-run the installer, which can never obtain one for it.
2122
- echo "[halfcycle] This project belongs to another Halfcycle account, so nothing here is checked."
2123
- echo "[halfcycle] Your work carries on exactly as it would without Halfcycle \u2014 no edit is"
2124
- echo "[halfcycle] evaluated, and nothing you do is reported as having passed."
2125
- echo "[halfcycle] Halfcycle is Solo today \u2014 one developer per project \u2014 with Team plans coming."
2126
- echo "[halfcycle] Ask the project's owner for view access."
2127
- echo "[halfcycle] If this account is wrong, sign in as the one that owns the project and run"
2128
- echo "[halfcycle] \\"npx halfcycle\\" again."
2129
- else
2130
- hc_missing=""
2131
- for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
2132
- eval "hc_value=\\\${$hc_key:-}"
2133
- if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
2134
- done
2135
- if [ -n "$hc_missing" ]; then
2136
- echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
2137
- echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
2138
- echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
2139
- echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
2140
- echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
2141
- echo "[halfcycle] and needs nothing configured first."
2142
- fi
2143
- fi
2144
- ) 2>/dev/null || true
2145
2153
  exit 0
2146
2154
  `;
2147
2155
  }
@@ -2157,7 +2165,8 @@ INPUT="$(cat 2>/dev/null || true)"
2157
2165
 
2158
2166
  REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
2159
2167
  if [ -z "$REPO_ROOT" ]; then exit 0; fi
2160
- HASH="$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-12)"
2168
+ ${REPO_HASH_FROM_ROOT_SH}
2169
+ if [ -z "$HASH" ]; then exit 0; fi
2161
2170
 
2162
2171
  # The session id arrives on stdin as part of the hook payload. Empty when absent.
2163
2172
  ${SESSION_ID_FROM_HOOK_INPUT_SH}
@@ -2204,7 +2213,8 @@ set -e
2204
2213
 
2205
2214
  # The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
2206
2215
  # session's cwd \u2014 a session started in a subdirectory must find the same root
2207
- # .mcp.json the editor loaded, which is the bug mcp-headers.sh already paid for.
2216
+ # .mcp.json the editor loaded, which is the bug the editor's headers command
2217
+ # already paid for.
2208
2218
  HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
2209
2219
  HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
2210
2220
  HC_REGISTRATION="$HC_PROJECT_ROOT/${MCP_REGISTRATION_REL}"
@@ -2251,7 +2261,9 @@ function isTrackedAndClean(targetRepo, relPath) {
2251
2261
  return true;
2252
2262
  }
2253
2263
  function removeLegacyCiStanza(targetRepo) {
2254
- const rel = ".halfcycle/ci-stanza.yml";
2264
+ return removeRetiredGeneratedFile(targetRepo, ".halfcycle/ci-stanza.yml", 0, CI_STANZA_KNOWN_HEADER_HASHES);
2265
+ }
2266
+ function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeaderHashes) {
2255
2267
  const path = join5(targetRepo, rel);
2256
2268
  if (!existsSync3(path))
2257
2269
  return "absent";
@@ -2261,10 +2273,9 @@ function removeLegacyCiStanza(targetRepo) {
2261
2273
  } catch {
2262
2274
  return "failed";
2263
2275
  }
2264
- const headerLine = raw.split("\n")[0] ?? "";
2276
+ const headerLine = raw.split("\n")[headerLineIndex] ?? "";
2265
2277
  const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
2266
- const ours = CI_STANZA_KNOWN_HEADER_HASHES.includes(digest);
2267
- if (!ours)
2278
+ if (!knownHeaderHashes.includes(digest))
2268
2279
  return "kept-foreign";
2269
2280
  if (!isTrackedAndClean(targetRepo, rel))
2270
2281
  return "kept-uncommitted";
@@ -2275,205 +2286,33 @@ function removeLegacyCiStanza(targetRepo) {
2275
2286
  }
2276
2287
  return "removed";
2277
2288
  }
2278
- var MCP_REGISTRATION_REL = ".mcp.json";
2279
- var MCP_SERVER_KEY = "halfcycle";
2280
- var MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
2281
- 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}"'`;
2282
- var GENERATED_MCP_HEADERS_HEADER = "# Halfcycle MCP connection headers \u2014 generated by";
2289
+ var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
2290
+ `const p=require('path'),f=require('fs'),c=require('child_process');`,
2291
+ `let d=process.cwd();`,
2292
+ `for(;;){`,
2293
+ `const b=p.join(d,'${VENDORED_BIN_REL}');`,
2294
+ `if(f.existsSync(b)){`,
2295
+ `const r=c.spawnSync(process.execPath,[b,'mcp-headers'],{stdio:'inherit'});`,
2296
+ `process.exit(typeof r.status==='number'?r.status:1)}`,
2297
+ `const u=p.dirname(d);`,
2298
+ `if(u===d)break;`,
2299
+ `d=u}`,
2300
+ `console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
2301
+ `process.exit(1)`
2302
+ ].join("")}"`;
2303
+ var MCP_HEADERS_KNOWN_HEADER_HASHES = [
2304
+ "37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
2305
+ "6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
2306
+ ];
2307
+ function removeRetiredMcpHeadersHelper(targetRepo) {
2308
+ return removeRetiredGeneratedFile(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL, 1, MCP_HEADERS_KNOWN_HEADER_HASHES);
2309
+ }
2283
2310
  var OWNED_GENERATED_HEADERS = {
2284
2311
  ".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
2285
2312
  ".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
2286
2313
  ".claude/hooks/session-end-marker.sh": GENERATED_SESSION_END_HEADER,
2287
- ".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER,
2288
- ".halfcycle/mcp-headers.sh": GENERATED_MCP_HEADERS_HEADER
2314
+ ".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
2289
2315
  };
2290
- function generateMcpHeadersHelper() {
2291
- return `#!/bin/sh
2292
- ${GENERATED_MCP_HEADERS_HEADER} the Halfcycle installer.
2293
- #
2294
- # Claude Code runs this at MCP connection time, from the session's cwd, and merges
2295
- # its stdout into the connection headers for the "halfcycle" server in .mcp.json.
2296
- #
2297
- # It exists because \${VAR} expansion in .mcp.json reads the PROCESS environment
2298
- # only: nothing loads a credential into a session, so a referenced
2299
- # \${HALFCYCLE_TOKEN} expanded to nothing and the server was silently absent from
2300
- # the tool list.
2301
- #
2302
- # This file is tracked and holds NO secret. NEITHER DOES ANY OTHER FILE IN THIS
2303
- # REPOSITORY (T-09): the credential lives at machine level, outside every checkout,
2304
- # and this script READS one line out of it (never sources \u2014 a credential store is
2305
- # not code).
2306
- set -eu
2307
-
2308
- # The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
2309
- # caller's working directory. Claude Code runs the helper from the SESSION's cwd,
2310
- # and a session started in a subdirectory still finds the root .mcp.json \u2014 so a
2311
- # relative read here silently produced no credential for anyone who started the
2312
- # session in any subdirectory of a monorepo.
2313
- SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
2314
- PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
2315
-
2316
- ${engagementResolutionShell()}
2317
-
2318
- # Read one KEY's value out of the credential store into HC_VALUE, in the CALLER's
2319
- # shell (call it as a statement, never inside $(...)). Last assignment wins,
2320
- # matching the shell's own \`.\` semantics. An optional leading \`export \` is
2321
- # tolerated because a hand-edited store may use one.
2322
- halfcycle_store_value() {
2323
- HC_VALUE=""
2324
- hc_line=$(grep "^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}$2=" "$1" | tail -n 1)
2325
- hc_v=\${hc_line#*"$2="}
2326
- hc_v=$(printf '%s' "$hc_v" | tr -d '\\r')
2327
-
2328
- # Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
2329
- # writers use the same shq: this installer and Studio's provider), because
2330
- # guard-runner.sh SOURCES the same file and an unquoted value carrying a space
2331
- # would execute its own remainder. This reader greps rather than sources, so it
2332
- # has to undo the quoting itself \u2014 and it must land on the same value the
2333
- # sourcing consumer gets, or one file has two answers.
2334
- case $hc_v in
2335
- '"'*'"') hc_v=\${hc_v#'"'}; hc_v=\${hc_v%'"'} ;;
2336
- "'"*"'")
2337
- hc_v=\${hc_v#"'"}; hc_v=\${hc_v%"'"}
2338
- # \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
2339
- # through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
2340
- # the CI platform) and BSD sed (macOS), feeding each the script from a file so
2341
- # no shell quoting is in the way:
2342
- #
2343
- # s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
2344
- # s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
2345
- #
2346
- # A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
2347
- # does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
2348
- # and the token then travels with four stray characters in it. The bracket
2349
- # expression makes the backslash literal by a construction POSIX does define.
2350
- hc_v=$(printf '%s' "$hc_v" | sed "s/'[\\]''/'/g") ;;
2351
- esac
2352
- HC_VALUE=$hc_v
2353
- }
2354
-
2355
- # The scheme and host[:port] of a URL, lowercased \u2014 or NOTHING when the URL holds
2356
- # a space or a control character anywhere. That refusal is the load-bearing part:
2357
- # a URL parser drops tabs and newlines before it reads the host, so
2358
- # "https://ours<newline>@elsewhere/" is a request to "elsewhere", while a
2359
- # line-by-line reading here would see only "https://ours". The host ends at the
2360
- # first / ? # or backslash, which is where a URL parser ends it for http(s).
2361
- halfcycle_origin() {
2362
- hc_u=$1
2363
- if [ "$(printf '%s' "$hc_u" | tr -d '[:cntrl:][:space:]')" != "$hc_u" ]; then
2364
- return 0
2365
- fi
2366
- printf '%s\\n' "$hc_u" | sed -n 's|^\\([A-Za-z][A-Za-z0-9+.-]*://[^/?#\\\\]*\\).*$|\\1|p' | tr '[:upper:]' '[:lower:]'
2367
- }
2368
-
2369
- # HALFCYCLE_TOKEN in the environment WINS, and it is the CI arm: a job with no
2370
- # browser and no per-user store exports the credential, and a stale store on a
2371
- # long-lived runner must not silently win over it. Same order, and the same reason,
2372
- # as the CLI's own resolver (\`resolve-credential.ts\`). The server it may be sent
2373
- # to is NOT taken from the environment: it is read from this machine's store in
2374
- # both arms, so a token supplied this way still goes only where this machine was
2375
- # set up to send it.
2376
- # The pin names an id that is not a project id. Re-running the installer against
2377
- # that pin refuses it too, so the one remedy that works is a fresh pin.
2378
- halfcycle_bad_id_message() {
2379
- echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names an engagement id that is not a Halfcycle project id," >&2
2380
- echo "halfcycle: so no credential was read. Delete that file and run \\"npx halfcycle\\" again: this repository" >&2
2381
- echo "halfcycle: is then set up as a new Halfcycle project." >&2
2382
- }
2383
-
2384
- TOKEN=\${HALFCYCLE_TOKEN:-}
2385
- MCP_URL=""
2386
-
2387
- if [ -n "$TOKEN" ]; then
2388
- if halfcycle_env_file "$PROJECT_ROOT"; then
2389
- halfcycle_store_value "$HALFCYCLE_ENV_FILE" HALFCYCLE_MCP_URL
2390
- MCP_URL=$HC_VALUE
2391
- fi
2392
- else
2393
- if ! halfcycle_env_file "$PROJECT_ROOT"; then
2394
- case $HALFCYCLE_ENV_PROBLEM in
2395
- no-pin)
2396
- echo "halfcycle: no .halfcycle/bundle.json in $PROJECT_ROOT, so this repository has no Halfcycle engagement." >&2
2397
- echo "halfcycle: run \\"npx halfcycle\\" here to install one." >&2 ;;
2398
- no-id)
2399
- echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names no engagement id." >&2
2400
- echo "halfcycle: run \\"npx halfcycle\\" here to rewrite it." >&2 ;;
2401
- bad-id)
2402
- halfcycle_bad_id_message ;;
2403
- no-home)
2404
- echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
2405
- *)
2406
- # OUTCOME B (T-02b, solo-project-ownership) is a \`no-credential\` PROBLEM
2407
- # with a REASON attached, checked here rather than as a fifth case value
2408
- # \u2014 see \`engagementResolutionShell\`'s own docblock for why. Re-running
2409
- # this installer can never obtain a credential for this reason, so it is
2410
- # the one sentence in this file that must not say to.
2411
- if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
2412
- echo "halfcycle: this project belongs to another Halfcycle account, so this machine has no" >&2
2413
- echo "halfcycle: credential for it and the Halfcycle tools are not available here. Nothing is" >&2
2414
- echo "halfcycle: broken \u2014 ask the project's owner for view access, or sign in as the account" >&2
2415
- echo "halfcycle: that owns it and run \\"npx halfcycle\\" again." >&2
2416
- else
2417
- echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
2418
- 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
2419
- fi ;;
2420
- esac
2421
- exit 1
2422
- fi
2423
- ENV_FILE="$HALFCYCLE_ENV_FILE"
2424
-
2425
- halfcycle_store_value "$ENV_FILE" HALFCYCLE_TOKEN
2426
- TOKEN=$HC_VALUE
2427
- if [ -z "$TOKEN" ]; then
2428
- echo "halfcycle: HALFCYCLE_TOKEN is absent or empty in $ENV_FILE." >&2
2429
- echo "halfcycle: run \\"npx halfcycle\\" in this repository to rewrite this machine's credential." >&2
2430
- exit 1
2431
- fi
2432
- halfcycle_store_value "$ENV_FILE" HALFCYCLE_MCP_URL
2433
- MCP_URL=$HC_VALUE
2434
- fi
2435
-
2436
- # THE TOKEN GOES ONLY TO THIS MACHINE'S HALFCYCLE SERVER. .mcp.json is a tracked
2437
- # file: anyone who can land a commit can change the server's url, and this script
2438
- # would then hand the credential to whatever it names. Claude Code tells the helper
2439
- # which url it is about to connect to (CLAUDE_CODE_MCP_SERVER_URL); the server this
2440
- # machine was set up against is recorded OUTSIDE the repository, beside the
2441
- # credential. The two must share scheme, host and port, or nothing is printed.
2442
- # No url at all is a refusal too: sending the credential without knowing where it
2443
- # is going is the thing this check exists to stop.
2444
- REQUESTED=\${CLAUDE_CODE_MCP_SERVER_URL:-}
2445
- if [ -z "$REQUESTED" ]; then
2446
- echo "halfcycle: Claude Code did not say which server it is connecting to, so the Halfcycle credential was not sent." >&2
2447
- echo "halfcycle: update Claude Code, then reconnect." >&2
2448
- exit 1
2449
- fi
2450
- if [ -z "$MCP_URL" ] && [ "\${HALFCYCLE_ENV_PROBLEM:-}" = "bad-id" ]; then
2451
- halfcycle_bad_id_message
2452
- exit 1
2453
- fi
2454
- if [ -z "$MCP_URL" ]; then
2455
- echo "halfcycle: this machine has no Halfcycle server address recorded for this repository, so the credential was not sent." >&2
2456
- echo "halfcycle: run \\"npx halfcycle\\" in this repository on this machine to record it." >&2
2457
- exit 1
2458
- fi
2459
- WANT=$(halfcycle_origin "$MCP_URL")
2460
- GOT=$(halfcycle_origin "$REQUESTED")
2461
- if [ -z "$WANT" ] || [ "$GOT" != "$WANT" ]; then
2462
- SHOWN=$(printf '%s' "$REQUESTED" | tr -cd '[:graph:]' | cut -c1-200)
2463
- echo "halfcycle: .mcp.json asks for this repository's Halfcycle credential to be sent to $SHOWN," >&2
2464
- echo "halfcycle: which is not this machine's Halfcycle server (\${WANT:-none recorded}). The credential was NOT sent." >&2
2465
- echo "halfcycle: if nobody meant to change .mcp.json, treat that change as suspect; \\"npx halfcycle\\" restores the entry." >&2
2466
- exit 1
2467
- fi
2468
-
2469
- # JSON-escape: backslash first, then double quote. A token carrying either would
2470
- # otherwise emit invalid JSON, and invalid JSON is dropped SILENTLY \u2014 the
2471
- # connection succeeds carrying no Authorization header at all.
2472
- ESCAPED=$(printf '%s' "$TOKEN" | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g')
2473
-
2474
- printf '{"Authorization":"Bearer %s"}\\n' "$ESCAPED"
2475
- `;
2476
- }
2477
2316
  function generateMcpRegistration(existing, mcpOrigin) {
2478
2317
  let base = { mcpServers: {} };
2479
2318
  if (existing !== null) {
@@ -2540,6 +2379,42 @@ ${header}
2540
2379
  return "failed";
2541
2380
  }
2542
2381
  }
2382
+ var GITATTRIBUTES_HEADER = "# Halfcycle \u2014 generated scripts keep LF line endings";
2383
+ function readTextOrNull(path) {
2384
+ try {
2385
+ return readFileSync4(path, "utf-8");
2386
+ } catch {
2387
+ return null;
2388
+ }
2389
+ }
2390
+ function lfRules(paths) {
2391
+ return [...paths].filter((path) => path.endsWith(".sh")).map((path) => `${path} text eol=lf`);
2392
+ }
2393
+ var REQUIRED_GITATTRIBUTES_RULES = lfRules(Object.keys(OWNED_GENERATED_HEADERS));
2394
+ function reconcileGitattributes(targetRepo) {
2395
+ const path = join5(targetRepo, GITATTRIBUTES_REL);
2396
+ const rules = REQUIRED_GITATTRIBUTES_RULES;
2397
+ const routed = [];
2398
+ try {
2399
+ if (lstatOrNull(path)?.isSymbolicLink() === true)
2400
+ return "failed";
2401
+ if (!existsSync3(path)) {
2402
+ writeAllowlisted(path, targetRepo, [GITATTRIBUTES_HEADER, ...rules].join("\n") + "\n", routed);
2403
+ return "written";
2404
+ }
2405
+ const existing = readFileSync4(path, "utf-8");
2406
+ const present = new Set(existing.split("\n").map((l) => l.trim()));
2407
+ const toAdd = rules.filter((rule) => !present.has(rule));
2408
+ if (toAdd.length === 0)
2409
+ return "skipped";
2410
+ const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
2411
+ const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join("\n") + "\n";
2412
+ writeAllowlisted(path, targetRepo, existing + suffix, routed);
2413
+ return "written";
2414
+ } catch {
2415
+ return "failed";
2416
+ }
2417
+ }
2543
2418
  function previousPinForEngagement(targetRepoRoot, engagementId) {
2544
2419
  try {
2545
2420
  const existing = readBundlePin(targetRepoRoot);
@@ -2752,9 +2627,6 @@ async function install(options) {
2752
2627
  break;
2753
2628
  }
2754
2629
  if (credential) {
2755
- const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
2756
- const helperOutcome = recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
2757
- noteFile(draft, targetRepo, MCP_HEADERS_HELPER_REL, helperOutcome);
2758
2630
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
2759
2631
  const existingMcp = existsSync3(mcpPath) ? readFileSync4(mcpPath, "utf-8") : null;
2760
2632
  const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
@@ -2766,6 +2638,24 @@ async function install(options) {
2766
2638
  } else {
2767
2639
  report.skippedPaths.push(MCP_REGISTRATION_REL);
2768
2640
  }
2641
+ const helperRel = RETIRED_MCP_HEADERS_HELPER_REL;
2642
+ switch (removeRetiredMcpHeadersHelper(targetRepo)) {
2643
+ case "removed":
2644
+ recordRemovedFile(draft, helperRel);
2645
+ report.writtenPaths.push(`${helperRel} (REMOVED \u2014 Halfcycle no longer uses this file)`);
2646
+ break;
2647
+ case "kept-foreign":
2648
+ 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)`);
2649
+ break;
2650
+ case "kept-uncommitted":
2651
+ 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)`);
2652
+ break;
2653
+ case "failed":
2654
+ report.skippedPaths.push(`${helperRel} (could not be removed \u2014 Halfcycle no longer uses this file, DELETE IT BY HAND)`);
2655
+ break;
2656
+ case "absent":
2657
+ break;
2658
+ }
2769
2659
  } else {
2770
2660
  report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
2771
2661
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
@@ -2781,6 +2671,15 @@ async function install(options) {
2781
2671
  } else {
2782
2672
  record(report, gitignoreOutcome, ".gitignore");
2783
2673
  }
2674
+ const gitattributesPath = join5(targetRepo, GITATTRIBUTES_REL);
2675
+ const gitattributesBefore = readTextOrNull(gitattributesPath);
2676
+ const gitattributesOutcome = reconcileGitattributes(targetRepo);
2677
+ recordGitattributesReconcile(draft, gitattributesBefore, readTextOrNull(gitattributesPath));
2678
+ if (gitattributesOutcome === "failed") {
2679
+ 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)`);
2680
+ } else {
2681
+ record(report, gitattributesOutcome, GITATTRIBUTES_REL);
2682
+ }
2784
2683
  const identityOutcome = writeProjectIdentity(targetRepo);
2785
2684
  record(report, identityOutcome, PROJECT_IDENTITY_REL);
2786
2685
  noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);