pantheon-opencode 1.5.0-beta.5 → 1.5.0-beta.7

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.
package/CHANGELOG.md CHANGED
@@ -18,6 +18,76 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
18
18
 
19
19
  ## ✅ Closed Issues
20
20
 
21
+ ## [v1.5.0-beta.7] - 2026-09-13
22
+
23
+ <!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
24
+ move this section to a versioned entry and reset the template below. -->
25
+
26
+ ## 🆕 What's New
27
+
28
+ - **Cost CLI fallback + `PANTHEON_NODE`:** `pantheon_cost` falls back to a
29
+ read-only `node scripts/cost.mjs` subprocess when `node:sqlite` is
30
+ unavailable in the host runtime (for example under Bun). The fallback
31
+ resolves a real Node.js binary — `PANTHEON_NODE` first, then `node` on
32
+ `PATH` — probes it for `node:sqlite` support (Node.js >= 22.5), and runs it
33
+ with an argument array, never a shell. Previously the tool returned
34
+ `status: UNSUPPORTED` with no fallback.
35
+ - **Delegation engine contract (ADR-0011):** legacy `pantheon_delegate` remains
36
+ the default engine through v1.5.x; native delegation is opt-in and
37
+ experimental via `PANTHEON_DELEGATE_MODE=native` until v1.6. See
38
+ [ADR-0011](https://github.com/ils15/pantheon-opencode/pull/126) (Proposed).
39
+
40
+ ## 🐞 Fixed
41
+
42
+ - **Legacy read-only bypass on retry (P0):** a retried delegation now
43
+ re-applies read-only enforcement (the same rule as the initial child) and
44
+ registers the retry session, closing the bypass where a retried child could
45
+ write despite `read_only: true` or being a read-only agent.
46
+ - **Kill-switch scope (P0):** `PANTHEON_DELEGATION=off` now also disables the
47
+ legacy engine, not only the native manager — the legacy delegate/read/list
48
+ tools throw as documented.
49
+ - **Delegation signal leak (P1):** the legacy finalize path now deletes the
50
+ auto-wake signal file, so terminal delegations no longer leave stale
51
+ `.signal.json` artifacts behind.
52
+ - **Crash recovery is durable (P1):** `recoverRunningJobs` persists the
53
+ `error` state, marks the job `terminalUnreconciled`, and emits the terminal
54
+ signal plus notification for every reaped job instead of updating memory
55
+ only.
56
+ - **Atomic concurrency (P1):** `registerLaunch` validates
57
+ `maxConcurrentPerAgent` before any mutation, so concurrent dispatches cannot
58
+ both claim the last slot. A refused launch tears down the orphan child
59
+ session (read-only unregister plus `session.delete`) and the retry path
60
+ re-validates the same limit.
61
+ - **Legacy board hygiene (P1):** the legacy finalize path now runs
62
+ `pruneCompleted` and `enforceEntryCap`, matching the native manager and
63
+ preventing unbounded board growth.
64
+ - **Resilient V2 events:** a native child refused by the per-agent limit is
65
+ logged and skipped instead of aborting the rest of the event handler.
66
+ ## [v1.5.0-beta.6] - 2026-09-12
67
+
68
+ <!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
69
+ move this section to a versioned entry and reset the template below. -->
70
+
71
+ ## 🆕 What's New
72
+
73
+ - **Delegation panel v2:** live tool activity per running delegation
74
+ (`↳ bash npm test` — what the child is doing right now), a paginated
75
+ `Archived (n)` section for terminal reports beyond the recent 8, and a
76
+ distinct `retry` state (⟳ marker, RETRYING label) instead of lumping
77
+ retries into running.
78
+
79
+ ## 🐞 Fixed
80
+
81
+ - **Stale plugin refs from old installs:** the installer now prunes
82
+ `pantheon-opencode` plugin references pointing at other installed copies of
83
+ the package (older nvm prefixes, npx cache hashes). Previously every init
84
+ accumulated refs from past installs and the oldest — without the newest
85
+ fixes — won the plugin load race, silently disabling native-task
86
+ mirroring. Third-party plugins sharing the same file names remain
87
+ protected.
88
+ - **Docs:** `@beta` dist-tag guidance on all npx commands — plain
89
+ `npx pantheon-opencode` resolves the stable release and silently downgrades
90
+ the installed TUI copy.
21
91
  ## [v1.5.0-beta.5] - 2026-09-12
22
92
 
23
93
  <!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
package/README.md CHANGED
@@ -76,22 +76,43 @@ availability and configuration of OpenCode and any optional services you choose
76
76
  to use. Check the [releases](https://github.com/ils15/pantheon-opencode/releases)
77
77
  and [changelog](CHANGELOG.md) for the latest published changes.
78
78
 
79
- ## Native delegation mode
79
+ ## Delegation engines (legacy default, native opt-in)
80
80
 
81
- Set `PANTHEON_DELEGATE_MODE=native` to use OpenCode's native child-session
82
- delegation. Native mode is strict: it does not use foreground fallback, model
83
- failover, or retry; it performs a lazy capability probe on the first dispatch,
84
- and includes the resulting status in the delegation receipt.
81
+ Pantheon exposes two delegation engines behind one tool surface:
82
+
83
+ - **Legacy (default)** — `pantheon_delegate`, the original engine registered by
84
+ the V1 plugin. No configuration is required.
85
+ - **Native (opt-in, experimental)** — OpenCode's native `task()` child-session
86
+ engine, selected with `PANTHEON_DELEGATE_MODE=native`. Native is strict: it
87
+ does not use foreground fallback, model failover, or retry; it performs a
88
+ lazy capability probe on the first dispatch and includes the resulting
89
+ status in the delegation receipt.
90
+
91
+ Legacy stays the default for v1.5.x; native is not promoted to default until
92
+ v1.6. See [ADR-0011](.pantheon/memory-bank/adr/0011-delegation-engine-contract.md)
93
+ (Proposed) for the engine contract, rationale, and migration prerequisites.
94
+
95
+ **Kill-switch:** set `PANTHEON_DELEGATION=off` (case-insensitive) to disable
96
+ delegation entirely. The switch covers **both** engines — the legacy
97
+ `pantheon_delegate`/read/list tools throw, and the native manager throws too.
85
98
 
86
99
  ## Cost tool backend
87
100
 
88
- `pantheon_cost` has one backend: read-only `node:sqlite` against the selected
89
- `opencode.db`. On Node versions without `node:sqlite` (Node < 22.5), the tool
90
- returns `status: UNSUPPORTED`; it has no CLI fallback. Database failures return
91
- a contract status such as `UNAVAILABLE` or
92
- `CORRUPT_DATA` and include diagnostic detail, including captured stderr when
101
+ `pantheon_cost` prefers a read-only `node:sqlite` backend against the selected
102
+ `opencode.db`. When `node:sqlite` is unavailable in the host runtime (for
103
+ example under Bun), the tool falls back to spawning `node scripts/cost.mjs` as
104
+ a read-only subprocess. The fallback resolves a real Node.js binary —
105
+ `PANTHEON_NODE` first, then `node` on `PATH` — verifies it supports
106
+ `node:sqlite` (Node.js >= 22.5), and runs the script with an argument array,
107
+ never a shell. If neither source resolves, the tool returns `status:
108
+ UNSUPPORTED`. Database failures return a contract status such as `UNAVAILABLE`
109
+ or `CORRUPT_DATA` and include diagnostic detail, including captured stderr when
93
110
  available.
94
111
 
112
+ | Variable | Default | Purpose |
113
+ |----------|---------|---------|
114
+ | `PANTHEON_NODE` | `node` from `PATH` | Path to a Node.js >= 22.5 binary used by the CLI fallback; an explicit path must exist and be executable, otherwise the tool fails fast instead of ignoring the override |
115
+
95
116
 
96
117
  ## Code-mode execution (explicit opt-in)
97
118
 
@@ -218,8 +239,13 @@ V1 plugin into V2 or provide V2 with V1 hooks.
218
239
  One command keeps an existing installation current:
219
240
 
220
241
  ```bash
221
- npx pantheon-opencode update # npm beta channel + config refresh
222
- npx pantheon-opencode update --stable # stable channel instead
242
+ # Without a global install, ALWAYS pin the dist-tag — plain `npx
243
+ # pantheon-opencode` resolves `latest`, which is the stable release (1.4.3):
244
+ npx pantheon-opencode@beta update # npm beta channel + config refresh
245
+ npx pantheon-opencode@beta update --stable # stable channel instead
246
+
247
+ # With a global install, use the global bin (defaults to the beta channel):
248
+ pantheon-opencode update
223
249
  ```
224
250
 
225
251
  `update` compares your installed version with the npm dist-tag, runs
package/README.pt-BR.md CHANGED
@@ -184,8 +184,13 @@ converte um plugin V1 em V2 nem dá hooks V1 ao V2.
184
184
  Um comando mantém uma instalação existente em dia:
185
185
 
186
186
  ```bash
187
- npx pantheon-opencode update # canal beta do npm + refresh do config
188
- npx pantheon-opencode update --stable # canal estável
187
+ # Sem instalação global, SEMPRE fixe o dist-tag — `npx pantheon-opencode`
188
+ # puro resolve a tag `latest`, que é o release estável (1.4.3):
189
+ npx pantheon-opencode@beta update # canal beta do npm + refresh do config
190
+ npx pantheon-opencode@beta update --stable # canal estável
191
+
192
+ # Com instalação global, use o bin global (o padrão é o canal beta):
193
+ pantheon-opencode update
189
194
  ```
190
195
 
191
196
  O `update` compara a versão instalada com o dist-tag do npm, roda
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.5.0-beta.5",
3
+ "version": "1.5.0-beta.7",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -41,8 +41,9 @@
41
41
  "test:security": "npm run secret-scan",
42
42
  "test:node": "node --test tests/*.mjs tests/pantheon/*.mjs",
43
43
  "test:ts": "for f in tests/pantheon/*.test.ts; do if [ \"$f\" = tests/pantheon/plugin-v2-contract.test.ts ]; then NODE_OPTIONS=--conditions=import tsx \"$f\"; else tsx \"$f\"; fi || exit 1; done",
44
- "test": "python3 -m pytest tests/ -q 2>&1; status=$?; if [ \"$status\" -eq 5 ]; then echo \"No tests found\"; fi; exit \"$status\"",
45
- "test:ci": "python3 -m pytest tests/ -q --tb=short 2>&1"
44
+ "test:all": "npm run test:ci && npm run test:node && npm run test:ts",
45
+ "test": "npm run test:all",
46
+ "test:ci": "python3 -m pytest -q --tb=short 2>&1"
46
47
  },
47
48
  "bin": {
48
49
  "pantheon-opencode": "bin/pantheon-init.mjs"
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon",
3
3
  "publisher": "ils15",
4
- "version": "1.5.0-beta.5",
4
+ "version": "1.5.0-beta.7",
5
5
  "displayName": "Pantheon",
6
6
  "description": "Conductor-Delegate orchestration platform with 14 specialized agents, 21 skills, and OpenCode support.",
7
7
  "agents": "./src/agents",
package/pyproject.toml CHANGED
@@ -4,13 +4,21 @@ build-backend = "setuptools.backends._legacy:_Backend"
4
4
 
5
5
  [project]
6
6
  name = "pantheon"
7
- version = "1.5.0-beta.5"
7
+ version = "1.5.0-beta.7"
8
8
  description = "Multi-agent orchestration framework"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = []
11
11
 
12
12
  [tool.pytest.ini_options]
13
- testpaths = ["tests"]
13
+ # `tests/` holds the main suite; the two entries below are orphaned test
14
+ # modules that live next to the code they cover and are collected here so
15
+ # pytest (and therefore `npm test`) exercises them instead of letting them
16
+ # rot as dead code.
17
+ testpaths = [
18
+ "tests",
19
+ "scripts/vector_memory/test_memory.py",
20
+ "benchmarks/beta2/test_beta2.py",
21
+ ]
14
22
  asyncio_mode = "auto"
15
23
  addopts = "-v --tb=short"
16
24
 
@@ -217,6 +217,21 @@ function managedPluginIdentity(ref) {
217
217
  if (absolutePath === normalizePluginPath(resolve(ROOT, PANTHEON_V2_LEGACY_FILE))) {
218
218
  return PANTHEON_V2_PLUGIN
219
219
  }
220
+ // beta.5: stale refs from OTHER installed copies of this package (older
221
+ // nvm prefixes, npx cache hashes). Only paths inside
222
+ // `node_modules/pantheon-opencode/` count as installed copies — a dev
223
+ // checkout elsewhere (e.g. a vendor folder named pantheon-opencode) remains
224
+ // protected by the third-party rule above.
225
+ const installedMarker = '/node_modules/pantheon-opencode/'
226
+ const withSlashes = normalizePluginPath(normalized)
227
+ const markerIndex = withSlashes.lastIndexOf(installedMarker)
228
+ if (markerIndex !== -1) {
229
+ const suffix = withSlashes.slice(markerIndex + installedMarker.length)
230
+ if (suffix === PANTHEON_V1_PLUGIN) return PANTHEON_V1_PLUGIN
231
+ if (suffix === PANTHEON_V1_HOOKS) return PANTHEON_V1_HOOKS
232
+ if (suffix === PANTHEON_V2_PLUGIN) return PANTHEON_V2_PLUGIN
233
+ if (suffix === PANTHEON_V2_LEGACY_FILE) return PANTHEON_V2_PLUGIN
234
+ }
220
235
  }
221
236
 
222
237
  return null
@@ -777,13 +777,32 @@ def _validate_heartbeat(heartbeat: object) -> None:
777
777
  raise ValueError("heartbeat.turn_count must be a non-negative integer")
778
778
 
779
779
 
780
+ def _validate_version_metadata(version: object) -> None:
781
+ """Validate optional checkpoint ``version`` metadata leniently.
782
+
783
+ ``version`` is opaque application metadata (rehydrate does not consume it),
784
+ not a persistence requirement. It may be a positive integer, a JSON float
785
+ such as ``1.0``, or a version label like ``"1.5.0-beta.2"``. Absent/null
786
+ versions and any non-empty string remain valid, preserving existing integer
787
+ checkpoints while allowing forward-compatible labels. Only bools, empty
788
+ strings, non-finite/zero/negative numbers, and structured values are
789
+ rejected.
790
+ """
791
+ if version is None:
792
+ return
793
+ if isinstance(version, bool):
794
+ raise ValueError("version must be a positive integer or version label")
795
+ if isinstance(version, (int, float)):
796
+ if not 1 <= version < float("inf"):
797
+ raise ValueError("version must be a positive integer or version label")
798
+ return
799
+ if not isinstance(version, str) or not version.strip():
800
+ raise ValueError("version must be a positive integer or version label")
801
+
802
+
780
803
  def _validate_checkpoint_shape(checkpoint: dict) -> None:
781
804
  """Validate bounded structured checkpoint fields without sanitizing them."""
782
- version = checkpoint.get("version")
783
- if version is not None and (
784
- isinstance(version, bool) or not isinstance(version, int) or version < 1
785
- ):
786
- raise ValueError("version must be a positive integer")
805
+ _validate_version_metadata(checkpoint.get("version"))
787
806
  validators = (
788
807
  ("goal", _validate_goal),
789
808
  ("phase", _validate_phase),
@@ -21,8 +21,8 @@
21
21
  */
22
22
 
23
23
  import { execFileSync } from 'node:child_process'
24
- import { readFileSync } from 'node:fs'
25
- import { dirname, join } from 'node:path'
24
+ import { existsSync, readFileSync } from 'node:fs'
25
+ import { basename, dirname, join } from 'node:path'
26
26
  import { fileURLToPath } from 'node:url'
27
27
  import {
28
28
  commitPreparedUpdates,
@@ -37,6 +37,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
37
37
  const ROOT = join(__dirname, '..')
38
38
 
39
39
  const CHANGELOG_PATH = join(ROOT, 'CHANGELOG.md')
40
+ const ZENODO_PATH = join(ROOT, '.zenodo.json')
41
+ const CITATION_PATH = join(ROOT, 'CITATION.cff')
40
42
 
41
43
  // ---------------------------------------------------------------------------
42
44
  // Git helpers
@@ -111,6 +113,90 @@ function prepareManifests(newVersion) {
111
113
  return { inventory, updates: prepareInventoryVersion(inventory, newVersion) }
112
114
  }
113
115
 
116
+ /**
117
+ * Serialize `.zenodo.json` the way Biome 2.x formats it: objects stay expanded
118
+ * (matching `JSON.stringify(value, null, 2)`), while arrays containing only
119
+ * primitives are collapsed onto one line when they fit within the line width.
120
+ * A plain `JSON.stringify` emits the `keywords` array multiline, which Biome
121
+ * collapses — reintroducing a lint failure on the next release bump.
122
+ *
123
+ * @param {unknown} value parsed JSON document
124
+ * @param {number} [lineWidth] formatter line width (matches biome.json)
125
+ * @returns {string} Biome-compatible JSON with a trailing newline
126
+ */
127
+ function formatZenodoJson(value, lineWidth = 100) {
128
+ const indent = (depth) => ' '.repeat(depth)
129
+ const render = (node, depth, column) => {
130
+ if (Array.isArray(node)) {
131
+ if (node.length === 0) return '[]'
132
+ const primitiveOnly = node.every((item) => item === null || typeof item !== 'object')
133
+ if (primitiveOnly) {
134
+ const inline = `[${node.map((item) => JSON.stringify(item)).join(', ')}]`
135
+ if (column + inline.length <= lineWidth) return inline
136
+ }
137
+ const body = node
138
+ .map((item) => `${indent(depth + 1)}${render(item, depth + 1, indent(depth + 1).length)}`)
139
+ .join(',\n')
140
+ return `[\n${body}\n${indent(depth)}]`
141
+ }
142
+ if (node !== null && typeof node === 'object') {
143
+ const entries = Object.entries(node)
144
+ if (entries.length === 0) return '{}'
145
+ const body = entries
146
+ .map(([key, item]) => {
147
+ const keyPrefix = `${indent(depth + 1)}${JSON.stringify(key)}: `
148
+ return `${keyPrefix}${render(item, depth + 1, keyPrefix.length)}`
149
+ })
150
+ .join(',\n')
151
+ return `{\n${body}\n${indent(depth)}}`
152
+ }
153
+ return JSON.stringify(node)
154
+ }
155
+ return `${render(value, 0, 0)}\n`
156
+ }
157
+
158
+ /**
159
+ * Keep the Zenodo deposition metadata (.zenodo.json) and the citation file
160
+ * (CITATION.cff) aligned with the version and date being released. Zenodo's
161
+ * release validator hard-fails when CITATION.cff version differs from the
162
+ * release tag, so these must be bumped together with the manifests.
163
+ */
164
+ function prepareReleaseMetadata(newVersion, dateStr) {
165
+ if (!SEMVER_PATTERN.test(newVersion)) throw new Error(`invalid version: ${newVersion}`)
166
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) throw new Error(`invalid date: ${dateStr}`)
167
+ const tagUrl = `https://github.com/ils15/pantheon-opencode/releases/tag/v${newVersion}`
168
+ const updates = []
169
+
170
+ if (existsSync(ZENODO_PATH)) {
171
+ const zenodo = JSON.parse(readFileSync(ZENODO_PATH, 'utf8'))
172
+ const zenodoPrevious = typeof zenodo.version === 'string' ? zenodo.version : null
173
+ if (zenodoPrevious && zenodoPrevious !== newVersion && typeof zenodo.description === 'string') {
174
+ zenodo.description = zenodo.description.replaceAll(zenodoPrevious, newVersion)
175
+ }
176
+ zenodo.version = newVersion
177
+ zenodo.publication_date = dateStr
178
+ zenodo.url = tagUrl
179
+ updates.push({ path: ZENODO_PATH, content: formatZenodoJson(zenodo) })
180
+ }
181
+
182
+ if (existsSync(CITATION_PATH)) {
183
+ const citationRaw = readFileSync(CITATION_PATH, 'utf8')
184
+ const citationMatch = /^version:\s*["']?([^"'\s]+)["']?\s*$/m.exec(citationRaw)
185
+ const citationPrevious = citationMatch ? citationMatch[1] : null
186
+ let citation = citationRaw
187
+ if (citationPrevious && citationPrevious !== newVersion) {
188
+ citation = citation.replaceAll(`Pantheon v${citationPrevious}`, `Pantheon v${newVersion}`)
189
+ }
190
+ citation = citation
191
+ .replace(/^version:.*$/m, `version: ${newVersion}`)
192
+ .replace(/^date-released:.*$/m, `date-released: ${dateStr}`)
193
+ .replace(/^url:.*$/m, `url: ${tagUrl}`)
194
+ updates.push({ path: CITATION_PATH, content: citation })
195
+ }
196
+
197
+ return updates
198
+ }
199
+
114
200
  // ---------------------------------------------------------------------------
115
201
  // CHANGELOG updater
116
202
  //
@@ -243,8 +329,8 @@ function contentHasUnreleased() {
243
329
  return readFileSync(CHANGELOG_PATH, 'utf8').includes('## [Unreleased]')
244
330
  }
245
331
 
246
- function commitPreparedRelease(manifestPlan, changelogPlan) {
247
- const updates = [...manifestPlan.updates]
332
+ function commitPreparedRelease(manifestPlan, changelogPlan, metadataUpdates = []) {
333
+ const updates = [...manifestPlan.updates, ...metadataUpdates]
248
334
  if (changelogPlan.changed) {
249
335
  updates.push({ path: CHANGELOG_PATH, content: changelogPlan.content })
250
336
  }
@@ -252,6 +338,9 @@ function commitPreparedRelease(manifestPlan, changelogPlan) {
252
338
  for (const document of manifestPlan.inventory.documents) {
253
339
  console.log(` ✓ ${document.entry.file} → ${manifestPlan.version}`)
254
340
  }
341
+ for (const { path } of metadataUpdates) {
342
+ console.log(` ✓ ${basename(path)} → ${manifestPlan.version}`)
343
+ }
255
344
  if (changelogPlan.changed) {
256
345
  console.log(
257
346
  changelogPlan.notesGenerated
@@ -314,7 +403,11 @@ switch (command) {
314
403
  console.log(`Bumping ${current} → ${newVersion} (beta)`)
315
404
  const changelogPlan = prepareUnreleased(newVersion, date)
316
405
  const manifestPlan = prepareManifests(newVersion)
317
- commitPreparedRelease({ ...manifestPlan, version: newVersion }, changelogPlan)
406
+ commitPreparedRelease(
407
+ { ...manifestPlan, version: newVersion },
408
+ changelogPlan,
409
+ prepareReleaseMetadata(newVersion, date),
410
+ )
318
411
  console.log(`\nDone. Commit the version inventory and CHANGELOG as v${newVersion}.`)
319
412
  console.log(`Tag v${newVersion} will be created by the release workflow after merge to main.`)
320
413
  break
@@ -347,7 +440,11 @@ switch (command) {
347
440
  const notesBody = generateNotes()
348
441
  const changelogPlan = prepareUnreleased(current, date, notesBody)
349
442
  const manifestPlan = prepareManifests(current)
350
- commitPreparedRelease({ ...manifestPlan, version: current }, changelogPlan)
443
+ commitPreparedRelease(
444
+ { ...manifestPlan, version: current },
445
+ changelogPlan,
446
+ prepareReleaseMetadata(current, date),
447
+ )
351
448
  console.log(`Tag v${current} will be created by the release workflow after merge to main.`)
352
449
  break
353
450
  }
@@ -360,7 +457,11 @@ switch (command) {
360
457
  const notesBody = generateNotes()
361
458
  const changelogPlan = prepareUnreleased(newVersion, date, notesBody)
362
459
  const manifestPlan = prepareManifests(newVersion)
363
- commitPreparedRelease({ ...manifestPlan, version: newVersion }, changelogPlan)
460
+ commitPreparedRelease(
461
+ { ...manifestPlan, version: newVersion },
462
+ changelogPlan,
463
+ prepareReleaseMetadata(newVersion, date),
464
+ )
364
465
  console.log(`\nDone. Commit the version inventory and CHANGELOG as v${newVersion}.`)
365
466
  console.log(`Tag v${newVersion} will be created by the release workflow after merge to main.`)
366
467
  break
@@ -34,7 +34,7 @@ permission:
34
34
  bash: deny
35
35
  read: allow
36
36
  grep: allow
37
- edit: deny
37
+ edit: allow
38
38
  task:
39
39
  "*": deny
40
40
  ---
@@ -777,13 +777,32 @@ def _validate_heartbeat(heartbeat: object) -> None:
777
777
  raise ValueError("heartbeat.turn_count must be a non-negative integer")
778
778
 
779
779
 
780
+ def _validate_version_metadata(version: object) -> None:
781
+ """Validate optional checkpoint ``version`` metadata leniently.
782
+
783
+ ``version`` is opaque application metadata (rehydrate does not consume it),
784
+ not a persistence requirement. It may be a positive integer, a JSON float
785
+ such as ``1.0``, or a version label like ``"1.5.0-beta.2"``. Absent/null
786
+ versions and any non-empty string remain valid, preserving existing integer
787
+ checkpoints while allowing forward-compatible labels. Only bools, empty
788
+ strings, non-finite/zero/negative numbers, and structured values are
789
+ rejected.
790
+ """
791
+ if version is None:
792
+ return
793
+ if isinstance(version, bool):
794
+ raise ValueError("version must be a positive integer or version label")
795
+ if isinstance(version, (int, float)):
796
+ if not 1 <= version < float("inf"):
797
+ raise ValueError("version must be a positive integer or version label")
798
+ return
799
+ if not isinstance(version, str) or not version.strip():
800
+ raise ValueError("version must be a positive integer or version label")
801
+
802
+
780
803
  def _validate_checkpoint_shape(checkpoint: dict) -> None:
781
804
  """Validate bounded structured checkpoint fields without sanitizing them."""
782
- version = checkpoint.get("version")
783
- if version is not None and (
784
- isinstance(version, bool) or not isinstance(version, int) or version < 1
785
- ):
786
- raise ValueError("version must be a positive integer")
805
+ _validate_version_metadata(checkpoint.get("version"))
787
806
  validators = (
788
807
  ("goal", _validate_goal),
789
808
  ("phase", _validate_phase),
@@ -222,15 +222,28 @@ export class BackgroundJobBoard {
222
222
  async recoverRunningJobs(): Promise<void> {
223
223
  if (!this.persistence) return
224
224
  const records = await this.persistence.loadAllJobs()
225
+ const recoveredIDs: string[] = []
225
226
  for (const record of records) {
226
227
  if (record.state === 'running') {
227
228
  record.state = 'error'
228
229
  record.lastStatusError = 'Process restarted — job marked as errored'
229
230
  record.updatedAt = Date.now()
231
+ record.completedAt = Date.now()
230
232
  record.totalErrors++
233
+ record.terminalUnreconciled = true
234
+ recoveredIDs.push(record.taskID)
231
235
  }
232
236
  this.jobs.set(record.taskID, record)
233
237
  }
238
+ // Persist + signal + notify for each recovered job (write-ahead-log pattern)
239
+ for (const taskID of recoveredIDs) {
240
+ const job = this.jobs.get(taskID)
241
+ if (job) {
242
+ await this.persistRecord(job)
243
+ await this.writeSignal(job)
244
+ this.notifyTerminal(job.taskID)
245
+ }
246
+ }
234
247
  }
235
248
 
236
249
  // ─── Core Operations ────────────────────────────────────────────────
@@ -240,6 +253,16 @@ export class BackgroundJobBoard {
240
253
  * The job starts in the `running` state with a generated alias.
241
254
  */
242
255
  async registerLaunch(input: LaunchInput): Promise<BackgroundJobRecord> {
256
+ // Atomic concurrency check: validate BEFORE any mutation or await.
257
+ // JavaScript's single-threaded event loop guarantees this check + set
258
+ // cannot be interleaved with another registerLaunch call.
259
+ if (!this.canDispatch(input.agent)) {
260
+ throw new Error(
261
+ `Concurrency limit reached for agent "${input.agent}" ` +
262
+ `(${this.getRunningCount(input.agent)} running, max ${this.options.maxConcurrentPerAgent})`,
263
+ )
264
+ }
265
+
243
266
  const prefix = getAgentPrefix(input.agent)
244
267
  let sessionCounters = this.aliasCounters.get(input.parentSessionID)
245
268
  if (!sessionCounters) {