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
package/docs/ci-setup.md CHANGED
@@ -28,11 +28,92 @@ token or PAT stored as the masked CI variable `SCHEMA_DRIFT_TOKEN`.
28
28
  > the baseline gains a public mirror — then set the token (Option A below)
29
29
  > and flip `SCHEMA_DRIFT_OPTIONAL` at both sites.
30
30
 
31
+ ### Activation status (measured 2026-09-02)
32
+
33
+ **Update, same day:** the token below was **revoked** — an unused credential is a
34
+ liability per SEC-005's secrets-lifecycle discipline, and leaving a live,
35
+ never-set-as-a-CI-variable token sitting in `infrastructure/projects-baseline`
36
+ served no purpose once the control run below had already answered the
37
+ question it was minted for. Re-minting it (same `glab api --method POST … --input -`
38
+ recipe as Option A step 1) is now **Step 0** of the re-activation sequence
39
+ below, not an assumed-still-valid token.
40
+
41
+ A Project Access Token was provisioned today, scoped exactly as Option A
42
+ below recommends:
43
+
44
+ - **Name:** `session-orchestrator-ci-schema-drift`
45
+ - **Project:** `infrastructure/projects-baseline` (id 52) — the TARGET repo,
46
+ not this one
47
+ - **Scopes:** `read_repository`
48
+ - **Access level:** Reporter (20)
49
+ - **Expires:** 2027-09-01
50
+
51
+ The masked `SCHEMA_DRIFT_TOKEN` CI variable on this project (id 74) was set
52
+ with that token, then **removed again**. A control run with the token set
53
+ confirmed the clone step authenticates correctly — but the drift check itself
54
+ then failed for a real, already-known reason:
55
+ `skills/vault-sync/validator.mjs`'s `vaultNoteTypeSchema` enum carries
56
+ `peer-card` and `board`, and the canonical `infrastructure/projects-baseline`
57
+ source does not have either yet. This is the documented vendor-ahead state
58
+ (`scripts/sync-vault-schema.mjs` header, "Vendor-ahead state (2026-05-23,
59
+ #503, I5)") and tracked as upstream-sync-debt in issue #531 (#503 itself is
60
+ closed). With the variable set and `SCHEMA_DRIFT_OPTIONAL` still `"true"`,
61
+ this exit-1 `DRIFT` is a **hard** failure — it is not in
62
+ `allow_failure.exit_codes: [3]` — so leaving the variable set today would turn
63
+ the next push red for a fact already tracked in #531, not for a new defect.
64
+ The variable was removed rather than left set; activation stays blocked until
65
+ the canonical enum gains both values.
66
+
67
+ **Re-activation sequence once #531 lands upstream:**
68
+
69
+ 0. **Re-mint the token** — it was revoked (see § Activation status above). Run
70
+ the same `glab api --method POST … --input -` recipe as Option A step 1,
71
+ against the TARGET project (id 52), and copy the response's `token` field
72
+ immediately — it is shown exactly once.
73
+ 1. `read -rs TOKEN` at the prompt (no echo), then pipe it into `glab variable
74
+ set` rather than passing it as a `--value` argument — a value passed on the
75
+ command line is visible to any other process on the host via `ps`, while
76
+ stdin is not:
77
+
78
+ ```bash
79
+ read -rs TOKEN
80
+ printf '%s' "$TOKEN" | glab variable set SCHEMA_DRIFT_TOKEN \
81
+ -R infrastructure/session-orchestrator --masked
82
+ ```
83
+
84
+ **Not** `--protected`: `.gate-rules` (`.gitlab-ci.yml:74`) runs the job
85
+ on every branch and every MR pipeline, and a protected-only variable would
86
+ silently reproduce the exit-5 `UNAVAILABLE` failure on every unprotected
87
+ branch. (`glab variable set --help` documents stdin piping directly —
88
+ `cat file.txt | glab variable set SERVER_TOKEN` — but no `-`/dash value
89
+ for `--value`; the flag only accepts a literal string, so omitting it
90
+ entirely and piping the value is the only way to keep the token off argv.)
91
+ 2. Push an ordinary commit and read the `schema-drift-check` job log for
92
+ `RESULT: IN-SYNC` — and confirm the job DURATION is well over 20 seconds
93
+ (see the pipeline-6815 warning above). A fast "success" is the exit-3
94
+ soft-skip in disguise, not a real run.
95
+ 3. Flip `SCHEMA_DRIFT_OPTIONAL` to `"false"` at **both** sites —
96
+ `schema-drift-check` and `pipeline-gate` — in one commit.
97
+ `tests/ci/schema-drift-check.test.mjs` already asserts the two values are
98
+ equal, so no test edit is needed to enforce the flip.
99
+ 4. Local counter-probe before trusting the pipeline: clone
100
+ `infrastructure/projects-baseline` with the token, make a throwaway copy of
101
+ `packages/zod-schemas/src/vault-frontmatter.ts` with one field
102
+ deliberately edited, then run
103
+ `node scripts/sync-vault-schema.mjs --check --canonical <path-to-edited-copy>`
104
+ — expect exit 1 with a diff naming the edited field. That confirms the
105
+ check diffs real content rather than passing on a broken comparison.
106
+
107
+ Per `.claude/rules/security.md` § SEC-005, this token's lifecycle belongs in
108
+ `.claude/docs/SECRETS-INVENTORY.md` once one exists — that file is not present
109
+ in this repo (measured 2026-09-02: no `.claude/docs/` directory tracked), so
110
+ the inventory is not adopted here and this section remains the sole record.
111
+
31
112
  ### Required CI variable
32
113
 
33
114
  | Variable | Type | Mask | Protect | Value |
34
115
  |---|---|---|---|---|
35
- | `SCHEMA_DRIFT_TOKEN` | Variable | Yes | Optional | deploy token or PAT (see below) |
116
+ | `SCHEMA_DRIFT_TOKEN` | Variable | Yes | No | Project Access Token or PAT see Option A/B below |
36
117
 
37
118
  If `SCHEMA_DRIFT_TOKEN` is **not set**, the job prints a `NOT VERIFIED` notice
38
119
  and exits **3** — which `allow_failure.exit_codes` renders as an amber *warning*,
@@ -76,6 +157,18 @@ a schema diff that does not exist. Only 3 is listed in
76
157
  outcome also prints its own `[schema-drift] RESULT: <STATE>` line, so the job log
77
158
  answers "what happened" without the reader having to know this table.
78
159
 
160
+ **Caveat — a second, narrower exit-3 collision (do not change the YAML for
161
+ it).** `scripts/sync-vault-schema.mjs` has its own exit 3, for a different
162
+ condition: malformed sentinel comments in `validator.mjs` (only one of
163
+ `begin`/`end` present). If `--check` ever hit that branch, it would return
164
+ exit 3 from the tool itself — and `allow_failure.exit_codes: [3]` reads the
165
+ shell's final exit code, not which tool produced it, so a genuine tooling
166
+ defect (broken sentinels) would render as the same amber "no token, declared
167
+ optional" warning that the missing-token guard produces. This is a caveat to
168
+ note, not a blocker: the sentinels are intact today, and the fix — if it is
169
+ ever needed — is giving `sync-vault-schema.mjs`'s malformed-sentinel case a
170
+ distinct exit code, not a change here.
171
+
79
172
  **After completing the token setup below, change `SCHEMA_DRIFT_OPTIONAL` to
80
173
  `"false"` in `.gitlab-ci.yml`** — in **both** places: the `schema-drift-check`
81
174
  job and `pipeline-gate`. One flag, two enforcement points;
@@ -98,35 +191,93 @@ not the accidental side effect of an unset CI variable.
98
191
  > exactly this failure — pipeline 6815 reported SUCCESS in 17 s having checked
99
192
  > nothing (issue #933).
100
193
 
101
- ### Option A — Deploy Token (recommended, least-privilege)
102
-
103
- 1. Open `infrastructure/projects-baseline` on your GitLab instance.
104
- 2. Go to **Settings Repository Deploy tokens**.
105
- 3. Click **Add token**:
106
- - **Name:** `session-orchestrator-ci-schema-drift`
107
- - **Expires at:** set a reminder (e.g. 1 year); rotate before expiry
108
- - **Scopes:** check `read_repository` only
109
- 4. Copy the generated token value (shown once).
110
- 5. Open `session-orchestrator` on GitLab.
111
- 6. Go to **Settings CI/CD Variables Add variable**:
112
- - **Key:** `SCHEMA_DRIFT_TOKEN`
113
- - **Value:** paste the deploy token
114
- - **Type:** Variable
115
- - **Masked:** Yes
116
- - **Protected:** Optional (enable if you only need it on protected branches)
117
- 7. Save.
194
+ ### Option A — Project Access Token (recommended — works with the current clone URL)
195
+
196
+ GitLab resolves a **deploy token** by its own fixed username
197
+ (`gitlab+deploy-token-<n>`, or a custom username if one was set at creation).
198
+ The job's clone step hardcodes the login as `oauth2:${SCHEMA_DRIFT_TOKEN}`
199
+ (`.gitlab-ci.yml` ~:652) `oauth2` is the username GitLab expects for a
200
+ Personal or Project Access Token, not for a deploy token. A deploy token's
201
+ value paired with that hardcoded username fails authentication at clone time
202
+ and surfaces as exit 5 `UNAVAILABLE`, which reads as a network/credential
203
+ problem rather than "wrong username" (see the demoted Deploy Token option
204
+ below). Tokens with **PAT semantics** GitLab accepts any username alongside
205
+ the token value — authenticate correctly with this clone URL: a Personal
206
+ Access Token, or, least-privilege, a **Project Access Token** scoped to the
207
+ TARGET project (`infrastructure/projects-baseline`). A Project Access Token
208
+ is preferred over a personal PAT for the same reason the deploy token used to
209
+ be recommended: it belongs to the project, not a person, and survives staff
210
+ changes.
211
+
212
+ 1. Create the token via API against the TARGET project (id 52) — GitLab has
213
+ no path to create a Project Access Token FOR a project from outside that
214
+ project's own Settings UI, so use `glab api`:
215
+
216
+ ```bash
217
+ glab api --hostname "$GITLAB_HOST" -X POST "projects/52/access_tokens" \
218
+ -H 'Content-Type: application/json' --input - <<'JSON'
219
+ {"name":"session-orchestrator-ci-schema-drift","scopes":["read_repository"],"access_level":20,"expires_at":"2027-09-01"}
220
+ JSON
221
+ ```
222
+
223
+ `--input -` plus the explicit `Content-Type: application/json` header is
224
+ required because the payload has a nested type (`scopes` is a JSON array),
225
+ which `glab api`'s `-f`/`-F` flag form cannot express. `access_level: 20`
226
+ is Reporter — the lowest access level that can read repository content.
227
+ `expires_at` is an operator choice, not a fixed value; the token created
228
+ for this document's own dry run (2026-09-02) was set 1 year out
229
+ (`2027-09-01`) — rotate before expiry.
230
+
231
+ 2. The response's `token` field holds the token value and is **shown exactly
232
+ once** — copy it immediately; GitLab will not display it again.
233
+
234
+ 3. Store it in `session-orchestrator` CI/CD variables as `SCHEMA_DRIFT_TOKEN`.
235
+ Prefer stdin over `--value` — a value passed as a command-line argument is
236
+ visible to other processes on the host (`ps`), while stdin is not:
237
+
238
+ ```bash
239
+ read -rs TOKEN
240
+ printf '%s' "$TOKEN" | glab variable set SCHEMA_DRIFT_TOKEN \
241
+ -R infrastructure/session-orchestrator --masked
242
+ ```
243
+
244
+ **Masked:** Yes. **Not** `--protected` — `.gate-rules` (`.gitlab-ci.yml:74`)
245
+ runs the job on every branch and every MR pipeline, so a protected-only
246
+ variable would silently be absent everywhere the job actually needs it.
118
247
 
119
248
  ### Option B — Personal Access Token (fallback)
120
249
 
121
- Use this if a deploy token is not available for the target project.
250
+ The same PAT-semantics reasoning from Option A applies: a personal PAT
251
+ authenticates under any username, so it works with the hardcoded `oauth2:`
252
+ clone login. Use this only if you cannot create a Project Access Token on
253
+ `infrastructure/projects-baseline` (e.g. you lack Owner/Maintainer there).
122
254
 
123
255
  1. Go to your GitLab profile → **Access Tokens**.
124
256
  2. Create a token with scope `read_repository` and a reasonable expiry.
125
257
  3. Store it in `session-orchestrator` CI/CD variables as `SCHEMA_DRIFT_TOKEN`
126
- (Masked: Yes)same steps 5–7 above.
127
-
128
- Note: a PAT is scoped to the creating user's access; prefer a deploy token so
129
- the CI credential survives staff changes.
258
+ (Masked: Yes, **not** Protected see Option A step 3 above).
259
+
260
+ A personal PAT is tied to the creating user's account and access; prefer the
261
+ Project Access Token in Option A so the CI credential survives staff changes.
262
+
263
+ ### Deploy Token — does not work with the current clone URL
264
+
265
+ This was the previously recommended option; it is demoted here because, as
266
+ the job is written today, it does not authenticate. GitLab deploy tokens
267
+ authenticate under their OWN username (`gitlab+deploy-token-<n>`, or a custom
268
+ username set at creation) — never as `oauth2`. The job's clone step hardcodes
269
+ `oauth2:${SCHEMA_DRIFT_TOKEN}` (`.gitlab-ci.yml` ~:652), so a deploy token's
270
+ value paired with the wrong username fails authentication at clone time. This
271
+ job reports that as exit 5 `UNAVAILABLE` — read as a network/credential-scope
272
+ problem, when the actual cause is the username mismatch.
273
+
274
+ To use a deploy token instead of Option A, `.gitlab-ci.yml`'s clone step would
275
+ need to stop hardcoding `oauth2` — either read the deploy token's own username
276
+ from a second CI variable and interpolate it into the clone URL, or create the
277
+ deploy token with a custom username of `oauth2` if the GitLab instance allows
278
+ choosing one. Neither change is made in this repo; that edit is out of this
279
+ document's scope. Option A avoids needing it at all, by using a token whose
280
+ username requirement (any username) already matches the hardcoded login.
130
281
 
131
282
  ### Verification path
132
283
 
@@ -162,8 +313,12 @@ Documenting it here for completeness:
162
313
  `infrastructure/session-orchestrator`.
163
314
  - Once the allowlist entry is saved, the job can use `CI_JOB_TOKEN` directly
164
315
  and `SCHEMA_DRIFT_TOKEN` is not needed.
165
- - Issue #279 chose the deploy-token path because it requires no admin action
166
- in the foreign project and works immediately after variable creation.
316
+ - Issue #279 chose the token-variable path over this allowlist because it
317
+ requires no admin action in the foreign project and works immediately after
318
+ variable creation. The original choice was a deploy token; as documented in
319
+ Option A above, a deploy token does not actually authenticate with this
320
+ job's hardcoded `oauth2:` clone login, so a Project Access Token (or PAT)
321
+ is the variant that delivers on that original reasoning.
167
322
 
168
323
  ## `pipeline-gate` — the fan-in job
169
324
 
@@ -189,7 +189,7 @@ codex plugin list --available --json
189
189
 
190
190
  Three facts explain why launching Codex from `$HOME` used to kill the MCP server before `initialize` (GH Kanevry/session-orchestrator#64), and none of them is guessable from the docs:
191
191
 
192
- 1. **Codex copies the plugin; it does not run it from your clone.** A marketplace install lands at `<CODEX_HOME>/plugins/cache/<marketplace>/<plugin>/<version>/` — measured here as `~/.codex/plugins/cache/local/session-orchestrator/3.22.0+codex.20260822193811/`, a full self-contained tree with its own `.mcp.json`, `package.json` and `scripts/mcp-server.sh`. Claude Code uses the same shape under `~/.claude/plugins/cache/`.
192
+ 1. **Codex copies the plugin; it does not run it from your clone.** A marketplace install lands at `<CODEX_HOME>/plugins/cache/<marketplace>/<plugin>/<version>/` — measured here as `~/.codex/plugins/cache/local/session-orchestrator/<version>+codex.<stamp>/`, a full self-contained tree with its own `.mcp.json`, `package.json` and `scripts/mcp-server.sh`. Claude Code uses the same shape under `~/.claude/plugins/cache/`.
193
193
  2. **The MCP child gets no plugin-root variable and no working directory of its own.** Probing a registered MCP server launched from `/tmp` showed `PWD=/private/tmp` (the launch directory, verbatim) and `CLAUDE_PLUGIN_ROOT`, `CODEX_PLUGIN_ROOT`, `PLUGIN_ROOT` and `CODEX_HOME` **all unset**; `codex mcp list` prints `Env: -` and `Cwd: -` for the entry. `HOME` *is* set. So from `$HOME` — not a git repository — every locator the entrypoint had was blind, and `$(git rev-parse --show-toplevel)` collapsed the path to `/scripts/mcp-server.sh`.
194
194
  3. **Codex does not expand `${...}` in the registered command, and the registration is a snapshot.** `codex mcp list` shows the launch string verbatim, `${CLAUDE_PLUGIN_ROOT:-…}` and all — bash expands it, not Codex. It comes from the *cached* `.mcp.json`, taken at install time: a fix committed to this repo reaches an existing install only after a reinstall.
195
195
 
@@ -7,7 +7,7 @@ Detailed component inventory and architecture reference for Session Orchestrator
7
7
  ```mermaid
8
8
  flowchart LR
9
9
  USER([Operator]) -->|invokes /session| COORD[Coordinator]
10
- COORD -->|reads| SK[Skills<br/>48 user-facing]
10
+ COORD -->|reads| SK[Skills<br/>49 user-facing]
11
11
  COORD -->|invokes| CMD[Commands<br/>28 slash-cmds]
12
12
  COORD -->|dispatches| AG[Agents<br/>15 typed sub-agents]
13
13
  AG -.->|parallel waves| W1[code-implementer]
@@ -18,7 +18,7 @@ flowchart LR
18
18
  COORD -->|writes| METRIC[.orchestrator/metrics/<br/>sessions · learnings · events]
19
19
  ```
20
20
 
21
- ## Skills (48 user-facing)
21
+ ## Skills (49 user-facing)
22
22
 
23
23
  - **Lifecycle:** `session-start`, `session-plan`, `wave-executor`, `session-end`, `quality-gates`, `using-orchestrator`
24
24
  - **Authoring:** `skill-creator`, `mcp-builder`, `hook-development`, `frontmatter-guard`, `contract-version-bump`
@@ -26,7 +26,7 @@ flowchart LR
26
26
  - **Architecture:** `architecture`, `domain-model`, `ubiquitous-language`
27
27
  - **Cross-session:** `evolve`, `convergence-monitoring`, `memory-cleanup`, `reconcile`, `sunset-review`, `eval`
28
28
  - **Vault & docs:** `vault-sync`, `vault-mirror`, `daily`, `docs-orchestrator`
29
- - **Ecosystem:** `bootstrap`, `gitlab-ops`, `gitlab-portfolio`, `ecosystem-health`, `mode-selector`, `autopilot`, `dispatcher`, `spinout`, `npm-publish`
29
+ - **Ecosystem:** `bootstrap`, `gitlab-ops`, `gitlab-portfolio`, `ecosystem-health`, `mode-selector`, `autopilot`, `dispatcher`, `remote-offload`, `spinout`, `npm-publish`
30
30
  - **Testing:** `test-runner`, `playwright-driver`, `peekaboo-driver`
31
31
  - **Content review:** `persona-panel`
32
32
  - **Operator ergonomics:** `eli5` (plain-language restatement of the last answer)
@@ -10,13 +10,45 @@ session at SessionStart (`events-rotation.mjs`).
10
10
  Every record is a single JSON line of the form:
11
11
 
12
12
  ```json
13
- { "timestamp": "2026-05-28T14:35:13.123Z", "event": "orchestrator.session.ended", "...": "payload" }
13
+ { "timestamp": "2026-05-28T14:35:13.123Z", "event": "orchestrator.session.ended", "...": "payload", "schema_version": 1 }
14
14
  ```
15
15
 
16
+ `schema_version` serialises **last**, after the payload spread — `emitEvent()` builds
17
+ `{timestamp, event, ...correlation, ...payload}` first and only then calls
18
+ `stampEventSchemaVersion()`, which adds the key when absent rather than inlining it up
19
+ front (see below).
20
+
16
21
  - `timestamp` — ISO-8601 UTC string (trailing `Z`). Generated by `emitEvent()`.
17
22
  - `event` — the event name (see convention below).
23
+ - `schema_version` — record schema version (currently `1`, `CURRENT_SCHEMA_VERSION` in
24
+ `scripts/lib/events-schema.mjs`). Stamped by `emitEvent()` on every write (#1177); a
25
+ caller-supplied `schema_version` in `payload` is never overwritten. **Validated before write:**
26
+ `validateEventRecord()` runs BEFORE the line is appended and BEFORE any webhook POST — a record
27
+ that fails validation throws `EventValidationError` (carrying `.errors` and `.eventType`) and
28
+ produces neither a ledger line nor a webhook call. `scripts/emit-event.mjs` maps a thrown
29
+ `EventValidationError` to exit `1` (a filesystem/I/O failure keeps its existing exit `2`).
30
+ `scripts/lib/tmux-layout/telemetry.mjs` stamps and validates synchronously on its own write path
31
+ and drops an invalid line rather than appending it. **The read path stays lenient**: an absent
32
+ `schema_version` is not itself invalid, so the 33,700 historical records written before #1177
33
+ remain valid (measured 2026-09-02, working tree @ `c3ab480`: `jq -c 'select(.schema_version ==
34
+ null)' .orchestrator/metrics/events.jsonl | wc -l`) — 20 of those, all from 2026-04-19, carry a
35
+ legacy `ts` field instead of `timestamp` and are known test-fixture leakage, not a live producer.
36
+ The webhook body (`{ event_type, source, payload }`) is unchanged by #1177 — it never carries
37
+ `schema_version`, which describes the JSONL record, not the wire contract with the external
38
+ consumer.
18
39
  - remaining keys — event-specific payload, shallow-merged.
19
40
 
41
+ ### Correlation keys (best-effort)
42
+
43
+ `session_id` / `semantic_session_id` come from `sessionAttribution()` (`scripts/lib/events.mjs`).
44
+ `emitEvent()` fills these keys automatically when the caller passed none — but **only** when a
45
+ PROCESS-LOCAL witness (`CLAUDE_CODE_SESSION_ID`, or the hook-input session id) exactly equals the
46
+ lock's raw `session_id`. STATE.md is **not** a witness. They are **omitted, never fabricated**,
47
+ whenever that witness check fails or no readable lock exists — a made-up id would silently collide
48
+ across every unattributed run, and attributing a record to a session it does not belong to is worse
49
+ than leaving the field out (#1123). `wave` is filled the same way, but only from a `wave-scope.json`
50
+ manifest bound to THIS SAME session — an unbound manifest is ignored — and coerced to an integer.
51
+
20
52
  ## Single emission path
21
53
 
22
54
  **All orchestrator events MUST be emitted via `emitEvent(type, payload)` from
@@ -55,24 +87,25 @@ namespace we own. The validator + regex live in `scripts/lib/events-schema.mjs`
55
87
 
56
88
  | Event | Emitter | Hook / trigger |
57
89
  |---|---|---|
58
- | `orchestrator.session.started` | `hooks/on-session-start.mjs` | SessionStart |
59
- | `orchestrator.session.ended` | `hooks/on-session-end.mjs` | SessionEnd |
60
- | `orchestrator.session.stopped` | `hooks/on-stop.mjs` | Stop |
61
- | `orchestrator.session.backfill_completed` | `hooks/on-session-end.mjs` (`emitBackfillOutcome`) | SessionEnd, once per backfill call (#1068 AC2 — the backfill outcome is canonically queryable, the side-log is no longer the only result source). Payload: `kind` (`abandoned`\|`state-md-completed`), `action` (the backfill result action, e.g. `appended`\|`superseded`\|`skipped-already-recorded`\|`unknown`), plus `session_id`, `semantic_session_id`, `record_id`, `supersedes`, `reason` — each OMITTED when unknown, never `null` |
90
+ | `orchestrator.session.started` | `hooks/on-session-start.mjs` | SessionStart. **Optional, additive:** `peers_superseded` (number, GH#67) — the count of mechanically-detected peers (`mechanicalPeers`) whose `lockSuperseded === true` (a LIVE lock at this root is held by a different raw session_id than that registry-only peer — a HINT, not a verdict; see the GH#67 discussion above). Computed as `mechanicalPeersSuperseded` and rendered inline per peer via `supersessionMarker()` in the banner text. `peer_count` is deliberately left unchanged by this addition, so the supersession rate is measurable (`peers_superseded` / `peer_count`) instead of only inferred from banner prose (HR-105) |
91
+ | `orchestrator.session.ended` | `hooks/on-session-end.mjs` | SessionEnd. **Payload:** `reason` (always), plus `session_id` / `semantic_session_id` / `duration_ms` — **each OMITTED, never fabricated, when it could not be measured** (#1068 AC1; `duration_ms` since the W5 F1 sweep). `duration_ms` is written ONLY when the ending session IS the one `.orchestrator/current-session.json` records (the `isRecordedSession` predicate, decided on the RAW stdin UUID) AND that file's start timestamp parsed. Until that fix it fell back to a hard `0`: **1082 of 1498** fleet records (72,2 % — 415 nonzero, 1 key absent, measured 2026-09-02) carried a zero that reads as a MEASURED zero-length session and is indistinguishable from one. An ABSENT `duration_ms` means NOT MEASURED, never "instant" — same omit-never-fabricate contract as `session.stopped` / `agent.stopped` above |
92
+ | `orchestrator.session.stopped` | `hooks/on-stop.mjs` (`handleStop`) | Stop. **Payload:** `session_id`, `semantic_session_id`, `wave`, optional `branch` / `commit`, plus the pair `duration_ms` + `duration_source` (`stdin-start-ms` | `session-lock`). **The pair is written TOGETHER or omitted together — never `0`.** Until this change `duration_ms` was a hard `0` in **8.127 of 8.127** fleet records (measured 2026-09-02): the expression fell back to `0` because the harness never sends `start_ms`, and a fabricated zero reads as a MEASURED zero-length turn, indistinguishable from one. The span is now derived from `.orchestrator/session.lock` `started_at` — **ownership-gated on the RAW stdin `session_id`**, because a lock in this working copy routinely names a live PEER session (the resolved id is deliberately not used: it falls back to `current-session.json`, which is the foreign-identity inheritance the guard refuses). No owned, readable, parseable lock ⇒ both keys absent, which means NOT MEASURED and never "instant". **`duration_source` says WHICH span the number is, and the two are not the same quantity:** `session-lock` is SESSION-elapsed measured at this turn's end — Stop fires per TURN while `started_at` is stamped once per SESSION, so it GROWS MONOTONICALLY across a session's turns (the last Stop of a 3-hour session reports ~3 hours, not its final turn); `stdin-start-ms` is TURN-elapsed, the only first-party measurement of the turn itself, and the harness has never sent it. Do not sum `session-lock` spans over a session — that double-counts |
93
+ | `orchestrator.session.backfill_completed` | `hooks/on-session-end.mjs` (`emitBackfillOutcome`) · `scripts/backfill-abandoned-sessions.mjs` (`emitBackfillCompleted`, the startup/CLI path, #1167) | SessionEnd, once per backfill call (#1068 AC2 — the backfill outcome is canonically queryable, the side-log is no longer the only result source) — and separately, once per record the startup/CLI path itself writes, which until #1167 wrote SILENTLY: the SessionEnd hook was the only emitter, so nothing distinguished "the backfill never ran" from "it ran at startup". Both producers mirror the same payload shape so one filter queries either. Payload: `kind` (`abandoned`\|`state-md-completed`), `action` (the backfill result action, e.g. `appended`\|`superseded`\|`skipped-already-recorded`\|`unknown`), plus `session_id`, `semantic_session_id`, `record_id`, `supersedes`, `reason` — each OMITTED when unknown, never `null`. Best-effort on both paths: emission is wrapped in try/catch so a telemetry failure never blocks the backfill it describes |
62
94
  | `orchestrator.session.root_left` | `scripts/lib/session-transition.mjs` (`leaveSourceRoot`) | a session left a repo root for good — the process-boundary teardown of Worktree-Auto-Promotion (#1069), emitted into the OLD root's stream after `deregisterSelf()` + `release()`, whether or not either found anything. NOT emitted when the teardown ABORTED (invalid args, a lock owned by another session, an unparseable lock): the event asserts a root was left, and on those branches none was. Payload: `session_id`, `semantic_session_id` (OMITTED when unknown, never `null`), `from_root_hash` (`repoPathHash()` of the abandoned root — the SAME hash the session registry keys its entries by, so a departure joins to the entry it removed), `from_root_basename` (matching the registry's `repo_name`), `reason` (e.g. `worktree-promotion`). **Never the absolute root:** this payload also travels over the optional Clank webhook with no redaction, and an absolute root on a developer host is `/Users/<operator>/…` — same rule as `board_written` / `mirror_completed` and `relativeWorktreePath` in `worktree-pipeline.mjs`. Its absence beside a live-looking registry entry is the phantom-peer signature the event exists to make visible |
63
95
  | `orchestrator.session.lock.acquired` | `hooks/_lib/lock-bootstrap.mjs` | SessionStart |
64
96
  | `orchestrator.session.lock.released` | `hooks/on-session-end.mjs` · `scripts/lib/autopilot/worktree-pipeline.mjs` (`teardownWorktree`) · `scripts/lib/session-transition.mjs` (`leaveSourceRoot`) | after a `release()` that matched ownership (#952). Payload: `session_id`, `caller` (`on-session-end`\|`worktree-pipeline`\|`session-transition`), `outcome` (`deleted`\|`already-gone`), `verified`; hook-side additionally `lock_session_id`, `semantic_session_id`, `end_reason` (the SessionEnd reason — deliberately NOT `reason`, which the sibling `release_failed` uses for the failure reason); pipeline-side additionally `worktree_path`, `issue_iid`. The `session-transition` caller emits only this event and no `release_failed` sibling — a failed release there is reported to its caller as `{ ok: false, reason: 'lock-<reason>' }`, which the promotion prose must WARN on, so the stream is not the only witness. `outcome: 'already-gone'` means the lock had ALREADY vanished between `readLock()` and `release()` — the forensically interesting case, since a successful release previously left no trace at all and a missing lock was therefore indistinguishable from a lock someone else deleted (#914 residual 3) |
65
97
  | `orchestrator.session.lock.release_failed` | `hooks/on-session-end.mjs` · `scripts/lib/autopilot/worktree-pipeline.mjs` (`teardownWorktree`) | ownership matched but `release()` did NOT delete the lock (#724). Payload: `session_id`, `reason` (`fs-error`\|`session-mismatch`\|`not-deleted`\|`threw`), `caller`; pipeline-side additionally `worktree_path`, `issue_iid` |
66
98
  | `orchestrator.session.lock.reconcile_attempted` | `hooks/_lib/lock-reconcile.mjs` | SessionEnd, when NEITHER ownership check matched the recorded lock (#748). Payload: `session_id`, `action` (`reaped`\|`skipped`\|`unknown`), `reason` (e.g. `own-host-pid-alive`) |
67
99
  | `orchestrator.session.lock.reaped` | `scripts/lib/lock-reaper.mjs` | a dead lease was reaped. Payload: `session_id`, `semantic_session_id`, `host`, `pid`, `age_hours`, `reap_mode`, `current_session` |
68
- | `orchestrator.agent.stopped` | `hooks/on-stop.mjs` | SubagentStop |
100
+ | `orchestrator.agent.stopped` | `hooks/on-stop.mjs` (`handleSubagentStop`) | SubagentStop, once per stopping subagent — the fleet's most frequent event (103.763 records / 19 repos, measured 2026-09-02). **Payload (#1190) — every field is OPTIONAL and the KEY IS OMITTED when the measurement could not be made; never `null`, never `'unknown'`, never a stand-in `0`/`false`:** `agent` (the stdin `agent_type`/`subagent_type`, trimmed) — **omitted when empty**, which is the #1190 fix itself: the previous `input?.agent_type ?? 'unknown'` never fired on the EMPTY STRING the harness actually sends, so 89.991 of the 103.763 historical records (86,7%) carry `agent: ""`. A consumer must read a MISSING `agent` as "the harness did not name the type", not as a broken emitter. `agent_id` (stdin `agent_id`\|`subagent_id`; opaque id, charset-guarded with `/^[A-Za-z0-9_-]{1,64}$/` before it is interpolated into any path). The remaining fields are derived from the agent's sidecar pair `<transcript-dir>/<parent-basename>/subagents/agent-<agent_id>.{jsonl,meta.json}` and are all omitted when that derivation is not possible (no `agent_id`, no `transcript_path`, or a rejected id): `transcript_found` (boolean — `false` here is a MEASURED absence, the probe ran; the key is ABSENT when it could not run), `tool_use_id` + `agent_type_meta` (from the `.meta.json` keys `toolUseId` / `agentType`; `agent_type_meta` is a SECOND witness for the type and is deliberately NOT merged into `agent`, so the empty-`agent_type` rate stays measurable), `duration_ms` + `duration_source` (`meta-birthtime` — the sidecar carries no spawn timestamp, so its birthtime IS the spawn moment; both keys omitted together when the stat fails, never a fabricated `0`), and `status` (`done`\|`partial`\|`blocked`\|`failed`\|`no-tests-needed`, from the last LINE-ANCHORED `STATUS:` marker in the final 64 KiB of the agent transcript — the anchoring rationale is `scripts/lib/wave-transcript-tail.mjs:105-112`, since a free-floating match fires on any agent that merely QUOTES the token). **`status` coverage is partial by measurement, not by accident: 61,7 %** — 71 `done` / 3 `partial` / 46 absent over **120 COMPLETED sidecars**, population: one operator's `~/.claude/projects/<this-repo>` directory, sidecars idle ≥ 30 min (`find … -path "*/subagents/agent-*.jsonl" -mmin +30 | head -120`), each read through `readStatusFromTranscriptTail`, measured 2026-09-02. Re-cut it with that command; the earlier "roughly a quarter to a half" came from an n=4 IN-FLIGHT sample and undercounted, because a running agent has not written its STATUS line yet. **An absent `status` means NOT FOUND, never success.** **Never the transcript text, the meta `description`, or an absolute path:** this payload also travels over the optional Clank webhook with no redaction — same rule as `board_written` / `session.root_left`. `session_id` / `semantic_session_id` / `schema_version` are stamped by `emitEvent()` |
69
101
  | `orchestrator.memory.propose_invoked` | `hooks/pre-bash-memory-propose-audit.mjs` | PreToolUse(Bash) |
70
- | `orchestrator.wave.started` / `.completed` | `hooks/post-tool-batch-wave-signal.mjs` | PostToolBatch — fires live via `.claude/wave-scope.json` `.wave` increase (mechanical fallback, #612); an explicit injected `wave_signal` still takes precedence |
102
+ | `orchestrator.wave.started` / `.completed` | `hooks/post-tool-batch-wave-signal.mjs` · `hooks/on-session-end.mjs` (`emitFinalWaveCompleted`, `.completed` only) | PostToolBatch — fires live via `.claude/wave-scope.json` `.wave` increase (mechanical fallback, #612); an explicit injected `wave_signal` still takes precedence. **Second `.completed` emitter (#1193):** the batch hook closes wave N-1 only at an N-1→N transition, so the LAST wave of every session never received a completion — measured fleet-wide 2026-09-02 as **296 gaps over 296 wave runs** (1018 started vs 722 completed), exactly one missing final completion per run. SessionEnd now emits it. **Payload of that record:** `wave_number` (the `current-session.json` `last_wave`), `reason: 'session-end'`, `emitted_by: 'on-session-end'`, plus `session_id` / `semantic_session_id` — **omitted when unattested**, never fabricated. **Idempotent** via the `last_wave_completed` high-water mark in `.orchestrator/current-session.json`, written by BOTH emitters and preserved across `/clear`+compact by `on-session-start.mjs`; `last_wave` absent or `0` emits nothing (an Express-Path or coordinator-direct session never batched, and zero waves is the correct reading, not a gap). **Two gates on the SessionEnd emitter, both load-bearing:** (a) OWNERSHIP — `current-session.json` is a single repo-global file describing whichever session most recently ran SessionStart, routinely a different still-live session in a shared working copy, so the emit reuses `resolveSession()`'s `isRecordedSession` predicate; when false it emits nothing AND writes nothing, since writing the marker into a peer's file would silence the peer's own SessionEnd and preserve this very gap on the wrong session. (b) REASON — `reason === 'clear'` **and `reason === 'resume'`** are SKIPPED: the SessionEnd matcher is empty, so `/clear` fires the hook mid-wave while the LOGICAL session continues, and `on-session-start.mjs` preserves `last_wave` / `last_wave_completed` across a resume of the SAME logical session exactly as it does across a clear — so both end the HARNESS session, not the logical one. Closing the live wave on either is premature, and the preserved marker would then suppress the real completion later. Resume is the MORE common of the two (fleet n = 1498 `session.ended`, 2026-09-02: 12 `resume` vs 9 `clear`). Deliberately SessionEnd-only (`on-stop.mjs` is not mirrored) so the closed-vs-abandoned split stays measurable |
103
+ | `orchestrator.wave.final_refused` | `hooks/on-session-end.mjs` (`emitFinalWaveCompleted` → `emitFinalRefused`, via `emitEvent(..., {repoRoot})` + `sessionAttribution(repoRoot)`, wrapped in its OWN try/catch — independent of the caller's outer catch — so a telemetry failure on one refusal can never surface as a teardown failure) | the SIBLING event to `orchestrator.wave.completed` above (#1201 Part B / Discovery D8) — fires exactly once per SessionEnd in which the final `.completed` was NOT emitted, one row per refusal. Deliberately a SEPARATE event name rather than `.completed` carrying `emitted:false`: existing consumers of `.completed` treat every row as a finished wave, and overloading it would silently corrupt that count. **Payload:** `reason` (always present, closed enum — verified against `emitFinalWaveCompleted`'s own call sites) `not-recorded` \| `clear` \| `resume` \| `unreadable` \| `session-id-mismatch` \| `no-wave` \| `already-completed` \| `exception`, `emitted_by: 'on-session-end'` (always), plus `session_id` / `semantic_session_id` — omitted when `null`, never fabricated. **Optional, absent-is-not-zero:** `wave_number` — present ONLY on the `already-completed` reason, the sole call site that passes a resolved `last_wave` through to `emitFinalRefused`; every other reason, INCLUDING `no-wave`, omits it (that path never resolved a wave number at all — the function's own JSDoc pins this: "only when `last_wave` was resolved to a positive number before the refusal (currently only `already-completed`)"). Six of the eight reasons were previously SILENT refusal paths with no trace anywhere (`.claude/rules/host-resources.md` § HR-105: "a refusal that writes nothing is unfalsifiable") |
71
104
  | `orchestrator.quality_gate.passed` / `.failed` | `scripts/run-quality-gate.mjs` (the gate CLI — live between waves) · `scripts/lib/quality-gate.mjs` (`emitGateEvent` inside `runQualityGateWithRetry`, reached ONLY under `verification-auto-fix.enabled: true` — default `false`, and `false` in this repo) | CLI: once per gate-CLI run. Library: once per `runQualityGateWithRetry` **call**, never per retry attempt (`attempts` carries that detail). The two paths never nest, so one run passes through exactly one emitter. **Payload (both):** `variant`, `exit_code`, plus `session_id` / `semantic_session_id` when `sessionAttribution()` finds a session lock (both omitted when it does not). CLI `variant` is the `--variant` value (`baseline`\|`incremental`\|`full-gate`\|`per-file`); the library pins `variant: 'auto-fix-loop'`. **CLI only:** `wave_number`. **Library only:** `attempts` (1…`maxRetries+1`) and `gate` (`lint`\|`typecheck`\|`test` — the fail-fast gate of the last attempt; omitted on the passing path). **Optional on both:** `counts: {passed, failed, total}`. **Absent is not zero — for both optional fields.** `counts` is admitted by the ONE shared policy `admitSuiteCounts()` (`scripts/lib/gates/gate-helpers.mjs`, #967 item 2), which returns `null` — never a zero triple — for an unmeasured or inconsistent input (test gate skipped/stubbed, fail-fast on lint or typecheck before the test step, no parseable `<N> passed` marker, or `passed + failed !== total`); both callers spread `...(counts ? { counts } : {})`, so the KEY is missing in those runs. A present `counts.failed: 0` therefore means "measured, zero failures", while an absent `counts` means "not measured" — reading a missing field as `0` mis-analyses the ledger in both directions. Same contract for `wave_number` (CLI, #966 step 1): resolved from the `.{pi,cursor,codex,claude}/wave-scope.json` sidecar, **omitted** — never `0` — when there is no sidecar or its `wave` is non-numeric/non-positive; a human running `npm run quality-gate` from a `git push` has no wave at all, so an invented wave 0 would have to be special-cased by every consumer. Note `total` is `passed + failed` and EXCLUDES skipped/todo (see `extractTestCounts`) |
72
105
  | `orchestrator.scope.foreign_session_ignored` | `hooks/enforce-scope.mjs` (Gate 3b, #1123) | PreToolUse Edit/Write — exactly once per gated tool call while a FOREIGN-session `wave-scope.json` is live: the manifest's `session`/`semantic_session` provably name another session, so the hook allows instead of enforcing. **Payload:** `hook`, `manifest` (path), `manifest_session` (string[]), `own_session` (string[]), `wave`, `file_path`. Legacy manifests without a `session` field never emit this — they stay enforced |
73
106
  | `orchestrator.grounding.injected` | `scripts/compute-grounding-injection.sh` (via `scripts/emit-event.mjs`) | grounding injection, when `PERSISTENCE=true` |
74
107
  | `orchestrator.handover.gated` | `skills/session-end/SKILL.md` Phase 1.65 (skill-prose, via `scripts/emit-event.mjs`) | Handover-Alignment-Gate outcome (#773). Payload: `candidates_total`, `auto_carry`, `asked`, `dropped`, `questions_asked`, `questions_answered`, `questions_deferred`, `path` (`fast_path`\|`triage`\|`weiterarbeiten`\|`fail_open`). Emitted exactly once per close — including the fail-open skip and the "Weiterarbeiten" abort — so never-measured paths become observable |
75
- | `orchestrator.vault.board_written` | `scripts/lib/vault-status/board-writer.mjs` (`emitBoardEvent`; name const `BOARD_EVENT`) | exactly ONE record per `mirrorBoard()` call, and therefore per `sweepBoard()` call — the sweep never double-emits. Call sites: the `mirrorBoard` wrapper through which all six inner return points funnel, and `sweepBoard`'s two paths (happy + enumeration-failure fallback). **Payload:** `action` (always — including every no-op: the five `skipped-vault-disabled` guards at `:803/:812/:817/:821/:829`, plus `skipped-handwritten`, `skipped-noop`, `skipped-write-failed`, `dry-run`, `written`), `caller` (always, `mirrorBoard`\|`sweepBoard`). **Optional, absent-is-not-zero:** `path_tail` (the BASENAME only — never the full path: under `01-projects/` the parent directory is the private project slug, and this payload also travels over the optional Clank webhook with no redaction), `rows`, `repos_swept`, `duration_ms`, plus `session_id` / `semantic_session_id` via `sessionAttribution(repoRoot)` (#1147 — the SAME root the record is pinned to, so attribution can never name a different tree than the ledger line; both keys omitted, never fabricated, when no `session.lock` is readable) — the numeric three admitted via `Number.isFinite(...)` / `typeof === 'string'`, NOT truthiness, so a measured `repos_swept: 0` survives while an unmeasured field is omitted. The enumeration-failure fallback deliberately omits `repos_swept`. **A throw from the inner function emits nothing** — `action` is mandatory and a throw has no action the code knows; inventing one would put a fictional state in the ledger (#1073) |
108
+ | `orchestrator.vault.board_written` | `scripts/lib/vault-status/board-writer.mjs` (`emitBoardEvent`; name const `BOARD_EVENT`) | exactly ONE record per `mirrorBoard()` call, and therefore per `sweepBoard()` call — the sweep never double-emits. Call sites: the `mirrorBoard` wrapper through which all six inner return points funnel, and `sweepBoard`'s two paths (happy + enumeration-failure fallback). **Payload:** `action` (always — including every no-op: the five `skipped-vault-disabled` guards at `:803/:812/:817/:821/:829`, plus `skipped-handwritten`, `skipped-noop`, `skipped-write-failed`, `dry-run`, `written`), `caller` (always, `mirrorBoard`\|`sweepBoard`). **Optional, absent-is-not-zero:** `path_tail` (the BASENAME only — never the full path: under `01-projects/` the parent directory is the private project slug, and this payload also travels over the optional Clank webhook with no redaction), `rows`, `repos_swept`, `duration_ms`, plus `session_id` / `semantic_session_id` via `sessionAttribution(repoRoot)` (#1147 — the SAME root the record is pinned to, so attribution can never name a different tree than the ledger line; both keys omitted, never fabricated, when no `session.lock` is readable) — the numeric three admitted via `Number.isFinite(...)` / `typeof === 'string'`, NOT truthiness, so a measured `repos_swept: 0` survives while an unmeasured field is omitted. `lock` — an additive diagnostic object, `{ locked: boolean, reason?: string, stale_override?: string, waited_ms: number }` (snake_case like the sibling keys) — is present whenever `withBoardLock()`'s `onLockOutcome` fired, i.e. every non-dry-run path; `stale_override` carries the file-lock reason TOKEN (e.g. `mtime age 600002ms > 60000ms`) only when a stale lock was force-overridden, and the key is entirely absent on `dryRun`, which never takes the lock. This makes an unlocked fail-open write and a stale-override observable in aggregate for the first time — until now `onLockOutcome` had no production caller at all. The enumeration-failure fallback deliberately omits `repos_swept`. **A throw from the inner function emits nothing** — `action` is mandatory and a throw has no action the code knows; inventing one would put a fictional state in the ledger (#1073) |
76
109
  | `orchestrator.vault.narrative_mirrored` | `scripts/lib/vault-status/narrative-mirror.mjs` (`emitNarrativeEvent`; name const `NARRATIVE_EVENT`) | one record per `mirrorNarrative()` call, from the thin wrapper — every outcome plus the throw path (`action: 'error'`, then re-throws). The old body became `runNarrativeMirror()`, so an early return added later is telemetered by construction. Covered: `skipped-vault-disabled` ×4, `skipped-invalid-path`, `skipped-no-statemd`, `written`, `skipped-noop`, `skipped-handwritten`, `dry-run`. **Payload:** `action` (always); optional `path_tail` (BASENAME only, same reason as `board_written`), `chars`, `session_id`, `semantic_session_id` (via `sessionAttribution`), `error_code` (throw path only — the error MESSAGE is deliberately not recorded, it can quote a path or STATE.md prose). **Named gap with a revisit trigger:** when `repoRoot` is absent, NOTHING is emitted — `emitEvent` would fall back to `SO_PROJECT_DIR` and the two rootless unit tests would append synthetic records to this repo's real ledger on every suite run. This deliberately diverges from `board-writer.mjs`, which emits there (#1073) |
77
110
  | `orchestrator.vault.mirror_completed` | `scripts/lib/vault-mirror/telemetry.mjs` (`emitMirrorEvent`; name const `MIRROR_EVENT`), called from `scripts/lib/vault-mirror/process.mjs` (`emitAction`, reached from all **18** of its call sites — census `grep -n 'emitEntryAction' scripts/lib/vault-mirror/process.mjs`, 2026-08-23) and from the two `skipped-invalid` branches in `scripts/vault-mirror.mjs` | **ONE record per JSONL entry processed, EXCEPT `skipped-noop`** (#1151: noop dominates a steady-state run; its count survives in the run-event's `skipped` total + `action_breakdown`) — `created`, `updated`, every other `skipped-*`, and both invalid paths. Until #1147 it was **failure-only**: only the two `skipped-invalid` branches emitted, which is why this repo's ledger held **0** records of it against 1272 `orchestrator.secret_masker.applied` from the same CLI (measured 2026-08-23). A healthy run was therefore indistinguishable from a broken emitter — the gap the sibling `orchestrator.vault.mirror_run_completed` row below closes. **Payload:** `action` (the SAME string the entry wrote to stdout), `kind`, `line` (1-based JSONL line — the only locator when a record has no id). **Optional, absent-is-not-zero:** `record_id` (the record's `id` / `session_id`), `path` (**vault-RELATIVE**, never absolute: this payload also travels over the optional Clank webhook with no redaction — omitted on `skipped-invalid` and on the pre-path quality skips, which are reached before a target path exists), `skip_class` (`validation` | `mapper-crash`, invalid branches only — mirrors the stdout `reason` verbatim so the failure class stays groupable without string-matching), `reason` (the renderer's message on the invalid branches, or the existing `meta.reason` string on a quality skip — `confidence:X < min:Y` / `narrative:N < min:M` / `status:…` — REUSED from the stdout payload rather than recomputed; clamped to 300 chars), `dry_run`, `session_id` / `semantic_session_id`. `record_id` / `path` / `skip_class` / `reason` treat **`null` as not-measured** and are omitted: a `record_id: null` would read as "measured, empty id" rather than "this record had none". **Attribution is read at `SO_PROJECT_DIR`, explicitly** — `readLock()` defaults to `process.cwd()`, so a bare `sessionAttribution()` would attribute the record to whatever tree the process happens to run in while the ledger line lands under `CLAUDE_PROJECT_DIR`. Same root for both halves or neither. The ledger destination stays the 2-arg `emitEvent` default so every event of one run shares it: this CLI has no repo-root flag, and deriving one from `--source` would split a single run's telemetry across two ledgers |
78
111
  | `orchestrator.vault.mirror_run_completed` | `scripts/lib/vault-mirror/telemetry.mjs` (`emitMirrorRunEvent`; name const `MIRROR_RUN_EVENT`), called from `finishRun()` in `scripts/vault-mirror.mjs` — the ONE close-out function every exit routes through (the happy tail, the malformed-JSON abort, the filesystem-error abort, and the top-level `main().catch`), latched so it can only fire once | exactly ONE record per CLI run, **unconditionally** — beside the `orchestrator.secret_masker.applied` emit and BEFORE the `--strict-schema` abort, so a failing run still reports its denominator. **This event is the denominator the per-entry row above lacks:** a healthy run over an empty source emits zero per-entry records, and so does a run whose emitter is broken — from the ledger the two are identical (`.claude/rules/host-resources.md` § HR-105). **Payload:** `kind`, `total` (non-blank JSONL entries attempted), `created`, `updated`, `skipped` (every non-failure `skipped-*` class), `failed` (`skipped-invalid` — validation error or mapper crash; split out because those are the entries whose session silently ends up WITHOUT a vault note), `dry_run`. **These five counters are ALWAYS present, including as `0`** — this is the one place a written zero is the payload rather than a violation of "absent is not zero", because each was measured over the whole run; `total: 0` is a measured empty run and the record's ABSENCE is the broken-emitter signal. `created + updated + skipped + failed === total` for any run that does not abort (pinned by a test). **Optional, and the discriminator that keeps the counters honest:** `aborted` (`malformed-json` | `filesystem-error` | `unexpected-error` | `missing-vault-dir` | `vault-not-canonical` | `missing-source` (#1151: the three pre-loop exit-2 aborts now close the run out through finishRun())) — present ONLY when the run exited before its tail, absent means "ran to the end" and never "unknown". Its presence says the five counters are PARTIAL (every line after the abort was never attempted), so the classes stop partitioning `total` and that gap must be read as an abort, not as producer/consumer drift. Until it existed, the two `process.exit` calls inside the entry loop and the `main().catch` jumped straight over this emit: the runs an operator most wants counted were the ones that vanished from the ledger, in the exact shape ("no record") the paragraph above reserves for a broken emitter. **Optional:** `action_breakdown` (per-`action` counts, keyed by the same strings the entries wrote to stdout — enumerates only actions that OCCURRED, so a missing key there means zero occurrences; the always-present `total` makes that reading unambiguous, and the key itself is omitted when nothing was processed), plus `session_id` / `semantic_session_id` via the same `SO_PROJECT_DIR`-pinned `sessionAttribution` as the per-entry event |
@@ -80,7 +113,12 @@ namespace we own. The validator + regex live in `scripts/lib/events-schema.mjs`
80
113
  | `orchestrator.probes.completed` | `scripts/lib/session-start-probes.mjs` (`runSessionStartProbes`), called from `hooks/on-session-start.mjs` | once per SessionStart, after the Phase-4 measurement probes run. **This event is the whole point of #1073:** the 18 module-backed probes had **zero** mechanical callers across `hooks/`, npm scripts, CI and husky — their only caller was prose in `skills/session-start/SKILL.md` — and across the 336 session starts recorded up to 2026-08-23 there was **no banner event at all**, so whether they ever ran was unfalsifiable (`.claude/rules/host-resources.md` § HR-105). **Payload:** `total`, `ran`, `warned`, `skipped`, `errored`, `timed_out`, `duration_ms`, and `probes` — one `{id, outcome, reason?}` per probe — `reason` travels whenever one was recorded, because `module-absent` (a permanently dead entry) must be distinguishable from `network-probe-opt-in` (the intended default); `outcome` ∈ `ran-clean`\|`ran-warn`\|`ran-alert`\|`skipped`\|`timeout`\|`error`. Two invariants are asserted by tests: `total === probes.length` and `ran + skipped + errored + timed_out === total`. **The count is 18, not the 19 Phase 4 appears to list:** four Phase-4 items are prose-only measurements with no module and no entry function (SSOT freshness, quality baseline, Pencil design status, plugin freshness) — 22 measurements, 18 wireable probes. **Network probes (`ci-status`, `mirror-issues`) are excluded by default** and appear as `outcome: 'skipped', reason: 'network-probe-opt-in'` — never omitted, because omitting them would rebuild the defect one layer down. Opt in with `SO_PROBES_INCLUDE_NETWORK=1`. The grounds are measured, not assumed: `hooks/hooks.json` gives the WHOLE SessionStart hook `timeout: 5` seconds while each network probe carries its own 8 s CLI timeout, so one slow network probe alone exceeds the hook's entire budget and takes the started-event and the banner down with it; warm-and-authenticated best case measured 520 ms / 498 ms, paid on every start of every repo. **Budget:** `PROBE_BUDGET_MS = 2000`; measured median against this repo **968 ms** (5 runs, 855–1104), 130–229 ms in a fresh tmp repo. Revisit trigger: median past HALF the budget, or any single probe past the budget → move the slow probes off the hook's critical path, do NOT raise the number. **Named ceiling:** the deadline is enforced at await points, so it is hard for async/network probes and advisory for the two that shell out synchronously (`project-hygiene`, `tests-src-ratio`). Escape hatch: `SO_DISABLE_STARTUP_PROBES=1`. **Deliberately NOT gated on `enable-host-banner: false`** — that preference governs DISPLAY; gating the RUN on it would rebuild exactly the unfalsifiable blind spot this event removes |
81
114
  | `orchestrator.express_path.evaluated` | `scripts/lib/express-path.mjs` (`evaluateExpressPath`, emit in `_emitEvaluated`; name const `EXPRESS_PATH_EVENT`) | once per Phase-8.5 evaluation — **on refusal as well as activation**. Until #1119 this was unrecordable twice over: `scripts/lib/config.mjs` discarded the `express-path` key **even when the block was present** (synthetic probe: 88 keys emitted, none of them this one), and the decision lived only in `skills/session-start/phase-8-5-express-path.md` prose, so it fired only when a coordinator read that prose. Ledger evidence, measured 2026-08-23 @ `34321bc` (a count, so read it as history, not as state): **0** express events at that point, against 22 of the last 30 sessions running with no wave at all — every one of them `housekeeping`, the exact population the path targets. **Payload:** `activated` (always, boolean), `reasons` (always — the BLOCKING codes on refusal, the satisfied ones on activation; nothing short-circuits, so a refusal names every blocker and a reader can tell whether trimming the issue list alone would have helped). **Optional, absent-is-not-zero:** `enabled`, `session_type`, `task_count`, `parallel_agents_required`, plus `session_id`/`semantic_session_id` via `sessionAttribution`. An unmeasured `sessionType` or `taskCount` fails CLOSED (`reasons: ['session-type-unknown','task-count-unknown']`) — defaulting unknown scope to 0 would activate a gate-skipping path on data nobody supplied. **Four inputs, not three:** activation condition 3 carries two clauses (`≤ 3 issues` AND no parallel agents), which both condition matrices list as a non-activating row. **A missing `repoRoot` SKIPS the emit with a stderr WARN** rather than falling through to `SO_PROJECT_DIR` — that is the wave-1 incident of this session (a probe with an unexported var wrote a synthetic record into the real fleet ledger) made structurally impossible; a regression test reproduces it. `events.mjs` is imported lazily so `config.mjs`'s 48-file import graph does not gain `platform.mjs`, which runs filesystem walk-ups at module load |
82
115
  | `orchestrator.foreign_dispatch.completed` | `scripts/lib/wave-executor/foreign-dispatch.mjs` (`dispatchForeign`, via `emitEvent(..., {repoRoot})` + `sessionAttribution(repoRoot)`) | once per foreign-model dispatch (#1150) — the replacement for `SubagentStop` telemetry, which cannot fire for a Bash-spawned `cursor-agent` child (no hook in the chain sees it). **Payload:** `model`, `role`, `ok`, `exit_code`, `timed_out`, `duration_s`, `changed_files` (count, tracked-modified ∪ untracked-new — `git diff` alone is blind to new files), `reason` (present on every refusal — `never-foreign-role`, `empty-diff`, `channel-unavailable`, `unsafe-*` — and on the failure classes of a completed run, so no failure class is reasonless), `hook_tampering` (tri-state: `true` = the child repointed/rewrote the shared `.git` hooks path, invalidates the run regardless of `ok`; `false` = fingerprint matched; absent/`null` = not measured, never read as clean), plus `session_id`/`semantic_session_id` via `sessionAttribution` (omitted, never fabricated, without a readable `session.lock`). Emitted on refusals too (`ok:false`), so a blocked dispatch is a record, not a silence |
116
+ | `orchestrator.remote_dispatch.completed` | `scripts/lib/wave-executor/remote-dispatch.mjs` (`dispatchRemote`, via `emitEvent(..., {repoRoot})` + `sessionAttribution(repoRoot)`; name const `REMOTE_DISPATCH_EVENT`) | once per REMOTE-host dispatch over the `offload` CLI (#1160) — the sibling of `foreign_dispatch.completed` on the other channel: that one sends a task to a foreign MODEL on this machine, this one sends a task to Claude on ANOTHER machine. Same reason for existing — a Bash-spawned `offload` child fires no `SubagentStop` hook, so this is the only ledger record a remote dispatch produces. **Payload:** `host` (the `offload` alias, never a hostname or an IP), `role`, `run_id`, `ok`, `exit_code`, `duration_ms`, `patch_files` (COUNT of paths parsed from the returned patch — `+++ b/` plus the `diff --git` header, because a DELETED file's `+++` is `/dev/null`), `patch_bytes`, `reason` (present on every refusal — `never-foreign-role`, `unsafe-run-id`, `unsafe-host`, `unsafe-patch-path` — and on every failure class of a completed run: `usage-config`, `host-unreachable`, `remote-command-failed`, `sync-failed`, `timeout`, `empty-diff`, `rate-limited`, `write-lock-busy`, `channel-unavailable`; absent means success, so no failure class is reasonless), plus `session_id`/`semantic_session_id` via `sessionAttribution` (omitted, never fabricated, without a readable `session.lock`). **Emitted on refusals too** (`ok:false`, `exit_code: null`, `duration_ms: 0`, `patch_files: 0`) — a blocked dispatch is a record, not a silence, and the null exit code is what keeps "refused" distinguishable from "attempted and measured empty". **Deliberately EXCLUDED, pinned by a test:** the prompt text, the patch BODY, and `patch_path` — this payload also travels over the optional Clank webhook with no redaction, and a tmp patch path names the run id and the operator's host |
83
117
  | `orchestrator.wave_dispatch.scope_checked` | `hooks/pre-task-scope-disjoint.mjs` (name const `SCOPE_EVENT`; built by `decide()` as `verdict.telemetry`, emitted in `main()` via `emitEvent(..., {repoRoot: projectDir})` + `sessionAttribution(projectDir)`) | PreToolUse `Agent` — **once per dispatch DECISION** (#1092), awaited BEFORE the terminal `emitAllow`/`emitDeny`/`emitWarn`, all of which `process.exit()` and would discard a pending append. **Payload:** `hook`, `agent_id` (the coordinator's `description` + `subagent_type`, clamped to 120 chars), `declared_path_count`, `injected` (a `FILE-SCOPE` declaration was found AND at least one path survived parsing), `shape` (`fenced` \| `inline` \| `none` — which PARSER won, deliberately not a second spelling of `signal`: a fenced block whose lines are prose is `signal: 'unparseable', shape: 'none'`), `signal` (`marker-absent` \| `unparseable` \| `extracted` — the row-5-vs-row-6 distinction of the hook's error-class matrix), `ledger_result` (`no-scope` \| `allow` \| `allow-finished` \| `deny` \| `warn-ledger-corrupt` \| `warn-not-evaluable`), `collision_count` (collisions involving THIS dispatch, live or already-finished — `ledger_result` says which). **Optional, absent-is-not-zero:** `wave` (the number out of `waveKeyOf()`'s `w<N>` segment — **omitted, never `0`**, under the `<session>|w?|?` fallback, same contract as `quality_gate`'s `wave_number`), plus `session_id` / `semantic_session_id` via `sessionAttribution` — omitted, never fabricated, without a readable `session.lock`. **What it proves and what it does not:** that the hook SAW (or did not see) a declaration in the prompt the coordinator handed to the dispatch tool, and what the guard decided — the SEND side. It proves nothing about the block reaching the agent's context or the agent reading it; that receive-side half of #1092 stays open for want of a platform prompt-assembly boundary (`docs/scope-collision-guard.md` § 4.2). **No prompt body and no declared path is in the payload** (issue #1092 acceptance criterion 3) — counts and closed enums only, because this record also travels over the optional Clank webhook with no redaction. Rows 1–4 of the matrix emit nothing (no decision was made) and neither do the two crash rows 2/12 — a hook that fell over cannot describe itself, which is what the `GUARD INACTIVE` stderr banner is for |
118
+ | `orchestrator.reconcile.completed` | `scripts/lib/reconcile/engine.mjs` (`emitReconcileCompleted`, called from the thin `runReconcile` wrapper; name const `RECONCILE_EVENT`) | one record per `runReconcile` call (#1192) — from the WRAPPER, so all three return points are covered: the empty short-circuit, the normal tail, and the never-throws catch. An inline emit would have missed two of them, including the empty corpus and the error path — the two runs an operator most needs recorded (`.claude/rules/host-resources.md` § HR-105). Same shape as `narrative_mirrored`'s wrapper, and the emit is try/catch-wrapped because `emitEvent` THROWS `EventValidationError`, which would otherwise break `runReconcile`'s never-throws contract. **Payload, all ALWAYS present including as `0`** (each was measured over the whole run, like `mirror_run_completed`'s counters): `trigger` (`skill` | `session-end` | `phase-skip` | `unknown` — written always, so the per-trigger denominator is complete; the two markdown callers depend on a coordinator passing it, hence the honest `unknown` default), `dry_run`, `learnings_total`, `eligible`, `proposals`, `rejected`, `capped`, `already_materialized` (`summary.alreadyMaterialized` — the REAL idempotent-skip count, #484), `written`, `duration_ms`. **Optional, absent-is-not-zero:** `targets` (the caller's effective target list; absent ⇒ none asserted), `store_records_dropped` (`summary.skipped` — absent ⇒ the candidate store was never INSPECTED: under `dryRun`, on the empty short-circuit, on the error path; a `0` there would be a false all-clear), and `aborted: 'engine-error'` + `reason` (clamped to 300 chars) — present ONLY when the never-throws guard fired; their absence means "ran to the end", never "unknown". **`dry_run` is the discriminator, not the event's absence:** the `phase-skip` caller runs dry on EVERY close and is the highest-volume trigger, so consumers filter `dry_run: false` for real runs. **A missing `repoRoot` SKIPS the emit with a stderr WARN** rather than falling back to `SO_PROJECT_DIR` — most engine tests pass none, and the fallback would append synthetic records to the real fleet ledger on every `npm test` (#1119, same contract as `express_path.evaluated` and `narrative_mirrored`); a regression test pins it. **Engine identities — the counters are NOT a flat partition, and a live payload reads as inconsistent without them** (source: `scripts/lib/reconcile/engine.mjs:64-83`): `learnings_total === proposals + rejected`, and `capped` + `already_materialized` are DIAGNOSTIC SUB-COUNTS *inside* `rejected`, not siblings of it — each capped or already-materialized learning is also counted as rejected. Within the eligible set: `eligible − proposals − capped === already_materialized`. Worked against a live record: `learnings_total 164 = proposals 10 + rejected 154`, and `eligible 102 − proposals 10 − capped 72 = already_materialized 20` — with `capped 72` and `already_materialized 20` both sitting inside those 154. `already_materialized` is computed BEFORE the volume brake, so a terminal learning never consumes a new learning's quota. `session_id` / `semantic_session_id` / `wave` / `schema_version` are stamped by `emitEvent()` |
119
+ | `orchestrator.evolve.completed` | `skills/evolve/SKILL.md` Phase 1 (persistence/no-session-data aborts) and Phase 3 Step 3.5 (skill-prose, via `scripts/emit-event.mjs`) | once per `/evolve analyze` run (default mode; #1200) — emitted immediately after Step 3.5's "Saved N new learnings, updated M existing. Total active: K." report, and on the two hard-abort paths that precede it (persistence disabled at Phase 1.2; no session data at Step 3.1). Until this addition `/evolve` reported completion in prose only — the whole class of `orchestrator.evolve.*` / `orchestrator.dialectic.*` events was **0 records across 164k fleet events** despite every run reporting success (#1200). **Payload (success), all FOUR counters ALWAYS present including as `0`** (same contract as `mirror_run_completed`'s counters): `appended` (new learnings written, Step 3.5(4)), `boosted` (existing learnings reinforced, Step 3.5(2)), `pruned` (`$PRUNE.archived` from the Step 3.5(5) `sweep-expired-learnings.mjs --prune` call — total entries archived this run, across every `_archive_reason`), `promoted` (always `0` from THIS call site — promotion to `public` scope is a separate CLI, `npm run share:hw-learnings -- --promote`, never invoked by `/evolve analyze` itself), `duration_ms`. **Payload (abort):** `aborted` (`persistence-disabled` \| `no-session-data`), `reason` (the abort message shown to the user, clamped to 300 chars), `duration_ms` |
120
+ | `orchestrator.dialectic.completed` | `skills/evolve/SKILL.md` Phase 6 Step 6.4 / Step 6.5 (skill-prose, via `scripts/emit-event.mjs`) | once per `/evolve --dialectic` run (#1200) — emitted after Step 6.4's "Dialectic-derived: M deltas to USER.md, N deltas to AGENT.md…" report on both the dry-run (default) and `--apply` paths, and once per Step 6.5 error outcome (a dialectic run reaching Step 6.5 never also reaches Step 6.4, so exactly one of the two payload shapes below is emitted per run). Same fleet-zero gap as `evolve.completed` above. **Payload (success):** `mode` (`dry-run` \| `apply`), `user_deltas`, `agent_deltas`, `tokens_in`, `tokens_out`, `duration_ms`. **Payload (abort):** `aborted` (`unknown-model` \| `budget-exceeded` \| `would-empty-card` \| `empty-input` \| `subagent-crash`), `duration_ms` |
121
+ | `orchestrator.dialectic.nudge_decided` | `scripts/lib/auto-dialectic.mjs` (`decideAndRecordAutoDialectic`, wrapping `shouldDispatchAutoDialectic` with an additive `emitEvent` call) | once per `decideAndRecordAutoDialectic()` call made with `record !== false` (#1200 part c) — the MECHANICAL sibling of the two skill-prose events above: those two cover a manual `/evolve` run, this one covers the session-end Phase 3.6.7 auto-dialectic NUDGE DECISION itself, which previously computed a trigger every close and recorded nothing. Emitted on ALL FOUR `shouldDispatchAutoDialectic()` return paths (kill-switch, no-new-input, cadence-threshold-met, under-threshold) — try/catch-wrapped, because `emitEvent()` throws `EventValidationError` on a malformed record and a telemetry failure must never change the decision the caller already has in hand. **Payload:** `decided` (boolean, mirrors `trigger` verbatim), `reason`, `cadence`, `sessions_since`, `learnings_since`, plus `session_id`/`semantic_session_id` via `sessionAttribution` (omitted, never fabricated, without a readable `session.lock`). **Deliberately never emitted** when the caller passes `record: false` — the read-only PROBE call site (`scripts/lib/session-end/phase-skip.mjs`'s Phase-3.6.x tail-skip aggregator) keeps calling the un-wrapped `shouldDispatchAutoDialectic()` directly instead, so its own documented side-effect-free contract ("reads only … writes NOTHING") is not broken by this event's write |
84
122
 
85
123
  Non-orchestrator names still present in the stream: `tmux-layout.{invoked,completed,degraded}`
86
124
  (tmux-layout skill) and `stagnation_detected`. The latter keeps its legacy bare name
@@ -43,7 +43,7 @@ The coordinator's **own** planned direct edits belong in `coordinator.json` in t
43
43
 
44
44
  Two OPTIONAL manifest fields close that: `session` (the raw `session_id`) and its human-readable twin `semantic_session`. Both come from ONE `sessionAttribution(repoRoot)` call (`scripts/lib/events.mjs`, reads `.orchestrator/session.lock` once) in the same coordinator step that writes the rest of the manifest — `skills/wave-executor/wave-loop.md` § Scope Manifest 1.
45
45
 
46
- The reader is `hooks/enforce-scope.mjs` **Gate 3b**, between the manifest parse (G3) and the path-guard gate (G4). It resolves identity via `readOwnSessionIds(projectRoot, { hookInput: input })` and classifies via `classifyManifestSession(scope, ownIds)`, both from [`scripts/lib/session-identity/own-session.mjs`](../scripts/lib/session-identity/own-session.mjs):
46
+ The reader is `hooks/enforce-scope.mjs` **Gate 3b**, between the manifest parse (G3) and the path-guard gate (G4). It resolves identity via `new Set(readProcessLocalSessionIds({ hookInput: input }))` and classifies via `classifyManifestSession(scope, ownIds)`, both from [`scripts/lib/session-identity/own-session.mjs`](../scripts/lib/session-identity/own-session.mjs):
47
47
 
48
48
  | Manifest state | `classifyManifestSession` verdict | Gate 3b disposition |
49
49
  |---|---|---|
@@ -54,8 +54,8 @@ The reader is `hooks/enforce-scope.mjs` **Gate 3b**, between the manifest parse
54
54
 
55
55
  Five properties are choices, not omissions — and every one of them points the fail-**closed** way, the deliberate inverse of § 4.1's posture for the dispatch hook:
56
56
 
57
- - **Only what is PROVABLY foreign is foreign.** `readOwnSessionIds()` returns the **UNION** of three sources — hook input (`session_id`/`sessionId`/`parent_session_id`) `CLAUDE_CODE_SESSION_ID` ∪ `session.lock` (`session_id`/`semantic_session_id`) — and an EMPTY set when none yields an id, which can only produce `unknown`. A gate that guessed would turn "cannot tell" into a silent enforcement-off on every harness exporting no session id. Every value is trimmed on the way in, so a whitespace-only env var cannot enter as a phantom id that matches nothing (`.claude/rules/development.md` § env-var whitespace trap).
58
- - **Union, not first-tier-wins.** Any id the process can legitimately claim names this session; only an id in NO source is somebody else's. Gating the sources made the READER's identity a strict subset of the WRITER's — the manifest's `session` comes from `sessionAttribution()` = the same repo-global lock — and three divergences all produced the same silent failure, the OWN manifest read `foreign` and the write gate switched itself off for the whole wave, logging an event indistinguishable from correct behaviour: (a) a nested harness where payload `session_id` ≠ `CLAUDE_CODE_SESSION_ID` (measured in `hooks/pre-bash-issue-budget.mjs` `resolveSessionId`); (b) a sub-agent invocation, whose own id is the subagent's while the manifest names the coordinator; (c) a session that lost the lock race and therefore wrote a PEER's id into its own manifest (see the writer guard below). The union only ADDS ids the process actually carries, so the security direction is unchanged: a manifest whose id appears in no source still classifies `foreign`. Its cost is named and points fail-**closed** — a peer-owned lock makes us enforce a peer's wave plan, which is a visible deny rather than a silent enforcement-off.
57
+ - **Only what is PROVABLY foreign is foreign.** `readProcessLocalSessionIds()` returns the ids that are PROCESS-LOCAL — hook input (`session_id`/`sessionId`/`parent_session_id`) and `CLAUDE_CODE_SESSION_ID` — and an EMPTY set when neither yields an id, which can only produce `unknown`. The repo-global `session.lock` is deliberately NOT a tier here (#1194): it is ONE file shared by every session in the checkout, so unioning it let a peer's manifest match a peer-written lock id, classify `own`, and have Gate 7 deny the second session's legitimate writes — the exact lockout G3b exists to end. A better signal REPLACES a worse one (`.claude/rules/host-resources.md` § HR-102). A gate that guessed would turn "cannot tell" into a silent enforcement-off on every harness exporting no session id. Every value is trimmed on the way in, so a whitespace-only env var cannot enter as a phantom id that matches nothing (`.claude/rules/development.md` § env-var whitespace trap).
58
+ - **Union across the process-local tiers, not first-tier-wins.** Both process-local tiers are read and merged; only an id in NEITHER is somebody else's. Gating them against each other made the READER's identity a strict subset of the WRITER's — the manifest's `session` comes from `sessionAttribution()` = the same repo-global lock — and two divergences inside these tiers produce the same silent failure, the OWN manifest read `foreign` and the write gate switched itself off for the whole wave, logging an event indistinguishable from correct behaviour: (a) a nested harness where payload `session_id` ≠ `CLAUDE_CODE_SESSION_ID` (measured in `hooks/pre-bash-issue-budget.mjs` `resolveSessionId`); (b) a sub-agent invocation, whose own id is the subagent's while the manifest names the coordinator. A third divergence — a session that lost the lock race and wrote a PEER's id into its own manifest is NO longer covered here since #1194 dropped the lock tier; it is the accepted cost in limit 11, and its defense is the writer guard. The merge only ADDS ids the process actually carries, so the security direction is unchanged: a manifest whose id appears in neither tier still classifies `foreign`. Its cost is named in limit 11 below.
59
59
  - **The writer verifies the binding names itself.** Because the lock is repo-global, `skills/wave-executor/wave-loop.md` § Scope Manifest 1 requires the coordinator to compare `sessionAttribution()`'s ids against its own session (STATE.md `session`) and to OMIT the `session`/`semantic_session` keys when they diverge — unbound = ENFORCE. The reader's union covers the case anyway; the writer guard keeps the manifest readable as an audit record instead of publishing a foreign name.
60
60
  - **Gate 3b runs after the parse, never on the raw bytes.** A corrupt manifest yields `{}`, hence no ids, hence `unknown` — and keeps failing closed. A gate that peeked at the bytes first would let a truncated manifest disarm the guard.
61
61
  - **The empty string is a validator ERROR, not a third flavour of absent.** `validateSession()` → `validateOptionalSessionId()` in `scripts/validate-wave-scope.mjs` rejects `"session": ""` with *"an empty id attributes to nothing; omit the key entirely to declare the manifest unbound"*. An empty id satisfies a truthiness check while matching nobody, so every reader would classify the manifest FOREIGN where the writer meant UNBOUND — opposite dispositions, not a cosmetic ambiguity. An ABSENT key only WARNS, because the § 3.3 pre-union skeleton is itself an unbound manifest and so is every manifest written before #1123.
@@ -174,7 +174,7 @@ Complete list of what this guard does **not** see, or sees only approximately:
174
174
  8. **Lock loss reopens the race.** On lock timeout the cycle runs unlocked (row 14) — two dispatches starting together can then read the same ledger state and one record is lost. That is the pre-lock behaviour, chosen over denying on a lock-file problem.
175
175
  9. **The session binding is self-declared** (§ 2.3). `session` is a plain field in a file any process in this working copy can write, so writing a foreign id into it turns the write gate off for that manifest. Named rather than hidden: it is the SAME power `enforcement: "off"` already grants in the same file, so Gate 3b adds no new authority — the manifest is the coordinator's own artefact either way.
176
176
  10. **Only the WRITE gate is session-bound.** The dispatch ledger of § 4 takes its session component from the harness's own `input.session_id` (`waveKeyOf(projectDir, sessionId, …)`), and reads only `wave` and `role` out of `wave-scope.json` — the `session` field is not consulted there at all. So a peer session's manifest cannot bind this session's writes since #1123, but the two hooks reach that property by different routes, and a change to one does not carry to the other.
177
- 11. **A peer-owned lock costs enforcement precision, deliberately.** `readOwnSessionIds()` unions the `session.lock` ids in (§ 2.3), and the lock is repo-global so while a peer holds it, that peer's manifest classifies `own` here and its `allowedPaths` bind this session's writes: the #1082 shape, but now as a visible deny naming a wave plan we do not own. The alternative (gating the lock behind the payload) fails the other way and silently, because the manifest's own `session` is written from that same lock. Bounded by the writer guard in `skills/wave-executor/wave-loop.md` § Scope Manifest 1 (omit rather than write a foreign id) and by `session.lock` heartbeat-liveness. Revisit if a manifest ever carries the writer's PROCESS identity alongside the lock-derived one, which would let the reader distinguish the two cases instead of unioning them.
177
+ 11. **A session that published a peer's id reads its OWN manifest as `foreign` (#1194).** Dropping the lock tier (§ 2.3) moved the cost to the other side of the trade, deliberately: a session that lost the `bootstrapLock()` race got the peer's id from `sessionAttribution()`, and if it writes that id into its own manifest, Gate 3b now classifies the manifest `foreign` and its own write guard stands down. The defense is on the WRITER, not the reader `skills/wave-executor/wave-loop.md` § Scope Manifest, "Verify the binding names YOU before you write it": compare both ids against your own session and OMIT the keys when they diverge, because unbound = ENFORCE. CEILING (BV-004): on a harness that exports no session env var and puts no `session_id` in the hook payload (Codex CLI, Cursor today) both tiers are empty, so G3b is permanently `unknown` = enforce = pre-#1123 behaviour there. Revisit when Codex/Cursor hook payloads carry a session id.
178
178
 
179
179
  ## 7. Debugging
180
180