create-agent-rig 0.7.1 → 0.9.0

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +184 -2
  2. package/README.md +93 -4
  3. package/package.json +4 -3
  4. package/packages/cli/dist/commands/memory.js +123 -0
  5. package/packages/cli/dist/commands/setup.js +45 -0
  6. package/packages/cli/dist/index.js +107 -3
  7. package/packages/cli/dist/lib/subsystems.js +269 -0
  8. package/packages/cli/dist/lib/version.js +15 -0
  9. package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
  10. package/packages/cli/dist/policy/core/adapter.js +18 -0
  11. package/packages/cli/dist/policy/core/coverage.js +253 -0
  12. package/packages/cli/dist/policy/core/decision-record.js +287 -0
  13. package/packages/cli/dist/policy/core/declaration.js +127 -0
  14. package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
  15. package/packages/cli/dist/policy/core/probe.js +442 -0
  16. package/packages/cli/dist/policy/core/registry.js +115 -0
  17. package/packages/cli/dist/policy/core/validation.js +275 -0
  18. package/packages/cli/dist/policy/core/vocabulary.js +123 -0
  19. package/packages/cli/dist/policy/harness/claude.js +47 -0
  20. package/packages/cli/dist/policy/harness/codex.js +87 -0
  21. package/packages/cli/dist/policy/harness/index.js +15 -0
  22. package/packages/cli/dist/policy/harness/shared-hooks.js +28 -0
  23. package/packages/cli/dist/policy/index.js +17 -0
  24. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
  25. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
  26. package/templates/agent-os/subagent-routing.json +32 -0
  27. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +48 -7
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
  31. package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
  32. package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
  33. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
  34. package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
  35. package/templates/agent-os/universal/.claude/rules/autonomy.md +17 -7
  36. package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
  37. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
  38. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
  39. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
  40. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
  41. package/templates/agent-os/universal/.claude/settings.json +16 -0
  42. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +48 -7
  43. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
  44. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
  45. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
  46. package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
  47. package/templates/agent-os/universal/.codex/config.toml +3 -0
  48. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
  49. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
  50. package/templates/agent-os/universal/layers.json +4 -0
  51. package/templates/hash-history.json +15 -7
  52. package/templates/release-ledger.json +3 -1
  53. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
  54. package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
  55. package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
  56. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +9 -8
@@ -0,0 +1,142 @@
1
+ # Subagent routing: every gate pins its model and effort
2
+
3
+ Status: accepted for RP-173 (Claude Code). The Codex half, RP-166, is recorded in
4
+ `codex-adapter.md`. Both harnesses' routing comes from one role table: the Codex
5
+ profiles are generated from it, and the Claude agent definitions are checked against it.
6
+
7
+ ## Decision
8
+
9
+ Each named subagent this rig ships pins the model and the effort it reads with:
10
+
11
+ | Role | Claude Code | Codex |
12
+ | --- | --- | --- |
13
+ | `code-reviewer`, `security-scanner`, a stack's infrastructure reviewer | `claude-opus-5`, `high` | `gpt-5.6-sol`, `high` |
14
+ | `test-writer`, `prose-reviewer` | `claude-sonnet-5`, `high` | `gpt-5.6-terra`, `high` |
15
+ | a subagent with no definition | `claude-sonnet-5`; effort follows the session | `gpt-5.6-terra`, `medium` |
16
+
17
+ In this project the pins are the files themselves: `model:` and `effort:` in
18
+ `.claude/agents/<role>.md`, `model` and `model_reasoning_effort` in
19
+ `.codex/agents/<role>.toml`, `env.CLAUDE_CODE_SUBAGENT_MODEL` in
20
+ `.claude/settings.json`, and the `[agents]` defaults in `.codex/config.toml`.
21
+
22
+ The driver session's own model and effort are not pinned. They belong to whoever
23
+ starts the session.
24
+
25
+ ## Why
26
+
27
+ A gate that inherits the driver's model returns a verdict whose meaning changes
28
+ whenever the driver does. Pinning makes the reader behind a SHIP a property of the
29
+ rulebook rather than of how a session happened to be launched.
30
+
31
+ - **Full model IDs, not aliases.** An alias moves when a new model ships, and every
32
+ verdict would change meaning without a diff. Moving a role to another model is a
33
+ policy change.
34
+ - **The larger model reads correctness, security and infrastructure; the smaller one
35
+ does bounded, frequent work** — the same split as the Codex tiers.
36
+ - **`high` for every gate, and escalation is a policy change.** A dispatch has no way
37
+ to set a subagent's effort — the `per-dispatch-effort` row below — so raising a
38
+ gate's effort is an edit to its definition, and nothing here invents a per-call knob.
39
+
40
+ ## How Claude Code resolves a pin, and what voids one
41
+
42
+ Claude Code's changelog states two of the rules:
43
+
44
+ - A subagent's model comes from the per-dispatch `model` first, then the
45
+ definition's `model:`, then `CLAUDE_CODE_SUBAGENT_MODEL`, then the parent session —
46
+ and that order holds from **2.1.251**. Before it the environment variable overrode
47
+ definitions, so the unnamed default this rig sets would replace every gate's pin.
48
+ That is why 2.1.251 is the version the session-start check warns below; it is
49
+ the release that makes the MODEL pins outrank the shipped default, and nothing more
50
+ is claimed for it — the `effort:` field and the `Agent` tool name the guard matches
51
+ were observed working on 2.1.269 and 2.1.270 only.
52
+ - `CLAUDE_CODE_SUBAGENT_MODEL_FORCE`, added in 2.1.257, applies one model to every
53
+ subagent and ignores each definition's `model:`.
54
+
55
+ The rest is measured. Each row records a live Claude Code run, read by a script from
56
+ what Claude Code itself wrote — a subagent transcript, a hook's payload or a hook's
57
+ environment — never from a subagent's own report. The rows are the generator's
58
+ `docs/capability-evidence.json` (absent in a generated rig), keyed by `mechanism`
59
+ and `surface`:
60
+
61
+ - `subagent-model-pin` and `subagent-effort-pin` hold under a driver on another model
62
+ and effort (surface `.claude/agents/test-writer.md`);
63
+ - `subagent-model-pin` does not hold with `CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1`, and
64
+ `subagent-effort-pin` does not hold with `CLAUDE_CODE_EFFORT_LEVEL=low` — each
65
+ variable replaced only the pin it names;
66
+ - `subagent-model-pin` does not hold against a call-site `model` when no guard
67
+ refuses the dispatch;
68
+ - `unnamed-subagent-model` holds and `unnamed-subagent-effort` is unsupported: a
69
+ subagent with no definition ran on the shipped default at the session's effort.
70
+
71
+ Two mechanisms follow, both in `.claude/hooks/`. `guard-subagent-model` refuses a
72
+ per-dispatch `model` for an agent whose definition pins one (`call-site-model-guard`).
73
+ `warn-subagent-routing` warns at session start when either variable is set, when
74
+ Claude Code is older than 2.1.251, or when its version cannot be read from `AI_AGENT`
75
+ (`claude-code-version-signal`); it never blocks, because the variables are the
76
+ operator's to set and the warning makes their cost visible instead of taking the
77
+ decision. Pinned in `subagent-routing-hooks.test.ts` (absent in a generated rig)
78
+ › "blocks a call-site model on a project agent that pins one, and says to re-dispatch without it"
79
+ and › "warns when %s is set, and never blocks the session".
80
+
81
+ Neither hook is wired for Codex, which has no dispatch hook and no Claude
82
+ environment: `subagent-routing.test.ts` (absent in a generated rig) ›
83
+ "keeps both routing hooks out of the Codex projection, which has no Agent tool and no Claude environment".
84
+
85
+ ## What this does not do
86
+
87
+ - **The effort of a subagent with no definition is not pinned.** It runs at the
88
+ session's effort (`unnamed-subagent-effort`). That is recorded as unsupported, not
89
+ worked around.
90
+ - **A definition says what was asked for; only the transcript says what ran.** A
91
+ verdict does not yet carry the model and effort it was produced on.
92
+ - **The guard's notion of a role is "a project agent whose definition pins a model".**
93
+ In a generated project the definitions are the policy (next section), so an agent
94
+ a project adds with a pin is a role, and a role whose definition is changed to
95
+ `model: inherit` has stopped being one — by that project's reviewed decision.
96
+ - **The built-in agents get no definitions here.** A general-purpose subagent follows
97
+ the unnamed default like any subagent without a definition.
98
+ - **The warning is a warning.** A session started with `CLAUDE_CODE_EFFORT_LEVEL` set
99
+ still runs every gate — at that level.
100
+
101
+ ## Changing a role in this project
102
+
103
+ The item this record implements asked that a project lower a default by editing
104
+ "its policy", with "the test following". Here that is realised as follows, and the
105
+ difference from a literal reading is deliberate:
106
+
107
+ - **The agent definitions are this project's policy.** It receives no copy of the
108
+ generator's role table and no routing check: a second copy beside the definitions,
109
+ with no projector here to keep the two aligned, is the drift `rules/invariants.md`
110
+ forbids ("one mechanism, one implementation"), and the Codex half already shipped
111
+ that way.
112
+ - **To move a role**, edit `model:` / `effort:` in `.claude/agents/<role>.md` and the
113
+ matching `.codex/agents/<role>.toml` in one reviewed change; both trees are
114
+ elevated paths.
115
+ - **The test that follows the table lives in the generator**, where the expected
116
+ values are read from the table rather than restated.
117
+ - **`upgrade` reports the edited agent file as yours and leaves it alone**, while an
118
+ agent file nobody edited is replaced by the release's pinned one —
119
+ `subagent-routing-install.test.ts` (absent in a generated rig) ›
120
+ "replaces an untouched pre-pin agent with the pinned one" and ›
121
+ "reports a pre-pin agent the user edited as a conflict and leaves its bytes alone".
122
+
123
+ ## In the generator
124
+
125
+ `templates/agent-os/subagent-routing.json` is the one table.
126
+ `scripts/sync-codex-adapter.mjs --check` loads it through `scripts/subagent-routing.mjs`,
127
+ derives the Codex profiles from it, and refuses a Claude agent without `model` or
128
+ `effort`, a value that differs from its role, a role without an agent or an agent
129
+ without a role, an effort the pinned model does not support, an unknown field in
130
+ either harness's mapping, and shipped settings that miss the unnamed default or set
131
+ either voiding variable —
132
+ `subagent-routing.test.ts` (absent in a generated rig) › "refuses a routing policy with %s"
133
+ and › "refuses a Claude agent template whose model disagrees with the routing policy".
134
+
135
+ ## Risk and rollback
136
+
137
+ A pinned model can be unavailable to an account. In the generator the recovery is
138
+ changing the table and, in the same change, the Claude agent definitions checked
139
+ against it; in a project it is changing the definitions as above — never a
140
+ per-call override. Rollback in a project is deleting the `model:` / `effort:` lines,
141
+ the `env` entry, and the two hooks with their wiring; every gate then inherits the
142
+ session again.
@@ -16,6 +16,8 @@
16
16
  ".claude/hooks/inject-rules.mjs",
17
17
  ".claude/hooks/guard-secret-file.mjs",
18
18
  ".claude/hooks/guard-rulebook.mjs",
19
+ ".claude/hooks/guard-subagent-model.mjs",
20
+ ".claude/hooks/warn-subagent-routing.mjs",
19
21
  ".claude/hooks/lib/edit-input.mjs",
20
22
  ".claude/hooks/lib/hook-input.mjs",
21
23
  ".claude/skills/pr-ship/SKILL.md",
@@ -69,6 +71,7 @@
69
71
  "docs/decisions/gate-coverage.md",
70
72
  "docs/decisions/fail-open-guards.md",
71
73
  "docs/decisions/codex-adapter.md",
74
+ "docs/decisions/subagent-routing.md",
72
75
  "docs/decisions/closing-a-task.md",
73
76
  "docs/decisions/content-blind-revalidation.md",
74
77
  "docs/decisions/review-lanes.md",
@@ -84,6 +87,7 @@
84
87
  ],
85
88
  "meta": [
86
89
  ".claude/settings.json",
90
+ ".codex/config.toml",
87
91
  ".codex/hooks.json",
88
92
  "CLAUDE.md",
89
93
  "AGENTS.md"
@@ -9,7 +9,9 @@
9
9
  "0.6.0",
10
10
  "0.6.1",
11
11
  "0.6.2",
12
- "0.7.0"
12
+ "0.7.0",
13
+ "0.7.1",
14
+ "0.8.0"
13
15
  ],
14
16
  "files": {
15
17
  ".agents/skills/check-premises/SKILL.md": {
@@ -26,7 +28,8 @@
26
28
  "47922b182915cb92696f851174f2e88c4ae8c67dc119ef39a3a597ef1300fab5",
27
29
  "57f6add0504db45e7577474f97705fe474310e4cd3310cdb348a2c64eabe88cd",
28
30
  "18f4300de4c4c7ea380d726fb109fa224da950ef0a4cea5015ab1d408b823be5",
29
- "0a1b6575f6df8139766c8e71ba7320e14d2a5a5ed8ab26b6b43f7a006153d37d"
31
+ "0a1b6575f6df8139766c8e71ba7320e14d2a5a5ed8ab26b6b43f7a006153d37d",
32
+ "a0d11ca1745095cc5b68777915161f16a6d1602c21f6d273f475d1d07c4b8546"
30
33
  ]
31
34
  },
32
35
  ".agents/skills/new-invariant/SKILL.md": {
@@ -59,7 +62,8 @@
59
62
  "hashes": [
60
63
  "a2fb0d03c65701af9d2d69cbcb2582ae573f097b1e1071eb2916e5e4f7832b37",
61
64
  "9616e4767942b2021e34ab7bb76eec9a5c280cc608a0756abb02743741d5fc8a",
62
- "a6340b95a03000b89fb27f5357cdd9c7ec12b714054b5334b61a30da600405e8"
65
+ "a6340b95a03000b89fb27f5357cdd9c7ec12b714054b5334b61a30da600405e8",
66
+ "dc1d9c02879e383448f2336ae6433f960f86e8f8efb4ab8ccf9a17a5f7f524c3"
63
67
  ]
64
68
  },
65
69
  ".agents/skills/ro-debug/SKILL.md": {
@@ -227,7 +231,8 @@
227
231
  "ce2104ce697d3e5ed9210e85fe0daeb66b432a36868493f06fec54073a5fff31",
228
232
  "530f63a4b29a5cc67867fa7b23575db81d42609ae401bfc4089b05194c586c21",
229
233
  "a7359408d56e6bdbf268019ad9193d5fbf4a872385a65ba08564204e6a8f5b5f",
230
- "619999500386b3d2867bbd4799f86a800fe7e168083c6e35fb2e629a2c926c5f"
234
+ "619999500386b3d2867bbd4799f86a800fe7e168083c6e35fb2e629a2c926c5f",
235
+ "532dc5cdffb1464fb68889628664b1e7e345272c806cf2ccb7a063c6a3a7f1bf"
231
236
  ]
232
237
  },
233
238
  ".claude/rules/aws-cdk.md": {
@@ -438,7 +443,8 @@
438
443
  "hashes": [
439
444
  "32f847a2a3a5136cabcb42f6447da9f8cf98e2d20512212ee5201461128d7798",
440
445
  "b62348a27bcaa1d62a0e4ddd53873fab6cb75f9af8e8c69d24f6ce81c592c4e3",
441
- "b151045e8083c398e94571b35dc9568dfa0102abcb847cdf764f6f694914d53e"
446
+ "b151045e8083c398e94571b35dc9568dfa0102abcb847cdf764f6f694914d53e",
447
+ "f756b7ccc2a4459e58b2125408de1f91372cd6093bf6f4ed14e26dc2eb4190fc"
442
448
  ]
443
449
  },
444
450
  ".claude/scripts/revalidation-report.mjs": {
@@ -514,7 +520,8 @@
514
520
  "47922b182915cb92696f851174f2e88c4ae8c67dc119ef39a3a597ef1300fab5",
515
521
  "57f6add0504db45e7577474f97705fe474310e4cd3310cdb348a2c64eabe88cd",
516
522
  "18f4300de4c4c7ea380d726fb109fa224da950ef0a4cea5015ab1d408b823be5",
517
- "0a1b6575f6df8139766c8e71ba7320e14d2a5a5ed8ab26b6b43f7a006153d37d"
523
+ "0a1b6575f6df8139766c8e71ba7320e14d2a5a5ed8ab26b6b43f7a006153d37d",
524
+ "a0d11ca1745095cc5b68777915161f16a6d1602c21f6d273f475d1d07c4b8546"
518
525
  ]
519
526
  },
520
527
  ".claude/skills/new-invariant/SKILL.md": {
@@ -550,7 +557,8 @@
550
557
  "75adb84414a330db621ff4f1363b3926f537133199236a9e25bb9880b935e02e",
551
558
  "a2fb0d03c65701af9d2d69cbcb2582ae573f097b1e1071eb2916e5e4f7832b37",
552
559
  "9616e4767942b2021e34ab7bb76eec9a5c280cc608a0756abb02743741d5fc8a",
553
- "a6340b95a03000b89fb27f5357cdd9c7ec12b714054b5334b61a30da600405e8"
560
+ "a6340b95a03000b89fb27f5357cdd9c7ec12b714054b5334b61a30da600405e8",
561
+ "dc1d9c02879e383448f2336ae6433f960f86e8f8efb4ab8ccf9a17a5f7f524c3"
554
562
  ]
555
563
  },
556
564
  ".claude/skills/ro-debug/SKILL.md": {
@@ -9,5 +9,7 @@
9
9
  "0.6.0": "29ca0543368e922349945e52541a92b46ddcc5bd",
10
10
  "0.6.1": "f1d1e3dbd2161545d77ee5e7f90fa8e74b8a9f3e",
11
11
  "0.6.2": "2a1fc8e10cd2b65deb5c95d937fd7de39f2c92a2",
12
- "0.7.0": "6589db36e1daa63a99ec595191db1cccf7373196"
12
+ "0.7.0": "6589db36e1daa63a99ec595191db1cccf7373196",
13
+ "0.7.1": "52e879b6c103f6ba70493007b6a6466c57ea9824",
14
+ "0.8.0": "870f9a3ecae2881908ece8ec3e2ac13f84f505f5"
13
15
  }
@@ -1,17 +1,16 @@
1
1
  // CD brief §2: node-service's deployable artifact must actually build and run.
2
2
  // This bundles the server, boots the bundle over a real socket, and closes the
3
3
  // path — proving the artifact is genuine, not a stub.
4
- import { execFile, spawn } from 'node:child_process';
4
+ import { spawn } from 'node:child_process';
5
5
  import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
6
6
  import { tmpdir } from 'node:os';
7
7
  import type { AddressInfo } from 'node:net';
8
8
  import { createServer } from 'node:net';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
- import { promisify } from 'node:util';
12
11
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';
12
+ import { runPackageManager } from './package-manager.js';
13
13
 
14
- const exec = promisify(execFile);
15
14
  const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
16
15
  const dist = path.join(projectRoot, 'dist');
17
16
 
@@ -38,7 +37,7 @@ describe('deployable artifact (dist/)', () => {
38
37
  () => true,
39
38
  () => false,
40
39
  );
41
- if (!built) await exec('pnpm', ['build:artifact'], { cwd: projectRoot });
40
+ if (!built) await runPackageManager(['build:artifact'], { cwd: projectRoot });
42
41
  }, 180_000);
43
42
 
44
43
  afterAll(async () => {
@@ -0,0 +1,40 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+
6
+ import { runPackageManager } from './package-manager.js';
7
+
8
+ const literalArgs = ['space value', '&|<>^%!()', 'single "double"', ''];
9
+
10
+ describe('package-manager transport for the artifact fallback', () => {
11
+ let work: string | undefined;
12
+
13
+ afterEach(async () => {
14
+ if (work !== undefined) await rm(work, { recursive: true, force: true });
15
+ });
16
+
17
+ it('honors the trusted current pnpm CLI on every platform with literal argv and preserves a failing child output', async () => {
18
+ work = await mkdtemp(path.join(tmpdir(), 'artifact-package-manager-'));
19
+ const cli = path.join(work, 'pnpm-cli.cjs');
20
+ await writeFile(
21
+ cli,
22
+ [
23
+ 'process.stdout.write(JSON.stringify(process.argv.slice(2)));',
24
+ "process.stderr.write('artifact transport stderr');",
25
+ 'process.exit(7);',
26
+ ].join(''),
27
+ );
28
+
29
+ const failure = await runPackageManager(literalArgs, {
30
+ cwd: work,
31
+ env: { ...process.env, npm_execpath: cli },
32
+ }).catch((error: unknown) => error);
33
+
34
+ expect(failure).toMatchObject({
35
+ code: 7,
36
+ stdout: JSON.stringify(literalArgs),
37
+ stderr: 'artifact transport stderr',
38
+ });
39
+ });
40
+ });
@@ -0,0 +1,51 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ const exec = promisify(execFile);
7
+
8
+ const currentPnpmCli = (environment: NodeJS.ProcessEnv): string | undefined => {
9
+ const currentCli = environment.npm_execpath;
10
+ if (currentCli && /^pnpm(?:-cli)?\.(?:cjs|js)$/i.test(path.basename(currentCli)) && existsSync(currentCli)) {
11
+ return currentCli;
12
+ }
13
+ return undefined;
14
+ };
15
+
16
+ const pnpmCli = (environment: NodeJS.ProcessEnv): string => {
17
+ const currentCli = currentPnpmCli(environment);
18
+ if (currentCli) return currentCli;
19
+
20
+ const installedCli = path.join(
21
+ path.dirname(process.execPath),
22
+ 'node_modules',
23
+ 'pnpm',
24
+ 'bin',
25
+ 'pnpm.cjs',
26
+ );
27
+ if (existsSync(installedCli)) return installedCli;
28
+
29
+ const corepackCli = path.join(
30
+ path.dirname(process.execPath),
31
+ 'node_modules',
32
+ 'corepack',
33
+ 'dist',
34
+ 'pnpm.js',
35
+ );
36
+ if (existsSync(corepackCli)) return corepackCli;
37
+
38
+ throw new Error('Cannot locate pnpm JavaScript CLI; npm_execpath must name the installed pnpm CLI');
39
+ };
40
+
41
+ /** Runs the workspace package manager without passing its arguments through a Windows shell. */
42
+ export const runPackageManager = (
43
+ args: string[],
44
+ options: Parameters<typeof exec>[2],
45
+ ): ReturnType<typeof exec> => {
46
+ const environment = options?.env ?? process.env;
47
+ const currentCli = currentPnpmCli(environment);
48
+ if (currentCli) return exec(process.execPath, [currentCli, ...args], options);
49
+ if (process.platform !== 'win32') return exec('pnpm', args, options);
50
+ return exec(process.execPath, [pnpmCli(environment), ...args], options);
51
+ };
@@ -1,26 +1,27 @@
1
1
  // The composition root resolves the built web bundle relative to itself; the
2
2
  // resolution is a pure function so it can be tested without importing main.ts.
3
3
  //
4
- // What is NOT pinned here: the Windows half of the same defect — the old
5
- // `new URL(url).pathname` yielded `/C:/…`. Both expressions are identical on a
6
- // POSIX runner, so observing the difference would mean passing a path flavour
7
- // into `defaultStaticDirFor`, i.e. a parameter that exists only for the test.
8
- // The decision was to leave that hook out; percent-decoding below is the same
9
- // bug's POSIX-visible half, and it fails against the old expression.
4
+ // This native absolute fixture exercises the platform root spelling and
5
+ // percent-decoding without injecting a path flavor into the production helper.
10
6
  import path from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
11
8
  import { describe, expect, it } from 'vitest';
12
9
  import { defaultStaticDirFor } from '../src/static-dir.js';
13
10
 
11
+ const moduleUrl = pathToFileURL(
12
+ path.join(path.parse(process.cwd()).root, 'a b', 'proj', 'services', 'api', 'src', 'main.ts'),
13
+ ).href;
14
+
14
15
  describe('resolving the default static dir from the module url', () => {
15
16
  it('decodes percent-escapes in the path (a directory may contain a space)', () => {
16
- const resolved = defaultStaticDirFor('file:///Users/a%20b/proj/services/api/src/main.ts');
17
+ const resolved = defaultStaticDirFor(moduleUrl);
17
18
 
18
19
  expect(resolved).toContain('a b');
19
20
  expect(resolved).not.toContain('%20');
20
21
  });
21
22
 
22
23
  it('points three levels up from services/api/src at apps/web/out', () => {
23
- const resolved = defaultStaticDirFor('file:///Users/a%20b/proj/services/api/src/main.ts');
24
+ const resolved = defaultStaticDirFor(moduleUrl);
24
25
 
25
26
  expect(resolved.endsWith(path.join('a b', 'proj', 'apps', 'web', 'out'))).toBe(true);
26
27
  expect(path.isAbsolute(resolved)).toBe(true);