halfcycle 0.3.25 → 0.3.27

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
@@ -1,6 +1,8 @@
1
1
  // dist/install.js
2
+ import { execFileSync as execFileSync2 } from "node:child_process";
3
+ import { createHash as createHash2 } from "node:crypto";
2
4
  import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
3
- import { dirname as dirname2, join as join5, relative } from "node:path";
5
+ import { dirname as dirname2, join as join5, posix, relative } from "node:path";
4
6
  import { fileURLToPath } from "node:url";
5
7
 
6
8
  // ../events/dist/result.js
@@ -28,6 +30,11 @@ var HALFCYCLE_DIR_NAME = ".halfcycle";
28
30
  var ENGAGEMENTS_DIR_NAME = "engagements";
29
31
  var ENGAGEMENT_ENV_FILENAME = "env";
30
32
  var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
33
+ 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}$";
34
+ var ENGAGEMENT_ID_SHAPE = new RegExp(ENGAGEMENT_ID_PATTERN);
35
+ function isEngagementId(value) {
36
+ return typeof value === "string" && ENGAGEMENT_ID_SHAPE.test(value);
37
+ }
31
38
  var ENGAGEMENT_ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
32
39
  function shq(value) {
33
40
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -675,7 +682,19 @@ var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
675
682
  var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
676
683
  var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
677
684
  var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
685
+ var InvalidEngagementIdError = class extends Error {
686
+ constructor(engagementId) {
687
+ const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
688
+ super(`${shown} is not a Halfcycle project id, so it cannot name a folder on this machine and nothing was written. A project id looks like 3f2a1c4e-8b7d-4e2f-9a61-0c5d7e8f9b10. If it is the "${PIN_ENGAGEMENT_ID_FIELD}" in .halfcycle/bundle.json, delete that file and run "npx halfcycle" again: this repository is then set up as a new Halfcycle project.`);
689
+ this.name = "InvalidEngagementIdError";
690
+ }
691
+ };
692
+ function assertEngagementId(engagementId) {
693
+ if (!isEngagementId(engagementId))
694
+ throw new InvalidEngagementIdError(engagementId);
695
+ }
678
696
  function engagementStateDir(engagementId, home) {
697
+ assertEngagementId(engagementId);
679
698
  return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
680
699
  }
681
700
  function engagementEnvPath(engagementId, home) {
@@ -766,6 +785,14 @@ halfcycle_env_file() {
766
785
  HALFCYCLE_ENV_PROBLEM="no-id"
767
786
  return 1
768
787
  fi
788
+ # The id is about to become a path under $HOME, and it came from a committed
789
+ # file: anything that is not a project id (a "../", a "/") is refused here,
790
+ # before the path exists. hc_id holds no newline (the pin was flattened above),
791
+ # so grep sees exactly one line.
792
+ if ! printf '%s\\n' "$hc_id" | grep -Eq '${ENGAGEMENT_ID_PATTERN}'; then
793
+ HALFCYCLE_ENV_PROBLEM="bad-id"
794
+ return 1
795
+ fi
769
796
  HALFCYCLE_ENV_ENGAGEMENT="$hc_id"
770
797
  if [ -z "\${HOME:-}" ]; then
771
798
  HALFCYCLE_ENV_PROBLEM="no-home"
@@ -834,6 +861,264 @@ function mintOrReadIdentity(targetRepoRoot) {
834
861
  return { identity, minted: true };
835
862
  }
836
863
 
864
+ // dist/install-manifest.js
865
+ import { createHash } from "node:crypto";
866
+ var INSTALL_MANIFEST_REL = ".halfcycle/install-manifest.json";
867
+ var INSTALL_MANIFEST_FORMAT = "halfcycle-install-manifest/v1";
868
+ function sha256Hex(bytes) {
869
+ return createHash("sha256").update(bytes).digest("hex");
870
+ }
871
+ function canonicalJson(value) {
872
+ if (Array.isArray(value))
873
+ return value.map(canonicalJson);
874
+ if (value !== null && typeof value === "object") {
875
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => [k, canonicalJson(v)]);
876
+ return Object.fromEntries(entries);
877
+ }
878
+ return value;
879
+ }
880
+ function canonicalSha256(value) {
881
+ return sha256Hex(JSON.stringify(canonicalJson(value)));
882
+ }
883
+ function byString(a, b) {
884
+ return a < b ? -1 : a > b ? 1 : 0;
885
+ }
886
+ function serializeManifest(manifest) {
887
+ const settings = {
888
+ ...manifest.settings,
889
+ hookCommands: [...manifest.settings.hookCommands].sort(byString),
890
+ denyAddedSha256: [...manifest.settings.denyAddedSha256].sort(byString),
891
+ eventsCreated: [...manifest.settings.eventsCreated].sort(byString)
892
+ };
893
+ const ordered = {
894
+ ...manifest,
895
+ files: [...manifest.files].sort((a, b) => byString(a.path, b.path)),
896
+ createdDirs: [...manifest.createdDirs].sort(byString),
897
+ leftAlone: [...manifest.leftAlone].sort(byString),
898
+ settings
899
+ };
900
+ return JSON.stringify(canonicalJson(ordered), null, 2) + "\n";
901
+ }
902
+ var UnreadableManifestError = class extends Error {
903
+ constructor(reason) {
904
+ super(reason);
905
+ this.name = "UnreadableManifestError";
906
+ }
907
+ };
908
+ var HEX64 = /^[0-9a-f]{64}$/;
909
+ function isRecord(value) {
910
+ return value !== null && typeof value === "object" && !Array.isArray(value);
911
+ }
912
+ function stringArray(value, field) {
913
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
914
+ throw new UnreadableManifestError(`"${field}" is not a list of strings`);
915
+ }
916
+ return value;
917
+ }
918
+ function hashArray(value, field) {
919
+ const list = stringArray(value, field);
920
+ if (!list.every((v) => HEX64.test(v)))
921
+ throw new UnreadableManifestError(`"${field}" holds a value that is not a hash`);
922
+ return list;
923
+ }
924
+ function bool(value, field) {
925
+ if (typeof value !== "boolean")
926
+ throw new UnreadableManifestError(`"${field}" is not true or false`);
927
+ return value;
928
+ }
929
+ function onlyKeys(value, allowed, where) {
930
+ const extra = Object.keys(value).filter((k) => !allowed.includes(k));
931
+ if (extra.length > 0)
932
+ throw new UnreadableManifestError(`${where} has a field this version does not know: ${extra.join(", ")}`);
933
+ }
934
+ function parseManifest(text) {
935
+ let raw;
936
+ try {
937
+ raw = JSON.parse(text);
938
+ } catch {
939
+ throw new UnreadableManifestError("it is not valid JSON");
940
+ }
941
+ if (!isRecord(raw))
942
+ throw new UnreadableManifestError("it is not a JSON object");
943
+ onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
944
+ if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
945
+ throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
946
+ }
947
+ if (!Array.isArray(raw["files"]))
948
+ throw new UnreadableManifestError('"files" is not a list');
949
+ const files = raw["files"].map((entry) => {
950
+ if (!isRecord(entry) || typeof entry["path"] !== "string") {
951
+ throw new UnreadableManifestError('a "files" entry has no path');
952
+ }
953
+ if (entry["perMachine"] === true) {
954
+ onlyKeys(entry, ["path", "perMachine"], `the "files" entry for ${entry["path"]}`);
955
+ return { path: entry["path"], perMachine: true };
956
+ }
957
+ onlyKeys(entry, ["path", "sha256"], `the "files" entry for ${entry["path"]}`);
958
+ if (typeof entry["sha256"] !== "string" || !HEX64.test(entry["sha256"])) {
959
+ throw new UnreadableManifestError(`the "files" entry for ${entry["path"]} has no valid hash`);
960
+ }
961
+ return { path: entry["path"], sha256: entry["sha256"] };
962
+ });
963
+ const s = raw["settings"];
964
+ if (!isRecord(s))
965
+ throw new UnreadableManifestError('"settings" is missing');
966
+ onlyKeys(s, [
967
+ "created",
968
+ "adopted",
969
+ "hookCommands",
970
+ "denyAddedSha256",
971
+ "schemaBefore",
972
+ "eventsCreated",
973
+ "hooksCreated",
974
+ "permissionsCreated",
975
+ "denyCreated"
976
+ ], '"settings"');
977
+ const settings = {
978
+ created: bool(s["created"], "settings.created"),
979
+ adopted: bool(s["adopted"], "settings.adopted"),
980
+ hookCommands: stringArray(s["hookCommands"], "settings.hookCommands"),
981
+ denyAddedSha256: hashArray(s["denyAddedSha256"], "settings.denyAddedSha256"),
982
+ ..."schemaBefore" in s ? { schemaBefore: s["schemaBefore"] } : {},
983
+ eventsCreated: stringArray(s["eventsCreated"], "settings.eventsCreated"),
984
+ hooksCreated: bool(s["hooksCreated"], "settings.hooksCreated"),
985
+ permissionsCreated: bool(s["permissionsCreated"], "settings.permissionsCreated"),
986
+ denyCreated: bool(s["denyCreated"], "settings.denyCreated")
987
+ };
988
+ let mcp;
989
+ if (raw["mcp"] !== void 0) {
990
+ const m = raw["mcp"];
991
+ if (!isRecord(m))
992
+ throw new UnreadableManifestError('"mcp" is not an object');
993
+ onlyKeys(m, ["created", "adopted", "mcpServersCreated", "entrySha256"], '"mcp"');
994
+ if (m["entrySha256"] !== void 0 && (typeof m["entrySha256"] !== "string" || !HEX64.test(m["entrySha256"]))) {
995
+ throw new UnreadableManifestError('"mcp.entrySha256" is not a hash');
996
+ }
997
+ mcp = {
998
+ created: bool(m["created"], "mcp.created"),
999
+ adopted: bool(m["adopted"], "mcp.adopted"),
1000
+ mcpServersCreated: bool(m["mcpServersCreated"], "mcp.mcpServersCreated"),
1001
+ ...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
1002
+ };
1003
+ }
1004
+ const g = raw["gitignore"];
1005
+ if (!isRecord(g))
1006
+ throw new UnreadableManifestError('"gitignore" is missing');
1007
+ onlyKeys(g, ["created", "appended"], '"gitignore"');
1008
+ const gitignore = {
1009
+ created: bool(g["created"], "gitignore.created"),
1010
+ appended: stringArray(g["appended"], "gitignore.appended")
1011
+ };
1012
+ const paths = files.map((f) => f.path);
1013
+ if (new Set(paths).size !== paths.length)
1014
+ throw new UnreadableManifestError('"files" names a path twice');
1015
+ const leftAlone = stringArray(raw["leftAlone"], "leftAlone");
1016
+ if (leftAlone.some((p) => paths.includes(p))) {
1017
+ throw new UnreadableManifestError('a path is in both "files" and "leftAlone"');
1018
+ }
1019
+ return {
1020
+ format: INSTALL_MANIFEST_FORMAT,
1021
+ files,
1022
+ createdDirs: stringArray(raw["createdDirs"], "createdDirs"),
1023
+ leftAlone,
1024
+ settings,
1025
+ ...mcp !== void 0 ? { mcp } : {},
1026
+ gitignore
1027
+ };
1028
+ }
1029
+ function startManifest(previous, isMember, isMemberDir) {
1030
+ const files = /* @__PURE__ */ new Map();
1031
+ for (const entry of previous?.files ?? []) {
1032
+ if (isMember(entry.path))
1033
+ files.set(entry.path, entry);
1034
+ }
1035
+ return {
1036
+ previous,
1037
+ files,
1038
+ collided: /* @__PURE__ */ new Set(),
1039
+ createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
1040
+ settings: previous?.settings,
1041
+ mcp: previous?.mcp,
1042
+ gitignore: previous?.gitignore
1043
+ };
1044
+ }
1045
+ function recordInstalledFile(draft, path, bytes) {
1046
+ draft.files.set(path, { path, sha256: sha256Hex(bytes) });
1047
+ }
1048
+ function recordPerMachineFile(draft, path) {
1049
+ draft.files.set(path, { path, perMachine: true });
1050
+ }
1051
+ function recordCollision(draft, path) {
1052
+ draft.collided.add(path);
1053
+ }
1054
+ function recordCreatedDir(draft, path) {
1055
+ draft.createdDirs.add(path);
1056
+ }
1057
+ function recordSettings(draft, observed) {
1058
+ const prev = draft.settings;
1059
+ if (prev === void 0) {
1060
+ draft.settings = observed;
1061
+ return;
1062
+ }
1063
+ draft.settings = {
1064
+ created: prev.created,
1065
+ adopted: prev.adopted,
1066
+ hookCommands: union(prev.hookCommands, observed.hookCommands),
1067
+ denyAddedSha256: union(prev.denyAddedSha256, observed.denyAddedSha256),
1068
+ ..."schemaBefore" in prev ? { schemaBefore: prev.schemaBefore } : "schemaBefore" in observed ? { schemaBefore: observed.schemaBefore } : {},
1069
+ eventsCreated: union(prev.eventsCreated, observed.eventsCreated),
1070
+ hooksCreated: prev.hooksCreated,
1071
+ permissionsCreated: prev.permissionsCreated,
1072
+ denyCreated: prev.denyCreated
1073
+ };
1074
+ }
1075
+ function recordMcp(draft, observed) {
1076
+ const prev = draft.mcp;
1077
+ if (prev === void 0) {
1078
+ draft.mcp = observed;
1079
+ return;
1080
+ }
1081
+ const entrySha256 = observed.entrySha256 ?? prev.entrySha256;
1082
+ draft.mcp = {
1083
+ created: prev.created,
1084
+ adopted: prev.adopted,
1085
+ mcpServersCreated: prev.mcpServersCreated,
1086
+ ...entrySha256 !== void 0 ? { entrySha256 } : {}
1087
+ };
1088
+ }
1089
+ function recordGitignore(draft, created, appended) {
1090
+ const prev = draft.gitignore;
1091
+ draft.gitignore = {
1092
+ created: prev?.created ?? created,
1093
+ appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
1094
+ };
1095
+ }
1096
+ function finishManifest(draft) {
1097
+ const files = [...draft.files.values()];
1098
+ const leftAlone = [...draft.collided].filter((p) => !draft.files.has(p));
1099
+ return {
1100
+ format: INSTALL_MANIFEST_FORMAT,
1101
+ files,
1102
+ createdDirs: [...draft.createdDirs],
1103
+ leftAlone,
1104
+ settings: draft.settings ?? {
1105
+ created: false,
1106
+ adopted: false,
1107
+ hookCommands: [],
1108
+ denyAddedSha256: [],
1109
+ eventsCreated: [],
1110
+ hooksCreated: false,
1111
+ permissionsCreated: false,
1112
+ denyCreated: false
1113
+ },
1114
+ ...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
1115
+ gitignore: draft.gitignore ?? { created: false, appended: [] }
1116
+ };
1117
+ }
1118
+ function union(a, b) {
1119
+ return [.../* @__PURE__ */ new Set([...a, ...b])];
1120
+ }
1121
+
837
1122
  // dist/mcp-endpoint.js
838
1123
  var MCP_ENDPOINT_PATH = "/mcp";
839
1124
  function mcpEndpointUrl(origin) {
@@ -919,8 +1204,9 @@ function scanLayers(targetRepo, scannedAt = (/* @__PURE__ */ new Date()).toISOSt
919
1204
  }
920
1205
  return { format: HALFCYCLE_STATE_FORMAT, note: HALFCYCLE_STATE_NOTE, layers };
921
1206
  }
1207
+ var BOOTSTRAP_STATE_REL = ".halfcycle/state.json";
922
1208
  function runBootstrapScan(targetRepo) {
923
- const statePath = join4(targetRepo, ".halfcycle", "state.json");
1209
+ const statePath = join4(targetRepo, BOOTSTRAP_STATE_REL);
924
1210
  if (existsSync2(statePath)) {
925
1211
  const existing = JSON.parse(readFileSync3(statePath, "utf-8"));
926
1212
  return { state: existing, ran: false };
@@ -994,22 +1280,152 @@ function isAllowlisted(targetRelPath) {
994
1280
  return normalised === p || normalised.startsWith(p + "/");
995
1281
  });
996
1282
  }
1283
+ var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
1284
+ var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
1285
+ var CREW_ROSTER_REL = ".halfcycle/crew.json";
1286
+ var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
1287
+ var SETTINGS_REL = ".claude/settings.json";
1288
+ var GITIGNORE_REL = ".gitignore";
1289
+ function commandStubRel(name) {
1290
+ return `.claude/commands/${name}.md`;
1291
+ }
1292
+ var RETIRED_WRITE_SET_FILES = [];
1293
+ var RETIRED_HOOK_COMMANDS = [];
1294
+ var RETIRED_DENY_PATTERNS = [];
1295
+ function installerHookCommands() {
1296
+ const settings = JSON.parse(generateSettingsJson());
1297
+ return Object.values(settings.hooks ?? {}).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
1298
+ }
1299
+ function closedWriteSet() {
1300
+ const files = /* @__PURE__ */ new Set([
1301
+ ...(readPluginManifest().commands ?? []).map((cmd) => commandStubRel(cmd.name)),
1302
+ ...Object.keys(OWNED_GENERATED_HEADERS),
1303
+ VENDORED_BIN_REL,
1304
+ CREW_ROSTER_REL,
1305
+ BUNDLE_PIN_REL,
1306
+ PROJECT_IDENTITY_REL,
1307
+ BOOTSTRAP_STATE_REL,
1308
+ CAPTURED_INDEX_REL,
1309
+ INSTALL_MANIFEST_REL,
1310
+ ...RETIRED_WRITE_SET_FILES
1311
+ ]);
1312
+ const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
1313
+ const dirs = /* @__PURE__ */ new Set();
1314
+ for (const path of [...files, ...merged]) {
1315
+ for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
1316
+ dirs.add(dir);
1317
+ }
1318
+ const ownedDirs = new Set([...dirs].filter((dir) => `${dir}/`.startsWith(INSTALLER_OWNED_DIR)));
1319
+ return {
1320
+ files,
1321
+ merged,
1322
+ dirs,
1323
+ ownedDirs,
1324
+ hookCommands: /* @__PURE__ */ new Set([...installerHookCommands(), ...RETIRED_HOOK_COMMANDS]),
1325
+ denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
1326
+ gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL])
1327
+ };
1328
+ }
1329
+ function readPreviousManifest(targetRepo) {
1330
+ try {
1331
+ return parseManifest(readFileSync4(join5(targetRepo, INSTALL_MANIFEST_REL), "utf-8"));
1332
+ } catch {
1333
+ return null;
1334
+ }
1335
+ }
1336
+ function noteFile(draft, targetRepo, rel, outcome) {
1337
+ if (outcome === "collided") {
1338
+ recordCollision(draft, rel);
1339
+ return;
1340
+ }
1341
+ recordInstalledFile(draft, rel, readFileSync4(join5(targetRepo, rel)));
1342
+ }
1343
+ function recordSettingsMerge(draft, existing, generated, preexisted) {
1344
+ const generatedHooks = generated.hooks ?? {};
1345
+ const hookCommands = Object.values(generatedHooks).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
1346
+ const ours = new Set(hookCommands);
1347
+ const existingHooks = isPlainObject(existing.hooks) ? existing.hooks : void 0;
1348
+ const heldOurs = Object.values(existingHooks ?? {}).some((entries) => Array.isArray(entries) && entries.some((entry) => isHalfcycleEntry(entry, ours)));
1349
+ const existingDeny = Array.isArray(existing.permissions?.deny) ? existing.permissions.deny : [];
1350
+ const schemaChanged = generated.$schema !== void 0 && existing.$schema !== generated.$schema;
1351
+ recordSettings(draft, {
1352
+ created: !preexisted,
1353
+ adopted: draft.previous === null && heldOurs,
1354
+ hookCommands,
1355
+ denyAddedSha256: (generated.permissions?.deny ?? []).filter((rule) => !existingDeny.includes(rule)).map((rule) => sha256Hex(rule)),
1356
+ ...schemaChanged ? { schemaBefore: existing.$schema ?? null } : {},
1357
+ eventsCreated: Object.keys(generatedHooks).filter((event) => !(existingHooks && event in existingHooks)),
1358
+ hooksCreated: existing.hooks === void 0,
1359
+ permissionsCreated: existing.permissions === void 0,
1360
+ denyCreated: existing.permissions?.deny === void 0
1361
+ });
1362
+ }
1363
+ function recordMcpMerge(draft, existingText, writtenText) {
1364
+ const before = existingText === null ? void 0 : JSON.parse(existingText);
1365
+ const servers = isPlainObject(before) ? before["mcpServers"] : void 0;
1366
+ const written = JSON.parse(writtenText);
1367
+ recordMcp(draft, {
1368
+ created: existingText === null,
1369
+ adopted: draft.previous === null && isPlainObject(servers) && MCP_SERVER_KEY in servers,
1370
+ mcpServersCreated: !(isPlainObject(before) && "mcpServers" in before),
1371
+ entrySha256: canonicalSha256(written.mcpServers[MCP_SERVER_KEY])
1372
+ });
1373
+ }
1374
+ function recordMcpAdoptionOnly(draft, existingText) {
1375
+ if (draft.previous !== null || draft.mcp !== void 0)
1376
+ return;
1377
+ let before;
1378
+ try {
1379
+ before = JSON.parse(existingText);
1380
+ } catch {
1381
+ return;
1382
+ }
1383
+ const servers = isPlainObject(before) ? before["mcpServers"] : void 0;
1384
+ if (!isPlainObject(servers) || !(MCP_SERVER_KEY in servers))
1385
+ return;
1386
+ recordMcp(draft, { created: false, adopted: true, mcpServersCreated: false });
1387
+ }
1388
+ function recordGitignoreReconcile(draft, before, after) {
1389
+ const adopted = draft.gitignore === void 0 && before !== null ? adoptOlderGitignoreBlock(before) : "";
1390
+ let appended = "";
1391
+ if (after !== null && after !== before) {
1392
+ if (before === null)
1393
+ appended = after;
1394
+ else if (after.startsWith(before))
1395
+ appended = after.slice(before.length);
1396
+ }
1397
+ recordGitignore(draft, before === null && after !== null, [adopted, appended]);
1398
+ }
1399
+ function assertManifestInWriteSet(manifest, writeSet) {
1400
+ const strays = [
1401
+ ...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
1402
+ ...manifest.leftAlone.filter((p) => !writeSet.files.has(p)),
1403
+ ...manifest.createdDirs.filter((p) => !writeSet.dirs.has(p))
1404
+ ];
1405
+ if (strays.length > 0) {
1406
+ throw new Error(`[bundle install] the install record names paths outside the write set: ${strays.join(", ")}`);
1407
+ }
1408
+ }
1409
+ function isPlainObject(value) {
1410
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1411
+ }
997
1412
  function readPluginManifest() {
998
1413
  const manifestPath = join5(BUNDLE_ROOT, ".claude-plugin", "plugin.json");
999
1414
  const raw = readFileSync4(manifestPath, "utf-8");
1000
1415
  return JSON.parse(raw);
1001
1416
  }
1002
- function copyManifestCommands(manifest, targetRepo, report) {
1003
- const commandsTargetDir = join5(targetRepo, ".claude", "commands");
1417
+ function copyManifestCommands(manifest, targetRepo, report, draft) {
1004
1418
  for (const cmd of manifest.commands ?? []) {
1005
1419
  const srcFile = join5(BUNDLE_ROOT, ".claude-plugin", cmd.path);
1006
1420
  if (!existsSync3(srcFile)) {
1007
1421
  throw new Error(`[bundle install] plugin.json declares command "${cmd.name}" at "${cmd.path}", but no file exists there (${srcFile}). The manifest and commands/ must agree.`);
1008
1422
  }
1009
- const destFile = join5(commandsTargetDir, `${cmd.name}.md`);
1423
+ const rel = commandStubRel(cmd.name);
1424
+ const destFile = join5(targetRepo, rel);
1010
1425
  const content = readFileSync4(srcFile, "utf-8");
1011
- const rel = relative(targetRepo, destFile).replace(/\\/g, "/");
1012
- record(report, writeCollisionSafe(destFile, targetRepo, content), rel);
1426
+ const outcome = writeCollisionSafe(destFile, targetRepo, content);
1427
+ record(report, outcome, rel);
1428
+ noteFile(draft, targetRepo, rel, outcome);
1013
1429
  }
1014
1430
  }
1015
1431
  function writeAllowlisted(targetAbsPath, targetRepoRoot, content, writtenPaths) {
@@ -1075,6 +1491,7 @@ function recordOwnedGenerated(report, targetAbsPath, targetRepoRoot, content, ge
1075
1491
  record(report, outcome, rel);
1076
1492
  if (replacedExisting)
1077
1493
  report.replacedPaths.push(rel);
1494
+ return outcome;
1078
1495
  }
1079
1496
  function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
1080
1497
  const existedBefore = existsSync3(targetAbsPath);
@@ -1082,6 +1499,7 @@ function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
1082
1499
  record(report, outcome, rel);
1083
1500
  if (outcome === "written" && existedBefore)
1084
1501
  report.replacedPaths.push(rel);
1502
+ return outcome;
1085
1503
  }
1086
1504
  function record(report, outcome, rel) {
1087
1505
  const bucket = {
@@ -1406,82 +1824,53 @@ printf '%s\\n' '${USER_PROMPT_REMINDER_SENTENCE}'
1406
1824
  exit 0
1407
1825
  `;
1408
1826
  }
1409
- function generateCiStanza() {
1410
- return `# Halfcycle guard CI job \u2014 generated by the Halfcycle installer.
1411
- # Paste this job into your CI workflow. It assumes no package manager and no
1412
- # monorepo tooling: it runs the self-contained guard binary vendored at
1413
- # .halfcycle/bin/, so it works in a Python or Go repository as well as a Node
1414
- # one \u2014 the only prerequisite is Node 20 to run the bundled binary.
1415
- #
1416
- # THERE IS NOTHING TO STORE. No token in your repository's secrets, no project id
1417
- # to look up, and no address to set. The \`permissions\` block below is the whole
1418
- # configuration: it lets the job ask GitHub for a short-lived signed token naming
1419
- # the repository it is running in, and Halfcycle trusts the name GitHub signs
1420
- # rather than anything the job says about itself. That token is traded for a
1421
- # credential that lives for minutes, and nothing is kept at either end.
1422
- #
1423
- # BOTH PERMISSION LINES, NOT JUST THE SECOND. Declaring any permission
1424
- # replaces the defaults rather than adding to them, so a block naming only
1425
- # \`id-token\` takes read access away from the checkout step and a private
1426
- # repository stops checking out before the guard is reached. If your workflow
1427
- # already has a \`permissions\` block, add \`id-token: write\` to it and leave the
1428
- # rest alone.
1429
- #
1430
- # ONE THING TO DO FIRST, ONCE, ON YOUR OWN MACHINE. In this project, run
1431
- #
1432
- # npx halfcycle ci bind <owner>/<repo>
1433
- #
1434
- # naming this repository as GitHub spells it (for example acme/widgets). That
1435
- # tells Halfcycle this project's CI runs from that repository, and it is the only
1436
- # thing that makes a run mean anything: without it, Halfcycle has a signed
1437
- # statement of which repository the job is in and no idea whose project that is.
1438
- # The check says exactly that, and names the command, if you skip it.
1439
- #
1440
- # THERE IS NO ADDRESS TO CONFIGURE ANYWHERE IN HALFCYCLE. Every service this
1441
- # product talks to has one address, the same for every user, and it ships in the
1442
- # tools you already have: the installer, the guard hook and the binary this job
1443
- # runs each know where to go. If something tells you to set a Halfcycle URL, it is
1444
- # out of date.
1445
- #
1446
- # WHICH BRANCH MODEL THIS ASSUMES: none. It works on pull-request branches AND on
1447
- # commits pushed straight to the default branch, which is the shape most
1448
- # Halfcycle engagements settle on. \`fetch-depth\` is what makes that true: the
1449
- # check needs the commit BEFORE the one it is evaluating, and the default
1450
- # shallow checkout does not have it. With \`fetch-depth: 0\` the check fails loudly
1451
- # if it cannot work out what to evaluate \u2014 it will not pass quietly having
1452
- # evaluated nothing.
1453
- #
1454
- # halfcycle-guard-ci:
1455
- # runs-on: ubuntu-latest
1456
- # permissions:
1457
- # contents: read
1458
- # id-token: write
1459
- # steps:
1460
- # - uses: actions/checkout@v4
1461
- # with:
1462
- # # REQUIRED. 0 = full history. The check diffs against the commit before
1463
- # # HEAD (or the fork point on a branch); the default depth of 1 has
1464
- # # neither. Do not lower this.
1465
- # fetch-depth: 0
1466
- # - uses: actions/setup-node@v4
1467
- # with:
1468
- # node-version: '20'
1469
- # - name: Halfcycle guard CI check
1470
- # # No env block, on purpose: this step holds no secret. The permissions
1471
- # # above are what authenticate it.
1472
- # run: node ./.halfcycle/bin/bin.bundle.mjs ci
1473
- #
1474
- # The job prints the diff base it used and how many files it evaluated, on every
1475
- # run. If that line says 0 files on a commit that changed something, the base is
1476
- # wrong \u2014 set HALFCYCLE_DIFF_BASE in an env block OF YOUR COPY of the check step,
1477
- # to name it explicitly (on a GitHub push event, \${{ github.event.before }} is the
1478
- # right value).
1479
- #
1480
- # EDIT YOUR COPY, NOT THIS FILE. This one is regenerated by the installer and your
1481
- # changes to it would be replaced the next time you run \`npx halfcycle\`. It is
1482
- # also inert where it sits: no CI system reads this path. Copy the job above into
1483
- # your own workflow and change it there.
1484
- `;
1827
+ var CI_STANZA_KNOWN_HEADER_HASHES = [
1828
+ "b225800b2d699811f32ac213238cbf81c35b5e6a74114bdf55e39cd9d7e2928f",
1829
+ "674181735460f051af7c157222cf851fc3000fb3b8af3a10ea0bde9fe5eaba1f",
1830
+ "d17bde608a6fec54a63cb934a28d88acc8babc205334b5ee84d4ed6ee219d340",
1831
+ "85e71d3f6551977b241a28468f121ee4d1c4edef40a2c4dc1aa6f2f737ebd037"
1832
+ ];
1833
+ function isTrackedAndClean(targetRepo, relPath) {
1834
+ try {
1835
+ execFileSync2("git", ["-C", targetRepo, "ls-files", "--error-unmatch", "--", relPath], {
1836
+ stdio: "ignore"
1837
+ });
1838
+ } catch {
1839
+ return false;
1840
+ }
1841
+ try {
1842
+ execFileSync2("git", ["-C", targetRepo, "diff", "--quiet", "HEAD", "--", relPath], {
1843
+ stdio: "ignore"
1844
+ });
1845
+ } catch {
1846
+ return false;
1847
+ }
1848
+ return true;
1849
+ }
1850
+ function removeLegacyCiStanza(targetRepo) {
1851
+ const rel = ".halfcycle/ci-stanza.yml";
1852
+ const path = join5(targetRepo, rel);
1853
+ if (!existsSync3(path))
1854
+ return "absent";
1855
+ let raw;
1856
+ try {
1857
+ raw = readFileSync4(path, "utf-8");
1858
+ } catch {
1859
+ return "failed";
1860
+ }
1861
+ const headerLine = raw.split("\n")[0] ?? "";
1862
+ const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
1863
+ const ours = CI_STANZA_KNOWN_HEADER_HASHES.includes(digest);
1864
+ if (!ours)
1865
+ return "kept-foreign";
1866
+ if (!isTrackedAndClean(targetRepo, rel))
1867
+ return "kept-uncommitted";
1868
+ try {
1869
+ rmSync(path);
1870
+ } catch {
1871
+ return "failed";
1872
+ }
1873
+ return "removed";
1485
1874
  }
1486
1875
  var MCP_REGISTRATION_REL = ".mcp.json";
1487
1876
  var MCP_SERVER_KEY = "halfcycle";
@@ -1523,13 +1912,81 @@ PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
1523
1912
 
1524
1913
  ${engagementResolutionShell()}
1525
1914
 
1915
+ # Read one KEY's value out of the credential store into HC_VALUE, in the CALLER's
1916
+ # shell (call it as a statement, never inside $(...)). Last assignment wins,
1917
+ # matching the shell's own \`.\` semantics. An optional leading \`export \` is
1918
+ # tolerated because a hand-edited store may use one.
1919
+ halfcycle_store_value() {
1920
+ HC_VALUE=""
1921
+ hc_line=$(grep "^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}$2=" "$1" | tail -n 1)
1922
+ hc_v=\${hc_line#*"$2="}
1923
+ hc_v=$(printf '%s' "$hc_v" | tr -d '\\r')
1924
+
1925
+ # Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
1926
+ # writers use the same shq: this installer and Studio's provider), because
1927
+ # guard-runner.sh SOURCES the same file and an unquoted value carrying a space
1928
+ # would execute its own remainder. This reader greps rather than sources, so it
1929
+ # has to undo the quoting itself \u2014 and it must land on the same value the
1930
+ # sourcing consumer gets, or one file has two answers.
1931
+ case $hc_v in
1932
+ '"'*'"') hc_v=\${hc_v#'"'}; hc_v=\${hc_v%'"'} ;;
1933
+ "'"*"'")
1934
+ hc_v=\${hc_v#"'"}; hc_v=\${hc_v%"'"}
1935
+ # \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
1936
+ # through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
1937
+ # the CI platform) and BSD sed (macOS), feeding each the script from a file so
1938
+ # no shell quoting is in the way:
1939
+ #
1940
+ # s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
1941
+ # s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
1942
+ #
1943
+ # A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
1944
+ # does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
1945
+ # and the token then travels with four stray characters in it. The bracket
1946
+ # expression makes the backslash literal by a construction POSIX does define.
1947
+ hc_v=$(printf '%s' "$hc_v" | sed "s/'[\\]''/'/g") ;;
1948
+ esac
1949
+ HC_VALUE=$hc_v
1950
+ }
1951
+
1952
+ # The scheme and host[:port] of a URL, lowercased \u2014 or NOTHING when the URL holds
1953
+ # a space or a control character anywhere. That refusal is the load-bearing part:
1954
+ # a URL parser drops tabs and newlines before it reads the host, so
1955
+ # "https://ours<newline>@elsewhere/" is a request to "elsewhere", while a
1956
+ # line-by-line reading here would see only "https://ours". The host ends at the
1957
+ # first / ? # or backslash, which is where a URL parser ends it for http(s).
1958
+ halfcycle_origin() {
1959
+ hc_u=$1
1960
+ if [ "$(printf '%s' "$hc_u" | tr -d '[:cntrl:][:space:]')" != "$hc_u" ]; then
1961
+ return 0
1962
+ fi
1963
+ printf '%s\\n' "$hc_u" | sed -n 's|^\\([A-Za-z][A-Za-z0-9+.-]*://[^/?#\\\\]*\\).*$|\\1|p' | tr '[:upper:]' '[:lower:]'
1964
+ }
1965
+
1526
1966
  # HALFCYCLE_TOKEN in the environment WINS, and it is the CI arm: a job with no
1527
1967
  # browser and no per-user store exports the credential, and a stale store on a
1528
1968
  # long-lived runner must not silently win over it. Same order, and the same reason,
1529
- # as the CLI's own resolver (\`resolve-credential.ts\`).
1969
+ # as the CLI's own resolver (\`resolve-credential.ts\`). The server it may be sent
1970
+ # to is NOT taken from the environment: it is read from this machine's store in
1971
+ # both arms, so a token supplied this way still goes only where this machine was
1972
+ # set up to send it.
1973
+ # The pin names an id that is not a project id. Re-running the installer against
1974
+ # that pin refuses it too, so the one remedy that works is a fresh pin.
1975
+ halfcycle_bad_id_message() {
1976
+ echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names an engagement id that is not a Halfcycle project id," >&2
1977
+ echo "halfcycle: so no credential was read. Delete that file and run \\"npx halfcycle\\" again: this repository" >&2
1978
+ echo "halfcycle: is then set up as a new Halfcycle project." >&2
1979
+ }
1980
+
1530
1981
  TOKEN=\${HALFCYCLE_TOKEN:-}
1982
+ MCP_URL=""
1531
1983
 
1532
- if [ -z "$TOKEN" ]; then
1984
+ if [ -n "$TOKEN" ]; then
1985
+ if halfcycle_env_file "$PROJECT_ROOT"; then
1986
+ halfcycle_store_value "$HALFCYCLE_ENV_FILE" HALFCYCLE_MCP_URL
1987
+ MCP_URL=$HC_VALUE
1988
+ fi
1989
+ else
1533
1990
  if ! halfcycle_env_file "$PROJECT_ROOT"; then
1534
1991
  case $HALFCYCLE_ENV_PROBLEM in
1535
1992
  no-pin)
@@ -1538,6 +1995,8 @@ if [ -z "$TOKEN" ]; then
1538
1995
  no-id)
1539
1996
  echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names no engagement id." >&2
1540
1997
  echo "halfcycle: run \\"npx halfcycle\\" here to rewrite it." >&2 ;;
1998
+ bad-id)
1999
+ halfcycle_bad_id_message ;;
1541
2000
  no-home)
1542
2001
  echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
1543
2002
  *)
@@ -1560,42 +2019,48 @@ if [ -z "$TOKEN" ]; then
1560
2019
  fi
1561
2020
  ENV_FILE="$HALFCYCLE_ENV_FILE"
1562
2021
 
1563
- # Last assignment wins, matching the shell's own \`.\` semantics. An optional
1564
- # leading \`export \` is tolerated because a hand-edited store may use one.
1565
- LINE=$(grep '^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}HALFCYCLE_TOKEN=' "$ENV_FILE" | tail -n 1)
1566
- TOKEN=\${LINE#*HALFCYCLE_TOKEN=}
1567
- TOKEN=$(printf '%s' "$TOKEN" | tr -d '\\r')
1568
-
1569
- # Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
1570
- # writers use the same shq: this installer and Studio's provider), because
1571
- # guard-runner.sh SOURCES the same file and an unquoted value carrying a space
1572
- # would execute its own remainder. This reader greps rather than sources, so it
1573
- # has to undo the quoting itself \u2014 and it must land on the same value the
1574
- # sourcing consumer gets, or one file has two answers.
1575
- case $TOKEN in
1576
- '"'*'"') TOKEN=\${TOKEN#'"'}; TOKEN=\${TOKEN%'"'} ;;
1577
- "'"*"'")
1578
- TOKEN=\${TOKEN#"'"}; TOKEN=\${TOKEN%"'"}
1579
- # \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
1580
- # through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
1581
- # the CI platform) and BSD sed (macOS), feeding each the script from a file so
1582
- # no shell quoting is in the way:
1583
- #
1584
- # s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
1585
- # s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
1586
- #
1587
- # A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
1588
- # does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
1589
- # and the token then travels with four stray characters in it. The bracket
1590
- # expression makes the backslash literal by a construction POSIX does define.
1591
- TOKEN=$(printf '%s' "$TOKEN" | sed "s/'[\\]''/'/g") ;;
1592
- esac
1593
-
2022
+ halfcycle_store_value "$ENV_FILE" HALFCYCLE_TOKEN
2023
+ TOKEN=$HC_VALUE
1594
2024
  if [ -z "$TOKEN" ]; then
1595
2025
  echo "halfcycle: HALFCYCLE_TOKEN is absent or empty in $ENV_FILE." >&2
1596
2026
  echo "halfcycle: run \\"npx halfcycle\\" in this repository to rewrite this machine's credential." >&2
1597
2027
  exit 1
1598
2028
  fi
2029
+ halfcycle_store_value "$ENV_FILE" HALFCYCLE_MCP_URL
2030
+ MCP_URL=$HC_VALUE
2031
+ fi
2032
+
2033
+ # THE TOKEN GOES ONLY TO THIS MACHINE'S HALFCYCLE SERVER. .mcp.json is a tracked
2034
+ # file: anyone who can land a commit can change the server's url, and this script
2035
+ # would then hand the credential to whatever it names. Claude Code tells the helper
2036
+ # which url it is about to connect to (CLAUDE_CODE_MCP_SERVER_URL); the server this
2037
+ # machine was set up against is recorded OUTSIDE the repository, beside the
2038
+ # credential. The two must share scheme, host and port, or nothing is printed.
2039
+ # No url at all is a refusal too: sending the credential without knowing where it
2040
+ # is going is the thing this check exists to stop.
2041
+ REQUESTED=\${CLAUDE_CODE_MCP_SERVER_URL:-}
2042
+ if [ -z "$REQUESTED" ]; then
2043
+ echo "halfcycle: Claude Code did not say which server it is connecting to, so the Halfcycle credential was not sent." >&2
2044
+ echo "halfcycle: update Claude Code, then reconnect." >&2
2045
+ exit 1
2046
+ fi
2047
+ if [ -z "$MCP_URL" ] && [ "\${HALFCYCLE_ENV_PROBLEM:-}" = "bad-id" ]; then
2048
+ halfcycle_bad_id_message
2049
+ exit 1
2050
+ fi
2051
+ if [ -z "$MCP_URL" ]; then
2052
+ echo "halfcycle: this machine has no Halfcycle server address recorded for this repository, so the credential was not sent." >&2
2053
+ echo "halfcycle: run \\"npx halfcycle\\" in this repository on this machine to record it." >&2
2054
+ exit 1
2055
+ fi
2056
+ WANT=$(halfcycle_origin "$MCP_URL")
2057
+ GOT=$(halfcycle_origin "$REQUESTED")
2058
+ if [ -z "$WANT" ] || [ "$GOT" != "$WANT" ]; then
2059
+ SHOWN=$(printf '%s' "$REQUESTED" | tr -cd '[:graph:]' | cut -c1-200)
2060
+ echo "halfcycle: .mcp.json asks for this repository's Halfcycle credential to be sent to $SHOWN," >&2
2061
+ echo "halfcycle: which is not this machine's Halfcycle server (\${WANT:-none recorded}). The credential was NOT sent." >&2
2062
+ echo "halfcycle: if nobody meant to change .mcp.json, treat that change as suspect; \\"npx halfcycle\\" restores the entry." >&2
2063
+ exit 1
1599
2064
  fi
1600
2065
 
1601
2066
  # JSON-escape: backslash first, then double quote. A token carrying either would
@@ -1611,7 +2076,7 @@ function generateMcpRegistration(existing, mcpOrigin) {
1611
2076
  if (existing !== null) {
1612
2077
  const parsed = JSON.parse(existing);
1613
2078
  if (parsed !== null && typeof parsed === "object") {
1614
- base = { mcpServers: {}, ...parsed };
2079
+ base = { ...parsed };
1615
2080
  }
1616
2081
  }
1617
2082
  if (typeof base.mcpServers !== "object" || base.mcpServers === null) {
@@ -1625,13 +2090,33 @@ function generateMcpRegistration(existing, mcpOrigin) {
1625
2090
  return JSON.stringify(base, null, 2) + "\n";
1626
2091
  }
1627
2092
  var REQUIRED_GITIGNORE_ENTRIES = [".halfcycle/state.json", `${ZONE_B_DIR}/`];
2093
+ var GITIGNORE_HEADER = "# Halfcycle \u2014 machine-local secrets/state and Zone-B (never push to client remote)";
2094
+ function adoptOlderGitignoreBlock(before) {
2095
+ const claimable = /* @__PURE__ */ new Set([...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]);
2096
+ const claimed = [];
2097
+ let underHeader = false;
2098
+ const lines = before.split("\n");
2099
+ for (const [i, line] of lines.entries()) {
2100
+ if (line === GITIGNORE_HEADER) {
2101
+ if (i > 0 && lines[i - 1] === "")
2102
+ claimed.push("");
2103
+ claimed.push(line);
2104
+ underHeader = true;
2105
+ } else if (line.trim() === "") {
2106
+ underHeader = false;
2107
+ } else if (underHeader && claimable.has(line)) {
2108
+ claimed.push(line);
2109
+ }
2110
+ }
2111
+ return claimed.length > 0 ? claimed.join("\n") + "\n" : "";
2112
+ }
1628
2113
  function gitignoreCovers(content) {
1629
2114
  const lines = new Set(content.split("\n").map((l) => l.trim()));
1630
2115
  return REQUIRED_GITIGNORE_ENTRIES.every((entry) => lines.has(entry));
1631
2116
  }
1632
2117
  function reconcileGitignore(targetRepo) {
1633
- const gitignorePath = join5(targetRepo, ".gitignore");
1634
- const header = "# Halfcycle \u2014 machine-local secrets/state and Zone-B (never push to client remote)";
2118
+ const gitignorePath = join5(targetRepo, GITIGNORE_REL);
2119
+ const header = GITIGNORE_HEADER;
1635
2120
  try {
1636
2121
  if (!existsSync3(gitignorePath)) {
1637
2122
  const body = [header, ...REQUIRED_GITIGNORE_ENTRIES].join("\n") + "\n";
@@ -1652,26 +2137,31 @@ ${header}
1652
2137
  return "failed";
1653
2138
  }
1654
2139
  }
1655
- function previousAccountId(targetRepoRoot, engagementId) {
2140
+ function previousPinForEngagement(targetRepoRoot, engagementId) {
1656
2141
  try {
1657
2142
  const existing = readBundlePin(targetRepoRoot);
1658
2143
  if (existing === null || existing.engagementId !== engagementId)
1659
2144
  return void 0;
1660
- return existing.accountId;
2145
+ return existing;
1661
2146
  } catch {
1662
2147
  return void 0;
1663
2148
  }
1664
2149
  }
2150
+ function nonEmpty(value) {
2151
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
2152
+ }
1665
2153
  function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, writtenPaths) {
1666
- const carried = accountId ?? previousAccountId(targetRepoRoot, engagementId);
2154
+ const previous = previousPinForEngagement(targetRepoRoot, engagementId);
2155
+ const carried = nonEmpty(accountId) ?? nonEmpty(previous?.accountId);
2156
+ const installedAt = nonEmpty(previous?.installedAt) ?? (/* @__PURE__ */ new Date()).toISOString();
1667
2157
  const pin = {
1668
2158
  version,
1669
2159
  engagementId,
1670
2160
  engagementType,
1671
- installedAt: (/* @__PURE__ */ new Date()).toISOString(),
1672
- ...carried !== void 0 && carried.trim() !== "" ? { accountId: carried } : {}
2161
+ installedAt,
2162
+ ...carried !== void 0 ? { accountId: carried } : {}
1673
2163
  };
1674
- const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
2164
+ const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
1675
2165
  writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
1676
2166
  }
1677
2167
  function writeCrewRoster(targetRepoRoot, report) {
@@ -1681,11 +2171,11 @@ function writeCrewRoster(targetRepoRoot, report) {
1681
2171
  };
1682
2172
  const rendered = `${JSON.stringify(doc, null, 2)}
1683
2173
  `;
1684
- const crewPath = join5(targetRepoRoot, ".halfcycle", "crew.json");
1685
- recordOwned(report, crewPath, targetRepoRoot, rendered, ".halfcycle/crew.json");
2174
+ const crewPath = join5(targetRepoRoot, CREW_ROSTER_REL);
2175
+ return recordOwned(report, crewPath, targetRepoRoot, rendered, CREW_ROSTER_REL);
1686
2176
  }
1687
2177
  function readBundlePin(targetRepoRoot) {
1688
- const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
2178
+ const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
1689
2179
  if (!existsSync3(pinPath))
1690
2180
  return null;
1691
2181
  return JSON.parse(readFileSync4(pinPath, "utf-8"));
@@ -1709,7 +2199,7 @@ function checkDrift(targetRepoRoot) {
1709
2199
  return { drifted: installed !== current, installed, current };
1710
2200
  }
1711
2201
  function writeProjectIdentity(targetRepo) {
1712
- const path = join5(targetRepo, ".halfcycle", "project.json");
2202
+ const path = join5(targetRepo, PROJECT_IDENTITY_REL);
1713
2203
  const { identity } = mintOrReadIdentity(targetRepo);
1714
2204
  const serialized = JSON.stringify(identity, null, 2) + "\n";
1715
2205
  if (existsSync3(path) && readFileSync4(path, "utf-8") === serialized)
@@ -1788,6 +2278,7 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
1788
2278
  }
1789
2279
  async function install(options) {
1790
2280
  const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
2281
+ assertEngagementId(engagementId);
1791
2282
  if (!existsSync3(targetRepo)) {
1792
2283
  throw new Error(`[bundle install] Target repo does not exist: ${targetRepo}`);
1793
2284
  }
@@ -1799,22 +2290,30 @@ async function install(options) {
1799
2290
  collidedPaths: [],
1800
2291
  replacedPaths: []
1801
2292
  };
1802
- copyManifestCommands(manifest, targetRepo, report);
1803
- const capturedDir = join5(targetRepo, "test", "fixtures", "captured");
1804
- const capturedManifest = join5(capturedDir, "manifest.json");
2293
+ const writeSet = closedWriteSet();
2294
+ const draft = startManifest(readPreviousManifest(targetRepo), (path) => writeSet.files.has(path), (path) => writeSet.dirs.has(path));
2295
+ const dirsBefore = new Set([...writeSet.dirs].filter((dir) => existsSync3(join5(targetRepo, dir))));
2296
+ copyManifestCommands(manifest, targetRepo, report, draft);
2297
+ const capturedManifest = join5(targetRepo, CAPTURED_INDEX_REL);
2298
+ const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
1805
2299
  if (!existsSync3(capturedManifest)) {
1806
- const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
1807
2300
  writeAllowlisted(capturedManifest, targetRepo, readFileSync4(srcManifest, "utf-8"), report.writtenPaths);
2301
+ noteFile(draft, targetRepo, CAPTURED_INDEX_REL, "written");
1808
2302
  } else {
1809
- report.skippedPaths.push("test/fixtures/captured/manifest.json");
2303
+ report.skippedPaths.push(CAPTURED_INDEX_REL);
2304
+ if (readFileSync4(capturedManifest).equals(readFileSync4(srcManifest))) {
2305
+ noteFile(draft, targetRepo, CAPTURED_INDEX_REL, "skipped");
2306
+ }
1810
2307
  }
1811
2308
  const vendoredBinSrc = resolveVendoredBinary();
1812
2309
  const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
1813
- recordOwned(report, vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8"), ".halfcycle/bin/bin.bundle.mjs");
1814
- const settingsPath = join5(targetRepo, ".claude", "settings.json");
2310
+ const vendoredOutcome = recordOwned(report, vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8"), VENDORED_BIN_REL);
2311
+ noteFile(draft, targetRepo, VENDORED_BIN_REL, vendoredOutcome);
2312
+ const settingsPath = join5(targetRepo, SETTINGS_REL);
1815
2313
  const settingsPreexisted = existsSync3(settingsPath);
1816
2314
  const generatedSettings = JSON.parse(generateSettingsJson());
1817
2315
  const existingSettings = settingsPreexisted ? JSON.parse(readFileSync4(settingsPath, "utf-8")) : {};
2316
+ recordSettingsMerge(draft, existingSettings, generatedSettings, settingsPreexisted);
1818
2317
  const mergedSettings = mergeSettings(existingSettings, generatedSettings);
1819
2318
  const mergedSettingsText = JSON.stringify(mergedSettings, null, 2) + "\n";
1820
2319
  mkdirSync3(dirname2(settingsPath), { recursive: true });
@@ -1828,16 +2327,33 @@ async function install(options) {
1828
2327
  ]) {
1829
2328
  const rel = `.claude/hooks/${name}`;
1830
2329
  const hookPath = join5(targetRepo, ".claude", "hooks", name);
1831
- recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
2330
+ const outcome = recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
2331
+ noteFile(draft, targetRepo, rel, outcome);
2332
+ }
2333
+ switch (removeLegacyCiStanza(targetRepo)) {
2334
+ case "removed":
2335
+ report.writtenPaths.push(".halfcycle/ci-stanza.yml (REMOVED \u2014 Halfcycle no longer generates this file)");
2336
+ break;
2337
+ case "kept-foreign":
2338
+ report.skippedPaths.push(".halfcycle/ci-stanza.yml (KEPT \u2014 this file was not generated by Halfcycle, so it was left alone)");
2339
+ break;
2340
+ case "kept-uncommitted":
2341
+ report.skippedPaths.push(".halfcycle/ci-stanza.yml (KEPT \u2014 this looks like a Halfcycle-generated file, but it is not a clean, committed copy in this repository, so it was left alone rather than risk losing an edit)");
2342
+ break;
2343
+ case "failed":
2344
+ report.skippedPaths.push(".halfcycle/ci-stanza.yml (could not be removed \u2014 DELETE IT BY HAND: Halfcycle no longer uses this file)");
2345
+ break;
2346
+ case "absent":
2347
+ break;
1832
2348
  }
1833
- const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
1834
- recordOwned(report, ciStanzaPath, targetRepo, generateCiStanza(), ".halfcycle/ci-stanza.yml");
1835
2349
  if (credential) {
1836
2350
  const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
1837
- recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
2351
+ const helperOutcome = recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
2352
+ noteFile(draft, targetRepo, MCP_HEADERS_HELPER_REL, helperOutcome);
1838
2353
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
1839
2354
  const existingMcp = existsSync3(mcpPath) ? readFileSync4(mcpPath, "utf-8") : null;
1840
2355
  const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
2356
+ recordMcpMerge(draft, existingMcp, mcpContent);
1841
2357
  if (existingMcp === null) {
1842
2358
  writeAllowlisted(mcpPath, targetRepo, mcpContent, report.writtenPaths);
1843
2359
  } else if (existingMcp !== mcpContent) {
@@ -1847,14 +2363,22 @@ async function install(options) {
1847
2363
  }
1848
2364
  } else {
1849
2365
  report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
2366
+ const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
2367
+ if (existsSync3(mcpPath))
2368
+ recordMcpAdoptionOnly(draft, readFileSync4(mcpPath, "utf-8"));
1850
2369
  }
2370
+ const gitignorePath = join5(targetRepo, GITIGNORE_REL);
2371
+ const gitignoreBefore = existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null;
1851
2372
  const gitignoreOutcome = reconcileGitignore(targetRepo);
2373
+ recordGitignoreReconcile(draft, gitignoreBefore, existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null);
1852
2374
  if (gitignoreOutcome === "failed") {
1853
2375
  report.skippedPaths.push(".gitignore (write failed \u2014 see credential refusal)");
1854
2376
  } else {
1855
2377
  record(report, gitignoreOutcome, ".gitignore");
1856
2378
  }
1857
- record(report, writeProjectIdentity(targetRepo), ".halfcycle/project.json");
2379
+ const identityOutcome = writeProjectIdentity(targetRepo);
2380
+ record(report, identityOutcome, PROJECT_IDENTITY_REL);
2381
+ noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
1858
2382
  const credentialPath = engagementEnvPath(engagementId, home);
1859
2383
  if (credential) {
1860
2384
  record(report, writeEngagementCredential(credential, engagementId, home), credentialPath);
@@ -1880,8 +2404,18 @@ async function install(options) {
1880
2404
  break;
1881
2405
  }
1882
2406
  writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
1883
- writeCrewRoster(targetRepo, report);
2407
+ noteFile(draft, targetRepo, BUNDLE_PIN_REL, "written");
2408
+ noteFile(draft, targetRepo, CREW_ROSTER_REL, writeCrewRoster(targetRepo, report));
1884
2409
  const scanResult = runBootstrapScan(targetRepo);
2410
+ if (existsSync3(join5(targetRepo, BOOTSTRAP_STATE_REL)))
2411
+ recordPerMachineFile(draft, BOOTSTRAP_STATE_REL);
2412
+ for (const dir of writeSet.dirs) {
2413
+ if (!dirsBefore.has(dir) && existsSync3(join5(targetRepo, dir)))
2414
+ recordCreatedDir(draft, dir);
2415
+ }
2416
+ const installManifest = finishManifest(draft);
2417
+ assertManifestInWriteSet(installManifest, writeSet);
2418
+ recordOwned(report, join5(targetRepo, INSTALL_MANIFEST_REL), targetRepo, serializeManifest(installManifest), INSTALL_MANIFEST_REL);
1885
2419
  return {
1886
2420
  version: manifest.version,
1887
2421
  writtenPaths: report.writtenPaths,
@@ -2072,7 +2606,7 @@ async function mintBoardEnterCode(baseUrl, sessionToken) {
2072
2606
 
2073
2607
  // dist/setup/manifest.js
2074
2608
  import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync5, statSync, writeFileSync as writeFileSync4 } from "node:fs";
2075
- import { dirname as dirname3, join as join6, posix } from "node:path";
2609
+ import { dirname as dirname3, join as join6, posix as posix2 } from "node:path";
2076
2610
  var DECLARED_ROOT_FILES = ["CLAUDE.md", "AGENTS.md", "ENGAGEMENT.md"];
2077
2611
  var DECLARED_DIR_PREFIX = "docs";
2078
2612
  var RESERVED_METHOD_PATH = "docs/method";
@@ -2083,7 +2617,7 @@ function normalise(path) {
2083
2617
  const slashed = path.replace(/\\/g, "/");
2084
2618
  if (slashed.trim() === "")
2085
2619
  return "";
2086
- return posix.normalize(slashed).replace(/\/+$/, "");
2620
+ return posix2.normalize(slashed).replace(/\/+$/, "");
2087
2621
  }
2088
2622
  function isUnder(candidate, prefix) {
2089
2623
  return candidate === prefix || candidate.startsWith(prefix + "/");
@@ -2533,11 +3067,11 @@ function projectSeedGuard(fired, includeExplanation) {
2533
3067
 
2534
3068
  // dist/build-record/sources.js
2535
3069
  import { readFileSync as readFileSync7, existsSync as existsSync6 } from "node:fs";
2536
- import { createHash } from "node:crypto";
3070
+ import { createHash as createHash3 } from "node:crypto";
2537
3071
  import { join as join10 } from "node:path";
2538
3072
 
2539
3073
  // dist/build-record/close-record.js
2540
- import { execFileSync as execFileSync2 } from "node:child_process";
3074
+ import { execFileSync as execFileSync3 } from "node:child_process";
2541
3075
  import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
2542
3076
  import { dirname as dirname4, join as join8 } from "node:path";
2543
3077
  var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
@@ -2554,11 +3088,11 @@ function closeRecordPath(repoRoot, phase) {
2554
3088
  }
2555
3089
  function resolveCloseAtHead(repoRoot) {
2556
3090
  try {
2557
- const closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
3091
+ const closeCommit = execFileSync3("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
2558
3092
  encoding: "utf-8",
2559
3093
  stdio: ["ignore", "pipe", "ignore"]
2560
3094
  }).trim();
2561
- const closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
3095
+ const closedDate = execFileSync3("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
2562
3096
  encoding: "utf-8",
2563
3097
  stdio: ["ignore", "pipe", "ignore"]
2564
3098
  }).trim();
@@ -2782,7 +3316,7 @@ function syntheticRunId(record2) {
2782
3316
  record2["runType"],
2783
3317
  record2["phase"]
2784
3318
  ].join("|");
2785
- const hex = createHash("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
3319
+ const hex = createHash3("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
2786
3320
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
2787
3321
  }
2788
3322
  function readGuardEvalLog(logDir, phaseId) {