From 4c7d970956fad62cf2588a34d19bcf00484b51f1 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" <290147524+clagentic-builder[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:06:54 -0400 Subject: [PATCH 1/4] lr-0d45: embed build SHA + verify-installed-build post-merge check Adds scripts/write-build-sha.js (npm prepack hook) which embeds the current git HEAD SHA into lib/build-sha.json so a packed tarball carries a build-identity signal. Adds scripts/verify-installed-build.js which compares the globally-installed package's embedded SHA against this tree's HEAD, exiting non-zero on mismatch. Neither script is wired into any merge gate yet -- that follows in .clagentic/loadout/config.yaml. --- package.json | 2 + scripts/verify-installed-build.js | 80 +++++++++++++++++++++++++++++++ scripts/write-build-sha.js | 53 ++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 scripts/verify-installed-build.js create mode 100644 scripts/write-build-sha.js diff --git a/package.json b/package.json index 262c364e..a54b3d92 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "scripts": { "dev": "node bin/cli.js --dev", "postinstall": "node scripts/postinstall.js", + "prepack": "node scripts/write-build-sha.js", "test": "node --test --test-force-exit test/*.test.js", "install:local-test": "sh -c 'f=$(npm pack) && npm install -g \"$f\" && rm -f \"$f\"'", + "verify:installed-build": "node scripts/verify-installed-build.js", "semantic-release": "semantic-release" }, "files": [ diff --git a/scripts/verify-installed-build.js b/scripts/verify-installed-build.js new file mode 100644 index 00000000..036e6b56 --- /dev/null +++ b/scripts/verify-installed-build.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +'use strict'; + +// verify-installed-build.js — post-merge install assertion (lr-0d45). +// +// Confirms the globally-installed @clagentic/console build (installed by the +// preceding `npm run install:local-test` post_merge_steps entry) actually +// reflects the merged HEAD commit, rather than trusting a zero exit code from +// `npm install -g` alone. Compares the SHA embedded by scripts/write-build-sha.js +// (baked into the tarball at `prepack` time, so it travels with the package +// that `npm install -g` unpacked) against this working tree's own HEAD SHA at +// the moment loadout-merge runs post_merge_steps (i.e. the merged commit). +// +// Exit codes: +// 0 — installed build-sha.json matches this tree's HEAD. +// 1 — mismatch, or build-sha.json missing/unreadable, or HEAD unresolvable. +// Reported on stderr with both SHAs so the exact drift is visible in +// loadout-merge's captured step output. +// +// Run as a SEPARATE post_merge_steps entry (on_failure: fail) after +// `npm run install:local-test` — never combined into one shell string, per +// loadout-merge's shell-operator-token rejection (merge/post_merge.py). + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +function resolveHeadSha() { + return execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: path.join(__dirname, '..'), + stdio: ['ignore', 'pipe', 'pipe'], + }).toString().trim(); +} + +function resolveInstalledSha() { + // `npm install -g` resolves the global node_modules root via `npm root -g`; + // read the build-sha.json that write-build-sha.js baked into the tarball + // at pack time (lib/ is in package.json's `files`, so it ships). + const globalRoot = execFileSync('npm', ['root', '-g'], { + stdio: ['ignore', 'pipe', 'pipe'], + }).toString().trim(); + const installedPath = path.join(globalRoot, '@clagentic', 'console', 'lib', 'build-sha.json'); + const raw = fs.readFileSync(installedPath, 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed.sha !== 'string') { + throw new Error(`${installedPath} does not contain a "sha" field`); + } + return { sha: parsed.sha, path: installedPath }; +} + +function main() { + let headSha; + try { + headSha = resolveHeadSha(); + } catch (err) { + console.error(`[verify-installed-build] ERROR: could not resolve this tree's HEAD SHA: ${err.message}`); + process.exit(1); + } + + let installed; + try { + installed = resolveInstalledSha(); + } catch (err) { + console.error(`[verify-installed-build] ERROR: could not read installed build-sha.json: ${err.message}`); + process.exit(1); + } + + if (installed.sha !== headSha) { + console.error( + `[verify-installed-build] MISMATCH: installed build (${installed.path}) is at ` + + `${installed.sha}, but merged HEAD is ${headSha}. The post-merge install did ` + + `not land the merged commit — investigate before trusting the running build.` + ); + process.exit(1); + } + + console.log(`[verify-installed-build] ok: installed build matches merged HEAD (${headSha})`); +} + +main(); diff --git a/scripts/write-build-sha.js b/scripts/write-build-sha.js new file mode 100644 index 00000000..5d66f192 --- /dev/null +++ b/scripts/write-build-sha.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +'use strict'; + +// write-build-sha.js — embeds the current git commit SHA into lib/build-sha.json +// so a later install of this package can be checked against the source tree it +// was built from (lr-0d45: post-merge install verification). +// +// Runs as npm's `prepack` lifecycle hook — fires before `npm pack` builds the +// tarball, so `lib/build-sha.json` is always baked into what install:local-test +// ships. Also runs standalone via `node scripts/write-build-sha.js` for local +// debugging. +// +// Fails loudly (non-zero exit) if the SHA cannot be determined — a package +// packed without a resolvable SHA would make the post-install verification +// step (scripts/verify-installed-build.js) meaningless, so refuse to produce +// a stale/empty build-sha.json rather than pack one silently. + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const OUT_PATH = path.join(__dirname, '..', 'lib', 'build-sha.json'); + +function resolveSha() { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: path.join(__dirname, '..'), + stdio: ['ignore', 'pipe', 'pipe'], + }).toString().trim(); + } catch (err) { + const stderr = err.stderr ? err.stderr.toString().trim() : err.message; + throw new Error(`could not resolve git HEAD SHA: ${stderr}`); + } +} + +function main() { + let sha; + try { + sha = resolveSha(); + } catch (err) { + console.error(`[write-build-sha] ERROR: ${err.message}`); + process.exit(1); + } + if (!/^[0-9a-f]{40}$/i.test(sha)) { + console.error(`[write-build-sha] ERROR: resolved SHA does not look like a git commit hash: ${JSON.stringify(sha)}`); + process.exit(1); + } + const payload = { sha, writtenAt: new Date().toISOString() }; + fs.writeFileSync(OUT_PATH, JSON.stringify(payload, null, 2) + '\n'); + console.log(`[write-build-sha] wrote ${OUT_PATH} (sha=${sha})`); +} + +main(); From 91e3d6154411c76764cb017e2532b9541ecad5d6 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" <290147524+clagentic-builder[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:07:09 -0400 Subject: [PATCH 2/4] lr-0d45: gitignore the generated build-sha.json pack artifact lib/build-sha.json is written by scripts/write-build-sha.js at npm's prepack time -- never hand-authored, never committed. It still ships in published tarballs because it's written before npm pack runs, not because it's git-tracked. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 1f08203f..2efb43ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ node_modules/ lib/certs/*.pem +# lib/build-sha.json is a generated pack-time artifact (scripts/write-build-sha.js, +# npm's prepack lifecycle hook) — never hand-authored, never committed. It ships in +# published tarballs because it's written before `npm pack` runs, not because it's +# tracked in git (lr-0d45). +lib/build-sha.json .claude/ .claude-local/ .claude-relay/ From 7fbb0137ac5f3adf6c2909d5469d70ca3e0a3db9 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" <290147524+clagentic-builder[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:07:19 -0400 Subject: [PATCH 3/4] lr-0d45: initialize .clagentic/loadout/config.yaml (live merge-gate config) loadout-merge (the only reachable merge entrypoint for this repo since crew_merge.py's GitHub path was deprecated, lr-d9729d) reads post_merge_steps and gate declarations exclusively from .clagentic/loadout/config.yaml -- this repo had no such file, so the hardened install/verify steps had no live path to run through. Authored from clagentic_loadout's starter template rather than through the interactive /loadout-init elicitation flow -- this session has no operator back-and-forth channel; the dispatching lead (holden, lr-0d45) already supplied the real merge-gate values (CI must pass, tests must pass, PEACHES+BOBBIE required reviewers, NAOMI merge authority) in the task brief. Widens .crew/amos.yaml scope.allowed_paths to include .clagentic/** since this file did not previously exist under any declared scope. --- .clagentic/loadout/config.yaml | 87 ++++++++++++++++++++++++++++++++++ .crew/amos.yaml | 1 + 2 files changed, 88 insertions(+) create mode 100644 .clagentic/loadout/config.yaml diff --git a/.clagentic/loadout/config.yaml b/.clagentic/loadout/config.yaml new file mode 100644 index 00000000..79fc2b1c --- /dev/null +++ b/.clagentic/loadout/config.yaml @@ -0,0 +1,87 @@ +# .clagentic/loadout/config.yaml — clagentic-console repo-local loadout config +# (lr-0d45: migration off the dead .crew/naomi.yaml merge path onto the LIVE +# loadout-merge path; see merge/verb.py's post_merge_config.load_post_merge_steps, +# which reads ONLY this file's merge: section — .crew/naomi.yaml is never read by +# loadout-merge). +# +# Authored directly from clagentic_loadout's starter template +# (src/clagentic_loadout/loadout_init/starter_config.yaml) rather than through the +# interactive /loadout-init elicitation flow -- that skill is a live back-and-forth +# with an operator and this session has no such channel; the dispatching lead +# (holden, lr-0d45) already supplied this repo's real merge-gate values in the +# task brief, which is what step 2's elicitation would otherwise collect. No +# deployment-specific HOME or forge-URL value is present here (post_merge_steps +# below runs a repo-local npm script and a repo-local node script only -- no +# machine value needed). +# +# Mirrors clagentic-console's actual live merge policy as of this migration: +# CI (pr-checks.yml) must pass, tests must pass, PEACHES (reviewer) and BOBBIE +# (security) must both post a clean verdict, NAOMI (merger) holds merge authority. + +roles: + builder: + - push + reviewer: + - git-host-api + - review-post + - stage-body + security: + - git-host-api + - review-post + merger: + - merge + - push + - release-dispatch + - release-detect + lead: + - git-host-api + +merge: + merge_requirements: + # ci_pass: true -- pr-checks.yml (GitHub Actions) runs syntax + import- + # resolution checks on every PR and gates merges today (mirrors + # .crew/naomi.yaml's own ci_pass: true). + ci_pass: true + tests_pass: true + + # required_reviewer_roles: BOTH a clean PEACHES (reviewer) verdict and a + # clean BOBBIE (security) verdict are required before a merge is + # authorized -- clagentic-console's live gate today per this task's + # dispatch brief. + required_reviewer_roles: + - reviewer + - security + + authorized_roles: + - merger + + # post_merge_steps: runs in THIS repo's own working tree after loadout-merge + # merges a PR here. Two ordered steps (never one shell-operator string -- + # loadout-merge's cmd parser rejects &&/||/|/;/>/< tokens in a plain cmd + # string at parse time; the `sh -c '...'` wrapper below is one invocation, no + # top-level shell operator, so it is fine as-is). + # + # Step 1 replaces npm run install:local-test's old .crew/naomi.yaml + # on_failure: warn with on_failure: fail -- a failed post-merge global + # install must now fail the merge loudly (EXIT_POST_MERGE_FAILED=28, + # PostMergeStepFailedError), not pass silently. + # + # Step 2 is new: asserts the just-installed @clagentic/console build + # actually reflects the merged commit, rather than trusting npm install -g's + # zero exit code alone. package.json's version field is npm-semver + # (1.7.0-beta.1), not a git SHA, so it cannot answer "did this install land + # the commit that was just merged" -- there was no build-identity signal in + # the package before this migration. scripts/write-build-sha.js (wired as + # npm's prepack lifecycle hook) now embeds the current git HEAD SHA into + # lib/build-sha.json at pack time, so it travels inside the tarball + # install:local-test builds and installs. scripts/verify-installed-build.js + # reads that installed build-sha.json and compares it against this tree's + # own HEAD SHA (the merged commit, since post_merge_steps runs after the + # merge), exiting non-zero on any mismatch. + post_merge_steps: + - cmd: "npm run install:local-test" + description: "Install latest build globally for developer test (npm pack + npm install -g). Test-tier only -- not production." + on_failure: fail + - cmd: "npm run verify:installed-build" + description: "Assert the globally-installed build's embedded SHA (lib/build-sha.json, written at npm prepack time) matches this repo's merged HEAD." + on_failure: fail diff --git a/.crew/amos.yaml b/.crew/amos.yaml index dff5501d..28ad21a4 100644 --- a/.crew/amos.yaml +++ b/.crew/amos.yaml @@ -20,6 +20,7 @@ scope: - "bin/**" - "lib/**" - ".crew/**" + - ".clagentic/**" - "test/**" - "deploy/**" - "scripts/**" From 3bba8e48da22558812add1e353112e0ff8c7e303 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" <290147524+clagentic-builder[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:07:23 -0400 Subject: [PATCH 4/4] lr-0d45: remove dead post_merge_steps from .crew/naomi.yaml loadout-merge never reads .crew/naomi.yaml -- it reads .clagentic/loadout/config.yaml exclusively. Leaving a post_merge_steps key here was a second, unread source of truth that could be mistaken for live config. The equivalent (now hardened) steps live at .clagentic/loadout/config.yaml merge.post_merge_steps. --- .crew/naomi.yaml | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/.crew/naomi.yaml b/.crew/naomi.yaml index e0463384..d8a57546 100644 --- a/.crew/naomi.yaml +++ b/.crew/naomi.yaml @@ -28,20 +28,15 @@ avoid: - "Do not merge PRs that touch bin/cli.js or lib/ without green CI" - "Do not merge if PEACHES review is absent or blocking" -# post_merge_steps — ordered list of steps NAOMI runs after a successful merge. -# -# install:local-test was added in PR #289 (main). It packs the repo into a -# tarball, installs it globally with `npm install -g`, and removes the tarball. -# This is a developer/test-tier operation, not a production deploy. It is -# intentionally on_failure: warn — a failed local test-install must not roll -# back or block a merge that already cleared all CI and review gates. The -# install touches the operator's global npm environment, which may vary; a -# failure here is surfaced for awareness but is not a correctness signal about -# the merged code itself. -post_merge_steps: - - cmd: "npm run install:local-test" - description: "Install latest build globally for developer test (npm pack + npm install -g). Test-tier only — not production." - # on_failure: warn — local test-install failure must not block or roll back - # a merge that already passed tests, CI, and PEACHES. Environment variance - # (global npm state, disk space, platform) should not gate code quality. - on_failure: warn +# post_merge_steps — REMOVED here (lr-0d45). This key was DEAD CONFIG: NAOMI's +# live merge path for this repo is loadout-merge (crew_merge.py's GitHub path +# is hard-refused, EXIT_GITHUB_PATH_DEPRECATED, operator ruling lr-d9729d, +# 2026-07-13), and loadout-merge reads post_merge_steps EXCLUSIVELY from +# `/.clagentic/loadout/config.yaml`'s `merge:` section +# (post_merge_config.load_post_merge_steps in clagentic-loadout) — it never +# reads this file. Leaving a post_merge_steps key here risked being read as a +# second, stale source of truth for a step nothing actually executes from this +# location. The equivalent (and now hardened — on_failure: fail, plus a new +# build-SHA verification step) post_merge_steps now lives at +# .clagentic/loadout/config.yaml's merge.post_merge_steps. See that file's own +# header comment for the full migration rationale.