halfcycle 0.3.25 → 0.3.26

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.3.25",
3
+ "version": "0.3.26",
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
  {
@@ -15142,14 +15142,25 @@ function emitNeverConfigured(missing, exitCode, reason) {
15142
15142
  var HOOK_REMEDY = `Fix it by running "npx halfcycle" in this repository: it may open your browser to sign in first, then writes this engagement's credentials to ~/.halfcycle \u2014 outside the repository \u2014 and the hook loads them itself.`;
15143
15143
  var PERMISSIONS_BLOCK = "\n permissions:\n contents: read\n id-token: write\n\n";
15144
15144
  var BOTH_LINES_SENTENCE = "BOTH LINES. Declaring any permission replaces the defaults rather than adding to them, so a block naming only the identity token takes read access away from the checkout step and a private repository stops checking out before this check is reached.";
15145
- var CI_REMEDY = "This job cannot vouch for anything, because it has no way to prove which project it belongs to. Add this to the workflow \u2014 at the top of the file, or on this job:\n" + PERMISSIONS_BLOCK + `${BOTH_LINES_SENTENCE} Then, once, on your own machine in this project, if you have not already: \`npx halfcycle ci bind <owner>/<repo>\`. That is the whole configuration \u2014 there is no secret to store. The older setup, GUARD_SERVICE_TOKEN and GUARD_ENGAGEMENT_ID supplied as repository secrets, still works unchanged, and a job that has both needs neither line.`;
15145
+ var CI_CANNOT_PROVE_PROJECT = "has no way to prove which project it belongs to";
15146
+ var CI_REMEDY_LEAD = "Add this to the workflow \u2014 at the top of the file, or on this job:\n" + PERMISSIONS_BLOCK + `${BOTH_LINES_SENTENCE} Then, once, on your own machine in this project, if you have not already: \`npx halfcycle ci bind <owner>/<repo>\`. That is the whole configuration \u2014 there is no secret to store.`;
15147
+ var CI_OLDER_SETUP = "The older setup, GUARD_SERVICE_TOKEN and GUARD_ENGAGEMENT_ID supplied as repository secrets, still works unchanged, and a job that has both needs neither line.";
15148
+ var CI_REMEDY = `This job cannot vouch for anything, because it ${CI_CANNOT_PROVE_PROJECT}. ${CI_REMEDY_LEAD} ${CI_OLDER_SETUP}`;
15149
+ var NEVER_CONFIGURED_HEADLINE = "GUARD NEVER CONFIGURED";
15150
+ var NOT_A_SERVICE_OUTAGE = "This is not a service outage.";
15146
15151
  function neverConfiguredMessage(missing, remedy, label = "[Halfcycle]") {
15147
- return `${label} GUARD NEVER CONFIGURED \u2014 this repository has no guard coverage.
15152
+ return `${label} ${NEVER_CONFIGURED_HEADLINE} \u2014 this repository has no guard coverage.
15148
15153
  No evaluation was attempted, because there is nothing to attempt one with: ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} absent.
15149
- This is not a service outage. It means the guard has never run here \u2014 not on this change, and not on any change before it.
15154
+ ${NOT_A_SERVICE_OUTAGE} It means the guard has never run here \u2014 not on this change, and not on any change before it.
15150
15155
  ${remedy}
15151
15156
  `;
15152
15157
  }
15158
+ function ciNeverConfiguredMessage() {
15159
+ return `[Halfcycle CI] ${NEVER_CONFIGURED_HEADLINE} \u2014 this job ${CI_CANNOT_PROVE_PROJECT}, so it checked nothing. ${CI_REMEDY_LEAD}
15160
+ ${NOT_A_SERVICE_OUTAGE} Nothing was sent: this job holds no credential and was not permitted to ask GitHub for a signed identity token. It speaks for this job on this change only \u2014 other jobs and earlier changes it did not see.
15161
+ ${CI_OLDER_SETUP}
15162
+ `;
15163
+ }
15153
15164
  function notThisAccountReport(label) {
15154
15165
  return `${label} This project belongs to another Halfcycle account. Halfcycle is Solo today \u2014 one person works on a project at a time \u2014 so no credential exists for this machine and none will be issued. Nothing here needs fixing; this copy simply is not checked. Ask the project's owner for view access, or \u2014 if this account is wrong \u2014 sign in as the one that owns the project and run "npx halfcycle" again.
15155
15166
  `;
@@ -15867,7 +15878,7 @@ function parseRawSql(line) {
15867
15878
  }
15868
15879
  function parseQueryBuilder(rawLine) {
15869
15880
  const line = stripDiffPrefix(rawLine);
15870
- const m = line.match(/['"]([\w]+)['"]\s*\)\s*\.(insert|update|upsert|delete)\s*\(/i);
15881
+ const m = line.match(/(?<!\bcreate(?:Hash|Hmac|Sign|Verify|Cipheriv|Decipheriv)\s*\([^)]*)['"]([\w]+)['"]\s*\)\s*\.(insert|update|upsert|delete)\s*\(/i);
15871
15882
  if (m) {
15872
15883
  const kind = m[2].toLowerCase();
15873
15884
  return { kind, table: m[1].toLowerCase(), whereColumns: [] };
@@ -16433,6 +16444,22 @@ function isCodeFile(filePath) {
16433
16444
  }
16434
16445
  return true;
16435
16446
  }
16447
+ var INSTALLER_OUTPUT_DIR = ".halfcycle/";
16448
+ function isInstallerOutput(relPath) {
16449
+ const normalised = relPath.replace(/\\/g, "/");
16450
+ return normalised === INSTALLER_OUTPUT_DIR.slice(0, -1) || normalised.startsWith(INSTALLER_OUTPUT_DIR);
16451
+ }
16452
+ function partitionInstallerOutput(files) {
16453
+ const evaluated = [];
16454
+ const installerOutput = [];
16455
+ for (const file2 of files) {
16456
+ if (isInstallerOutput(file2))
16457
+ installerOutput.push(file2);
16458
+ else
16459
+ evaluated.push(file2);
16460
+ }
16461
+ return { evaluated, installerOutput };
16462
+ }
16436
16463
  function buildChangeSet(files, context = {}) {
16437
16464
  const excerptConfig = context.excerpts;
16438
16465
  const excerptsEnabled = excerptConfig?.enabled === true;
@@ -16722,7 +16749,9 @@ async function emitRunRecord(opts) {
16722
16749
  });
16723
16750
  await emitTelemetry(run, {
16724
16751
  controlTelemetryUrl: telemetryConfig.controlTelemetryUrl,
16725
- controlTelemetryToken: opts.credential ?? telemetryConfig.controlTelemetryToken,
16752
+ // No bearer at all on a refused credential: `emitTelemetry` skips the POST
16753
+ // when it has nothing to authenticate with, which is the true state here.
16754
+ controlTelemetryToken: opts.credentialRefused ? void 0 : opts.credential ?? telemetryConfig.controlTelemetryToken,
16726
16755
  guardEvalLogDir: telemetryConfig.guardEvalLogDir
16727
16756
  }, { warn: (msg) => process.stderr.write(msg + "\n") });
16728
16757
  }
@@ -16750,11 +16779,13 @@ async function runCi() {
16750
16779
  `);
16751
16780
  return 1;
16752
16781
  }
16753
- const changedFiles = changedFilesResult.value;
16754
- process.stdout.write(`[Halfcycle CI] Diff base ${mergeBase.slice(0, 12)} \u2014 ${describeDiffBase(diffBase)}. ${changedFiles.length} file(s) to evaluate.
16782
+ const { evaluated: changedFiles, installerOutput } = partitionInstallerOutput(changedFilesResult.value);
16783
+ const setAside = installerOutput.length === 1 ? ` 1 more under ${INSTALLER_OUTPUT_DIR} is Halfcycle's own file and is not evaluated.` : installerOutput.length > 1 ? ` ${installerOutput.length} more under ${INSTALLER_OUTPUT_DIR} are Halfcycle's own files and are not evaluated.` : "";
16784
+ process.stdout.write(`[Halfcycle CI] Diff base ${mergeBase.slice(0, 12)} \u2014 ${describeDiffBase(diffBase)}. ${changedFiles.length} file(s) to evaluate.${setAside}
16755
16785
  `);
16756
16786
  if (changedFiles.length === 0) {
16757
- process.stdout.write(`[Halfcycle CI] No changed files against the diff base \u2014 nothing to evaluate.
16787
+ process.stdout.write(installerOutput.length > 0 ? `[Halfcycle CI] Nothing to evaluate: every changed file is under ${INSTALLER_OUTPUT_DIR}, which Halfcycle writes itself and does not check.
16788
+ ` : `[Halfcycle CI] No changed files against the diff base \u2014 nothing to evaluate.
16758
16789
  `);
16759
16790
  return 0;
16760
16791
  }
@@ -16792,7 +16823,7 @@ async function runCi() {
16792
16823
  }
16793
16824
  if (!configResult.config) {
16794
16825
  await emitRunRecord({ runType: "ci", outcome: "unconfigured", changeSet });
16795
- process.stderr.write(neverConfiguredMessage(configResult.missing, CI_REMEDY, "[Halfcycle CI]"));
16826
+ process.stderr.write(ciNeverConfiguredMessage());
16796
16827
  return 1;
16797
16828
  }
16798
16829
  return await evaluateAndAct({ ...configResult.config, changeSet });
@@ -16811,7 +16842,8 @@ async function evaluateAndAct(args2) {
16811
16842
  runType: "ci",
16812
16843
  outcome: outcomeForClientFailure(clientResult.kind),
16813
16844
  failureReason: failure.reason,
16814
- changeSet
16845
+ changeSet,
16846
+ credentialRefused: failure.kind === "credential"
16815
16847
  });
16816
16848
  if (failure.kind === "credential") {
16817
16849
  process.stderr.write(credentialRejectedMessage(failure.statusCode, failure.message, CI_REMEDY, "[Halfcycle CI]"));
@@ -16863,13 +16895,17 @@ async function evaluateAndAct(args2) {
16863
16895
  }, {
16864
16896
  warn: (msg) => process.stderr.write(msg + "\n")
16865
16897
  });
16866
- return actOnEnvelope(clientResult.envelope);
16898
+ return actOnEnvelope(clientResult.envelope, {
16899
+ files: changeSet.files.length,
16900
+ signals: changeSetDescriptorCount(changeSet)
16901
+ });
16867
16902
  }
16868
- function actOnEnvelope(envelope) {
16903
+ function actOnEnvelope(envelope, scope) {
16869
16904
  const notRun = notRunReport(envelope.notRun, "[Halfcycle CI]");
16870
16905
  if (notRun)
16871
16906
  process.stdout.write(notRun + "\n");
16872
16907
  if (envelope.guardsFired.length === 0) {
16908
+ process.stdout.write(cleanRunSummary(envelope.explanation, scope) + "\n");
16873
16909
  return 0;
16874
16910
  }
16875
16911
  if (envelope.blocking) {
@@ -16901,6 +16937,13 @@ function actOnEnvelope(envelope) {
16901
16937
  return 0;
16902
16938
  }
16903
16939
  }
16940
+ function cleanRunSummary(verdict, scope) {
16941
+ const files = `${scope.files} file(s) evaluated`;
16942
+ if (scope.signals === 0) {
16943
+ return `[Halfcycle CI] ${verdict} ${files}, 0 signals found to check \u2014 nothing in this change is of a kind the guards look at (env references, SQL writes, schema constraints, wire-bound numeric columns, marked third-party endpoints).`;
16944
+ }
16945
+ return `[Halfcycle CI] ${verdict} ${files}, ${scope.signals} signal(s) checked.`;
16946
+ }
16904
16947
 
16905
16948
  // dist/credential-store.js
16906
16949
  import { chmodSync, readFileSync as readFileSync5, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2, realpathSync } from "node:fs";
@@ -17118,6 +17161,8 @@ async function runPostToolUse() {
17118
17161
  const diffContent = isNotebook ? "" : getSingleFileDiff(absFilePath, repoRoot);
17119
17162
  const { relative: pathRelative } = await import("node:path");
17120
17163
  const relPath = pathRelative(repoRoot, absFilePath);
17164
+ if (isInstallerOutput(relPath))
17165
+ return;
17121
17166
  const context = loadChangeSetContext(repoRoot);
17122
17167
  const changeSet = buildChangeSet([{ path: relPath, diffContent }], {
17123
17168
  ...context,
@@ -17149,7 +17194,8 @@ async function runPostToolUse() {
17149
17194
  outcome: outcomeForClientFailure(clientResult.kind),
17150
17195
  failureReason: failure.reason,
17151
17196
  changeSet,
17152
- credential: evaluation.token
17197
+ credential: evaluation.token,
17198
+ credentialRefused: failure.kind === "credential"
17153
17199
  });
17154
17200
  if (failure.kind === "credential") {
17155
17201
  emitCredentialRejected(
@@ -17394,7 +17440,8 @@ async function runSessionDiff(input, kind) {
17394
17440
  emitLocalFault(`Could not get session diff: ${collected.message}`);
17395
17441
  return;
17396
17442
  }
17397
- const { files: changedFiles, getDiff } = collected;
17443
+ const { getDiff } = collected;
17444
+ const { evaluated: changedFiles } = partitionInstallerOutput(collected.files);
17398
17445
  if (changedFiles.length === 0) {
17399
17446
  return;
17400
17447
  }
@@ -17437,7 +17484,8 @@ async function runSessionDiff(input, kind) {
17437
17484
  failureReason: failure.reason,
17438
17485
  changeSet,
17439
17486
  diffBase,
17440
- credential: evaluation.token
17487
+ credential: evaluation.token,
17488
+ credentialRefused: failure.kind === "credential"
17441
17489
  });
17442
17490
  if (failure.kind === "credential") {
17443
17491
  emitCredentialRejected(
package/dist/bin.js CHANGED
@@ -1761,10 +1761,21 @@ exit 0
1761
1761
  }
1762
1762
  function generateCiStanza() {
1763
1763
  return `# Halfcycle guard CI job \u2014 generated by the Halfcycle installer.
1764
- # Paste this job into your CI workflow. It assumes no package manager and no
1765
- # monorepo tooling: it runs the self-contained guard binary vendored at
1766
- # .halfcycle/bin/, so it works in a Python or Go repository as well as a Node
1767
- # one \u2014 the only prerequisite is Node 20 to run the bundled binary.
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.
1768
1779
  #
1769
1780
  # THERE IS NOTHING TO STORE. No token in your repository's secrets, no project id
1770
1781
  # to look up, and no address to set. The \`permissions\` block below is the whole
@@ -1798,31 +1809,39 @@ function generateCiStanza() {
1798
1809
  #
1799
1810
  # WHICH BRANCH MODEL THIS ASSUMES: none. It works on pull-request branches AND on
1800
1811
  # commits pushed straight to the default branch, which is the shape most
1801
- # Halfcycle engagements settle on. \`fetch-depth\` is what makes that true: the
1802
- # check needs the commit BEFORE the one it is evaluating, and the default
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
1803
1817
  # shallow checkout does not have it. With \`fetch-depth: 0\` the check fails loudly
1804
1818
  # if it cannot work out what to evaluate \u2014 it will not pass quietly having
1805
1819
  # evaluated nothing.
1806
1820
  #
1807
- # halfcycle-guard-ci:
1808
- # runs-on: ubuntu-latest
1809
- # permissions:
1810
- # contents: read
1811
- # id-token: write
1812
- # steps:
1813
- # - uses: actions/checkout@v4
1814
- # with:
1815
- # # REQUIRED. 0 = full history. The check diffs against the commit before
1816
- # # HEAD (or the fork point on a branch); the default depth of 1 has
1817
- # # neither. Do not lower this.
1818
- # fetch-depth: 0
1819
- # - uses: actions/setup-node@v4
1820
- # with:
1821
- # node-version: '20'
1822
- # - name: Halfcycle guard CI check
1823
- # # No env block, on purpose: this step holds no secret. The permissions
1824
- # # above are what authenticate it.
1825
- # run: node ./.halfcycle/bin/bin.bundle.mjs ci
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
1826
1845
  #
1827
1846
  # The job prints the diff base it used and how many files it evaluated, on every
1828
1847
  # run. If that line says 0 files on a commit that changed something, the base is
@@ -1832,8 +1851,8 @@ function generateCiStanza() {
1832
1851
  #
1833
1852
  # EDIT YOUR COPY, NOT THIS FILE. This one is regenerated by the installer and your
1834
1853
  # changes to it would be replaced the next time you run \`npx halfcycle\`. It is
1835
- # also inert where it sits: no CI system reads this path. Copy the job above into
1836
- # your own workflow and change it there.
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.
1837
1856
  `;
1838
1857
  }
1839
1858
  var MCP_REGISTRATION_REL = ".mcp.json";
@@ -2005,24 +2024,29 @@ ${header}
2005
2024
  return "failed";
2006
2025
  }
2007
2026
  }
2008
- function previousAccountId(targetRepoRoot, engagementId) {
2027
+ function previousPinForEngagement(targetRepoRoot, engagementId) {
2009
2028
  try {
2010
2029
  const existing = readBundlePin(targetRepoRoot);
2011
2030
  if (existing === null || existing.engagementId !== engagementId)
2012
2031
  return void 0;
2013
- return existing.accountId;
2032
+ return existing;
2014
2033
  } catch {
2015
2034
  return void 0;
2016
2035
  }
2017
2036
  }
2037
+ function nonEmpty(value) {
2038
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
2039
+ }
2018
2040
  function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, writtenPaths) {
2019
- const carried = accountId ?? previousAccountId(targetRepoRoot, engagementId);
2041
+ const previous = previousPinForEngagement(targetRepoRoot, engagementId);
2042
+ const carried = nonEmpty(accountId) ?? nonEmpty(previous?.accountId);
2043
+ const installedAt = nonEmpty(previous?.installedAt) ?? (/* @__PURE__ */ new Date()).toISOString();
2020
2044
  const pin = {
2021
2045
  version,
2022
2046
  engagementId,
2023
2047
  engagementType,
2024
- installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2025
- ...carried !== void 0 && carried.trim() !== "" ? { accountId: carried } : {}
2048
+ installedAt,
2049
+ ...carried !== void 0 ? { accountId: carried } : {}
2026
2050
  };
2027
2051
  const pinPath = join5(targetRepoRoot, ".halfcycle", "bundle.json");
2028
2052
  writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
@@ -4693,7 +4717,7 @@ ${USAGE}`);
4693
4717
  `);
4694
4718
  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.
4695
4719
  `);
4696
- process.stdout.write(`[halfcycle] To check changes in CI too: run \`npx halfcycle ci bind <owner>/<repo>\` in this folder once, then paste the job from .halfcycle/ci-stanza.yml into a workflow \u2014 there is no secret to store.
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.
4697
4721
  `);
4698
4722
  }
4699
4723
  if (credential) {