halfcycle 0.4.3 → 0.4.4

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halfcycle",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Halfcycle Method bundle — resolution-stub slash commands, remote method-delivery registration, and governance-hook wiring for a Halfcycle engagement repo. It ships NO worker role files: a project's roles are authored from that project's own recorded decisions at orchestration kickoff (FX-2, W3-F-27).",
5
5
  "commands": [
6
6
  {
@@ -17520,7 +17520,8 @@ function deleteMarker(repoRoot, sessionId) {
17520
17520
  var SESSION_NOTICE_SUFFIX = {
17521
17521
  "no-repository": ".norepo",
17522
17522
  "credential-rejected": ".credential",
17523
- "credential-renewed-for-this-run": ".renewed"
17523
+ "credential-renewed-for-this-run": ".renewed",
17524
+ "credential-missing": ".nocredential"
17524
17525
  };
17525
17526
  function firstNoticeThisSession(notice, folder, sessionId) {
17526
17527
  if (!sessionId)
@@ -17810,6 +17811,8 @@ async function evaluateEdit(reply, readSeat) {
17810
17811
  const configResult = readConfig();
17811
17812
  if (!configResult.config) {
17812
17813
  await emitRunRecord({ runType: "hook", outcome: "unconfigured", changeSet });
17814
+ if (!firstNoticeThisSession("credential-missing", repoRoot, input.session_id))
17815
+ return;
17813
17816
  emitNeverConfigured(UNCONFIGURED_EXIT_TOOL_USE, readNeverConfiguredReason());
17814
17817
  return;
17815
17818
  }
@@ -17989,6 +17992,8 @@ async function evaluateSession(input, kind, reply, readSeat) {
17989
17992
  const configResult = readConfig();
17990
17993
  if (!configResult.config) {
17991
17994
  await emitRunRecord({ runType: "hook", outcome: "unconfigured", changeSet, diffBase });
17995
+ if (!firstNoticeThisSession("credential-missing", repoRoot, sessionId))
17996
+ return;
17992
17997
  emitNeverConfigured(UNCONFIGURED_EXIT_STOP_FAMILY, readNeverConfiguredReason());
17993
17998
  return;
17994
17999
  }
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // dist/bin.js
4
- import { existsSync as existsSync7, readFileSync as readFileSync11, realpathSync as realpathSync3, statSync } from "node:fs";
4
+ import { existsSync as existsSync7, readFileSync as readFileSync11, realpathSync as realpathSync3, statSync, writeSync as writeSync2 } from "node:fs";
5
5
  import { homedir as homedir3 } from "node:os";
6
6
  import { join as join12 } from "node:path";
7
7
 
@@ -1648,12 +1648,20 @@ function createProjectIdentityFile(targetRepoRoot) {
1648
1648
  }
1649
1649
 
1650
1650
  // dist/install-manifest.js
1651
+ import { Buffer as Buffer2 } from "node:buffer";
1651
1652
  import { createHash } from "node:crypto";
1652
1653
  var INSTALL_MANIFEST_REL = ".halfcycle/install-manifest.json";
1653
1654
  var INSTALL_MANIFEST_FORMAT = "halfcycle-install-manifest/v1";
1654
1655
  function sha256Hex(bytes) {
1655
1656
  return createHash("sha256").update(bytes).digest("hex");
1656
1657
  }
1658
+ function lineKey(line) {
1659
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
1660
+ }
1661
+ function fileSha256(bytes) {
1662
+ const text = typeof bytes === "string" ? bytes : Buffer2.from(bytes).toString("utf-8");
1663
+ return sha256Hex(text.split("\n").map(lineKey).join("\n"));
1664
+ }
1657
1665
  function canonicalJson(value) {
1658
1666
  if (Array.isArray(value))
1659
1667
  return value.map(canonicalJson);
@@ -1836,7 +1844,7 @@ function startManifest(previous, isMember, isMemberDir) {
1836
1844
  };
1837
1845
  }
1838
1846
  function recordInstalledFile(draft, path, bytes) {
1839
- draft.files.set(path, { path, sha256: sha256Hex(bytes) });
1847
+ draft.files.set(path, { path, sha256: fileSha256(bytes) });
1840
1848
  }
1841
1849
  function recordRemovedFile(draft, path) {
1842
1850
  draft.files.delete(path);
@@ -2155,17 +2163,17 @@ function sha256OfWhatThisVersionWrites(commands) {
2155
2163
  const out = /* @__PURE__ */ new Map();
2156
2164
  const fromFile = (rel, src) => {
2157
2165
  if (existsSync3(src))
2158
- out.set(rel, sha256Hex(readFileSync5(src)));
2166
+ out.set(rel, fileSha256(readFileSync5(src)));
2159
2167
  };
2160
2168
  for (const cmd of commands)
2161
2169
  fromFile(commandStubRel(cmd.name), join5(BUNDLE_ROOT, ".claude-plugin", cmd.path));
2162
2170
  for (const [rel, content] of hookScriptsThisVersionWrites())
2163
- out.set(rel, sha256Hex(content));
2171
+ out.set(rel, fileSha256(content));
2164
2172
  fromFile(CAPTURED_INDEX_REL, capturedIndexScaffold());
2165
2173
  return out;
2166
2174
  }
2167
2175
  function bytesAreOurs(path, bytes, recorded, writeSet) {
2168
- const hash = sha256Hex(bytes);
2176
+ const hash = fileSha256(bytes);
2169
2177
  const recordedHash = recorded !== void 0 && "sha256" in recorded ? recorded.sha256 : void 0;
2170
2178
  return hash === recordedHash || hash === writeSet.generatedSha256.get(path);
2171
2179
  }
@@ -2353,16 +2361,17 @@ function writeCollisionSafe(targetAbsPath, targetRepoRoot, content, isOurs) {
2353
2361
  throw new Error(`[halfcycle] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
2354
2362
  }
2355
2363
  let replacedExisting = false;
2356
- if (existsSync3(targetAbsPath)) {
2357
- const current = readFileSync5(targetAbsPath);
2358
- if (current.toString("utf-8") === content)
2364
+ const current = existsSync3(targetAbsPath) ? readFileSync5(targetAbsPath) : null;
2365
+ const text = inLineEndingOf(current?.toString("utf-8") ?? null, content);
2366
+ if (current !== null) {
2367
+ if (current.toString("utf-8") === text)
2359
2368
  return { outcome: "skipped", replacedExisting: false };
2360
2369
  if (!isOurs(current))
2361
2370
  return { outcome: "collided", replacedExisting: false };
2362
2371
  replacedExisting = true;
2363
2372
  }
2364
2373
  mkdirSync5(dirname3(targetAbsPath), { recursive: true });
2365
- writeFileSync5(targetAbsPath, content, "utf-8");
2374
+ writeFileSync5(targetAbsPath, text, "utf-8");
2366
2375
  return { outcome: "written", replacedExisting };
2367
2376
  }
2368
2377
  var INSTALLER_OWNED_DIR = ".halfcycle/";
@@ -2374,11 +2383,12 @@ function writeOwned(targetAbsPath, targetRepoRoot, content) {
2374
2383
  if (!rel.startsWith(INSTALLER_OWNED_DIR)) {
2375
2384
  throw new Error(`[halfcycle] Path-ownership violation: attempted to claim "${rel}" by path. Only ${INSTALLER_OWNED_DIR} is owned outright by this installer; everywhere else a differing file may be a developer's, so ownership must be proved from the file's own contents (writeOwnedGenerated) rather than assumed from where it sits.`);
2376
2385
  }
2377
- if (existsSync3(targetAbsPath) && readFileSync5(targetAbsPath, "utf-8") === content) {
2386
+ const current = readFileOrNull(targetAbsPath);
2387
+ const text = inLineEndingOf(current, content);
2388
+ if (current === text)
2378
2389
  return "skipped";
2379
- }
2380
2390
  mkdirSync5(dirname3(targetAbsPath), { recursive: true });
2381
- writeFileSync5(targetAbsPath, content, "utf-8");
2391
+ writeFileSync5(targetAbsPath, text, "utf-8");
2382
2392
  return "written";
2383
2393
  }
2384
2394
  var GENERATED_HEADER_LINE_INDEX = 1;
@@ -2780,13 +2790,13 @@ function adoptBlockUnderHeader(before, header, claimable) {
2780
2790
  }
2781
2791
  return claimed.length > 0 ? claimed.join("\n") + "\n" : "";
2782
2792
  }
2783
- function lineKey(line) {
2784
- return line.endsWith("\r") ? line.slice(0, -1) : line;
2785
- }
2786
2793
  function lineEndingOf(text) {
2787
2794
  const at = text.indexOf("\n");
2788
2795
  return at > 0 && text[at - 1] === "\r" ? "\r\n" : "\n";
2789
2796
  }
2797
+ function inLineEndingOf(existing, generated) {
2798
+ return existing === null ? generated : generated.split("\n").join(lineEndingOf(existing));
2799
+ }
2790
2800
  function gitignoreCovers(content) {
2791
2801
  const lines = new Set(content.split("\n").map((l) => l.trim()));
2792
2802
  return REQUIRED_GITIGNORE_ENTRIES.every((entry) => lines.has(entry));
@@ -2879,7 +2889,8 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, c
2879
2889
  ...projectPage !== void 0 ? { projectPage } : {}
2880
2890
  };
2881
2891
  const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
2882
- writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
2892
+ const pinText = inLineEndingOf(readFileOrNull(pinPath), JSON.stringify(pin, null, 2) + "\n");
2893
+ writeAllowlisted(pinPath, targetRepoRoot, pinText, writtenPaths);
2883
2894
  }
2884
2895
  function writeCrewRoster(targetRepoRoot, report) {
2885
2896
  const doc = {
@@ -3068,7 +3079,7 @@ async function install(options) {
3068
3079
  }
3069
3080
  recordSettingsMerge(draft, existingSettings, generatedSettings, settingsPreexisted);
3070
3081
  const mergedSettings = mergeSettings(existingSettings, generatedSettings);
3071
- const mergedSettingsText = JSON.stringify(mergedSettings, null, 2) + "\n";
3082
+ const mergedSettingsText = inLineEndingOf(readFileOrNull(settingsPath), JSON.stringify(mergedSettings, null, 2) + "\n");
3072
3083
  mkdirSync5(dirname3(settingsPath), { recursive: true });
3073
3084
  writeFileSync5(settingsPath, mergedSettingsText, "utf-8");
3074
3085
  (settingsPreexisted ? report.mergedPaths : report.writtenPaths).push(".claude/settings.json");
@@ -3096,7 +3107,7 @@ async function install(options) {
3096
3107
  if (credential) {
3097
3108
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
3098
3109
  const existingMcp = existsSync3(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
3099
- const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
3110
+ const mcpContent = inLineEndingOf(existingMcp, generateMcpRegistration(existingMcp, credential.mcpUrl));
3100
3111
  recordMcpMerge(draft, existingMcp, mcpContent);
3101
3112
  if (existingMcp === null) {
3102
3113
  writeAllowlisted(mcpPath, targetRepo, mcpContent, report.writtenPaths);
@@ -4201,13 +4212,12 @@ async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
4201
4212
  return (await askAccount(serviceUrl, credential, fetchImpl)).identity;
4202
4213
  }
4203
4214
  function accountLabel(identity, storedAccountId, verbose2 = false) {
4204
- const accountId = identity?.accountId ?? storedAccountId;
4205
- if (identity?.email !== void 0 && identity.email.trim() !== "") {
4206
- return verbose2 && accountId !== void 0 ? `${identity.email} (account ${accountId})` : identity.email;
4215
+ if (identity === void 0)
4216
+ return void 0;
4217
+ if (identity.email !== void 0 && identity.email.trim() !== "") {
4218
+ return verbose2 ? `${identity.email} (account ${identity.accountId})` : identity.email;
4207
4219
  }
4208
- if (accountId !== void 0)
4209
- return `account ${accountId} (no email on record for it)`;
4210
- return "the account this credential belongs to, which this service did not name";
4220
+ return `account ${identity.accountId} (no email on record for it)`;
4211
4221
  }
4212
4222
  function onlyDate(iso) {
4213
4223
  if (iso === void 0)
@@ -4253,7 +4263,11 @@ function actingAs(credential, identity) {
4253
4263
  function question(colour) {
4254
4264
  return `[halfcycle] Continue as that account? ${ink("good", "[y]", colour)} yes ${ink("key", "[s]", colour)} sign in as someone else ${ink("faint", "[anything else]", colour)} stop: `;
4255
4265
  }
4256
- function armScript(credential, act, label, plainLabel, home) {
4266
+ function accountLine(identity, label) {
4267
+ return identity === void 0 ? "" : `[halfcycle] Account: ${label}
4268
+ `;
4269
+ }
4270
+ function armScript(credential, act, accountLineText, plainLabel, home) {
4257
4271
  switch (credential.source) {
4258
4272
  case "browser-handoff":
4259
4273
  return {
@@ -4265,11 +4279,11 @@ function armScript(credential, act, label, plainLabel, home) {
4265
4279
  stopRemedy: "",
4266
4280
  afterSwitch: ""
4267
4281
  };
4268
- case "environment":
4282
+ case "environment": {
4283
+ const credentialSentence = plainLabel !== void 0 ? `HALFCYCLE_TOKEN in this environment is the credential for ${plainLabel}, and it takes precedence over any saved sign-in` : `The credential in your environment could not be checked, because the Halfcycle service did not answer, and it takes precedence over any saved sign-in`;
4269
4284
  return {
4270
4285
  preamble: `[halfcycle] HALFCYCLE_TOKEN is set in this environment, and this install will use it.
4271
- [halfcycle] Account: ${label}
4272
- [halfcycle] About to ${actClause(act)}
4286
+ ` + accountLineText + `[halfcycle] About to ${actClause(act)}
4273
4287
  [halfcycle] A variable exported by a shell profile is not a choice about this run \u2014 if that is not your account, ${wrongAccountConsequence(act)}.
4274
4288
  `,
4275
4289
  asks: true,
@@ -4279,19 +4293,20 @@ function armScript(credential, act, label, plainLabel, home) {
4279
4293
  noTerminalNote: "",
4280
4294
  switchOpening: `[halfcycle] Signing you in through your browser \u2014 this install will use that account instead.
4281
4295
  `,
4282
- stopRemedy: `Stopped before anything was set up. HALFCYCLE_TOKEN in this environment is the credential for ${plainLabel}, and it takes precedence over any saved sign-in \u2014 so unset it, or set it to your own account's credential, before running this again.`,
4296
+ stopRemedy: `Stopped before anything was set up. ${credentialSentence} \u2014 so unset it, or set it to your own account's credential, before running this again.`,
4283
4297
  // The switch is real for THIS run (the new credential is what gets used) and it
4284
4298
  // does not persist: `obtainCredential` reads the variable first, so the next run
4285
4299
  // is the same variable again. Said here rather than discovered tomorrow.
4286
4300
  afterSwitch: `[halfcycle] HALFCYCLE_TOKEN is still set in this environment and still wins over a saved sign-in \u2014 unset it, or the next run will use it again.
4287
4301
  `
4288
4302
  };
4303
+ }
4289
4304
  case "stored": {
4290
4305
  const savedOn = onlyDate(credential.signedInAt);
4306
+ const savedSignInSentence = plainLabel !== void 0 ? `This machine's saved sign-in is for ${plainLabel}.` : `This machine's saved sign-in could not be checked, because the Halfcycle service did not answer.`;
4291
4307
  return {
4292
4308
  preamble: `[halfcycle] This machine already has a Halfcycle sign-in saved, and this install will use it.
4293
- [halfcycle] Account: ${label}
4294
- ` + (savedOn !== void 0 ? `[halfcycle] Saved on this machine: ${savedOn}
4309
+ ` + accountLineText + (savedOn !== void 0 ? `[halfcycle] Saved on this machine: ${savedOn}
4295
4310
  ` : "") + `[halfcycle] About to ${actClause(act)}
4296
4311
  [halfcycle] If that is not your account, ${wrongAccountConsequence(act)}${wrongAccountVisibility(act) === void 0 ? "" : ` \u2014 ${wrongAccountVisibility(act)}`}.
4297
4312
  `,
@@ -4305,7 +4320,7 @@ function armScript(credential, act, label, plainLabel, home) {
4305
4320
  `,
4306
4321
  switchOpening: `[halfcycle] Forgetting this machine's saved sign-in and signing you in through your browser.
4307
4322
  `,
4308
- stopRemedy: `Stopped before anything was set up. This machine's saved sign-in is for ${plainLabel}. Run this again and answer "s" to sign in as a different account, or remove ${accountStorePath(home)} to be asked for a sign-in next time.`,
4323
+ stopRemedy: `Stopped before anything was set up. ${savedSignInSentence} Run this again and answer "s" to sign in as a different account, or remove ${accountStorePath(home)} to be asked for a sign-in next time.`,
4309
4324
  afterSwitch: ""
4310
4325
  };
4311
4326
  }
@@ -4320,9 +4335,9 @@ async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
4320
4335
  const colour = deps.colour ?? decideColour({ isTTY: process.stdout.isTTY === true, env: deps.env ?? process.env });
4321
4336
  const identity = await describeAccount(serviceUrl, credential.credential, deps.fetchImpl);
4322
4337
  const plainLabel = accountLabel(identity, credential.accountId, deps.verbose === true);
4323
- const label = ink("keyBold", plainLabel, colour);
4338
+ const label = plainLabel !== void 0 ? ink("keyBold", plainLabel, colour) : "";
4324
4339
  const acting = actingAs(credential, identity);
4325
- const script = armScript(credential, act, label, plainLabel, deps.home);
4340
+ const script = armScript(credential, act, accountLine(identity, label), plainLabel, deps.home);
4326
4341
  write(script.preamble);
4327
4342
  if (!script.asks)
4328
4343
  return acting;
@@ -6164,7 +6179,7 @@ function applyMergedJson(targetRepo, rule, file, deleteWhenEmpty, lists, name, e
6164
6179
  if (!changed)
6165
6180
  return;
6166
6181
  const empty = Object.keys(value).length === 0 && deleteWhenEmpty;
6167
- writeBack(targetRepo, rel, file.text, empty ? null : serializeJson(value), lists, name);
6182
+ writeBack(targetRepo, rel, file.text, empty ? null : inLineEndingOf(file.text, serializeJson(value)), lists, name);
6168
6183
  if (adopted && lexists(join10(targetRepo, rel))) {
6169
6184
  name(lists.leftAlone, `${rel} (Halfcycle's hooks were taken out. There is no record of this file from before the install, so a permission rule or "$schema" Halfcycle added may still be there \u2014 it cannot be told apart from your own)`, rel);
6170
6185
  }
@@ -6608,14 +6623,21 @@ ${USAGE}`);
6608
6623
  process.exit(1);
6609
6624
  return;
6610
6625
  }
6626
+ let interrupted = false;
6611
6627
  const onInstallSigint = () => {
6628
+ if (interrupted)
6629
+ return;
6630
+ interrupted = true;
6612
6631
  const kept = readFolderId(targetRepo) !== void 0 ? `${KEPT_FOLDER_ID_LINE}
6613
6632
  ` : "";
6614
- process.stderr.write(`halfcycle: interrupted.
6633
+ try {
6634
+ writeSync2(2, `halfcycle: interrupted.
6615
6635
  ${kept}`);
6636
+ } catch {
6637
+ }
6616
6638
  process.exit(1);
6617
6639
  };
6618
- process.once("SIGINT", onInstallSigint);
6640
+ process.on("SIGINT", onInstallSigint);
6619
6641
  try {
6620
6642
  const controlOrigin = resolveControlOrigin(process.env);
6621
6643
  const serviceUrl = controlOrigin.origin;