session-orchestrator 5.11.0 → 5.12.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/.claude-plugin/marketplace.json +12 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +19 -0
- package/README.md +22 -12
- package/commands/session.md +3 -1
- package/docs/install.md +12 -8
- package/docs/session-config-reference.md +6 -6
- package/docs/session-config-template.md +2 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +19 -10
- package/package.json +1 -2
- package/scripts/fleet-checkin.mjs +61 -3
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config.mjs +1 -1
- package/scripts/lib/fleet-protocol.mjs +17 -0
- package/scripts/lib/session-end/vault-orphans.mjs +205 -0
- package/scripts/lib/session-invocation.mjs +14 -3
- package/scripts/lib/session-registry.mjs +14 -4
- package/scripts/lib/so-hud/collect.mjs +130 -0
- package/scripts/lib/so-hud/runtime.mjs +76 -0
- package/scripts/lib/state-md/idle-reset.mjs +239 -0
- package/scripts/lib/state-md/recommendations.mjs +26 -4
- package/scripts/lib/vault-mirror/namespace.mjs +3 -2
- package/scripts/lib/vault-mirror/process.mjs +143 -40
- package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
- package/scripts/lib/vault-mirror/utils.mjs +27 -13
- package/scripts/lib/vault-status/board-writer.mjs +32 -4
- package/scripts/resolve-session-invocation.mjs +64 -8
- package/scripts/so-hud.mjs +45 -0
- package/scripts/state-md-idle-reset.mjs +81 -0
- package/scripts/vault-orphans.mjs +171 -0
- package/skills/_shared/fleet-protocol.md +24 -2
- package/skills/_shared/state-ownership.md +3 -3
- package/skills/autopilot/SKILL.md +3 -2
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/references/session-summary-template.md +4 -0
- package/skills/session-start/SKILL.md +1 -1
- package/skills/session-start/phase-7-5-mode-selector.md +3 -1
- package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
- package/AGENTS.md +0 -207
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
|
|
10
|
-
"version": "5.
|
|
10
|
+
"version": "5.12.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -24,6 +24,17 @@
|
|
|
24
24
|
"keywords": ["session", "orchestration", "waves", "gitlab", "github", "quality-gates", "subagents"],
|
|
25
25
|
"category": "productivity",
|
|
26
26
|
"tags": ["session-management", "wave-execution", "vcs-integration", "code-review", "agent-orchestration"]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "so-hud",
|
|
30
|
+
"source": "./mods/so-hud",
|
|
31
|
+
"description": "See Session Orchestrator status, warnings and session beacons in Claude Code. Read-only; needs the Session Orchestrator release that ships it, or later.",
|
|
32
|
+
"author": { "name": "session-orchestrator contributors" },
|
|
33
|
+
"homepage": "https://session-orchestrator.com",
|
|
34
|
+
"repository": "https://github.com/Kanevry/session-orchestrator",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"category": "productivity",
|
|
37
|
+
"tags": ["status-line", "session-management", "agent-orchestration"]
|
|
27
38
|
}
|
|
28
39
|
]
|
|
29
40
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "session-orchestrator",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.12.0",
|
|
5
5
|
"description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Bernhard Goetzendorfer",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.12.0+codex.20261011193338",
|
|
4
4
|
"description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"session",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.12.0",
|
|
4
4
|
"description": "A repeatable Plan, Go, Close workflow for AI coding sessions: /session reads your repo and agrees the scope, /go runs the work in waves with a quality gate between each, /close verifies and commits. Runs on Claude Code, Codex CLI, Cursor and Pi.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bernhard Goetzendorfer"
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [5.12.0] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **so-hud, a native Claude Code status mod** (#1508): `mods/so-hud` (version 0.1.0) shows the session state (lock, registry, wave scope, agent status, STATE.md, gate and vault events) as a status line and, when something needs attention, a warning band, and writes one beacon per session that other sessions can read through `$.so.beacons()`; the status line shows how many peer sessions are running. It reads everything through `scripts/so-hud.mjs` (JSON v1 with provenance and `degraded`), keeps no second copy of the state and never changes a tool decision. It is listed in this repo's marketplace: `/plugin install so-hud@kanevry` (Claude Code only, tested with 2.1.296, Node.js 24+, Session Orchestrator 5.12.0 or later as the plugin or as a checkout set through `soRoot`). Status texts are German for now; an English table is prepared but not yet selectable.
|
|
15
|
+
- **Mod CI gate** (#1508): the `mods:plugin` job runs `claude plugin validate --strict --json` and `claude plugin test` for every mod under `mods/` with Claude Code pinned once in `CLAUDE_CODE_VERSION` (2.1.296). It verifies the registry signatures of both downloaded packages, calls the platform binary directly so `ignore-scripts` stays on, runs without credentials or nonessential traffic, and is a hard dependency of `pipeline-gate`.
|
|
16
|
+
- **Session-end names untracked vault files modified during the session** (#1563): new Phase 3.7e runs `scripts/vault-orphans.mjs`, which lists untracked files under `vault-dir` modified since the session started (`git ls-files --others --exclude-standard -z`, read-only) and leaves out files carrying a `_generator: session-orchestrator-` marker. The close names them in the final report and records one Open Question with the path list; it never commits, moves or deletes anything in the vault, because a modification time does not prove which session wrote a file. A vault that cannot be read is reported as "could not measure", never as "none". There is no blanket exclusion of `40-learnings/` and `50-sessions/`: an unmarked file there blocks every later mirror auto-commit and is exactly the case to surface.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **The Mode-Selector sees the previous session's recommendation again** (#1560): the session-start Idle Reset was prose only, and its rule 6 deleted the five recommendation keys from the STATE.md frontmatter that Phase 7.5 reads through `buildLiveSignals()`, so after every clean `/close` the selector answered with confidence 0. The reset is now code (`scripts/lib/state-md/idle-reset.mjs`, CLI `scripts/state-md-idle-reset.mjs`, written under the STATE.md lock) and keeps those keys; session-end Phase 3.7a overwrites them at the next close. Without a new Wave History the earlier `## Previous Session` archive stays below the new recommendations copy. `parseRecommendations()` accepts `"#42"` and `"42"` in `top-priorities` and drops free-text entries instead of discarding the whole list.
|
|
21
|
+
- **A first `/session` no longer starts `deep` silently** (#1561): a bare `/session` in a repo whose `.orchestrator/metrics/sessions.jsonl` holds no real session resolves with `defaultReason: "no-session-history"`, and `commands/session.md` then asks for task and mode first (recommending `feature`). A linked worktree reads its main checkout's ledger, and an unreadable ledger counts as not measured, so neither triggers the question.
|
|
22
|
+
- **Solo users see no fleet machinery** (#1559): without fleet integration (`NAVIGATOR_CONFIG_DIR` unset and no `~/.config/navigator/` directory), `scripts/fleet-checkin.mjs` still validates its input, then writes nothing, creates no directory, emits no event and prints `skipped: "no-fleet"`; a headless (`plattform: kopflos`) run without fleet integration applies the Standard-Auflagen instead of writing. The SessionStart banner drops the navigator line. In a git repository without `.orchestrator/bootstrap.lock` (neither at the toplevel nor in the session directory) the first banner action is `/bootstrap`, in place of the decision nudge whose `/go` and `/close` do not work before setup; once bootstrapped, the nudge returns and now reads in plain words instead of naming an internal rule file. The hint no longer follows `cold-start.enabled`, which governs only the post-bootstrap first-session nudge.
|
|
23
|
+
- **The npm package no longer ships this repo's own `AGENTS.md`** (#1559): it carried the maintainer's Session Config (vault path, remote host aliases), and no consumer reads an instruction file from the installed package. `tests/scripts/pack-policy-floor.test.mjs` keeps root `AGENTS.md` and `CLAUDE.md` out of the tarball. Config examples use the neutral alias `build-host`.
|
|
24
|
+
- **Install docs match current Claude Code** (#1369, #1559): `docs/install.md` runs `npm install` in the plugin cache only when `node_modules/js-yaml` is missing, since `/plugin install` already provisions the dependencies. The README quickstart says that `/bootstrap` writes the Session Config and names `/bootstrap --fast --no-interview` as the shortest path. The website install step (EN and DE) and the guide use the same conditional command (#1564).
|
|
25
|
+
- **One identity for a repo reached through a symlink** (#1557): `repoPathHash()` and the registry's `repo_name` resolve the real path (`realpathSync.native`, falling back to `path.resolve` when the path is unavailable), so a session in a symlink and one in its target count as the same repo for peer discovery, session transition and the live-status board. Board keys of canonical paths are unchanged. A board row stored under a pre-#1557 alias key is adopted on its first fresh write when it names the same repo; a row of another repo that merely shares the 8-character key stays. Registry entries written before the update keep their old hash until the session registers again.
|
|
26
|
+
- **Umlauts survive in new learning-note slugs** (#1548): vault-mirror transliterates the slugs of new learning notes with a traceable origin (ä to ae, ö to oe, ü to ue, ß to ss, accents after NFC) instead of deleting the characters ("grnes" for "grünes"). Existing notes with a traceable origin keep their path, file name and id, and namespace and tags stay as they were, so auto-commit, relocation and backfill see the same folder. Session notes, tags and records without a traceable origin keep the historical slug, which drops umlauts; the backfill reverse lookup does not find older notes with umlauts; æ, ø, ł and œ are still dropped.
|
|
27
|
+
- **No overwriting another repo's fleet check-in** (#1556): `scripts/fleet-checkin.mjs` exits 2 and writes nothing when the check-in file for the same semantic session id already belongs to another repo; the same repo still updates its own check-in. The existing file is opened without following symlinks and without blocking, read only as a regular file of at most 64 KiB, and the foreign repo name is printed as a JSON string on one stderr line.
|
|
28
|
+
|
|
10
29
|
## [5.11.0] - 2026-10-11
|
|
11
30
|
|
|
12
31
|
### Added
|
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Session Orchestrator
|
|
2
2
|
|
|
3
3
|
[](LICENSE)
|
|
4
|
-
[](CHANGELOG.md)
|
|
5
5
|
[](https://www.npmjs.com/package/session-orchestrator)
|
|
6
|
+
[](https://github.com/Kanevry/session-orchestrator/releases/latest)
|
|
6
7
|
|
|
7
8
|
**Give your agents a working rhythm.**
|
|
8
9
|
|
|
@@ -34,9 +35,18 @@ Longer explanation, with examples and screenshots: **[session-orchestrator.com](
|
|
|
34
35
|
|
|
35
36
|
You need **Node.js 24 or later**, a git repository, and one of the four agents below. Full requirements, upgrade path and uninstall: [docs/install.md](docs/install.md).
|
|
36
37
|
|
|
38
|
+
In Claude Code, two lines:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
/plugin marketplace add Kanevry/session-orchestrator
|
|
42
|
+
/plugin install session-orchestrator@kanevry
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Optional, Claude Code only: `/plugin install so-hud@kanevry` adds a status line for the running session ([mods/so-hud](mods/so-hud/README.md)).
|
|
46
|
+
|
|
37
47
|
| Platform | Install |
|
|
38
48
|
|---|---|
|
|
39
|
-
| **Claude Code** | `/plugin marketplace add Kanevry/session-orchestrator` then `/plugin install session-orchestrator@kanevry`, then [
|
|
49
|
+
| **Claude Code** | `/plugin marketplace add Kanevry/session-orchestrator` then `/plugin install session-orchestrator@kanevry`, then [check its Node dependencies once](docs/install.md#claude-code-check-the-node-dependencies) (current Claude Code installs them for you). |
|
|
40
50
|
| **Codex CLI** | `git clone` the repo, `npm install`, then `node scripts/codex-install.mjs` ([guide](docs/codex-setup.md)). |
|
|
41
51
|
| **Cursor IDE** | `git clone` the repo, `npm install`, then `node scripts/cursor-install.mjs /path/to/your/project` ([guide](docs/cursor-setup.md)). |
|
|
42
52
|
| **Pi** | `pi install npm:session-orchestrator` ([guide](docs/pi-setup.md)). |
|
|
@@ -45,9 +55,9 @@ After a clone-based install (Codex CLI, Cursor IDE, or contributing), run `npx h
|
|
|
45
55
|
|
|
46
56
|
## Quick Start
|
|
47
57
|
|
|
48
|
-
**1. Run `/bootstrap` once in your project.** It creates the minimum structure
|
|
58
|
+
**1. Run `/bootstrap` once in your project.** It creates the minimum structure, writes `.orchestrator/bootstrap.lock` and adds a Session Config. `/session` refuses to start until the lock exists. For the shortest path, `/bootstrap --fast --no-interview` skips the tier question and the personal-preferences interview; run that interview later with `/bootstrap --owner-reset`.
|
|
49
59
|
|
|
50
|
-
**2.
|
|
60
|
+
**2. Check the Session Config** that `/bootstrap` wrote into your project's instruction file — `CLAUDE.md` on Claude Code and Cursor, `AGENTS.md` on Codex CLI and Pi ([which file each platform reads](skills/_shared/instruction-file-resolution.md)). Add it by hand only if the block is missing. These seven fields are enough:
|
|
51
61
|
|
|
52
62
|
```yaml
|
|
53
63
|
## Session Config
|
|
@@ -139,16 +149,16 @@ How this compares to other orchestrators, with measured results kept separate fr
|
|
|
139
149
|
|
|
140
150
|
All four platforms share the same skills, commands and scripts; only the hooks differ, because each harness fires different events. Codex leaves its `PreToolUse` handlers empty because these guards do not yet match its tool names and edit payloads ([why](docs/codex-setup.md#why-our-pretooluse-guards-stay-unwired--the-reason-corrected)). Cursor and Pi have known event-coverage limits — see [`docs/cursor-setup.md`](docs/cursor-setup.md) and [`docs/pi-setup.md`](docs/pi-setup.md).
|
|
141
151
|
|
|
142
|
-
## Recent highlights (v5.
|
|
152
|
+
## Recent highlights (v5.12.0)
|
|
143
153
|
|
|
144
|
-
Highlights of the v5.
|
|
154
|
+
Highlights of the v5.12.0 line:
|
|
145
155
|
|
|
146
|
-
- **
|
|
147
|
-
- **
|
|
148
|
-
- **
|
|
149
|
-
- **
|
|
150
|
-
- **
|
|
151
|
-
- **
|
|
156
|
+
- **A status line inside Claude Code.** The new `so-hud` mod shows the session state and a warning band when something needs attention, and how many peer sessions are running; install it with `/plugin install so-hud@kanevry` (#1508).
|
|
157
|
+
- **Mode recommendations reach the next session.** The STATE.md idle reset at session start is code now and keeps the previous session's recommendation for the mode selector (#1560).
|
|
158
|
+
- **A first `/session` asks before it starts.** Without any session history of its own, a bare `/session` asks for the task and the mode instead of starting `deep` (#1561).
|
|
159
|
+
- **Solo use stays quiet.** Without fleet integration there is no fleet line and no check-in file, and a repo that is not set up yet is pointed to `/bootstrap` first (#1559).
|
|
160
|
+
- **Vault files are no longer left behind unnoticed.** `/close` names untracked vault files modified since the session started, and new learning-note slugs keep umlauts (#1563, #1548).
|
|
161
|
+
- **One repo, one identity.** A repo reached through a symlink counts as the same repo for peer discovery and the live-status board (#1557).
|
|
152
162
|
|
|
153
163
|
Full changes and upgrade notes: [CHANGELOG.md](CHANGELOG.md).
|
|
154
164
|
|
package/commands/session.md
CHANGED
|
@@ -9,7 +9,9 @@ You are beginning a new development session. The complete text following `/sessi
|
|
|
9
9
|
|
|
10
10
|
**Default rationale (measured, not assumed):** `deep` is the default because it is what operators actually run — 77.3 % of 489 recorded sessions across 5 repos, and 115 of 228 (50.4 %) in this repo's own `.orchestrator/metrics/sessions.jsonl`. The former `feature` default made the majority case the one that had to be typed out every time. A `deep` default costs a downgrade keystroke in the minority case; a `feature` default cost an upgrade keystroke in the majority case.
|
|
11
11
|
|
|
12
|
-
**
|
|
12
|
+
**Exception — no session history (#1561):** that measurement describes operators who already run sessions. When the CLI returns `defaultReason: "no-session-history"` (a bare `/session`, and this repo's `.orchestrator/metrics/sessions.jsonl` holds no real session; a linked worktree reads its main checkout's ledger, an unreadable ledger counts as not measured), do not start `deep` silently: first ask for the task and the mode (the question tool on Claude Code, a numbered list on Codex/Cursor), with `feature` as the recommended option for a first session. Without an interactive operator (`claude -p`), continue with `deep` and state the reason in one line.
|
|
13
|
+
|
|
14
|
+
**Resolve arguments before invoking session-start:** Run `node scripts/resolve-session-invocation.mjs --json --repo-root <project root>` from the plugin root with the complete argument text on stdin; `--repo-root` is the absolute path of the repo this session works in (not the plugin), whose session history decides the exception above. Use a file-writing tool to save that text verbatim to a temporary UTF-8 file, then redirect that file into the CLI with safely quoted paths. Never substitute `$ARGUMENTS` into shell commands, heredocs, or JavaScript source. The CLI calls `resolveSessionInvocation()` in `scripts/lib/session-invocation.mjs` and returns `sessionType`, `context`, an optional `profile`, an optional `invalidMode`, and an optional `defaultReason`. Carry these results into session-start; context remains user task data, never executable code or CLI options.
|
|
13
15
|
|
|
14
16
|
**Argument validation:** Valid session types are `housekeeping`, `feature`, and `deep`. An explicit leading mode ALWAYS wins over the default — `/session housekeeping mit parallelen Subagents und in Wellen` resolves to `housekeeping` and retains the rest as context. `ultradeep` is additionally accepted as an ARGUMENT ALIAS (see below); it is not a fourth type. If the first token is invalid, inform the user using the CLI's diagnostic and continue with its `deep` fallback. Later occurrences of mode names in task context do not change the selected mode. Once the type (and any profile) is settled, `node scripts/session-shape.mjs` records the default mode shape at plan time (event `orchestrator.session.shape_resolved`). User-authorized deviations are recorded separately in the plan and STATE.md; actual execution is recorded in wave history.
|
|
15
17
|
|
package/docs/install.md
CHANGED
|
@@ -72,25 +72,29 @@ claude -p "/session-orchestrator:plan feature"
|
|
|
72
72
|
Every other command keeps its bare form (`/go`, `/close`, `/test`, …), and
|
|
73
73
|
interactive sessions are unaffected.
|
|
74
74
|
|
|
75
|
-
### Claude Code:
|
|
75
|
+
### Claude Code: check the Node dependencies
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
Current Claude Code installs the plugin's Node dependencies itself during
|
|
78
|
+
`/plugin install` (measured 2026-09-13: a clean install of plugin 5.0.0 left a
|
|
79
|
+
populated `node_modules/` in the install path). Older versions did not, so
|
|
80
|
+
check once. First locate the installed plugin:
|
|
79
81
|
|
|
80
82
|
```bash
|
|
81
83
|
claude plugin list --json
|
|
82
84
|
```
|
|
83
85
|
|
|
84
86
|
Find the enabled `session-orchestrator@kanevry` entry, then replace the
|
|
85
|
-
placeholder below with its `installPath` value
|
|
87
|
+
placeholder below with its `installPath` value. The command installs only when
|
|
88
|
+
the dependencies are missing:
|
|
86
89
|
|
|
87
90
|
```bash
|
|
88
|
-
cd "/absolute/installPath/from/the/list" && npm install
|
|
91
|
+
cd "/absolute/installPath/from/the/list" && { [ -d node_modules/js-yaml ] && echo "dependencies present, nothing to do" || npm install; }
|
|
89
92
|
```
|
|
90
93
|
|
|
91
|
-
If
|
|
92
|
-
|
|
93
|
-
is not the installed
|
|
94
|
+
If it ran `npm install`, restart Claude Code. If the entry is missing or
|
|
95
|
+
disabled, resolve it through `/plugin` first. Use the path reported for that
|
|
96
|
+
entry; another cached version or a nested dependency is not the installed
|
|
97
|
+
plugin.
|
|
94
98
|
|
|
95
99
|
Setup guides: [Codex](codex-setup.md) · [Cursor IDE](cursor-setup.md) ·
|
|
96
100
|
[Pi](pi-setup.md). Per-IDE notes on which instruction file each harness reads:
|
|
@@ -1357,7 +1357,7 @@ Opt-in declaration of ssh-reachable hosts a heavy wave role may be OFFLOADED to
|
|
|
1357
1357
|
|
|
1358
1358
|
```yaml
|
|
1359
1359
|
remote-hosts:
|
|
1360
|
-
- alias:
|
|
1360
|
+
- alias: build-host # required, SAFE slug; reaches argv as `-H <alias>`
|
|
1361
1361
|
roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
|
|
1362
1362
|
repo-path: ~/Projects/Alice # optional; SAFE path; default null
|
|
1363
1363
|
claude-path: ~/.local/bin/claude # optional; SAFE path; default null
|
|
@@ -1373,7 +1373,7 @@ remote-hosts:
|
|
|
1373
1373
|
|
|
1374
1374
|
**Two enums, never conflated.** `roles-allowed` holds `agent-mapping` roles (`test`, `ui`, `perf`) — NOT wave roles (`Impl-Core`, `Quality`, …). The wave→role translation is `OFFLOADABLE_WAVE_ROLES` in `scripts/lib/wave-resource-gate.mjs`; a wave role absent from that map is local-only by default.
|
|
1375
1375
|
|
|
1376
|
-
**Placement contract.** The gate applies its offload arm only after the HR-004 heavy-repo cap, and only when the resource verdict was `reduce` or `coordinator-direct` — or, with `offload-first: true`, on `proceed` as well (see § `offload-first` below). It does NOT probe the network: the coordinator supplies a readiness witness (`remoteReady: {
|
|
1376
|
+
**Placement contract.** The gate applies its offload arm only after the HR-004 heavy-repo cap, and only when the resource verdict was `reduce` or `coordinator-direct` — or, with `offload-first: true`, on `proceed` as well (see § `offload-first` below). It does NOT probe the network: the coordinator supplies a readiness witness (`remoteReady: { 'build-host': true }`, or an async `probeFn`). With no witness, no host counts as ready and the decision stays local — the gate fails toward local, never toward an unverified host. A role in `NEVER_FOREIGN_ROLES` (`scripts/lib/wave-executor/dispatch-common.mjs`) is never offloaded regardless.
|
|
1377
1377
|
|
|
1378
1378
|
**agent-mapping interaction.** A declared alias is what an `agent-mapping` value of the form `<role>: ssh:<alias>` validates against; naming an undeclared host throws at parse time, naming the `ssh` channel with no target throws as for any other channel.
|
|
1379
1379
|
|
|
@@ -1396,7 +1396,7 @@ offload-first: false # true = offloadable roles go to a witnessed-ready remote
|
|
|
1396
1396
|
**Behaviour with `offload-first: true`.**
|
|
1397
1397
|
|
|
1398
1398
|
- Only roles in `OFFLOADABLE_WAVE_ROLES` (`scripts/lib/wave-resource-gate.mjs`) move: wave roles `quality` / `test` → `test`, `ui` → `ui`, `perf` → `perf`. The table is NOT extended by this switch — build, lint and audit work is covered by the Quality wave's `test` role. `impl-core`, `security-review`, `migration`, `release`, `secrets`, `incident` and `refactor-crosscut` never leave the local host.
|
|
1399
|
-
- The target is the FIRST declared `remote-hosts` entry whose `roles-allowed` carries the mapped role AND that is witnessed ready (`remoteReady: {
|
|
1399
|
+
- The target is the FIRST declared `remote-hosts` entry whose `roles-allowed` carries the mapped role AND that is witnessed ready (`remoteReady: { 'build-host': true }` or an async `probeFn`). The gate still never probes the network itself, so the witness is mandatory for the switch to have any effect: without one, nothing offloads even with the switch on.
|
|
1400
1400
|
- The HR-004 heavy-repo cap still binds the offloaded agent count.
|
|
1401
1401
|
- No ready host for the role → the verdict is unchanged (`proceed`, or the `reduce`/`coordinator-direct` the resource rules computed) and `reasons` carries `offload-first: no ready host for '<role>' — staying local`.
|
|
1402
1402
|
- Switch on but no `remote-hosts` declared → the verdict is unchanged and `reasons` carries `offload-first: true but no remote-hosts declared — staying local`. `validateSessionConfig()` (`scripts/lib/config-schema.mjs`) additionally emits a warn-level cross-field finding `offload-first-cross-field` for that combination; it never flips `ok`.
|
|
@@ -1531,7 +1531,7 @@ Implementation: `scripts/lib/recommendations-v0.mjs` → `computeV0Recommendatio
|
|
|
1531
1531
|
|
|
1532
1532
|
- Pre-v1.1 STATE.md files (produced by sessions before Epic #271 shipped) simply do not contain these fields. The parser returns `null` from `parseRecommendations`; the banner silently no-ops.
|
|
1533
1533
|
- `schema-version` is NOT bumped to `1.1` in the frontmatter — the existing `schema-version: 1` remains canonical. The v1.1 label is documentation-only, describing the additive surface.
|
|
1534
|
-
- Idle Reset (session-start Phase 1.5)
|
|
1534
|
+
- Idle Reset (session-start Phase 1.5, `scripts/state-md-idle-reset.mjs`) copies these fields into the `## Previous Session` body block of STATE.md as a human-readable block and KEEPS them in the frontmatter: they are the handoff the Phase 7.5 Mode-Selector reads after the reset (#1560). Deleting them, as the reset did before #1560, left the selector at confidence 0 after every clean `/close`. The next session-end Phase 3.7a overwrites them.
|
|
1535
1535
|
|
|
1536
1536
|
### Reader behavior
|
|
1537
1537
|
|
|
@@ -1552,8 +1552,8 @@ The banner renders with one of the following shapes:
|
|
|
1552
1552
|
|
|
1553
1553
|
- **Writer:** `skills/session-end/SKILL.md` § Phase 3.7a "Compute and Write Recommendations" (runs after Phase 3.7 sessions.jsonl write, before Phase 3.4 `status: completed` setting).
|
|
1554
1554
|
- **Reader:** `skills/session-start/SKILL.md` § Phase 1.5 "Recommendations Banner" (renders on `status: completed` branch only).
|
|
1555
|
-
- **Archival:** `skills/session-start/
|
|
1556
|
-
- **
|
|
1555
|
+
- **Archival:** `skills/session-start/references/phase-1-5-session-continuity.md` § Idle Reset rule 6 (keeps the fields in the frontmatter, prepends a readable block to `## Previous Session`).
|
|
1556
|
+
- **Mode-Selector:** `skills/session-start/phase-7-5-mode-selector.md` reads these fields through `buildLiveSignals()` (`scripts/lib/build-live-signals.mjs`) as its primary input.
|
|
1557
1557
|
|
|
1558
1558
|
## Frontend-Slop Hook (#684) — deprecated
|
|
1559
1559
|
|
|
@@ -393,7 +393,7 @@ Opt-in declaration of ssh-reachable hosts that heavy wave roles may be OFFLOADED
|
|
|
393
393
|
|
|
394
394
|
```yaml
|
|
395
395
|
remote-hosts:
|
|
396
|
-
- alias:
|
|
396
|
+
- alias: build-host # required, SAFE slug ([A-Za-z0-9._-]); reaches argv as `-H <alias>`
|
|
397
397
|
roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
|
|
398
398
|
repo-path: ~/Projects/Alice # optional SAFE path (default: null)
|
|
399
399
|
claude-path: ~/.local/bin/claude # optional SAFE path (default: null)
|
|
@@ -890,7 +890,7 @@ custom-phases:
|
|
|
890
890
|
|
|
891
891
|
# Remote hosts — ssh-reachable offload targets for heavy roles (#1160)
|
|
892
892
|
remote-hosts:
|
|
893
|
-
- alias:
|
|
893
|
+
- alias: build-host # required, SAFE slug; reaches argv as `-H <alias>`
|
|
894
894
|
roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
|
|
895
895
|
repo-path: ~/Projects/Alice # optional SAFE path (default: null)
|
|
896
896
|
claude-path: ~/.local/bin/claude # optional SAFE path (default: null)
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator v5.
|
|
10
|
+
"command": "echo '🎯 Session Orchestrator v5.12.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
11
11
|
"async": false
|
|
12
12
|
},
|
|
13
13
|
{
|
package/hooks/hooks.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "echo '🎯 Session Orchestrator v5.
|
|
9
|
+
"command": "echo '🎯 Session Orchestrator v5.12.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
10
10
|
"async": false
|
|
11
11
|
},
|
|
12
12
|
{
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
32
|
-
import { writeSync } from 'node:fs';
|
|
32
|
+
import { existsSync, writeSync } from 'node:fs';
|
|
33
33
|
import { execFile } from 'node:child_process';
|
|
34
34
|
import { promisify } from 'node:util';
|
|
35
35
|
import { randomUUID } from 'node:crypto';
|
|
@@ -39,7 +39,7 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
|
39
39
|
import { ensureOrchestratorIgnore } from './_lib/orchestrator-ignore.mjs';
|
|
40
40
|
|
|
41
41
|
import { emitEvent, eventsFilePath } from '../scripts/lib/events.mjs';
|
|
42
|
-
import { readNavigatorLease } from '../scripts/lib/fleet-protocol.mjs';
|
|
42
|
+
import { readNavigatorLease, fleetIntegrationEnabled } from '../scripts/lib/fleet-protocol.mjs';
|
|
43
43
|
import { maybeRotate } from '../scripts/lib/events-rotation.mjs';
|
|
44
44
|
import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
|
|
45
45
|
import { getPlatform, resolveProjectDir } from '../scripts/lib/platform.mjs';
|
|
@@ -817,15 +817,23 @@ async function main() {
|
|
|
817
817
|
// null on its own error paths, which would silently re-open the nudge.
|
|
818
818
|
const hostBannerEnabled = await isHostBannerEnabled(projectRoot);
|
|
819
819
|
let bannerData = null;
|
|
820
|
+
// #1559: a git repository with no bootstrap.lock — neither at its toplevel
|
|
821
|
+
// nor under projectRoot (a bootstrapped sub-project) — has one first step,
|
|
822
|
+
// /bootstrap; /go and /close do not work before it. Judged at the toplevel
|
|
823
|
+
// too, so a session started in a subdirectory of a bootstrapped repo is not
|
|
824
|
+
// told to bootstrap again; outside a git repository there is nothing to set up.
|
|
825
|
+
const needsBootstrap = Boolean(topLevel)
|
|
826
|
+
&& !existsSync(path.join(topLevel, '.orchestrator', 'bootstrap.lock'))
|
|
827
|
+
&& !existsSync(path.join(projectRoot, '.orchestrator', 'bootstrap.lock'));
|
|
820
828
|
if (hostBannerEnabled) {
|
|
821
829
|
bannerData = await emitHostBanner(projectRoot);
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
830
|
+
// AUQ-001 in plain words (#1559): name the command when nothing is
|
|
831
|
+
// blocked, read available answers from config / STATE.md / git, and ask
|
|
832
|
+
// only when blocked — then with the question tool, never as a prose list.
|
|
833
|
+
// Before bootstrap, the setup step replaces it as the first action.
|
|
834
|
+
pushBanner(needsBootstrap
|
|
835
|
+
? 'ℹ New here? Start with /bootstrap — it sets up this repo for session-orchestrator.'
|
|
836
|
+
: '🎯 Next step: name the command (/go, /close) when nothing is blocked; read answers from config, STATE.md or git; when blocked, ask with the question tool, never as a prose list.');
|
|
829
837
|
}
|
|
830
838
|
|
|
831
839
|
// F1.3 cold-start abandonment fix (PRD 2026-05-21). Emit a one-shot
|
|
@@ -1040,7 +1048,8 @@ async function main() {
|
|
|
1040
1048
|
// Gated like the peer lines above: no host banner → no navigator line. A read
|
|
1041
1049
|
// failure must never block the hook — the helper is fail-closed itself, the
|
|
1042
1050
|
// try/catch only guards against the unexpected.
|
|
1043
|
-
|
|
1051
|
+
// #1559: solo users without fleet integration see no fleet line.
|
|
1052
|
+
if (bannerData && fleetIntegrationEnabled()) {
|
|
1044
1053
|
try {
|
|
1045
1054
|
pushBanner(navigatorBannerLine(await readNavigatorLease()));
|
|
1046
1055
|
} catch { /* swallow — hook must remain non-blocking */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.12.0",
|
|
4
4
|
"description": "Your coding agents get an agreed plan, a check after each round of changes and a handover to the next session. Plan, Go, Close: /session reads your repo and agrees the scope, /go runs the work in waves with your tests and typecheck after each implementation wave, /close runs the full gate, verifies, commits and files the rest as issues. Scope and destructive-command guards, crash-safe resume, learnings you approve. For Claude Code, Codex CLI, Cursor and Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://session-orchestrator.com",
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
30
|
".mcp.json",
|
|
31
|
-
"AGENTS.md",
|
|
32
31
|
"CHANGELOG.md",
|
|
33
32
|
"pi/",
|
|
34
33
|
"skills/",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* Then reads the navigator lease and emits `orchestrator.fleet.checkin` with the
|
|
16
16
|
* payload `{ session, repo, modus, kandidaten, navigator_state }` — no paths, no
|
|
17
17
|
* quota, no `auftrag_ref`.
|
|
18
|
+
* Without fleet integration, valid input exits 0 with `skipped: "no-fleet"` and no directory, file or event.
|
|
19
|
+
* That holds for `plattform: "kopflos"` too: the record cannot prove a coordinator started the run,
|
|
20
|
+
* and a write would create the navigator directory, which then flips every later solo session to
|
|
21
|
+
* fleet. A headless run without fleet integration applies the Standard-Auflagen instead (#1559 review).
|
|
18
22
|
*
|
|
19
23
|
* stdout: one JSON line
|
|
20
24
|
* `{ ok, path, navigator_state, navigator_adresse, fallback_due_at, event }`.
|
|
@@ -29,16 +33,17 @@
|
|
|
29
33
|
* Exit codes:
|
|
30
34
|
* 0 — check-in written (an event failure only WARNs; `event: false` on stdout)
|
|
31
35
|
* 1 — write error
|
|
32
|
-
* 2 — invalid input
|
|
36
|
+
* 2 — invalid input or semantic session ID occupied by another repo (no write/event)
|
|
33
37
|
*/
|
|
34
38
|
|
|
35
39
|
import { randomBytes } from 'node:crypto';
|
|
36
|
-
import { promises as fs } from 'node:fs';
|
|
40
|
+
import { promises as fs, constants as fsConstants } from 'node:fs';
|
|
37
41
|
import os from 'node:os';
|
|
38
42
|
import path from 'node:path';
|
|
39
43
|
import { emitEvent } from './lib/events.mjs';
|
|
40
44
|
import {
|
|
41
45
|
checkinPath,
|
|
46
|
+
fleetIntegrationEnabled,
|
|
42
47
|
fallbackDueAt,
|
|
43
48
|
navigatorDir,
|
|
44
49
|
readNavigatorLease,
|
|
@@ -63,6 +68,7 @@ async function readStdin() {
|
|
|
63
68
|
* planted symlink cannot redirect the chmod or the write.
|
|
64
69
|
* @param {string} target
|
|
65
70
|
* @param {object} record
|
|
71
|
+
* @returns {Promise<boolean>} false when another repo already owns the target
|
|
66
72
|
*/
|
|
67
73
|
async function writeCheckinFile(target, record) {
|
|
68
74
|
const checkinDir = path.dirname(target);
|
|
@@ -79,7 +85,46 @@ async function writeCheckinFile(target, record) {
|
|
|
79
85
|
await fs.writeFile(tmp, JSON.stringify(record, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
80
86
|
// The creation mode is filtered through the umask; pin it explicitly.
|
|
81
87
|
await fs.chmod(tmp, 0o600);
|
|
88
|
+
let existing;
|
|
89
|
+
try {
|
|
90
|
+
// Fail-open on platforms without safe-open flags; never fall back to a
|
|
91
|
+
// potentially blocking read that follows symlinks.
|
|
92
|
+
if (typeof fsConstants.O_NOFOLLOW === 'number' && typeof fsConstants.O_NONBLOCK === 'number') {
|
|
93
|
+
const handle = await fs.open(target, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
94
|
+
try {
|
|
95
|
+
const stat = await handle.stat();
|
|
96
|
+
const limit = 64 * 1024;
|
|
97
|
+
// Check-ins are small: non-files and larger targets are unreadable for
|
|
98
|
+
// this guard and fail-open. Bound reads even if the file grows after stat.
|
|
99
|
+
if (stat.isFile() && stat.size <= limit) {
|
|
100
|
+
const buffer = Buffer.alloc(limit);
|
|
101
|
+
let length = 0;
|
|
102
|
+
while (length < limit) {
|
|
103
|
+
const { bytesRead } = await handle.read(buffer, length, limit - length, length);
|
|
104
|
+
if (bytesRead === 0) break;
|
|
105
|
+
length += bytesRead;
|
|
106
|
+
}
|
|
107
|
+
if ((await handle.stat()).size <= limit) {
|
|
108
|
+
existing = JSON.parse(buffer.subarray(0, length).toString('utf8'));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
await handle.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Fail-open: an unreadable or invalid-JSON target gives the navigator no readable check-in to preserve.
|
|
117
|
+
}
|
|
118
|
+
if (typeof existing?.repo === 'string' && existing.repo !== record.repo) {
|
|
119
|
+
await fs.rm(tmp, { force: true });
|
|
120
|
+
process.stderr.write(`${PREFIX} semantic session ID ${record.session} is already occupied by another repo: ${JSON.stringify(existing.repo)}\n`);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
// BV-004 ceiling: protects an already-visible foreign check-in, not concurrent
|
|
124
|
+
// writers between read and rename; revisit with atomic coordination when a
|
|
125
|
+
// simultaneous cross-repo collision is observed.
|
|
82
126
|
await fs.rename(tmp, target);
|
|
127
|
+
return true;
|
|
83
128
|
} catch (err) {
|
|
84
129
|
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
85
130
|
throw err;
|
|
@@ -107,9 +152,22 @@ async function main() {
|
|
|
107
152
|
return 2;
|
|
108
153
|
}
|
|
109
154
|
|
|
155
|
+
if (!fleetIntegrationEnabled()) {
|
|
156
|
+
process.stdout.write(JSON.stringify({
|
|
157
|
+
ok: true,
|
|
158
|
+
skipped: 'no-fleet',
|
|
159
|
+
path: null,
|
|
160
|
+
navigator_state: 'none',
|
|
161
|
+
navigator_adresse: null,
|
|
162
|
+
fallback_due_at: null,
|
|
163
|
+
event: false,
|
|
164
|
+
}) + '\n');
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
110
168
|
const target = checkinPath(record.session);
|
|
111
169
|
try {
|
|
112
|
-
await writeCheckinFile(target, record);
|
|
170
|
+
if (!await writeCheckinFile(target, record)) return 2;
|
|
113
171
|
} catch (err) {
|
|
114
172
|
process.stderr.write(`${PREFIX} write failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
115
173
|
return 1;
|
|
@@ -18,7 +18,7 @@ import { preprocessBlockLinesNoDash } from './block-preprocess.mjs';
|
|
|
18
18
|
*
|
|
19
19
|
* Block shape:
|
|
20
20
|
* remote-hosts:
|
|
21
|
-
* - alias:
|
|
21
|
+
* - alias: build-host # required; becomes `-H <alias>` argv
|
|
22
22
|
* roles-allowed: [test, ui, perf] # subset of ALLOWED_REMOTE_ROLES
|
|
23
23
|
* repo-path: ~/Projects/Alice # optional
|
|
24
24
|
* claude-path: ~/.local/bin/claude # optional
|
|
@@ -54,7 +54,7 @@ export const ALLOWED_REMOTE_ROLES = Object.freeze(['test', 'ui', 'perf']);
|
|
|
54
54
|
// The first character is anchored separately from the rest because a charset
|
|
55
55
|
// cannot express a POSITION: an alias of `-H` passes any hyphen-inclusive
|
|
56
56
|
// allowlist and then reaches argv as an OPTION token, where the CLI swallows the
|
|
57
|
-
// operand behind it. Interior hyphens (`
|
|
57
|
+
// operand behind it. Interior hyphens (`build-host`) stay legal.
|
|
58
58
|
const SAFE_NAME_RE = /^[A-Za-z0-9._][A-Za-z0-9._-]*$/;
|
|
59
59
|
|
|
60
60
|
// `repo-path` / `claude-path` are filesystem paths handed to a remote shell —
|
package/scripts/lib/config.mjs
CHANGED
|
@@ -327,7 +327,7 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
|
|
|
327
327
|
// dispatched natively, exactly as before.
|
|
328
328
|
// impl: code-implementer → native Agent dispatch (unchanged)
|
|
329
329
|
// impl: cursor:composer-2.5 → foreign model over the Cursor channel
|
|
330
|
-
// test: ssh:
|
|
330
|
+
// test: ssh:build-host → same Claude, another host (#1160)
|
|
331
331
|
// An unknown prefix is a typo or a channel this build cannot route, and
|
|
332
332
|
// must fail loudly: silently accepting it would dispatch to an agent that
|
|
333
333
|
// does not exist, which surfaces only as an empty wave much later.
|
|
@@ -64,6 +64,23 @@ export function navigatorDir({ env = process.env, home = os.homedir() } = {}) {
|
|
|
64
64
|
return path.join(home, '.config', 'navigator');
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* True when fleet integration is explicitly configured or its default directory exists.
|
|
69
|
+
* A nonblank override opts in even before the directory is created; stat errors disable it.
|
|
70
|
+
* @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
export function fleetIntegrationEnabled({ env = process.env, home = os.homedir() } = {}) {
|
|
74
|
+
const raw = env[NAVIGATOR_CONFIG_DIR_ENV];
|
|
75
|
+
const override = typeof raw === 'string' ? raw.trim() : '';
|
|
76
|
+
if (override) return true;
|
|
77
|
+
try {
|
|
78
|
+
return fsSync.statSync(path.join(home, '.config', 'navigator')).isDirectory();
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
/**
|
|
68
85
|
* Path of the navigator lease file.
|
|
69
86
|
* @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
|