@bybrawe/opencode-loop 0.5.37 → 0.6.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.
- package/CHANGELOG.md +26 -0
- package/README.md +31 -1
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/package.json +12 -8
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +19 -10
- package/scripts/control-plane-until-test.mjs +39 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- package/scripts/goal-compaction-safety-test.mjs +68 -0
- package/scripts/goal-companion-test.mjs +1 -1
- package/scripts/host-adapter-contract-test.mjs +1 -0
- package/scripts/install-node.mjs +105 -58
- package/scripts/install-test.mjs +6 -1
- package/scripts/install-with-goals.mjs +17 -4
- package/scripts/job-workspace-test.mjs +11 -1
- package/scripts/loopd-test.mjs +74 -4
- package/scripts/loopd.mjs +153 -25
- package/scripts/native-cli-host-canary.mjs +118 -0
- package/scripts/native-cli-io-test.mjs +66 -0
- package/scripts/native-cli-test.mjs +130 -0
- package/scripts/native-coexistence-test.mjs +38 -0
- package/scripts/native-entry-test.mjs +77 -0
- package/scripts/native-guide-test.mjs +45 -0
- package/scripts/native-install-pair-test.mjs +73 -0
- package/scripts/native-install-test.mjs +107 -0
- package/scripts/native-lifecycle-test.mjs +100 -0
- package/scripts/native-plugin-lifetime-test.mjs +92 -0
- package/scripts/native-presets-test.mjs +227 -0
- package/scripts/native-process-tree-fixture.mjs +72 -0
- package/scripts/native-process-tree-test.mjs +40 -0
- package/scripts/native-release.mjs +121 -0
- package/scripts/native-session-location-test.mjs +86 -0
- package/scripts/native-shell-test.mjs +38 -0
- package/scripts/native-v2-test.mjs +262 -0
- package/scripts/network-recovery-test.mjs +38 -1
- package/scripts/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +119 -10
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -14
- package/scripts/pulse-check.mjs +246 -0
- package/scripts/run-finalization-test.mjs +33 -0
- package/scripts/server-entry-test.mjs +15 -0
- package/scripts/session-error-recovery-test.mjs +186 -0
- package/scripts/session-status-test.mjs +42 -0
- package/scripts/v2-command-adapter-test.mjs +14 -2
- package/scripts/v2-plugin-sentinel-test.mjs +35 -4
- package/scripts/v2-runtime-adapter-test.mjs +75 -0
- package/src/index.js +227 -53
- package/src/native.js +3135 -0
- package/src/server.js +4055 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/core/jobs.js +17 -0
- package/src/source/core/process.js +6 -4
- package/src/source/core/progress.js +58 -0
- package/src/source/legacy-v1.js +14 -3
- package/src/source/native.js +1 -0
- package/src/source/opencode/host.js +1 -0
- package/src/source/opencode/loop-commands.js +1 -27
- package/src/source/opencode/loop-registration.js +2 -12
- package/src/source/opencode/messages.js +1 -1
- package/src/source/opencode2/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +52 -11
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +31 -0
- package/src/source/opencode2/experimental.js +53 -7
- package/src/source/opencode2/native-companion.js +24 -0
- package/src/source/opencode2/native-plugin.js +153 -0
- package/src/source/opencode2/native-policy.js +106 -0
- package/src/source/opencode2/native-runtime.js +464 -0
- package/src/source/opencode2/native-shell.js +81 -0
- package/src/source/opencode2/prompt-runtime.js +38 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +16 -2
- package/src/source/runtime/terminal-guard.js +32 -7
- package/src/source/server.js +26 -0
- package/src/v1.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 - 2026-09-29
|
|
4
|
+
|
|
5
|
+
- Fix #164: persisted core user-message events cannot abort compaction; trusted admission hooks alone may steer legacy turns.
|
|
6
|
+
- Activate native V2 queued scheduling and default local installation, preserving explicit V1 compatibility.
|
|
7
|
+
- Correct unsupported plugin shell assumptions with a bounded local process lifecycle and correlated terminals.
|
|
8
|
+
- Preserve dedicated Goal ownership, native compaction barriers, uncertain-admission safety, and configuration options.
|
|
9
|
+
- Fix branch success reporting, timeout exit codes and self-matching completion markers.
|
|
10
|
+
- Explicitly report the OpenCode 2.0.18 scheduled-compaction capability gap.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## 0.5.38
|
|
14
|
+
|
|
15
|
+
Long-session provider-error, waiting-user, and OpenCode 2.0.11 compatibility release.
|
|
16
|
+
|
|
17
|
+
- Route Loop-owned OpenCode `session.error` events into stable V1 run ownership instead of letting a terminal provider failure disappear into later idle/stale recovery.
|
|
18
|
+
- Pause fail-closed on non-retryable provider/auth/policy/unknown session errors, persisting the error name/message/timestamp so an unlimited idle loop cannot keep dispatching after a terminal host failure.
|
|
19
|
+
- Refund and exponentially back off retryable terminal `APIError` attempts without consuming logical `runCount`, `--max-runs`, or ordinary failure budget; keep `MessageAbortedError` on the existing user/session-abort path.
|
|
20
|
+
- Deduplicate identical stale/completed-status recovery diagnostics per active run, preventing concurrent status probes from amplifying one recovery event into hundreds of `loop.log` records.
|
|
21
|
+
- Keep ordinary `/loop devam et` intentionally unlimited, but pause after two consecutive current assistant replies explicitly say autonomous work is waiting for user approval, input, access, credentials, or another user action. A reply naming concrete autonomous next work clears the waiting-user streak.
|
|
22
|
+
- Add exact regressions for the reported Loto Diyarı free-tier provider error, the Turkish `Onay bekleyenler: commit + push, canlı migration, cihaz testi` steady-state, retryable provider recovery, abort classification, recovery-log deduplication, waiting-user UX, and generated-bundle parity.
|
|
23
|
+
- Harden Windows regression reliability with bounded temporary-directory cleanup retries and bounded waits for persisted stale-busy recovery, without changing production scheduler semantics.
|
|
24
|
+
- Add a dual local-plugin server entry that preserves the OpenCode 1.x `server` contract while exposing the OpenCode 2.x `{ id, setup }` contract required by OpenCode 2.0.11.
|
|
25
|
+
- Support the current OpenCode 2.0.11 command editor and session-command field shapes while retaining the older beta adapter path; normalize current native session execution/status events and select the official Promise event stream without relying on function arity or command-reload timing.
|
|
26
|
+
- Prove OpenCode 2.0.11 compatibility with an exact real-host gate: the local plugin is active, Loop commands register, a two-turn autonomous Loop reaches the deterministic provider exactly twice, control lifecycle commands mutate persisted state correctly, and `--max-runs` stops further turns.
|
|
27
|
+
- Verify the exact feature heads across Ubuntu/Windows CI, minimum/latest OpenCode plugin compatibility, real OpenCode Loop canaries, Dedicated Goal Coexistence, V2 Lifecycle Contract, OpenCode 2 Real Adapter, and the Single-file Bundle Gate.
|
|
28
|
+
|
|
3
29
|
## 0.5.37
|
|
4
30
|
|
|
5
31
|
Dedicated Goal tool-isolation release.
|
package/README.md
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
# OpenCode Loop
|
|
2
2
|
|
|
3
|
+
## Native OpenCode 2 (0.6.0)
|
|
4
|
+
|
|
5
|
+
The installer now defaults to the standalone native V2 plugin. It needs no V1 SDK or legacy command files for a local installation. Existing npm registrations migrate from `plugin` to `plugins` without dropping object options. Use `--legacy-v1` explicitly for the compatibility installation.
|
|
6
|
+
|
|
7
|
+
V2 prompt admission and durable inbox/execution/compaction events own scheduling. Core-generated user-role messages never authorize a session abort. An unfinished dedicated Goal reserves its session, including paused and handed-off states; Loop does not wake or replace that Goal. Soft iteration timeouts do not abort a native model/tool/compaction turn.
|
|
8
|
+
|
|
9
|
+
Scheduled shell commands use bounded local child processes when OpenCode does not expose `session.shell` to plugins. Automatic compaction stays host-owned. **Scheduled compaction requires a host exposing `session.compact`; OpenCode 2.0.18's published plugin Context does not expose it, and those commands are rejected before job creation rather than silently ignored.** Use OpenCode's built-in manual/automatic compaction on that host.
|
|
10
|
+
|
|
11
|
+
Prompt/command timers, watch and stop conditions, verification, preflight/postrun, notifications, checkpoints and diagnostic commands use the V2 runtime. Uncertain restart admissions pause for review rather than replaying potentially admitted work. The dedicated `@bybrawe/opencode-goal` plugin owns `/goal`; legacy Loop Goal records are preserved and are not silently reinterpreted as new Goal contracts.
|
|
12
|
+
|
|
13
|
+
|
|
3
14
|
**Idle-safe auto-continue, scheduled OpenCode work, and background loops.**
|
|
4
15
|
|
|
5
16
|
OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
|
|
6
17
|
|
|
7
|
-
> **Current stable release: `0.5.
|
|
18
|
+
> **Current stable release: `0.5.38`.**
|
|
8
19
|
|
|
9
20
|
## Install or update
|
|
10
21
|
|
|
@@ -383,6 +394,25 @@ Pin a session and limit runs:
|
|
|
383
394
|
opencode-loopd --project . --session ses_xxx --every 5m --max-runs 20 --timeout 30m --prompt-file loop-prompt.md
|
|
384
395
|
```
|
|
385
396
|
|
|
397
|
+
### Dedicated Goal pulse-check
|
|
398
|
+
|
|
399
|
+
Use the daemon as a read-only stall detector for dedicated `/goal` state:
|
|
400
|
+
|
|
401
|
+
```bash
|
|
402
|
+
# one-shot (good for systemd timers / cron-style runners)
|
|
403
|
+
opencode-loopd pulse-check --project . --stale-after 30m
|
|
404
|
+
|
|
405
|
+
# repeating opt-in watcher
|
|
406
|
+
opencode-loopd pulse-check --project . --stale-after 30m --every 1m
|
|
407
|
+
|
|
408
|
+
# watch one Goal by Goal ID or session ID
|
|
409
|
+
opencode-loopd pulse-check --project . --goal <goal-or-session-id> --stale-after 30m --every 1m
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
The pulse-check reads `.opencode/goals/*.json` and alerts only while the dedicated Goal is `active`. `paused`, `waiting_user`, `blocked`, budget/usage-limited, and completed Goals do not alert. A stall episode is based on durable Goal activity (`updatedAt`, progress/revision changes); OpenCode session `updated` time is included only as corroborating telemetry because it is not reliable enough to be the stall authority on every host version.
|
|
413
|
+
|
|
414
|
+
Episode state is stored separately in `.opencode/opencode-loop/pulse-check.json`. The checker never writes Goal state, never dispatches a prompt, never runs `/goal resume`, and therefore does not participate in the prompt-producing Goal-overlap guard. It emits at most one alert per stall episode and re-arms after observed Goal activity or a status change. The alert explicitly names the human recovery action: run `/goal resume` in the Goal's session.
|
|
415
|
+
|
|
386
416
|
Windows Task Scheduler:
|
|
387
417
|
|
|
388
418
|
```powershell
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# OpenCode Loop 0.6.0
|
|
2
|
+
|
|
3
|
+
Native OpenCode 2 release, paired with OpenCode Goal 1.3.39.
|
|
4
|
+
|
|
5
|
+
## Included behavior
|
|
6
|
+
|
|
7
|
+
The default installer uses the standalone native V2 entry and preserves plugin
|
|
8
|
+
object options. Explicit V1 compatibility remains available. Native prompt
|
|
9
|
+
admission, durable execution/compaction boundaries and exclusive Goal ownership
|
|
10
|
+
prevent a second continuation owner or aborts caused by synthetic user-role text.
|
|
11
|
+
|
|
12
|
+
All ten non-Goal presets share schedule parsing, including every/after/in and
|
|
13
|
+
delayed one-shot behavior. /loop-init creates only missing project-local progress
|
|
14
|
+
files, preserves existing bytes and rejects control-plane and symlink escapes.
|
|
15
|
+
Commands resolve their session's actual location rather than a different worktree.
|
|
16
|
+
|
|
17
|
+
Local scheduled shell work has bounded process-tree cleanup. Plugin teardown and
|
|
18
|
+
partial setup attempt all cleanup without hanging on the event subscription.
|
|
19
|
+
Goal live/archive/lease/queue state cannot satisfy --until; explicit until.txt and
|
|
20
|
+
user-owned project progress files remain valid markers.
|
|
21
|
+
|
|
22
|
+
Goal 1.3.39 adds the native sidebar, location-bound read-only status RPC, late-result
|
|
23
|
+
handoff guards and full-contract three-session checks. Goal IDs, evidence and
|
|
24
|
+
cumulative budgets remain durable across session rotation.
|
|
25
|
+
|
|
26
|
+
## Publication and verification
|
|
27
|
+
|
|
28
|
+
The release no longer creates a release branch. An explicit main release request
|
|
29
|
+
runs Windows/Linux bundle, test and installed-package gates; actual production
|
|
30
|
+
Loop and Goal tarballs are exercised together on OpenCode 2.0.18. Normal CI for the
|
|
31
|
+
same immutable commit must also pass. Only then does the existing OIDC publisher
|
|
32
|
+
publish 0.6.0. Existing npm versions must match their source SHA, not merely version.
|
|
33
|
+
|
|
34
|
+
Registry latest, gitHead, exports, bins and integrity are checked after publication.
|
|
35
|
+
A clean consumer installs both published packages from npm and re-runs native
|
|
36
|
+
installation, actual-host commands and the two-turn single-session daemon.
|
|
37
|
+
The GitHub tag/release follows those checks in a separate least-privilege job.
|
|
38
|
+
|
|
39
|
+
## Host capability boundaries
|
|
40
|
+
|
|
41
|
+
OpenCode 2.0.18 is the exact native integration baseline. Its published plugin
|
|
42
|
+
context does not expose session.compact, so scheduled /loop-compact is rejected
|
|
43
|
+
before durable job creation rather than silently ignored. Automatic compaction is
|
|
44
|
+
host-owned. Legacy Loop Goal records are preserved, not silently converted into
|
|
45
|
+
dedicated Goal contracts. Real-model cost and long-duration reliability are not
|
|
46
|
+
claimed by deterministic local-provider canaries.
|
|
47
|
+
|
|
48
|
+
Install the published pair after successful registry verification:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
npx -y @bybrawe/opencode-loop@0.6.0 --with-goals --without-loop-goals
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Fully restart OpenCode after installation. Existing project state is preserved.
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "src/
|
|
6
|
+
"main": "src/server.js",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./src/
|
|
8
|
+
".": "./src/server.js",
|
|
9
|
+
"./server": "./src/server.js",
|
|
10
|
+
"./v2": "./src/native.js",
|
|
11
|
+
"./v1": "./src/index.js"
|
|
9
12
|
},
|
|
10
13
|
"files": [
|
|
11
14
|
"src",
|
|
@@ -18,14 +21,15 @@
|
|
|
18
21
|
"CHANGELOG.md"
|
|
19
22
|
],
|
|
20
23
|
"scripts": {
|
|
21
|
-
"build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
|
|
24
|
+
"build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool && bun build src/source/server.js --outfile=src/server.js --target=bun --format=esm --external=@opencode-ai/plugin/tool && bun build src/source/native.js --outfile=src/native.js --target=bun --format=esm",
|
|
22
25
|
"build:plugin:npm": "npm run build:plugin",
|
|
23
|
-
"prepack": "node --check src/index.js",
|
|
24
|
-
"check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs",
|
|
25
|
-
"test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
|
|
26
|
+
"prepack": "node --check src/index.js && node --check src/server.js && node --check src/native.js",
|
|
27
|
+
"check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/server.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check src/server.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/pulse-check.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/server-entry-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/session-error-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs && node --check scripts/goal-compaction-safety-test.mjs && node --check src/source/native.js && node --check src/source/opencode2/native-plugin.js && node --check src/source/opencode2/native-runtime.js && node --check src/source/opencode2/native-policy.js && node --check src/native.js && node --check scripts/native-entry-test.mjs && node --check scripts/package-native-smoke.mjs && node --check src/v1.js && node --check scripts/native-cli-io-test.mjs && node --check scripts/native-lifecycle-test.mjs && node --check scripts/native-plugin-lifetime-test.mjs && node --check src/source/core/progress.js && node --check scripts/native-presets-test.mjs && node --check scripts/control-plane-until-test.mjs && node --check src/source/opencode2/session-location.js && node --check scripts/native-session-location-test.mjs",
|
|
28
|
+
"test": "node --test scripts/native-session-location-test.mjs && node --test scripts/control-plane-until-test.mjs && node --test scripts/native-presets-test.mjs && node --test scripts/native-plugin-lifetime-test.mjs && node --test scripts/native-cli-io-test.mjs scripts/native-lifecycle-test.mjs && node scripts/native-entry-test.mjs && node scripts/native-coexistence-test.mjs && node scripts/native-shell-test.mjs && node scripts/native-install-test.mjs && node scripts/native-v2-test.mjs && node scripts/goal-compaction-safety-test.mjs && node scripts/publish-workflow-test.mjs && node scripts/server-entry-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/session-error-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
|
|
26
29
|
"canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
|
|
27
30
|
"install:global": "node scripts/install-with-goals.mjs",
|
|
28
|
-
"pack:zip": "node scripts/make-zip.mjs"
|
|
31
|
+
"pack:zip": "node scripts/make-zip.mjs",
|
|
32
|
+
"package:smoke": "node scripts/package-native-smoke.mjs"
|
|
29
33
|
},
|
|
30
34
|
"keywords": [
|
|
31
35
|
"opencode",
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { appendFile } from "node:fs/promises"
|
|
3
|
+
import process from "node:process"
|
|
4
|
+
|
|
5
|
+
function sameRepositoryHead(pull, repository) {
|
|
6
|
+
return pull?.head?.repo?.full_name === repository
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function encodeRef(name) {
|
|
10
|
+
return String(name || "").split("/").map(encodeURIComponent).join("/")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function validSha(value) {
|
|
14
|
+
return /^[0-9a-f]{40}$/i.test(String(value || ""))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function planBranchCleanup({ repository, defaultBranch, branches, openPulls, closedPulls }) {
|
|
18
|
+
const openHeads = new Set(
|
|
19
|
+
openPulls
|
|
20
|
+
.filter((pull) => sameRepositoryHead(pull, repository))
|
|
21
|
+
.map((pull) => String(pull?.head?.ref || "").trim())
|
|
22
|
+
.filter(Boolean),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const mergedHeads = new Map()
|
|
26
|
+
for (const pull of closedPulls) {
|
|
27
|
+
if (!pull?.merged_at || !sameRepositoryHead(pull, repository)) continue
|
|
28
|
+
const name = String(pull?.head?.ref || "").trim()
|
|
29
|
+
const sha = String(pull?.head?.sha || "").trim()
|
|
30
|
+
if (!name || !sha) continue
|
|
31
|
+
const values = mergedHeads.get(name) || []
|
|
32
|
+
values.push({ sha, number: pull.number })
|
|
33
|
+
mergedHeads.set(name, values)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const candidates = []
|
|
37
|
+
const retained = []
|
|
38
|
+
for (const branch of branches) {
|
|
39
|
+
const name = String(branch?.name || "").trim()
|
|
40
|
+
const sha = String(branch?.commit?.sha || "").trim()
|
|
41
|
+
if (!name || !sha) continue
|
|
42
|
+
if (name === defaultBranch) {
|
|
43
|
+
retained.push({ name, reason: "default" })
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (branch?.protected === true) {
|
|
47
|
+
retained.push({ name, reason: "protected" })
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (openHeads.has(name)) {
|
|
51
|
+
retained.push({ name, reason: "open-pr" })
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const merged = (mergedHeads.get(name) || []).find((entry) => entry.sha === sha)
|
|
56
|
+
if (!merged) {
|
|
57
|
+
retained.push({ name, reason: "no-exact-merged-head" })
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
candidates.push({ name, sha, pull: merged.number, source: "merged-pr" })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
candidates.sort((a, b) => a.name.localeCompare(b.name))
|
|
64
|
+
retained.sort((a, b) => a.name.localeCompare(b.name))
|
|
65
|
+
return { candidates, retained }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function planArchivedCleanup({
|
|
69
|
+
repository,
|
|
70
|
+
defaultBranch,
|
|
71
|
+
archiveRef,
|
|
72
|
+
manifest,
|
|
73
|
+
branches,
|
|
74
|
+
openPulls,
|
|
75
|
+
}) {
|
|
76
|
+
if (!manifest || typeof manifest !== "object") throw new Error("archive manifest must be an object")
|
|
77
|
+
if (manifest.repository !== repository) throw new Error(`archive manifest repository mismatch: ${manifest.repository}`)
|
|
78
|
+
if (manifest.archiveBranch !== archiveRef) throw new Error(`archive manifest branch mismatch: ${manifest.archiveBranch}`)
|
|
79
|
+
if (!Array.isArray(manifest.archivedBranches)) throw new Error("archive manifest archivedBranches must be an array")
|
|
80
|
+
|
|
81
|
+
const branchByName = new Map(
|
|
82
|
+
branches
|
|
83
|
+
.map((branch) => [String(branch?.name || "").trim(), branch])
|
|
84
|
+
.filter(([name]) => Boolean(name)),
|
|
85
|
+
)
|
|
86
|
+
const openHeads = new Set(
|
|
87
|
+
openPulls
|
|
88
|
+
.filter((pull) => sameRepositoryHead(pull, repository))
|
|
89
|
+
.map((pull) => String(pull?.head?.ref || "").trim())
|
|
90
|
+
.filter(Boolean),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const seenNames = new Set()
|
|
94
|
+
const candidates = []
|
|
95
|
+
const retained = []
|
|
96
|
+
|
|
97
|
+
for (const entry of manifest.archivedBranches) {
|
|
98
|
+
const name = String(entry?.name || "").trim()
|
|
99
|
+
const sha = String(entry?.sha || "").trim()
|
|
100
|
+
if (!name || !validSha(sha)) throw new Error(`invalid archived branch entry: ${JSON.stringify(entry)}`)
|
|
101
|
+
if (seenNames.has(name)) throw new Error(`duplicate archived branch entry: ${name}`)
|
|
102
|
+
seenNames.add(name)
|
|
103
|
+
|
|
104
|
+
if (name === defaultBranch || name === archiveRef) {
|
|
105
|
+
retained.push({ name, reason: "reserved" })
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const branch = branchByName.get(name)
|
|
110
|
+
if (!branch) {
|
|
111
|
+
retained.push({ name, reason: "already-absent" })
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (branch?.protected === true) {
|
|
115
|
+
retained.push({ name, reason: "protected" })
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
if (openHeads.has(name)) {
|
|
119
|
+
retained.push({ name, reason: "open-pr" })
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const currentSha = String(branch?.commit?.sha || "").trim()
|
|
124
|
+
if (currentSha !== sha) {
|
|
125
|
+
retained.push({ name, reason: "head-changed", expectedSha: sha, currentSha })
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
candidates.push({ name, sha, source: "archive-manifest" })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
candidates.sort((a, b) => a.name.localeCompare(b.name))
|
|
133
|
+
retained.sort((a, b) => a.name.localeCompare(b.name))
|
|
134
|
+
return { candidates, retained }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function request({ api, token }, method, pathname) {
|
|
138
|
+
const response = await fetch(`${api}${pathname}`, {
|
|
139
|
+
method,
|
|
140
|
+
headers: {
|
|
141
|
+
Accept: "application/vnd.github+json",
|
|
142
|
+
Authorization: `Bearer ${token}`,
|
|
143
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
144
|
+
"User-Agent": "opencode-branch-hygiene",
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
if (response.status === 204) return undefined
|
|
148
|
+
const raw = await response.text()
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new Error(`${method} ${pathname} failed (${response.status}): ${raw.slice(0, 1000)}`)
|
|
151
|
+
}
|
|
152
|
+
return raw ? JSON.parse(raw) : undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function paginate(client, pathname, query = {}) {
|
|
156
|
+
const values = []
|
|
157
|
+
for (let page = 1; page <= 100; page += 1) {
|
|
158
|
+
const params = new URLSearchParams({
|
|
159
|
+
...Object.fromEntries(Object.entries(query).map(([key, value]) => [key, String(value)])),
|
|
160
|
+
per_page: "100",
|
|
161
|
+
page: String(page),
|
|
162
|
+
})
|
|
163
|
+
const batch = await request(client, "GET", `${pathname}?${params}`)
|
|
164
|
+
if (!Array.isArray(batch)) throw new Error(`Expected an array from ${pathname}`)
|
|
165
|
+
values.push(...batch)
|
|
166
|
+
if (batch.length < 100) return values
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`Pagination safety limit exceeded for ${pathname}`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function loadArchive(client, root, repository, archiveRef) {
|
|
172
|
+
if (!archiveRef) return null
|
|
173
|
+
|
|
174
|
+
const archivePath = "archive/legacy-branches-2026-09-21.json"
|
|
175
|
+
const encodedRef = encodeURIComponent(archiveRef)
|
|
176
|
+
const payload = await request(client, "GET", `${root}/contents/${archivePath}?ref=${encodedRef}`)
|
|
177
|
+
if (payload?.encoding !== "base64" || typeof payload?.content !== "string") {
|
|
178
|
+
throw new Error("archive manifest content is not base64")
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const raw = Buffer.from(payload.content.replace(/\s+/g, ""), "base64").toString("utf8")
|
|
182
|
+
const manifest = JSON.parse(raw)
|
|
183
|
+
if (manifest.repository !== repository) throw new Error("archive manifest repository does not match current repository")
|
|
184
|
+
if (manifest.archiveBranch !== archiveRef) throw new Error("archive manifest archiveBranch does not match configured ref")
|
|
185
|
+
|
|
186
|
+
const ref = await request(client, "GET", `${root}/git/ref/heads/${encodeRef(archiveRef)}`)
|
|
187
|
+
const archiveSha = String(ref?.object?.sha || "").trim()
|
|
188
|
+
if (!validSha(archiveSha)) throw new Error("archive branch did not resolve to a commit SHA")
|
|
189
|
+
|
|
190
|
+
return { manifest, archiveSha, archivePath }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function verifyArchivedReachability(client, root, archiveSha, items) {
|
|
194
|
+
const verified = []
|
|
195
|
+
for (const item of items) {
|
|
196
|
+
const compare = await request(client, "GET", `${root}/compare/${item.sha}...${archiveSha}`)
|
|
197
|
+
if (!["ahead", "identical"].includes(compare?.status)) {
|
|
198
|
+
throw new Error(`archive ref does not retain ${item.name} @ ${item.sha}; compare status=${compare?.status || "unknown"}`)
|
|
199
|
+
}
|
|
200
|
+
verified.push(item)
|
|
201
|
+
}
|
|
202
|
+
return verified
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function deleteCandidates(client, root, items, dryRun, label) {
|
|
206
|
+
const deleted = []
|
|
207
|
+
const skipped = []
|
|
208
|
+
const failures = []
|
|
209
|
+
|
|
210
|
+
for (const item of items) {
|
|
211
|
+
console.log(`${dryRun ? "DRY-RUN" : "DELETE"} [${label}] ${item.name} @ ${item.sha}`)
|
|
212
|
+
if (dryRun) continue
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const encodedRef = encodeRef(item.name)
|
|
216
|
+
const refPath = `${root}/git/ref/heads/${encodedRef}`
|
|
217
|
+
const current = await request(client, "GET", refPath)
|
|
218
|
+
const currentSha = String(current?.object?.sha || "").trim()
|
|
219
|
+
if (currentSha !== item.sha) {
|
|
220
|
+
console.log(`SKIP [${label}] ${item.name}: head changed from ${item.sha} to ${currentSha || "unknown"}`)
|
|
221
|
+
skipped.push({ ...item, reason: "head-changed-at-delete", currentSha })
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
await request(client, "DELETE", `${root}/git/refs/heads/${encodedRef}`)
|
|
225
|
+
deleted.push(item)
|
|
226
|
+
} catch (error) {
|
|
227
|
+
failures.push({ item, error: error instanceof Error ? error.message : String(error) })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { deleted, skipped, failures }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function countReasons(items) {
|
|
235
|
+
return Object.fromEntries(
|
|
236
|
+
[...new Set(items.map((item) => item.reason))]
|
|
237
|
+
.sort()
|
|
238
|
+
.map((reason) => [reason, items.filter((item) => item.reason === reason).length]),
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function main() {
|
|
243
|
+
const repository = String(process.env.GITHUB_REPOSITORY || "").trim()
|
|
244
|
+
const token = String(process.env.GITHUB_TOKEN || "").trim()
|
|
245
|
+
const api = String(process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, "")
|
|
246
|
+
const defaultBranch = String(process.env.BRANCH_HYGIENE_DEFAULT_BRANCH || "main").trim()
|
|
247
|
+
const archiveRef = String(process.env.BRANCH_HYGIENE_ARCHIVE_REF || "").trim()
|
|
248
|
+
const dryRun = process.env.BRANCH_HYGIENE_DRY_RUN === "1"
|
|
249
|
+
|
|
250
|
+
if (!/^[^/]+\/[^/]+$/.test(repository)) throw new Error("GITHUB_REPOSITORY must be owner/name")
|
|
251
|
+
if (!token) throw new Error("GITHUB_TOKEN is required")
|
|
252
|
+
if (!defaultBranch) throw new Error("default branch is required")
|
|
253
|
+
|
|
254
|
+
const client = { api, token }
|
|
255
|
+
const root = `/repos/${repository}`
|
|
256
|
+
const [branches, openPulls, closedPulls] = await Promise.all([
|
|
257
|
+
paginate(client, `${root}/branches`),
|
|
258
|
+
paginate(client, `${root}/pulls`, { state: "open" }),
|
|
259
|
+
paginate(client, `${root}/pulls`, { state: "closed" }),
|
|
260
|
+
])
|
|
261
|
+
|
|
262
|
+
const mergedPlan = planBranchCleanup({
|
|
263
|
+
repository,
|
|
264
|
+
defaultBranch,
|
|
265
|
+
branches,
|
|
266
|
+
openPulls,
|
|
267
|
+
closedPulls,
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
let archivePlan = { candidates: [], retained: [] }
|
|
271
|
+
let archiveSha = ""
|
|
272
|
+
if (archiveRef) {
|
|
273
|
+
const archive = await loadArchive(client, root, repository, archiveRef)
|
|
274
|
+
archiveSha = archive.archiveSha
|
|
275
|
+
archivePlan = planArchivedCleanup({
|
|
276
|
+
repository,
|
|
277
|
+
defaultBranch,
|
|
278
|
+
archiveRef,
|
|
279
|
+
manifest: archive.manifest,
|
|
280
|
+
branches,
|
|
281
|
+
openPulls,
|
|
282
|
+
})
|
|
283
|
+
await verifyArchivedReachability(client, root, archiveSha, archivePlan.candidates)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const mergedResult = await deleteCandidates(client, root, mergedPlan.candidates, dryRun, "merged-pr")
|
|
287
|
+
|
|
288
|
+
const mergedNames = new Set(mergedPlan.candidates.map((item) => item.name))
|
|
289
|
+
const archiveCandidates = archivePlan.candidates.filter((item) => !mergedNames.has(item.name))
|
|
290
|
+
const archiveResult = await deleteCandidates(client, root, archiveCandidates, dryRun, "archive")
|
|
291
|
+
|
|
292
|
+
const failures = [...mergedResult.failures, ...archiveResult.failures]
|
|
293
|
+
const summary = [
|
|
294
|
+
"## Branch hygiene",
|
|
295
|
+
"",
|
|
296
|
+
`- Repository: \`${repository}\``,
|
|
297
|
+
`- Default branch: \`${defaultBranch}\``,
|
|
298
|
+
`- Branches scanned: ${branches.length}`,
|
|
299
|
+
`- Exact merged-head candidates: ${mergedPlan.candidates.length}`,
|
|
300
|
+
`- Archived inactive candidates: ${archiveCandidates.length}`,
|
|
301
|
+
`- Archive ref: \`${archiveRef || "disabled"}\``,
|
|
302
|
+
`- Archive SHA: \`${archiveSha || "n/a"}\``,
|
|
303
|
+
`- Deleted: ${dryRun ? 0 : mergedResult.deleted.length + archiveResult.deleted.length}`,
|
|
304
|
+
`- Dry run: ${dryRun}`,
|
|
305
|
+
`- Retained merged-plan reasons: \`${JSON.stringify(countReasons(mergedPlan.retained))}\``,
|
|
306
|
+
`- Retained archive-plan reasons: \`${JSON.stringify(countReasons(archivePlan.retained))}\``,
|
|
307
|
+
"",
|
|
308
|
+
"Safety rules: merged branches require an exact merged-PR head SHA. Archived branches additionally require an unchanged current SHA, no open PR, and proof that the archived tip is an ancestor of the configured archive ref before deletion.",
|
|
309
|
+
].join("\n")
|
|
310
|
+
|
|
311
|
+
if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`, "utf8")
|
|
312
|
+
console.log(summary)
|
|
313
|
+
|
|
314
|
+
if (failures.length) {
|
|
315
|
+
for (const failure of failures) console.error(`FAILED ${failure.item.name}: ${failure.error}`)
|
|
316
|
+
throw new Error(`Failed to delete ${failures.length} eligible branch(es)`)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const invokedDirectly = process.argv[1] && new URL(import.meta.url).pathname.endsWith(process.argv[1].replaceAll("\\", "/"))
|
|
321
|
+
if (invokedDirectly) {
|
|
322
|
+
main().catch((error) => {
|
|
323
|
+
console.error(error?.stack || error)
|
|
324
|
+
process.exitCode = 1
|
|
325
|
+
})
|
|
326
|
+
}
|
|
@@ -165,7 +165,7 @@ async function createHarness(options = {}) {
|
|
|
165
165
|
},
|
|
166
166
|
async cleanup() {
|
|
167
167
|
await hooks.dispose?.()
|
|
168
|
-
await fs.rm(directory, { recursive: true, force: true })
|
|
168
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
|
169
169
|
},
|
|
170
170
|
}
|
|
171
171
|
}
|
|
@@ -373,9 +373,12 @@ async function testActionRoutingAndSafety() {
|
|
|
373
373
|
await fs.writeFile(path.join(h.directory, "empty.md"), "fallback context", "utf8")
|
|
374
374
|
await h.command("loop-command", "0s --prompt-file empty.md")
|
|
375
375
|
await h.command("loop-now", "command")
|
|
376
|
-
const state = await
|
|
377
|
-
|
|
378
|
-
|
|
376
|
+
const state = await waitForValue(async () => {
|
|
377
|
+
const candidate = await h.readState()
|
|
378
|
+
return candidate.jobs[0]?.paused === true && candidate.jobs[0]?.lastFailureReason === "empty_command" ? candidate : undefined
|
|
379
|
+
}, 2_000)
|
|
380
|
+
assert.equal(state?.jobs[0]?.paused, true)
|
|
381
|
+
assert.equal(state?.jobs[0]?.lastFailureReason, "empty_command")
|
|
379
382
|
} finally {
|
|
380
383
|
await h.cleanup()
|
|
381
384
|
}
|
|
@@ -384,9 +387,12 @@ async function testActionRoutingAndSafety() {
|
|
|
384
387
|
try {
|
|
385
388
|
await h.command("loop-compact", "0s")
|
|
386
389
|
await h.command("loop-now", "compact")
|
|
387
|
-
const state = await
|
|
388
|
-
|
|
389
|
-
|
|
390
|
+
const state = await waitForValue(async () => {
|
|
391
|
+
const candidate = await h.readState()
|
|
392
|
+
return candidate.jobs[0]?.paused === true && candidate.jobs[0]?.lastFailureReason === "compact_failed" ? candidate : undefined
|
|
393
|
+
}, 2_000)
|
|
394
|
+
assert.equal(state?.jobs[0]?.paused, true)
|
|
395
|
+
assert.equal(state?.jobs[0]?.lastFailureReason, "compact_failed")
|
|
390
396
|
} finally {
|
|
391
397
|
await h.cleanup()
|
|
392
398
|
}
|
|
@@ -486,8 +492,11 @@ async function testStaleBusyUsesCompletedAssistantTail() {
|
|
|
486
492
|
// and can reconcile the completed assistant tail instead of trusting cached busy.
|
|
487
493
|
await delay(1_700)
|
|
488
494
|
await h.command("loop-now", "stale-complete")
|
|
489
|
-
const state = await
|
|
490
|
-
|
|
495
|
+
const state = await waitForValue(async () => {
|
|
496
|
+
const candidate = await h.readState()
|
|
497
|
+
return candidate.jobs[0]?.lastFinishedAt > 0 ? candidate : undefined
|
|
498
|
+
}, 3_000)
|
|
499
|
+
assert.ok(state?.jobs[0]?.lastFinishedAt > 0, "a completed assistant tail must override a stale busy status")
|
|
491
500
|
assert.ok(h.records.messageReads.length > 0, "busy recovery must cross-check message history")
|
|
492
501
|
} finally {
|
|
493
502
|
await h.cleanup()
|
|
@@ -643,7 +652,7 @@ async function testInitializationDoesNotWaitForLocalApi() {
|
|
|
643
652
|
assert.equal(typeof hooks.event, "function")
|
|
644
653
|
await hooks.dispose?.()
|
|
645
654
|
} finally {
|
|
646
|
-
await fs.rm(directory, { recursive: true, force: true })
|
|
655
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
|
647
656
|
}
|
|
648
657
|
}
|
|
649
658
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { createJobWorkspaceRuntime } from "../src/source/runtime/job-workspace.js"
|
|
7
|
+
|
|
8
|
+
const marker = "NATIVE_COMPLETION_FROM_WORK_ONLY_8fd8"
|
|
9
|
+
const runtime = createJobWorkspaceRuntime({ toast: async () => {} })
|
|
10
|
+
async function fixture(file, expected) {
|
|
11
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "loop-control-until-"))
|
|
12
|
+
try {
|
|
13
|
+
const target = path.join(directory, file)
|
|
14
|
+
await mkdir(path.dirname(target), { recursive: true })
|
|
15
|
+
await writeFile(target, JSON.stringify({ objective: marker }))
|
|
16
|
+
assert.equal(await runtime.untilReached(directory, { until: marker }), expected)
|
|
17
|
+
} finally {
|
|
18
|
+
await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const file of [
|
|
23
|
+
".opencode/goals/live.json",
|
|
24
|
+
".opencode/goals/archive/history.json",
|
|
25
|
+
".opencode/goal-sequences/session.json",
|
|
26
|
+
".opencode/goal-locks/lease.json",
|
|
27
|
+
".opencode/goal-handoff-locks/lease.json",
|
|
28
|
+
".opencode/opencode-loop/session.json",
|
|
29
|
+
]) {
|
|
30
|
+
test("Goal/Loop control state cannot satisfy --until: " + file, () => fixture(file, false))
|
|
31
|
+
}
|
|
32
|
+
for (const file of [
|
|
33
|
+
"progress.md",
|
|
34
|
+
".opencode/opencode-loop/until.txt",
|
|
35
|
+
".opencode/commands/project-task.md",
|
|
36
|
+
".opencode/goals-project-notes/finished.md",
|
|
37
|
+
]) {
|
|
38
|
+
test("explicit or project-owned completion marker stays supported: " + file, () => fixture(file, true))
|
|
39
|
+
}
|
|
@@ -28,7 +28,12 @@ export default {
|
|
|
28
28
|
const sessionID = String(created?.id || created?.data?.id || created?.sessionID || "")
|
|
29
29
|
if (!sessionID) throw new Error("session.create returned no session id")
|
|
30
30
|
try {
|
|
31
|
-
await ctx.session.command({
|
|
31
|
+
await ctx.session.command({
|
|
32
|
+
sessionID,
|
|
33
|
+
name: COMMAND_SENTINEL,
|
|
34
|
+
command: COMMAND_SENTINEL,
|
|
35
|
+
text: "",
|
|
36
|
+
})
|
|
32
37
|
commandFieldProbe = {
|
|
33
38
|
matched: false,
|
|
34
39
|
sessionID,
|