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/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) {
@@ -1053,7 +1055,6 @@ function halfcycleHome(home) {
1053
1055
  var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
1054
1056
  var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
1055
1057
  var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
1056
- var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
1057
1058
  var InvalidEngagementIdError = class extends Error {
1058
1059
  constructor(engagementId) {
1059
1060
  const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
@@ -1352,6 +1353,15 @@ function bool(value, field) {
1352
1353
  throw new UnreadableManifestError(`"${field}" is not true or false`);
1353
1354
  return value;
1354
1355
  }
1356
+ function appendedTextRecord(value, field) {
1357
+ if (!isRecord(value))
1358
+ throw new UnreadableManifestError(`"${field}" is not an object`);
1359
+ onlyKeys(value, ["created", "appended"], `"${field}"`);
1360
+ return {
1361
+ created: bool(value["created"], `${field}.created`),
1362
+ appended: stringArray(value["appended"], `${field}.appended`)
1363
+ };
1364
+ }
1355
1365
  function onlyKeys(value, allowed, where) {
1356
1366
  const extra = Object.keys(value).filter((k) => !allowed.includes(k));
1357
1367
  if (extra.length > 0)
@@ -1366,7 +1376,7 @@ function parseManifest(text) {
1366
1376
  }
1367
1377
  if (!isRecord(raw))
1368
1378
  throw new UnreadableManifestError("it is not a JSON object");
1369
- onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
1379
+ onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore", "gitattributes"], "the manifest");
1370
1380
  if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
1371
1381
  throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
1372
1382
  }
@@ -1427,14 +1437,10 @@ function parseManifest(text) {
1427
1437
  ...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
1428
1438
  };
1429
1439
  }
1430
- const g = raw["gitignore"];
1431
- if (!isRecord(g))
1440
+ if (raw["gitignore"] === void 0)
1432
1441
  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
- };
1442
+ const gitignore = appendedTextRecord(raw["gitignore"], "gitignore");
1443
+ const gitattributes = raw["gitattributes"] === void 0 ? void 0 : appendedTextRecord(raw["gitattributes"], "gitattributes");
1438
1444
  const paths = files.map((f) => f.path);
1439
1445
  if (new Set(paths).size !== paths.length)
1440
1446
  throw new UnreadableManifestError('"files" names a path twice');
@@ -1449,7 +1455,8 @@ function parseManifest(text) {
1449
1455
  leftAlone,
1450
1456
  settings,
1451
1457
  ...mcp !== void 0 ? { mcp } : {},
1452
- gitignore
1458
+ gitignore,
1459
+ ...gitattributes !== void 0 ? { gitattributes } : {}
1453
1460
  };
1454
1461
  }
1455
1462
  function startManifest(previous, isMember, isMemberDir) {
@@ -1465,12 +1472,16 @@ function startManifest(previous, isMember, isMemberDir) {
1465
1472
  createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
1466
1473
  settings: previous?.settings,
1467
1474
  mcp: previous?.mcp,
1468
- gitignore: previous?.gitignore
1475
+ gitignore: previous?.gitignore,
1476
+ gitattributes: previous?.gitattributes
1469
1477
  };
1470
1478
  }
1471
1479
  function recordInstalledFile(draft, path, bytes) {
1472
1480
  draft.files.set(path, { path, sha256: sha256Hex(bytes) });
1473
1481
  }
1482
+ function recordRemovedFile(draft, path) {
1483
+ draft.files.delete(path);
1484
+ }
1474
1485
  function recordPerMachineFile(draft, path) {
1475
1486
  draft.files.set(path, { path, perMachine: true });
1476
1487
  }
@@ -1513,8 +1524,15 @@ function recordMcp(draft, observed) {
1513
1524
  };
1514
1525
  }
1515
1526
  function recordGitignore(draft, created, appended) {
1516
- const prev = draft.gitignore;
1517
- draft.gitignore = {
1527
+ draft.gitignore = carryAppended(draft.gitignore, created, appended);
1528
+ }
1529
+ function recordGitattributes(draft, created, appended) {
1530
+ if (draft.gitattributes === void 0 && !created && appended.every((a) => a === ""))
1531
+ return;
1532
+ draft.gitattributes = carryAppended(draft.gitattributes, created, appended);
1533
+ }
1534
+ function carryAppended(prev, created, appended) {
1535
+ return {
1518
1536
  created: prev?.created ?? created,
1519
1537
  appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
1520
1538
  };
@@ -1538,7 +1556,8 @@ function finishManifest(draft) {
1538
1556
  denyCreated: false
1539
1557
  },
1540
1558
  ...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
1541
- gitignore: draft.gitignore ?? { created: false, appended: [] }
1559
+ gitignore: draft.gitignore ?? { created: false, appended: [] },
1560
+ ...draft.gitattributes !== void 0 ? { gitattributes: draft.gitattributes } : {}
1542
1561
  };
1543
1562
  }
1544
1563
  function union(a, b) {
@@ -1697,11 +1716,12 @@ var WRITE_ALLOWLIST = [
1697
1716
  ".claude/hooks",
1698
1717
  ".claude/settings.json",
1699
1718
  ".halfcycle",
1700
- ".halfcycle/bundle.json",
1719
+ BUNDLE_PIN_REL,
1701
1720
  ".halfcycle/bin",
1702
1721
  "docs/build-records",
1703
1722
  "test/fixtures/captured",
1704
1723
  ".mcp.json",
1724
+ ".gitattributes",
1705
1725
  ZONE_B_DIR
1706
1726
  ];
1707
1727
  function isAllowlisted(targetRelPath) {
@@ -1711,15 +1731,16 @@ function isAllowlisted(targetRelPath) {
1711
1731
  return normalised === p || normalised.startsWith(p + "/");
1712
1732
  });
1713
1733
  }
1714
- var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
1715
1734
  var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
1716
1735
  var CREW_ROSTER_REL = ".halfcycle/crew.json";
1717
1736
  var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
1718
1737
  var GITIGNORE_REL = ".gitignore";
1738
+ var GITATTRIBUTES_REL = ".gitattributes";
1719
1739
  function commandStubRel(name) {
1720
1740
  return `.claude/commands/${name}.md`;
1721
1741
  }
1722
- var RETIRED_WRITE_SET_FILES = [];
1742
+ var RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
1743
+ var RETIRED_WRITE_SET_FILES = [RETIRED_MCP_HEADERS_HELPER_REL];
1723
1744
  var RETIRED_DENY_PATTERNS = [];
1724
1745
  function closedWriteSet() {
1725
1746
  const files = /* @__PURE__ */ new Set([
@@ -1734,7 +1755,7 @@ function closedWriteSet() {
1734
1755
  INSTALL_MANIFEST_REL,
1735
1756
  ...RETIRED_WRITE_SET_FILES
1736
1757
  ]);
1737
- const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
1758
+ const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL, GITATTRIBUTES_REL]);
1738
1759
  const dirs = /* @__PURE__ */ new Set();
1739
1760
  for (const path of [...files, ...merged]) {
1740
1761
  for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
@@ -1748,7 +1769,11 @@ function closedWriteSet() {
1748
1769
  ownedDirs,
1749
1770
  hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
1750
1771
  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])
1772
+ gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
1773
+ gitattributesLines: /* @__PURE__ */ new Set([
1774
+ GITATTRIBUTES_HEADER,
1775
+ ...lfRules([...Object.keys(OWNED_GENERATED_HEADERS), ...RETIRED_WRITE_SET_FILES])
1776
+ ])
1752
1777
  };
1753
1778
  }
1754
1779
  function readPreviousManifest(targetRepo) {
@@ -1812,15 +1837,19 @@ function recordMcpAdoptionOnly(draft, existingText) {
1812
1837
  }
1813
1838
  function recordGitignoreReconcile(draft, before, after) {
1814
1839
  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
- }
1840
+ const appended = appendedText(before, after);
1822
1841
  recordGitignore(draft, before === null && after !== null, [adopted, appended]);
1823
1842
  }
1843
+ function recordGitattributesReconcile(draft, before, after) {
1844
+ recordGitattributes(draft, before === null && after !== null, [appendedText(before, after)]);
1845
+ }
1846
+ function appendedText(before, after) {
1847
+ if (after === null || after === before)
1848
+ return "";
1849
+ if (before === null)
1850
+ return after;
1851
+ return after.startsWith(before) ? after.slice(before.length) : "";
1852
+ }
1824
1853
  function assertManifestInWriteSet(manifest, writeSet) {
1825
1854
  const strays = [
1826
1855
  ...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
@@ -1835,7 +1864,8 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
1835
1864
  const merged = [
1836
1865
  SETTINGS_REL,
1837
1866
  ...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
1838
- ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : []
1867
+ ...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : [],
1868
+ ...manifest.gitattributes !== void 0 && (manifest.gitattributes.created || manifest.gitattributes.appended.length > 0) ? [GITATTRIBUTES_REL] : []
1839
1869
  ];
1840
1870
  const collided = new Set(collidedPaths);
1841
1871
  const dirVerdicts = /* @__PURE__ */ new Map();
@@ -2204,7 +2234,8 @@ set -e
2204
2234
 
2205
2235
  # The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
2206
2236
  # 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.
2237
+ # .mcp.json the editor loaded, which is the bug the editor's headers command
2238
+ # already paid for.
2208
2239
  HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
2209
2240
  HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
2210
2241
  HC_REGISTRATION="$HC_PROJECT_ROOT/${MCP_REGISTRATION_REL}"
@@ -2251,7 +2282,9 @@ function isTrackedAndClean(targetRepo, relPath) {
2251
2282
  return true;
2252
2283
  }
2253
2284
  function removeLegacyCiStanza(targetRepo) {
2254
- const rel = ".halfcycle/ci-stanza.yml";
2285
+ return removeRetiredGeneratedFile(targetRepo, ".halfcycle/ci-stanza.yml", 0, CI_STANZA_KNOWN_HEADER_HASHES);
2286
+ }
2287
+ function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeaderHashes) {
2255
2288
  const path = join5(targetRepo, rel);
2256
2289
  if (!existsSync3(path))
2257
2290
  return "absent";
@@ -2261,10 +2294,9 @@ function removeLegacyCiStanza(targetRepo) {
2261
2294
  } catch {
2262
2295
  return "failed";
2263
2296
  }
2264
- const headerLine = raw.split("\n")[0] ?? "";
2297
+ const headerLine = raw.split("\n")[headerLineIndex] ?? "";
2265
2298
  const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
2266
- const ours = CI_STANZA_KNOWN_HEADER_HASHES.includes(digest);
2267
- if (!ours)
2299
+ if (!knownHeaderHashes.includes(digest))
2268
2300
  return "kept-foreign";
2269
2301
  if (!isTrackedAndClean(targetRepo, rel))
2270
2302
  return "kept-uncommitted";
@@ -2277,203 +2309,33 @@ function removeLegacyCiStanza(targetRepo) {
2277
2309
  }
2278
2310
  var MCP_REGISTRATION_REL = ".mcp.json";
2279
2311
  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";
2312
+ var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
2313
+ `const p=require('path'),f=require('fs'),c=require('child_process');`,
2314
+ `let d=process.cwd();`,
2315
+ `for(;;){`,
2316
+ `const b=p.join(d,'${VENDORED_BIN_REL}');`,
2317
+ `if(f.existsSync(b)){`,
2318
+ `const r=c.spawnSync(process.execPath,[b,'mcp-headers'],{stdio:'inherit'});`,
2319
+ `process.exit(typeof r.status==='number'?r.status:1)}`,
2320
+ `const u=p.dirname(d);`,
2321
+ `if(u===d)break;`,
2322
+ `d=u}`,
2323
+ `console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
2324
+ `process.exit(1)`
2325
+ ].join("")}"`;
2326
+ var MCP_HEADERS_KNOWN_HEADER_HASHES = [
2327
+ "37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
2328
+ "6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
2329
+ ];
2330
+ function removeRetiredMcpHeadersHelper(targetRepo) {
2331
+ return removeRetiredGeneratedFile(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL, 1, MCP_HEADERS_KNOWN_HEADER_HASHES);
2332
+ }
2283
2333
  var OWNED_GENERATED_HEADERS = {
2284
2334
  ".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
2285
2335
  ".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
2286
2336
  ".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
2337
+ ".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
2289
2338
  };
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
2339
  function generateMcpRegistration(existing, mcpOrigin) {
2478
2340
  let base = { mcpServers: {} };
2479
2341
  if (existing !== null) {
@@ -2540,6 +2402,42 @@ ${header}
2540
2402
  return "failed";
2541
2403
  }
2542
2404
  }
2405
+ var GITATTRIBUTES_HEADER = "# Halfcycle \u2014 generated scripts keep LF line endings";
2406
+ function readTextOrNull(path) {
2407
+ try {
2408
+ return readFileSync4(path, "utf-8");
2409
+ } catch {
2410
+ return null;
2411
+ }
2412
+ }
2413
+ function lfRules(paths) {
2414
+ return [...paths].filter((path) => path.endsWith(".sh")).map((path) => `${path} text eol=lf`);
2415
+ }
2416
+ var REQUIRED_GITATTRIBUTES_RULES = lfRules(Object.keys(OWNED_GENERATED_HEADERS));
2417
+ function reconcileGitattributes(targetRepo) {
2418
+ const path = join5(targetRepo, GITATTRIBUTES_REL);
2419
+ const rules = REQUIRED_GITATTRIBUTES_RULES;
2420
+ const routed = [];
2421
+ try {
2422
+ if (lstatOrNull(path)?.isSymbolicLink() === true)
2423
+ return "failed";
2424
+ if (!existsSync3(path)) {
2425
+ writeAllowlisted(path, targetRepo, [GITATTRIBUTES_HEADER, ...rules].join("\n") + "\n", routed);
2426
+ return "written";
2427
+ }
2428
+ const existing = readFileSync4(path, "utf-8");
2429
+ const present = new Set(existing.split("\n").map((l) => l.trim()));
2430
+ const toAdd = rules.filter((rule) => !present.has(rule));
2431
+ if (toAdd.length === 0)
2432
+ return "skipped";
2433
+ const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
2434
+ const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join("\n") + "\n";
2435
+ writeAllowlisted(path, targetRepo, existing + suffix, routed);
2436
+ return "written";
2437
+ } catch {
2438
+ return "failed";
2439
+ }
2440
+ }
2543
2441
  function previousPinForEngagement(targetRepoRoot, engagementId) {
2544
2442
  try {
2545
2443
  const existing = readBundlePin(targetRepoRoot);
@@ -2752,9 +2650,6 @@ async function install(options) {
2752
2650
  break;
2753
2651
  }
2754
2652
  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
2653
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
2759
2654
  const existingMcp = existsSync3(mcpPath) ? readFileSync4(mcpPath, "utf-8") : null;
2760
2655
  const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
@@ -2766,6 +2661,24 @@ async function install(options) {
2766
2661
  } else {
2767
2662
  report.skippedPaths.push(MCP_REGISTRATION_REL);
2768
2663
  }
2664
+ const helperRel = RETIRED_MCP_HEADERS_HELPER_REL;
2665
+ switch (removeRetiredMcpHeadersHelper(targetRepo)) {
2666
+ case "removed":
2667
+ recordRemovedFile(draft, helperRel);
2668
+ report.writtenPaths.push(`${helperRel} (REMOVED \u2014 Halfcycle no longer uses this file)`);
2669
+ break;
2670
+ case "kept-foreign":
2671
+ 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)`);
2672
+ break;
2673
+ case "kept-uncommitted":
2674
+ 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)`);
2675
+ break;
2676
+ case "failed":
2677
+ report.skippedPaths.push(`${helperRel} (could not be removed \u2014 Halfcycle no longer uses this file, DELETE IT BY HAND)`);
2678
+ break;
2679
+ case "absent":
2680
+ break;
2681
+ }
2769
2682
  } else {
2770
2683
  report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
2771
2684
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
@@ -2781,6 +2694,15 @@ async function install(options) {
2781
2694
  } else {
2782
2695
  record(report, gitignoreOutcome, ".gitignore");
2783
2696
  }
2697
+ const gitattributesPath = join5(targetRepo, GITATTRIBUTES_REL);
2698
+ const gitattributesBefore = readTextOrNull(gitattributesPath);
2699
+ const gitattributesOutcome = reconcileGitattributes(targetRepo);
2700
+ recordGitattributesReconcile(draft, gitattributesBefore, readTextOrNull(gitattributesPath));
2701
+ if (gitattributesOutcome === "failed") {
2702
+ 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)`);
2703
+ } else {
2704
+ record(report, gitattributesOutcome, GITATTRIBUTES_REL);
2705
+ }
2784
2706
  const identityOutcome = writeProjectIdentity(targetRepo);
2785
2707
  record(report, identityOutcome, PROJECT_IDENTITY_REL);
2786
2708
  noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
@@ -3554,7 +3476,7 @@ function renderCouldNotRun(entries, p) {
3554
3476
  }
3555
3477
  return lines.join("\n");
3556
3478
  }
3557
- function headline(observed, attested) {
3479
+ function headline(observed, attested, checks) {
3558
3480
  const met = attested.decision === "done-when-met";
3559
3481
  const problemsReported = attested.walk !== null && attested.walk.verdict === "defects";
3560
3482
  switch (observed.crossed.kind) {
@@ -3562,7 +3484,10 @@ function headline(observed, attested) {
3562
3484
  if (!met) {
3563
3485
  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.";
3564
3486
  }
3565
- 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.";
3487
+ if (problemsReported) {
3488
+ return "**Closed with problems reported.** Halfcycle saw nothing outstanding, and the person closing the phase reported problems found in its acceptance walk.";
3489
+ }
3490
+ 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.";
3566
3491
  case "unfinished-steps":
3567
3492
  case "clean-over-findings":
3568
3493
  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.";
@@ -3650,10 +3575,10 @@ function renderStated(attested, p) {
3650
3575
  }
3651
3576
  return lines.join("\n");
3652
3577
  }
3653
- function renderClose(heading, observed, attested, p) {
3578
+ function renderClose(heading, observed, attested, checks, p) {
3654
3579
  return [
3655
3580
  heading,
3656
- headline(observed, attested),
3581
+ headline(observed, attested, checks),
3657
3582
  OBSERVED_LABEL,
3658
3583
  [
3659
3584
  `- Closed at: ${closedAtText(observed.closedAt, p)}`,
@@ -3688,12 +3613,12 @@ function renderBuildRecordV2Markdown(record2) {
3688
3613
  ].join("\n"),
3689
3614
  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.`,
3690
3615
  ...renderChecks(record2.observed.checks, p),
3691
- ...renderClose(total === 1 ? "## The close" : `## The latest close (close ${String(latest.sequence)} of ${String(total)})`, latest, attestedBySequence.get(latest.sequence), p)
3616
+ ...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)
3692
3617
  ];
3693
3618
  if (total > 1) {
3694
3619
  body.push("## Earlier closes");
3695
3620
  for (const close of closes.slice(0, -1)) {
3696
- body.push(...renderClose(`### Close ${String(close.sequence)} of ${String(total)}`, close, attestedBySequence.get(close.sequence), p));
3621
+ body.push(...renderClose(`### Close ${String(close.sequence)} of ${String(total)}`, close, attestedBySequence.get(close.sequence), record2.observed.checks.state, p));
3697
3622
  }
3698
3623
  }
3699
3624
  const header = [`${TITLE_PREFIX}${phase}`, HEADER_WHAT, HEADER_REGISTERS, HEADER_CHECKS, HEADER_KEPT];