LearnNewsExamplesServices
Frontmatter
id19052
titleneo.mjs''s postinstall calls a devDependency, so consumer installs fail
stateClosed
labels
bugairegressionbuild
assigneesneo-opus-ada
createdAtSep 23, 2026, 12:14 AM
updatedAtSep 23, 2026, 12:54 AM
githubUrlhttps://github.com/neomjs/neo/issues/19052
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[ ] 19047 v13.2 cannot be deployed: pages pins one engine version and devindex has no home
closedAtSep 23, 2026, 12:54 AM
milestonev13.2

neo.mjs's postinstall calls a devDependency, so consumer installs fail

Closed v13.2.0/archive-v13-2-0-chunk-22 bugairegressionbuild
neo-opus-ada
neo-opus-ada commented on Sep 23, 2026, 12:14 AM

Context

Found while dry-running the v13.2 deploy for #19047: the pages pipeline installs the engine as an npm dependency, so I installed engine dev the same way. It fails before any build step runs.

Reproduced 2026-09-22 on dev@e66b6f8142:

git worktree add --detach <wt> origin/dev
cd <wt> && npm pack --ignore-scripts            # neo.mjs-13.1.0.tgz, 5,714 files
mkdir <consumer> && cd <consumer>
echo '{"name":"c","version":"1.0.0","private":true}' > package.json
npm install <wt>/neo.mjs-13.1.0.tgz
→ npm error code 127
→ npm error command sh -c neo-agent-skills-materialize
→ npm error sh: neo-agent-skills-materialize: command not found

The Problem

package.json declares "postinstall": "neo-agent-skills-materialize", and neo-agent-skills is a devDependency. npm runs a dependency's postinstall inside every consumer's install, and a consumer never installs its dependencies' devDependencies. So the binary exists where the hook runs only if the consumer's own tree supplies it.

No published version carries it yet. neo.mjs@13.1.0 (npm, 2026-07-03) has no postinstall; the hook arrived on dev on 2026-08-26 via #17799. Measured 2026-09-22 with packed tarballs (npm 11):

Consumer shape dev this fix
empty project, npm install <tgz> exit 127 exit 0
create-app scaffold as generated today (postinstall: cd node_modules/neo.mjs && npm i) — the same order as pages' step 4 (install) → step 7 (npm i on neo) exit 127: the outer install fires the hook before the consumer's own npm i on neo runs exit 0; the nested npm i runs prepare and materializes 37 links inside node_modules/neo.mjs
a workspace that declares neo-agent-skills itself, with no hook of its own exit 0, and 37 links projected at the workspace root — the dependency script finds the consumer's .bin, and INIT_CWD is the consumer root exit 0, 0 links — the workspace runs the materializer itself

So 13.2.0 would break every default-scripts install whose tree lacks neo-agent-skills: pages (step 4 dies before step 7), devindex (declares no skills), today's create-app scaffold, and third-party projects. (pnpm and bun block dependency lifecycle scripts by default.)

The last row is a behaviour this fix removes, not only a failure it cures: on dev, a consumer that happens to carry the skills gets them projected into its root by the engine. Fork resolved: consumer-owned projection. The alternative — an engine postinstall script that runs the materializer only when neo-agent-skills resolves from the installing tree — was put to @neo-gpt at @tobiu's suggestion, and rejected: that root projection is an incidental cross-package effect of the engine's hook, not a promise #17798 makes to consumers (it gives each consumer its own dependency and materializer), and keeping it would preserve an engine-controlled write into a consumer's tree. Corrected 2026-09-22: the first version of this section claimed every default-scripts install would fail.

The Architectural Reality

  • #17798 chose install-time materialization so each repository that uses the skills gets projected links in its own checkout. That intent is right. What it missed is that neo.mjs is also a published package, so its own install hook fires in other people's installs.
  • npm already separates the two contexts: a registry or tarball install never runs prepare, while a dependency's postinstall runs in every consumer's install. prepare does run on an argument-less npm install / npm ci in the package's own directory, on pack/publish, and for Git-dependency and linked-folder installs, which build from a checkout (npm v11 scripts); neither of the last two is a supported way to consume neo.mjs, and neither was measured here. The engine's own-checkout lifecycle already lives in prepare: buildScripts/util/prepare.mjs (runPrepare), which short-circuits on --package-lock-only and resolves husky from its manifest's bin so it works under cmd.exe.
  • The materializer finds its target through INIT_CWD (materialize-harness-skills.mjs consumerRoot()), which npm sets identically for prepare, so the projection lands in the same place.
  • neo-agent-brain is unaffected on its own: it is private, and neo-agent-skills is a regular dependency there.

The Fix

  1. package.json — delete scripts.postinstall.
  2. buildScripts/util/prepare.mjs — generalize resolveHuskyBin into a package-and-bin resolver, and give runPrepare a second stage after husky: neo-agent-skills-materialize, resolved from node_modules/neo-agent-skills/package.json bin, launched through process.execPath. The lock-only guard still returns before either stage; a materializer failure fails the install, as husky's does.
  3. test/playwright/unit/buildScripts/prepare.spec.mjs — "husky is the only Engine prepare step" becomes husky-then-materializer; add the failure, missing-package and missing-bin arms.
  4. Comments that name postinstall as the materialization path (.gitignore, .github/workflows/substrate-sync.yml, .github/workflows/guard-ci-parity-lint.yml) say prepare.
  5. Consequence for consumers: one that wants skills runs neo-agent-skills-materialize from its own lifecycle — #17798's consumer contract, which the Brain already follows. For create-app, that line travels with the devDependency sync in neomjs/create-app#34.

Contract Ledger

Target surface Source of authority Proposed behavior Fallback Docs Evidence
package.json scripts.postinstall #17798 mechanism removed — prepare.mjs docblock consumer probe above: exit 127
buildScripts/util/prepare.mjs runPrepare the engine's own-checkout lifecycle lock-only guard → husky → materializer; either failure fails the install npm ci --ignore-scripts skips it, as before — substrate-sync.yml's --check stays the arm docblock prepare.spec.mjs
a registry or tarball install of neo.mjs npm lifecycle semantics runs no engine script — — the same probe, exit 0

Decision Record impact

amends #17798 AC-2, narrowly and for the Engine only. That AC required neomjs/neo, the Brain and devindex each to run the materializer from postinstall; the Engine moves it to prepare because it is also a published package, and the consumer-install falsifier above is why. Everything else #17798 settled stands: the consumer-owned dependency and projection, the Brain's and devindex's own hooks, and the --check arm. No ADR is involved. (@neo-gpt's source-of-authority read, R1 preflight on PR #19053.)

Acceptance Criteria

  • AC-1 — package.json has no postinstall; runPrepare runs the materializer after husky, resolved from neo-agent-skills's bin declaration.
  • AC-2 — The reproducer above, run against the branch, exits 0. On dev it exits 127 — red first, then green.
  • AC-3 — The engine's own checkout still materializes: a bare npm ci leaves npx --no-install neo-agent-skills-materialize --check green, and npm install --package-lock-only runs neither stage.
  • AC-4 — prepare.spec.mjs covers the stage order, a materializer failure failing the install, and a missing package or bin entry as a named error.

Out of Scope

  • Whether the published tarball should carry materialized links at all (npm publish runs after npm ci, with or without this change) — that belongs to check-package-contents.yml's gate.
  • The pages topology — D#19050.

Avoided Traps

  • Moving neo-agent-skills into dependencies. Consumer installs pass, but every library user downloads agent substrate and runs the materializer against their own project root via INIT_CWD.
  • A shell guard (command -v … || true). POSIX-only — prepare.mjs exists because cmd.exe has no [ — and it would swallow a real materializer failure in the engine's own checkout.
  • Detecting "am I a dependency?" in a script. Re-implements what npm's prepare/postinstall split already encodes.

Related

  • #19047 — its AC-4 dry run installs the engine as a dependency, so it cannot pass until this lands
  • #17798 — origin of the hook (closed)

Sweeps

  • Live latest-open sweep: latest 20 open issues at 2026-09-22T22:12:26Z — no equivalent.
  • A2A in-flight sweep: last 30 messages, all read-states, at 22:12Z — no claim on this scope.
  • MC sweep: query_raw_memories("npm install neo.mjs fails postinstall neo-agent-skills-materialize command not found consumer workspace devDependency"), 6 results, no prior decision found.
  • Own-assignment sweep: 8 open, none overlapping (#18985's contributor npm install runs in the engine's own checkout, which this keeps working).
  • Mirror grep: resources/content/issues for neo-agent-skills-materialize → only #17798.

Origin Session ID: 3f07edfa-63cf-4d5d-9c78-1e0d592ce98f

Retrieval Hint: query_raw_memories("neo.mjs postinstall devDependency consumer install exit 127 prepare lifecycle")

Authored by ⚖️ Ada · @neo-opus-ada · Claude Opus 5.5 · Claude Code

tobiu referenced in commit b43eb83 - "fix(build): run the skills materializer from prepare, not postinstall (#19052) (#19053) on Sep 23, 2026, 12:54 AM
tobiu closed this issue on Sep 23, 2026, 12:54 AM