session-orchestrator 4.0.1 → 4.1.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 +1 -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 +23 -0
- package/README.md +54 -50
- package/commands/release.md +4 -4
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/package.json +1 -1
- package/scripts/ci/assert-coverage-green.mjs +100 -0
- package/scripts/lib/events.mjs +3 -3
- package/scripts/lib/project-hygiene.mjs +182 -6
- package/scripts/lib/session-identity/own-session.mjs +24 -13
- package/scripts/lib/validate/check-owner-leakage.mjs +17 -8
- package/scripts/lib/vault-mirror/render-sessions.mjs +8 -1
- package/scripts/release.mjs +32 -11
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
|
|
10
|
-
"version": "4.0
|
|
10
|
+
"version": "4.1.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -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": "4.0
|
|
4
|
+
"version": "4.1.0",
|
|
5
5
|
"description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. 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": "4.0
|
|
3
|
+
"version": "4.1.0+codex.20260908074427",
|
|
4
4
|
"description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. 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": "4.0
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bernhard Goetzendorfer"
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.1.0] - 2026-09-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- The owner-privacy scanner accepts `--include-untracked` to check new files before staging while respecting Git ignore rules. Tracked-only remains the default (#1267).
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Privacy scanning includes MDX and preserves filenames containing Unicode or newlines. Failure output includes the scanned-file count, and the pre-commit regression verifies that an unavailable confidential-name policy blocks the commit (#1267, #1269).
|
|
19
|
+
- Native Codex `CODEX_THREAD_ID` now participates in process-confirmed session ownership and event attribution. Explicit harness selection prevents inherited IDs from proving ownership; ambiguous IDs remain unconfirmed. Test workers isolate the invoking harness's identity (#1274).
|
|
20
|
+
- GitHub release recovery prints commands with the exact repository and tag, retains failed-create notes for retry, and inspects uncertain release state before any creation (#1095).
|
|
21
|
+
- CI audit detection examines executable GitHub steps and GitLab jobs, recognizes package-manager options and supported wrappers, and excludes metadata, comments, unused templates and help-only invocations. Local GitLab references, inheritance and `spec:inputs` headers are supported; external includes and dynamic conditions remain outside this heuristic (#1040).
|
|
22
|
+
- Vault session notes render lifecycle agent counts, preserve measured zero, and label counts known only as completed or planned (#1276).
|
|
23
|
+
- Guide copy buttons stay beside their command blocks, including multiline snippets and narrow screens (#1275).
|
|
24
|
+
- Full-repository validation runs once before Vitest workers start, and its result is shared by the existing smoke assertions. This avoids four competing scans during coverage while retaining mandatory validation and failure propagation (#1278).
|
|
25
|
+
- Coverage verification now requires generated reports and compares structured measurements with the canonical Vitest thresholds before writing its verified marker. Missing artifacts and low coverage previously passed through shell conditions that did not stop the CI job (#1279).
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- README, English/German landing pages, guide and machine-readable summaries now agree on installation prerequisites, active plugin-cache selection, Codex command skills, Pi package management, platform-specific enforcement and update-cache behavior (#1275).
|
|
30
|
+
- Refreshed distribution evidence and submission drafts, corrected stable repository steering, and recorded the product-quality review with Chrome and backlog evidence (#824, #1080).
|
|
31
|
+
- Release instructions now place the fully green preflight after commit, both remote pushes and exact-commit CI, matching the executable gate's requirements (#1277).
|
|
32
|
+
|
|
10
33
|
## [4.0.1] - 2026-09-07
|
|
11
34
|
|
|
12
35
|
A PATCH release, two work streams. Codex CLI command entrypoints (closing Epic #1263 and
|
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# Session Orchestrator
|
|
2
2
|
|
|
3
3
|
[](LICENSE)
|
|
4
|
-
[](CHANGELOG.md)
|
|
5
5
|
[](https://www.npmjs.com/package/session-orchestrator)
|
|
6
6
|
[](docs/telemetry/telemetry-claims.md)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Plan, run and verify coding sessions with **Claude Code, Codex CLI, Cursor IDE, or [Pi](docs/pi-setup.md)**. Session Orchestrator reads your repository and issues, proposes a plan, coordinates work in waves, and records what passed and what remains. Free, MIT-licensed, and community-maintained.
|
|
9
|
+
|
|
10
|
+
[Website](https://session-orchestrator.com) · [User guide](docs/USER-GUIDE.md) · [Platform support](#platform-support) · [Changelog](CHANGELOG.md)
|
|
9
11
|
|
|
10
12
|
The same workflows are available on all four harnesses; Codex exposes commands as selectable skills. **Enforcement depth differs** — scope enforcement is full on Claude Code, bridged on Cursor and Pi, and currently unavailable on Codex CLI (see [Platform support](#platform-support)).
|
|
11
13
|
|
|
@@ -16,7 +18,7 @@ The same workflows are available on all four harnesses; Codex exposes commands a
|
|
|
16
18
|
| **Node.js** | **24 or later** (`node --version`) — `package.json` `engines.node` is `>=24.0.0`. The plugin is ES modules and needs a real Node runtime. [Install Node.js](https://nodejs.org/). |
|
|
17
19
|
| **A coding agent** | Claude Code, Codex CLI, Cursor IDE, or Pi. This is a workflow layer *on top of* one of them, not a replacement. |
|
|
18
20
|
| **Harness version** | Codex CLI **0.144.4 or later** ([docs/codex-setup.md](docs/codex-setup.md)). No minimum is pinned for Claude Code, Cursor, or Pi — if `/plugin` (or the Cursor/Pi installer) runs, the plugin loads. |
|
|
19
|
-
| **OS** | macOS and Linux are
|
|
21
|
+
| **OS** | macOS and Linux are tested in CI. Windows is untested and best-effort; shell hooks and the optional Bash/`jq` MCP server need WSL or Git Bash. |
|
|
20
22
|
| **Git** | A git repository. Session-orchestrator reads git state at every session start and commits at close. |
|
|
21
23
|
|
|
22
24
|
## Install
|
|
@@ -28,42 +30,21 @@ The same workflows are available on all four harnesses; Codex exposes commands a
|
|
|
28
30
|
| **Cursor IDE** | `git clone https://github.com/Kanevry/session-orchestrator.git ~/Projects/session-orchestrator && cd ~/Projects/session-orchestrator && npm install && node scripts/cursor-install.mjs /path/to/your/project` |
|
|
29
31
|
| **Pi** | `pi install npm:session-orchestrator` — or dev-fallback: `git clone https://github.com/Kanevry/session-orchestrator.git ~/Projects/session-orchestrator && cd ~/Projects/session-orchestrator && npm install && node scripts/pi-install.mjs /path/to/your/project --settings-only` |
|
|
30
32
|
|
|
31
|
-
For Claude Code, also install Node dependencies **once**
|
|
33
|
+
For Claude Code, also install the package's Node dependencies **once** and restart Claude Code. First locate the installed plugin:
|
|
32
34
|
|
|
33
35
|
```bash
|
|
34
|
-
|
|
35
|
-
SO_DIR="$(dirname "$(find ~/.claude/plugins/cache -path '*session-orchestrator*' -name package.json 2>/dev/null | head -1)")"
|
|
36
|
-
cd "$SO_DIR" && npm install
|
|
36
|
+
claude plugin list --json
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
Setup guides: [Codex](docs/codex-setup.md) · [Cursor IDE](docs/cursor-setup.md) · [Pi](docs/pi-setup.md). Per-IDE notes on `CLAUDE.md` vs `AGENTS.md`: [instruction-file-resolution](skills/_shared/instruction-file-resolution.md).
|
|
42
|
-
|
|
43
|
-
## Upgrade
|
|
39
|
+
Find the enabled `session-orchestrator@kanevry` entry, then replace the placeholder below with its `installPath` value:
|
|
44
40
|
|
|
45
|
-
```
|
|
46
|
-
/
|
|
41
|
+
```bash
|
|
42
|
+
cd "/absolute/installPath/from/the/list" && npm install
|
|
47
43
|
```
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
Session-start tells you when the running copy is behind: `scripts/lib/plugin-update-banner.mjs` compares the version of the code **that is actually loaded** against the published npm version and warns in the session-start banner (minor or major; patch-only updates stay silent). It fails silent — offline, a non-2xx response, or a malformed answer produces *no statement*, never a false "up to date".
|
|
52
|
-
|
|
53
|
-
Upgrading across a major version: **[docs/migration-v4.md](docs/migration-v4.md)** is the current one — v4.0.0 removes five skills, three commands and eight top-level scripts, each on a measured 90-day two-signal rule rather than a judgement call, and it names what replaces every removed invocation. [docs/migration-v3.md](docs/migration-v3.md) documents the older v2 → v3 path and the shape both guides follow (what changes · prerequisites · per-platform steps · what stays · known issues · rollback).
|
|
45
|
+
If that entry is missing or disabled, resolve it through `/plugin` first. Use the path reported for that entry; another cached version or a nested dependency is not the installed plugin.
|
|
54
46
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Remove the plugin through your harness's own plugin manager — `/plugin` in Claude Code (marketplace entry `session-orchestrator@kanevry`), `codex plugin remove` on Codex CLI ([docs/codex-setup.md](docs/codex-setup.md)). On Cursor and Pi, delete the files the installer wrote into your project.
|
|
58
|
-
|
|
59
|
-
**What stays behind in your repo** — none of it is removed by uninstalling, and all of it is plain text you can delete by hand:
|
|
60
|
-
|
|
61
|
-
- `.orchestrator/` — `bootstrap.lock`, `metrics/` (your session and learning JSONL records), `policy/`, `steering/`, `runtime/`, `peers/`, `session.lock`
|
|
62
|
-
- `STATE.md` under your harness's state directory (`.claude/STATE.md` on Claude Code — see [Platform support](#platform-support))
|
|
63
|
-
- The `## Session Config` block you added to `CLAUDE.md` / `AGENTS.md`
|
|
64
|
-
- `.claude/rules/*.md` if you vendored the rule library via `/bootstrap --sync-rules`
|
|
65
|
-
|
|
66
|
-
Deleting `.orchestrator/metrics/` deletes your session history. Nothing is sent anywhere without your explicit consent (see [Data & telemetry](#safety--data--telemetry)) — the one exception is the session-start update check (`scripts/lib/plugin-update-banner.mjs`): a single anonymous `GET` to the npm registry, at most once per day per repo, comparing your installed version against the latest release. Set `SO_DISABLE_UPDATE_CHECK=1` (or `DO_NOT_TRACK=1`) to turn it off. Beyond that, there is nothing else to revoke.
|
|
47
|
+
Setup guides: [Codex](docs/codex-setup.md) · [Cursor IDE](docs/cursor-setup.md) · [Pi](docs/pi-setup.md). Per-IDE notes on `CLAUDE.md` vs `AGENTS.md`: [instruction-file-resolution](skills/_shared/instruction-file-resolution.md).
|
|
67
48
|
|
|
68
49
|
## Quick Start
|
|
69
50
|
|
|
@@ -117,6 +98,31 @@ $session-orchestrator:close
|
|
|
117
98
|
|
|
118
99
|
These entries preserve each command's full workflow and prechecks. Codex's native `/goal` is a separate feature. `/plan` and `/evolve` extend the loop, but you can start with just these three.
|
|
119
100
|
|
|
101
|
+
## Upgrade
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
/plugin update session-orchestrator@kanevry # Claude Code
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Restart the harness afterwards, and re-run `npm install` in the plugin directory when the release adds dependencies. On Cursor and the Pi clone fallback, upgrade with `git pull` in your clone followed by the same install script you originally ran. Manage npm-installed Pi packages through Pi's package manager. For Codex, follow the [refresh instructions](docs/codex-setup.md#refresh-and-explicit-cache-invalidation) for your marketplace source, then reload the skill picker or restart Codex.
|
|
108
|
+
|
|
109
|
+
Session-start tells you when the running copy is behind: `scripts/lib/plugin-update-banner.mjs` compares the version of the code **that is actually loaded** against the published npm version and warns in the session-start banner (minor or major; patch-only updates stay silent). It fails silent — offline, a non-2xx response, or a malformed answer produces *no statement*, never a false "up to date".
|
|
110
|
+
|
|
111
|
+
Upgrading across a major version: **[docs/migration-v4.md](docs/migration-v4.md)** is the current one — v4.0.0 removes five skills, three commands and eight top-level scripts, each on a measured 90-day two-signal rule rather than a judgement call, and it names what replaces every removed invocation. [docs/migration-v3.md](docs/migration-v3.md) documents the older v2 → v3 path and the shape both guides follow (what changes · prerequisites · per-platform steps · what stays · known issues · rollback).
|
|
112
|
+
|
|
113
|
+
## Uninstall
|
|
114
|
+
|
|
115
|
+
Remove the plugin through your harness's own plugin manager — `/plugin` in Claude Code (marketplace entry `session-orchestrator@kanevry`), `codex plugin remove` on Codex CLI ([docs/codex-setup.md](docs/codex-setup.md)), or Pi's package manager for an npm-installed Pi package. On Cursor and the Pi clone fallback, delete the files the installer wrote into your project.
|
|
116
|
+
|
|
117
|
+
**What stays behind in your repo** — none of it is removed by uninstalling, and all of it is plain text you can delete by hand:
|
|
118
|
+
|
|
119
|
+
- `.orchestrator/` — `bootstrap.lock`, `metrics/` (your session and learning JSONL records), `policy/`, `steering/`, `runtime/`, `peers/`, `session.lock`
|
|
120
|
+
- `STATE.md` under your harness's state directory (`.claude/STATE.md` on Claude Code — see [Platform support](#platform-support))
|
|
121
|
+
- The `## Session Config` block you added to `CLAUDE.md` / `AGENTS.md`
|
|
122
|
+
- `.claude/rules/*.md` if you vendored the rule library via `/bootstrap --sync-rules`
|
|
123
|
+
|
|
124
|
+
Deleting `.orchestrator/metrics/` deletes your session history. Telemetry requires explicit consent (see [Data & telemetry](#safety--data--telemetry)). The session-start update check (`scripts/lib/plugin-update-banner.mjs`) makes an anonymous `GET` to the npm registry to compare your installed version against the latest release. Successful results are cached for 24 hours per repo; failed checks can retry at the next session start. Set `SO_DISABLE_UPDATE_CHECK=1` (or `DO_NOT_TRACK=1`) to turn it off.
|
|
125
|
+
|
|
120
126
|
## Lifecycle and waves
|
|
121
127
|
|
|
122
128
|

|
|
@@ -177,8 +183,8 @@ Counts measured on 2026-09-07 with the command in brackets:
|
|
|
177
183
|
- **25 slash commands** (`/session`, `/go`, `/close`, `/discovery`, `/plan`, `/grill`, `/evolve`, `/autopilot`, `/dispatcher`, `/reconcile`, `/eval`, `/test`, `/debug`, …) (`ls commands/*.md | wc -l`)
|
|
178
184
|
- **14 typed subagents** (code-implementer, test-writer, security-reviewer, session-reviewer, qa-strategist, architect-reviewer, …) (`ls agents/*.md | wc -l`)
|
|
179
185
|
- **27 hook files across 10 event types**, enforcing scope, blocking destructive commands, gating templates-first, and capturing telemetry — full on Claude Code; experimental, post-hoc, or bridged elsewhere ([Platform support](#platform-support)) (`ls hooks/*.mjs | wc -l`)
|
|
180
|
-
- **26
|
|
181
|
-
- **667 vitest test files**
|
|
186
|
+
- **26 rule files** and **18 ADRs** carrying the reasoning behind the mechanisms (`ls .claude/rules/*.md | wc -l`, `ls docs/adr/*.md | wc -l`)
|
|
187
|
+
- **667 vitest test files** covered by the full quality gate and CI — 13,827 static `it()`/`test()` definitions at that measurement, and the runtime total is higher because of parameterised blocks ([methodology](docs/telemetry/telemetry-claims.md)) (`find tests -name '*.test.mjs' | wc -l`)
|
|
182
188
|
|
|
183
189
|
**Portable across harnesses by construction.** `scripts/generate-agents-skills.mjs` generates root `AGENTS.md` byte-identical from `CLAUDE.md` and the `.agents/skills/<name>/SKILL.md` mirrors, with spec-legal frontmatter and pointers to canonical instructions. `scripts/generate-codex-skills.mjs` generates the Codex command entrypoints. Plugin validation checks both surfaces. Separate manifests under `.claude-plugin/`, `.codex-plugin/` and `.cursor-plugin/` register each harness's components; see [Codex manifest compatibility](docs/codex-setup.md#manifest-compatibility).
|
|
184
190
|
|
|
@@ -189,24 +195,22 @@ Full component inventory: [`docs/components.md`](docs/components.md). Version hi
|
|
|
189
195
|
- **Typed waves, not one big batch.** Discovery first, so implementers start with shared context. Impl-Core before Impl-Polish, so architecture lands before integrations. Quality runs a *simplification pass* on AI-generated code **before** tests are written — otherwise tests pin the AI patterns into place.
|
|
190
196
|
- **Inter-wave reviews, not just end-of-session.** Catching regressions between waves stops a bad pattern from propagating into later work; the confidence floor filters speculative criticism so only high-signal findings reach you.
|
|
191
197
|
- **State persists across crashes.** `STATE.md` records wave progress and deviations; the next `/session` offers to resume from the last completed wave.
|
|
192
|
-
- **
|
|
193
|
-
- **Parallel *operator* sessions are treated as a hazard.** Two humans — or two of your own sessions — in the same working copy share one git index, one filesystem, one `STATE.md`. A heartbeat session lock, peer-scope manifests, and the PSA rule set in [`.claude/rules/parallel-sessions.md`](.claude/rules/parallel-sessions.md) exist for exactly that axis.
|
|
198
|
+
- **Hook enforcement has a defined platform boundary.** Claude Code hooks block covered destructive commands; writes outside declared paths warn in `warn` mode and block in `strict` mode. Cursor and Pi bridge supported events. Codex currently has no scope-enforcement adapter ([Platform support](#platform-support)).
|
|
199
|
+
- **Parallel *operator* sessions are treated as a hazard.** Two humans — or two of your own sessions — in the same working copy share one git index, one filesystem, one `STATE.md`. A heartbeat session lock, peer-scope manifests, and the PSA rule set in [`.claude/rules/parallel-sessions.md`](https://github.com/Kanevry/session-orchestrator/blob/main/.claude/rules/parallel-sessions.md) exist for exactly that axis.
|
|
194
200
|
- **Cross-session learning is opt-in and inspectable.** Every session writes a record; after 5+ sessions `/evolve analyze` extracts confidence-scored patterns you can read and prune. Nothing is hidden.
|
|
195
201
|
- **VCS dual support, no lock-in.** Auto-detects GitLab or GitHub from your remote and drives the full lifecycle for both.
|
|
196
202
|
|
|
197
203
|
How this compares to other orchestrators — with the parts that are measured and the parts that are not: [`docs/components.md` § Comparisons](docs/components.md#comparisons).
|
|
198
204
|
|
|
199
|
-
## Recent highlights (v4.0
|
|
205
|
+
## Recent highlights (v4.1.0)
|
|
200
206
|
|
|
201
|
-
|
|
207
|
+
Highlights of the v4.1.0 line:
|
|
202
208
|
|
|
203
|
-
- **
|
|
204
|
-
- **
|
|
205
|
-
- **
|
|
206
|
-
- **Review-driven hardening.** The owner-privacy scanner (CP11) now fails CLOSED on a corrupted or env-configured-but-unresolvable confidential-names list instead of silently degrading to allow, and no longer prints the names-file path into logs; `check-unwired-features` splits 46 coordinator-invoked modules out of its actionable finding set (52 → 5 unreachable), so the report names what an operator can actually act on; a new session-start probe (`telemetry-flush-health`) surfaces when the sandbox refused a telemetry flush instead of that failure staying silent.
|
|
207
|
-
- **Sixteen follow-ups from the 4.0.0 review closed, and the patch itself was reviewed before the cut.** A four-reviewer panel plus an external Codex gpt-6-astra pass over the packed npm tarball found two P1 and three P2 defects in this session's own changes — a names-file path printed into the scanner's failing output, a deep-import contract change, a flag swallowed as a value, a substring match that hid a real finding, a comment that counted as a target — all fixed before publishing. The residual list lives in GitLab #1268–#1273.
|
|
209
|
+
- **Privacy and session ownership.** The privacy scanner covers MDX and can include untracked files with `--include-untracked`. Native Codex task IDs now confirm session ownership; ambiguous inherited IDs remain unconfirmed.
|
|
210
|
+
- **CI and release recovery.** Coverage verification now requires valid reports and measured thresholds. Audit detection checks executable workflow commands; GitHub release recovery retains the exact repository, tag and retry notes.
|
|
211
|
+
- **Clearer onboarding.** README, English/German pages and the guide agree on installation and platform capabilities. Guide Copy buttons stay beside their command blocks, including on narrow screens.
|
|
208
212
|
|
|
209
|
-
|
|
213
|
+
If upgrading from before 4.0, read [the v4 migration guide](docs/migration-v4.md). Full changes and verification: [CHANGELOG.md](CHANGELOG.md).
|
|
210
214
|
|
|
211
215
|
## Platform support
|
|
212
216
|
|
|
@@ -225,13 +229,13 @@ All platforms share the same skills, commands, and scripts; hooks use platform-s
|
|
|
225
229
|
|
|
226
230
|
**Your data stays in your repo.** Session Orchestrator runs locally, requires no account, and writes its records as append-only JSONL under `.orchestrator/metrics/` in *your* repository — sessions, learnings, events, subagent records. Those files are yours: readable, greppable, deletable. Optional anonymous usage telemetry is **off until you explicitly consent** and is separate from the local records ([docs/telemetry.md](docs/telemetry.md) says exactly what it would collect and how to turn it off). Reported metrics describe *this* repository under its own conditions and will not transfer unchanged to yours ([details](docs/telemetry/telemetry-claims.md)).
|
|
227
231
|
|
|
228
|
-
**Destructive-command guard.** `hooks/pre-bash-destructive-guard.mjs` enforces `.orchestrator/policy/blocked-commands.json` — 14 rules, of which 10 block outright (`git reset --hard`, `rm -rf`, `git push --force`, and more) and 4 warn — in the main session *and* in subagent waves. Bypass per session only for intentional maintenance:
|
|
232
|
+
**Destructive-command guard.** On Claude Code, `hooks/pre-bash-destructive-guard.mjs` enforces `.orchestrator/policy/blocked-commands.json` — 14 rules, of which 10 block outright (`git reset --hard`, `rm -rf`, `git push --force`, and more) and 4 warn — in the main session *and* in subagent waves. Other harnesses depend on their event adapters; see [Platform support](#platform-support). Bypass per session only for intentional maintenance:
|
|
229
233
|
|
|
230
234
|
```yaml
|
|
231
235
|
allow-destructive-ops: true
|
|
232
236
|
```
|
|
233
237
|
|
|
234
|
-
The rule source of truth is [`.claude/rules/parallel-sessions.md`](.claude/rules/parallel-sessions.md) (PSA-003), vendored to consumer repos via `/bootstrap`.
|
|
238
|
+
The rule source of truth is [`.claude/rules/parallel-sessions.md`](https://github.com/Kanevry/session-orchestrator/blob/main/.claude/rules/parallel-sessions.md) (PSA-003), vendored to consumer repos via `/bootstrap`.
|
|
235
239
|
|
|
236
240
|
**Import probe.** `hooks/post-edit-import-probe.mjs` (PostToolUse on `Edit`/`Write`/`MultiEdit`) guards the other direction: a hook-reachable helper saved in a broken intermediate state makes *every* Bash/Edit/Write call fail with "Internal hook error — request blocked", host-wide, for every session sharing the working copy. Right after such a file is saved the probe runs ESLint `no-undef` on it (plus a child-process `import()` for `scripts/lib/**`) and reports the blast radius; it never blocks and always exits 0. It only fires for files listed in the committed allowlist [`hooks/_lib/hook-import-set.json`](hooks/_lib/hook-import-set.json), regenerated by `node scripts/generate-hook-import-set.mjs`. Kill switch: `SO_DISABLED_HOOKS=post-edit-import-probe`.
|
|
237
241
|
|
|
@@ -255,9 +259,9 @@ npm run typecheck # node --check on every .mjs file
|
|
|
255
259
|
|
|
256
260
|
`.npmrc` ships with `ignore-scripts=true` (supply-chain defence), so Husky git hooks don't auto-wire on install — run `npx husky` once after cloning. `git commit` then runs gitleaks → owner-privacy scan → lint-staged → commitlint. CI re-runs everything, plus more.
|
|
257
261
|
|
|
258
|
-
Two directories share the name *rules* and play opposite roles: [`rules/`](rules/README.md) is the **deliverable rule library** shipped *out* to consumer repos via `/bootstrap --sync-rules`, while [`.claude/rules/`](.claude/rules/) is this repo's own always-on
|
|
262
|
+
Two directories share the name *rules* and play opposite roles: [`rules/`](rules/README.md) is the **deliverable rule library** shipped *out* to consumer repos via `/bootstrap --sync-rules`, while [`.claude/rules/`](https://github.com/Kanevry/session-orchestrator/tree/main/.claude/rules/) is this repo's own rule set with always-on and path-scoped entries.
|
|
259
263
|
|
|
260
|
-
Contributor docs: [Plugin Architecture (v3)](docs/plugin-architecture-v3.md) · [CONTRIBUTING.md](CONTRIBUTING.md) · [sub-agent authoring spec](docs/agent-authoring.md).
|
|
264
|
+
Contributor docs: [Plugin Architecture (v3)](docs/plugin-architecture-v3.md) · [CONTRIBUTING.md](https://github.com/Kanevry/session-orchestrator/blob/main/CONTRIBUTING.md) · [sub-agent authoring spec](docs/agent-authoring.md).
|
|
261
265
|
|
|
262
266
|
## Support & scope
|
|
263
267
|
|
|
@@ -278,12 +282,12 @@ What it is **not**:
|
|
|
278
282
|
- [User Guide](docs/USER-GUIDE.md) — installation, config reference, workflow walkthrough, FAQ
|
|
279
283
|
- [Components & Reference](docs/components.md) — full skill/command/agent/hook inventory, repository anatomy, comparisons
|
|
280
284
|
- [Plugin Architecture (v3)](docs/plugin-architecture-v3.md) — contributor guide, layering, hook anatomy, testing
|
|
281
|
-
- [Migration
|
|
285
|
+
- [Migration to v4](docs/migration-v4.md) — upgrade path, removed surfaces and replacements
|
|
282
286
|
- [Telemetry](docs/telemetry.md) · [Telemetry claims](docs/telemetry/telemetry-claims.md) — what is collected, how metrics are measured, why they may not transfer
|
|
283
|
-
- [Example Configs](docs/examples/) — Session Config examples for Next.js, Express, Swift
|
|
287
|
+
- [Example Configs](https://github.com/Kanevry/session-orchestrator/tree/main/docs/examples/) — Session Config examples for Next.js, Express, Swift
|
|
284
288
|
- [CHANGELOG.md](CHANGELOG.md) — version history
|
|
285
289
|
|
|
286
|
-
We follow [Conventional Commits](https://www.conventionalcommits.org/) — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
290
|
+
We follow [Conventional Commits](https://www.conventionalcommits.org/) — see [CONTRIBUTING.md](https://github.com/Kanevry/session-orchestrator/blob/main/CONTRIBUTING.md).
|
|
287
291
|
|
|
288
292
|
## Learn the method behind it
|
|
289
293
|
|
package/commands/release.md
CHANGED
|
@@ -29,14 +29,14 @@ The same reading shows the other half: the GitHub releases for 3.15/3.18/3.19/3.
|
|
|
29
29
|
1. **Preconditions.** Working tree clean, on `main`, and `origin/main` **and** `github/main` both level with `HEAD`. The mirror is checked because the site deploy hangs off `github`, not `origin`.
|
|
30
30
|
2. **Set the version.** `node scripts/release.mjs --set-version X.Y.Z` — rewrites every version surface, syncs `package-lock.json`, re-stamps the measured census on the site.
|
|
31
31
|
3. **Write the editorial half.** The dated `## [X.Y.Z] - YYYY-MM-DD` CHANGELOG entry, `[Unreleased]` folded, README highlights. The script does not write these; `--check` enforces them.
|
|
32
|
-
4. **
|
|
33
|
-
5. **
|
|
34
|
-
6. **
|
|
32
|
+
4. **Gate, commit, push.** Full quality gate, then commit and push to **both** remotes.
|
|
33
|
+
5. **CI green — on the commit that will be published, on BOTH platforms.** Not on its predecessor: a green pipeline from before step 4's commit is evidence about a different tree. And not on GitLab alone — `--check` carries two CI rows, `ci-green-on-head` (GitLab, via `origin`) and `ci-green-on-head-github` (the mirror). The GitLab pipeline is Linux-only; the **macOS** matrix leg exists solely in `.github/workflows/test.yml`, i.e. on the operator's own platform. The github row self-disables (`skipped — no github remote`) in a checkout without a mirror, and fails on `unknown`/`degraded` exactly like the GitLab one — "could not read the mirror" is not "the mirror is green".
|
|
34
|
+
6. **Preflight.** `node scripts/release.mjs --check --json` — every row green. Run this after the release commit is pushed and its CI succeeds: the preflight requires a clean working tree, exact HEAD parity on both remotes and green CI on that commit. It derives its target from `package.json`, so checking the pre-bump version instead produces registry- and tag-collision failures.
|
|
35
35
|
7. **Publish.** `node scripts/release.mjs --publish` — **give it ≥600 s of wall clock, or run it in the background.** The script sets no spawn timeout on purpose (a kill mid-`npm publish` or mid-push is the very failure the receipt boundary exists to avoid), and the tail is slow by construction: each of the two `git push` remotes re-runs the husky pre-push full gate, plus up to 120 s of live-site polling. A 3-minute default command timeout kills it mid-tail.
|
|
36
36
|
|
|
37
37
|
The target-confirmed npm receipt is the irreversible boundary. Before that receipt, any failure aborts normally. After it, never rerun `--publish`: registry propagation timeout/query/wait failures are reconciliation while the script still tags, pushes `main` + tag to both remotes, handles the GitHub release, and polls the live site. If tag/push fails after the receipt, the dependent GitHub-release and site steps are skipped and the script returns structured reconciliation guidance instead. Add the GitHub release for the new tag (`gh release create`) as part of this step, not "later" — "later" is what produced the three-second backfill above.
|
|
38
38
|
|
|
39
|
-
Steps 2–7 are one continuous act. A release left parked between step
|
|
39
|
+
Steps 2–7 are one continuous act. A release left parked between step 4 and step 7 is exactly the `3.18.0` state: every surface says released, the registry disagrees.
|
|
40
40
|
|
|
41
41
|
## Abort criteria
|
|
42
42
|
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator v4.0
|
|
10
|
+
"command": "echo '🎯 Session Orchestrator v4.1.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 v4.0
|
|
9
|
+
"command": "echo '🎯 Session Orchestrator v4.1.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
10
10
|
"async": false
|
|
11
11
|
},
|
|
12
12
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://session-orchestrator.com",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Repository CI only: requires the checkout's Vitest config and dev dependencies.
|
|
3
|
+
// Test-result JSON does not prove coverage. Check the structured coverage summary
|
|
4
|
+
// against the same global percentage thresholds Vitest uses before writing a marker.
|
|
5
|
+
// The caller removes both reports and the marker before starting the current run.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import config from '../../vitest.config.mjs';
|
|
9
|
+
|
|
10
|
+
const metrics = ['lines', 'functions', 'statements', 'branches'];
|
|
11
|
+
const [summaryPath = 'coverage/coverage-summary.json', xmlPath = 'coverage/cobertura-coverage.xml'] = process.argv.slice(2);
|
|
12
|
+
|
|
13
|
+
function requireValid(condition, reason) {
|
|
14
|
+
if (!condition) throw new Error(reason);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Check completeness of the XML emitted by the configured Istanbul reporter.
|
|
18
|
+
// This is an artifact check, not a DTD/schema evaluator; never fetch the DOCTYPE.
|
|
19
|
+
function checkCobertura(xml, total) {
|
|
20
|
+
const stack = [];
|
|
21
|
+
let root;
|
|
22
|
+
let hasClassLine = false;
|
|
23
|
+
const parts = xml.match(/<[^>]*>|[^<]+/g) ?? [];
|
|
24
|
+
requireValid(parts.join('') === xml, 'malformed Cobertura artifact');
|
|
25
|
+
for (const part of parts) {
|
|
26
|
+
if (/^<\?xml\s[^<>]*\?>$|^<!DOCTYPE coverage SYSTEM "[^"<>]*">$/.test(part)) {
|
|
27
|
+
requireValid(!root, 'unexpected Cobertura declaration');
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!part.startsWith('<')) {
|
|
31
|
+
requireValid(!part.trim() || stack.at(-1) === 'source', 'unexpected Cobertura content');
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const tag = part.match(/^<(\/?)([A-Za-z][\w.-]*)((?:\s+[\w:-]+="[^"<>]*")*)\s*(\/?)>$/);
|
|
35
|
+
requireValid(tag, 'malformed Cobertura tag');
|
|
36
|
+
const [, closing, name, attributes, selfClosing] = tag;
|
|
37
|
+
if (closing) {
|
|
38
|
+
requireValid(!attributes && !selfClosing && stack.pop() === name, 'unbalanced Cobertura tags');
|
|
39
|
+
} else {
|
|
40
|
+
const entries = [...attributes.matchAll(/([\w:-]+)="([^"]*)"/g)].map((match) => [match[1], match[2]]);
|
|
41
|
+
requireValid(new Set(entries.map(([key]) => key)).size === entries.length, 'duplicate Cobertura attributes');
|
|
42
|
+
const values = Object.fromEntries(entries);
|
|
43
|
+
if (stack.length === 0) {
|
|
44
|
+
requireValid(!root && name === 'coverage' && !selfClosing, 'invalid Cobertura root');
|
|
45
|
+
root = values;
|
|
46
|
+
}
|
|
47
|
+
const path = [...stack, name].join('/');
|
|
48
|
+
if (path === 'coverage/packages/package/classes/class') {
|
|
49
|
+
requireValid(typeof values.filename === 'string' && values.filename.trim(), 'Cobertura class is missing a filename');
|
|
50
|
+
}
|
|
51
|
+
if (path === 'coverage/packages/package/classes/class/lines/line') {
|
|
52
|
+
requireValid(/^\d+$/.test(values.number) && Number(values.number) > 0
|
|
53
|
+
&& /^\d+$/.test(values.hits), 'invalid Cobertura line record');
|
|
54
|
+
hasClassLine = true;
|
|
55
|
+
}
|
|
56
|
+
if (!selfClosing) stack.push(name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
requireValid(root && stack.length === 0, 'empty or incomplete Cobertura artifact');
|
|
60
|
+
requireValid(total.lines.total === 0 || hasClassLine, 'Cobertura artifact contains no class line records');
|
|
61
|
+
for (const [attribute, expected] of Object.entries({
|
|
62
|
+
'lines-valid': total.lines.total,
|
|
63
|
+
'lines-covered': total.lines.covered,
|
|
64
|
+
'line-rate': total.lines.pct / 100,
|
|
65
|
+
'branches-valid': total.branches.total,
|
|
66
|
+
'branches-covered': total.branches.covered,
|
|
67
|
+
'branch-rate': total.branches.pct / 100,
|
|
68
|
+
})) {
|
|
69
|
+
requireValid(root[attribute] !== undefined && root[attribute] !== ''
|
|
70
|
+
&& Number(root[attribute]) === expected, `Cobertura ${attribute} disagrees with coverage summary`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const thresholds = config.test?.coverage?.thresholds;
|
|
76
|
+
requireValid(thresholds && Object.keys(thresholds).every((key) => metrics.includes(key)),
|
|
77
|
+
'unsupported coverage thresholds: expected global percentage thresholds');
|
|
78
|
+
const { total } = JSON.parse(readFileSync(summaryPath, 'utf8'));
|
|
79
|
+
requireValid(total && typeof total === 'object', 'missing coverage totals');
|
|
80
|
+
for (const metric of metrics) {
|
|
81
|
+
const threshold = thresholds[metric];
|
|
82
|
+
requireValid(Number.isFinite(threshold) && threshold >= 0 && threshold <= 100,
|
|
83
|
+
`missing or invalid configured ${metric} threshold`);
|
|
84
|
+
const data = total[metric];
|
|
85
|
+
requireValid(data && ['total', 'covered', 'skipped'].every((key) => Number.isSafeInteger(data[key]) && data[key] >= 0)
|
|
86
|
+
&& data.total > 0 && data.covered <= data.total && data.skipped <= data.total
|
|
87
|
+
&& Number.isFinite(data.pct) && data.pct >= 0 && data.pct <= 100,
|
|
88
|
+
`missing or invalid ${metric} coverage data`);
|
|
89
|
+
// Istanbul truncates percentages to two decimals; compare the reported
|
|
90
|
+
// percentage (Vitest's threshold input) only after checking its counters.
|
|
91
|
+
const percentage = Math.floor((100000 * data.covered / data.total) / 10) / 100;
|
|
92
|
+
requireValid(data.pct === percentage, `inconsistent ${metric} coverage percentage`);
|
|
93
|
+
requireValid(data.pct >= threshold, `${metric} coverage ${data.pct}% is below configured ${threshold}%`);
|
|
94
|
+
}
|
|
95
|
+
checkCobertura(readFileSync(xmlPath, 'utf8'), total);
|
|
96
|
+
console.log(`[ci] coverage verified: ${metrics.map((metric) => `${metric}=${total[metric].pct}%`).join(' ')}`);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error(`[ci] coverage NOT VERIFIED: ${error.message}`);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
}
|
package/scripts/lib/events.mjs
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
* 3. **Never a peer's id (#1123).** A shared working copy means
|
|
28
28
|
* `session.lock` can name a PEER session that won the acquire race. The
|
|
29
29
|
* lock alone therefore does not prove ownership; the fill happens only
|
|
30
|
-
* when a PROCESS-LOCAL id (`CLAUDE_CODE_SESSION_ID
|
|
31
|
-
* `
|
|
30
|
+
* when a confirmed native PROCESS-LOCAL id (`CLAUDE_CODE_SESSION_ID` or
|
|
31
|
+
* `CODEX_THREAD_ID`) equals the lock's raw `session_id`. STATE.md is NOT a
|
|
32
32
|
* witness here (#1177 FX1): it is a shared working-copy file written by
|
|
33
33
|
* the lock holder, so under a peer-owned lock both agreed about the peer
|
|
34
34
|
* and the union stamped the peer's ids. See {@link attributionForRecord}.
|
|
@@ -124,7 +124,7 @@ const STATE_DIR_CANDIDATES = ['.claude', '.codex', '.cursor', '.pi'];
|
|
|
124
124
|
* when one exists it decides alone.**
|
|
125
125
|
*
|
|
126
126
|
* - No lock (CI, a bare script) → `{}`. Nothing to attribute to.
|
|
127
|
-
* - No process-local id (
|
|
127
|
+
* - No confirmed process-local id (absent or ambiguous native env) → `{}`. Ownership is
|
|
128
128
|
* UNPROVEN, and an unproven attribution is exactly the peer-id write #1123
|
|
129
129
|
* forbids; an absent key costs a correlation, a wrong key costs a false one.
|
|
130
130
|
* - A process-local id that equals the lock's raw `session_id` → fill BOTH
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
import { execFileSync } from 'node:child_process';
|
|
45
45
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
46
46
|
import { join } from 'node:path';
|
|
47
|
+
import yaml from 'js-yaml';
|
|
48
|
+
import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
|
|
47
49
|
|
|
48
50
|
/** Commits past the newest tag before release hygiene is worth mentioning. */
|
|
49
51
|
export const DEFAULT_RELEASE_DRIFT_COMMITS = 50;
|
|
@@ -408,8 +410,8 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
|
|
|
408
410
|
/**
|
|
409
411
|
* H4 — CI configuration hygiene.
|
|
410
412
|
*
|
|
411
|
-
* Measured 5/6.
|
|
412
|
-
* pipeline
|
|
413
|
+
* Measured 5/6. Checks locally declared executable commands without evaluating
|
|
414
|
+
* pipeline conditions or external includes. The dependency-audit gap is the load-bearing one: three
|
|
413
415
|
* of the tested repos carried known-vulnerable dependencies that no pipeline
|
|
414
416
|
* would ever surface.
|
|
415
417
|
*
|
|
@@ -438,19 +440,21 @@ export function checkCiConfig(repoRoot) {
|
|
|
438
440
|
return findings;
|
|
439
441
|
}
|
|
440
442
|
|
|
441
|
-
|
|
442
|
-
if (hasGitlab)
|
|
443
|
+
const ciConfigs = [];
|
|
444
|
+
if (hasGitlab) ciConfigs.push({ text: safeRead(gitlabCi), platform: 'gitlab' });
|
|
443
445
|
if (hasGithub) {
|
|
444
446
|
try {
|
|
445
447
|
for (const f of readdirSync(ghWorkflows)) {
|
|
446
|
-
if (f.endsWith('.yml') || f.endsWith('.yaml'))
|
|
448
|
+
if (f.endsWith('.yml') || f.endsWith('.yaml')) {
|
|
449
|
+
ciConfigs.push({ text: safeRead(join(ghWorkflows, f)), platform: 'github' });
|
|
450
|
+
}
|
|
447
451
|
}
|
|
448
452
|
} catch {
|
|
449
453
|
/* unreadable workflows dir — fall through with what we have */
|
|
450
454
|
}
|
|
451
455
|
}
|
|
452
456
|
|
|
453
|
-
if (
|
|
457
|
+
if (ciConfigs.some(({ text }) => text) && !ciConfigs.some(hasAuditStep)) {
|
|
454
458
|
findings.push({
|
|
455
459
|
check: 'ci-audit-job',
|
|
456
460
|
fixable: true,
|
|
@@ -461,6 +465,178 @@ export function checkCiConfig(repoRoot) {
|
|
|
461
465
|
return findings;
|
|
462
466
|
}
|
|
463
467
|
|
|
468
|
+
const GITLAB_COMMAND_FIELDS = ['script', 'before_script', 'after_script'];
|
|
469
|
+
const GITLAB_GLOBAL_FIELDS = new Set([
|
|
470
|
+
'stages', 'types', 'variables', 'default', 'include', 'workflow',
|
|
471
|
+
'image', 'services', 'cache', 'before_script', 'after_script', 'spec',
|
|
472
|
+
]);
|
|
473
|
+
const GITLAB_REFERENCE = Symbol('gitlab-reference');
|
|
474
|
+
const GITLAB_SCHEMA = yaml.DEFAULT_SCHEMA.extend([
|
|
475
|
+
new yaml.Type('!reference', {
|
|
476
|
+
kind: 'sequence',
|
|
477
|
+
construct: (path) => ({ [GITLAB_REFERENCE]: path }),
|
|
478
|
+
}),
|
|
479
|
+
]);
|
|
480
|
+
const AUDIT_VALUE_FLAGS = new Set([
|
|
481
|
+
'--filter', '-F', '--dir', '-C', '--prefix', '--cwd', '--registry',
|
|
482
|
+
'--userconfig', '--globalconfig', '--workspace', '-w', '--cache', '--pm-on-fail',
|
|
483
|
+
]);
|
|
484
|
+
const AUDIT_BOOLEAN_FLAGS = new Set([
|
|
485
|
+
'-r', '--recursive', '-s', '--silent', '-g', '--global', '--workspaces', '--offline',
|
|
486
|
+
]);
|
|
487
|
+
|
|
488
|
+
/** Inspect executable job/step locations, never arbitrary keys named run/script. */
|
|
489
|
+
function hasAuditStep({ text, platform }) {
|
|
490
|
+
let config;
|
|
491
|
+
try {
|
|
492
|
+
if (platform === 'gitlab') {
|
|
493
|
+
const documents = yaml.loadAll(text, undefined, { schema: GITLAB_SCHEMA });
|
|
494
|
+
if (documents.length === 1) {
|
|
495
|
+
[config] = documents;
|
|
496
|
+
} else if (documents.length === 2 && isConfigMap(documents[0])
|
|
497
|
+
&& Object.keys(documents[0]).length === 1 && isConfigMap(documents[0].spec)) {
|
|
498
|
+
// GitLab permits a spec header followed by --- and the job document.
|
|
499
|
+
// Header input defaults are data; unrelated multi-document YAML is ambiguous.
|
|
500
|
+
[, config] = documents;
|
|
501
|
+
} else {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
} else {
|
|
505
|
+
config = yaml.load(text);
|
|
506
|
+
}
|
|
507
|
+
} catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
if (!isConfigMap(config)) return false;
|
|
511
|
+
if (platform === 'github') {
|
|
512
|
+
if (!isConfigMap(config.jobs)) return false;
|
|
513
|
+
return Object.values(config.jobs).some((job) => isConfigMap(job)
|
|
514
|
+
&& job.if !== false && Array.isArray(job.steps)
|
|
515
|
+
&& job.steps.some((step) => isConfigMap(step) && step.if !== false
|
|
516
|
+
&& typeof step.run === 'string' && hasAuditCommand(step.run)));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return Object.keys(config).some((name) => {
|
|
520
|
+
if (name.startsWith('.') || GITLAB_GLOBAL_FIELDS.has(name)) return false;
|
|
521
|
+
const job = resolveGitlabJob(config, name);
|
|
522
|
+
if (!job?.script || job.when === 'never') return false;
|
|
523
|
+
return GITLAB_COMMAND_FIELDS.some((field) => {
|
|
524
|
+
let commands = job[field];
|
|
525
|
+
if (!Object.hasOwn(job, field) && field !== 'script') {
|
|
526
|
+
const inherit = job.inherit?.default;
|
|
527
|
+
if (inherit === false || (Array.isArray(inherit) && !inherit.includes(field))) return false;
|
|
528
|
+
commands = Object.hasOwn(config.default ?? {}, field) ? config.default[field] : config[field];
|
|
529
|
+
}
|
|
530
|
+
return hasGitlabAuditCommands(commands, config);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function isConfigMap(value) {
|
|
536
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
537
|
+
&& !Object.hasOwn(value, GITLAB_REFERENCE);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Local extends uses later-parent/child replacement for command arrays. */
|
|
541
|
+
function resolveGitlabJob(config, name, seen = new Set()) {
|
|
542
|
+
if (typeof name !== 'string' || !Object.hasOwn(config, name)
|
|
543
|
+
|| !isConfigMap(config[name]) || seen.has(name)) return null;
|
|
544
|
+
seen.add(name);
|
|
545
|
+
const job = config[name];
|
|
546
|
+
let merged = {};
|
|
547
|
+
const parents = Array.isArray(job.extends) ? job.extends : [job.extends];
|
|
548
|
+
for (const parent of parents.filter((entry) => entry !== undefined)) {
|
|
549
|
+
const inherited = resolveGitlabJob(config, parent, seen);
|
|
550
|
+
// An unresolved later parent may override earlier commands. Only fields
|
|
551
|
+
// supplied by later local parents or the job itself remain evidence.
|
|
552
|
+
merged = inherited ? mergeGitlabJob(merged, inherited) : {};
|
|
553
|
+
}
|
|
554
|
+
seen.delete(name);
|
|
555
|
+
return mergeGitlabJob(merged, job);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function mergeGitlabJob(parent, child) {
|
|
559
|
+
const merged = { ...parent, ...child };
|
|
560
|
+
// Command fields replace wholesale; inherit is a map, so its default flag
|
|
561
|
+
// survives a child that overrides only inherit.variables.
|
|
562
|
+
if (isConfigMap(parent.inherit) && isConfigMap(child.inherit)) {
|
|
563
|
+
merged.inherit = { ...parent.inherit, ...child.inherit };
|
|
564
|
+
}
|
|
565
|
+
return merged;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Resolve only command strings/arrays and local !reference paths, with cycle guards. */
|
|
569
|
+
function hasGitlabAuditCommands(commands, config) {
|
|
570
|
+
const seen = new WeakSet();
|
|
571
|
+
function visit(value) {
|
|
572
|
+
if (typeof value === 'string') return hasAuditCommand(value);
|
|
573
|
+
if (!value || typeof value !== 'object' || seen.has(value)) return false;
|
|
574
|
+
seen.add(value);
|
|
575
|
+
let found = false;
|
|
576
|
+
if (Array.isArray(value)) {
|
|
577
|
+
found = value.some(visit);
|
|
578
|
+
} else if (Array.isArray(value[GITLAB_REFERENCE])) {
|
|
579
|
+
let target = config;
|
|
580
|
+
for (const key of value[GITLAB_REFERENCE]) {
|
|
581
|
+
target = target && typeof target === 'object' && Object.hasOwn(target, key) ? target[key] : undefined;
|
|
582
|
+
}
|
|
583
|
+
found = visit(target);
|
|
584
|
+
}
|
|
585
|
+
seen.delete(value);
|
|
586
|
+
return found;
|
|
587
|
+
}
|
|
588
|
+
return visit(commands);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Bounded command recognition: wrappers and options may precede the audit
|
|
593
|
+
* subcommand, but another verb (install/run/echo) ends the search. Unknown
|
|
594
|
+
* value-taking flags and commands hidden in external includes are not resolved.
|
|
595
|
+
*/
|
|
596
|
+
function hasAuditCommand(command) {
|
|
597
|
+
return splitChainSegments(tokenizeCommand(command)).some((segment) => hasAuditSegment(segment));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function hasAuditSegment(segment, depth = 0) {
|
|
601
|
+
const resolved = resolveSegmentVerb(segment);
|
|
602
|
+
if (resolved.alt) return false;
|
|
603
|
+
let { verb, index } = resolved;
|
|
604
|
+
if (resolved.payloads.length > 0) {
|
|
605
|
+
if (depth >= 3 || resolved.payloads.length !== 1) return false;
|
|
606
|
+
const tokens = tokenizeCommand(resolved.payloads[0]);
|
|
607
|
+
const parts = splitChainSegments(tokens);
|
|
608
|
+
// env -S splits argv; it does not execute shell operators. Restrict this
|
|
609
|
+
// reuse of the shared tokenizer to one plain command with no redirects.
|
|
610
|
+
if (parts.length !== 1 || parts[0].length !== tokens.length
|
|
611
|
+
|| tokens.some((token) => token.redirect)) return false;
|
|
612
|
+
const trailingArgs = index < 0 ? [] : segment.slice(index);
|
|
613
|
+
return hasAuditSegment([...tokens, ...trailingArgs], depth + 1);
|
|
614
|
+
}
|
|
615
|
+
if (verb === 'corepack' || verb === 'npx') {
|
|
616
|
+
index++;
|
|
617
|
+
while (['-y', '--yes', '--no-install', '--'].includes(segment[index]?.text)) index++;
|
|
618
|
+
verb = segment[index]?.text;
|
|
619
|
+
}
|
|
620
|
+
if (segment.slice(index + 1).some((token) => ['-h', '--help', '--version'].includes(token.text))) return false;
|
|
621
|
+
if (/^(?:pip-audit|osv-scanner|dependency-check(?:\.sh)?)$/.test(verb ?? '')) return true;
|
|
622
|
+
if (!/^(?:npm|pnpm|yarn|bun)(?:@[\w.+-]+)?$/.test(verb ?? '') && verb !== 'cargo') return false;
|
|
623
|
+
let i = index + 1;
|
|
624
|
+
while (segment[i]?.text.startsWith('-')) {
|
|
625
|
+
const flag = segment[i].text;
|
|
626
|
+
if (flag === '--') { i++; break; }
|
|
627
|
+
if (AUDIT_VALUE_FLAGS.has(flag)) {
|
|
628
|
+
if (!segment[i + 1] || segment[i + 1].redirect) return false;
|
|
629
|
+
i += 2;
|
|
630
|
+
} else if (AUDIT_BOOLEAN_FLAGS.has(flag) || /^--[\w-]+=/.test(flag)) {
|
|
631
|
+
i++;
|
|
632
|
+
} else {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (/^yarn(?:@|$)/.test(verb) && segment[i]?.text === 'npm') i++;
|
|
637
|
+
return segment[i]?.text === 'audit';
|
|
638
|
+
}
|
|
639
|
+
|
|
464
640
|
/** @param {string} p @returns {string} */
|
|
465
641
|
function safeRead(p) {
|
|
466
642
|
try {
|
|
@@ -80,8 +80,8 @@ function readLockIds(repoRoot) {
|
|
|
80
80
|
* handled right now (`session_id` / `sessionId`, plus `parent_session_id`
|
|
81
81
|
* for a sub-agent invocation, whose coordinator is equally us). The only
|
|
82
82
|
* tier that is per-INVOCATION rather than per-working-copy.
|
|
83
|
-
* 2.
|
|
84
|
-
*
|
|
83
|
+
* 2. The native process-scoped id (`CLAUDE_CODE_SESSION_ID` or
|
|
84
|
+
* `CODEX_THREAD_ID`), selected by {@link readProcessLocalSessionIds}.
|
|
85
85
|
* 3. `session.lock` `session_id` / `semantic_session_id` — repo-GLOBAL, and
|
|
86
86
|
* the identity the WRITER of a manifest uses: `wave-scope.json`'s
|
|
87
87
|
* `session` field comes from `sessionAttribution()`, which reads this same
|
|
@@ -138,20 +138,12 @@ function readLockIds(repoRoot) {
|
|
|
138
138
|
* never as a mismatch.
|
|
139
139
|
*/
|
|
140
140
|
export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
|
|
141
|
-
const ids = new Set();
|
|
141
|
+
const ids = new Set(readProcessLocalSessionIds({ hookInput }));
|
|
142
142
|
const add = (value) => {
|
|
143
143
|
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
144
144
|
if (trimmed) ids.add(trimmed);
|
|
145
145
|
};
|
|
146
146
|
|
|
147
|
-
// Source 1 — the harness's statement about THIS invocation.
|
|
148
|
-
if (hookInput && typeof hookInput === 'object') {
|
|
149
|
-
for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Source 2 — process-scoped env var.
|
|
153
|
-
add(process.env.CLAUDE_CODE_SESSION_ID);
|
|
154
|
-
|
|
155
147
|
// Source 3 — repo-global lock file (the manifest writer's own identity).
|
|
156
148
|
try {
|
|
157
149
|
const lock = readLockIds(repoRoot);
|
|
@@ -190,6 +182,14 @@ export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
|
|
|
190
182
|
* rather than unioning is the fix — a better signal REPLACES a worse one
|
|
191
183
|
* (`.claude/rules/host-resources.md` § HR-102).
|
|
192
184
|
*
|
|
185
|
+
* Native env identity (#1274): an explicit SO_PLATFORM selects ONLY its own
|
|
186
|
+
* variable (claude → CLAUDE_CODE_SESSION_ID, codex → CODEX_THREAD_ID). Cursor
|
|
187
|
+
* and Pi have no native env source here. Without a valid explicit selection,
|
|
188
|
+
* a single non-empty native id, or two equal ids, is usable; conflicting ids
|
|
189
|
+
* are ambiguous and contribute nothing. This prevents an inherited parent
|
|
190
|
+
* harness's id from confirming a peer lock. Hook invocation/parent ids remain
|
|
191
|
+
* valid independently. No filesystem/platform detection is an identity proof.
|
|
192
|
+
*
|
|
193
193
|
* Never throws.
|
|
194
194
|
*
|
|
195
195
|
* @param {{ env?: object, hookInput?: object|null }} [opts]
|
|
@@ -211,8 +211,19 @@ export function readProcessLocalSessionIds({ env = process.env, hookInput = null
|
|
|
211
211
|
if (hookInput && typeof hookInput === 'object') {
|
|
212
212
|
for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
|
|
213
213
|
}
|
|
214
|
-
// Tier 2 — process
|
|
215
|
-
|
|
214
|
+
// Tier 2 — select a native process id without importing the platform module
|
|
215
|
+
// into this hook-safe leaf or using shared repository markers as evidence.
|
|
216
|
+
const trim = (value) => typeof value === 'string' ? value.trim() : '';
|
|
217
|
+
const platform = trim(env?.SO_PLATFORM);
|
|
218
|
+
const claudeId = trim(env?.CLAUDE_CODE_SESSION_ID);
|
|
219
|
+
const codexId = trim(env?.CODEX_THREAD_ID);
|
|
220
|
+
if (platform === 'claude') add(claudeId);
|
|
221
|
+
else if (platform === 'codex') add(codexId);
|
|
222
|
+
else if (platform !== 'cursor' && platform !== 'pi') {
|
|
223
|
+
// Invalid/absent SO_PLATFORM is not an explicit choice (platform.mjs uses
|
|
224
|
+
// the same trimmed allowlist). Disagreement must never become a union.
|
|
225
|
+
if (!claudeId || !codexId || claudeId === codexId) add(claudeId || codexId);
|
|
226
|
+
}
|
|
216
227
|
|
|
217
228
|
return ids;
|
|
218
229
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* check-owner-leakage.mjs — Scan tracked files for owner-privacy leakage patterns.
|
|
4
|
+
* Add --include-untracked to also scan new files that Git does not ignore.
|
|
4
5
|
*
|
|
5
6
|
* Implements the #462 audit trail durable CI guard (#471).
|
|
6
7
|
* Canonicalization-before-matching refactor (issue #661): the historical
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
* toward over-matching: this is a security guard, a false-positive is cheap, a
|
|
20
21
|
* false-negative ships a leak to the public mirror.
|
|
21
22
|
*
|
|
22
|
-
* Usage: check-owner-leakage.mjs <plugin-root>
|
|
23
|
+
* Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked]
|
|
23
24
|
*
|
|
24
25
|
* Forbidden patterns (canonical rules CP1–CP10):
|
|
25
26
|
* CP1 personal home path `/Users/bernhardg…` — matched on the CANONICAL form,
|
|
@@ -128,8 +129,9 @@ const isMain =
|
|
|
128
129
|
|
|
129
130
|
// CLI: single positional arg required (only enforced when run directly).
|
|
130
131
|
const pluginRoot = argv[2];
|
|
132
|
+
const includeUntracked = argv.slice(3).includes('--include-untracked');
|
|
131
133
|
if (isMain && !pluginRoot) {
|
|
132
|
-
console.error('Usage: check-owner-leakage.mjs <plugin-root>');
|
|
134
|
+
console.error('Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked]');
|
|
133
135
|
process.exit(1);
|
|
134
136
|
}
|
|
135
137
|
|
|
@@ -789,9 +791,10 @@ function rawConfidentialNamesKeyState(ownerYamlPath) {
|
|
|
789
791
|
* exists to block. The operator knows their own path; the CLASS of failure is what
|
|
790
792
|
* this line has to convey.
|
|
791
793
|
*
|
|
794
|
+
* @param {{ loadHostPaths?: typeof import('../config/host-paths.mjs').loadHostPaths }} [opts]
|
|
792
795
|
* @returns {Promise<{ patterns: RegExp[], disabledReason?: string, inertWarn?: string }>}
|
|
793
796
|
*/
|
|
794
|
-
async function getConfidentialNamePatterns() {
|
|
797
|
+
export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
795
798
|
let helpers;
|
|
796
799
|
try {
|
|
797
800
|
helpers = {
|
|
@@ -833,7 +836,7 @@ async function getConfidentialNamePatterns() {
|
|
|
833
836
|
}
|
|
834
837
|
|
|
835
838
|
try {
|
|
836
|
-
const {
|
|
839
|
+
const { resolveHostPath } = helpers.hostPaths;
|
|
837
840
|
const { inspectConfidentialNames } = helpers.confidentialNames;
|
|
838
841
|
const { resolveOwnerYamlPath } = helpers.ownerYaml;
|
|
839
842
|
|
|
@@ -841,7 +844,7 @@ async function getConfidentialNamePatterns() {
|
|
|
841
844
|
// health (source / reason / droppedSections) straight through, so the
|
|
842
845
|
// env>owner.yaml>default precedence and the load's diagnosis come from the
|
|
843
846
|
// same read — no second loadOwnerConfig() call to recover what was discarded.
|
|
844
|
-
const hostCtx = loadHostPaths();
|
|
847
|
+
const hostCtx = (loadHostPaths ?? helpers.hostPaths.loadHostPaths)();
|
|
845
848
|
|
|
846
849
|
// FAIL-CLOSED on an unknowable load (LOW-2). `loadHostPaths` has its own
|
|
847
850
|
// defensive catch that swallows a THROWING owner loader and returns
|
|
@@ -987,6 +990,7 @@ async function getConfidentialNamePatterns() {
|
|
|
987
990
|
// adding: all 7 tracked `.jsonl` files pass (1525 -> 1532 scanned, 0 findings).
|
|
988
991
|
const TEXT_EXTS = new Set([
|
|
989
992
|
'.md',
|
|
993
|
+
'.mdx',
|
|
990
994
|
'.mjs',
|
|
991
995
|
'.js',
|
|
992
996
|
'.ts',
|
|
@@ -1021,9 +1025,14 @@ function isTextFile(filePath) {
|
|
|
1021
1025
|
|
|
1022
1026
|
function getTrackedFiles() {
|
|
1023
1027
|
try {
|
|
1024
|
-
|
|
1028
|
+
// NUL delimiters preserve Unicode, spaces and newlines without Git's quoted
|
|
1029
|
+
// filename encoding. --exclude-standard applies only to untracked entries;
|
|
1030
|
+
// tracked files remain visible even if an ignore rule now matches them.
|
|
1031
|
+
const args = ['ls-files', '-z', '--cached'];
|
|
1032
|
+
if (includeUntracked) args.push('--others', '--exclude-standard');
|
|
1033
|
+
const output = execFileSync('git', args, { cwd: pluginRoot, encoding: 'utf8' });
|
|
1025
1034
|
return output
|
|
1026
|
-
.split('\
|
|
1035
|
+
.split('\0')
|
|
1027
1036
|
.filter(Boolean)
|
|
1028
1037
|
.map((f) => join(pluginRoot, f));
|
|
1029
1038
|
} catch {
|
|
@@ -1402,7 +1411,7 @@ if (violations.length === 0) {
|
|
|
1402
1411
|
}
|
|
1403
1412
|
|
|
1404
1413
|
console.log('');
|
|
1405
|
-
console.log(`Results: ${passed} passed, ${failed} failed`);
|
|
1414
|
+
console.log(`Results: ${passed} passed, ${failed} failed (${scanFiles.length} scanned files)`);
|
|
1406
1415
|
process.exit(failed === 0 ? 0 : 1);
|
|
1407
1416
|
}
|
|
1408
1417
|
|
|
@@ -473,12 +473,19 @@ export function generateSessionNote(entry, options = {}) {
|
|
|
473
473
|
// that "repaired" that one too would be inventing a measurement.
|
|
474
474
|
const waveRows = waves
|
|
475
475
|
.map((w) => {
|
|
476
|
+
// #1276: lifecycle-only records omit every older count alias. Started
|
|
477
|
+
// measures participation; completed/planned-only counts retain their
|
|
478
|
+
// labels so a plan or a completion count never claims dispatch coverage.
|
|
479
|
+
const completedAgents = waveCount(w.agent_count_completed);
|
|
480
|
+
const plannedAgents = waveCount(w.agent_count_planned);
|
|
476
481
|
const agentsCell =
|
|
477
482
|
waveCount(w.agent_count) ??
|
|
478
483
|
waveCount(w.agents) ??
|
|
479
484
|
waveCount(w.agents_dispatched) ??
|
|
480
485
|
waveCount(w.dispatched) ??
|
|
481
|
-
|
|
486
|
+
waveCount(w.agent_count_started) ??
|
|
487
|
+
(completedAgents === undefined ? undefined : `${completedAgents} completed`) ??
|
|
488
|
+
(plannedAgents === undefined ? MISSING_CELL : `${plannedAgents} planned`);
|
|
482
489
|
const filesCell = waveCount(w.files_changed) ?? waveCount(w.files) ?? MISSING_CELL;
|
|
483
490
|
const qualityCell =
|
|
484
491
|
w.quality ?? w.quality_check ?? w.status ?? w.result ?? w.outcome ?? MISSING_CELL;
|
package/scripts/release.mjs
CHANGED
|
@@ -1520,10 +1520,15 @@ export function printPublishOutcome(outcome, target, io = {}) {
|
|
|
1520
1520
|
log(` ${outcome.release.detail}.`);
|
|
1521
1521
|
} else {
|
|
1522
1522
|
error(`\nRECONCILIATION: ${outcome.release.detail}`);
|
|
1523
|
-
|
|
1524
|
-
|
|
1523
|
+
const recovery = outcome.release.recovery;
|
|
1524
|
+
if (recovery?.inspect) {
|
|
1525
|
+
error(` Inspect with: ${renderRecoveryCommand(recovery.inspect)}`);
|
|
1526
|
+
if (outcome.release.state === 'create-failed' && recovery.create) {
|
|
1527
|
+
error(` Recover with: ${renderRecoveryCommand(recovery.create)}`);
|
|
1528
|
+
error(' The notes file is retained for recovery; remove it after the release is reconciled.');
|
|
1529
|
+
}
|
|
1525
1530
|
} else {
|
|
1526
|
-
error('
|
|
1531
|
+
error(' Resolve the GitHub repository identity before inspecting or creating the release.');
|
|
1527
1532
|
}
|
|
1528
1533
|
}
|
|
1529
1534
|
|
|
@@ -1553,6 +1558,11 @@ export function printPublishOutcome(outcome, target, io = {}) {
|
|
|
1553
1558
|
return 0;
|
|
1554
1559
|
}
|
|
1555
1560
|
|
|
1561
|
+
/** Render argv for a POSIX shell without interpreting paths as shell code. */
|
|
1562
|
+
function renderRecoveryCommand(argv) {
|
|
1563
|
+
return argv.map((arg) => /^[\w./:@=+-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`).join(' ');
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1556
1566
|
/**
|
|
1557
1567
|
* Create the GitHub release for `v<target>`, or confirm the existing one.
|
|
1558
1568
|
*
|
|
@@ -1582,18 +1592,23 @@ export function printPublishOutcome(outcome, target, io = {}) {
|
|
|
1582
1592
|
* @param {string} repoRoot
|
|
1583
1593
|
* @param {string} target
|
|
1584
1594
|
* @param {{runImpl?: Function, repoSpec?: string}} [deps] — injection seam for tests
|
|
1585
|
-
* @returns {{ok: boolean, created: boolean, tag: string, state: 'exists'|'created'|'unknown'|'create-failed', detail: string, argv?: string[]}}
|
|
1595
|
+
* @returns {{ok: boolean, created: boolean, tag: string, state: 'exists'|'created'|'unknown'|'create-failed', detail: string, argv?: string[], recovery?: {inspect: string[], create?: string[]}}}
|
|
1586
1596
|
*/
|
|
1587
1597
|
export function ensureGithubRelease(repoRoot, target, deps = {}) {
|
|
1588
1598
|
const runImpl = deps.runImpl ?? run;
|
|
1589
1599
|
const tag = `v${target}`;
|
|
1590
|
-
|
|
1591
|
-
// resolveRepoSpec returns undefined when it cannot auto-detect; its contract
|
|
1592
|
-
// is that callers OMIT the flag rather than pass `-R undefined`.
|
|
1593
|
-
const repoFlag = spec ? ['--repo', spec] : [];
|
|
1600
|
+
let recovery;
|
|
1594
1601
|
|
|
1595
1602
|
try {
|
|
1596
|
-
const
|
|
1603
|
+
const spec = deps.repoSpec ?? resolveRepoSpec({ repoRoot, vcs: 'github' });
|
|
1604
|
+
if (typeof spec !== 'string' || !spec.trim()) {
|
|
1605
|
+
return { ok: false, created: false, tag, state: 'unknown', detail: 'GitHub repository identity could not be resolved' };
|
|
1606
|
+
}
|
|
1607
|
+
// Recovery must carry the same resolved identity as the real invocation.
|
|
1608
|
+
// An absent identity cannot safely fall back to the caller's ambient repo.
|
|
1609
|
+
const repoFlag = ['--repo', spec];
|
|
1610
|
+
recovery = { inspect: ['gh', 'release', 'view', tag, ...repoFlag] };
|
|
1611
|
+
const existing = runImpl(recovery.inspect[0], recovery.inspect.slice(1), { cwd: repoRoot });
|
|
1597
1612
|
const viewOutput = `${existing.stdout || ''}\n${existing.stderr || ''}`.trim();
|
|
1598
1613
|
if (existing.status === 0 && viewOutput) {
|
|
1599
1614
|
return { ok: true, created: false, tag, state: 'exists', detail: `GitHub release ${tag} already exists — no-op` };
|
|
@@ -1607,6 +1622,7 @@ export function ensureGithubRelease(repoRoot, target, deps = {}) {
|
|
|
1607
1622
|
created: false,
|
|
1608
1623
|
tag,
|
|
1609
1624
|
state: 'unknown',
|
|
1625
|
+
recovery,
|
|
1610
1626
|
detail: `could not determine whether GitHub release ${tag} exists (gh release view exited ${existing.status}: ${viewOutput.slice(0, 300) || 'empty output'})`,
|
|
1611
1627
|
};
|
|
1612
1628
|
}
|
|
@@ -1614,26 +1630,31 @@ export function ensureGithubRelease(repoRoot, target, deps = {}) {
|
|
|
1614
1630
|
const notesDir = mkdtempSync(join(tmpdir(), 'release-ghnotes-'));
|
|
1615
1631
|
const notesFile = join(notesDir, 'notes.md');
|
|
1616
1632
|
let argv;
|
|
1633
|
+
let retainNotes = false;
|
|
1617
1634
|
try {
|
|
1618
1635
|
writeFileSync(notesFile, `${changelogExcerpt(repoRoot, target)}\n`);
|
|
1619
1636
|
argv = ['release', 'create', tag, ...repoFlag, '--verify-tag', '--title', tag, '--notes-file', notesFile];
|
|
1620
1637
|
const created = runImpl('gh', argv, { cwd: repoRoot });
|
|
1621
1638
|
if (created.status !== 0) {
|
|
1639
|
+
// A recovery argv pointing to a file deleted by finally is unusable.
|
|
1640
|
+
// Preserve only this failed-create excerpt; successful runs still clean up.
|
|
1641
|
+
retainNotes = true;
|
|
1622
1642
|
return {
|
|
1623
1643
|
ok: false,
|
|
1624
1644
|
created: false,
|
|
1625
1645
|
tag,
|
|
1626
1646
|
state: 'create-failed',
|
|
1627
1647
|
argv,
|
|
1648
|
+
recovery: { ...recovery, create: ['gh', ...argv] },
|
|
1628
1649
|
detail: `gh release create exited ${created.status}: ${(created.stderr || created.stdout || '').trim().slice(0, 300)}`,
|
|
1629
1650
|
};
|
|
1630
1651
|
}
|
|
1631
1652
|
return { ok: true, created: true, tag, state: 'created', argv, detail: `GitHub release ${tag} created (--verify-tag)` };
|
|
1632
1653
|
} finally {
|
|
1633
|
-
rmSync(notesDir, { recursive: true, force: true });
|
|
1654
|
+
if (!retainNotes) rmSync(notesDir, { recursive: true, force: true });
|
|
1634
1655
|
}
|
|
1635
1656
|
} catch (err) {
|
|
1636
|
-
return { ok: false, created: false, tag, state: 'unknown', detail: `gh could not be run: ${err.message}` };
|
|
1657
|
+
return { ok: false, created: false, tag, state: 'unknown', ...(recovery ? { recovery } : {}), detail: `gh could not be run: ${err.message}` };
|
|
1637
1658
|
}
|
|
1638
1659
|
}
|
|
1639
1660
|
|