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/bin.js CHANGED
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // dist/bin.js
4
- import { execFileSync as execFileSync4 } from "node:child_process";
5
- import { readFileSync as readFileSync9 } from "node:fs";
6
- import { join as join11 } from "node:path";
4
+ import { execFileSync as execFileSync5 } from "node:child_process";
5
+ import { existsSync as existsSync5, readFileSync as readFileSync10, statSync } from "node:fs";
6
+ import { join as join12 } from "node:path";
7
7
 
8
8
  // dist/install.js
9
+ import { execFileSync as execFileSync2 } from "node:child_process";
10
+ import { createHash as createHash2 } from "node:crypto";
9
11
  import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
10
- import { dirname as dirname2, join as join5, relative } from "node:path";
12
+ import { dirname as dirname2, join as join5, posix, relative } from "node:path";
11
13
  import { fileURLToPath } from "node:url";
12
14
 
13
15
  // ../events/dist/result.js
@@ -36,6 +38,11 @@ var ENGAGEMENTS_DIR_NAME = "engagements";
36
38
  var ENGAGEMENT_ENV_FILENAME = "env";
37
39
  var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
38
40
  var ACCOUNT_STORE_FILENAME = "account.json";
41
+ 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}$";
42
+ var ENGAGEMENT_ID_SHAPE = new RegExp(ENGAGEMENT_ID_PATTERN);
43
+ function isEngagementId(value) {
44
+ return typeof value === "string" && ENGAGEMENT_ID_SHAPE.test(value);
45
+ }
39
46
  var ENGAGEMENT_ENV_HEADER = "# Halfcycle per-engagement credential \u2014 machine level, owner-only, never in a repository.";
40
47
  function shq(value) {
41
48
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -484,6 +491,18 @@ var artefactRefSchema = z5.object({
484
491
  name: z5.string(),
485
492
  parent: z5.string()
486
493
  }).strict();
494
+ var coverageSchema = z5.object({
495
+ asked: z5.number().int().nonnegative(),
496
+ answered: z5.number().int().nonnegative()
497
+ }).strict().superRefine((coverage, ctx) => {
498
+ if (coverage.answered > coverage.asked) {
499
+ ctx.addIssue({
500
+ code: z5.ZodIssueCode.custom,
501
+ path: ["answered"],
502
+ message: "answered cannot be more than asked"
503
+ });
504
+ }
505
+ });
487
506
  var artefactRecordSchema = stateRecordEnvelopeSchema.extend({
488
507
  recordKind: z5.literal("artefact"),
489
508
  artefactRef: artefactRefSchema,
@@ -522,7 +541,8 @@ var gateRecordSchema = stateRecordEnvelopeSchema.extend({
522
541
  gateKind: gateKindSchema,
523
542
  verdict: gateVerdictSchema,
524
543
  actor: z5.string(),
525
- outcomeReason: z5.string().max(2e3).optional()
544
+ outcomeReason: z5.string().max(2e3).optional(),
545
+ coverage: coverageSchema.optional()
526
546
  }).strict().superRefine((rec, ctx) => {
527
547
  const issue = outcomeReasonIssue(rec.verdict, rec.outcomeReason);
528
548
  if (issue) {
@@ -535,7 +555,8 @@ var interventionRecordSchema = stateRecordEnvelopeSchema.extend({
535
555
  mechanism: mechanismSchema,
536
556
  severity: severitySchema,
537
557
  disposition: dispositionSchema,
538
- summary: z5.string().max(2e3)
558
+ summary: z5.string().max(2e3),
559
+ coverage: coverageSchema.optional()
539
560
  }).strict();
540
561
  var stateRecordSchema = z5.discriminatedUnion("recordKind", [
541
562
  artefactRecordSchema,
@@ -1024,7 +1045,19 @@ var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
1024
1045
  var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
1025
1046
  var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
1026
1047
  var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
1048
+ var InvalidEngagementIdError = class extends Error {
1049
+ constructor(engagementId) {
1050
+ const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
1051
+ 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.`);
1052
+ this.name = "InvalidEngagementIdError";
1053
+ }
1054
+ };
1055
+ function assertEngagementId(engagementId) {
1056
+ if (!isEngagementId(engagementId))
1057
+ throw new InvalidEngagementIdError(engagementId);
1058
+ }
1027
1059
  function engagementStateDir(engagementId, home) {
1060
+ assertEngagementId(engagementId);
1028
1061
  return join2(halfcycleHome(home), ENGAGEMENTS_DIR, engagementId);
1029
1062
  }
1030
1063
  function engagementEnvPath(engagementId, home) {
@@ -1127,6 +1160,14 @@ halfcycle_env_file() {
1127
1160
  HALFCYCLE_ENV_PROBLEM="no-id"
1128
1161
  return 1
1129
1162
  fi
1163
+ # The id is about to become a path under $HOME, and it came from a committed
1164
+ # file: anything that is not a project id (a "../", a "/") is refused here,
1165
+ # before the path exists. hc_id holds no newline (the pin was flattened above),
1166
+ # so grep sees exactly one line.
1167
+ if ! printf '%s\\n' "$hc_id" | grep -Eq '${ENGAGEMENT_ID_PATTERN}'; then
1168
+ HALFCYCLE_ENV_PROBLEM="bad-id"
1169
+ return 1
1170
+ fi
1130
1171
  HALFCYCLE_ENV_ENGAGEMENT="$hc_id"
1131
1172
  if [ -z "\${HOME:-}" ]; then
1132
1173
  HALFCYCLE_ENV_PROBLEM="no-home"
@@ -1202,6 +1243,264 @@ function mintOrReadIdentity(targetRepoRoot) {
1202
1243
  return { identity, minted: true };
1203
1244
  }
1204
1245
 
1246
+ // dist/install-manifest.js
1247
+ import { createHash } from "node:crypto";
1248
+ var INSTALL_MANIFEST_REL = ".halfcycle/install-manifest.json";
1249
+ var INSTALL_MANIFEST_FORMAT = "halfcycle-install-manifest/v1";
1250
+ function sha256Hex(bytes) {
1251
+ return createHash("sha256").update(bytes).digest("hex");
1252
+ }
1253
+ function canonicalJson(value) {
1254
+ if (Array.isArray(value))
1255
+ return value.map(canonicalJson);
1256
+ if (value !== null && typeof value === "object") {
1257
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => [k, canonicalJson(v)]);
1258
+ return Object.fromEntries(entries);
1259
+ }
1260
+ return value;
1261
+ }
1262
+ function canonicalSha256(value) {
1263
+ return sha256Hex(JSON.stringify(canonicalJson(value)));
1264
+ }
1265
+ function byString(a, b) {
1266
+ return a < b ? -1 : a > b ? 1 : 0;
1267
+ }
1268
+ function serializeManifest(manifest) {
1269
+ const settings = {
1270
+ ...manifest.settings,
1271
+ hookCommands: [...manifest.settings.hookCommands].sort(byString),
1272
+ denyAddedSha256: [...manifest.settings.denyAddedSha256].sort(byString),
1273
+ eventsCreated: [...manifest.settings.eventsCreated].sort(byString)
1274
+ };
1275
+ const ordered = {
1276
+ ...manifest,
1277
+ files: [...manifest.files].sort((a, b) => byString(a.path, b.path)),
1278
+ createdDirs: [...manifest.createdDirs].sort(byString),
1279
+ leftAlone: [...manifest.leftAlone].sort(byString),
1280
+ settings
1281
+ };
1282
+ return JSON.stringify(canonicalJson(ordered), null, 2) + "\n";
1283
+ }
1284
+ var UnreadableManifestError = class extends Error {
1285
+ constructor(reason) {
1286
+ super(reason);
1287
+ this.name = "UnreadableManifestError";
1288
+ }
1289
+ };
1290
+ var HEX64 = /^[0-9a-f]{64}$/;
1291
+ function isRecord(value) {
1292
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1293
+ }
1294
+ function stringArray(value, field) {
1295
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
1296
+ throw new UnreadableManifestError(`"${field}" is not a list of strings`);
1297
+ }
1298
+ return value;
1299
+ }
1300
+ function hashArray(value, field) {
1301
+ const list = stringArray(value, field);
1302
+ if (!list.every((v) => HEX64.test(v)))
1303
+ throw new UnreadableManifestError(`"${field}" holds a value that is not a hash`);
1304
+ return list;
1305
+ }
1306
+ function bool(value, field) {
1307
+ if (typeof value !== "boolean")
1308
+ throw new UnreadableManifestError(`"${field}" is not true or false`);
1309
+ return value;
1310
+ }
1311
+ function onlyKeys(value, allowed, where) {
1312
+ const extra = Object.keys(value).filter((k) => !allowed.includes(k));
1313
+ if (extra.length > 0)
1314
+ throw new UnreadableManifestError(`${where} has a field this version does not know: ${extra.join(", ")}`);
1315
+ }
1316
+ function parseManifest(text) {
1317
+ let raw;
1318
+ try {
1319
+ raw = JSON.parse(text);
1320
+ } catch {
1321
+ throw new UnreadableManifestError("it is not valid JSON");
1322
+ }
1323
+ if (!isRecord(raw))
1324
+ throw new UnreadableManifestError("it is not a JSON object");
1325
+ onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
1326
+ if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
1327
+ throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
1328
+ }
1329
+ if (!Array.isArray(raw["files"]))
1330
+ throw new UnreadableManifestError('"files" is not a list');
1331
+ const files = raw["files"].map((entry) => {
1332
+ if (!isRecord(entry) || typeof entry["path"] !== "string") {
1333
+ throw new UnreadableManifestError('a "files" entry has no path');
1334
+ }
1335
+ if (entry["perMachine"] === true) {
1336
+ onlyKeys(entry, ["path", "perMachine"], `the "files" entry for ${entry["path"]}`);
1337
+ return { path: entry["path"], perMachine: true };
1338
+ }
1339
+ onlyKeys(entry, ["path", "sha256"], `the "files" entry for ${entry["path"]}`);
1340
+ if (typeof entry["sha256"] !== "string" || !HEX64.test(entry["sha256"])) {
1341
+ throw new UnreadableManifestError(`the "files" entry for ${entry["path"]} has no valid hash`);
1342
+ }
1343
+ return { path: entry["path"], sha256: entry["sha256"] };
1344
+ });
1345
+ const s = raw["settings"];
1346
+ if (!isRecord(s))
1347
+ throw new UnreadableManifestError('"settings" is missing');
1348
+ onlyKeys(s, [
1349
+ "created",
1350
+ "adopted",
1351
+ "hookCommands",
1352
+ "denyAddedSha256",
1353
+ "schemaBefore",
1354
+ "eventsCreated",
1355
+ "hooksCreated",
1356
+ "permissionsCreated",
1357
+ "denyCreated"
1358
+ ], '"settings"');
1359
+ const settings = {
1360
+ created: bool(s["created"], "settings.created"),
1361
+ adopted: bool(s["adopted"], "settings.adopted"),
1362
+ hookCommands: stringArray(s["hookCommands"], "settings.hookCommands"),
1363
+ denyAddedSha256: hashArray(s["denyAddedSha256"], "settings.denyAddedSha256"),
1364
+ ..."schemaBefore" in s ? { schemaBefore: s["schemaBefore"] } : {},
1365
+ eventsCreated: stringArray(s["eventsCreated"], "settings.eventsCreated"),
1366
+ hooksCreated: bool(s["hooksCreated"], "settings.hooksCreated"),
1367
+ permissionsCreated: bool(s["permissionsCreated"], "settings.permissionsCreated"),
1368
+ denyCreated: bool(s["denyCreated"], "settings.denyCreated")
1369
+ };
1370
+ let mcp;
1371
+ if (raw["mcp"] !== void 0) {
1372
+ const m = raw["mcp"];
1373
+ if (!isRecord(m))
1374
+ throw new UnreadableManifestError('"mcp" is not an object');
1375
+ onlyKeys(m, ["created", "adopted", "mcpServersCreated", "entrySha256"], '"mcp"');
1376
+ if (m["entrySha256"] !== void 0 && (typeof m["entrySha256"] !== "string" || !HEX64.test(m["entrySha256"]))) {
1377
+ throw new UnreadableManifestError('"mcp.entrySha256" is not a hash');
1378
+ }
1379
+ mcp = {
1380
+ created: bool(m["created"], "mcp.created"),
1381
+ adopted: bool(m["adopted"], "mcp.adopted"),
1382
+ mcpServersCreated: bool(m["mcpServersCreated"], "mcp.mcpServersCreated"),
1383
+ ...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
1384
+ };
1385
+ }
1386
+ const g = raw["gitignore"];
1387
+ if (!isRecord(g))
1388
+ throw new UnreadableManifestError('"gitignore" is missing');
1389
+ onlyKeys(g, ["created", "appended"], '"gitignore"');
1390
+ const gitignore = {
1391
+ created: bool(g["created"], "gitignore.created"),
1392
+ appended: stringArray(g["appended"], "gitignore.appended")
1393
+ };
1394
+ const paths = files.map((f) => f.path);
1395
+ if (new Set(paths).size !== paths.length)
1396
+ throw new UnreadableManifestError('"files" names a path twice');
1397
+ const leftAlone = stringArray(raw["leftAlone"], "leftAlone");
1398
+ if (leftAlone.some((p) => paths.includes(p))) {
1399
+ throw new UnreadableManifestError('a path is in both "files" and "leftAlone"');
1400
+ }
1401
+ return {
1402
+ format: INSTALL_MANIFEST_FORMAT,
1403
+ files,
1404
+ createdDirs: stringArray(raw["createdDirs"], "createdDirs"),
1405
+ leftAlone,
1406
+ settings,
1407
+ ...mcp !== void 0 ? { mcp } : {},
1408
+ gitignore
1409
+ };
1410
+ }
1411
+ function startManifest(previous, isMember, isMemberDir) {
1412
+ const files = /* @__PURE__ */ new Map();
1413
+ for (const entry of previous?.files ?? []) {
1414
+ if (isMember(entry.path))
1415
+ files.set(entry.path, entry);
1416
+ }
1417
+ return {
1418
+ previous,
1419
+ files,
1420
+ collided: /* @__PURE__ */ new Set(),
1421
+ createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
1422
+ settings: previous?.settings,
1423
+ mcp: previous?.mcp,
1424
+ gitignore: previous?.gitignore
1425
+ };
1426
+ }
1427
+ function recordInstalledFile(draft, path, bytes) {
1428
+ draft.files.set(path, { path, sha256: sha256Hex(bytes) });
1429
+ }
1430
+ function recordPerMachineFile(draft, path) {
1431
+ draft.files.set(path, { path, perMachine: true });
1432
+ }
1433
+ function recordCollision(draft, path) {
1434
+ draft.collided.add(path);
1435
+ }
1436
+ function recordCreatedDir(draft, path) {
1437
+ draft.createdDirs.add(path);
1438
+ }
1439
+ function recordSettings(draft, observed) {
1440
+ const prev = draft.settings;
1441
+ if (prev === void 0) {
1442
+ draft.settings = observed;
1443
+ return;
1444
+ }
1445
+ draft.settings = {
1446
+ created: prev.created,
1447
+ adopted: prev.adopted,
1448
+ hookCommands: union(prev.hookCommands, observed.hookCommands),
1449
+ denyAddedSha256: union(prev.denyAddedSha256, observed.denyAddedSha256),
1450
+ ..."schemaBefore" in prev ? { schemaBefore: prev.schemaBefore } : "schemaBefore" in observed ? { schemaBefore: observed.schemaBefore } : {},
1451
+ eventsCreated: union(prev.eventsCreated, observed.eventsCreated),
1452
+ hooksCreated: prev.hooksCreated,
1453
+ permissionsCreated: prev.permissionsCreated,
1454
+ denyCreated: prev.denyCreated
1455
+ };
1456
+ }
1457
+ function recordMcp(draft, observed) {
1458
+ const prev = draft.mcp;
1459
+ if (prev === void 0) {
1460
+ draft.mcp = observed;
1461
+ return;
1462
+ }
1463
+ const entrySha256 = observed.entrySha256 ?? prev.entrySha256;
1464
+ draft.mcp = {
1465
+ created: prev.created,
1466
+ adopted: prev.adopted,
1467
+ mcpServersCreated: prev.mcpServersCreated,
1468
+ ...entrySha256 !== void 0 ? { entrySha256 } : {}
1469
+ };
1470
+ }
1471
+ function recordGitignore(draft, created, appended) {
1472
+ const prev = draft.gitignore;
1473
+ draft.gitignore = {
1474
+ created: prev?.created ?? created,
1475
+ appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
1476
+ };
1477
+ }
1478
+ function finishManifest(draft) {
1479
+ const files = [...draft.files.values()];
1480
+ const leftAlone = [...draft.collided].filter((p) => !draft.files.has(p));
1481
+ return {
1482
+ format: INSTALL_MANIFEST_FORMAT,
1483
+ files,
1484
+ createdDirs: [...draft.createdDirs],
1485
+ leftAlone,
1486
+ settings: draft.settings ?? {
1487
+ created: false,
1488
+ adopted: false,
1489
+ hookCommands: [],
1490
+ denyAddedSha256: [],
1491
+ eventsCreated: [],
1492
+ hooksCreated: false,
1493
+ permissionsCreated: false,
1494
+ denyCreated: false
1495
+ },
1496
+ ...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
1497
+ gitignore: draft.gitignore ?? { created: false, appended: [] }
1498
+ };
1499
+ }
1500
+ function union(a, b) {
1501
+ return [.../* @__PURE__ */ new Set([...a, ...b])];
1502
+ }
1503
+
1205
1504
  // dist/mcp-endpoint.js
1206
1505
  var MCP_ENDPOINT_PATH = "/mcp";
1207
1506
  function mcpEndpointUrl(origin) {
@@ -1287,8 +1586,9 @@ function scanLayers(targetRepo, scannedAt = (/* @__PURE__ */ new Date()).toISOSt
1287
1586
  }
1288
1587
  return { format: HALFCYCLE_STATE_FORMAT, note: HALFCYCLE_STATE_NOTE, layers };
1289
1588
  }
1589
+ var BOOTSTRAP_STATE_REL = ".halfcycle/state.json";
1290
1590
  function runBootstrapScan(targetRepo) {
1291
- const statePath = join4(targetRepo, ".halfcycle", "state.json");
1591
+ const statePath = join4(targetRepo, BOOTSTRAP_STATE_REL);
1292
1592
  if (existsSync2(statePath)) {
1293
1593
  const existing = JSON.parse(readFileSync4(statePath, "utf-8"));
1294
1594
  return { state: existing, ran: false };
@@ -1347,22 +1647,152 @@ function isAllowlisted(targetRelPath) {
1347
1647
  return normalised === p || normalised.startsWith(p + "/");
1348
1648
  });
1349
1649
  }
1650
+ var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
1651
+ var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
1652
+ var CREW_ROSTER_REL = ".halfcycle/crew.json";
1653
+ var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
1654
+ var SETTINGS_REL = ".claude/settings.json";
1655
+ var GITIGNORE_REL = ".gitignore";
1656
+ function commandStubRel(name) {
1657
+ return `.claude/commands/${name}.md`;
1658
+ }
1659
+ var RETIRED_WRITE_SET_FILES = [];
1660
+ var RETIRED_HOOK_COMMANDS = [];
1661
+ var RETIRED_DENY_PATTERNS = [];
1662
+ function installerHookCommands() {
1663
+ const settings = JSON.parse(generateSettingsJson());
1664
+ return Object.values(settings.hooks ?? {}).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
1665
+ }
1666
+ function closedWriteSet() {
1667
+ const files = /* @__PURE__ */ new Set([
1668
+ ...(readPluginManifest().commands ?? []).map((cmd) => commandStubRel(cmd.name)),
1669
+ ...Object.keys(OWNED_GENERATED_HEADERS),
1670
+ VENDORED_BIN_REL,
1671
+ CREW_ROSTER_REL,
1672
+ BUNDLE_PIN_REL,
1673
+ PROJECT_IDENTITY_REL,
1674
+ BOOTSTRAP_STATE_REL,
1675
+ CAPTURED_INDEX_REL,
1676
+ INSTALL_MANIFEST_REL,
1677
+ ...RETIRED_WRITE_SET_FILES
1678
+ ]);
1679
+ const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
1680
+ const dirs = /* @__PURE__ */ new Set();
1681
+ for (const path of [...files, ...merged]) {
1682
+ for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
1683
+ dirs.add(dir);
1684
+ }
1685
+ const ownedDirs = new Set([...dirs].filter((dir) => `${dir}/`.startsWith(INSTALLER_OWNED_DIR)));
1686
+ return {
1687
+ files,
1688
+ merged,
1689
+ dirs,
1690
+ ownedDirs,
1691
+ hookCommands: /* @__PURE__ */ new Set([...installerHookCommands(), ...RETIRED_HOOK_COMMANDS]),
1692
+ denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
1693
+ gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL])
1694
+ };
1695
+ }
1696
+ function readPreviousManifest(targetRepo) {
1697
+ try {
1698
+ return parseManifest(readFileSync5(join5(targetRepo, INSTALL_MANIFEST_REL), "utf-8"));
1699
+ } catch {
1700
+ return null;
1701
+ }
1702
+ }
1703
+ function noteFile(draft, targetRepo, rel, outcome) {
1704
+ if (outcome === "collided") {
1705
+ recordCollision(draft, rel);
1706
+ return;
1707
+ }
1708
+ recordInstalledFile(draft, rel, readFileSync5(join5(targetRepo, rel)));
1709
+ }
1710
+ function recordSettingsMerge(draft, existing, generated, preexisted) {
1711
+ const generatedHooks = generated.hooks ?? {};
1712
+ const hookCommands = Object.values(generatedHooks).flatMap((entries) => entries.flatMap((entry) => entry.hooks.map((h) => h.command)));
1713
+ const ours = new Set(hookCommands);
1714
+ const existingHooks = isPlainObject(existing.hooks) ? existing.hooks : void 0;
1715
+ const heldOurs = Object.values(existingHooks ?? {}).some((entries) => Array.isArray(entries) && entries.some((entry) => isHalfcycleEntry(entry, ours)));
1716
+ const existingDeny = Array.isArray(existing.permissions?.deny) ? existing.permissions.deny : [];
1717
+ const schemaChanged = generated.$schema !== void 0 && existing.$schema !== generated.$schema;
1718
+ recordSettings(draft, {
1719
+ created: !preexisted,
1720
+ adopted: draft.previous === null && heldOurs,
1721
+ hookCommands,
1722
+ denyAddedSha256: (generated.permissions?.deny ?? []).filter((rule) => !existingDeny.includes(rule)).map((rule) => sha256Hex(rule)),
1723
+ ...schemaChanged ? { schemaBefore: existing.$schema ?? null } : {},
1724
+ eventsCreated: Object.keys(generatedHooks).filter((event) => !(existingHooks && event in existingHooks)),
1725
+ hooksCreated: existing.hooks === void 0,
1726
+ permissionsCreated: existing.permissions === void 0,
1727
+ denyCreated: existing.permissions?.deny === void 0
1728
+ });
1729
+ }
1730
+ function recordMcpMerge(draft, existingText, writtenText) {
1731
+ const before = existingText === null ? void 0 : JSON.parse(existingText);
1732
+ const servers = isPlainObject(before) ? before["mcpServers"] : void 0;
1733
+ const written = JSON.parse(writtenText);
1734
+ recordMcp(draft, {
1735
+ created: existingText === null,
1736
+ adopted: draft.previous === null && isPlainObject(servers) && MCP_SERVER_KEY in servers,
1737
+ mcpServersCreated: !(isPlainObject(before) && "mcpServers" in before),
1738
+ entrySha256: canonicalSha256(written.mcpServers[MCP_SERVER_KEY])
1739
+ });
1740
+ }
1741
+ function recordMcpAdoptionOnly(draft, existingText) {
1742
+ if (draft.previous !== null || draft.mcp !== void 0)
1743
+ return;
1744
+ let before;
1745
+ try {
1746
+ before = JSON.parse(existingText);
1747
+ } catch {
1748
+ return;
1749
+ }
1750
+ const servers = isPlainObject(before) ? before["mcpServers"] : void 0;
1751
+ if (!isPlainObject(servers) || !(MCP_SERVER_KEY in servers))
1752
+ return;
1753
+ recordMcp(draft, { created: false, adopted: true, mcpServersCreated: false });
1754
+ }
1755
+ function recordGitignoreReconcile(draft, before, after) {
1756
+ const adopted = draft.gitignore === void 0 && before !== null ? adoptOlderGitignoreBlock(before) : "";
1757
+ let appended = "";
1758
+ if (after !== null && after !== before) {
1759
+ if (before === null)
1760
+ appended = after;
1761
+ else if (after.startsWith(before))
1762
+ appended = after.slice(before.length);
1763
+ }
1764
+ recordGitignore(draft, before === null && after !== null, [adopted, appended]);
1765
+ }
1766
+ function assertManifestInWriteSet(manifest, writeSet) {
1767
+ const strays = [
1768
+ ...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
1769
+ ...manifest.leftAlone.filter((p) => !writeSet.files.has(p)),
1770
+ ...manifest.createdDirs.filter((p) => !writeSet.dirs.has(p))
1771
+ ];
1772
+ if (strays.length > 0) {
1773
+ throw new Error(`[bundle install] the install record names paths outside the write set: ${strays.join(", ")}`);
1774
+ }
1775
+ }
1776
+ function isPlainObject(value) {
1777
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1778
+ }
1350
1779
  function readPluginManifest() {
1351
1780
  const manifestPath = join5(BUNDLE_ROOT, ".claude-plugin", "plugin.json");
1352
1781
  const raw = readFileSync5(manifestPath, "utf-8");
1353
1782
  return JSON.parse(raw);
1354
1783
  }
1355
- function copyManifestCommands(manifest, targetRepo, report) {
1356
- const commandsTargetDir = join5(targetRepo, ".claude", "commands");
1784
+ function copyManifestCommands(manifest, targetRepo, report, draft) {
1357
1785
  for (const cmd of manifest.commands ?? []) {
1358
1786
  const srcFile = join5(BUNDLE_ROOT, ".claude-plugin", cmd.path);
1359
1787
  if (!existsSync3(srcFile)) {
1360
1788
  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.`);
1361
1789
  }
1362
- const destFile = join5(commandsTargetDir, `${cmd.name}.md`);
1790
+ const rel = commandStubRel(cmd.name);
1791
+ const destFile = join5(targetRepo, rel);
1363
1792
  const content = readFileSync5(srcFile, "utf-8");
1364
- const rel = relative(targetRepo, destFile).replace(/\\/g, "/");
1365
- record(report, writeCollisionSafe(destFile, targetRepo, content), rel);
1793
+ const outcome = writeCollisionSafe(destFile, targetRepo, content);
1794
+ record(report, outcome, rel);
1795
+ noteFile(draft, targetRepo, rel, outcome);
1366
1796
  }
1367
1797
  }
1368
1798
  function writeAllowlisted(targetAbsPath, targetRepoRoot, content, writtenPaths) {
@@ -1428,6 +1858,7 @@ function recordOwnedGenerated(report, targetAbsPath, targetRepoRoot, content, ge
1428
1858
  record(report, outcome, rel);
1429
1859
  if (replacedExisting)
1430
1860
  report.replacedPaths.push(rel);
1861
+ return outcome;
1431
1862
  }
1432
1863
  function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
1433
1864
  const existedBefore = existsSync3(targetAbsPath);
@@ -1435,6 +1866,7 @@ function recordOwned(report, targetAbsPath, targetRepoRoot, content, rel) {
1435
1866
  record(report, outcome, rel);
1436
1867
  if (outcome === "written" && existedBefore)
1437
1868
  report.replacedPaths.push(rel);
1869
+ return outcome;
1438
1870
  }
1439
1871
  function record(report, outcome, rel) {
1440
1872
  const bucket = {
@@ -1759,101 +2191,53 @@ printf '%s\\n' '${USER_PROMPT_REMINDER_SENTENCE}'
1759
2191
  exit 0
1760
2192
  `;
1761
2193
  }
1762
- function generateCiStanza() {
1763
- return `# Halfcycle guard CI job \u2014 generated by the Halfcycle installer.
1764
- # Below is a complete GitHub Actions workflow: a name, the events it runs on, and
1765
- # one job. It assumes no package manager and no monorepo tooling: it runs the
1766
- # self-contained guard binary vendored at .halfcycle/bin/, so it works in a Python
1767
- # or Go repository as well as a Node one \u2014 the only prerequisite is Node 20 to run
1768
- # the bundled binary.
1769
- #
1770
- # NO WORKFLOW YET? Copy everything from the \`name:\` line to the end of the job
1771
- # into a new file, .github/workflows/halfcycle-guard.yml, without the leading
1772
- # "# " on each line. That file is complete as it stands: commit it, and the check
1773
- # runs on the next push and on every pull request.
1774
- #
1775
- # ALREADY HAVE A WORKFLOW? Copy only the job \u2014 from \`halfcycle-guard-ci:\` down to
1776
- # its \`run:\` line \u2014 under the \`jobs:\` key of the workflow you have, keeping the
1777
- # indentation. The job carries its own \`permissions\` block, so it needs nothing
1778
- # from the rest of that file.
1779
- #
1780
- # THERE IS NOTHING TO STORE. No token in your repository's secrets, no project id
1781
- # to look up, and no address to set. The \`permissions\` block below is the whole
1782
- # configuration: it lets the job ask GitHub for a short-lived signed token naming
1783
- # the repository it is running in, and Halfcycle trusts the name GitHub signs
1784
- # rather than anything the job says about itself. That token is traded for a
1785
- # credential that lives for minutes, and nothing is kept at either end.
1786
- #
1787
- # BOTH PERMISSION LINES, NOT JUST THE SECOND. Declaring any permission
1788
- # replaces the defaults rather than adding to them, so a block naming only
1789
- # \`id-token\` takes read access away from the checkout step and a private
1790
- # repository stops checking out before the guard is reached. If your workflow
1791
- # already has a \`permissions\` block, add \`id-token: write\` to it and leave the
1792
- # rest alone.
1793
- #
1794
- # ONE THING TO DO FIRST, ONCE, ON YOUR OWN MACHINE. In this project, run
1795
- #
1796
- # npx halfcycle ci bind <owner>/<repo>
1797
- #
1798
- # naming this repository as GitHub spells it (for example acme/widgets). That
1799
- # tells Halfcycle this project's CI runs from that repository, and it is the only
1800
- # thing that makes a run mean anything: without it, Halfcycle has a signed
1801
- # statement of which repository the job is in and no idea whose project that is.
1802
- # The check says exactly that, and names the command, if you skip it.
1803
- #
1804
- # THERE IS NO ADDRESS TO CONFIGURE ANYWHERE IN HALFCYCLE. Every service this
1805
- # product talks to has one address, the same for every user, and it ships in the
1806
- # tools you already have: the installer, the guard hook and the binary this job
1807
- # runs each know where to go. If something tells you to set a Halfcycle URL, it is
1808
- # out of date.
1809
- #
1810
- # WHICH BRANCH MODEL THIS ASSUMES: none. It works on pull-request branches AND on
1811
- # commits pushed straight to the default branch, which is the shape most
1812
- # Halfcycle engagements settle on. The \`on:\` block is what runs it in both
1813
- # places: every push, and every pull request. Narrow it if you want (for example
1814
- # \`branches: [main]\` under \`push:\`), but keep \`pull_request\`, or a change is
1815
- # first checked after it has merged. \`fetch-depth\` is what makes the check work
1816
- # there: it needs the commit BEFORE the one it is evaluating, and the default
1817
- # shallow checkout does not have it. With \`fetch-depth: 0\` the check fails loudly
1818
- # if it cannot work out what to evaluate \u2014 it will not pass quietly having
1819
- # evaluated nothing.
1820
- #
1821
- # name: Halfcycle guard
1822
- # on:
1823
- # push:
1824
- # pull_request:
1825
- # jobs:
1826
- # halfcycle-guard-ci:
1827
- # runs-on: ubuntu-latest
1828
- # permissions:
1829
- # contents: read
1830
- # id-token: write
1831
- # steps:
1832
- # - uses: actions/checkout@v4
1833
- # with:
1834
- # # REQUIRED. 0 = full history. The check diffs against the commit before
1835
- # # HEAD (or the fork point on a branch); the default depth of 1 has
1836
- # # neither. Do not lower this.
1837
- # fetch-depth: 0
1838
- # - uses: actions/setup-node@v4
1839
- # with:
1840
- # node-version: '20'
1841
- # - name: Halfcycle guard CI check
1842
- # # No env block, on purpose: this step holds no secret. The permissions
1843
- # # above are what authenticate it.
1844
- # run: node ./.halfcycle/bin/bin.bundle.mjs ci
1845
- #
1846
- # The job prints the diff base it used and how many files it evaluated, on every
1847
- # run. If that line says 0 files on a commit that changed something, the base is
1848
- # wrong \u2014 set HALFCYCLE_DIFF_BASE in an env block OF YOUR COPY of the check step,
1849
- # to name it explicitly (on a GitHub push event, \${{ github.event.before }} is the
1850
- # right value).
1851
- #
1852
- # EDIT YOUR COPY, NOT THIS FILE. This one is regenerated by the installer and your
1853
- # changes to it would be replaced the next time you run \`npx halfcycle\`. It is
1854
- # also inert where it sits: no CI system reads this path. Copy the workflow above
1855
- # \u2014 or just its job \u2014 into .github/workflows/ and change it there.
1856
- `;
2194
+ var CI_STANZA_KNOWN_HEADER_HASHES = [
2195
+ "b225800b2d699811f32ac213238cbf81c35b5e6a74114bdf55e39cd9d7e2928f",
2196
+ "674181735460f051af7c157222cf851fc3000fb3b8af3a10ea0bde9fe5eaba1f",
2197
+ "d17bde608a6fec54a63cb934a28d88acc8babc205334b5ee84d4ed6ee219d340",
2198
+ "85e71d3f6551977b241a28468f121ee4d1c4edef40a2c4dc1aa6f2f737ebd037"
2199
+ ];
2200
+ function isTrackedAndClean(targetRepo, relPath) {
2201
+ try {
2202
+ execFileSync2("git", ["-C", targetRepo, "ls-files", "--error-unmatch", "--", relPath], {
2203
+ stdio: "ignore"
2204
+ });
2205
+ } catch {
2206
+ return false;
2207
+ }
2208
+ try {
2209
+ execFileSync2("git", ["-C", targetRepo, "diff", "--quiet", "HEAD", "--", relPath], {
2210
+ stdio: "ignore"
2211
+ });
2212
+ } catch {
2213
+ return false;
2214
+ }
2215
+ return true;
2216
+ }
2217
+ function removeLegacyCiStanza(targetRepo) {
2218
+ const rel = ".halfcycle/ci-stanza.yml";
2219
+ const path = join5(targetRepo, rel);
2220
+ if (!existsSync3(path))
2221
+ return "absent";
2222
+ let raw;
2223
+ try {
2224
+ raw = readFileSync5(path, "utf-8");
2225
+ } catch {
2226
+ return "failed";
2227
+ }
2228
+ const headerLine = raw.split("\n")[0] ?? "";
2229
+ const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
2230
+ const ours = CI_STANZA_KNOWN_HEADER_HASHES.includes(digest);
2231
+ if (!ours)
2232
+ return "kept-foreign";
2233
+ if (!isTrackedAndClean(targetRepo, rel))
2234
+ return "kept-uncommitted";
2235
+ try {
2236
+ rmSync(path);
2237
+ } catch {
2238
+ return "failed";
2239
+ }
2240
+ return "removed";
1857
2241
  }
1858
2242
  var MCP_REGISTRATION_REL = ".mcp.json";
1859
2243
  var MCP_SERVER_KEY = "halfcycle";
@@ -1895,13 +2279,81 @@ PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
1895
2279
 
1896
2280
  ${engagementResolutionShell()}
1897
2281
 
2282
+ # Read one KEY's value out of the credential store into HC_VALUE, in the CALLER's
2283
+ # shell (call it as a statement, never inside $(...)). Last assignment wins,
2284
+ # matching the shell's own \`.\` semantics. An optional leading \`export \` is
2285
+ # tolerated because a hand-edited store may use one.
2286
+ halfcycle_store_value() {
2287
+ HC_VALUE=""
2288
+ hc_line=$(grep "^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}$2=" "$1" | tail -n 1)
2289
+ hc_v=\${hc_line#*"$2="}
2290
+ hc_v=$(printf '%s' "$hc_v" | tr -d '\\r')
2291
+
2292
+ # Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
2293
+ # writers use the same shq: this installer and Studio's provider), because
2294
+ # guard-runner.sh SOURCES the same file and an unquoted value carrying a space
2295
+ # would execute its own remainder. This reader greps rather than sources, so it
2296
+ # has to undo the quoting itself \u2014 and it must land on the same value the
2297
+ # sourcing consumer gets, or one file has two answers.
2298
+ case $hc_v in
2299
+ '"'*'"') hc_v=\${hc_v#'"'}; hc_v=\${hc_v%'"'} ;;
2300
+ "'"*"'")
2301
+ hc_v=\${hc_v#"'"}; hc_v=\${hc_v%"'"}
2302
+ # \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
2303
+ # through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
2304
+ # the CI platform) and BSD sed (macOS), feeding each the script from a file so
2305
+ # no shell quoting is in the way:
2306
+ #
2307
+ # s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
2308
+ # s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
2309
+ #
2310
+ # A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
2311
+ # does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
2312
+ # and the token then travels with four stray characters in it. The bracket
2313
+ # expression makes the backslash literal by a construction POSIX does define.
2314
+ hc_v=$(printf '%s' "$hc_v" | sed "s/'[\\]''/'/g") ;;
2315
+ esac
2316
+ HC_VALUE=$hc_v
2317
+ }
2318
+
2319
+ # The scheme and host[:port] of a URL, lowercased \u2014 or NOTHING when the URL holds
2320
+ # a space or a control character anywhere. That refusal is the load-bearing part:
2321
+ # a URL parser drops tabs and newlines before it reads the host, so
2322
+ # "https://ours<newline>@elsewhere/" is a request to "elsewhere", while a
2323
+ # line-by-line reading here would see only "https://ours". The host ends at the
2324
+ # first / ? # or backslash, which is where a URL parser ends it for http(s).
2325
+ halfcycle_origin() {
2326
+ hc_u=$1
2327
+ if [ "$(printf '%s' "$hc_u" | tr -d '[:cntrl:][:space:]')" != "$hc_u" ]; then
2328
+ return 0
2329
+ fi
2330
+ printf '%s\\n' "$hc_u" | sed -n 's|^\\([A-Za-z][A-Za-z0-9+.-]*://[^/?#\\\\]*\\).*$|\\1|p' | tr '[:upper:]' '[:lower:]'
2331
+ }
2332
+
1898
2333
  # HALFCYCLE_TOKEN in the environment WINS, and it is the CI arm: a job with no
1899
2334
  # browser and no per-user store exports the credential, and a stale store on a
1900
2335
  # long-lived runner must not silently win over it. Same order, and the same reason,
1901
- # as the CLI's own resolver (\`resolve-credential.ts\`).
2336
+ # as the CLI's own resolver (\`resolve-credential.ts\`). The server it may be sent
2337
+ # to is NOT taken from the environment: it is read from this machine's store in
2338
+ # both arms, so a token supplied this way still goes only where this machine was
2339
+ # set up to send it.
2340
+ # The pin names an id that is not a project id. Re-running the installer against
2341
+ # that pin refuses it too, so the one remedy that works is a fresh pin.
2342
+ halfcycle_bad_id_message() {
2343
+ echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names an engagement id that is not a Halfcycle project id," >&2
2344
+ echo "halfcycle: so no credential was read. Delete that file and run \\"npx halfcycle\\" again: this repository" >&2
2345
+ echo "halfcycle: is then set up as a new Halfcycle project." >&2
2346
+ }
2347
+
1902
2348
  TOKEN=\${HALFCYCLE_TOKEN:-}
2349
+ MCP_URL=""
1903
2350
 
1904
- if [ -z "$TOKEN" ]; then
2351
+ if [ -n "$TOKEN" ]; then
2352
+ if halfcycle_env_file "$PROJECT_ROOT"; then
2353
+ halfcycle_store_value "$HALFCYCLE_ENV_FILE" HALFCYCLE_MCP_URL
2354
+ MCP_URL=$HC_VALUE
2355
+ fi
2356
+ else
1905
2357
  if ! halfcycle_env_file "$PROJECT_ROOT"; then
1906
2358
  case $HALFCYCLE_ENV_PROBLEM in
1907
2359
  no-pin)
@@ -1910,6 +2362,8 @@ if [ -z "$TOKEN" ]; then
1910
2362
  no-id)
1911
2363
  echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names no engagement id." >&2
1912
2364
  echo "halfcycle: run \\"npx halfcycle\\" here to rewrite it." >&2 ;;
2365
+ bad-id)
2366
+ halfcycle_bad_id_message ;;
1913
2367
  no-home)
1914
2368
  echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
1915
2369
  *)
@@ -1932,42 +2386,48 @@ if [ -z "$TOKEN" ]; then
1932
2386
  fi
1933
2387
  ENV_FILE="$HALFCYCLE_ENV_FILE"
1934
2388
 
1935
- # Last assignment wins, matching the shell's own \`.\` semantics. An optional
1936
- # leading \`export \` is tolerated because a hand-edited store may use one.
1937
- LINE=$(grep '^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}HALFCYCLE_TOKEN=' "$ENV_FILE" | tail -n 1)
1938
- TOKEN=\${LINE#*HALFCYCLE_TOKEN=}
1939
- TOKEN=$(printf '%s' "$TOKEN" | tr -d '\\r')
1940
-
1941
- # Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
1942
- # writers use the same shq: this installer and Studio's provider), because
1943
- # guard-runner.sh SOURCES the same file and an unquoted value carrying a space
1944
- # would execute its own remainder. This reader greps rather than sources, so it
1945
- # has to undo the quoting itself \u2014 and it must land on the same value the
1946
- # sourcing consumer gets, or one file has two answers.
1947
- case $TOKEN in
1948
- '"'*'"') TOKEN=\${TOKEN#'"'}; TOKEN=\${TOKEN%'"'} ;;
1949
- "'"*"'")
1950
- TOKEN=\${TOKEN#"'"}; TOKEN=\${TOKEN%"'"}
1951
- # \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
1952
- # through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
1953
- # the CI platform) and BSD sed (macOS), feeding each the script from a file so
1954
- # no shell quoting is in the way:
1955
- #
1956
- # s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
1957
- # s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
1958
- #
1959
- # A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
1960
- # does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
1961
- # and the token then travels with four stray characters in it. The bracket
1962
- # expression makes the backslash literal by a construction POSIX does define.
1963
- TOKEN=$(printf '%s' "$TOKEN" | sed "s/'[\\]''/'/g") ;;
1964
- esac
1965
-
2389
+ halfcycle_store_value "$ENV_FILE" HALFCYCLE_TOKEN
2390
+ TOKEN=$HC_VALUE
1966
2391
  if [ -z "$TOKEN" ]; then
1967
2392
  echo "halfcycle: HALFCYCLE_TOKEN is absent or empty in $ENV_FILE." >&2
1968
2393
  echo "halfcycle: run \\"npx halfcycle\\" in this repository to rewrite this machine's credential." >&2
1969
2394
  exit 1
1970
2395
  fi
2396
+ halfcycle_store_value "$ENV_FILE" HALFCYCLE_MCP_URL
2397
+ MCP_URL=$HC_VALUE
2398
+ fi
2399
+
2400
+ # THE TOKEN GOES ONLY TO THIS MACHINE'S HALFCYCLE SERVER. .mcp.json is a tracked
2401
+ # file: anyone who can land a commit can change the server's url, and this script
2402
+ # would then hand the credential to whatever it names. Claude Code tells the helper
2403
+ # which url it is about to connect to (CLAUDE_CODE_MCP_SERVER_URL); the server this
2404
+ # machine was set up against is recorded OUTSIDE the repository, beside the
2405
+ # credential. The two must share scheme, host and port, or nothing is printed.
2406
+ # No url at all is a refusal too: sending the credential without knowing where it
2407
+ # is going is the thing this check exists to stop.
2408
+ REQUESTED=\${CLAUDE_CODE_MCP_SERVER_URL:-}
2409
+ if [ -z "$REQUESTED" ]; then
2410
+ echo "halfcycle: Claude Code did not say which server it is connecting to, so the Halfcycle credential was not sent." >&2
2411
+ echo "halfcycle: update Claude Code, then reconnect." >&2
2412
+ exit 1
2413
+ fi
2414
+ if [ -z "$MCP_URL" ] && [ "\${HALFCYCLE_ENV_PROBLEM:-}" = "bad-id" ]; then
2415
+ halfcycle_bad_id_message
2416
+ exit 1
2417
+ fi
2418
+ if [ -z "$MCP_URL" ]; then
2419
+ echo "halfcycle: this machine has no Halfcycle server address recorded for this repository, so the credential was not sent." >&2
2420
+ echo "halfcycle: run \\"npx halfcycle\\" in this repository on this machine to record it." >&2
2421
+ exit 1
2422
+ fi
2423
+ WANT=$(halfcycle_origin "$MCP_URL")
2424
+ GOT=$(halfcycle_origin "$REQUESTED")
2425
+ if [ -z "$WANT" ] || [ "$GOT" != "$WANT" ]; then
2426
+ SHOWN=$(printf '%s' "$REQUESTED" | tr -cd '[:graph:]' | cut -c1-200)
2427
+ echo "halfcycle: .mcp.json asks for this repository's Halfcycle credential to be sent to $SHOWN," >&2
2428
+ echo "halfcycle: which is not this machine's Halfcycle server (\${WANT:-none recorded}). The credential was NOT sent." >&2
2429
+ echo "halfcycle: if nobody meant to change .mcp.json, treat that change as suspect; \\"npx halfcycle\\" restores the entry." >&2
2430
+ exit 1
1971
2431
  fi
1972
2432
 
1973
2433
  # JSON-escape: backslash first, then double quote. A token carrying either would
@@ -1983,7 +2443,7 @@ function generateMcpRegistration(existing, mcpOrigin) {
1983
2443
  if (existing !== null) {
1984
2444
  const parsed = JSON.parse(existing);
1985
2445
  if (parsed !== null && typeof parsed === "object") {
1986
- base = { mcpServers: {}, ...parsed };
2446
+ base = { ...parsed };
1987
2447
  }
1988
2448
  }
1989
2449
  if (typeof base.mcpServers !== "object" || base.mcpServers === null) {
@@ -1997,13 +2457,33 @@ function generateMcpRegistration(existing, mcpOrigin) {
1997
2457
  return JSON.stringify(base, null, 2) + "\n";
1998
2458
  }
1999
2459
  var REQUIRED_GITIGNORE_ENTRIES = [".halfcycle/state.json", `${ZONE_B_DIR}/`];
2460
+ var GITIGNORE_HEADER = "# Halfcycle \u2014 machine-local secrets/state and Zone-B (never push to client remote)";
2461
+ function adoptOlderGitignoreBlock(before) {
2462
+ const claimable = /* @__PURE__ */ new Set([...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]);
2463
+ const claimed = [];
2464
+ let underHeader = false;
2465
+ const lines = before.split("\n");
2466
+ for (const [i, line] of lines.entries()) {
2467
+ if (line === GITIGNORE_HEADER) {
2468
+ if (i > 0 && lines[i - 1] === "")
2469
+ claimed.push("");
2470
+ claimed.push(line);
2471
+ underHeader = true;
2472
+ } else if (line.trim() === "") {
2473
+ underHeader = false;
2474
+ } else if (underHeader && claimable.has(line)) {
2475
+ claimed.push(line);
2476
+ }
2477
+ }
2478
+ return claimed.length > 0 ? claimed.join("\n") + "\n" : "";
2479
+ }
2000
2480
  function gitignoreCovers(content) {
2001
2481
  const lines = new Set(content.split("\n").map((l) => l.trim()));
2002
2482
  return REQUIRED_GITIGNORE_ENTRIES.every((entry) => lines.has(entry));
2003
2483
  }
2004
2484
  function reconcileGitignore(targetRepo) {
2005
- const gitignorePath = join5(targetRepo, ".gitignore");
2006
- const header = "# Halfcycle \u2014 machine-local secrets/state and Zone-B (never push to client remote)";
2485
+ const gitignorePath = join5(targetRepo, GITIGNORE_REL);
2486
+ const header = GITIGNORE_HEADER;
2007
2487
  try {
2008
2488
  if (!existsSync3(gitignorePath)) {
2009
2489
  const body = [header, ...REQUIRED_GITIGNORE_ENTRIES].join("\n") + "\n";
@@ -2048,7 +2528,7 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, a
2048
2528
  installedAt,
2049
2529
  ...carried !== void 0 ? { accountId: carried } : {}
2050
2530
  };
2051
- const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
2531
+ const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
2052
2532
  writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
2053
2533
  }
2054
2534
  function writeCrewRoster(targetRepoRoot, report) {
@@ -2058,11 +2538,11 @@ function writeCrewRoster(targetRepoRoot, report) {
2058
2538
  };
2059
2539
  const rendered = `${JSON.stringify(doc, null, 2)}
2060
2540
  `;
2061
- const crewPath = join5(targetRepoRoot, ".halfcycle", "crew.json");
2062
- recordOwned(report, crewPath, targetRepoRoot, rendered, ".halfcycle/crew.json");
2541
+ const crewPath = join5(targetRepoRoot, CREW_ROSTER_REL);
2542
+ return recordOwned(report, crewPath, targetRepoRoot, rendered, CREW_ROSTER_REL);
2063
2543
  }
2064
2544
  function readBundlePin(targetRepoRoot) {
2065
- const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
2545
+ const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
2066
2546
  if (!existsSync3(pinPath))
2067
2547
  return null;
2068
2548
  return JSON.parse(readFileSync5(pinPath, "utf-8"));
@@ -2072,6 +2552,7 @@ function readPinnedEngagement(targetRepoRoot, home) {
2072
2552
  const engagementId = pin?.engagementId;
2073
2553
  if (engagementId === void 0 || engagementId === "")
2074
2554
  return null;
2555
+ assertEngagementId(engagementId);
2075
2556
  const stored = readEngagementEnv(engagementId, home);
2076
2557
  const fromStore = stored === null ? void 0 : toCredential(stored);
2077
2558
  if (fromStore !== void 0) {
@@ -2123,7 +2604,7 @@ function checkDrift(targetRepoRoot) {
2123
2604
  return { drifted: installed !== current, installed, current };
2124
2605
  }
2125
2606
  function writeProjectIdentity(targetRepo) {
2126
- const path = join5(targetRepo, ".halfcycle", "project.json");
2607
+ const path = join5(targetRepo, PROJECT_IDENTITY_REL);
2127
2608
  const { identity } = mintOrReadIdentity(targetRepo);
2128
2609
  const serialized = JSON.stringify(identity, null, 2) + "\n";
2129
2610
  if (existsSync3(path) && readFileSync5(path, "utf-8") === serialized)
@@ -2202,6 +2683,7 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
2202
2683
  }
2203
2684
  async function install(options) {
2204
2685
  const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
2686
+ assertEngagementId(engagementId);
2205
2687
  if (!existsSync3(targetRepo)) {
2206
2688
  throw new Error(`[bundle install] Target repo does not exist: ${targetRepo}`);
2207
2689
  }
@@ -2213,22 +2695,30 @@ async function install(options) {
2213
2695
  collidedPaths: [],
2214
2696
  replacedPaths: []
2215
2697
  };
2216
- copyManifestCommands(manifest, targetRepo, report);
2217
- const capturedDir = join5(targetRepo, "test", "fixtures", "captured");
2218
- const capturedManifest = join5(capturedDir, "manifest.json");
2698
+ const writeSet = closedWriteSet();
2699
+ const draft = startManifest(readPreviousManifest(targetRepo), (path) => writeSet.files.has(path), (path) => writeSet.dirs.has(path));
2700
+ const dirsBefore = new Set([...writeSet.dirs].filter((dir) => existsSync3(join5(targetRepo, dir))));
2701
+ copyManifestCommands(manifest, targetRepo, report, draft);
2702
+ const capturedManifest = join5(targetRepo, CAPTURED_INDEX_REL);
2703
+ const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
2219
2704
  if (!existsSync3(capturedManifest)) {
2220
- const srcManifest = join5(BUNDLE_ROOT, "scaffolding", "test", "fixtures", "captured", "manifest.json");
2221
2705
  writeAllowlisted(capturedManifest, targetRepo, readFileSync5(srcManifest, "utf-8"), report.writtenPaths);
2706
+ noteFile(draft, targetRepo, CAPTURED_INDEX_REL, "written");
2222
2707
  } else {
2223
- report.skippedPaths.push("test/fixtures/captured/manifest.json");
2708
+ report.skippedPaths.push(CAPTURED_INDEX_REL);
2709
+ if (readFileSync5(capturedManifest).equals(readFileSync5(srcManifest))) {
2710
+ noteFile(draft, targetRepo, CAPTURED_INDEX_REL, "skipped");
2711
+ }
2224
2712
  }
2225
2713
  const vendoredBinSrc = resolveVendoredBinary();
2226
2714
  const vendoredBinDest = join5(targetRepo, ".halfcycle", "bin", "bin.bundle.mjs");
2227
- recordOwned(report, vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8"), ".halfcycle/bin/bin.bundle.mjs");
2228
- const settingsPath = join5(targetRepo, ".claude", "settings.json");
2715
+ const vendoredOutcome = recordOwned(report, vendoredBinDest, targetRepo, readFileSync5(vendoredBinSrc, "utf-8"), VENDORED_BIN_REL);
2716
+ noteFile(draft, targetRepo, VENDORED_BIN_REL, vendoredOutcome);
2717
+ const settingsPath = join5(targetRepo, SETTINGS_REL);
2229
2718
  const settingsPreexisted = existsSync3(settingsPath);
2230
2719
  const generatedSettings = JSON.parse(generateSettingsJson());
2231
2720
  const existingSettings = settingsPreexisted ? JSON.parse(readFileSync5(settingsPath, "utf-8")) : {};
2721
+ recordSettingsMerge(draft, existingSettings, generatedSettings, settingsPreexisted);
2232
2722
  const mergedSettings = mergeSettings(existingSettings, generatedSettings);
2233
2723
  const mergedSettingsText = JSON.stringify(mergedSettings, null, 2) + "\n";
2234
2724
  mkdirSync4(dirname2(settingsPath), { recursive: true });
@@ -2242,16 +2732,33 @@ async function install(options) {
2242
2732
  ]) {
2243
2733
  const rel = `.claude/hooks/${name}`;
2244
2734
  const hookPath = join5(targetRepo, ".claude", "hooks", name);
2245
- recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
2735
+ const outcome = recordOwnedGenerated(report, hookPath, targetRepo, content, OWNED_GENERATED_HEADERS[rel], rel);
2736
+ noteFile(draft, targetRepo, rel, outcome);
2737
+ }
2738
+ switch (removeLegacyCiStanza(targetRepo)) {
2739
+ case "removed":
2740
+ report.writtenPaths.push(".halfcycle/ci-stanza.yml (REMOVED \u2014 Halfcycle no longer generates this file)");
2741
+ break;
2742
+ case "kept-foreign":
2743
+ report.skippedPaths.push(".halfcycle/ci-stanza.yml (KEPT \u2014 this file was not generated by Halfcycle, so it was left alone)");
2744
+ break;
2745
+ case "kept-uncommitted":
2746
+ 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)");
2747
+ break;
2748
+ case "failed":
2749
+ report.skippedPaths.push(".halfcycle/ci-stanza.yml (could not be removed \u2014 DELETE IT BY HAND: Halfcycle no longer uses this file)");
2750
+ break;
2751
+ case "absent":
2752
+ break;
2246
2753
  }
2247
- const ciStanzaPath = join5(targetRepo, ".halfcycle", "ci-stanza.yml");
2248
- recordOwned(report, ciStanzaPath, targetRepo, generateCiStanza(), ".halfcycle/ci-stanza.yml");
2249
2754
  if (credential) {
2250
2755
  const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
2251
- recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
2756
+ const helperOutcome = recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
2757
+ noteFile(draft, targetRepo, MCP_HEADERS_HELPER_REL, helperOutcome);
2252
2758
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
2253
2759
  const existingMcp = existsSync3(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
2254
2760
  const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
2761
+ recordMcpMerge(draft, existingMcp, mcpContent);
2255
2762
  if (existingMcp === null) {
2256
2763
  writeAllowlisted(mcpPath, targetRepo, mcpContent, report.writtenPaths);
2257
2764
  } else if (existingMcp !== mcpContent) {
@@ -2261,14 +2768,22 @@ async function install(options) {
2261
2768
  }
2262
2769
  } else {
2263
2770
  report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
2771
+ const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
2772
+ if (existsSync3(mcpPath))
2773
+ recordMcpAdoptionOnly(draft, readFileSync5(mcpPath, "utf-8"));
2264
2774
  }
2775
+ const gitignorePath = join5(targetRepo, GITIGNORE_REL);
2776
+ const gitignoreBefore = existsSync3(gitignorePath) ? readFileSync5(gitignorePath, "utf-8") : null;
2265
2777
  const gitignoreOutcome = reconcileGitignore(targetRepo);
2778
+ recordGitignoreReconcile(draft, gitignoreBefore, existsSync3(gitignorePath) ? readFileSync5(gitignorePath, "utf-8") : null);
2266
2779
  if (gitignoreOutcome === "failed") {
2267
2780
  report.skippedPaths.push(".gitignore (write failed \u2014 see credential refusal)");
2268
2781
  } else {
2269
2782
  record(report, gitignoreOutcome, ".gitignore");
2270
2783
  }
2271
- record(report, writeProjectIdentity(targetRepo), ".halfcycle/project.json");
2784
+ const identityOutcome = writeProjectIdentity(targetRepo);
2785
+ record(report, identityOutcome, PROJECT_IDENTITY_REL);
2786
+ noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
2272
2787
  const credentialPath = engagementEnvPath(engagementId, home);
2273
2788
  if (credential) {
2274
2789
  record(report, writeEngagementCredential(credential, engagementId, home), credentialPath);
@@ -2294,8 +2809,18 @@ async function install(options) {
2294
2809
  break;
2295
2810
  }
2296
2811
  writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, report.writtenPaths);
2297
- writeCrewRoster(targetRepo, report);
2812
+ noteFile(draft, targetRepo, BUNDLE_PIN_REL, "written");
2813
+ noteFile(draft, targetRepo, CREW_ROSTER_REL, writeCrewRoster(targetRepo, report));
2298
2814
  const scanResult = runBootstrapScan(targetRepo);
2815
+ if (existsSync3(join5(targetRepo, BOOTSTRAP_STATE_REL)))
2816
+ recordPerMachineFile(draft, BOOTSTRAP_STATE_REL);
2817
+ for (const dir of writeSet.dirs) {
2818
+ if (!dirsBefore.has(dir) && existsSync3(join5(targetRepo, dir)))
2819
+ recordCreatedDir(draft, dir);
2820
+ }
2821
+ const installManifest = finishManifest(draft);
2822
+ assertManifestInWriteSet(installManifest, writeSet);
2823
+ recordOwned(report, join5(targetRepo, INSTALL_MANIFEST_REL), targetRepo, serializeManifest(installManifest), INSTALL_MANIFEST_REL);
2299
2824
  return {
2300
2825
  version: manifest.version,
2301
2826
  writtenPaths: report.writtenPaths,
@@ -2329,11 +2854,27 @@ import { createServer } from "node:http";
2329
2854
  import { randomBytes, timingSafeEqual } from "node:crypto";
2330
2855
  var LOOPBACK_PORT_ENV = "HALFCYCLE_LOOPBACK_PORT";
2331
2856
  var DEFAULT_STILL_WAITING_MS = 15e3;
2332
- function completionPage(result) {
2857
+ var COMPLETION_REDIRECT_SECONDS = 2;
2858
+ function onwardTarget(engagementId) {
2859
+ if (isEngagementId(engagementId)) {
2860
+ return {
2861
+ href: `${DASHBOARD_URL}/${encodeURIComponent(engagementId)}`,
2862
+ label: "Go to your project's page"
2863
+ };
2864
+ }
2865
+ return { href: DASHBOARD_URL, label: "Go to your projects" };
2866
+ }
2867
+ function escapeHtml(value) {
2868
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/'/g, "&#39;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2869
+ }
2870
+ function completionPage(result, engagementId) {
2333
2871
  const approved = result === LOOPBACK_RESULT.APPROVED;
2334
2872
  const headline = approved ? "Signed in. You can close this tab and return to your terminal." : "Sign-in declined. Nothing was created. You can close this tab.";
2335
- const successLink = approved ? `<p><a href="${DASHBOARD_URL}" style="color:#19d3ff">Go to your projects</a></p>` : "";
2336
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Halfcycle</title></head><body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0a0a0a;color:#fafafa;font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif"><div style="max-width:26rem;padding:2rem;text-align:center"><p>${headline}</p>${successLink}</div></body></html>`;
2873
+ const onward = approved ? onwardTarget(engagementId) : null;
2874
+ const href = onward === null ? "" : escapeHtml(onward.href);
2875
+ const refresh = onward === null ? "" : `<meta http-equiv="refresh" content="${COMPLETION_REDIRECT_SECONDS};url=${href}">`;
2876
+ const successLink = onward === null ? "" : `<p><a href="${href}" style="color:#19d3ff">${escapeHtml(onward.label)}</a></p>`;
2877
+ return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer">' + refresh + `<title>Halfcycle</title></head><body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0a0a0a;color:#fafafa;font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif"><div style="max-width:26rem;padding:2rem;text-align:center"><p>${headline}</p>${successLink}</div></body></html>`;
2337
2878
  }
2338
2879
  function isLoopbackPeer(remoteAddress) {
2339
2880
  if (remoteAddress === void 0)
@@ -2348,7 +2889,7 @@ function statesMatch(presented, expected) {
2348
2889
  return false;
2349
2890
  return timingSafeEqual(a, b);
2350
2891
  }
2351
- function createCallbackHandler(state, onCallback) {
2892
+ function createCallbackHandler(state, onCallback, engagementId) {
2352
2893
  return (req, res) => {
2353
2894
  if (!isLoopbackPeer(req.socket.remoteAddress)) {
2354
2895
  res.writeHead(403).end();
@@ -2372,7 +2913,7 @@ function createCallbackHandler(state, onCallback) {
2372
2913
  const declared = url.searchParams.get(LOOPBACK_QUERY_RESULT);
2373
2914
  const result = declared === LOOPBACK_RESULT.DECLINED ? LOOPBACK_RESULT.DECLINED : LOOPBACK_RESULT.APPROVED;
2374
2915
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
2375
- res.end(completionPage(result));
2916
+ res.end(completionPage(result, engagementId));
2376
2917
  onCallback({ result });
2377
2918
  };
2378
2919
  }
@@ -2391,7 +2932,7 @@ function requestedPort(env) {
2391
2932
  }
2392
2933
  return { port };
2393
2934
  }
2394
- async function bindLoopback(env = process.env) {
2935
+ async function bindLoopback(env = process.env, engagementId) {
2395
2936
  const wanted = requestedPort(env);
2396
2937
  if ("problem" in wanted)
2397
2938
  return { bound: false, reason: wanted.problem };
@@ -2403,7 +2944,7 @@ async function bindLoopback(env = process.env) {
2403
2944
  deliver(callback);
2404
2945
  else
2405
2946
  pending = callback;
2406
- }));
2947
+ }, engagementId));
2407
2948
  const bindProblem = await new Promise((resolve4) => {
2408
2949
  const onError = (err) => {
2409
2950
  resolve4(`${err.code ?? "bind failed"} on ${LOOPBACK_REDIRECT_HOST}:${wanted.port}`);
@@ -2660,7 +3201,7 @@ async function signIn(serviceUrl, deps = {}) {
2660
3201
  }
2661
3202
  write(`[halfcycle] Halfcycle needs an account before it can ${deps.reason ?? DEFAULT_SIGN_IN_REASON} \u2014 signing you in through your browser.
2662
3203
  `);
2663
- const bind = await bindLoopback(env);
3204
+ const bind = await bindLoopback(env, deps.onwardEngagementId);
2664
3205
  let closed = false;
2665
3206
  const closeListener = async () => {
2666
3207
  if (bind.bound && !closed) {
@@ -3439,8 +3980,14 @@ async function createOwnedEngagement(serviceUrl, targetRepo, deps = {}) {
3439
3980
  }
3440
3981
  }
3441
3982
  var JOIN_REFUSED_LOCAL_REMEDY = "No credential was minted and nothing was written. This repository is pinned to that engagement by .halfcycle/bundle.json \u2014 check that the id in it is the one you meant. To start a NEW engagement here instead, remove that file and re-run.";
3983
+ var JOIN_SIGN_IN_REASON = "join an existing project";
3442
3984
  async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps = {}) {
3443
- let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, deps), { kind: "join", targetRepo, engagementId }, deps);
3985
+ const signInDeps = {
3986
+ ...deps,
3987
+ reason: deps.reason ?? JOIN_SIGN_IN_REASON,
3988
+ onwardEngagementId: engagementId
3989
+ };
3990
+ let credential = await confirmActingIdentity(serviceUrl, await obtainCredential(serviceUrl, signInDeps), { kind: "join", targetRepo, engagementId }, signInDeps);
3444
3991
  for (; ; ) {
3445
3992
  try {
3446
3993
  return {
@@ -3468,7 +4015,7 @@ async function joinPinnedEngagement(serviceUrl, engagementId, targetRepo, deps =
3468
4015
  if (err.status !== 401) {
3469
4016
  throw err;
3470
4017
  }
3471
- const replacement = await replaceRefusedCredential(serviceUrl, credential, deps);
4018
+ const replacement = await replaceRefusedCredential(serviceUrl, credential, signInDeps);
3472
4019
  if (replacement === null) {
3473
4020
  throw new SignInRefused(`join-${err.status}`, `${err.serverMessage} ${refusedCredentialRemedy(credential.source)} No credential was minted and nothing was written.`);
3474
4021
  }
@@ -3560,11 +4107,11 @@ function projectSeedGuard(fired, includeExplanation) {
3560
4107
 
3561
4108
  // dist/build-record/sources.js
3562
4109
  import { readFileSync as readFileSync6, existsSync as existsSync4 } from "node:fs";
3563
- import { createHash } from "node:crypto";
4110
+ import { createHash as createHash3 } from "node:crypto";
3564
4111
  import { join as join8 } from "node:path";
3565
4112
 
3566
4113
  // dist/build-record/close-record.js
3567
- import { execFileSync as execFileSync2 } from "node:child_process";
4114
+ import { execFileSync as execFileSync3 } from "node:child_process";
3568
4115
  import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
3569
4116
  import { dirname as dirname3, join as join6 } from "node:path";
3570
4117
  var CLOSE_RECORD_FORMAT = "halfcycle-phase-close/v1";
@@ -3581,11 +4128,11 @@ function closeRecordPath(repoRoot, phase) {
3581
4128
  }
3582
4129
  function resolveCloseAtHead(repoRoot) {
3583
4130
  try {
3584
- const closeCommit = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
4131
+ const closeCommit = execFileSync3("git", ["-C", repoRoot, "rev-parse", "--short", "HEAD"], {
3585
4132
  encoding: "utf-8",
3586
4133
  stdio: ["ignore", "pipe", "ignore"]
3587
4134
  }).trim();
3588
- const closedDate = execFileSync2("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
4135
+ const closedDate = execFileSync3("git", ["-C", repoRoot, "show", "-s", "--format=%cs", "HEAD"], {
3589
4136
  encoding: "utf-8",
3590
4137
  stdio: ["ignore", "pipe", "ignore"]
3591
4138
  }).trim();
@@ -3809,7 +4356,7 @@ function syntheticRunId(record2) {
3809
4356
  record2["runType"],
3810
4357
  record2["phase"]
3811
4358
  ].join("|");
3812
- const hex = createHash("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
4359
+ const hex = createHash3("sha256").update(`legacy-guard-eval-run:${key}`).digest("hex");
3813
4360
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
3814
4361
  }
3815
4362
  function readGuardEvalLog(logDir, phaseId) {
@@ -3981,6 +4528,8 @@ var OPEN_PHASE_ENV_KEYS = [
3981
4528
  ];
3982
4529
  function readRepoCredential(repoRoot, env = process.env, home) {
3983
4530
  const pinned = readPinnedEngagementId(repoRoot);
4531
+ if (pinned !== null)
4532
+ assertEngagementId(pinned);
3984
4533
  const fromStore = pinned === null ? null : readEngagementEnv(pinned, home);
3985
4534
  const looked = pinned === null ? `${join9(repoRoot, ".halfcycle", "bundle.json")} (which names no engagement)` : engagementEnvPath(pinned, home);
3986
4535
  const values = {};
@@ -4007,6 +4556,9 @@ function readPinnedEngagementId(repoRoot) {
4007
4556
  }
4008
4557
  }
4009
4558
  function writePhaseStamp(engagementId, phase, home) {
4559
+ if (!isEngagementId(engagementId)) {
4560
+ return { path: "(none \u2014 no valid project id)", reason: new InvalidEngagementIdError(engagementId).message };
4561
+ }
4010
4562
  const path = engagementEnvPath(engagementId, home);
4011
4563
  if (phase !== null && !isValidPhaseIdentity(phase)) {
4012
4564
  return { path, reason: new InvalidPhaseIdentityError(phase).message };
@@ -4115,115 +4667,13 @@ async function closePhase(credential, phase, outcome, close, home, repoRoot) {
4115
4667
  return { engagementId: body.engagementId, status: body.status, stampFailure, closeRecord };
4116
4668
  }
4117
4669
 
4118
- // dist/ci-bind.js
4119
- function parseCiRepositoryArg(arg) {
4120
- const parts = arg.split("/");
4121
- if (parts.length !== 2)
4122
- return null;
4123
- const [owner, repo] = parts;
4124
- if (owner === void 0 || owner.trim() === "")
4125
- return null;
4126
- if (repo === void 0 || repo.trim() === "")
4127
- return null;
4128
- return { owner, repo };
4129
- }
4130
- var CiBindRefused = class extends Error {
4131
- status;
4132
- constructor(status, message) {
4133
- super(message);
4134
- this.status = status;
4135
- this.name = "CiBindRefused";
4136
- }
4137
- /** Is this the ONE arm signing in again can fix — see this file's header. */
4138
- get authRefused() {
4139
- return this.status === 401;
4140
- }
4141
- /**
4142
- * Is this "ours to fix, try again", not "yours to fix"? The question is not
4143
- * mechanical (did a handler run) — it is what a developer reading the message
4144
- * does next. `502`/`504` are the reverse proxy in front of the published origin
4145
- * answering for a control plane that is down or slow, with an HTML body this
4146
- * file's own `requestBinding` cannot parse, so the message would otherwise
4147
- * degrade to a bare "the URL returned 502". `503` is `resolveAccount`'s own
4148
- * fail-closed refusal when it could not reach the token store — the caller's
4149
- * credential was never actually checked, and its posture is emphatic this is NOT
4150
- * a "no". **`500` belongs beside them, not with the 4xx arms.** It is an
4151
- * unhandled throw: nothing decided the request's merits, and the bind may even
4152
- * have half-happened if the throw landed after a commit. On this route the
4153
- * body is the same degraded "the URL returned 500" a 502/504 produces — an
4154
- * internal path echoed back with no advice — so `refused (500)` would send a
4155
- * developer to re-check ownership, the repository name and whether it is bound
4156
- * elsewhere: every 4xx question, none of them this status's actual cause.
4157
- *
4158
- * So this is every `5xx`, not an enumerated set of three — the boundary is
4159
- * "did any handler decide yes or no", and a 4xx is the only family that ever did.
4160
- */
4161
- get unavailable() {
4162
- return this.status >= 500 && this.status < 600;
4163
- }
4164
- };
4165
- async function requestBinding(method, serviceUrl, engagementId, repository, credential, noun) {
4166
- const url = `${serviceUrl.replace(/\/+$/, "")}/engagements/${encodeURIComponent(engagementId)}/ci-bindings/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}`;
4167
- let res;
4168
- try {
4169
- res = await fetch(url, { method, headers: { authorization: `Bearer ${credential}` } });
4170
- } catch (err) {
4171
- throw new Error(`[halfcycle] Could not reach the Halfcycle service at ${url}: ${err instanceof Error ? err.message : String(err)}. Nothing was ${noun}.`);
4172
- }
4173
- if (res.status === 204)
4174
- return;
4175
- const body = await res.json().catch(() => null);
4176
- const message = typeof body?.message === "string" ? body.message : `${url} returned ${res.status}.`;
4177
- throw new CiBindRefused(res.status, message);
4178
- }
4179
- async function setCiBinding(action, serviceUrl, engagementId, repository, deps = {}) {
4180
- const noun = action === "bind" ? "bound" : "unbound";
4181
- const signInDeps = {
4182
- ...deps,
4183
- reason: deps.reason ?? (action === "bind" ? "trust this repository for this engagement's CI" : "stop trusting this repository for this engagement's CI")
4184
- };
4185
- let credential = await obtainCredential(serviceUrl, signInDeps);
4186
- for (; ; ) {
4187
- try {
4188
- await requestBinding(action === "bind" ? "PUT" : "DELETE", serviceUrl, engagementId, repository, credential.credential, noun);
4189
- return;
4190
- } catch (err) {
4191
- if (!(err instanceof CiBindRefused) || !err.authRefused)
4192
- throw err;
4193
- const replacement = await replaceRefusedCredential(serviceUrl, credential, signInDeps);
4194
- if (replacement === null) {
4195
- throw new SignInRefused(`ci-${action}-${err.status}`, `${err.message} ${refusedCredentialRemedy(credential.source)} Nothing was ${noun}.`);
4196
- }
4197
- process.stdout.write(`[halfcycle] Your saved Halfcycle sign-in was refused \u2014 signing you in again.
4198
- `);
4199
- credential = replacement;
4200
- }
4201
- }
4202
- }
4203
- function bindCiRepository(serviceUrl, engagementId, repository, deps = {}) {
4204
- return setCiBinding("bind", serviceUrl, engagementId, repository, deps);
4205
- }
4206
- function unbindCiRepository(serviceUrl, engagementId, repository, deps = {}) {
4207
- return setCiBinding("unbind", serviceUrl, engagementId, repository, deps);
4208
- }
4209
-
4210
4670
  // dist/cli-contract.js
4211
- var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase", "ci"];
4671
+ var CLI_VERBS = ["install", "check-drift", "build-record", "open-phase", "close-phase", "uninstall"];
4212
4672
  var CONTRACTS = [
4213
- // `install`, `check-drift`, `build-record` and `ci` take positionals and no
4214
- // required flags. They are declared so the verb set has ONE home — a remedy
4215
- // naming a verb this CLI does not have is the same defect as one missing a flag.
4216
- //
4217
- // `ci`'s two forms (`ci bind <owner>/<repo>`, `ci unbind <owner>/<repo>`) take a
4218
- // sub-action and a repository as POSITIONALS, not flags — this table answers only
4219
- // *which flags must an invocation carry*, and neither form has one (T-11,
4220
- // `ci-oidc-token-exchange`). The route it calls declares no wire shape either
4221
- // (path segments in, `204` out — T-05), so there is no schema for an argument
4222
- // here to disagree with.
4223
4673
  { verb: "install", required: [], conditional: [] },
4224
4674
  { verb: "check-drift", required: [], conditional: [] },
4225
4675
  { verb: "build-record", required: [], conditional: [] },
4226
- { verb: "ci", required: [], conditional: [] },
4676
+ { verb: "uninstall", required: [], conditional: [] },
4227
4677
  {
4228
4678
  verb: "open-phase",
4229
4679
  required: [
@@ -4330,10 +4780,618 @@ var PLACEHOLDERS = {
4330
4780
  "--evidence": '"\u2026"'
4331
4781
  };
4332
4782
 
4783
+ // dist/uninstall.js
4784
+ import { lstatSync, readFileSync as readFileSync8, readdirSync, rmSync as rmSync2, rmdirSync, writeFileSync as writeFileSync7 } from "node:fs";
4785
+ import { homedir as homedir2 } from "node:os";
4786
+ import { dirname as dirname4, join as join10, relative as relative3, sep } from "node:path";
4787
+ var REMOVE_CREDENTIAL_FLAG = "--remove-credential";
4788
+ function parseUninstallArgs(args2) {
4789
+ let removeCredential = false;
4790
+ for (const arg of args2) {
4791
+ if (arg === REMOVE_CREDENTIAL_FLAG) {
4792
+ removeCredential = true;
4793
+ continue;
4794
+ }
4795
+ return {
4796
+ error: arg.startsWith("-") ? `unknown flag "${arg}" \u2014 the one flag is ${REMOVE_CREDENTIAL_FLAG}. Nothing was changed.` : `unexpected argument "${arg}" \u2014 run it in the project directory, with no path. Nothing was changed.`
4797
+ };
4798
+ }
4799
+ return { removeCredential };
4800
+ }
4801
+ function lexists(path) {
4802
+ try {
4803
+ lstatSync(path);
4804
+ return true;
4805
+ } catch {
4806
+ return false;
4807
+ }
4808
+ }
4809
+ function symlinkOnPath(root, rel) {
4810
+ let current = root;
4811
+ for (const segment of rel.split("/")) {
4812
+ current = join10(current, segment);
4813
+ try {
4814
+ if (lstatSync(current).isSymbolicLink())
4815
+ return true;
4816
+ } catch {
4817
+ return false;
4818
+ }
4819
+ }
4820
+ return false;
4821
+ }
4822
+ function isPlainObject2(value) {
4823
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4824
+ }
4825
+ function serializeJson(value) {
4826
+ return JSON.stringify(value, null, 2) + "\n";
4827
+ }
4828
+ var Refusal = class extends Error {
4829
+ };
4830
+ function readMergedJson(targetRepo, rel) {
4831
+ if (symlinkOnPath(targetRepo, rel))
4832
+ return { state: "symlink" };
4833
+ const path = join10(targetRepo, rel);
4834
+ if (!lexists(path))
4835
+ return { state: "absent" };
4836
+ let value;
4837
+ try {
4838
+ value = JSON.parse(readFileSync8(path, "utf-8"));
4839
+ } catch {
4840
+ value = void 0;
4841
+ }
4842
+ if (!isPlainObject2(value)) {
4843
+ throw new Refusal(`${rel} is not a JSON object, so Halfcycle cannot take its own entries out of it and nothing was removed. Fix it or delete it, then run "npx halfcycle uninstall" again.`);
4844
+ }
4845
+ return { state: "object", value };
4846
+ }
4847
+ function validate(options) {
4848
+ const { targetRepo, removeCredential, home } = options;
4849
+ const manifestPath = join10(targetRepo, INSTALL_MANIFEST_REL);
4850
+ const pinPath = join10(targetRepo, BUNDLE_PIN_REL);
4851
+ if (symlinkOnPath(targetRepo, INSTALL_MANIFEST_REL) || symlinkOnPath(targetRepo, BUNDLE_PIN_REL)) {
4852
+ throw new Refusal(`.halfcycle, or a file Halfcycle keeps in it, is a symbolic link here, so nothing was followed and nothing was removed.`);
4853
+ }
4854
+ if (!lexists(manifestPath)) {
4855
+ if (!lexists(pinPath))
4856
+ return "not-installed";
4857
+ throw new Refusal(`Halfcycle was installed here by a version that kept no record of what it wrote, so nothing was removed. Run "npx halfcycle" once \u2014 it writes that record \u2014 then run "npx halfcycle uninstall".`);
4858
+ }
4859
+ let manifest;
4860
+ try {
4861
+ manifest = parseManifest(readFileSync8(manifestPath, "utf-8"));
4862
+ } catch (err) {
4863
+ const why = err instanceof UnreadableManifestError ? err.message : "it could not be read";
4864
+ throw new Refusal(`${INSTALL_MANIFEST_REL} cannot be read (${why}), so nothing was removed. Restore it from git, or run "npx halfcycle" once to rewrite it, then run "npx halfcycle uninstall" again.`);
4865
+ }
4866
+ let engagementId;
4867
+ let pinReadable = true;
4868
+ if (lexists(pinPath)) {
4869
+ try {
4870
+ const pin = JSON.parse(readFileSync8(pinPath, "utf-8"));
4871
+ const id = isPlainObject2(pin) ? pin["engagementId"] : void 0;
4872
+ engagementId = typeof id === "string" && id !== "" ? id : void 0;
4873
+ } catch {
4874
+ pinReadable = false;
4875
+ }
4876
+ }
4877
+ let idValid = false;
4878
+ if (engagementId !== void 0) {
4879
+ try {
4880
+ assertEngagementId(engagementId);
4881
+ idValid = true;
4882
+ } catch {
4883
+ idValid = false;
4884
+ }
4885
+ }
4886
+ if (removeCredential) {
4887
+ if (!pinReadable) {
4888
+ throw new Refusal(`${BUNDLE_PIN_REL} cannot be read, so there is no project id to find this machine's credential by. Nothing was removed.`);
4889
+ }
4890
+ if (engagementId !== void 0 && !idValid) {
4891
+ throw new Refusal(`the project id in ${BUNDLE_PIN_REL} is not a valid Halfcycle project id, so it cannot name a folder on this machine. Nothing was removed, here or under ~/.halfcycle.`);
4892
+ }
4893
+ if (engagementId !== void 0) {
4894
+ const expectedParent = join10(home ?? homedir2(), HALFCYCLE_DIR_NAME, ENGAGEMENTS_DIR_NAME);
4895
+ if (dirname4(engagementStateDir(engagementId, home)) !== expectedParent) {
4896
+ throw new Refusal(`the project id in ${BUNDLE_PIN_REL} does not name a folder under ~/.halfcycle/engagements. Nothing was removed.`);
4897
+ }
4898
+ }
4899
+ }
4900
+ return {
4901
+ manifest,
4902
+ engagementId,
4903
+ idValid,
4904
+ settings: readMergedJson(targetRepo, SETTINGS_REL),
4905
+ mcp: manifest.mcp !== void 0 ? readMergedJson(targetRepo, MCP_REGISTRATION_REL) : { state: "absent" }
4906
+ };
4907
+ }
4908
+ function unmergeSettings(value, rec, writeSet) {
4909
+ let changed = false;
4910
+ const refused = [];
4911
+ const commands = new Set(rec.hookCommands.filter((c) => writeSet.hookCommands.has(c)));
4912
+ for (const command of rec.hookCommands) {
4913
+ if (!writeSet.hookCommands.has(command)) {
4914
+ refused.push(`${SETTINGS_REL}: the hook command ${JSON.stringify(command)} (Halfcycle never writes it, so it was kept)`);
4915
+ }
4916
+ }
4917
+ const denyHashes = rec.denyAddedSha256.filter((h) => writeSet.denyRuleSha256.has(h));
4918
+ if (denyHashes.length < rec.denyAddedSha256.length) {
4919
+ refused.push(`${SETTINGS_REL}: a deny rule the install record names but Halfcycle never adds (it was kept)`);
4920
+ }
4921
+ const hooks = value["hooks"];
4922
+ if (isPlainObject2(hooks)) {
4923
+ let emptiedAnEvent = false;
4924
+ for (const event of Object.keys(hooks)) {
4925
+ const entries = hooks[event];
4926
+ if (!Array.isArray(entries))
4927
+ continue;
4928
+ let touched = false;
4929
+ const next = [];
4930
+ for (const entry of entries) {
4931
+ const wellFormed = isPlainObject2(entry) && Array.isArray(entry["hooks"]) && entry["hooks"].every(isPlainObject2);
4932
+ if (!wellFormed || !isHalfcycleEntry(entry, commands)) {
4933
+ next.push(entry);
4934
+ continue;
4935
+ }
4936
+ touched = true;
4937
+ const remaining = entry["hooks"].filter((h) => !(typeof h["command"] === "string" && commands.has(h["command"])));
4938
+ if (remaining.length > 0)
4939
+ next.push({ ...entry, hooks: remaining });
4940
+ }
4941
+ if (touched)
4942
+ changed = true;
4943
+ if (next.length === 0 && (rec.eventsCreated.includes(event) || rec.adopted && touched)) {
4944
+ delete hooks[event];
4945
+ changed = true;
4946
+ emptiedAnEvent = true;
4947
+ } else if (touched) {
4948
+ hooks[event] = next;
4949
+ }
4950
+ }
4951
+ if (Object.keys(hooks).length === 0 && (rec.hooksCreated || rec.adopted && emptiedAnEvent)) {
4952
+ delete value["hooks"];
4953
+ changed = true;
4954
+ }
4955
+ }
4956
+ const permissions = value["permissions"];
4957
+ if (isPlainObject2(permissions) && Array.isArray(permissions["deny"])) {
4958
+ const deny = permissions["deny"];
4959
+ for (const hash of denyHashes) {
4960
+ for (let i = deny.length - 1; i >= 0; i--) {
4961
+ const rule = deny[i];
4962
+ if (typeof rule === "string" && sha256Hex(rule) === hash) {
4963
+ deny.splice(i, 1);
4964
+ changed = true;
4965
+ break;
4966
+ }
4967
+ }
4968
+ }
4969
+ if (deny.length === 0 && rec.denyCreated) {
4970
+ delete permissions["deny"];
4971
+ changed = true;
4972
+ }
4973
+ if (Object.keys(permissions).length === 0 && rec.permissionsCreated) {
4974
+ delete value["permissions"];
4975
+ changed = true;
4976
+ }
4977
+ }
4978
+ if ("schemaBefore" in rec) {
4979
+ if (rec.schemaBefore === null) {
4980
+ if ("$schema" in value) {
4981
+ delete value["$schema"];
4982
+ changed = true;
4983
+ }
4984
+ } else if (value["$schema"] !== rec.schemaBefore) {
4985
+ value["$schema"] = rec.schemaBefore;
4986
+ changed = true;
4987
+ }
4988
+ }
4989
+ return { changed, refused };
4990
+ }
4991
+ function unmergeMcp(value, rec) {
4992
+ const servers = value["mcpServers"];
4993
+ if (!isPlainObject2(servers))
4994
+ return { changed: false, keptEntry: false };
4995
+ let changed = false;
4996
+ let keptEntry = false;
4997
+ let removedEntry = false;
4998
+ if (MCP_SERVER_KEY in servers) {
4999
+ if (rec.entrySha256 !== void 0 && canonicalSha256(servers[MCP_SERVER_KEY]) === rec.entrySha256) {
5000
+ delete servers[MCP_SERVER_KEY];
5001
+ changed = true;
5002
+ removedEntry = true;
5003
+ } else {
5004
+ keptEntry = true;
5005
+ }
5006
+ }
5007
+ if (Object.keys(servers).length === 0 && (rec.mcpServersCreated || rec.adopted && removedEntry)) {
5008
+ delete value["mcpServers"];
5009
+ changed = true;
5010
+ }
5011
+ return { changed, keptEntry };
5012
+ }
5013
+ function trimGitignore(targetRepo, text, rec, writeSet) {
5014
+ const keptLines = [];
5015
+ const foreignLines = [];
5016
+ let current = text;
5017
+ for (const block of [...rec.appended].reverse()) {
5018
+ const lines = block.split("\n").filter((line) => line !== "");
5019
+ const foreign = lines.filter((line) => !writeSet.gitignoreLines.has(line));
5020
+ foreignLines.push(...foreign);
5021
+ const ours = lines.filter((line) => writeSet.gitignoreLines.has(line));
5022
+ const entries = ours.filter((line) => line !== GITIGNORE_HEADER);
5023
+ const kept = entries.filter((line) => coveredPathExists(targetRepo, line));
5024
+ keptLines.push(...kept);
5025
+ if (kept.length === 0 && foreign.length === 0 && current.includes(block)) {
5026
+ const at = current.lastIndexOf(block);
5027
+ current = current.slice(0, at) + current.slice(at + block.length);
5028
+ continue;
5029
+ }
5030
+ const separated = block.startsWith("\n") || block.includes("\n\n");
5031
+ const removable = ours.filter((line) => line === GITIGNORE_HEADER ? kept.length === 0 && foreign.length === 0 : !kept.includes(line));
5032
+ for (const line of removable) {
5033
+ const fileLines = current.split("\n");
5034
+ const at = fileLines.lastIndexOf(line);
5035
+ if (at < 0)
5036
+ continue;
5037
+ const withSeparator = line === GITIGNORE_HEADER && separated && at > 0 && fileLines[at - 1] === "";
5038
+ fileLines.splice(withSeparator ? at - 1 : at, withSeparator ? 2 : 1);
5039
+ current = fileLines.join("\n");
5040
+ }
5041
+ }
5042
+ return { text: current, keptLines, foreignLines };
5043
+ }
5044
+ function coveredPathExists(targetRepo, line) {
5045
+ const path = line.trim().replace(/^\//, "").replace(/\/$/, "");
5046
+ return path !== "" && lexists(join10(targetRepo, path));
5047
+ }
5048
+ function uninstall(options) {
5049
+ const result = {
5050
+ removed: [],
5051
+ restored: [],
5052
+ kept: [],
5053
+ keptIgnoreLines: [],
5054
+ leftAlone: [],
5055
+ failed: []
5056
+ };
5057
+ const nothingToSay = { kind: "nothing-to-say" };
5058
+ let checked;
5059
+ try {
5060
+ checked = validate(options);
5061
+ } catch (err) {
5062
+ if (err instanceof Refusal) {
5063
+ return { outcome: "refused", exitCode: 1, refusal: err.message, ...result, credential: nothingToSay };
5064
+ }
5065
+ throw err;
5066
+ }
5067
+ if (checked === "not-installed") {
5068
+ return { outcome: "not-installed", exitCode: 0, ...result, credential: nothingToSay };
5069
+ }
5070
+ const { targetRepo, home } = options;
5071
+ const { manifest } = checked;
5072
+ const writeSet = closedWriteSet();
5073
+ const named = /* @__PURE__ */ new Set();
5074
+ const name = (list, text, path) => {
5075
+ list.push(text);
5076
+ if (path !== void 0)
5077
+ named.add(path);
5078
+ };
5079
+ applyMergedJson(targetRepo, SETTINGS_REL, checked.settings, result, name, (value) => {
5080
+ const { changed, refused } = unmergeSettings(value, manifest.settings, writeSet);
5081
+ for (const note of refused)
5082
+ name(result.leftAlone, note);
5083
+ return { changed, created: manifest.settings.created, adopted: manifest.settings.adopted, keptNote: void 0 };
5084
+ });
5085
+ const mcpRecord = manifest.mcp;
5086
+ if (mcpRecord !== void 0) {
5087
+ applyMergedJson(targetRepo, MCP_REGISTRATION_REL, checked.mcp, result, name, (value) => {
5088
+ const { changed, keptEntry } = unmergeMcp(value, mcpRecord);
5089
+ return {
5090
+ changed,
5091
+ created: mcpRecord.created,
5092
+ adopted: mcpRecord.adopted,
5093
+ keptNote: keptEntry ? `${MCP_REGISTRATION_REL} (its "${MCP_SERVER_KEY}" server entry)` : void 0
5094
+ };
5095
+ });
5096
+ }
5097
+ const deferred = /* @__PURE__ */ new Set([BUNDLE_PIN_REL, INSTALL_MANIFEST_REL]);
5098
+ for (const entry of manifest.files) {
5099
+ if (deferred.has(entry.path))
5100
+ continue;
5101
+ removeRecordedFile(targetRepo, entry, writeSet, result, name);
5102
+ }
5103
+ for (const path of manifest.leftAlone) {
5104
+ if (lexists(join10(targetRepo, path)))
5105
+ name(result.leftAlone, path, path);
5106
+ }
5107
+ switch (removeLegacyCiStanza(targetRepo)) {
5108
+ case "removed":
5109
+ name(result.removed, ".halfcycle/ci-stanza.yml", ".halfcycle/ci-stanza.yml");
5110
+ break;
5111
+ case "kept-foreign":
5112
+ name(result.leftAlone, ".halfcycle/ci-stanza.yml", ".halfcycle/ci-stanza.yml");
5113
+ break;
5114
+ case "kept-uncommitted":
5115
+ name(result.kept, ".halfcycle/ci-stanza.yml", ".halfcycle/ci-stanza.yml");
5116
+ break;
5117
+ case "failed":
5118
+ name(result.failed, ".halfcycle/ci-stanza.yml", ".halfcycle/ci-stanza.yml");
5119
+ break;
5120
+ case "absent":
5121
+ break;
5122
+ }
5123
+ const createdDirs = [.../* @__PURE__ */ new Set([...manifest.createdDirs, ...writeSet.ownedDirs])].sort((a, b) => b.split("/").length - a.split("/").length || (a < b ? 1 : a > b ? -1 : 0));
5124
+ for (const dir of createdDirs) {
5125
+ if (dir === ".halfcycle")
5126
+ continue;
5127
+ removeEmptyCreatedDir(targetRepo, dir, writeSet, result, name);
5128
+ }
5129
+ trimGitignoreFile(targetRepo, manifest.gitignore, writeSet, result, name);
5130
+ const credential = handleCredential(options, checked);
5131
+ const credentialFailed = credential.kind === "failed";
5132
+ if (result.failed.length === 0 && !credentialFailed) {
5133
+ const pinEntry = manifest.files.find((f) => f.path === BUNDLE_PIN_REL);
5134
+ if (pinEntry !== void 0)
5135
+ removeRecordedFile(targetRepo, pinEntry, writeSet, result, name);
5136
+ else if (lexists(join10(targetRepo, BUNDLE_PIN_REL)))
5137
+ name(result.leftAlone, BUNDLE_PIN_REL, BUNDLE_PIN_REL);
5138
+ try {
5139
+ rmSync2(join10(targetRepo, INSTALL_MANIFEST_REL), { force: true });
5140
+ named.add(INSTALL_MANIFEST_REL);
5141
+ } catch {
5142
+ name(result.failed, INSTALL_MANIFEST_REL, INSTALL_MANIFEST_REL);
5143
+ }
5144
+ if (manifest.createdDirs.includes(".halfcycle") || writeSet.ownedDirs.has(".halfcycle")) {
5145
+ removeEmptyCreatedDir(targetRepo, ".halfcycle", writeSet, result, name);
5146
+ }
5147
+ } else {
5148
+ named.add(BUNDLE_PIN_REL);
5149
+ named.add(INSTALL_MANIFEST_REL);
5150
+ }
5151
+ for (const path of filesUnder(targetRepo, ".halfcycle")) {
5152
+ if (!named.has(path))
5153
+ name(result.leftAlone, path, path);
5154
+ }
5155
+ const exitCode = result.failed.length > 0 || credentialFailed || credential.kind === "no-project-id" ? 1 : 0;
5156
+ return { outcome: "uninstalled", exitCode, ...result, credential };
5157
+ }
5158
+ function applyMergedJson(targetRepo, rel, file, lists, name, edit) {
5159
+ if (file.state === "absent")
5160
+ return;
5161
+ if (file.state === "symlink") {
5162
+ name(lists.leftAlone, `${rel} (a symbolic link, or inside one \u2014 not followed)`, rel);
5163
+ return;
5164
+ }
5165
+ const value = file.value;
5166
+ const { changed, created, adopted, keptNote } = edit(value);
5167
+ if (keptNote !== void 0)
5168
+ name(lists.kept, keptNote, rel);
5169
+ const path = join10(targetRepo, rel);
5170
+ const empty = Object.keys(value).length === 0;
5171
+ try {
5172
+ if (empty && created && !adopted) {
5173
+ rmSync2(path);
5174
+ name(lists.removed, rel, rel);
5175
+ } else if (changed) {
5176
+ writeFileSync7(path, serializeJson(value), "utf-8");
5177
+ name(lists.restored, rel, rel);
5178
+ }
5179
+ if (adopted && lexists(path)) {
5180
+ name(lists.leftAlone, `${rel} (an older Halfcycle version changed it before keeping a record, so some of what it added may still be there \u2014 it cannot be told apart from your own)`, rel);
5181
+ }
5182
+ } catch {
5183
+ name(lists.failed, rel, rel);
5184
+ }
5185
+ }
5186
+ function removeRecordedFile(targetRepo, entry, writeSet, lists, name) {
5187
+ const { path } = entry;
5188
+ if (!writeSet.files.has(path)) {
5189
+ name(lists.leftAlone, `${path} (Halfcycle never writes this path, so it was not removed)`, path);
5190
+ return;
5191
+ }
5192
+ if (symlinkOnPath(targetRepo, path)) {
5193
+ name(lists.leftAlone, `${path} (a symbolic link, or inside one \u2014 not followed)`, path);
5194
+ return;
5195
+ }
5196
+ const abs = join10(targetRepo, path);
5197
+ let isFile;
5198
+ try {
5199
+ isFile = lstatSync(abs).isFile();
5200
+ } catch {
5201
+ return;
5202
+ }
5203
+ if (!isFile) {
5204
+ name(lists.leftAlone, `${path} (not a file)`, path);
5205
+ return;
5206
+ }
5207
+ if ("sha256" in entry) {
5208
+ let bytes;
5209
+ try {
5210
+ bytes = readFileSync8(abs);
5211
+ } catch {
5212
+ name(lists.failed, path, path);
5213
+ return;
5214
+ }
5215
+ if (sha256Hex(bytes) !== entry.sha256) {
5216
+ name(lists.kept, path, path);
5217
+ return;
5218
+ }
5219
+ }
5220
+ try {
5221
+ rmSync2(abs);
5222
+ name(lists.removed, path, path);
5223
+ } catch {
5224
+ name(lists.failed, path, path);
5225
+ }
5226
+ }
5227
+ function removeEmptyCreatedDir(targetRepo, dir, writeSet, lists, name) {
5228
+ if (!writeSet.dirs.has(dir) || symlinkOnPath(targetRepo, dir))
5229
+ return;
5230
+ const abs = join10(targetRepo, dir);
5231
+ try {
5232
+ if (!lstatSync(abs).isDirectory() || readdirSync(abs).length > 0)
5233
+ return;
5234
+ } catch {
5235
+ return;
5236
+ }
5237
+ try {
5238
+ rmdirSync(abs);
5239
+ } catch {
5240
+ name(lists.failed, `${dir}/`, dir);
5241
+ }
5242
+ }
5243
+ function trimGitignoreFile(targetRepo, rec, writeSet, lists, name) {
5244
+ if (rec.appended.length === 0)
5245
+ return;
5246
+ if (symlinkOnPath(targetRepo, GITIGNORE_REL)) {
5247
+ name(lists.leftAlone, `${GITIGNORE_REL} (a symbolic link \u2014 not followed)`, GITIGNORE_REL);
5248
+ return;
5249
+ }
5250
+ const path = join10(targetRepo, GITIGNORE_REL);
5251
+ let before;
5252
+ try {
5253
+ before = readFileSync8(path, "utf-8");
5254
+ } catch {
5255
+ return;
5256
+ }
5257
+ const { text, keptLines, foreignLines } = trimGitignore(targetRepo, before, rec, writeSet);
5258
+ for (const line of foreignLines) {
5259
+ name(lists.leftAlone, `${GITIGNORE_REL}: the line ${JSON.stringify(line)} (Halfcycle never writes it, so it was kept)`);
5260
+ }
5261
+ for (const line of keptLines) {
5262
+ lists.keptIgnoreLines.push(`${line.trim()} \u2014 ${line.trim().replace(/^\//, "")} still exists`);
5263
+ }
5264
+ if (text === before)
5265
+ return;
5266
+ try {
5267
+ if (text === "" && rec.created) {
5268
+ rmSync2(path);
5269
+ name(lists.removed, GITIGNORE_REL, GITIGNORE_REL);
5270
+ } else {
5271
+ writeFileSync7(path, text, "utf-8");
5272
+ name(lists.restored, GITIGNORE_REL, GITIGNORE_REL);
5273
+ }
5274
+ } catch {
5275
+ name(lists.failed, GITIGNORE_REL, GITIGNORE_REL);
5276
+ }
5277
+ }
5278
+ function handleCredential(options, checked) {
5279
+ const { removeCredential, home } = options;
5280
+ const { engagementId, idValid } = checked;
5281
+ if (!removeCredential) {
5282
+ if (engagementId === void 0)
5283
+ return { kind: "nothing-to-say" };
5284
+ if (!idValid)
5285
+ return { kind: "invalid-id" };
5286
+ const dir2 = engagementStateDir(engagementId, home);
5287
+ return lexists(dir2) ? { kind: "still-held", dir: dir2 } : { kind: "nothing-to-say" };
5288
+ }
5289
+ if (engagementId === void 0)
5290
+ return { kind: "no-project-id" };
5291
+ const dir = engagementStateDir(engagementId, home);
5292
+ if (!lexists(dir))
5293
+ return { kind: "not-held", dir };
5294
+ try {
5295
+ if (lstatSync(dir).isSymbolicLink())
5296
+ return { kind: "failed", dir, reason: "it is a symbolic link" };
5297
+ rmSync2(engagementEnvPath(engagementId, home), { force: true });
5298
+ rmSync2(notThisAccountMarkerPath(engagementId, home), { force: true });
5299
+ if (readdirSync(dir).length === 0)
5300
+ rmdirSync(dir);
5301
+ } catch (err) {
5302
+ return { kind: "failed", dir, reason: err instanceof Error ? err.message : String(err) };
5303
+ }
5304
+ return { kind: "removed", dir };
5305
+ }
5306
+ function filesUnder(root, rel) {
5307
+ const out = [];
5308
+ const walk = (dirRel) => {
5309
+ let names;
5310
+ try {
5311
+ if (!lstatSync(join10(root, dirRel)).isDirectory())
5312
+ return;
5313
+ names = readdirSync(join10(root, dirRel));
5314
+ } catch {
5315
+ return;
5316
+ }
5317
+ for (const entry of names.sort()) {
5318
+ const childRel = `${dirRel}/${entry}`;
5319
+ try {
5320
+ if (lstatSync(join10(root, childRel)).isDirectory())
5321
+ walk(childRel);
5322
+ else
5323
+ out.push(childRel);
5324
+ } catch {
5325
+ continue;
5326
+ }
5327
+ }
5328
+ };
5329
+ walk(rel);
5330
+ return out;
5331
+ }
5332
+ function shownPath(path, home) {
5333
+ const base = home ?? homedir2();
5334
+ const rel = relative3(base, path);
5335
+ const under = rel !== "" && !rel.startsWith("..") && !rel.startsWith(sep);
5336
+ return `${under ? `~/${rel.split(sep).join("/")}` : path}/`;
5337
+ }
5338
+ function renderUninstallReport(result, home) {
5339
+ if (result.outcome === "refused") {
5340
+ return { stdout: "", stderr: `halfcycle uninstall: ${result.refusal ?? "refused."}
5341
+ ` };
5342
+ }
5343
+ if (result.outcome === "not-installed") {
5344
+ return { stdout: "[halfcycle] Halfcycle is not installed in this directory, so there is nothing to remove.\n", stderr: "" };
5345
+ }
5346
+ const out = [];
5347
+ const section = (title, items) => {
5348
+ if (items.length === 0)
5349
+ return;
5350
+ out.push(`[halfcycle] ${title}`);
5351
+ for (const item of items)
5352
+ out.push(` ${item}`);
5353
+ };
5354
+ section("Removed from this project:", result.removed);
5355
+ section("Restored \u2014 only Halfcycle's entries were taken out:", result.restored);
5356
+ section("Kept, because it changed after Halfcycle wrote it \u2014 delete it yourself if you do not want it:", result.kept);
5357
+ section('Kept in .gitignore, because the path each line covers still exists \u2014 without the line, "git add ." would commit it:', result.keptIgnoreLines);
5358
+ section("Left alone \u2014 Halfcycle did not write these, or cannot tell that it did:", result.leftAlone);
5359
+ section('Could not remove \u2014 fix the cause, then run "npx halfcycle uninstall" again to finish:', result.failed);
5360
+ const c = result.credential;
5361
+ switch (c.kind) {
5362
+ case "still-held":
5363
+ out.push(`[halfcycle] This machine still holds this project's credential, at ${shownPath(c.dir, home)}.`);
5364
+ out.push(" Every checkout and worktree of this project on this machine uses it.");
5365
+ out.push(` To remove it too: npx halfcycle uninstall ${REMOVE_CREDENTIAL_FLAG}`);
5366
+ break;
5367
+ case "invalid-id":
5368
+ out.push(`[halfcycle] This machine may still hold a credential for this project, but the project id in ${BUNDLE_PIN_REL} is not valid, so it is not shown as a path.`);
5369
+ break;
5370
+ case "removed":
5371
+ out.push(`[halfcycle] Removed this machine's credential for this project, at ${shownPath(c.dir, home)}.`);
5372
+ out.push(" Every other checkout and worktree of this project on this machine used it too. Each stops reaching");
5373
+ out.push(" Halfcycle until `npx halfcycle` is run there again.");
5374
+ break;
5375
+ case "not-held":
5376
+ out.push(`[halfcycle] This machine holds no credential for this project (nothing at ${shownPath(c.dir, home)}).`);
5377
+ break;
5378
+ case "no-project-id":
5379
+ out.push(`[halfcycle] No credential was removed: ${BUNDLE_PIN_REL} is missing, so there is no project id to find it by.`);
5380
+ break;
5381
+ case "failed":
5382
+ out.push(`[halfcycle] Could not remove this machine's credential for this project, at ${shownPath(c.dir, home)}: ${c.reason}. Run "npx halfcycle uninstall ${REMOVE_CREDENTIAL_FLAG}" again to finish.`);
5383
+ break;
5384
+ case "nothing-to-say":
5385
+ break;
5386
+ }
5387
+ out.push("[halfcycle] Your project on Halfcycle is unchanged. Restart any Claude Code session open in this project.");
5388
+ return { stdout: out.join("\n") + "\n", stderr: "" };
5389
+ }
5390
+
4333
5391
  // dist/banner-facts.js
4334
- import { execFileSync as execFileSync3 } from "node:child_process";
4335
- import { readFileSync as readFileSync8 } from "node:fs";
4336
- import { basename as basename3, join as join10, resolve as resolve3 } from "node:path";
5392
+ import { execFileSync as execFileSync4 } from "node:child_process";
5393
+ import { readFileSync as readFileSync9 } from "node:fs";
5394
+ import { basename as basename3, join as join11, resolve as resolve3 } from "node:path";
4337
5395
  var BRAND_URL = "halfcycle.ai";
4338
5396
  var TAGLINE = [
4339
5397
  "Fewer cycles.",
@@ -4346,7 +5404,7 @@ var TAGLINE_NARROW = [
4346
5404
  ];
4347
5405
  function bundleVersion() {
4348
5406
  try {
4349
- const pkg = JSON.parse(readFileSync8(join10(BUNDLE_ROOT, "package.json"), "utf-8"));
5407
+ const pkg = JSON.parse(readFileSync9(join11(BUNDLE_ROOT, "package.json"), "utf-8"));
4350
5408
  return typeof pkg.version === "string" ? pkg.version : void 0;
4351
5409
  } catch {
4352
5410
  return void 0;
@@ -4354,7 +5412,7 @@ function bundleVersion() {
4354
5412
  }
4355
5413
  function claudeCodeVersion() {
4356
5414
  try {
4357
- const raw = execFileSync3("claude", ["--version"], {
5415
+ const raw = execFileSync4("claude", ["--version"], {
4358
5416
  encoding: "utf-8",
4359
5417
  timeout: 3e3,
4360
5418
  stdio: ["ignore", "pipe", "ignore"]
@@ -4466,7 +5524,7 @@ function compareVersions(a, b) {
4466
5524
  function reportClaudeCodeVersion(verbose2) {
4467
5525
  let raw;
4468
5526
  try {
4469
- raw = execFileSync4("claude", ["--version"], {
5527
+ raw = execFileSync5("claude", ["--version"], {
4470
5528
  encoding: "utf-8",
4471
5529
  timeout: 5e3,
4472
5530
  stdio: ["ignore", "pipe", "ignore"]
@@ -4498,23 +5556,36 @@ var USAGE = ` halfcycle [install] [target-repo] [engagement-id] [self-build|cli
4498
5556
  halfcycle build-record <phase> [--repo <root>]
4499
5557
  halfcycle open-phase <phase|--none> --actor "\u2026" (--evidence "\u2026" | --override --reason "\u2026") [--repo <root>]
4500
5558
  halfcycle close-phase <phase> --verdict <clean|defects> --actor "\u2026" [--finding "\u2026"]\u2026 [--override --reason "\u2026"] [--repo <root>]
4501
- halfcycle ci bind <owner>/<repo> [--repo <root>]
4502
- halfcycle ci unbind <owner>/<repo> [--repo <root>]
5559
+ halfcycle uninstall [${REMOVE_CREDENTIAL_FLAG}]
5560
+ take out what the installer added to this project, keeping anything you changed
5561
+ ${REMOVE_CREDENTIAL_FLAG}: also remove this machine's credential for the project, which every
5562
+ checkout and worktree of that project on this machine shares
4503
5563
 
4504
5564
  --quiet / -q: print no opening banner (any command)
4505
5565
  --verbose / -v: print full run detail (paths written, merged, skipped) on install
5566
+
5567
+ Support: hello@halfcycle.ai
4506
5568
  `;
4507
5569
  function isHalfcycleMonorepo(dir) {
4508
5570
  try {
4509
- const pkg = JSON.parse(readFileSync9(join11(dir, "package.json"), "utf-8"));
5571
+ const pkg = JSON.parse(readFileSync10(join12(dir, "package.json"), "utf-8"));
4510
5572
  return pkg.name === "halfcycle-monorepo";
4511
5573
  } catch {
4512
5574
  return false;
4513
5575
  }
4514
5576
  }
5577
+ var RETIRED_VERBS = /* @__PURE__ */ new Map([
5578
+ ["ci", '"ci" was removed \u2014 CI setup is part of your project, not Halfcycle']
5579
+ ]);
4515
5580
  async function main() {
4516
5581
  const [cmd, ...rest] = args;
4517
- const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !/^(check-drift|build-record|open-phase|close-phase|ci)$/.test(cmd);
5582
+ if (cmd !== void 0 && RETIRED_VERBS.has(cmd)) {
5583
+ process.stderr.write(`halfcycle: ${RETIRED_VERBS.get(cmd)}
5584
+ `);
5585
+ process.exit(2);
5586
+ return;
5587
+ }
5588
+ const bareTarget = cmd !== void 0 && !cmd.startsWith("-") && !CLI_VERBS.includes(cmd);
4518
5589
  const installArm = cmd === "install" || cmd === void 0 || bareTarget;
4519
5590
  const positionals = cmd === "install" ? rest : args;
4520
5591
  const targetRepo = installArm ? positionals[0] ?? process.cwd() : process.cwd();
@@ -4530,6 +5601,12 @@ ${USAGE}`);
4530
5601
  if (installArm) {
4531
5602
  const engagementIdArg = positionals[1];
4532
5603
  const engagementTypeRaw = positionals[2] ?? "client";
5604
+ if (!existsSync5(targetRepo) || !statSync(targetRepo).isDirectory()) {
5605
+ process.stderr.write(`halfcycle: no such directory: ${targetRepo}
5606
+ `);
5607
+ process.exit(1);
5608
+ return;
5609
+ }
4533
5610
  if (isHalfcycleMonorepo(targetRepo)) {
4534
5611
  process.stderr.write(`halfcycle: refusing to install into the Halfcycle monorepo itself (${targetRepo}).
4535
5612
  The bare form installs into the CURRENT directory, and this directory is the
@@ -4553,6 +5630,8 @@ ${USAGE}`);
4553
5630
  process.stdout.write(`[halfcycle] Halfcycle service: ${controlOriginNote(controlOrigin)}
4554
5631
  `);
4555
5632
  }
5633
+ if (engagementIdArg !== void 0)
5634
+ assertEngagementId(engagementIdArg);
4556
5635
  const pinned = readPinnedEngagement(targetRepo);
4557
5636
  const requestedId = engagementIdArg ?? pinned?.engagementId;
4558
5637
  const held = pinned !== null && pinned.engagementId === requestedId ? pinned.credential : void 0;
@@ -4716,8 +5795,6 @@ ${USAGE}`);
4716
5795
  process.stdout.write(`[halfcycle] Next: open this folder in Claude Code \u2014 accept the workspace-trust prompt, it is expected \u2014 then run /halfcycle-setup and answer what it asks about your project
4717
5796
  `);
4718
5797
  process.stdout.write(`[halfcycle] Claude Code will also ask permission the first time Halfcycle needs to look up your next step. Choose allow \u2014 without it nothing can run.
4719
- `);
4720
- process.stdout.write(`[halfcycle] To check changes in CI too: run \`npx halfcycle ci bind <owner>/<repo>\` in this folder once, then copy the workflow in .halfcycle/ci-stanza.yml into .github/workflows/ (or just its job into a workflow you have) \u2014 there is no secret to store.
4721
5798
  `);
4722
5799
  }
4723
5800
  if (credential) {
@@ -4740,6 +5817,29 @@ ${USAGE}`);
4740
5817
  return;
4741
5818
  }
4742
5819
  process.stderr.write(`halfcycle install failed: ${err instanceof Error ? err.message : String(err)}
5820
+ `);
5821
+ process.exit(1);
5822
+ }
5823
+ return;
5824
+ }
5825
+ if (cmd === "uninstall") {
5826
+ const parsed = parseUninstallArgs(rest);
5827
+ if ("error" in parsed) {
5828
+ process.stderr.write(`halfcycle uninstall: ${parsed.error}
5829
+
5830
+ Usage:
5831
+ ${USAGE}`);
5832
+ process.exit(2);
5833
+ return;
5834
+ }
5835
+ try {
5836
+ const result = uninstall({ targetRepo: process.cwd(), removeCredential: parsed.removeCredential });
5837
+ const { stdout, stderr } = renderUninstallReport(result);
5838
+ process.stdout.write(stdout);
5839
+ process.stderr.write(stderr);
5840
+ process.exit(result.exitCode);
5841
+ } catch (err) {
5842
+ process.stderr.write(`halfcycle uninstall failed: ${err instanceof Error ? err.message : String(err)}
4743
5843
  `);
4744
5844
  process.exit(1);
4745
5845
  }
@@ -4782,12 +5882,12 @@ ${USAGE}`);
4782
5882
  const phaseId = phaseArg;
4783
5883
  try {
4784
5884
  assertValidPhaseIdentity(phaseId, BUILD_RECORD_DIR);
4785
- const inputPath = join11(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phaseId)}.input.json`);
4786
- const input = JSON.parse(readFileSync9(inputPath, "utf-8"));
5885
+ const inputPath = join12(repoRoot, BUILD_RECORD_ZONE_B_DIR, `${renderPhaseSegment(phaseId)}.input.json`);
5886
+ const input = JSON.parse(readFileSync10(inputPath, "utf-8"));
4787
5887
  const result = assemblePhaseBuildRecord({
4788
5888
  repoRoot,
4789
- phasesDir: join11(repoRoot, "docs", "phases"),
4790
- guardEvalLogDir: input.guardEvalLogDir ?? join11(repoRoot, ".workbench", "guard-eval-log"),
5889
+ phasesDir: join12(repoRoot, "docs", "phases"),
5890
+ guardEvalLogDir: input.guardEvalLogDir ?? join12(repoRoot, ".workbench", "guard-eval-log"),
4791
5891
  phaseId,
4792
5892
  narrated: input.narrated,
4793
5893
  touchedInvariants: input.touchedInvariants
@@ -4976,69 +6076,6 @@ halfcycle close-phase: until ${closed.stampFailure.path} can be written, guard e
4976
6076
  return;
4977
6077
  }
4978
6078
  process.stderr.write(`halfcycle close-phase failed: ${err instanceof Error ? err.message : String(err)}
4979
- `);
4980
- process.exit(1);
4981
- }
4982
- return;
4983
- }
4984
- if (cmd === "ci") {
4985
- const repoRoot = flagValue(rest, "--repo") ?? process.cwd();
4986
- const positionals2 = rest.filter((token, i) => !token.startsWith("--") && !(i > 0 && rest[i - 1] === "--repo"));
4987
- const action = positionals2[0];
4988
- const repositoryArg = positionals2[1];
4989
- if (action !== "bind" && action !== "unbind") {
4990
- process.stderr.write("halfcycle ci: name bind or unbind.\n halfcycle ci bind <owner>/<repo> [--repo <root>]\n halfcycle ci unbind <owner>/<repo> [--repo <root>]\n");
4991
- process.exit(2);
4992
- return;
4993
- }
4994
- if (repositoryArg === void 0) {
4995
- process.stderr.write(`halfcycle ci ${action}: name the repository, as owner/repo.
4996
- `);
4997
- process.exit(2);
4998
- return;
4999
- }
5000
- const repository = parseCiRepositoryArg(repositoryArg);
5001
- if (repository === null) {
5002
- process.stderr.write(`halfcycle ci ${action}: "${repositoryArg}" is not a repository \u2014 give it as owner/repo, exactly as GitHub spells it, for example acme/widgets.
5003
- `);
5004
- process.exit(2);
5005
- return;
5006
- }
5007
- const pin = readBundlePin(repoRoot);
5008
- if (pin === null || pin.engagementId === "") {
5009
- process.stderr.write(`halfcycle ci ${action}: ${join11(repoRoot, ".halfcycle", "bundle.json")} names no Halfcycle engagement. Run \`npx halfcycle\` here first.
5010
- `);
5011
- process.exit(1);
5012
- return;
5013
- }
5014
- try {
5015
- const controlOrigin = resolveControlOrigin(process.env);
5016
- const label = `${repository.owner}/${repository.repo}`;
5017
- if (action === "bind") {
5018
- await bindCiRepository(controlOrigin.origin, pin.engagementId, repository);
5019
- process.stdout.write(`[halfcycle] ${label} now trusts this engagement's CI \u2014 a workflow there granting both \`contents: read\` and \`id-token: write\` can authenticate with no stored secret. Both lines: declaring any permission replaces the defaults, so naming only the identity token stops a private repository checking out.
5020
- `);
5021
- } else {
5022
- await unbindCiRepository(controlOrigin.origin, pin.engagementId, repository);
5023
- process.stdout.write(`[halfcycle] ${label} no longer trusts this engagement's CI.
5024
- `);
5025
- }
5026
- process.exit(0);
5027
- } catch (err) {
5028
- if (err instanceof SignInRefused) {
5029
- process.stderr.write(`halfcycle ci ${action}: ${err.message}
5030
- `);
5031
- process.exit(1);
5032
- return;
5033
- }
5034
- if (err instanceof CiBindRefused) {
5035
- const label = err.unavailable ? `temporarily unavailable (${err.status})` : `refused (${err.status})`;
5036
- process.stderr.write(`halfcycle ci ${action}: ${label}. ${err.message}
5037
- `);
5038
- process.exit(1);
5039
- return;
5040
- }
5041
- process.stderr.write(`halfcycle ci ${action} failed: ${err instanceof Error ? err.message : String(err)}
5042
6079
  `);
5043
6080
  process.exit(1);
5044
6081
  }
@@ -5072,6 +6109,7 @@ function firstPositional(argv) {
5072
6109
  }
5073
6110
  main().catch((err) => {
5074
6111
  process.stderr.write(`halfcycle: unexpected error: ${err instanceof Error ? err.message : String(err)}
6112
+ Contact hello@halfcycle.ai if this keeps happening.
5075
6113
  `);
5076
6114
  process.exit(1);
5077
6115
  });