session-orchestrator 3.23.0 → 3.24.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/skills/remote-offload/SKILL.md +13 -0
- package/CHANGELOG.md +278 -0
- package/README.md +16 -14
- package/agents/db-specialist.md +0 -1
- package/docs/ci-setup.md +180 -25
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +3 -3
- package/docs/events-schema.md +46 -8
- package/docs/scope-collision-guard.md +4 -4
- package/docs/session-config-reference.md +64 -6
- package/docs/session-config-template.md +34 -4
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +30 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +35 -6
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +278 -12
- package/hooks/on-session-start.mjs +50 -2
- package/hooks/on-stop.mjs +349 -20
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +148 -18
- package/hooks/post-tool-batch-wave-signal.mjs +154 -40
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/subagent-telemetry.mjs +11 -26
- package/package.json +1 -1
- package/scripts/autopilot.mjs +26 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/emit-event.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
- package/scripts/lib/build-live-signals.mjs +25 -22
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config.mjs +31 -3
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/memory-banner.mjs +20 -8
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/reconcile/engine.mjs +236 -5
- package/scripts/lib/scope-gate.mjs +36 -0
- package/scripts/lib/session-close-backfill.mjs +59 -10
- package/scripts/lib/session-discovery.mjs +57 -3
- package/scripts/lib/session-end/phase-skip.mjs +2 -2
- package/scripts/lib/session-identity/own-session.mjs +62 -1
- package/scripts/lib/session-transition.mjs +1 -1
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +49 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +0 -7
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +174 -135
- package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +24 -4
- package/scripts/materialize-wave-scope.mjs +20 -4
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/validate-plugin.mjs +26 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +111 -26
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +17 -4
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +6 -5
- package/skills/reconcile/SKILL.md +5 -2
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/phase-3-6-tail.md +9 -6
- package/skills/session-start/SKILL.md +26 -3
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +43 -5
|
@@ -98,7 +98,7 @@ agent-mapping: { impl: cursor:composer-2.5, test: cursor:cursor-grok-4.6-high, s
|
|
|
98
98
|
|
|
99
99
|
**Where the rest of the contract lives** — deliberately not here, so one place owns it:
|
|
100
100
|
|
|
101
|
-
- **Model selection** (which model for which role, and why): the
|
|
101
|
+
- **Model selection** (which model for which role, and why): the operator's model-routing SSOT (ADR-002). Working defaults are `composer-2.5` for foreign impl and `cursor-grok-4.6-high` for review / test-writing / judgment roles.
|
|
102
102
|
- **Dispatch contract** (detached worktree, the `never_foreign` role lock, the filesystem-measured verdict, the MANDATORY Claude semantic diff-review before merge-back, wall-clock timeout instead of `maxTurns`, and the `orchestrator.foreign_dispatch.completed` event that replaces the hook-chain telemetry a foreign run cannot emit): `skills/wave-executor/wave-loop.md` § Third branch: foreign-model dispatch.
|
|
103
103
|
|
|
104
104
|
## VCS & Infrastructure
|
|
@@ -111,8 +111,34 @@ agent-mapping: { impl: cursor:composer-2.5, test: cursor:cursor-grok-4.6-high, s
|
|
|
111
111
|
| `cross-repos` | list | none | Related repositories under `~/Projects/`. The orchestrator checks their git state and critical issues during session start. |
|
|
112
112
|
| `cross-repo.projects` | list | `[]` | Repos to process when running cross-repo maintenance scripts (`run-migrate-v2-cross-repo.mjs`, `vault-integration-watcher.mjs`, `promote-vault-strict.mjs`). Each entry is a path (absolute, `~`-prefixed, or bare name resolved under `~/Projects/`). When this list is empty or absent, those scripts emit a one-line notice and exit 0 — they never error on an empty list. Example: `[~/Projects/my-app, ~/Projects/another-app]`. |
|
|
113
113
|
| `pencil` | string | none | Path to a `.pen` design file (relative to project root). Enables design-code alignment reviews after Impl-Core and Impl-Polish waves. |
|
|
114
|
-
| `ecosystem-health` | boolean | `false` | Enable service health checks at session start. Requires `health-endpoints` to be configured. |
|
|
115
|
-
| `health-endpoints` | list | none | Service URLs to check health. Each entry is an object with `name` and `url` fields. |
|
|
114
|
+
| `ecosystem-health` | boolean | `false` | Enable service health checks at session start. Requires `health-endpoints` to be configured. Accepts the SCALAR form (`ecosystem-health: true`) read off the flat key/value map, or a BLOCK form (`ecosystem-health:` with no value, followed by an indented body — the wizard's output) read via `_parseEcosystemHealthBlockEnabled()`; the scalar wins when both are present (#1174). |
|
|
115
|
+
| `health-endpoints` | list | none | Service URLs to check health. Each entry is an object with `name` and `url` fields. Parsed by `scripts/lib/config/health-endpoints.mjs`, which accepts THREE forms (#1174) — see below. |
|
|
116
|
+
|
|
117
|
+
**`health-endpoints` accepted forms.** Before #1174 the parser read this key off the flat KV map,
|
|
118
|
+
which bails to `null` the instant a value contains `{` and cannot see a nested YAML block at all;
|
|
119
|
+
the wizard's own output (Form B) silently failed to parse. All three forms below are parsed
|
|
120
|
+
content-scoped, independent of the flat KV map:
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
# Form A — inline object array
|
|
124
|
+
health-endpoints: [{name: "API", url: "https://a/health"}, {name: "W", url: "http://w:8080/z"}]
|
|
125
|
+
|
|
126
|
+
# Form B — nested block (top-level, or one level under `ecosystem-health:`); the wizard's output.
|
|
127
|
+
# Block items may also be inline objects, `- { name: API, url: … }` (the form this file's own
|
|
128
|
+
# example above uses).
|
|
129
|
+
health-endpoints:
|
|
130
|
+
- name: API
|
|
131
|
+
url: https://api.example.com/health
|
|
132
|
+
|
|
133
|
+
# Form C — bare bracket list of URLs; each URL becomes its own name ({ name: <url>, url: <url> })
|
|
134
|
+
health-endpoints: [https://a/health, https://b/health]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The `name=url` shorthand (Form D) is **not** supported — an entry containing `=` is treated
|
|
138
|
+
verbatim as a Form-C URL, never split on `=`. A malformed entry (missing `name` or `url`, an
|
|
139
|
+
unmatched brace) resolves to `null` for the whole key and prints exactly one
|
|
140
|
+
`config: health-endpoints:` WARN to stderr — it never throws, so a broken config key cannot take
|
|
141
|
+
down session-start.
|
|
116
142
|
| `issue-limit` | integer | `50` | Maximum issues to fetch when querying VCS during session start. |
|
|
117
143
|
| `stale-branch-days` | integer | `7` | Days of inactivity before a branch is flagged as stale. |
|
|
118
144
|
| `stale-issue-days` | integer | `30` | Days without progress before an issue is flagged for triage. |
|
|
@@ -385,18 +411,18 @@ state-md-lock:
|
|
|
385
411
|
|
|
386
412
|
## Discovery-Validator (PSA-006 Enforcement, #567)
|
|
387
413
|
|
|
388
|
-
Non-blocking `SubagentStop` hook that mechanically enforces PSA-006: distributional claims ("N of M", "100% of", "all N", "no remaining", "every X", "none of") appearing in a subagent's transcript tail must carry an adjacent fenced grep/rg/find transcript. When a claim lacks one, the hook records a `discovery_validator_violation` event in `.orchestrator/metrics/events.jsonl` and emits a stderr WARN. v1 is log + warn only — exit 0 always, never blocks the agent; a blocking hard-gate is reserved for a future iteration. Default
|
|
414
|
+
Non-blocking `SubagentStop` hook that mechanically enforces PSA-006: distributional claims ("N of M", "100% of", "all N", "no remaining", "every X", "none of") appearing in a subagent's transcript tail must carry an adjacent fenced grep/rg/find transcript. When a claim lacks one, the hook records a `discovery_validator_violation` event in `.orchestrator/metrics/events.jsonl` and emits a stderr WARN. v1 is log + warn only — exit 0 always, never blocks the agent; a blocking hard-gate is reserved for a future iteration. Default OFF (opt-in) — the #690 flip to ON was reverted 2026-09-02 (#1191) after fleet measurement showed 6,946 violation events accumulating in 18 repos that never declared the block.
|
|
389
415
|
|
|
390
416
|
All fields live under a top-level `discovery-validator` object in your Session Config host file (`CLAUDE.md` or `AGENTS.md`), for example:
|
|
391
417
|
|
|
392
418
|
```yaml
|
|
393
419
|
discovery-validator:
|
|
394
|
-
enabled: true #
|
|
420
|
+
enabled: true # off by default; opt in per repo — log+warn-only, exit-0-always
|
|
395
421
|
```
|
|
396
422
|
|
|
397
423
|
| Field | Type | Default | Description |
|
|
398
424
|
|-------|------|---------|-------------|
|
|
399
|
-
| `discovery-validator.enabled` | boolean | `
|
|
425
|
+
| `discovery-validator.enabled` | boolean | `false` | Master toggle. When `false` (or when the block is absent), the `SubagentStop` hook is bypassed entirely — no transcript scanning, no `discovery_validator_violation` events. Note: when the `discovery-validator:` block is present but omits the `enabled:` line, the parser conservatively resolves to `false` — only a literal `true` enables the hook. Always set `enabled` explicitly when adding this block. |
|
|
400
426
|
|
|
401
427
|
**Used by:** `hooks/post-subagent-discovery-validator.mjs`, `scripts/lib/config/discovery-validator.mjs` (`_parseDiscoveryValidator`). See `.claude/rules/parallel-sessions.md` § PSA-006.
|
|
402
428
|
|
|
@@ -1190,6 +1216,38 @@ Read by: `scripts/lib/config/custom-phases.mjs` (parser), `skills/session-end/SK
|
|
|
1190
1216
|
- `archive-closed-prds` (#782, Epic #774) — `node scripts/archive-closed-prds.mjs --apply` — archives `docs/prd/` PRDs (defaults).
|
|
1191
1217
|
- `archive-closed-plans` (#786) — `… --apply --prd-dir docs/plans --vault-subdir 01-projects/session-orchestrator/plans` — archives `docs/plans/` executable-plan artefacts of closed features/Epics. The plan's tracking `#NNN` (inline in the plan's `Source:` header, see `skills/write-executable-plan/`) is the anchor this phase reads; a plan with no `#NNN` is never archived (fail-closed `no-epic-ref`).
|
|
1192
1218
|
|
|
1219
|
+
## Remote Hosts (#1160)
|
|
1220
|
+
|
|
1221
|
+
Opt-in declaration of ssh-reachable hosts a heavy wave role may be OFFLOADED to instead of shrinking the wave under local resource pressure. This key only DECLARES: it never probes a host, never dispatches, and never changes a wave by itself. Absent/empty ⇒ `[]` ⇒ every wave stays local, exactly as before.
|
|
1222
|
+
|
|
1223
|
+
**Parser gotcha:** like every other block-shaped Session Config key, the `remote-hosts:` key-line itself MUST NOT carry an inline comment — see § Parser Gotcha: No-Inline-Comment Block Headers (top of this file). A trailing `# comment` on that exact line means the parser never enters the block and `remote-hosts` silently resolves to `[]`.
|
|
1224
|
+
|
|
1225
|
+
```yaml
|
|
1226
|
+
remote-hosts:
|
|
1227
|
+
- alias: m5 # required, SAFE slug; reaches argv as `-H <alias>`
|
|
1228
|
+
roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
|
|
1229
|
+
repo-path: ~/Projects/Alice # optional; SAFE path; default null
|
|
1230
|
+
claude-path: ~/.local/bin/claude # optional; SAFE path; default null
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
| Field | Type | Default | Description |
|
|
1234
|
+
|-------|------|---------|-------------|
|
|
1235
|
+
| `remote-hosts` | list | `[]` | The declared hosts, in preference order — the gate takes the FIRST host that accepts the role and is witnessed ready. |
|
|
1236
|
+
| `alias` | string | — (required) | ssh destination as configured on this host. Must match `^[A-Za-z0-9._-]+$`; it reaches argv as `-H <alias>`. A record missing or failing this is dropped with a stderr WARN. |
|
|
1237
|
+
| `roles-allowed` | string[] | `[test, ui, perf]` | The `agent-mapping` roles this host accepts. Entries outside `test` / `ui` / `perf` are filtered with a WARN; a record whose list is empty after filtering is dropped. `impl`, `db`, `security`, `compliance` and `docs` work never leaves the local host. |
|
|
1238
|
+
| `repo-path` | string \| null | `null` | Checkout location on the remote host. SAFE-path validated (`^[A-Za-z0-9._~/-]+$`); an unsafe value drops the whole record with a WARN. |
|
|
1239
|
+
| `claude-path` | string \| null | `null` | `claude` binary location on the remote host. Same validation as `repo-path`. |
|
|
1240
|
+
|
|
1241
|
+
**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.
|
|
1242
|
+
|
|
1243
|
+
**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`. It does NOT probe the network: the coordinator supplies a readiness witness (`remoteReady: { m5: 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/foreign-dispatch.mjs`) is never offloaded regardless.
|
|
1244
|
+
|
|
1245
|
+
**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.
|
|
1246
|
+
|
|
1247
|
+
Read by: `scripts/lib/config/remote-hosts.mjs` (parser), `scripts/lib/config.mjs` (`ssh:` channel validation), `scripts/lib/wave-resource-gate.mjs` (placement).
|
|
1248
|
+
|
|
1249
|
+
See `skills/remote-offload/SKILL.md` for the wave-executor-side decision rule, the three offload channels, and how a declared alias here is what an `agent-mapping` `ssh:<alias>` value validates against.
|
|
1250
|
+
|
|
1193
1251
|
## Evolve Extra Sources (#638)
|
|
1194
1252
|
|
|
1195
1253
|
Opt-in EXTRA learning sources for `/evolve`. A domain measurement (e.g. an eval-learn regression harness) runs OUT-OF-BAND and writes a sidecar JSON of regression flags; `/evolve` then READS each declared sidecar and emits a `domain-regression` learning candidate per flag that has persisted across ≥2 consecutive sessions. This is a strict **read-only consumption contract**: `/evolve` never runs the domain measurement — it only consumes the sidecar output. Absent/empty ⇒ `[]` ⇒ no extra sources are read; existing `/evolve` runs are unaffected.
|
|
@@ -66,7 +66,7 @@ mirror: github # auto-push to mirror after every commit
|
|
|
66
66
|
cross-repos: [related-repo-1] # repos under ~/Projects/ to snapshot at session-start
|
|
67
67
|
pencil: path/to/design.pen # design-code alignment input
|
|
68
68
|
ecosystem-health: true # toggle health-endpoint probes
|
|
69
|
-
health-endpoints:
|
|
69
|
+
health-endpoints: # nested block form — content-scoped parse, see reference doc (#1174)
|
|
70
70
|
- { name: API, url: https://api.example.com/health }
|
|
71
71
|
issue-limit: 50 # max issues fetched at session-start
|
|
72
72
|
stale-branch-days: 7 # branch-age threshold for stale flag
|
|
@@ -381,6 +381,29 @@ Security: `command` and `review` reject shell metacharacters; records failing va
|
|
|
381
381
|
|
|
382
382
|
Read by: `scripts/lib/config/custom-phases.mjs`, `skills/session-end/SKILL.md` Phase 2.5.
|
|
383
383
|
|
|
384
|
+
## Remote Hosts (#1160)
|
|
385
|
+
|
|
386
|
+
Opt-in declaration of ssh-reachable hosts that heavy wave roles may be OFFLOADED to instead of shrinking the wave under local resource pressure. Declaring a host does not enable anything by itself: the wave resource gate only routes a wave to a host when the wave role is offloadable AND the coordinator supplies a readiness witness. Absent/empty ⇒ `[]` ⇒ everything runs locally, exactly as before.
|
|
387
|
+
|
|
388
|
+
```yaml
|
|
389
|
+
remote-hosts:
|
|
390
|
+
- alias: m5 # required, SAFE slug ([A-Za-z0-9._-]); reaches argv as `-H <alias>`
|
|
391
|
+
roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
|
|
392
|
+
repo-path: ~/Projects/Alice # optional SAFE path (default: null)
|
|
393
|
+
claude-path: ~/.local/bin/claude # optional SAFE path (default: null)
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Field semantics:
|
|
397
|
+
- **`alias`** — the ssh destination as configured on this host. Required; a record without it is dropped with a stderr WARN.
|
|
398
|
+
- **`roles-allowed`** — the `agent-mapping` roles this host accepts. `test`, `ui` and `perf` only — impl, db, security, compliance and docs work never leaves the local host. Unknown entries are filtered with a WARN; a record left with an empty list is dropped.
|
|
399
|
+
- **`repo-path` / `claude-path`** — where the checkout and the `claude` binary live on the remote host, when they differ from the defaults the dispatch adapter assumes.
|
|
400
|
+
|
|
401
|
+
A declared alias is also what an `agent-mapping` value of the form `<role>: ssh:<alias>` is validated against — naming an undeclared host is a parse error, not a runtime surprise.
|
|
402
|
+
|
|
403
|
+
The gate never probes the network. Without a readiness witness no host counts as ready and the decision stays local (fail toward local).
|
|
404
|
+
|
|
405
|
+
Read by: `scripts/lib/config/remote-hosts.mjs`, `scripts/lib/wave-resource-gate.mjs`.
|
|
406
|
+
|
|
384
407
|
## Evolve Extra Sources (#638)
|
|
385
408
|
|
|
386
409
|
Opt-in EXTRA learning sources for `/evolve`. A `domain-regression` measurement (e.g. an eval-learn harness) runs OUT-OF-BAND and writes a sidecar JSON; `/evolve` then READS each declared sidecar and emits a `domain-regression` learning candidate per persistent regression flag. `/evolve` NEVER runs the measurement itself — this is a strict read-only consumption contract. Absent/empty ⇒ `[]` ⇒ no extra sources are read.
|
|
@@ -431,11 +454,11 @@ Read by: `scripts/lib/config/reconcile.mjs` (parser), `skills/session-end/SKILL.
|
|
|
431
454
|
|
|
432
455
|
## Discovery-Validator (PSA-006 Enforcement)
|
|
433
456
|
|
|
434
|
-
Non-blocking `SubagentStop` hook that mechanically enforces PSA-006: distributional claims ("N of M", "100% of", "all N", "no remaining", "every X", "none of") in a subagent's transcript tail must carry an adjacent fenced grep/rg/find transcript. When a claim lacks one, the hook records a `discovery_validator_violation` event in `.orchestrator/metrics/events.jsonl` and emits a stderr WARN. v1 is log + warn only (exit 0 always — never blocks an agent) — a blocking hard-gate is reserved for a future iteration.
|
|
457
|
+
Non-blocking `SubagentStop` hook that mechanically enforces PSA-006: distributional claims ("N of M", "100% of", "all N", "no remaining", "every X", "none of") in a subagent's transcript tail must carry an adjacent fenced grep/rg/find transcript. When a claim lacks one, the hook records a `discovery_validator_violation` event in `.orchestrator/metrics/events.jsonl` and emits a stderr WARN. v1 is log + warn only (exit 0 always — never blocks an agent) — a blocking hard-gate is reserved for a future iteration. OFF by default (opt-in). Issue #567; #690 flip reverted 2026-09-02 after fleet measurement.
|
|
435
458
|
|
|
436
459
|
```yaml
|
|
437
460
|
discovery-validator:
|
|
438
|
-
enabled: true #
|
|
461
|
+
enabled: true # opt-in; hook is off unless this block sets it
|
|
439
462
|
```
|
|
440
463
|
|
|
441
464
|
Read by: `scripts/lib/config/discovery-validator.mjs`, `hooks/post-subagent-discovery-validator.mjs`.
|
|
@@ -817,6 +840,13 @@ custom-phases:
|
|
|
817
840
|
mode: hard # warn | hard | off (default: warn)
|
|
818
841
|
review: docs/eval/last-run.md # optional SAFE path read after the command (default: null)
|
|
819
842
|
|
|
843
|
+
# Remote hosts — ssh-reachable offload targets for heavy roles (#1160)
|
|
844
|
+
remote-hosts:
|
|
845
|
+
- alias: m5 # required, SAFE slug; reaches argv as `-H <alias>`
|
|
846
|
+
roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
|
|
847
|
+
repo-path: ~/Projects/Alice # optional SAFE path (default: null)
|
|
848
|
+
claude-path: ~/.local/bin/claude # optional SAFE path (default: null)
|
|
849
|
+
|
|
820
850
|
# Evolve extra-sources — opt-in EXTRA /evolve learning sources (#638)
|
|
821
851
|
evolve:
|
|
822
852
|
extra-sources:
|
|
@@ -836,7 +866,7 @@ reconcile:
|
|
|
836
866
|
|
|
837
867
|
# Discovery-validator — PSA-006 enforcement (#567)
|
|
838
868
|
discovery-validator:
|
|
839
|
-
enabled: true
|
|
869
|
+
enabled: true # opt-in; hook is off unless this block sets it
|
|
840
870
|
|
|
841
871
|
# Dialectic-Deriver (#506)
|
|
842
872
|
dialectic:
|
|
@@ -58,29 +58,30 @@ the measurement recipe — see [Reproduce it yourself](#reproduce-it-yourself).
|
|
|
58
58
|
|
|
59
59
|
---
|
|
60
60
|
|
|
61
|
-
## In-repo test suite — the "
|
|
61
|
+
## In-repo test suite — the "15,000+ tests" badge
|
|
62
62
|
|
|
63
63
|
Distinct from the private-corpus figures above, the README **Tests** badge and the
|
|
64
|
-
"
|
|
64
|
+
"15,000+ vitest tests run on every commit" line count **this repository's own test
|
|
65
65
|
suite** — a **public, CI-verifiable** number, not a maintainer-reported aggregate.
|
|
66
66
|
|
|
67
67
|
| Claim | What it counts | How measured | As-of |
|
|
68
68
|
| --- | --- | --- | --- |
|
|
69
|
-
|
|
|
69
|
+
| 15,000+ vitest tests | executed test cases across `tests/**/*.test.mjs` | `npm test` prints the exact runtime total; the static floor is countable without running the suite (below) | 2026-09 |
|
|
70
70
|
|
|
71
71
|
Both numbers reproduce in a fresh checkout:
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
|
-
find tests -name '*.test.mjs' | wc -l # test files ->
|
|
75
|
-
grep -rohE '\b(it|test)\(' tests | wc -l # static test defs -> ~
|
|
76
|
-
grep -rohE '\b(it|test|describe)\.each\b' tests | wc -l # parameterized blocks ->
|
|
74
|
+
find tests -name '*.test.mjs' | wc -l # test files -> 637
|
|
75
|
+
grep -rohE '\b(it|test)\(' tests | wc -l # static test defs -> ~13,361
|
|
76
|
+
grep -rohE '\b(it|test|describe)\.each\b' tests | wc -l # parameterized blocks -> 440
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
The static `it(` / `test(` count (~
|
|
79
|
+
The static `it(` / `test(` count (~13,361 across 637 files) is a **floor**: the 440
|
|
80
80
|
`it.each` / `test.each` parameterized blocks each expand to multiple executed cases
|
|
81
|
-
at runtime, so the **case count vitest reports on `npm test` is
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
at runtime, so the **case count vitest reports on `npm test` is 15,000+** (measured
|
|
82
|
+
15,869/0 on the local Full Gate at HEAD, 2026-09-02). Unlike the private-corpus
|
|
83
|
+
figures above, this one is fully auditable — run `npm test` in this checkout and
|
|
84
|
+
read vitest's summary line.
|
|
84
85
|
|
|
85
86
|
## Methodology
|
|
86
87
|
|
package/docs/telemetry.md
CHANGED
|
@@ -43,7 +43,36 @@ projection unit test enforces the drop of any non-whitelisted input field.
|
|
|
43
43
|
| `session_type` | One of `housekeeping`, `feature`, `deep`, `other`. |
|
|
44
44
|
| `duration_bucket` | One of `<15m`, `15-60m`, `1-3h`, `>3h` — a coarse bucket, never an exact duration. |
|
|
45
45
|
| `skills[]` | Names of invoked skills, filtered against the shipped plugin roster — any name not in that roster becomes `"other"`. |
|
|
46
|
-
| `commands[]` |
|
|
46
|
+
| `commands[]` | Names of invoked slash-commands, same filtering rule as `skills[]`. |
|
|
47
|
+
|
|
48
|
+
### How a name lands in `skills[]` or `commands[]`
|
|
49
|
+
|
|
50
|
+
Both buckets are fed from one local ledger of invocations
|
|
51
|
+
(`.orchestrator/metrics/skill-invocations.jsonl`), so a single classification
|
|
52
|
+
rule decides which bucket a name reaches — and it is deliberately biased
|
|
53
|
+
towards anonymizing rather than towards attributing:
|
|
54
|
+
|
|
55
|
+
- Shipped **skills** are recorded plugin-prefixed
|
|
56
|
+
(`session-orchestrator:session-end`); shipped **commands** are recorded bare
|
|
57
|
+
(`session`). The Skill tool surfaces a slash-command that has no backing
|
|
58
|
+
`skills/` directory under the *prefixed* form too, so a prefixed name whose
|
|
59
|
+
bare form is a shipped command is reported in `commands[]` under that bare
|
|
60
|
+
name.
|
|
61
|
+
- A name is only ever reported as one of our commands when it carries the
|
|
62
|
+
plugin prefix. A **bare** name is never credited to a command, even when it
|
|
63
|
+
collides with one of our command names — a third-party or personal skill
|
|
64
|
+
invoked bare as `test` would otherwise be reported as our `/test` command.
|
|
65
|
+
Bare unknown names take the skills path and are reduced to `"other"`. The one
|
|
66
|
+
exception is a name arriving in the ledger's `.command` **field**: that field
|
|
67
|
+
is itself the "this is one of ours" provenance signal a bare `.skill` arrival
|
|
68
|
+
lacks, so `buildUsagePing` prefixes every `.command` value before
|
|
69
|
+
classification (`scripts/lib/telemetry/schema.mjs`). Without that step the
|
|
70
|
+
`.command` producer would be wired but dead — every record it writes would
|
|
71
|
+
silently become `"other"`.
|
|
72
|
+
- On a spelling collision (`memory-cleanup` exists as both a skill and a
|
|
73
|
+
command) the skill roster wins, so exactly one bucket is credited. Counting
|
|
74
|
+
distinct surfaces across `skills[]` and `commands[]` therefore never
|
|
75
|
+
double-counts a single one.
|
|
47
76
|
|
|
48
77
|
## What we never collect
|
|
49
78
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* atomic-json.mjs — shared atomic read-modify-write helper for JSON hook state.
|
|
3
|
+
*
|
|
4
|
+
* Extracted (issue #1197) from four byte-identical private copies of
|
|
5
|
+
* `atomicMutateJson` that had accumulated independently in
|
|
6
|
+
* `hooks/cwd-change-restore.mjs`, `hooks/on-session-end.mjs`,
|
|
7
|
+
* `hooks/post-tool-batch-wave-signal.mjs`, and
|
|
8
|
+
* `hooks/post-tool-failure-corrective-context.mjs` (measured 2026-09-02 @
|
|
9
|
+
* a019d5a4 via `rg -n "function atomicMutateJson" hooks/` — 4 hits, one per
|
|
10
|
+
* file, logic byte-identical modulo the per-file tmp-suffix and the
|
|
11
|
+
* `fs.`-namespace-vs-named-import style `on-session-end.mjs` uses).
|
|
12
|
+
*
|
|
13
|
+
* F-H fix (W3-reviewer finding, #1197): the pre-extraction copies treated
|
|
14
|
+
* EVERY read/parse failure as "file absent" and silently fell back to
|
|
15
|
+
* `defaultValue` — so an unparsable file, `EISDIR`, `EACCES`, or a file
|
|
16
|
+
* truncated mid-write by a concurrent writer was overwritten with
|
|
17
|
+
* `defaultValue`-derived content instead of being left alone. Only `ENOENT`
|
|
18
|
+
* (file genuinely does not exist yet) is a legitimate "start fresh" case;
|
|
19
|
+
* every other failure now aborts BEFORE the tmp-write/rename stage and
|
|
20
|
+
* reports `{ ok: false, reason }` — the original file is never touched.
|
|
21
|
+
*
|
|
22
|
+
* Return-object over throw (deliberate — see #1197 task note): all four
|
|
23
|
+
* callers already run under `main().catch(() => {}).finally(() =>
|
|
24
|
+
* process.exit(0))` — informational, never-deny hooks (verified 2026-09-02:
|
|
25
|
+
* none of the four match a deny/block pattern) — so a thrown error would be
|
|
26
|
+
* swallowed safely too. But in `post-tool-batch-wave-signal.mjs` a throw at
|
|
27
|
+
* the FIRST call site (line ~278, the `last_batch` write) would abort
|
|
28
|
+
* `main()` before it reaches the independent heartbeat-refresh block that
|
|
29
|
+
* follows — a real behavioural loss the thrown-error path would introduce
|
|
30
|
+
* silently. A result object lets every caller decide locally whether an RMW
|
|
31
|
+
* failure should short-circuit the rest of `main()` or just get logged and
|
|
32
|
+
* ignored, so no caller loses unrelated post-call behaviour by construction.
|
|
33
|
+
*
|
|
34
|
+
* NAMED CEILING (BV-004): tmp-file + rename makes each individual write
|
|
35
|
+
* atomic, but not the full read-modify-write — two concurrent callers can
|
|
36
|
+
* still interleave (both read the same `current`, both compute an update
|
|
37
|
+
* from it, the second rename wins and silently drops the first mutation).
|
|
38
|
+
* This module does not defend against that race. Revisit with a per-file
|
|
39
|
+
* lock (e.g. an flock-style sidecar or `session-lock.mjs`'s lease pattern)
|
|
40
|
+
* if `.orchestrator/current-session.json` writes start dropping fields
|
|
41
|
+
* under concurrent-session load — no such loss has been measured yet.
|
|
42
|
+
*
|
|
43
|
+
* @module hooks/_lib/atomic-json
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Atomic read-modify-write of a JSON file via temp-file + rename.
|
|
51
|
+
*
|
|
52
|
+
* Reads the existing file and parses it as JSON. When the file does not
|
|
53
|
+
* exist (`ENOENT`), starts from `defaultValue` — the only case in which
|
|
54
|
+
* "file absent" is legitimate. Any OTHER read or parse failure (directory
|
|
55
|
+
* at `filePath`, permission denied, truncated/corrupt JSON, …) aborts
|
|
56
|
+
* WITHOUT calling `mutate` and WITHOUT writing anything — the original file
|
|
57
|
+
* (if any) is left exactly as it was.
|
|
58
|
+
*
|
|
59
|
+
* On success, applies the synchronous `mutate` transformer, writes the
|
|
60
|
+
* result to a `${filePath}.tmp-<suffix>-<pid>-<ts>` sibling, then renames it
|
|
61
|
+
* over `filePath` (atomic on POSIX same-filesystem rename; best-effort on
|
|
62
|
+
* Windows). If the write/rename stage itself fails, the tmp file is
|
|
63
|
+
* best-effort unlinked so a failure never leaves an orphaned `.tmp-*`
|
|
64
|
+
* artifact behind.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} filePath — absolute path to the JSON file.
|
|
67
|
+
* @param {object} defaultValue — starting value used ONLY when the file does
|
|
68
|
+
* not exist yet (`ENOENT`). Never applied on top of an unreadable-but-
|
|
69
|
+
* present file.
|
|
70
|
+
* @param {function(object): object} mutate — pure synchronous transformer;
|
|
71
|
+
* receives the parsed current value (or `defaultValue`), returns the next
|
|
72
|
+
* value to persist.
|
|
73
|
+
* @param {string} [tmpTag] — short tag folded into the tmp filename so
|
|
74
|
+
* concurrent callers targeting the same `filePath` from different hooks
|
|
75
|
+
* don't collide on the same tmp path (mirrors the per-caller suffixes the
|
|
76
|
+
* four pre-extraction copies used: `-cwd-`, `-ose-`, `-ptb-`, `-ptf-`).
|
|
77
|
+
* @returns {Promise<{ ok: true, value: object } | { ok: false, reason: string }>}
|
|
78
|
+
*/
|
|
79
|
+
export async function atomicMutateJson(filePath, defaultValue, mutate, tmpTag = 'ajs') {
|
|
80
|
+
let current = defaultValue;
|
|
81
|
+
try {
|
|
82
|
+
const raw = await readFile(filePath, 'utf8');
|
|
83
|
+
current = JSON.parse(raw);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err && err.code === 'ENOENT') {
|
|
86
|
+
// File genuinely does not exist yet — the only legitimate "fresh
|
|
87
|
+
// file" case. `current` already holds `defaultValue`.
|
|
88
|
+
} else {
|
|
89
|
+
// EISDIR, EACCES, a JSON.parse SyntaxError (unparsable/truncated
|
|
90
|
+
// content), or anything else — never silently treat as "absent".
|
|
91
|
+
// Abort before mutate/write; the original file is untouched.
|
|
92
|
+
return { ok: false, reason: (err && err.code) || 'parse-error' };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const updated = mutate(current);
|
|
97
|
+
const tmp = `${filePath}.tmp-${tmpTag}-${process.pid}-${Date.now()}`;
|
|
98
|
+
try {
|
|
99
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
100
|
+
await writeFile(tmp, JSON.stringify(updated, null, 2) + '\n', 'utf8');
|
|
101
|
+
await rename(tmp, filePath);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
try {
|
|
104
|
+
await unlink(tmp);
|
|
105
|
+
} catch {
|
|
106
|
+
// tmp was never created, or is already gone — nothing to clean up.
|
|
107
|
+
}
|
|
108
|
+
return { ok: false, reason: (err && err.code) || 'write-error' };
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, value: updated };
|
|
111
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-paths.mjs — consolidated subagent sidecar-path derivation (#1196).
|
|
3
|
+
*
|
|
4
|
+
* Four call sites independently derived the SAME shape — the stopping
|
|
5
|
+
* subagent's own `agent-<id>.jsonl` transcript + `agent-<id>.meta.json`
|
|
6
|
+
* sidecar, from the PARENT transcript path the harness sends on stdin — and
|
|
7
|
+
* diverged on strictness. Measured 2026-09-02 @ a019d5a4 (this consolidation):
|
|
8
|
+
*
|
|
9
|
+
* | copy (file:line) | honours `agent_transcript_path` | id bound | rejects `'unknown'` | containment check |
|
|
10
|
+
* |------------------------------------------------------------|:---:|:---:|:---:|:---:|
|
|
11
|
+
* | hooks/subagent-telemetry.mjs:513 `resolveSubagentTranscriptPath` | no | unbounded (`+`) | yes (:517) | n/a (no override) |
|
|
12
|
+
* | hooks/on-stop.mjs:666 `resolveSidecarBase` | no | `{1,64}` (:566) | **no** | n/a (no override) |
|
|
13
|
+
* | hooks/post-subagent-discovery-validator.mjs:538 `resolveAgentTranscriptPath` | yes (:539) | `{1,64}` (:547) | yes (:549) | **no** — `explicit` returned unchecked (:539) |
|
|
14
|
+
* | scripts/lib/wave-transcript-tail.mjs:610 `readAgentType` | no | **none** | no | n/a (no override) |
|
|
15
|
+
*
|
|
16
|
+
* `resolveSubagentSidecar()` below applies the STRICTEST rule found in ANY
|
|
17
|
+
* copy to every caller: the `{1,64}` bound, the `'unknown'` rejection (closes
|
|
18
|
+
* the on-stop.mjs / wave-transcript-tail.mjs gap), AND a path-containment
|
|
19
|
+
* check on the `agentTranscriptPath` override that NONE of the four copies
|
|
20
|
+
* performed — post-subagent-discovery-validator.mjs returned the harness
|
|
21
|
+
* override completely unvalidated. That containment check is a NEW rule this
|
|
22
|
+
* consolidation introduces, not a preserved behaviour.
|
|
23
|
+
*
|
|
24
|
+
* @module hooks/_lib/subagent-paths
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Real agent ids are hex-ish tokens (e.g. `a60348a01ca982b4c`); anything else
|
|
31
|
+
* is rejected, not sanitised, so no payload value can traverse out of the
|
|
32
|
+
* `subagents/` directory. `{1,64}` is the strictest bound found across the
|
|
33
|
+
* four prior copies (hooks/subagent-telemetry.mjs used an unbounded `+`;
|
|
34
|
+
* scripts/lib/wave-transcript-tail.mjs applied no charset check at all).
|
|
35
|
+
*/
|
|
36
|
+
export const AGENT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate a candidate agent id against the strictest rule of the four prior
|
|
40
|
+
* copies: the `{1,64}` charset+length bound, AND explicit rejection of the
|
|
41
|
+
* literal `'unknown'` — the no-usable-id fallback value that several callers
|
|
42
|
+
* (`firstNonEmptyString(..., 'unknown')`) substitute when the harness sends no
|
|
43
|
+
* id at all. `'unknown'` matches `AGENT_ID_RE` (it's plain lowercase letters),
|
|
44
|
+
* so a charset check alone does not catch it — hooks/on-stop.mjs's
|
|
45
|
+
* `resolveSidecarBase()` had exactly this gap (AGENT_ID_RE-only, no explicit
|
|
46
|
+
* `'unknown'` rejection) before this consolidation.
|
|
47
|
+
*
|
|
48
|
+
* @param {unknown} agentId
|
|
49
|
+
* @returns {agentId is string}
|
|
50
|
+
*/
|
|
51
|
+
export function isValidAgentId(agentId) {
|
|
52
|
+
return typeof agentId === 'string' && AGENT_ID_RE.test(agentId) && agentId !== 'unknown';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the sidecar pair (`.jsonl` transcript + `.meta.json`) for a stopping
|
|
57
|
+
* subagent (#949, #1190, #1191, #1196).
|
|
58
|
+
*
|
|
59
|
+
* Two paths to the same result:
|
|
60
|
+
* - **Override.** `agentTranscriptPath` (the harness's `agent_transcript_path`,
|
|
61
|
+
* honoured today only by hooks/post-subagent-discovery-validator.mjs:539)
|
|
62
|
+
* wins when present — but ONLY when, after `path.resolve`, it sits inside
|
|
63
|
+
* `dirname(transcriptPath)`. An override outside that tree is REJECTED
|
|
64
|
+
* (returns null), never silently accepted and never falls through to
|
|
65
|
+
* derivation — a bad override must not resolve to an unrelated agent's
|
|
66
|
+
* sidecar.
|
|
67
|
+
* - **Derivation.** `<dir>/<base>/subagents/agent-<agentId>` from the PARENT
|
|
68
|
+
* transcript path — the shape all four prior copies agree on
|
|
69
|
+
* (subagent-telemetry.mjs:513, on-stop.mjs:666,
|
|
70
|
+
* post-subagent-discovery-validator.mjs:552-553, and the subagentsDir
|
|
71
|
+
* scripts/lib/wave-transcript-tail.mjs's tailLoop() already builds by hand).
|
|
72
|
+
*
|
|
73
|
+
* Returns null — never a guessed or partial path — on ANY invalid input: an
|
|
74
|
+
* empty/non-string `transcriptPath`, an `agentId` failing `isValidAgentId()`
|
|
75
|
+
* (only checked in the derivation branch — the override branch does not need
|
|
76
|
+
* a valid agentId, matching all four prior copies' precedence), or an override
|
|
77
|
+
* that fails containment.
|
|
78
|
+
*
|
|
79
|
+
* NAMED CEILING (BV-004): the meta path is always derived as
|
|
80
|
+
* `transcript.replace(/\.jsonl$/i, '.meta.json')` (or `${transcript}.meta.json`
|
|
81
|
+
* when the transcript has no `.jsonl` suffix to replace — an override the
|
|
82
|
+
* harness contract says never happens; see the header comments of
|
|
83
|
+
* hooks/post-subagent-discovery-validator.mjs). REVISIT TRIGGER: if a real
|
|
84
|
+
* override without a `.jsonl` suffix is ever observed, this needs its own
|
|
85
|
+
* validation branch instead of the current always-well-formed assumption.
|
|
86
|
+
*
|
|
87
|
+
* NAMED CEILING (BV-004, LOW, qa review): the override's containment check
|
|
88
|
+
* (`path.relative(resolvedDir, resolvedOverride)` below) is LEXICAL, not
|
|
89
|
+
* filesystem-real — it runs on `path.resolve()`'d strings, never on
|
|
90
|
+
* `fs.realpathSync()`'d ones. A symlink SITTING INSIDE `dirname(transcriptPath)`
|
|
91
|
+
* whose TARGET points outside that tree therefore passes containment: the
|
|
92
|
+
* lexical path looks contained, the file it actually resolves to is not.
|
|
93
|
+
* Deliberately not fixed with a realpath call here — every current consumer
|
|
94
|
+
* of this override branch (hooks/post-subagent-discovery-validator.mjs) only
|
|
95
|
+
* READS the resulting sidecar files, and `agentTranscriptPath` is a value the
|
|
96
|
+
* HARNESS itself supplies on stdin, not one an untrusted external actor can
|
|
97
|
+
* inject — so the realpath syscalls would buy nothing against the live
|
|
98
|
+
* threat model. REVISIT TRIGGER: if `agentTranscriptPath` (or any future
|
|
99
|
+
* override this module accepts) is ever sourced from a producer this repo
|
|
100
|
+
* does NOT trust as much as the harness's own stdin payload, resolve both
|
|
101
|
+
* `resolvedOverride` and `resolvedDir` through `fs.realpathSync()` before the
|
|
102
|
+
* `path.relative()` comparison, so a symlink cannot smuggle the effective
|
|
103
|
+
* path outside the allowed tree.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} args
|
|
106
|
+
* @param {string|undefined|null} args.transcriptPath — parent transcript path
|
|
107
|
+
* (stdin `transcript_path`, or an equivalent parent-transcript path)
|
|
108
|
+
* @param {string|undefined|null} args.agentId
|
|
109
|
+
* @param {string|undefined|null} [args.agentTranscriptPath] — harness override
|
|
110
|
+
* (`agent_transcript_path`); takes precedence over derivation when present
|
|
111
|
+
* and contained under `dirname(transcriptPath)`
|
|
112
|
+
* @returns {{base: string, transcript: string, meta: string}|null}
|
|
113
|
+
*/
|
|
114
|
+
export function resolveSubagentSidecar({ transcriptPath, agentId, agentTranscriptPath = null }) {
|
|
115
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) return null;
|
|
116
|
+
|
|
117
|
+
const dir = path.dirname(transcriptPath);
|
|
118
|
+
const base = path.basename(transcriptPath).replace(/\.jsonl$/i, '');
|
|
119
|
+
if (!base || base === '.' || base === '..') return null;
|
|
120
|
+
|
|
121
|
+
let transcript;
|
|
122
|
+
if (typeof agentTranscriptPath === 'string' && agentTranscriptPath.trim()) {
|
|
123
|
+
const resolvedOverride = path.resolve(agentTranscriptPath);
|
|
124
|
+
const resolvedDir = path.resolve(dir);
|
|
125
|
+
const rel = path.relative(resolvedDir, resolvedOverride);
|
|
126
|
+
const contained = rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
127
|
+
if (!contained) return null;
|
|
128
|
+
transcript = resolvedOverride;
|
|
129
|
+
} else {
|
|
130
|
+
if (!isValidAgentId(agentId)) return null;
|
|
131
|
+
transcript = path.join(dir, base, 'subagents', `agent-${agentId}.jsonl`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const meta = /\.jsonl$/i.test(transcript)
|
|
135
|
+
? transcript.replace(/\.jsonl$/i, '.meta.json')
|
|
136
|
+
: `${transcript}.meta.json`;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
base: transcript.replace(/\.jsonl$/i, ''),
|
|
140
|
+
transcript,
|
|
141
|
+
meta,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
* Exit codes: 0 always (informational, never blocking).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
|
|
28
27
|
import path from 'node:path';
|
|
29
28
|
|
|
30
29
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
@@ -32,6 +31,7 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
|
32
31
|
if (!shouldRunHook('cwd-change-restore')) process.exit(0);
|
|
33
32
|
|
|
34
33
|
import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
|
|
34
|
+
import { atomicMutateJson } from './_lib/atomic-json.mjs';
|
|
35
35
|
|
|
36
36
|
// ---------------------------------------------------------------------------
|
|
37
37
|
// Constants
|
|
@@ -71,32 +71,6 @@ function readStdinJson() {
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
75
|
-
* Atomic read-modify-write of a JSON file via temp + rename.
|
|
76
|
-
* Reads the existing file (or starts with `defaultValue` when absent),
|
|
77
|
-
* applies `mutate`, writes to a tmp file, then renames over the original.
|
|
78
|
-
* Atomic on POSIX (same-filesystem rename). Best-effort on Windows.
|
|
79
|
-
*
|
|
80
|
-
* @param {string} filePath
|
|
81
|
-
* @param {object} defaultValue — used when the file does not exist or is unparseable
|
|
82
|
-
* @param {function(object): object} mutate — synchronous pure transformer
|
|
83
|
-
*/
|
|
84
|
-
async function atomicMutateJson(filePath, defaultValue, mutate) {
|
|
85
|
-
let current = defaultValue;
|
|
86
|
-
try {
|
|
87
|
-
const raw = await readFile(filePath, 'utf8');
|
|
88
|
-
current = JSON.parse(raw);
|
|
89
|
-
} catch {
|
|
90
|
-
// File absent or unparseable — start from defaultValue.
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const updated = mutate(current);
|
|
94
|
-
const tmp = `${filePath}.tmp-cwd-${process.pid}-${Date.now()}`;
|
|
95
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
96
|
-
await writeFile(tmp, JSON.stringify(updated, null, 2) + '\n', 'utf8');
|
|
97
|
-
await rename(tmp, filePath);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
74
|
// ---------------------------------------------------------------------------
|
|
101
75
|
// Main
|
|
102
76
|
// ---------------------------------------------------------------------------
|
|
@@ -117,14 +91,20 @@ async function main() {
|
|
|
117
91
|
|
|
118
92
|
const sessionFile = path.join(SO_PROJECT_DIR, '.orchestrator', 'current-session.json');
|
|
119
93
|
|
|
120
|
-
await atomicMutateJson(sessionFile, {}, (current) => {
|
|
94
|
+
const result = await atomicMutateJson(sessionFile, {}, (current) => {
|
|
121
95
|
const existing = Array.isArray(current.cwd_changes)
|
|
122
96
|
? current.cwd_changes
|
|
123
97
|
: [];
|
|
124
98
|
// Append the new record and cap at MAX_ENTRIES (keep most-recent).
|
|
125
99
|
const updated = [...existing, record].slice(-MAX_ENTRIES);
|
|
126
100
|
return { ...current, cwd_changes: updated };
|
|
127
|
-
});
|
|
101
|
+
}, 'cwd');
|
|
102
|
+
// Nothing else depends on this write — a non-ENOENT read/parse failure
|
|
103
|
+
// just means the record is dropped this turn. Diagnostic only (stderr),
|
|
104
|
+
// matches the hook's own "never blocking" contract.
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
console.error(`cwd-change-restore: atomicMutateJson skipped write (${result.reason})`);
|
|
107
|
+
}
|
|
128
108
|
}
|
|
129
109
|
|
|
130
110
|
// Exit 0 always — informational hook must never block Claude.
|