session-orchestrator 3.20.0 → 3.22.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 (202) 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/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -204,6 +204,16 @@ if (runCheck('check-agents.mjs') !== 0) checkFailed = 1;
204
204
  process.stdout.write('\n');
205
205
  if (runCheck('check-commands.mjs') !== 0) checkFailed = 1;
206
206
 
207
+ // FAIL-capable from day one: every rule this check enforces (frontmatter present,
208
+ // parses as YAML, kebab-case name matching the directory, non-empty description)
209
+ // was measured at 0 violations across all 46 SKILL.md files at the commit that
210
+ // added it — so unlike the WARN-only censuses further down, it cannot be red on
211
+ // arrival. It sits next to check-commands.mjs because it shares that check's
212
+ // posture: a real js-yaml parse, not the line-regex approach of check-agents.mjs
213
+ // that let 12 unparseable SKILL.md frontmatter blocks go unnoticed.
214
+ process.stdout.write('\n');
215
+ if (runCheck('check-skills.mjs') !== 0) checkFailed = 1;
216
+
207
217
  process.stdout.write('\n');
208
218
  if (runDriftCheck() !== 0) checkFailed = 1;
209
219
 
@@ -249,12 +259,23 @@ if (runCheck('check-owner-leakage.mjs') !== 0) checkFailed = 1;
249
259
  process.stdout.write('\n');
250
260
  if (runCheck('check-rules.mjs') !== 0) checkFailed = 1;
251
261
 
262
+ process.stdout.write('\n');
263
+ if (runCheck('check-auq-clarity.mjs') !== 0) checkFailed = 1;
264
+
252
265
  process.stdout.write('\n');
253
266
  if (runCheck('check-unicode-safety.mjs') !== 0) checkFailed = 1;
254
267
 
255
268
  process.stdout.write('\n');
256
269
  if (runCheck('check-dead-bridge.mjs') !== 0) checkFailed = 1;
257
270
 
271
+ // FAIL-capable, unlike the WARN-only censuses below: R2+R4 were measured at
272
+ // 1 hit / 1 TP / 0 FP each against the full test corpus, so this check cannot be
273
+ // red on arrival for anything but a real instance of the class. The alternatives
274
+ // (R1 at 67 FP, R3 at 47 FP) are exactly the shape that gets demoted to WARN and
275
+ // then ignored — see the rule table in the check's header.
276
+ process.stdout.write('\n');
277
+ if (runCheck('check-untracked-test-deps.mjs') !== 0) checkFailed = 1;
278
+
258
279
  // WARN-only (v1): the unwired-config-key census reports but never fails the
259
280
  // build — see the rationale in the check's header (a blocking gate on today's
260
281
  // inventory would be red from day one and get disabled). Exit code is
@@ -270,6 +291,49 @@ runCheck('check-unwired-features.mjs');
270
291
  process.stdout.write('\n');
271
292
  runCheck('check-learning-provenance.mjs');
272
293
 
294
+ // WARN-only (#971), same rationale as the two censuses above: a `gh`/`glab` call
295
+ // without `--repo`/`-R` resolves its target project from the ambient cwd remote —
296
+ // silently the wrong project in a sibling worktree, an /autopilot child, or a repo
297
+ // whose origin is a fork — and a gate that blocks on a backlog no single pass can
298
+ // drain gets switched off rather than obeyed. What WARN buys is that each newly
299
+ // added bare call site shows up in every validator run while the sweep drains the
300
+ // rest. No headcount is quoted here on purpose: the live number moves with every
301
+ // commit, and only the check's own `--json` summary can state it as of a SHA. The
302
+ // exit code is deliberately ignored; a tool error still surfaces because that path
303
+ // prints FAIL: lines, which runCheck tallies into totalFail.
304
+ process.stdout.write('\n');
305
+ runCheck('check-vcs-repo-flag.mjs');
306
+
307
+ // WARN-only (#1023): a `gh`/`glab` command cited in docs that no released CLI
308
+ // ever had — `glab repo edit --visibility`, `glab group list` — costs an
309
+ // operator a failed command and a re-derivation. The oracle is the CLI's own
310
+ // `--help` COMMANDS section, never an exit code (`glab repo <anything> --help`
311
+ // exits 0). WARN rather than FAIL because that oracle is the LOCALLY installed
312
+ // binary: a version skew must not red an unrelated commit, and a missing binary
313
+ // SKIPs. The exit code is deliberately ignored; a tool error still surfaces
314
+ // because that path prints FAIL: lines, which runCheck tallies into totalFail.
315
+ process.stdout.write('\n');
316
+ runCheck('check-doc-cli-commands.mjs');
317
+
318
+ // WARN-only: a state-mutating `git` call in tests/ that names no target resolves
319
+ // its destination from the ambient cwd (or an inherited GIT_DIR) — the 2026-08-19
320
+ // incident, where fixture commits, a fixture remote and a fixture identity landed
321
+ // in the REAL .git and two commits reached both remotes with the wrong author.
322
+ // WARN rather than FAIL is a MEASURED posture, not caution: the v1 rule reported
323
+ // 11 findings against tests/ and all 11 were false positives (`git init <dir>`,
324
+ // whose positional IS the target). After that refinement the live corpus reports
325
+ // 0 findings — i.e. zero measured true positives to weigh against a textual
326
+ // detector's residual false-positive surface (the variable argv arrays it cannot
327
+ // judge; the live count is in its own PASS line, never restated here).
328
+ // check-untracked-test-deps.mjs above earned FAIL-capability on a
329
+ // measured 1 TP / 0 FP; this check has not, and a single FAIL: line here reds the
330
+ // whole validator. Ratchet to FAIL once a live true positive has been observed
331
+ // and the GIT_DIR-inheritance gap (reported as `gitDirInheritable`) is closed
332
+ // centrally. The exit code is deliberately ignored; a tool error still surfaces
333
+ // because that path prints FAIL: lines, which runCheck tallies into totalFail.
334
+ process.stdout.write('\n');
335
+ runCheck('check-test-git-config-target.mjs');
336
+
273
337
  // ---------------------------------------------------------------------------
274
338
  // Summary
275
339
  // ---------------------------------------------------------------------------
@@ -9,6 +9,8 @@
9
9
  * node scripts/validate-wave-scope.mjs <path-to-wave-scope.json>
10
10
  * cat wave-scope.json | node scripts/validate-wave-scope.mjs
11
11
  * node scripts/validate-wave-scope.mjs --assert-subset <agent-filescope.json> < wave-scope.json
12
+ * node scripts/validate-wave-scope.mjs --assert-disjoint <agent-scopes.json> < wave-scope.json
13
+ * node scripts/validate-wave-scope.mjs --union <agent-scopes.json> < wave-scope.json
12
14
  *
13
15
  * Flags:
14
16
  * --assert-subset <path> After schema validation passes, read the agent
@@ -26,21 +28,68 @@
26
28
  * the caller may pass the flag unconditionally on
27
29
  * every pre-dispatch check. A skip is announced on
28
30
  * stderr as a WARN.
31
+ * --assert-disjoint <p> #1020. Read the wave's per-agent scope SIDECAR and
32
+ * assert no file is claimed by two agents of the same
33
+ * wave. Fails (exit 1) with one message per collision.
34
+ * `knownFiles` for the glob∩glob stage comes from
35
+ * `git ls-files` — spawned HERE, in the CLI layer,
36
+ * because scripts/lib/scope-gate.mjs is hook-safe and
37
+ * must not spawn a process (see its module header).
38
+ * --union <path> #1020. QUERY MODE. Read the same sidecar, compute
39
+ * `expandTestSiblings(unionFileScopes(scopes), {role})`
40
+ * using the MANIFEST'S OWN `role`, and print the
41
+ * resulting allowedPaths array as JSON on stdout.
42
+ * Mechanical replacement for the "Collect all file
43
+ * paths … Deduplicate entries" prose in
44
+ * skills/wave-executor/wave-loop.md § Scope Manifest #3.
45
+ *
46
+ * ## SIDECAR FORMAT (both #1020 flags) — an ARRAY, never an object map
47
+ * [{ "id": "W2-C1", "files": ["scripts/a.mjs"] }, { "id": "W2-C4", "files": [...] }]
48
+ * An object keyed by agent id would swallow a DUPLICATE agent id silently, and a
49
+ * duplicated id is a real copy-paste failure mode (it hides one agent's scope
50
+ * from every per-agent check). The array form keeps both records, and
51
+ * `findScopeCollisions` reports the duplicate as its own finding.
52
+ *
53
+ * ## STDOUT CONTRACT (why --union suppresses the manifest echo)
54
+ * Without `--union` this script writes EXACTLY ONE thing to stdout: the input
55
+ * manifest, echoed back verbatim. Callers rely on that — `JSON.parse(stdout)`.
56
+ * `--union` is the first mode that has something else to say, so it is a pure
57
+ * QUERY MODE: it REPLACES the echo rather than adding to it, and stdout carries
58
+ * only the computed allowedPaths array. Mixing both on stdout would break every
59
+ * `JSON.parse(stdout)` caller; writing the union to a second sink would need a
60
+ * file argument the caller must then read back. One JSON document per run, and
61
+ * the flag decides which one.
29
62
  *
30
63
  * Exit codes:
31
- * 0 — valid (validated JSON echoed to stdout)
32
- * 1 — invalid input / validation failure (error messages written to stderr)
33
- * 2 I/O error (file not found, unreadable stdin, unreadable --assert-subset file)
64
+ * 0 — valid (validated JSON echoed to stdout; with --union: the union array)
65
+ * 1 — invalid input / validation failure (error messages written to stderr).
66
+ * A scope COLLISION is a validation finding, exactly like the #796 subset
67
+ * and #970 test-sibling violations — the collision-vs-subset distinction
68
+ * lives in the MESSAGE, not in a new exit code.
69
+ * 2 — I/O error (file not found, unreadable stdin, unreadable sidecar file)
34
70
  */
35
71
 
36
72
  import path from 'node:path';
37
73
  import { readFileSync, existsSync, statSync } from 'node:fs';
74
+ import { execFileSync } from 'node:child_process';
38
75
  import { warn } from './lib/common.mjs';
39
76
  import {
40
77
  assertFileScopeSubset,
41
78
  assertTestSiblingCoverage,
42
79
  testSiblingExpansionApplies,
43
80
  TEST_SIBLING_EXPANSION_ROLES,
81
+ findScopeCollisions,
82
+ unionFileScopes,
83
+ // #1057 — the read-only-role predicate + THE list. Imported rather than
84
+ // re-listed so the validator and hooks/enforce-scope.mjs cannot disagree about
85
+ // which roles are allowed to grant zero paths.
86
+ isReadOnlyWaveRole,
87
+ READ_ONLY_WAVE_ROLES,
88
+ // Aliased: `expandTestSiblings` is ALSO the name of the pre-existing
89
+ // boolean parameter threaded through validate()/assertSubsetOrDie for the
90
+ // #970 flag. Aliasing the import avoids shadowing that parameter rather than
91
+ // renaming it — the #970 call path stays byte-identical.
92
+ expandTestSiblings as expandScopeTestSiblings,
44
93
  } from './lib/scope-gate.mjs';
45
94
 
46
95
  /**
@@ -54,21 +103,58 @@ function die(msg, code = 1) {
54
103
  process.exit(code);
55
104
  }
56
105
 
106
+ /**
107
+ * Read the value operand of a value-taking flag, REFUSING one that is itself a
108
+ * flag. Used by the #1020 flags only.
109
+ *
110
+ * `--assert-subset` (#796) consumes `argv[i + 1]` BLIND, so
111
+ * `--assert-subset --assert-disjoint x.json` reads `--assert-disjoint` as its
112
+ * path value. That behaviour is deliberately left untouched — its no-value
113
+ * message is pinned byte-for-byte by
114
+ * tests/scripts/validate-wave-scope.test.mjs — but the flags added here do not
115
+ * inherit it: a swallowed flag is silent (the mode never runs, and the caller
116
+ * believes it did), whereas this refusal is loud and one line long.
117
+ *
118
+ * An EMPTY value is the same failure class and is refused for the same reason.
119
+ * `--assert-disjoint ""` is what a failed `$(...)` capture of the materializer's
120
+ * stdout produces; since the mode is gated on a truthy path, the empty string
121
+ * silently skipped the collision check and still exited 0 (#1083).
122
+ *
123
+ * @param {string[]} argv
124
+ * @param {number} i - index of the FLAG token
125
+ * @param {string} flag - the flag name, for the error message
126
+ * @returns {string}
127
+ */
128
+ function flagValue(argv, i, flag) {
129
+ const value = argv[i + 1];
130
+ if (value === undefined || value === '' || value.startsWith('--')) {
131
+ die(`${flag} requires a file-path argument`, 1);
132
+ }
133
+ return value;
134
+ }
135
+
57
136
  /**
58
137
  * Parse CLI flags out of argv, leaving positional args behind.
59
138
  *
60
- * Recognised: `--assert-subset <path>` (#796) and `--expand-test-siblings`
61
- * (#970). Everything else is treated as a positional argument (the
62
- * wave-scope.json file path), preserving legacy behaviour where argv[2] is the
63
- * input file.
139
+ * Recognised: `--assert-subset <path>` (#796), `--expand-test-siblings` (#970),
140
+ * `--assert-disjoint <path>` and `--union <path>` (#1020). Everything else is
141
+ * treated as a positional argument (the wave-scope.json file path), preserving
142
+ * legacy behaviour where argv[2] is the input file.
143
+ *
144
+ * The #1020 branches sit BEFORE the positional fallback, as their own `else if`
145
+ * arms: routed through the fallback instead, `--assert-disjoint` would be read
146
+ * as a wave-scope.json path and the mode would never run.
64
147
  *
65
148
  * @param {string[]} argv - full process.argv
66
- * @returns {{ assertSubset: string|null, expandTestSiblings: boolean, positionals: string[] }}
149
+ * @returns {{ assertSubset: string|null, expandTestSiblings: boolean,
150
+ * assertDisjoint: string|null, union: string|null, positionals: string[] }}
67
151
  */
68
152
  function parseArgs(argv) {
69
153
  const positionals = [];
70
154
  let assertSubset = null;
71
155
  let expandTestSiblings = false;
156
+ let assertDisjoint = null;
157
+ let union = null;
72
158
  for (let i = 2; i < argv.length; i++) {
73
159
  const a = argv[i];
74
160
  if (a === '--expand-test-siblings') {
@@ -79,11 +165,17 @@ function parseArgs(argv) {
79
165
  die('--assert-subset requires a file-path argument', 1);
80
166
  }
81
167
  i++; // consume the value
168
+ } else if (a === '--assert-disjoint') {
169
+ assertDisjoint = flagValue(argv, i, '--assert-disjoint');
170
+ i++; // consume the value
171
+ } else if (a === '--union') {
172
+ union = flagValue(argv, i, '--union');
173
+ i++; // consume the value
82
174
  } else {
83
175
  positionals.push(a);
84
176
  }
85
177
  }
86
- return { assertSubset, expandTestSiblings, positionals };
178
+ return { assertSubset, expandTestSiblings, assertDisjoint, union, positionals };
87
179
  }
88
180
 
89
181
  /**
@@ -259,6 +351,27 @@ function validateAllowedPaths(obj, errors, warnings) {
259
351
  errors.push(`allowedPaths must be an array, got type: ${ap === null ? 'null' : typeof ap}`);
260
352
  return;
261
353
  }
354
+ // #1057 — an empty union under a WRITABLE role. WARN, never error, and the
355
+ // distinction is measured rather than stylistic: `skills/wave-executor/wave-loop.md`
356
+ // § Scope Manifest deliberately feeds a skeleton with `"allowedPaths": []`
357
+ // through THIS validator in `--assert-disjoint` and `--union` mode, BEFORE the
358
+ // union exists to be written. An error would break the documented procedure
359
+ // that produces the very field it complains about.
360
+ //
361
+ // Named ceiling (BV-004): the warning therefore also fires on that legitimate
362
+ // skeleton run — one stderr line on a happy path, accepted because the
363
+ // alternative is a mode-conditional warning, i.e. a second place that has to
364
+ // enumerate the modes correctly. Revisit if a third empty-skeleton mode lands.
365
+ if (ap.length === 0 && typeof obj.role === 'string' && obj.role.trim().length > 0
366
+ && !isReadOnlyWaveRole(obj.role)) {
367
+ warnings.push(
368
+ `allowedPaths is empty for role "${obj.role}" — every write in this wave will be DENIED by ` +
369
+ `hooks/enforce-scope.mjs. Empty is intentional only for a read-only role ` +
370
+ `(${READ_ONLY_WAVE_ROLES.join(', ')}); for a writable role it usually means the coordinator's ` +
371
+ `--union step did not complete. Expected while validating the pre-union skeleton; otherwise ` +
372
+ `re-run --union and rewrite the manifest.`,
373
+ );
374
+ }
262
375
  for (const entry of ap) {
263
376
  if (typeof entry !== 'string' || entry.length === 0) {
264
377
  errors.push('allowedPaths contains empty string');
@@ -414,13 +527,157 @@ function assertSubsetOrDie(obj, fileScopePath, expandTestSiblings = false) {
414
527
  }
415
528
  }
416
529
 
530
+ /**
531
+ * Read + shape-check the per-agent scope SIDECAR shared by `--assert-disjoint`
532
+ * and `--union` (#1020). Exits on any defect; returns the records on success.
533
+ *
534
+ * Exit codes mirror {@link assertSubsetOrDie} exactly: 2 for I/O (missing, not a
535
+ * regular file, unreadable), 1 for every content defect.
536
+ *
537
+ * ## Why the shape check is STRICTER than the library's tolerance
538
+ * `findScopeCollisions` / `unionFileScopes` are fail-closed and never throw:
539
+ * they SKIP a member that is not an object, and treat a missing `files` as `[]`.
540
+ * That is right for a hook-hot-path primitive and wrong for a CLI. A sidecar
541
+ * that spells the key `file:` instead of `files:` would then contribute nothing
542
+ * and both modes would report success on a scope that silently vanished — a
543
+ * path the operator NAMED and the tool did not honour. The absent-input guard
544
+ * belongs in the CLI layer (recorded learning, conf 0.80: a tolerant reader
545
+ * cannot carry a CLI's absent-input guard), so `files` is REQUIRED here.
546
+ *
547
+ * `id` is deliberately NOT required: scope-gate's `normalizeAgentScopes` runs a
548
+ * record with no usable id as `<unnamed#i>` rather than dropping it, precisely
549
+ * because an unreviewed scope is the one that collides. Requiring it here would
550
+ * reject exactly the input that contract was written to keep.
551
+ *
552
+ * @param {string} sidecarPath
553
+ * @param {string} flag - the flag name, for error messages
554
+ * @returns {Array<{id?: string, files: string[]}>}
555
+ */
556
+ function readAgentScopesOrDie(sidecarPath, flag) {
557
+ if (!existsSync(sidecarPath) || !statSync(sidecarPath).isFile()) {
558
+ die(`Cannot read ${flag} file: ${sidecarPath}`, 2);
559
+ }
560
+ let raw;
561
+ try {
562
+ raw = readFileSync(sidecarPath, 'utf8');
563
+ } catch (err) {
564
+ die(`Cannot read ${flag} file ${sidecarPath}: ${err.message}`, 2);
565
+ }
566
+ let parsed;
567
+ try {
568
+ parsed = JSON.parse(raw);
569
+ } catch {
570
+ die(`${flag} file is not valid JSON: ${sidecarPath}`, 1);
571
+ }
572
+ if (!Array.isArray(parsed)) {
573
+ const t = parsed === null ? 'null' : typeof parsed;
574
+ die(
575
+ `${flag} file must be a JSON array of {id, files} records, got type: ${t} — an object map would silently swallow a duplicate agent id`,
576
+ 1,
577
+ );
578
+ }
579
+ for (let i = 0; i < parsed.length; i++) {
580
+ const rec = parsed[i];
581
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) {
582
+ die(`${flag} file entry #${i} must be an object with a "files" array`, 1);
583
+ }
584
+ if (!Array.isArray(rec.files) || !rec.files.every((f) => typeof f === 'string')) {
585
+ die(`${flag} file entry #${i} ("${rec.id ?? '<unnamed>'}") must have a "files" string array`, 1);
586
+ }
587
+ }
588
+ return parsed;
589
+ }
590
+
591
+ /**
592
+ * The repo's tracked files, for {@link findScopeCollisions}' glob∩glob witness
593
+ * stage. Spawned HERE and injected as a parameter because
594
+ * `scripts/lib/scope-gate.mjs` is hook-safe (pure, sync, no I/O, no spawn) and
595
+ * `hooks/enforce-scope.mjs` reaches it on a hot path.
596
+ *
597
+ * An unavailable git (not a repo, git missing, huge output) is NOT an error:
598
+ * the library documents `knownFiles` as optional — stage 3a simply has fewer
599
+ * witnesses and the prefix fallback of stage 3b carries the load. Silent by
600
+ * design: a WARN here would print on the success path of a mode whose contract
601
+ * is "quiet when clean".
602
+ *
603
+ * @returns {string[]}
604
+ */
605
+ function knownRepoFiles() {
606
+ const opts = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 };
607
+ try {
608
+ const root = execFileSync('git', ['rev-parse', '--show-toplevel'], opts).trim();
609
+ if (!root) return [];
610
+ // -z: NUL-separated. Without it git QUOTES paths containing non-ASCII or
611
+ // special characters, and a quoted path would never match a scope entry.
612
+ return execFileSync('git', ['ls-files', '-z'], { ...opts, cwd: root })
613
+ .split('\0')
614
+ .filter((f) => f.length > 0);
615
+ } catch {
616
+ return [];
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Assert that no file is claimed by two agents of the SAME wave (#1020).
622
+ * Exits 1 with one message per collision (plus one per duplicate agent id);
623
+ * returns silently when the wave is clean.
624
+ *
625
+ * @param {string} sidecarPath
626
+ */
627
+ function assertDisjointOrDie(sidecarPath) {
628
+ const agentScopes = readAgentScopesOrDie(sidecarPath, '--assert-disjoint');
629
+ const { ok, collisions, duplicateIds } = findScopeCollisions(agentScopes, {
630
+ knownFiles: knownRepoFiles(),
631
+ });
632
+ if (ok) return;
633
+
634
+ // Duplicate ids FIRST: they are a malformed plan, and a reader who fixes them
635
+ // may well change which collisions remain.
636
+ for (const id of duplicateIds) {
637
+ process.stderr.write(
638
+ `ERROR: duplicate agent id in ${sidecarPath}: "${id}" — ids must be unique per wave; a copy-paste duplicate hides one agent's scope from every per-agent check\n`,
639
+ );
640
+ }
641
+ for (const c of collisions) {
642
+ process.stderr.write(
643
+ `ERROR: wave scope collision (${c.kind}): agents "${c.a}" and "${c.b}" both claim [${c.evidence.join(', ')}]\n`,
644
+ );
645
+ }
646
+ process.stderr.write(
647
+ `ERROR: ${collisions.length} scope collision(s), ${duplicateIds.length} duplicate id(s) — every file must belong to exactly ONE agent per wave (#1020; .claude/rules/parallel-sessions.md § Decision Tree)\n`,
648
+ );
649
+ process.exit(1);
650
+ }
651
+
652
+ /**
653
+ * QUERY MODE (#1020): print `expandTestSiblings(unionFileScopes(scopes), {role})`
654
+ * as JSON on stdout, using the MANIFEST'S own role. Replaces the manifest echo —
655
+ * see the STDOUT CONTRACT note in the file header.
656
+ *
657
+ * @param {Record<string, unknown>} obj - the already schema-validated wave-scope object
658
+ * @param {string} sidecarPath
659
+ */
660
+ function emitUnion(obj, sidecarPath) {
661
+ const agentScopes = readAgentScopesOrDie(sidecarPath, '--union');
662
+ const allowedPaths = expandScopeTestSiblings(unionFileScopes(agentScopes), { role: obj.role });
663
+ process.stdout.write(`${JSON.stringify(allowedPaths, null, 2)}\n`);
664
+ }
665
+
417
666
  /**
418
667
  * Main validation entry point. Reads input, validates, exits with appropriate code.
419
668
  * @param {string} input - raw JSON string
420
669
  * @param {string|null} [assertSubsetPath] - optional agent fileScope file for the #796 subset assertion
421
670
  * @param {boolean} [expandTestSiblings] - opt-in #970 test-sibling coverage assertion
671
+ * @param {string|null} [assertDisjointPath] - optional per-agent scope sidecar for the #1020 collision check
672
+ * @param {string|null} [unionPath] - optional per-agent scope sidecar for the #1020 union query mode
422
673
  */
423
- function validate(input, assertSubsetPath = null, expandTestSiblings = false) {
674
+ function validate(
675
+ input,
676
+ assertSubsetPath = null,
677
+ expandTestSiblings = false,
678
+ assertDisjointPath = null,
679
+ unionPath = null,
680
+ ) {
424
681
  const obj = parseJson(input);
425
682
  const errors = [];
426
683
  const warnings = [];
@@ -446,9 +703,26 @@ function validate(input, assertSubsetPath = null, expandTestSiblings = false) {
446
703
  assertSubsetOrDie(obj, assertSubsetPath, expandTestSiblings);
447
704
  }
448
705
 
706
+ // #1020 — collision check runs AFTER the #796/#970 assertions, for the same
707
+ // reason #970 runs after #796 (see assertSubsetOrDie): a manifest that
708
+ // violates BOTH the subset relation and disjointness must keep the older,
709
+ // byte-pinned subset message. Only ever ADDS a failure mode.
710
+ if (assertDisjointPath) {
711
+ assertDisjointOrDie(assertDisjointPath);
712
+ }
713
+
714
+ // #1020 QUERY MODE — replaces the echo below; see the STDOUT CONTRACT note in
715
+ // the file header. Last, so every assertion above still gates it.
716
+ if (unionPath) {
717
+ emitUnion(obj, unionPath);
718
+ return;
719
+ }
720
+
449
721
  // Echo validated JSON to stdout (trailing newline normalised)
450
722
  process.stdout.write(input.endsWith('\n') ? input : input + '\n');
451
723
  }
452
724
 
453
- const { assertSubset, expandTestSiblings, positionals } = parseArgs(process.argv);
454
- validate(readInput(positionals[0]), assertSubset, expandTestSiblings);
725
+ const { assertSubset, expandTestSiblings, assertDisjoint, union, positionals } = parseArgs(
726
+ process.argv,
727
+ );
728
+ validate(readInput(positionals[0]), assertSubset, expandTestSiblings, assertDisjoint, union);
@@ -25,12 +25,12 @@
25
25
  */
26
26
 
27
27
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
28
- import { join, resolve, dirname } from 'node:path';
28
+ import { join, resolve, dirname, relative, isAbsolute, sep } from 'node:path';
29
29
  import { createInterface } from 'node:readline';
30
30
 
31
31
  import {
32
32
  assertGlabExists, setVerbose as setGlabVerbose,
33
- listGroupRepos, checkVaultYaml, fetchRepoOwner,
33
+ listGroupRepos, checkVaultYaml,
34
34
  } from './lib/vault-backfill/glab.mjs';
35
35
 
36
36
  import {
@@ -155,11 +155,25 @@ function createVaultFolderStub(vaultDir, slug) {
155
155
 
156
156
  // ── Process one repo ──────────────────────────────────────────────────────────
157
157
 
158
+ /** Derive the complete parent namespace from a canonical GitLab project path. */
159
+ function parentNamespace(repoPath) {
160
+ if (typeof repoPath !== 'string') return 'unknown';
161
+
162
+ const segments = repoPath.split('/');
163
+ if (
164
+ segments.length < 2 ||
165
+ segments.some((segment) => !segment || segment === '.' || segment === '..')
166
+ ) {
167
+ return 'unknown';
168
+ }
169
+
170
+ return segments.slice(0, -1).join('/');
171
+ }
172
+
158
173
  function processRepo(entry, vaultDir, templateContent) {
159
174
  const { path: repoPath, slug, tier, visibility, group, id } = entry;
160
175
  const humanName = slugToHumanName(slug);
161
-
162
- const owner = applyWrites ? fetchRepoOwner(id) : 'unknown';
176
+ const owner = parentNamespace(repoPath);
163
177
 
164
178
  let renderedContent;
165
179
  try {
@@ -184,7 +198,20 @@ function processRepo(entry, vaultDir, templateContent) {
184
198
  }
185
199
 
186
200
  // Apply: write to staging directory at <out-dir>/<group>/<repo>/.vault.yaml
187
- const writePath = join(outDir, repoPath, '.vault.yaml');
201
+ const writePath = resolve(outDir, repoPath, '.vault.yaml');
202
+ const writePathRelativeToOutDir = relative(outDir, writePath);
203
+ if (
204
+ writePathRelativeToOutDir === '..' ||
205
+ writePathRelativeToOutDir.startsWith(`..${sep}`) ||
206
+ isAbsolute(writePathRelativeToOutDir)
207
+ ) {
208
+ const error = 'refusing to write outside staging directory';
209
+ emitAction('write-failed', '', { error });
210
+ process.stderr.write(`[vault-backfill] ERROR: ${error}\n`);
211
+ hadWriteError = true;
212
+ return;
213
+ }
214
+
188
215
  try {
189
216
  mkdirSync(dirname(writePath), { recursive: true });
190
217
  writeFileSync(writePath, renderedContent, 'utf8');
@@ -55,7 +55,8 @@ import { createInterface } from 'node:readline';
55
55
  import { createReadStream } from 'node:fs';
56
56
  import { pathToFileURL } from 'node:url';
57
57
 
58
- import { processLearning, processSession } from './lib/vault-mirror/process.mjs';
58
+ import { processLearning, processSession, getMaskerStats } from './lib/vault-mirror/process.mjs';
59
+ import { emitEvent } from './lib/events.mjs';
59
60
  import { autoCommitVaultMirror } from './lib/vault-mirror/auto-commit.mjs';
60
61
  import { parseColumnFlags, CliFlagError } from './lib/cli-flags.mjs';
61
62
  import { resolveRepoNamespace } from './lib/vault-mirror/namespace.mjs';
@@ -403,6 +404,30 @@ async function main() {
403
404
  }
404
405
  }
405
406
 
407
+ // ── Masking telemetry (#1025) ───────────────────────────────────────────────
408
+ //
409
+ // Emitted UNCONDITIONALLY, exactly once per channel run, and HERE — at the end
410
+ // of the run rather than at the lazy build site inside process.mjs. The build
411
+ // site is only reached once a record is actually processed, so a run over an
412
+ // empty/fully-skipped source would emit nothing and "the masker never ran" would
413
+ // be indistinguishable from "this channel has no masker wired". Placed BEFORE
414
+ // the --strict-schema abort so a failing run still reports its masking posture.
415
+ //
416
+ // Counts only — never a needle, never a prefix of one, never masked text.
417
+ // Best-effort: a telemetry write must never be the reason a mirror run fails.
418
+ try {
419
+ const maskerStats = getMaskerStats();
420
+ await emitEvent('orchestrator.secret_masker.applied', {
421
+ channel: 'vault-mirror',
422
+ needle_count: maskerStats.needleCount,
423
+ records: maskerStats.records,
424
+ hits: maskerStats.hits,
425
+ dry_run: dryRun,
426
+ });
427
+ } catch {
428
+ // Silent no-op — see the note above.
429
+ }
430
+
406
431
  // --strict-schema: abort with exit 1 when any entry was skipped-invalid.
407
432
  // Useful in CI to catch producer-side schema drift early (issue #249).
408
433
  if (strictSchema && skippedInvalidCount > 0) {
@@ -36,11 +36,11 @@ the GitHub mirror's main-branch SHA so the operator can confirm parity.
36
36
  ```bash
37
37
  prev=""
38
38
  while true; do
39
- s=$(glab ci status --pipeline-id LATEST --output json 2>/dev/null || echo '[]')
40
- cur=$(jq -r '.[] | select(.status!="running" and .status!="pending") | "\(.name): \(.status)"' <<<"$s" 2>/dev/null | sort)
39
+ s=$(glab ci status -R <OWNER>/<REPO> --output json 2>/dev/null || echo '{"jobs":[]}')
40
+ cur=$(jq -r '.jobs[] | select(.status!="running" and .status!="pending") | "\(.name): \(.status)"' <<<"$s" 2>/dev/null | sort)
41
41
  comm -13 <(echo "$prev") <(echo "$cur")
42
42
  prev=$cur
43
- jq -e 'all(.status=="success" or .status=="failed" or .status=="canceled" or .status=="skipped")' <<<"$s" >/dev/null 2>&1 && {
43
+ jq -e '(.jobs|length) > 0 and (.jobs | all(.status=="success" or .status=="failed" or .status=="canceled" or .status=="skipped"))' <<<"$s" >/dev/null 2>&1 && {
44
44
  sha=$(gh api repos/Kanevry/session-orchestrator/commits/main --jq '.sha' 2>/dev/null || echo "(mirror unreachable)")
45
45
  echo "GitHub mirror main: $sha"
46
46
  break
@@ -55,8 +55,28 @@ done
55
55
  SHA — silence at the end means glab JSON parsing failed (the `||` fallbacks
56
56
  prevent the whole loop from dying).
57
57
 
58
+ **Probed 2026-08-14 (glab 1.91.0), three corrections — #1022.** The snippet
59
+ above was silence-is-not-success in its own right until that date, and each
60
+ half failed into the next one's fallback:
61
+
62
+ - `--pipeline-id LATEST` is not a glab flag on any `ci` subcommand
63
+ (`ERROR Unknown flag`, exit 1), so `$s` was always the `||` fallback. There is
64
+ no replacement — the argument-less form already selects the current branch's
65
+ pipeline. Avoid `--branch=<name>` here: it pins a snapshot taken when the
66
+ monitor was armed.
67
+ - The payload is an OBJECT (`{"jobs":[…],"pipeline":{…}}`), so `.[]` raised
68
+ `Cannot index array with string "status"` (jq exit 5) on every real response.
69
+ The accessor is `.jobs[]`.
70
+ - `all(…)` over the empty fallback returns **true**, so the loop broke and
71
+ printed the mirror SHA on its FIRST iteration whenever glab hiccuped — a
72
+ transient network error read as "pipeline finished". The `(.jobs|length) > 0`
73
+ guard is what makes the fallback non-terminal; verified by running the
74
+ terminal test against `{"jobs":[]}` (exit 1 = keep watching) versus the old
75
+ form against `[]` (exit 0 = break).
76
+
58
77
  **GitHub-mirror equivalent.** When the pipeline is GitHub-Actions-native (PR
59
- checks rather than a GitLab pipeline), use `gh pr checks <pr> --watch --fail-fast`
78
+ checks rather than a GitLab pipeline), use
79
+ `gh pr checks -R <OWNER>/<REPO> <pr> --watch --fail-fast`
60
80
  as the `command` source — it streams each check transition and exits non-zero on
61
81
  the first failure, so the terminal state is never silent.
62
82