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.
Files changed (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  5. package/CHANGELOG.md +278 -0
  6. package/README.md +16 -14
  7. package/agents/db-specialist.md +0 -1
  8. package/docs/ci-setup.md +180 -25
  9. package/docs/codex-setup.md +1 -1
  10. package/docs/components.md +3 -3
  11. package/docs/events-schema.md +46 -8
  12. package/docs/scope-collision-guard.md +4 -4
  13. package/docs/session-config-reference.md +64 -6
  14. package/docs/session-config-template.md +34 -4
  15. package/docs/telemetry/telemetry-claims.md +11 -10
  16. package/docs/telemetry.md +30 -1
  17. package/hooks/_lib/atomic-json.mjs +111 -0
  18. package/hooks/_lib/subagent-paths.mjs +143 -0
  19. package/hooks/cwd-change-restore.mjs +9 -29
  20. package/hooks/enforce-scope.mjs +35 -6
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +278 -12
  24. package/hooks/on-session-start.mjs +50 -2
  25. package/hooks/on-stop.mjs +349 -20
  26. package/hooks/post-bash-write-verify.mjs +104 -4
  27. package/hooks/post-subagent-discovery-validator.mjs +148 -18
  28. package/hooks/post-tool-batch-wave-signal.mjs +154 -40
  29. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  30. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  31. package/hooks/subagent-telemetry.mjs +11 -26
  32. package/package.json +1 -1
  33. package/scripts/autopilot.mjs +26 -12
  34. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  35. package/scripts/emit-event.mjs +10 -2
  36. package/scripts/lib/auq/parse.mjs +5 -29
  37. package/scripts/lib/auto-dialectic.mjs +68 -0
  38. package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
  39. package/scripts/lib/build-live-signals.mjs +25 -22
  40. package/scripts/lib/cold-start-detector.mjs +23 -14
  41. package/scripts/lib/config/block-header.mjs +55 -0
  42. package/scripts/lib/config/discovery-validator.mjs +7 -2
  43. package/scripts/lib/config/health-endpoints.mjs +383 -0
  44. package/scripts/lib/config/remote-hosts.mjs +233 -0
  45. package/scripts/lib/config.mjs +31 -3
  46. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  47. package/scripts/lib/events-schema.mjs +48 -0
  48. package/scripts/lib/events.mjs +238 -5
  49. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  50. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  51. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  52. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  53. package/scripts/lib/memory-banner.mjs +20 -8
  54. package/scripts/lib/peer-discovery.mjs +20 -2
  55. package/scripts/lib/reconcile/engine.mjs +236 -5
  56. package/scripts/lib/scope-gate.mjs +36 -0
  57. package/scripts/lib/session-close-backfill.mjs +59 -10
  58. package/scripts/lib/session-discovery.mjs +57 -3
  59. package/scripts/lib/session-end/phase-skip.mjs +2 -2
  60. package/scripts/lib/session-identity/own-session.mjs +62 -1
  61. package/scripts/lib/session-transition.mjs +1 -1
  62. package/scripts/lib/sessions-canonical.mjs +446 -0
  63. package/scripts/lib/telemetry/schema.mjs +74 -8
  64. package/scripts/lib/telemetry/sync.mjs +49 -12
  65. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  66. package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
  67. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  68. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  69. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  70. package/scripts/lib/validate/check-unwired-features.mjs +0 -7
  71. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  72. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  73. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  74. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  75. package/scripts/lib/vault-status/board-writer.mjs +174 -135
  76. package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
  77. package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
  78. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  79. package/scripts/lib/wave-resource-gate.mjs +127 -7
  80. package/scripts/lib/wave-transcript-tail.mjs +24 -4
  81. package/scripts/materialize-wave-scope.mjs +20 -4
  82. package/scripts/memory-propose.mjs +132 -8
  83. package/scripts/promote-vault-strict.mjs +4 -15
  84. package/scripts/site-numbers.mjs +36 -4
  85. package/scripts/validate-plugin.mjs +26 -0
  86. package/scripts/vault-consolidate.mjs +3 -11
  87. package/scripts/vault-integration-watcher.mjs +2 -4
  88. package/scripts/vault-mirror.mjs +111 -26
  89. package/skills/_shared/parallel-aware-auq.md +31 -2
  90. package/skills/_shared/parallel-aware-preamble.md +17 -4
  91. package/skills/_shared/state-ownership.md +1 -1
  92. package/skills/contract-version-bump/SKILL.md +1 -1
  93. package/skills/ecosystem-health/SKILL.md +4 -1
  94. package/skills/ecosystem-health/wizard.md +5 -0
  95. package/skills/evolve/SKILL.md +38 -1
  96. package/skills/journey-audit/SKILL.md +6 -5
  97. package/skills/reconcile/SKILL.md +5 -2
  98. package/skills/remote-offload/SKILL.md +89 -0
  99. package/skills/session-end/phase-3-6-tail.md +9 -6
  100. package/skills/session-start/SKILL.md +26 -3
  101. package/skills/wave-executor/SKILL.md +1 -1
  102. 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 account-switch routing SSOT, ADR-002 / `tools/routing/routing.yaml`. Working defaults are `composer-2.5` for foreign impl and `cursor-grok-4.6-high` for review / test-writing / judgment roles.
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 ON (flip risk is near-zero; the hook only ever generates telemetry).
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 # on by default; log+warn-only, exit-0-always — set false to silence
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 | `true` | Master toggle. When `false`, 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` flips it) — the `true` default applies when the block is absent entirely. Always set `enabled` explicitly when adding this block. |
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. ON by default (flip risk is near-zero; generates real telemetry). Issue #567.
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 # on by default; log+warn-only, exit-0-always set false to silence
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 "10,000+ tests" badge
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
- "10,000+ vitest tests run on every commit" line count **this repository's own test
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
- | 10,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-06 |
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 -> 475
75
- grep -rohE '\b(it|test)\(' tests | wc -l # static test defs -> ~9,871
76
- grep -rohE '\b(it|test|describe)\.each\b' tests | wc -l # parameterized blocks -> 93
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 (~9,871 across 475 files) is a **floor**: the 93
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 10,000+**. Unlike the
82
- private-corpus figures above, this one is fully auditable run `npm test` in this
83
- checkout and read vitest's summary line.
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[]` | Same filtering rule as `skills[]`. |
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.