halfcycle 0.3.26 → 0.3.28

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, `'\\''`)}'`;
@@ -328,6 +335,18 @@ var artefactRefSchema = z4.object({
328
335
  name: z4.string(),
329
336
  parent: z4.string()
330
337
  }).strict();
338
+ var coverageSchema = z4.object({
339
+ asked: z4.number().int().nonnegative(),
340
+ answered: z4.number().int().nonnegative()
341
+ }).strict().superRefine((coverage, ctx) => {
342
+ if (coverage.answered > coverage.asked) {
343
+ ctx.addIssue({
344
+ code: z4.ZodIssueCode.custom,
345
+ path: ["answered"],
346
+ message: "answered cannot be more than asked"
347
+ });
348
+ }
349
+ });
331
350
  var artefactRecordSchema = stateRecordEnvelopeSchema.extend({
332
351
  recordKind: z4.literal("artefact"),
333
352
  artefactRef: artefactRefSchema,
@@ -366,7 +385,8 @@ var gateRecordSchema = stateRecordEnvelopeSchema.extend({
366
385
  gateKind: gateKindSchema,
367
386
  verdict: gateVerdictSchema,
368
387
  actor: z4.string(),
369
- outcomeReason: z4.string().max(2e3).optional()
388
+ outcomeReason: z4.string().max(2e3).optional(),
389
+ coverage: coverageSchema.optional()
370
390
  }).strict().superRefine((rec, ctx) => {
371
391
  const issue = outcomeReasonIssue(rec.verdict, rec.outcomeReason);
372
392
  if (issue) {
@@ -379,7 +399,8 @@ var interventionRecordSchema = stateRecordEnvelopeSchema.extend({
379
399
  mechanism: mechanismSchema,
380
400
  severity: severitySchema,
381
401
  disposition: dispositionSchema,
382
- summary: z4.string().max(2e3)
402
+ summary: z4.string().max(2e3),
403
+ coverage: coverageSchema.optional()
383
404
  }).strict();
384
405
  var stateRecordSchema = z4.discriminatedUnion("recordKind", [
385
406
  artefactRecordSchema,
@@ -675,7 +696,19 @@ var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
675
696
  var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
676
697
  var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
677
698
  var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
699
+ var InvalidEngagementIdError = class extends Error {
700
+ constructor(engagementId) {
701
+ const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
702
+ 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.`);
703
+ this.name = "InvalidEngagementIdError";
704
+ }
705
+ };
706
+ function assertEngagementId(engagementId) {
707
+ if (!isEngagementId(engagementId))
708
+ throw new InvalidEngagementIdError(engagementId);
709
+ }
678
710
  function engagementStateDir(engagementId, home) {
711
+ assertEngagementId(engagementId);
679
712
  return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
680
713
  }
681
714
  function engagementEnvPath(engagementId, home) {
@@ -766,6 +799,14 @@ halfcycle_env_file() {
766
799
  HALFCYCLE_ENV_PROBLEM="no-id"
767
800
  return 1
768
801
  fi
802
+ # The id is about to become a path under $HOME, and it came from a committed
803
+ # file: anything that is not a project id (a "../", a "/") is refused here,
804
+ # before the path exists. hc_id holds no newline (the pin was flattened above),
805
+ # so grep sees exactly one line.
806
+ if ! printf '%s\\n' "$hc_id" | grep -Eq '${ENGAGEMENT_ID_PATTERN}'; then
807
+ HALFCYCLE_ENV_PROBLEM="bad-id"
808
+ return 1
809
+ fi
769
810
  HALFCYCLE_ENV_ENGAGEMENT="$hc_id"
770
811
  if [ -z "\${HOME:-}" ]; then
771
812
  HALFCYCLE_ENV_PROBLEM="no-home"
@@ -834,6 +875,264 @@ function mintOrReadIdentity(targetRepoRoot) {
834
875
  return { identity, minted: true };
835
876
  }
836
877
 
878
+ // dist/install-manifest.js
879
+ import { createHash } from "node:crypto";
880
+ var INSTALL_MANIFEST_REL = ".halfcycle/install-manifest.json";
881
+ var INSTALL_MANIFEST_FORMAT = "halfcycle-install-manifest/v1";
882
+ function sha256Hex(bytes) {
883
+ return createHash("sha256").update(bytes).digest("hex");
884
+ }
885
+ function canonicalJson(value) {
886
+ if (Array.isArray(value))
887
+ return value.map(canonicalJson);
888
+ if (value !== null && typeof value === "object") {
889
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => [k, canonicalJson(v)]);
890
+ return Object.fromEntries(entries);
891
+ }
892
+ return value;
893
+ }
894
+ function canonicalSha256(value) {
895
+ return sha256Hex(JSON.stringify(canonicalJson(value)));
896
+ }
897
+ function byString(a, b) {
898
+ return a < b ? -1 : a > b ? 1 : 0;
899
+ }
900
+ function serializeManifest(manifest) {
901
+ const settings = {
902
+ ...manifest.settings,
903
+ hookCommands: [...manifest.settings.hookCommands].sort(byString),
904
+ denyAddedSha256: [...manifest.settings.denyAddedSha256].sort(byString),
905
+ eventsCreated: [...manifest.settings.eventsCreated].sort(byString)
906
+ };
907
+ const ordered = {
908
+ ...manifest,
909
+ files: [...manifest.files].sort((a, b) => byString(a.path, b.path)),
910
+ createdDirs: [...manifest.createdDirs].sort(byString),
911
+ leftAlone: [...manifest.leftAlone].sort(byString),
912
+ settings
913
+ };
914
+ return JSON.stringify(canonicalJson(ordered), null, 2) + "\n";
915
+ }
916
+ var UnreadableManifestError = class extends Error {
917
+ constructor(reason) {
918
+ super(reason);
919
+ this.name = "UnreadableManifestError";
920
+ }
921
+ };
922
+ var HEX64 = /^[0-9a-f]{64}$/;
923
+ function isRecord(value) {
924
+ return value !== null && typeof value === "object" && !Array.isArray(value);
925
+ }
926
+ function stringArray(value, field) {
927
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
928
+ throw new UnreadableManifestError(`"${field}" is not a list of strings`);
929
+ }
930
+ return value;
931
+ }
932
+ function hashArray(value, field) {
933
+ const list = stringArray(value, field);
934
+ if (!list.every((v) => HEX64.test(v)))
935
+ throw new UnreadableManifestError(`"${field}" holds a value that is not a hash`);
936
+ return list;
937
+ }
938
+ function bool(value, field) {
939
+ if (typeof value !== "boolean")
940
+ throw new UnreadableManifestError(`"${field}" is not true or false`);
941
+ return value;
942
+ }
943
+ function onlyKeys(value, allowed, where) {
944
+ const extra = Object.keys(value).filter((k) => !allowed.includes(k));
945
+ if (extra.length > 0)
946
+ throw new UnreadableManifestError(`${where} has a field this version does not know: ${extra.join(", ")}`);
947
+ }
948
+ function parseManifest(text) {
949
+ let raw;
950
+ try {
951
+ raw = JSON.parse(text);
952
+ } catch {
953
+ throw new UnreadableManifestError("it is not valid JSON");
954
+ }
955
+ if (!isRecord(raw))
956
+ throw new UnreadableManifestError("it is not a JSON object");
957
+ onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
958
+ if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
959
+ throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
960
+ }
961
+ if (!Array.isArray(raw["files"]))
962
+ throw new UnreadableManifestError('"files" is not a list');
963
+ const files = raw["files"].map((entry) => {
964
+ if (!isRecord(entry) || typeof entry["path"] !== "string") {
965
+ throw new UnreadableManifestError('a "files" entry has no path');
966
+ }
967
+ if (entry["perMachine"] === true) {
968
+ onlyKeys(entry, ["path", "perMachine"], `the "files" entry for ${entry["path"]}`);
969
+ return { path: entry["path"], perMachine: true };
970
+ }
971
+ onlyKeys(entry, ["path", "sha256"], `the "files" entry for ${entry["path"]}`);
972
+ if (typeof entry["sha256"] !== "string" || !HEX64.test(entry["sha256"])) {
973
+ throw new UnreadableManifestError(`the "files" entry for ${entry["path"]} has no valid hash`);
974
+ }
975
+ return { path: entry["path"], sha256: entry["sha256"] };
976
+ });
977
+ const s = raw["settings"];
978
+ if (!isRecord(s))
979
+ throw new UnreadableManifestError('"settings" is missing');
980
+ onlyKeys(s, [
981
+ "created",
982
+ "adopted",
983
+ "hookCommands",
984
+ "denyAddedSha256",
985
+ "schemaBefore",
986
+ "eventsCreated",
987
+ "hooksCreated",
988
+ "permissionsCreated",
989
+ "denyCreated"
990
+ ], '"settings"');
991
+ const settings = {
992
+ created: bool(s["created"], "settings.created"),
993
+ adopted: bool(s["adopted"], "settings.adopted"),
994
+ hookCommands: stringArray(s["hookCommands"], "settings.hookCommands"),
995
+ denyAddedSha256: hashArray(s["denyAddedSha256"], "settings.denyAddedSha256"),
996
+ ..."schemaBefore" in s ? { schemaBefore: s["schemaBefore"] } : {},
997
+ eventsCreated: stringArray(s["eventsCreated"], "settings.eventsCreated"),
998
+ hooksCreated: bool(s["hooksCreated"], "settings.hooksCreated"),
999
+ permissionsCreated: bool(s["permissionsCreated"], "settings.permissionsCreated"),
1000
+ denyCreated: bool(s["denyCreated"], "settings.denyCreated")
1001
+ };
1002
+ let mcp;
1003
+ if (raw["mcp"] !== void 0) {
1004
+ const m = raw["mcp"];
1005
+ if (!isRecord(m))
1006
+ throw new UnreadableManifestError('"mcp" is not an object');
1007
+ onlyKeys(m, ["created", "adopted", "mcpServersCreated", "entrySha256"], '"mcp"');
1008
+ if (m["entrySha256"] !== void 0 && (typeof m["entrySha256"] !== "string" || !HEX64.test(m["entrySha256"]))) {
1009
+ throw new UnreadableManifestError('"mcp.entrySha256" is not a hash');
1010
+ }
1011
+ mcp = {
1012
+ created: bool(m["created"], "mcp.created"),
1013
+ adopted: bool(m["adopted"], "mcp.adopted"),
1014
+ mcpServersCreated: bool(m["mcpServersCreated"], "mcp.mcpServersCreated"),
1015
+ ...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
1016
+ };
1017
+ }
1018
+ const g = raw["gitignore"];
1019
+ if (!isRecord(g))
1020
+ throw new UnreadableManifestError('"gitignore" is missing');
1021
+ onlyKeys(g, ["created", "appended"], '"gitignore"');
1022
+ const gitignore = {
1023
+ created: bool(g["created"], "gitignore.created"),
1024
+ appended: stringArray(g["appended"], "gitignore.appended")
1025
+ };
1026
+ const paths = files.map((f) => f.path);
1027
+ if (new Set(paths).size !== paths.length)
1028
+ throw new UnreadableManifestError('"files" names a path twice');
1029
+ const leftAlone = stringArray(raw["leftAlone"], "leftAlone");
1030
+ if (leftAlone.some((p) => paths.includes(p))) {
1031
+ throw new UnreadableManifestError('a path is in both "files" and "leftAlone"');
1032
+ }
1033
+ return {
1034
+ format: INSTALL_MANIFEST_FORMAT,
1035
+ files,
1036
+ createdDirs: stringArray(raw["createdDirs"], "createdDirs"),
1037
+ leftAlone,
1038
+ settings,
1039
+ ...mcp !== void 0 ? { mcp } : {},
1040
+ gitignore
1041
+ };
1042
+ }
1043
+ function startManifest(previous, isMember, isMemberDir) {
1044
+ const files = /* @__PURE__ */ new Map();
1045
+ for (const entry of previous?.files ?? []) {
1046
+ if (isMember(entry.path))
1047
+ files.set(entry.path, entry);
1048
+ }
1049
+ return {
1050
+ previous,
1051
+ files,
1052
+ collided: /* @__PURE__ */ new Set(),
1053
+ createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
1054
+ settings: previous?.settings,
1055
+ mcp: previous?.mcp,
1056
+ gitignore: previous?.gitignore
1057
+ };
1058
+ }
1059
+ function recordInstalledFile(draft, path, bytes) {
1060
+ draft.files.set(path, { path, sha256: sha256Hex(bytes) });
1061
+ }
1062
+ function recordPerMachineFile(draft, path) {
1063
+ draft.files.set(path, { path, perMachine: true });
1064
+ }
1065
+ function recordCollision(draft, path) {
1066
+ draft.collided.add(path);
1067
+ }
1068
+ function recordCreatedDir(draft, path) {
1069
+ draft.createdDirs.add(path);
1070
+ }
1071
+ function recordSettings(draft, observed) {
1072
+ const prev = draft.settings;
1073
+ if (prev === void 0) {
1074
+ draft.settings = observed;
1075
+ return;
1076
+ }
1077
+ draft.settings = {
1078
+ created: prev.created,
1079
+ adopted: prev.adopted,
1080
+ hookCommands: union(prev.hookCommands, observed.hookCommands),
1081
+ denyAddedSha256: union(prev.denyAddedSha256, observed.denyAddedSha256),
1082
+ ..."schemaBefore" in prev ? { schemaBefore: prev.schemaBefore } : "schemaBefore" in observed ? { schemaBefore: observed.schemaBefore } : {},
1083
+ eventsCreated: union(prev.eventsCreated, observed.eventsCreated),
1084
+ hooksCreated: prev.hooksCreated,
1085
+ permissionsCreated: prev.permissionsCreated,
1086
+ denyCreated: prev.denyCreated
1087
+ };
1088
+ }
1089
+ function recordMcp(draft, observed) {
1090
+ const prev = draft.mcp;
1091
+ if (prev === void 0) {
1092
+ draft.mcp = observed;
1093
+ return;
1094
+ }
1095
+ const entrySha256 = observed.entrySha256 ?? prev.entrySha256;
1096
+ draft.mcp = {
1097
+ created: prev.created,
1098
+ adopted: prev.adopted,
1099
+ mcpServersCreated: prev.mcpServersCreated,
1100
+ ...entrySha256 !== void 0 ? { entrySha256 } : {}
1101
+ };
1102
+ }
1103
+ function recordGitignore(draft, created, appended) {
1104
+ const prev = draft.gitignore;
1105
+ draft.gitignore = {
1106
+ created: prev?.created ?? created,
1107
+ appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
1108
+ };
1109
+ }
1110
+ function finishManifest(draft) {
1111
+ const files = [...draft.files.values()];
1112
+ const leftAlone = [...draft.collided].filter((p) => !draft.files.has(p));
1113
+ return {
1114
+ format: INSTALL_MANIFEST_FORMAT,
1115
+ files,
1116
+ createdDirs: [...draft.createdDirs],
1117
+ leftAlone,
1118
+ settings: draft.settings ?? {
1119
+ created: false,
1120
+ adopted: false,
1121
+ hookCommands: [],
1122
+ denyAddedSha256: [],
1123
+ eventsCreated: [],
1124
+ hooksCreated: false,
1125
+ permissionsCreated: false,
1126
+ denyCreated: false
1127
+ },
1128
+ ...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
1129
+ gitignore: draft.gitignore ?? { created: false, appended: [] }
1130
+ };
1131
+ }
1132
+ function union(a, b) {
1133
+ return [.../* @__PURE__ */ new Set([...a, ...b])];
1134
+ }
1135
+
837
1136
  // dist/mcp-endpoint.js
838
1137
  var MCP_ENDPOINT_PATH = "/mcp";
839
1138
  function mcpEndpointUrl(origin) {
@@ -919,8 +1218,9 @@ function scanLayers(targetRepo, scannedAt = (/* @__PURE__ */ new Date()).toISOSt
919
1218
  }
920
1219
  return { format: HALFCYCLE_STATE_FORMAT, note: HALFCYCLE_STATE_NOTE, layers };
921
1220
  }
1221
+ var BOOTSTRAP_STATE_REL = ".halfcycle/state.json";
922
1222
  function runBootstrapScan(targetRepo) {
923
- const statePath = join4(targetRepo, ".halfcycle", "state.json");
1223
+ const statePath = join4(targetRepo, BOOTSTRAP_STATE_REL);
924
1224
  if (existsSync2(statePath)) {
925
1225
  const existing = JSON.parse(readFileSync3(statePath, "utf-8"));
926
1226
  return { state: existing, ran: false };
@@ -994,22 +1294,152 @@ function isAllowlisted(targetRelPath) {
994
1294
  return normalised === p || normalised.startsWith(p + "/");
995
1295
  });
996
1296
  }
1297
+ var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
1298
+ var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
1299
+ var CREW_ROSTER_REL = ".halfcycle/crew.json";
1300
+ var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
1301
+ var SETTINGS_REL = ".claude/settings.json";
1302
+ var GITIGNORE_REL = ".gitignore";
1303
+ function commandStubRel(name) {
1304
+ return `.claude/commands/${name}.md`;
1305
+ }
1306
+ var RETIRED_WRITE_SET_FILES = [];
1307
+ var RETIRED_HOOK_COMMANDS = [];
1308
+ var RETIRED_DENY_PATTERNS = [];
1309
+ function installerHookCommands() {
1310
+ const settings = JSON.parse(generateSettingsJson());
1311
+ return Object.values(settings.hooks ?? {}).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
1312
+ }
1313
+ function closedWriteSet() {
1314
+ const files = /* @__PURE__ */ new Set([
1315
+ ...(readPluginManifest().commands ?? []).map((cmd) => commandStubRel(cmd.name)),
1316
+ ...Object.keys(OWNED_GENERATED_HEADERS),
1317
+ VENDORED_BIN_REL,
1318
+ CREW_ROSTER_REL,
1319
+ BUNDLE_PIN_REL,
1320
+ PROJECT_IDENTITY_REL,
1321
+ BOOTSTRAP_STATE_REL,
1322
+ CAPTURED_INDEX_REL,
1323
+ INSTALL_MANIFEST_REL,
1324
+ ...RETIRED_WRITE_SET_FILES
1325
+ ]);
1326
+ const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
1327
+ const dirs = /* @__PURE__ */ new Set();
1328
+ for (const path of [...files, ...merged]) {
1329
+ for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
1330
+ dirs.add(dir);
1331
+ }
1332
+ const ownedDirs = new Set([...dirs].filter((dir) => `${dir}/`.startsWith(INSTALLER_OWNED_DIR)));
1333
+ return {
1334
+ files,
1335
+ merged,
1336
+ dirs,
1337
+ ownedDirs,
1338
+ hookCommands: /* @__PURE__ */ new Set([...installerHookCommands(), ...RETIRED_HOOK_COMMANDS]),
1339
+ denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
1340
+ gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL])
1341
+ };
1342
+ }
1343
+ function readPreviousManifest(targetRepo) {
1344
+ try {
1345
+ return parseManifest(readFileSync4(join5(targetRepo, INSTALL_MANIFEST_REL), "utf-8"));
1346
+ } catch {
1347
+ return null;
1348
+ }
1349
+ }
1350
+ function noteFile(draft, targetRepo, rel, outcome) {
1351
+ if (outcome === "collided") {
1352
+ recordCollision(draft, rel);
1353
+ return;
1354
+ }
1355
+ recordInstalledFile(draft, rel, readFileSync4(join5(targetRepo, rel)));
1356
+ }
1357
+ function recordSettingsMerge(draft, existing, generated, preexisted) {
1358
+ const generatedHooks = generated.hooks ?? {};
1359
+ const hookCommands = Object.values(generatedHooks).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
1360
+ const ours = new Set(hookCommands);
1361
+ const existingHooks = isPlainObject(existing.hooks) ? existing.hooks : void 0;
1362
+ const heldOurs = Object.values(existingHooks ?? {}).some((entries) => Array.isArray(entries) && entries.some((entry) => isHalfcycleEntry(entry, ours)));
1363
+ const existingDeny = Array.isArray(existing.permissions?.deny) ? existing.permissions.deny : [];
1364
+ const schemaChanged = generated.$schema !== void 0 && existing.$schema !== generated.$schema;
1365
+ recordSettings(draft, {
1366
+ created: !preexisted,
1367
+ adopted: draft.previous === null && heldOurs,
1368
+ hookCommands,
1369
+ denyAddedSha256: (generated.permissions?.deny ?? []).filter((rule) => !existingDeny.includes(rule)).map((rule) => sha256Hex(rule)),
1370
+ ...schemaChanged ? { schemaBefore: existing.$schema ?? null } : {},
1371
+ eventsCreated: Object.keys(generatedHooks).filter((event) => !(existingHooks && event in existingHooks)),
1372
+ hooksCreated: existing.hooks === void 0,
1373
+ permissionsCreated: existing.permissions === void 0,
1374
+ denyCreated: existing.permissions?.deny === void 0
1375
+ });
1376
+ }
1377
+ function recordMcpMerge(draft, existingText, writtenText) {
1378
+ const before = existingText === null ? void 0 : JSON.parse(existingText);
1379
+ const servers = isPlainObject(before) ? before["mcpServers"] : void 0;
1380
+ const written = JSON.parse(writtenText);
1381
+ recordMcp(draft, {
1382
+ created: existingText === null,
1383
+ adopted: draft.previous === null && isPlainObject(servers) && MCP_SERVER_KEY in servers,
1384
+ mcpServersCreated: !(isPlainObject(before) && "mcpServers" in before),
1385
+ entrySha256: canonicalSha256(written.mcpServers[MCP_SERVER_KEY])
1386
+ });
1387
+ }
1388
+ function recordMcpAdoptionOnly(draft, existingText) {
1389
+ if (draft.previous !== null || draft.mcp !== void 0)
1390
+ return;
1391
+ let before;
1392
+ try {
1393
+ before = JSON.parse(existingText);
1394
+ } catch {
1395
+ return;
1396
+ }
1397
+ const servers = isPlainObject(before) ? before["mcpServers"] : void 0;
1398
+ if (!isPlainObject(servers) || !(MCP_SERVER_KEY in servers))
1399
+ return;
1400
+ recordMcp(draft, { created: false, adopted: true, mcpServersCreated: false });
1401
+ }
1402
+ function recordGitignoreReconcile(draft, before, after) {
1403
+ const adopted = draft.gitignore === void 0 && before !== null ? adoptOlderGitignoreBlock(before) : "";
1404
+ let appended = "";
1405
+ if (after !== null && after !== before) {
1406
+ if (before === null)
1407
+ appended = after;
1408
+ else if (after.startsWith(before))
1409
+ appended = after.slice(before.length);
1410
+ }
1411
+ recordGitignore(draft, before === null && after !== null, [adopted, appended]);
1412
+ }
1413
+ function assertManifestInWriteSet(manifest, writeSet) {
1414
+ const strays = [
1415
+ ...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
1416
+ ...manifest.leftAlone.filter((p) => !writeSet.files.has(p)),
1417
+ ...manifest.createdDirs.filter((p) => !writeSet.dirs.has(p))
1418
+ ];
1419
+ if (strays.length > 0) {
1420
+ throw new Error(`[bundle install] the install record names paths outside the write set: ${strays.join(", ")}`);
1421
+ }
1422
+ }
1423
+ function isPlainObject(value) {
1424
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1425
+ }
997
1426
  function readPluginManifest() {
998
1427
  const manifestPath = join5(BUNDLE_ROOT, ".claude-plugin", "plugin.json");
999
1428
  const raw = readFileSync4(manifestPath, "utf-8");
1000
1429
  return JSON.parse(raw);
1001
1430
  }
1002
- function copyManifestCommands(manifest, targetRepo, report) {
1003
- const commandsTargetDir = join5(targetRepo, ".claude", "commands");
1431
+ function copyManifestCommands(manifest, targetRepo, report, draft) {
1004
1432
  for (const cmd of manifest.commands ?? []) {
1005
1433
  const srcFile = join5(BUNDLE_ROOT, ".claude-plugin", cmd.path);
1006
1434
  if (!existsSync3(srcFile)) {
1007
1435
  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
1436
  }
1009
- const destFile = join5(commandsTargetDir, `${cmd.name}.md`);
1437
+ const rel = commandStubRel(cmd.name);
1438
+ const destFile = join5(targetRepo, rel);
1010
1439
  const content = readFileSync4(srcFile, "utf-8");
1011
- const rel = relative(targetRepo, destFile).replace(/\\/g, "/");
1012
- record(report, writeCollisionSafe(destFile, targetRepo, content), rel);
1440
+ const outcome = writeCollisionSafe(destFile, targetRepo, content);
1441
+ record(report, outcome, rel);
1442
+ noteFile(draft, targetRepo, rel, outcome);
1013
1443
  }
1014
1444
  }
1015
1445
  function writeAllowlisted(targetAbsPath, targetRepoRoot, content, writtenPaths) {
@@ -1075,6 +1505,7 @@ function recordOwnedGenerated(report, targetAbsPath, targetRepoRoot, content, ge
1075
1505
  record(report, outcome, rel);
1076
1506
  if (replacedExisting)
1077
1507
  report.replacedPaths.push(rel);
1508
+ return outcome;
1078
1509
  }
1079
1510
  function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
1080
1511
  const existedBefore = existsSync3(targetAbsPath);
@@ -1082,6 +1513,7 @@ function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
1082
1513
  record(report, outcome, rel);
1083
1514
  if (outcome === "written" && existedBefore)
1084
1515
  report.replacedPaths.push(rel);
1516
+ return outcome;
1085
1517
  }
1086
1518
  function record(report, outcome, rel) {
1087
1519
  const bucket = {
@@ -1406,101 +1838,53 @@ printf '%s\\n' '${USER_PROMPT_REMINDER_SENTENCE}'
1406
1838
  exit 0
1407
1839
  `;
1408
1840
  }
1409
- function generateCiStanza() {
1410
- return `# Halfcycle guard CI job \u2014 generated by the Halfcycle installer.
1411
- # Below is a complete GitHub Actions workflow: a name, the events it runs on, and
1412
- # one job. It assumes no package manager and no monorepo tooling: it runs the
1413
- # self-contained guard binary vendored at .halfcycle/bin/, so it works in a Python
1414
- # or Go repository as well as a Node one \u2014 the only prerequisite is Node 20 to run
1415
- # the bundled binary.
1416
- #
1417
- # NO WORKFLOW YET? Copy everything from the \`name:\` line to the end of the job
1418
- # into a new file, .github/workflows/halfcycle-guard.yml, without the leading
1419
- # "# " on each line. That file is complete as it stands: commit it, and the check
1420
- # runs on the next push and on every pull request.
1421
- #
1422
- # ALREADY HAVE A WORKFLOW? Copy only the job \u2014 from \`halfcycle-guard-ci:\` down to
1423
- # its \`run:\` line \u2014 under the \`jobs:\` key of the workflow you have, keeping the
1424
- # indentation. The job carries its own \`permissions\` block, so it needs nothing
1425
- # from the rest of that file.
1426
- #
1427
- # THERE IS NOTHING TO STORE. No token in your repository's secrets, no project id
1428
- # to look up, and no address to set. The \`permissions\` block below is the whole
1429
- # configuration: it lets the job ask GitHub for a short-lived signed token naming
1430
- # the repository it is running in, and Halfcycle trusts the name GitHub signs
1431
- # rather than anything the job says about itself. That token is traded for a
1432
- # credential that lives for minutes, and nothing is kept at either end.
1433
- #
1434
- # BOTH PERMISSION LINES, NOT JUST THE SECOND. Declaring any permission
1435
- # replaces the defaults rather than adding to them, so a block naming only
1436
- # \`id-token\` takes read access away from the checkout step and a private
1437
- # repository stops checking out before the guard is reached. If your workflow
1438
- # already has a \`permissions\` block, add \`id-token: write\` to it and leave the
1439
- # rest alone.
1440
- #
1441
- # ONE THING TO DO FIRST, ONCE, ON YOUR OWN MACHINE. In this project, run
1442
- #
1443
- # npx halfcycle ci bind <owner>/<repo>
1444
- #
1445
- # naming this repository as GitHub spells it (for example acme/widgets). That
1446
- # tells Halfcycle this project's CI runs from that repository, and it is the only
1447
- # thing that makes a run mean anything: without it, Halfcycle has a signed
1448
- # statement of which repository the job is in and no idea whose project that is.
1449
- # The check says exactly that, and names the command, if you skip it.
1450
- #
1451
- # THERE IS NO ADDRESS TO CONFIGURE ANYWHERE IN HALFCYCLE. Every service this
1452
- # product talks to has one address, the same for every user, and it ships in the
1453
- # tools you already have: the installer, the guard hook and the binary this job
1454
- # runs each know where to go. If something tells you to set a Halfcycle URL, it is
1455
- # out of date.
1456
- #
1457
- # WHICH BRANCH MODEL THIS ASSUMES: none. It works on pull-request branches AND on
1458
- # commits pushed straight to the default branch, which is the shape most
1459
- # Halfcycle engagements settle on. The \`on:\` block is what runs it in both
1460
- # places: every push, and every pull request. Narrow it if you want (for example
1461
- # \`branches: [main]\` under \`push:\`), but keep \`pull_request\`, or a change is
1462
- # first checked after it has merged. \`fetch-depth\` is what makes the check work
1463
- # there: it needs the commit BEFORE the one it is evaluating, and the default
1464
- # shallow checkout does not have it. With \`fetch-depth: 0\` the check fails loudly
1465
- # if it cannot work out what to evaluate \u2014 it will not pass quietly having
1466
- # evaluated nothing.
1467
- #
1468
- # name: Halfcycle guard
1469
- # on:
1470
- # push:
1471
- # pull_request:
1472
- # jobs:
1473
- # halfcycle-guard-ci:
1474
- # runs-on: ubuntu-latest
1475
- # permissions:
1476
- # contents: read
1477
- # id-token: write
1478
- # steps:
1479
- # - uses: actions/checkout@v4
1480
- # with:
1481
- # # REQUIRED. 0 = full history. The check diffs against the commit before
1482
- # # HEAD (or the fork point on a branch); the default depth of 1 has
1483
- # # neither. Do not lower this.
1484
- # fetch-depth: 0
1485
- # - uses: actions/setup-node@v4
1486
- # with:
1487
- # node-version: '20'
1488
- # - name: Halfcycle guard CI check
1489
- # # No env block, on purpose: this step holds no secret. The permissions
1490
- # # above are what authenticate it.
1491
- # run: node ./.halfcycle/bin/bin.bundle.mjs ci
1492
- #
1493
- # The job prints the diff base it used and how many files it evaluated, on every
1494
- # run. If that line says 0 files on a commit that changed something, the base is
1495
- # wrong \u2014 set HALFCYCLE_DIFF_BASE in an env block OF YOUR COPY of the check step,
1496
- # to name it explicitly (on a GitHub push event, \${{ github.event.before }} is the
1497
- # right value).
1498
- #
1499
- # EDIT YOUR COPY, NOT THIS FILE. This one is regenerated by the installer and your
1500
- # changes to it would be replaced the next time you run \`npx halfcycle\`. It is
1501
- # also inert where it sits: no CI system reads this path. Copy the workflow above
1502
- # \u2014 or just its job \u2014 into .github/workflows/ and change it there.
1503
- `;
1841
+ var CI_STANZA_KNOWN_HEADER_HASHES = [
1842
+ "b225800b2d699811f32ac213238cbf81c35b5e6a74114bdf55e39cd9d7e2928f",
1843
+ "674181735460f051af7c157222cf851fc3000fb3b8af3a10ea0bde9fe5eaba1f",
1844
+ "d17bde608a6fec54a63cb934a28d88acc8babc205334b5ee84d4ed6ee219d340",
1845
+ "85e71d3f6551977b241a28468f121ee4d1c4edef40a2c4dc1aa6f2f737ebd037"
1846
+ ];
1847
+ function isTrackedAndClean(targetRepo, relPath) {
1848
+ try {
1849
+ execFileSync2("git", ["-C", targetRepo, "ls-files", "--error-unmatch", "--", relPath], {
1850
+ stdio: "ignore"
1851
+ });
1852
+ } catch {
1853
+ return false;
1854
+ }
1855
+ try {
1856
+ execFileSync2("git", ["-C", targetRepo, "diff", "--quiet", "HEAD", "--", relPath], {
1857
+ stdio: "ignore"
1858
+ });
1859
+ } catch {
1860
+ return false;
1861
+ }
1862
+ return true;
1863
+ }
1864
+ function removeLegacyCiStanza(targetRepo) {
1865
+ const rel = ".halfcycle/ci-stanza.yml";
1866
+ const path = join5(targetRepo, rel);
1867
+ if (!existsSync3(path))
1868
+ return "absent";
1869
+ let raw;
1870
+ try {
1871
+ raw = readFileSync4(path, "utf-8");
1872
+ } catch {
1873
+ return "failed";
1874
+ }
1875
+ const headerLine = raw.split("\n")[0] ?? "";
1876
+ const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
1877
+ const ours = CI_STANZA_KNOWN_HEADER_HASHES.includes(digest);
1878
+ if (!ours)
1879
+ return "kept-foreign";
1880
+ if (!isTrackedAndClean(targetRepo, rel))
1881
+ return "kept-uncommitted";
1882
+ try {
1883
+ rmSync(path);
1884
+ } catch {
1885
+ return "failed";
1886
+ }
1887
+ return "removed";
1504
1888
  }
1505
1889
  var MCP_REGISTRATION_REL = ".mcp.json";
1506
1890
  var MCP_SERVER_KEY = "halfcycle";
@@ -1542,13 +1926,81 @@ PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
1542
1926
 
1543
1927
  ${engagementResolutionShell()}
1544
1928
 
1929
+ # Read one KEY's value out of the credential store into HC_VALUE, in the CALLER's
1930
+ # shell (call it as a statement, never inside $(...)). Last assignment wins,
1931
+ # matching the shell's own \`.\` semantics. An optional leading \`export \` is
1932
+ # tolerated because a hand-edited store may use one.
1933
+ halfcycle_store_value() {
1934
+ HC_VALUE=""
1935
+ hc_line=$(grep "^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}$2=" "$1" | tail -n 1)
1936
+ hc_v=\${hc_line#*"$2="}
1937
+ hc_v=$(printf '%s' "$hc_v" | tr -d '\\r')
1938
+
1939
+ # Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
1940
+ # writers use the same shq: this installer and Studio's provider), because
1941
+ # guard-runner.sh SOURCES the same file and an unquoted value carrying a space
1942
+ # would execute its own remainder. This reader greps rather than sources, so it
1943
+ # has to undo the quoting itself \u2014 and it must land on the same value the
1944
+ # sourcing consumer gets, or one file has two answers.
1945
+ case $hc_v in
1946
+ '"'*'"') hc_v=\${hc_v#'"'}; hc_v=\${hc_v%'"'} ;;
1947
+ "'"*"'")
1948
+ hc_v=\${hc_v#"'"}; hc_v=\${hc_v%"'"}
1949
+ # \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
1950
+ # through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
1951
+ # the CI platform) and BSD sed (macOS), feeding each the script from a file so
1952
+ # no shell quoting is in the way:
1953
+ #
1954
+ # s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
1955
+ # s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
1956
+ #
1957
+ # A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
1958
+ # does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
1959
+ # and the token then travels with four stray characters in it. The bracket
1960
+ # expression makes the backslash literal by a construction POSIX does define.
1961
+ hc_v=$(printf '%s' "$hc_v" | sed "s/'[\\]''/'/g") ;;
1962
+ esac
1963
+ HC_VALUE=$hc_v
1964
+ }
1965
+
1966
+ # The scheme and host[:port] of a URL, lowercased \u2014 or NOTHING when the URL holds
1967
+ # a space or a control character anywhere. That refusal is the load-bearing part:
1968
+ # a URL parser drops tabs and newlines before it reads the host, so
1969
+ # "https://ours<newline>@elsewhere/" is a request to "elsewhere", while a
1970
+ # line-by-line reading here would see only "https://ours". The host ends at the
1971
+ # first / ? # or backslash, which is where a URL parser ends it for http(s).
1972
+ halfcycle_origin() {
1973
+ hc_u=$1
1974
+ if [ "$(printf '%s' "$hc_u" | tr -d '[:cntrl:][:space:]')" != "$hc_u" ]; then
1975
+ return 0
1976
+ fi
1977
+ printf '%s\\n' "$hc_u" | sed -n 's|^\\([A-Za-z][A-Za-z0-9+.-]*://[^/?#\\\\]*\\).*$|\\1|p' | tr '[:upper:]' '[:lower:]'
1978
+ }
1979
+
1545
1980
  # HALFCYCLE_TOKEN in the environment WINS, and it is the CI arm: a job with no
1546
1981
  # browser and no per-user store exports the credential, and a stale store on a
1547
1982
  # long-lived runner must not silently win over it. Same order, and the same reason,
1548
- # as the CLI's own resolver (\`resolve-credential.ts\`).
1983
+ # as the CLI's own resolver (\`resolve-credential.ts\`). The server it may be sent
1984
+ # to is NOT taken from the environment: it is read from this machine's store in
1985
+ # both arms, so a token supplied this way still goes only where this machine was
1986
+ # set up to send it.
1987
+ # The pin names an id that is not a project id. Re-running the installer against
1988
+ # that pin refuses it too, so the one remedy that works is a fresh pin.
1989
+ halfcycle_bad_id_message() {
1990
+ echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names an engagement id that is not a Halfcycle project id," >&2
1991
+ echo "halfcycle: so no credential was read. Delete that file and run \\"npx halfcycle\\" again: this repository" >&2
1992
+ echo "halfcycle: is then set up as a new Halfcycle project." >&2
1993
+ }
1994
+
1549
1995
  TOKEN=\${HALFCYCLE_TOKEN:-}
1996
+ MCP_URL=""
1550
1997
 
1551
- if [ -z "$TOKEN" ]; then
1998
+ if [ -n "$TOKEN" ]; then
1999
+ if halfcycle_env_file "$PROJECT_ROOT"; then
2000
+ halfcycle_store_value "$HALFCYCLE_ENV_FILE" HALFCYCLE_MCP_URL
2001
+ MCP_URL=$HC_VALUE
2002
+ fi
2003
+ else
1552
2004
  if ! halfcycle_env_file "$PROJECT_ROOT"; then
1553
2005
  case $HALFCYCLE_ENV_PROBLEM in
1554
2006
  no-pin)
@@ -1557,6 +2009,8 @@ if [ -z "$TOKEN" ]; then
1557
2009
  no-id)
1558
2010
  echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names no engagement id." >&2
1559
2011
  echo "halfcycle: run \\"npx halfcycle\\" here to rewrite it." >&2 ;;
2012
+ bad-id)
2013
+ halfcycle_bad_id_message ;;
1560
2014
  no-home)
1561
2015
  echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
1562
2016
  *)
@@ -1579,42 +2033,48 @@ if [ -z "$TOKEN" ]; then
1579
2033
  fi
1580
2034
  ENV_FILE="$HALFCYCLE_ENV_FILE"
1581
2035
 
1582
- # Last assignment wins, matching the shell's own \`.\` semantics. An optional
1583
- # leading \`export \` is tolerated because a hand-edited store may use one.
1584
- LINE=$(grep '^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}HALFCYCLE_TOKEN=' "$ENV_FILE" | tail -n 1)
1585
- TOKEN=\${LINE#*HALFCYCLE_TOKEN=}
1586
- TOKEN=$(printf '%s' "$TOKEN" | tr -d '\\r')
1587
-
1588
- # Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
1589
- # writers use the same shq: this installer and Studio's provider), because
1590
- # guard-runner.sh SOURCES the same file and an unquoted value carrying a space
1591
- # would execute its own remainder. This reader greps rather than sources, so it
1592
- # has to undo the quoting itself \u2014 and it must land on the same value the
1593
- # sourcing consumer gets, or one file has two answers.
1594
- case $TOKEN in
1595
- '"'*'"') TOKEN=\${TOKEN#'"'}; TOKEN=\${TOKEN%'"'} ;;
1596
- "'"*"'")
1597
- TOKEN=\${TOKEN#"'"}; TOKEN=\${TOKEN%"'"}
1598
- # \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
1599
- # through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
1600
- # the CI platform) and BSD sed (macOS), feeding each the script from a file so
1601
- # no shell quoting is in the way:
1602
- #
1603
- # s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
1604
- # s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
1605
- #
1606
- # A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
1607
- # does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
1608
- # and the token then travels with four stray characters in it. The bracket
1609
- # expression makes the backslash literal by a construction POSIX does define.
1610
- TOKEN=$(printf '%s' "$TOKEN" | sed "s/'[\\]''/'/g") ;;
1611
- esac
1612
-
2036
+ halfcycle_store_value "$ENV_FILE" HALFCYCLE_TOKEN
2037
+ TOKEN=$HC_VALUE
1613
2038
  if [ -z "$TOKEN" ]; then
1614
2039
  echo "halfcycle: HALFCYCLE_TOKEN is absent or empty in $ENV_FILE." >&2
1615
2040
  echo "halfcycle: run \\"npx halfcycle\\" in this repository to rewrite this machine's credential." >&2
1616
2041
  exit 1
1617
2042
  fi
2043
+ halfcycle_store_value "$ENV_FILE" HALFCYCLE_MCP_URL
2044
+ MCP_URL=$HC_VALUE
2045
+ fi
2046
+
2047
+ # THE TOKEN GOES ONLY TO THIS MACHINE'S HALFCYCLE SERVER. .mcp.json is a tracked
2048
+ # file: anyone who can land a commit can change the server's url, and this script
2049
+ # would then hand the credential to whatever it names. Claude Code tells the helper
2050
+ # which url it is about to connect to (CLAUDE_CODE_MCP_SERVER_URL); the server this
2051
+ # machine was set up against is recorded OUTSIDE the repository, beside the
2052
+ # credential. The two must share scheme, host and port, or nothing is printed.
2053
+ # No url at all is a refusal too: sending the credential without knowing where it
2054
+ # is going is the thing this check exists to stop.
2055
+ REQUESTED=\${CLAUDE_CODE_MCP_SERVER_URL:-}
2056
+ if [ -z "$REQUESTED" ]; then
2057
+ echo "halfcycle: Claude Code did not say which server it is connecting to, so the Halfcycle credential was not sent." >&2
2058
+ echo "halfcycle: update Claude Code, then reconnect." >&2
2059
+ exit 1
2060
+ fi
2061
+ if [ -z "$MCP_URL" ] && [ "\${HALFCYCLE_ENV_PROBLEM:-}" = "bad-id" ]; then
2062
+ halfcycle_bad_id_message
2063
+ exit 1
2064
+ fi
2065
+ if [ -z "$MCP_URL" ]; then
2066
+ echo "halfcycle: this machine has no Halfcycle server address recorded for this repository, so the credential was not sent." >&2
2067
+ echo "halfcycle: run \\"npx halfcycle\\" in this repository on this machine to record it." >&2
2068
+ exit 1
2069
+ fi
2070
+ WANT=$(halfcycle_origin "$MCP_URL")
2071
+ GOT=$(halfcycle_origin "$REQUESTED")
2072
+ if [ -z "$WANT" ] || [ "$GOT" != "$WANT" ]; then
2073
+ SHOWN=$(printf '%s' "$REQUESTED" | tr -cd '[:graph:]' | cut -c1-200)
2074
+ echo "halfcycle: .mcp.json asks for this repository's Halfcycle credential to be sent to $SHOWN," >&2
2075
+ echo "halfcycle: which is not this machine's Halfcycle server (\${WANT:-none recorded}). The credential was NOT sent." >&2
2076
+ echo "halfcycle: if nobody meant to change .mcp.json, treat that change as suspect; \\"npx halfcycle\\" restores the entry." >&2
2077
+ exit 1
1618
2078
  fi
1619
2079
 
1620
2080
  # JSON-escape: backslash first, then double quote. A token carrying either would
@@ -1630,7 +2090,7 @@ function generateMcpRegistration(existing, mcpOrigin) {
1630
2090
  if (existing !== null) {
1631
2091
  const parsed = JSON.parse(existing);
1632
2092
  if (parsed !== null && typeof parsed === "object") {
1633
- base = { mcpServers: {}, ...parsed };
2093
+ base = { ...parsed };
1634
2094
  }
1635
2095
  }
1636
2096
  if (typeof base.mcpServers !== "object" || base.mcpServers === null) {
@@ -1644,13 +2104,33 @@ function generateMcpRegistration(existing, mcpOrigin) {
1644
2104
  return JSON.stringify(base, null, 2) + "\n";
1645
2105
  }
1646
2106
  var REQUIRED_GITIGNORE_ENTRIES = [".halfcycle/state.json", `${ZONE_B_DIR}/`];
2107
+ var GITIGNORE_HEADER = "# Halfcycle \u2014 machine-local secrets/state and Zone-B (never push to client remote)";
2108
+ function adoptOlderGitignoreBlock(before) {
2109
+ const claimable = /* @__PURE__ */ new Set([...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]);
2110
+ const claimed = [];
2111
+ let underHeader = false;
2112
+ const lines = before.split("\n");
2113
+ for (const [i, line] of lines.entries()) {
2114
+ if (line === GITIGNORE_HEADER) {
2115
+ if (i > 0 && lines[i - 1] === "")
2116
+ claimed.push("");
2117
+ claimed.push(line);
2118
+ underHeader = true;
2119
+ } else if (line.trim() === "") {
2120
+ underHeader = false;
2121
+ } else if (underHeader && claimable.has(line)) {
2122
+ claimed.push(line);
2123
+ }
2124
+ }
2125
+ return claimed.length > 0 ? claimed.join("\n") + "\n" : "";
2126
+ }
1647
2127
  function gitignoreCovers(content) {
1648
2128
  const lines = new Set(content.split("\n").map((l) => l.trim()));
1649
2129
  return REQUIRED_GITIGNORE_ENTRIES.every((entry) => lines.has(entry));
1650
2130
  }
1651
2131
  function reconcileGitignore(targetRepo) {
1652
- const gitignorePath = join5(targetRepo, ".gitignore");
1653
- const header = "# Halfcycle \u2014 machine-local secrets/state and Zone-B (never push to client remote)";
2132
+ const gitignorePath = join5(targetRepo, GITIGNORE_REL);
2133
+ const header = GITIGNORE_HEADER;
1654
2134
  try {
1655
2135
  if (!existsSync3(gitignorePath)) {
1656
2136
  const body = [header, ...REQUIRED_GITIGNORE_ENTRIES].join("\n") + "\n";
@@ -1695,7 +2175,7 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, a
1695
2175
  installedAt,
1696
2176
  ...carried !== void 0 ? { accountId: carried } : {}
1697
2177
  };
1698
- const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
2178
+ const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
1699
2179
  writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
1700
2180
  }
1701
2181
  function writeCrewRoster(targetRepoRoot, report) {
@@ -1705,11 +2185,11 @@ function writeCrewRoster(targetRepoRoot, report) {
1705
2185
  };
1706
2186
  const rendered = `${JSON.stringify(doc, null, 2)}
1707
2187
  `;
1708
- const crewPath = join5(targetRepoRoot, ".halfcycle", "crew.json");
1709
- recordOwned(report, crewPath, targetRepoRoot, rendered, ".halfcycle/crew.json");
2188
+ const crewPath = join5(targetRepoRoot, CREW_ROSTER_REL);
2189
+ return recordOwned(report, crewPath, targetRepoRoot, rendered, CREW_ROSTER_REL);
1710
2190
  }
1711
2191
  function readBundlePin(targetRepoRoot) {
1712
- const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
2192
+ const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
1713
2193
  if (!existsSync3(pinPath))
1714
2194
  return null;
1715
2195
  return JSON.parse(readFileSync4(pinPath, "utf-8"));
@@ -1733,7 +2213,7 @@ function checkDrift(targetRepoRoot) {
1733
2213
  return { drifted: installed !== current, installed, current };
1734
2214
  }
1735
2215
  function writeProjectIdentity(targetRepo) {
1736
- const path = join5(targetRepo, ".halfcycle", "project.json");
2216
+ const path = join5(targetRepo, PROJECT_IDENTITY_REL);
1737
2217
  const { identity } = mintOrReadIdentity(targetRepo);
1738
2218
  const serialized = JSON.stringify(identity, null, 2) + "\n";
1739
2219
  if (existsSync3(path) && readFileSync4(path, "utf-8") === serialized)
@@ -1812,6 +2292,7 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
1812
2292
  }
1813
2293
  async function install(options) {
1814
2294
  const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
2295
+ assertEngagementId(engagementId);
1815
2296
  if (!existsSync3(targetRepo)) {
1816
2297
  throw new Error(`[bundle install] Target repo does not exist: ${targetRepo}`);
1817
2298
  }
@@ -1823,22 +2304,30 @@ async function install(options) {
1823
2304
  collidedPaths: [],
1824
2305
  replacedPaths: []
1825
2306
  };
1826
- copyManifestCommands(manifest, targetRepo, report);
1827
- const capturedDir = join5(targetRepo, "test", "fixtures", "captured");
1828
- const capturedManifest = join5(capturedDir, "manifest.json");
2307
+ const writeSet = closedWriteSet();
2308
+ const draft = startManifest(readPreviousManifest(targetRepo), (path) => writeSet.files.has(path), (path) => writeSet.dirs.has(path));
2309
+ const dirsBefore = new Set([...writeSet.dirs].filter((dir) => existsSync3(join5(targetRepo, dir))));
2310
+ copyManifestCommands(manifest, targetRepo, report, draft);
2311
+ const capturedManifest = join5(targetRepo, CAPTURED_INDEX_REL);
2312
+ const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
1829
2313
  if (!existsSync3(capturedManifest)) {
1830
- const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
1831
2314
  writeAllowlisted(capturedManifest, targetRepo, readFileSync4(srcManifest, "utf-8"), report.writtenPaths);
2315
+ noteFile(draft, targetRepo, CAPTURED_INDEX_REL, "written");
1832
2316
  } else {
1833
- report.skippedPaths.push("test/fixtures/captured/manifest.json");
2317
+ report.skippedPaths.push(CAPTURED_INDEX_REL);
2318
+ if (readFileSync4(capturedManifest).equals(readFileSync4(srcManifest))) {
2319
+ noteFile(draft, targetRepo, CAPTURED_INDEX_REL, "skipped");
2320
+ }
1834
2321
  }
1835
2322
  const vendoredBinSrc = resolveVendoredBinary();
1836
2323
  const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
1837
- recordOwned(report, vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8"), ".halfcycle/bin/bin.bundle.mjs");
1838
- const settingsPath = join5(targetRepo, ".claude", "settings.json");
2324
+ const vendoredOutcome = recordOwned(report, vendoredBinDest, targetRepo, readFileSync4(vendoredBinSrc, "utf-8"), VENDORED_BIN_REL);
2325
+ noteFile(draft, targetRepo, VENDORED_BIN_REL, vendoredOutcome);
2326
+ const settingsPath = join5(targetRepo, SETTINGS_REL);
1839
2327
  const settingsPreexisted = existsSync3(settingsPath);
1840
2328
  const generatedSettings = JSON.parse(generateSettingsJson());
1841
2329
  const existingSettings = settingsPreexisted ? JSON.parse(readFileSync4(settingsPath, "utf-8")) : {};
2330
+ recordSettingsMerge(draft, existingSettings, generatedSettings, settingsPreexisted);
1842
2331
  const mergedSettings = mergeSettings(existingSettings, generatedSettings);
1843
2332
  const mergedSettingsText = JSON.stringify(mergedSettings, null, 2) + "\n";
1844
2333
  mkdirSync3(dirname2(settingsPath), { recursive: true });
@@ -1852,16 +2341,33 @@ async function install(options) {
1852
2341
  ]) {
1853
2342
  const rel = `.claude/hooks/${name}`;
1854
2343
  const hookPath = join5(targetRepo, ".claude", "hooks", name);
1855
- recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
2344
+ const outcome = recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
2345
+ noteFile(draft, targetRepo, rel, outcome);
2346
+ }
2347
+ switch (removeLegacyCiStanza(targetRepo)) {
2348
+ case "removed":
2349
+ report.writtenPaths.push(".halfcycle/ci-stanza.yml (REMOVED \u2014 Halfcycle no longer generates this file)");
2350
+ break;
2351
+ case "kept-foreign":
2352
+ report.skippedPaths.push(".halfcycle/ci-stanza.yml (KEPT \u2014 this file was not generated by Halfcycle, so it was left alone)");
2353
+ break;
2354
+ case "kept-uncommitted":
2355
+ 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)");
2356
+ break;
2357
+ case "failed":
2358
+ report.skippedPaths.push(".halfcycle/ci-stanza.yml (could not be removed \u2014 DELETE IT BY HAND: Halfcycle no longer uses this file)");
2359
+ break;
2360
+ case "absent":
2361
+ break;
1856
2362
  }
1857
- const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
1858
- recordOwned(report, ciStanzaPath, targetRepo, generateCiStanza(), ".halfcycle/ci-stanza.yml");
1859
2363
  if (credential) {
1860
2364
  const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
1861
- recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
2365
+ const helperOutcome = recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
2366
+ noteFile(draft, targetRepo, MCP_HEADERS_HELPER_REL, helperOutcome);
1862
2367
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
1863
2368
  const existingMcp = existsSync3(mcpPath) ? readFileSync4(mcpPath, "utf-8") : null;
1864
2369
  const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
2370
+ recordMcpMerge(draft, existingMcp, mcpContent);
1865
2371
  if (existingMcp === null) {
1866
2372
  writeAllowlisted(mcpPath, targetRepo, mcpContent, report.writtenPaths);
1867
2373
  } else if (existingMcp !== mcpContent) {
@@ -1871,14 +2377,22 @@ async function install(options) {
1871
2377
  }
1872
2378
  } else {
1873
2379
  report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
2380
+ const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
2381
+ if (existsSync3(mcpPath))
2382
+ recordMcpAdoptionOnly(draft, readFileSync4(mcpPath, "utf-8"));
1874
2383
  }
2384
+ const gitignorePath = join5(targetRepo, GITIGNORE_REL);
2385
+ const gitignoreBefore = existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null;
1875
2386
  const gitignoreOutcome = reconcileGitignore(targetRepo);
2387
+ recordGitignoreReconcile(draft, gitignoreBefore, existsSync3(gitignorePath) ? readFileSync4(gitignorePath, "utf-8") : null);
1876
2388
  if (gitignoreOutcome === "failed") {
1877
2389
  report.skippedPaths.push(".gitignore (write failed \u2014 see credential refusal)");
1878
2390
  } else {
1879
2391
  record(report, gitignoreOutcome, ".gitignore");
1880
2392
  }
1881
- record(report, writeProjectIdentity(targetRepo), ".halfcycle/project.json");
2393
+ const identityOutcome = writeProjectIdentity(targetRepo);
2394
+ record(report, identityOutcome, PROJECT_IDENTITY_REL);
2395
+ noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
1882
2396
  const credentialPath = engagementEnvPath(engagementId, home);
1883
2397
  if (credential) {
1884
2398
  record(report, writeEngagementCredential(credential, engagementId, home), credentialPath);
@@ -1904,8 +2418,18 @@ async function install(options) {
1904
2418
  break;
1905
2419
  }
1906
2420
  writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
1907
- writeCrewRoster(targetRepo, report);
2421
+ noteFile(draft, targetRepo, BUNDLE_PIN_REL, "written");
2422
+ noteFile(draft, targetRepo, CREW_ROSTER_REL, writeCrewRoster(targetRepo, report));
1908
2423
  const scanResult = runBootstrapScan(targetRepo);
2424
+ if (existsSync3(join5(targetRepo, BOOTSTRAP_STATE_REL)))
2425
+ recordPerMachineFile(draft, BOOTSTRAP_STATE_REL);
2426
+ for (const dir of writeSet.dirs) {
2427
+ if (!dirsBefore.has(dir) && existsSync3(join5(targetRepo, dir)))
2428
+ recordCreatedDir(draft, dir);
2429
+ }
2430
+ const installManifest = finishManifest(draft);
2431
+ assertManifestInWriteSet(installManifest, writeSet);
2432
+ recordOwned(report, join5(targetRepo, INSTALL_MANIFEST_REL), targetRepo, serializeManifest(installManifest), INSTALL_MANIFEST_REL);
1909
2433
  return {
1910
2434
  version: manifest.version,
1911
2435
  writtenPaths: report.writtenPaths,
@@ -2096,7 +2620,7 @@ async function mintBoardEnterCode(baseUrl, sessionToken) {
2096
2620
 
2097
2621
  // dist/setup/manifest.js
2098
2622
  import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync5, statSync, writeFileSync as writeFileSync4 } from "node:fs";
2099
- import { dirname as dirname3, join as join6, posix } from "node:path";
2623
+ import { dirname as dirname3, join as join6, posix as posix2 } from "node:path";
2100
2624
  var DECLARED_ROOT_FILES = ["CLAUDE.md", "AGENTS.md", "ENGAGEMENT.md"];
2101
2625
  var DECLARED_DIR_PREFIX = "docs";
2102
2626
  var RESERVED_METHOD_PATH = "docs/method";
@@ -2107,7 +2631,7 @@ function normalise(path) {
2107
2631
  const slashed = path.replace(/\\/g, "/");
2108
2632
  if (slashed.trim() === "")
2109
2633
  return "";
2110
- return posix.normalize(slashed).replace(/\/+$/, "");
2634
+ return posix2.normalize(slashed).replace(/\/+$/, "");
2111
2635
  }
2112
2636
  function isUnder(candidate, prefix) {
2113
2637
  return candidate === prefix || candidate.startsWith(prefix + "/");
@@ -2557,11 +3081,11 @@ function projectSeedGuard(fired, includeExplanation) {
2557
3081
 
2558
3082
  // dist/build-record/sources.js
2559
3083
  import { readFileSync as readFileSync7, existsSync as existsSync6 } from "node:fs";
2560
- import { createHash } from "node:crypto";
3084
+ import { createHash as createHash3 } from "node:crypto";
2561
3085
  import { join as join10 } from "node:path";
2562
3086
 
2563
3087
  // dist/build-record/close-record.js
2564
- import { execFileSync as execFileSync2 } from "node:child_process";
3088
+ import { execFileSync as execFileSync3 } from "node:child_process";
2565
3089
  import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
2566
3090
  import { dirname as dirname4, join as join8 } from "node:path";
2567
3091
  var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
@@ -2578,11 +3102,11 @@ function closeRecordPath(repoRoot, phase) {
2578
3102
  }
2579
3103
  function resolveCloseAtHead(repoRoot) {
2580
3104
  try {
2581
- const closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
3105
+ const closeCommit = execFileSync3("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
2582
3106
  encoding: "utf-8",
2583
3107
  stdio: ["ignore", "pipe", "ignore"]
2584
3108
  }).trim();
2585
- const closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
3109
+ const closedDate = execFileSync3("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
2586
3110
  encoding: "utf-8",
2587
3111
  stdio: ["ignore", "pipe", "ignore"]
2588
3112
  }).trim();
@@ -2806,7 +3330,7 @@ function syntheticRunId(record2) {
2806
3330
  record2["runType"],
2807
3331
  record2["phase"]
2808
3332
  ].join("|");
2809
- const hex = createHash("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
3333
+ const hex = createHash3("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
2810
3334
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
2811
3335
  }
2812
3336
  function readGuardEvalLog(logDir, phaseId) {