@lnilluv/pi-ralph-loop 0.2.1 → 1.0.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 (48) hide show
  1. package/.github/workflows/ci.yml +5 -2
  2. package/.github/workflows/release.yml +15 -43
  3. package/README.md +51 -113
  4. package/package.json +13 -5
  5. package/scripts/version-helper.ts +210 -0
  6. package/src/index.ts +1360 -275
  7. package/src/ralph-draft-context.ts +618 -0
  8. package/src/ralph-draft-llm.ts +297 -0
  9. package/src/ralph-draft.ts +33 -0
  10. package/src/ralph.ts +1457 -0
  11. package/src/runner-rpc.ts +434 -0
  12. package/src/runner-state.ts +822 -0
  13. package/src/runner.ts +957 -0
  14. package/src/secret-paths.ts +66 -0
  15. package/src/shims.d.ts +0 -3
  16. package/tests/fixtures/parity/migrate/OPEN_QUESTIONS.md +3 -0
  17. package/tests/fixtures/parity/migrate/RALPH.md +27 -0
  18. package/tests/fixtures/parity/migrate/golden/MIGRATED.md +15 -0
  19. package/tests/fixtures/parity/migrate/legacy/source.md +6 -0
  20. package/tests/fixtures/parity/migrate/legacy/source.yaml +3 -0
  21. package/tests/fixtures/parity/migrate/scripts/show-legacy.sh +10 -0
  22. package/tests/fixtures/parity/migrate/scripts/verify.sh +15 -0
  23. package/tests/fixtures/parity/research/OPEN_QUESTIONS.md +3 -0
  24. package/tests/fixtures/parity/research/RALPH.md +45 -0
  25. package/tests/fixtures/parity/research/claim-evidence-checklist.md +15 -0
  26. package/tests/fixtures/parity/research/expected-outputs.md +22 -0
  27. package/tests/fixtures/parity/research/scripts/show-snapshots.sh +13 -0
  28. package/tests/fixtures/parity/research/scripts/verify.sh +55 -0
  29. package/tests/fixtures/parity/research/snapshots/app-factory-ai-cli.md +11 -0
  30. package/tests/fixtures/parity/research/snapshots/docs-factory-ai-cli-features-missions.md +11 -0
  31. package/tests/fixtures/parity/research/snapshots/factory-ai-news-missions.md +11 -0
  32. package/tests/fixtures/parity/research/source-manifest.md +20 -0
  33. package/tests/index.test.ts +3529 -0
  34. package/tests/parity/README.md +9 -0
  35. package/tests/parity/harness.py +526 -0
  36. package/tests/parity-harness.test.ts +42 -0
  37. package/tests/parity-research-fixture.test.ts +34 -0
  38. package/tests/ralph-draft-context.test.ts +672 -0
  39. package/tests/ralph-draft-llm.test.ts +434 -0
  40. package/tests/ralph-draft.test.ts +168 -0
  41. package/tests/ralph.test.ts +1840 -0
  42. package/tests/runner-event-contract.test.ts +235 -0
  43. package/tests/runner-rpc.test.ts +358 -0
  44. package/tests/runner-state.test.ts +553 -0
  45. package/tests/runner.test.ts +1347 -0
  46. package/tests/secret-paths.test.ts +55 -0
  47. package/tests/version-helper.test.ts +75 -0
  48. package/tsconfig.json +3 -2
@@ -17,13 +17,16 @@ jobs:
17
17
  - name: Checkout
18
18
  uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
19
19
 
20
- - name: Setup Node.js 22
20
+ - name: Setup Node.js 22.22.1
21
21
  uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
22
22
  with:
23
- node-version: 22
23
+ node-version: 22.22.1
24
24
 
25
25
  - name: Install dependencies
26
26
  run: npm ci --ignore-scripts
27
27
 
28
+ - name: Test
29
+ run: npm test
30
+
28
31
  - name: Typecheck
29
32
  run: npm run typecheck
@@ -24,15 +24,18 @@ jobs:
24
24
  with:
25
25
  fetch-depth: 0
26
26
 
27
- - name: Setup Node.js 24
27
+ - name: Setup Node.js 24.14.1
28
28
  uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
29
29
  with:
30
- node-version: 24
30
+ node-version: 24.14.1
31
31
  registry-url: https://registry.npmjs.org
32
32
 
33
33
  - name: Install dependencies
34
34
  run: npm ci --ignore-scripts
35
35
 
36
+ - name: Test
37
+ run: npm test
38
+
36
39
  - name: Typecheck
37
40
  run: npm run typecheck
38
41
 
@@ -68,69 +71,38 @@ jobs:
68
71
  fi
69
72
 
70
73
  branch="${GITHUB_REF_NAME}"
71
- current_version="$(node -p "require('./package.json').version")"
72
- new_version="$(node - "$current_version" "$branch" "$bump" <<'NODE'
73
- const [current, branch, bump] = process.argv.slice(2);
74
- const match = current.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
75
- if (!match) throw new Error(`Unsupported version: ${current}`);
76
-
77
- let major = Number(match[1]);
78
- let minor = Number(match[2]);
79
- let patch = Number(match[3]);
80
- const prerelease = match[4];
81
-
82
- if (branch === 'dev') {
83
- if (prerelease && prerelease.startsWith('dev.')) {
84
- const currentN = Number(prerelease.split('.')[1] ?? '0');
85
- process.stdout.write(`${major}.${minor}.${patch}-dev.${currentN + 1}`);
86
- } else {
87
- if (bump === 'major') { major += 1; minor = 0; patch = 0; }
88
- else if (bump === 'minor') { minor += 1; patch = 0; }
89
- else { patch += 1; }
90
- process.stdout.write(`${major}.${minor}.${patch}-dev.0`);
91
- }
92
- } else {
93
- if (bump === 'major') { major += 1; minor = 0; patch = 0; }
94
- else if (bump === 'minor') { minor += 1; patch = 0; }
95
- else { patch += 1; }
96
- process.stdout.write(`${major}.${minor}.${patch}`);
97
- }
98
- NODE
99
- )"
74
+ npm_versions="$(npm view @lnilluv/pi-ralph-loop versions --json)"
75
+ git_tags="$(git tag --list)"
76
+ new_version="$(node --experimental-strip-types ./scripts/version-helper.ts "$branch" "$bump" "$npm_versions" "$git_tags")"
100
77
 
101
78
  echo "should_release=true" >> "$GITHUB_OUTPUT"
102
79
  echo "bump=$bump" >> "$GITHUB_OUTPUT"
103
80
  echo "new_version=$new_version" >> "$GITHUB_OUTPUT"
104
81
 
105
- - name: Bump version (main)
106
- if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'main'
107
- run: npm version ${{ steps.bump.outputs.bump }} --no-git-tag-version
108
-
109
- - name: Bump version (dev)
110
- if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'dev'
111
- run: npm version prerelease --preid dev --no-git-tag-version
82
+ - name: Bump version
83
+ if: steps.bump.outputs.should_release == 'true'
84
+ run: npm version ${{ steps.bump.outputs.new_version }} --no-git-tag-version
112
85
 
113
86
  - name: Capture version tag
114
87
  if: steps.bump.outputs.should_release == 'true'
115
88
  id: version
116
89
  run: |
117
- VERSION=$(node -p "require('./package.json').version")
118
- echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
90
+ echo "tag=v${{ steps.bump.outputs.new_version }}" >> "$GITHUB_OUTPUT"
119
91
 
120
92
  - name: Publish (main)
121
93
  if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'main'
122
- run: npx npm@11 publish --access public --provenance
94
+ run: npx -y npm@11.12.1 publish --access public --provenance
123
95
 
124
96
  - name: Publish (dev prerelease)
125
97
  if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'dev'
126
- run: npx npm@11 publish --access public --provenance --tag dev
98
+ run: npx -y npm@11.12.1 publish --access public --provenance --tag dev
127
99
 
128
100
  - name: Commit version bump and tag
129
101
  if: steps.bump.outputs.should_release == 'true'
130
102
  run: |
131
103
  git config user.name "github-actions[bot]"
132
104
  git config user.email "github-actions[bot]@users.noreply.github.com"
133
- VERSION=$(node -p "require('./package.json').version")
105
+ VERSION="${{ steps.bump.outputs.new_version }}"
134
106
  git add package.json package-lock.json
135
107
  git commit -m "chore(release): v${VERSION}"
136
108
  git tag "v${VERSION}"
package/README.md CHANGED
@@ -1,140 +1,78 @@
1
1
  # pi-ralph
2
+ Autonomous coding loops for pi with task folders, editable drafts, durable state, and per-iteration supervision.
2
3
 
3
- Autonomous coding loops for pi with mid-turn supervision.
4
+ ## Why use it
5
+ - Keep work in a task folder instead of a single chat turn.
6
+ - Re-run commands each iteration and feed the output back into the prompt.
7
+ - Keep short rolling memory in `RALPH_PROGRESS.md`.
8
+ - Store durable loop state in `.ralph-runner/`.
9
+ - Draft from plain language, then review before starting.
4
10
 
5
11
  ## Install
6
-
7
12
  ```bash
8
13
  pi install npm:@lnilluv/pi-ralph-loop
9
14
  ```
10
15
 
11
16
  ## Quick start
17
+ 1. Create `work/RALPH.md`.
18
+ 2. Run `/ralph --path work --arg owner="Ada Lovelace"`.
19
+ 3. If you want a draft first, use `/ralph-draft fix flaky auth tests`.
12
20
 
21
+ ## Concise `RALPH.md`
13
22
  ```md
14
- # my-task/RALPH.md
15
23
  ---
24
+ args:
25
+ - owner
16
26
  commands:
17
27
  - name: tests
18
- run: npm test -- --runInBand
28
+ run: npm test
19
29
  timeout: 60
20
- ---
21
- Fix failing tests using this output:
22
-
23
- {{ commands.tests }}
24
- ```
25
-
26
- Run `/ralph my-task` in pi.
27
-
28
- ## How it works
29
-
30
- On each iteration, pi-ralph reads `RALPH.md`, runs the configured commands, injects their output into the prompt through `{{ commands.<name> }}` placeholders, starts a fresh session, sends the prompt, and waits for completion. Failed test output appears in the next iteration, which creates a self-healing loop.
31
-
32
- ## RALPH.md format
33
-
34
- ```md
35
- ---
36
- commands:
37
- - name: tests
38
- run: npm test -- --runInBand
39
- timeout: 90
40
- - name: lint
41
- run: npm run lint
30
+ - name: verify
31
+ run: ./scripts/verify.sh
42
32
  timeout: 60
43
33
  max_iterations: 25
44
34
  timeout: 300
45
- completion_promise: "DONE"
46
- guardrails:
47
- block_commands:
48
- - "rm\\s+-rf\\s+/"
49
- - "git\\s+push"
50
- protected_files:
51
- - ".env*"
52
- - "**/secrets/**"
35
+ completion_promise: DONE
53
36
  ---
54
- You are fixing flaky tests in the auth module.
55
-
56
- <!-- This comment is stripped before sending to the agent -->
37
+ Fix the failing auth tests for {{ args.owner }}.
57
38
 
58
- Latest test output:
59
- {{ commands.tests }}
60
-
61
- Latest lint output:
62
- {{ commands.lint }}
63
-
64
- Iteration {{ ralph.iteration }} of {{ ralph.name }}.
65
- Apply the smallest safe fix and explain why it works.
39
+ Use {{ commands.tests }} and {{ commands.verify }} as evidence.
40
+ Stop with <promise>DONE</promise> only when the gate passes.
66
41
  ```
67
42
 
68
- | Field | Type | Default | Description |
69
- |-------|------|---------|-------------|
70
- | `commands` | array | `[]` | Commands to run each iteration |
71
- | `commands[].name` | string | required | Key for `{{ commands.<name> }}` |
72
- | `commands[].run` | string | required | Shell command |
73
- | `commands[].timeout` | number | `60` | Seconds before kill |
74
- | `max_iterations` | number | `50` | Stop after N iterations |
75
- | `timeout` | number | `300` | Per-iteration timeout in seconds; stops the loop if the agent is stuck |
76
- | `completion_promise` | string | | Agent signals completion by sending `<promise>DONE</promise>`; loop breaks on match |
77
- | `guardrails.block_commands` | string[] | `[]` | Regex patterns to block in bash |
78
- | `guardrails.protected_files` | string[] | `[]` | Glob patterns to block writes |
79
-
80
- ### Placeholders
81
-
82
- | Placeholder | Description |
83
- |-------------|-------------|
84
- | `{{ commands.<name> }}` | Output from the named command |
85
- | `{{ ralph.iteration }}` | Current 1-based iteration number |
86
- | `{{ ralph.name }}` | Directory name containing the RALPH.md |
87
-
88
- HTML comments (`<!-- ... -->`) are stripped from the prompt body after placeholder resolution, so you can annotate your RALPH.md freely.
43
+ ## Key features
44
+ - `/ralph` runs an existing task folder or `RALPH.md`, or drafts a new loop from plain language.
45
+ - `/ralph-draft` saves the draft without starting the loop.
46
+ - `/ralph-stop` writes a stop flag under `.ralph-runner/` so the loop exits after the current iteration.
47
+ - Frontmatter can declare `args` and `{{ args.name }}` placeholders; `--arg name=value` fills them when you run an existing task folder with `/ralph --path`.
48
+ - Commands that start with `./` run from the task directory, so checked-in helper scripts work.
49
+ - `RALPH_PROGRESS.md` is injected as short rolling memory and excluded from progress snapshots.
50
+ - The runner stores status, iteration records, events, transcripts, and stop signals in `.ralph-runner/`.
51
+ - Completion gating only stops early when the promise is seen and the readiness checks pass; a clear no-progress result will not trigger early stop.
52
+ - The loop can use a selected model and thinking level; if interactive draft strengthening has no authenticated model, it falls back to the deterministic draft path.
89
53
 
90
54
  ## Commands
91
-
92
- - `/ralph <path>`: Start the loop from a `RALPH.md` file or directory.
93
- - `/ralph-stop`: Request a graceful stop after the current iteration.
94
-
95
- ## Pi-only features
96
-
97
- ### Guardrails
98
-
99
- `guardrails.block_commands` and `guardrails.protected_files` come from RALPH frontmatter. The extension enforces them in the `tool_call` hook — but only for sessions created by the loop, so they don't leak into unrelated conversations. Matching bash commands are blocked, and writes/edits to protected file globs are denied.
100
-
101
- ### Cross-iteration memory
102
-
103
- After each iteration, the extension stores a short summary with iteration number and duration. In `before_agent_start`, it injects that history into the system prompt so the next run can avoid repeating completed work.
104
-
105
- ### Mid-turn steering
106
-
107
- In the `tool_result` hook, bash outputs are scanned for failure patterns. After three or more failures in the same iteration, the extension appends a stop-and-think warning to push root-cause analysis before another retry.
108
-
109
- ### Completion promise
110
-
111
- When `completion_promise` is set (e.g., `"DONE"`), the loop scans the agent's messages for `<promise>DONE</promise>` after each iteration. If found, the loop stops early — the agent signals it's finished rather than relying solely on `max_iterations`.
112
-
113
- ### Iteration timeout
114
-
115
- Each iteration has a configurable timeout (default 300 seconds). If the agent is stuck and doesn't become idle within the timeout, the loop stops with a warning. This prevents runaway iterations from running forever.
116
-
117
- ### Input validation
118
-
119
- The extension validates `RALPH.md` frontmatter before starting and on each re-parse: `max_iterations` must be a positive integer, `timeout` must be positive, `block_commands` regexes must compile, and commands must have non-empty names and run strings with positive timeouts.
120
-
121
- ## Comparison table
122
-
123
- | Feature | **@lnilluv/pi-ralph-loop** | pi-ralph | pi-ralph-wiggum | ralphi | ralphify |
124
- |---------|------------------------|----------------------|-----------------|--------|----------|
125
- | Command output injection | ✓ | ✗ | ✗ | ✗ | ✓ |
126
- | Fresh-context sessions | ✓ | ✓ | ✗ | ✓ | ✓ |
127
- | Mid-turn guardrails | ✓ | ✗ | ✗ | ✗ | ✗ |
128
- | Cross-iteration memory | ✓ | ✗ | ✗ | ✗ | ✗ |
129
- | Mid-turn steering | ✓ | ✗ | ✗ | ✗ | ✗ |
130
- | Live prompt editing | ✓ | ✗ | ✗ | ✗ | ✓ |
131
- | Completion promise | ✓ | ✗ | ✗ | ✗ | ✓ |
132
- | Iteration timeout | ✓ | ✗ | ✗ | ✗ | ✗ |
133
- | Session-scoped hooks | ✓ | ✗ | ✗ | ✗ | ✗ |
134
- | Input validation | ✓ | ✗ | ✗ | ✗ | ✗ |
135
- | Setup required | RALPH.md | config | RALPH.md | PRD pipeline | RALPH.md |
55
+ | Command | Use |
56
+ |---|---|
57
+ | `/ralph [path-or-task]` | Run an existing task folder or `RALPH.md`, or draft a new loop from a task description. |
58
+ | `/ralph-draft [path-or-task]` | Draft or edit a loop without starting it. |
59
+ | `/ralph-stop [path-or-task]` | Request a graceful stop after the current iteration. |
60
+
61
+ ## Config reference
62
+ | Field | Purpose |
63
+ |---|---|
64
+ | `commands` | Shell commands to run each iteration. |
65
+ | `args` | Declared runtime parameters for `--arg`. |
66
+ | `max_iterations` | Maximum iterations, from 1 to 50. |
67
+ | `inter_iteration_delay` | Delay between iterations, in seconds. |
68
+ | `timeout` | Per-iteration timeout, up to 300 seconds. |
69
+ | `completion_promise` | Early-stop marker such as `DONE`. |
70
+ | `required_outputs` | Files that must exist before early stop. |
71
+ | `guardrails.block_commands` | Regexes blocked in bash commands. |
72
+ | `guardrails.protected_files` | File globs protected from `write` and `edit`. |
73
+ | Model selection | Use a selected model and optional thinking level; the runner applies it before the prompt. |
74
+
75
+ Advanced behavior, validation, and edge cases live in `src/runner.ts`, `src/runner-state.ts`, `src/runner-rpc.ts`, `src/ralph.ts`, and `tests/`.
136
76
 
137
77
  ## License
138
-
139
78
  MIT
140
- # CI provenance test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lnilluv/pi-ralph-loop",
3
- "version": "0.2.1",
3
+ "version": "1.0.0",
4
4
  "description": "Pi-native ralph loop — autonomous coding iterations with mid-turn supervision",
5
5
  "type": "module",
6
6
  "pi": {
@@ -8,7 +8,11 @@
8
8
  "./src/index.ts"
9
9
  ]
10
10
  },
11
+ "engines": {
12
+ "node": "22.22.1 || 24.14.1"
13
+ },
11
14
  "scripts": {
15
+ "test": "node --test --experimental-strip-types $(find tests -type f -name '*.ts' | sort)",
12
16
  "typecheck": "tsc --noEmit"
13
17
  },
14
18
  "repository": {
@@ -16,18 +20,22 @@
16
20
  "url": "git+https://github.com/lnilluv/pi-ralph-loop.git"
17
21
  },
18
22
  "dependencies": {
19
- "yaml": "2.8.3",
20
- "minimatch": "10.2.3"
23
+ "@mariozechner/pi-ai": "0.66.1",
24
+ "minimatch": "10.2.3",
25
+ "yaml": "2.8.3"
21
26
  },
22
27
  "peerDependencies": {
23
28
  "@mariozechner/pi-coding-agent": "*"
24
29
  },
25
30
  "keywords": [
26
31
  "pi-extension",
27
- "pi-package",
28
32
  "ralph",
29
33
  "autonomous",
30
34
  "loop"
31
35
  ],
32
- "license": "MIT"
36
+ "license": "MIT",
37
+ "devDependencies": {
38
+ "@types/node": "25.6.0",
39
+ "typescript": "6.0.2"
40
+ }
33
41
  }
@@ -0,0 +1,210 @@
1
+ import { resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ export type VersionBump = "major" | "minor" | "patch";
5
+ export type ReleaseBranch = "main" | "dev";
6
+
7
+ export interface ReleaseVersionRequest {
8
+ branch: ReleaseBranch;
9
+ bump: VersionBump;
10
+ npmVersions: readonly string[] | string;
11
+ gitTags: readonly string[] | string;
12
+ }
13
+
14
+ type ParsedVersion = {
15
+ major: number;
16
+ minor: number;
17
+ patch: number;
18
+ prerelease?: string;
19
+ };
20
+
21
+ const STABLE_VERSION = /^\d+\.\d+\.\d+$/;
22
+ const SEMVER_VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
23
+ const DEV_PRERELEASE = /^dev\.(\d+)$/;
24
+
25
+ function normalizeVersionList(input: readonly string[] | string): string[] {
26
+ if (Array.isArray(input)) {
27
+ return input.map((version) => version.trim()).filter(Boolean);
28
+ }
29
+
30
+ const trimmed = input.trim();
31
+ if (!trimmed) {
32
+ return [];
33
+ }
34
+
35
+ try {
36
+ const parsed = JSON.parse(trimmed) as unknown;
37
+ if (Array.isArray(parsed)) {
38
+ return parsed.map((value) => String(value).trim()).filter(Boolean);
39
+ }
40
+
41
+ if (typeof parsed === "string") {
42
+ return parsed.trim() ? [parsed.trim()] : [];
43
+ }
44
+ } catch {
45
+ // Fall through to line-based parsing.
46
+ }
47
+
48
+ return trimmed
49
+ .split(/[\r\n,]+/)
50
+ .map((version) => version.trim())
51
+ .filter(Boolean);
52
+ }
53
+
54
+ function stripGitTagPrefix(version: string): string {
55
+ return version.startsWith("v") ? version.slice(1) : version;
56
+ }
57
+
58
+ function parseVersion(version: string): ParsedVersion | null {
59
+ const match = version.match(SEMVER_VERSION);
60
+ if (!match) {
61
+ return null;
62
+ }
63
+
64
+ return {
65
+ major: Number(match[1]),
66
+ minor: Number(match[2]),
67
+ patch: Number(match[3]),
68
+ prerelease: match[4],
69
+ };
70
+ }
71
+
72
+ function isStableVersion(version: string): boolean {
73
+ return STABLE_VERSION.test(version);
74
+ }
75
+
76
+ function compareVersions(left: string, right: string): number {
77
+ const leftParsed = parseVersion(left);
78
+ const rightParsed = parseVersion(right);
79
+
80
+ if (!leftParsed || !rightParsed) {
81
+ return 0;
82
+ }
83
+
84
+ if (leftParsed.major !== rightParsed.major) {
85
+ return leftParsed.major - rightParsed.major;
86
+ }
87
+
88
+ if (leftParsed.minor !== rightParsed.minor) {
89
+ return leftParsed.minor - rightParsed.minor;
90
+ }
91
+
92
+ return leftParsed.patch - rightParsed.patch;
93
+ }
94
+
95
+ function maxVersion(versions: string[]): string | null {
96
+ return versions.reduce<string | null>((currentMax, version) => {
97
+ if (!currentMax) {
98
+ return version;
99
+ }
100
+
101
+ return compareVersions(version, currentMax) > 0 ? version : currentMax;
102
+ }, null);
103
+ }
104
+
105
+ function incStable(version: string, bump: VersionBump): string {
106
+ const parsed = parseVersion(version);
107
+ if (!parsed) {
108
+ throw new Error(`Unsupported version: ${version}`);
109
+ }
110
+
111
+ if (bump === "major") {
112
+ return `${parsed.major + 1}.0.0`;
113
+ }
114
+
115
+ if (bump === "minor") {
116
+ return `${parsed.major}.${parsed.minor + 1}.0`;
117
+ }
118
+
119
+ return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`;
120
+ }
121
+
122
+ function collectStableVersions(input: readonly string[] | string): string[] {
123
+ return normalizeVersionList(input)
124
+ .map(stripGitTagPrefix)
125
+ .filter(isStableVersion);
126
+ }
127
+
128
+ function highestStableVersion(npmVersions: readonly string[] | string, gitTags: readonly string[] | string): string {
129
+ const stableNpm = collectStableVersions(npmVersions);
130
+ const stableTags = collectStableVersions(gitTags);
131
+ const highest = maxVersion([...stableNpm, ...stableTags]);
132
+ return highest ?? "0.0.0";
133
+ }
134
+
135
+ function stableReleaseExistsAtOrAboveOne(npmVersions: readonly string[] | string, gitTags: readonly string[] | string): boolean {
136
+ return [...collectStableVersions(npmVersions), ...collectStableVersions(gitTags)].some(
137
+ (version) => compareVersions(version, "1.0.0") >= 0,
138
+ );
139
+ }
140
+
141
+ function nextPrereleaseNumber(targetStable: string, npmVersions: readonly string[] | string, gitTags: readonly string[] | string): number {
142
+ const used = new Set<number>();
143
+
144
+ for (const rawVersion of [...normalizeVersionList(npmVersions), ...normalizeVersionList(gitTags)]) {
145
+ const version = stripGitTagPrefix(rawVersion);
146
+ const parsed = parseVersion(version);
147
+ if (!parsed || `${parsed.major}.${parsed.minor}.${parsed.patch}` !== targetStable) {
148
+ continue;
149
+ }
150
+
151
+ const prerelease = parsed.prerelease;
152
+ if (!prerelease) {
153
+ continue;
154
+ }
155
+
156
+ const match = prerelease.match(DEV_PRERELEASE);
157
+ if (!match) {
158
+ continue;
159
+ }
160
+
161
+ used.add(Number(match[1]));
162
+ }
163
+
164
+ let next = 0;
165
+ while (used.has(next)) {
166
+ next += 1;
167
+ }
168
+
169
+ return next;
170
+ }
171
+
172
+ export function computeReleaseVersion({ branch, bump, npmVersions, gitTags }: ReleaseVersionRequest): string {
173
+ const baseStable = highestStableVersion(npmVersions, gitTags);
174
+ let targetStable = incStable(baseStable, bump);
175
+
176
+ if (!stableReleaseExistsAtOrAboveOne(npmVersions, gitTags)) {
177
+ targetStable = compareVersions(targetStable, "1.0.0") < 0 ? "1.0.0" : targetStable;
178
+ }
179
+
180
+ if (branch === "main") {
181
+ return targetStable;
182
+ }
183
+
184
+ const prereleaseNumber = nextPrereleaseNumber(targetStable, npmVersions, gitTags);
185
+ return `${targetStable}-dev.${prereleaseNumber}`;
186
+ }
187
+
188
+ export const nextReleaseVersion = computeReleaseVersion;
189
+
190
+ function isReleaseBranch(value: string): value is ReleaseBranch {
191
+ return value === "main" || value === "dev";
192
+ }
193
+
194
+ function isVersionBump(value: string): value is VersionBump {
195
+ return value === "major" || value === "minor" || value === "patch";
196
+ }
197
+
198
+ function main(argv: string[]): void {
199
+ const [branch, bump, npmVersions, gitTags] = argv;
200
+
201
+ if (!branch || !bump || !npmVersions || !gitTags || !isReleaseBranch(branch) || !isVersionBump(bump)) {
202
+ throw new Error("Usage: version-helper <main|dev> <major|minor|patch> <npm-versions> <git-tags>");
203
+ }
204
+
205
+ process.stdout.write(computeReleaseVersion({ branch, bump, npmVersions, gitTags }));
206
+ }
207
+
208
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
209
+ main(process.argv.slice(2));
210
+ }