session-orchestrator 5.3.0 → 5.4.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 (206) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/navigator/SKILL.md +22 -0
  3. package/.agents/skills/navigator/agents/openai.yaml +5 -0
  4. package/.agents/skills/test-audit/SKILL.md +21 -0
  5. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  6. package/.claude-plugin/marketplace.json +1 -1
  7. package/.claude-plugin/plugin.json +1 -1
  8. package/.codex-plugin/plugin.json +1 -1
  9. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  10. package/.codex-plugin/skills/navigator/SKILL.md +22 -0
  11. package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
  12. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  13. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  14. package/.cursor/commands/navigator.md +15 -0
  15. package/.cursor/commands/test-audit.md +14 -0
  16. package/.cursor/rules/060-evolve.mdc +27 -12
  17. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  18. package/.cursor/skills/navigator/SKILL.md +13 -0
  19. package/.cursor/skills/test-audit/SKILL.md +12 -0
  20. package/.cursor-plugin/plugin.json +1 -1
  21. package/.orchestrator/policy/blocked-commands.json +1 -1
  22. package/AGENTS.md +9 -31
  23. package/CHANGELOG.md +70 -0
  24. package/README.md +14 -10
  25. package/docs/agent-authoring.md +2 -2
  26. package/docs/baseline.md +10 -1
  27. package/docs/ci-setup.md +57 -4
  28. package/docs/components.md +12 -12
  29. package/docs/events-schema.md +11 -8
  30. package/docs/persona-panel.md +5 -4
  31. package/docs/rule-authoring.md +27 -12
  32. package/docs/scope-collision-guard.md +26 -4
  33. package/docs/session-config-reference.md +55 -6
  34. package/docs/session-config-template.md +11 -2
  35. package/hooks/_lib/hook-import-set.json +27 -23
  36. package/hooks/hooks-codex.json +1 -1
  37. package/hooks/hooks-cursor.json +5 -0
  38. package/hooks/hooks-pi.json +5 -0
  39. package/hooks/hooks.json +6 -1
  40. package/hooks/on-session-start.mjs +53 -0
  41. package/hooks/on-stop.mjs +3 -1
  42. package/hooks/post-tool-batch-wave-signal.mjs +3 -1
  43. package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
  44. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  45. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  46. package/hooks/pre-task-scope-disjoint.mjs +559 -72
  47. package/hooks/subagent-telemetry.mjs +96 -19
  48. package/package.json +3 -3
  49. package/pi/prompts/navigator.md +12 -0
  50. package/pi/prompts/test-audit.md +12 -0
  51. package/rules/README.md +6 -1
  52. package/rules/_index.md +3 -3
  53. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  54. package/rules/opt-in-domain/prompt-caching.md +5 -1
  55. package/rules/opt-in-stack/backend-data.md +9 -1
  56. package/rules/opt-in-stack/backend.md +7 -1
  57. package/rules/opt-in-stack/frontend.md +6 -1
  58. package/rules/opt-in-stack/security-web.md +7 -1
  59. package/rules/opt-in-stack/swift.md +5 -1
  60. package/scripts/apply-session-learnings.mjs +492 -0
  61. package/scripts/archive-closed-prds.mjs +2 -1
  62. package/scripts/autopilot.mjs +49 -53
  63. package/scripts/backfill-abandoned-sessions.mjs +1 -1
  64. package/scripts/baseline-archetypes.mjs +1 -1
  65. package/scripts/check-package-manager.mjs +62 -5
  66. package/scripts/cursor-install.mjs +1 -4
  67. package/scripts/emit-session.mjs +296 -2
  68. package/scripts/export-hw-learnings.mjs +22 -3
  69. package/scripts/fleet-checkin.mjs +147 -0
  70. package/scripts/lib/agent-frontmatter.mjs +3 -3
  71. package/scripts/lib/auto-dream.mjs +7 -6
  72. package/scripts/lib/autopilot/flags.mjs +6 -1
  73. package/scripts/lib/autopilot/loop.mjs +10 -6
  74. package/scripts/lib/baseline-archetypes.mjs +41 -22
  75. package/scripts/lib/config/host-paths.mjs +79 -0
  76. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  77. package/scripts/lib/config/reaper.mjs +7 -6
  78. package/scripts/lib/config-schema.mjs +13 -0
  79. package/scripts/lib/config.mjs +71 -10
  80. package/scripts/lib/events.mjs +21 -2
  81. package/scripts/lib/fleet-protocol.mjs +270 -0
  82. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  83. package/scripts/lib/gates/gate-full.mjs +7 -2
  84. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  85. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  86. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  87. package/scripts/lib/instruction-budget-guard.mjs +69 -11
  88. package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
  89. package/scripts/lib/learnings/io.mjs +154 -26
  90. package/scripts/lib/learnings/schema.mjs +163 -15
  91. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  92. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  93. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  94. package/scripts/lib/named-vault-resolver.mjs +104 -5
  95. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  96. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  97. package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
  98. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  99. package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
  100. package/scripts/lib/orphan-reaper/scan.mjs +506 -0
  101. package/scripts/lib/orphan-reaper.mjs +43 -1315
  102. package/scripts/lib/owner-yaml.mjs +104 -6
  103. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  104. package/scripts/lib/process-group.mjs +62 -11
  105. package/scripts/lib/project-hygiene.mjs +21 -1
  106. package/scripts/lib/quality-gate.mjs +45 -5
  107. package/scripts/lib/resource-probe.mjs +1 -1
  108. package/scripts/lib/rule-loader.mjs +100 -62
  109. package/scripts/lib/rules-sync.mjs +27 -12
  110. package/scripts/lib/scope-baseline.mjs +3 -4
  111. package/scripts/lib/session-record-repair.mjs +183 -26
  112. package/scripts/lib/session-registry.mjs +27 -2
  113. package/scripts/lib/session-schema/constants.mjs +30 -4
  114. package/scripts/lib/session-schema/validator.mjs +28 -1
  115. package/scripts/lib/session-token-rollup.mjs +245 -52
  116. package/scripts/lib/sessions-canonical.mjs +100 -9
  117. package/scripts/lib/sh-quote.mjs +29 -0
  118. package/scripts/lib/subagents-schema.mjs +30 -3
  119. package/scripts/lib/telemetry/pricing.mjs +118 -71
  120. package/scripts/lib/tests-src-ratio.mjs +140 -23
  121. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  122. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  123. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  124. package/scripts/lib/validate/check-agents.mjs +1 -1
  125. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  126. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  127. package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
  128. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  129. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  130. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  131. package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
  132. package/scripts/lib/vault-status/board-writer.mjs +48 -11
  133. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  134. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  135. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  136. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  137. package/scripts/lib/wave-resource-gate.mjs +34 -8
  138. package/scripts/memory-propose.mjs +18 -5
  139. package/scripts/release-session-lock.mjs +2 -1
  140. package/scripts/release.mjs +10 -3
  141. package/scripts/repair-invalid-sessions.mjs +37 -4
  142. package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
  143. package/scripts/run-quality-gate.mjs +323 -302
  144. package/scripts/site-numbers.mjs +163 -5
  145. package/scripts/sweep-expired-learnings.mjs +56 -5
  146. package/scripts/sweep-expired-rules.mjs +2 -1
  147. package/scripts/validate-plugin.mjs +7 -0
  148. package/scripts/vault-integration-watcher.mjs +2 -1
  149. package/scripts/vault-mirror.mjs +72 -46
  150. package/scripts/wave-scope-binding.mjs +2 -1
  151. package/skills/_shared/config-reading.md +3 -3
  152. package/skills/_shared/fleet-protocol.md +131 -0
  153. package/skills/_shared/state-ownership.md +5 -0
  154. package/skills/autopilot/SKILL.md +25 -11
  155. package/skills/bootstrap/SKILL.md +1 -1
  156. package/skills/bootstrap/_shared-template.md +1 -1
  157. package/skills/bootstrap/fast-template.md +1 -1
  158. package/skills/bootstrap/private-contract.md +2 -1
  159. package/skills/bootstrap/public-fallback.md +6 -3
  160. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  161. package/skills/claude-md-drift-check/SKILL.md +10 -10
  162. package/skills/claude-md-drift-check/checker.mjs +26 -18
  163. package/skills/evolve/SKILL.md +2 -2
  164. package/skills/evolve/references/evolve-analyze-mode.md +10 -6
  165. package/skills/memory-cleanup/SKILL.md +1 -1
  166. package/skills/navigator/SKILL.md +193 -0
  167. package/skills/navigator/references/account-check.sh +87 -0
  168. package/skills/navigator/references/fleet-decisions.mjs +231 -0
  169. package/skills/navigator/references/slot-check.sh +114 -0
  170. package/skills/persona-panel/SKILL.md +2 -2
  171. package/skills/persona-panel/persona-format.md +2 -2
  172. package/skills/persona-panel/presets/designer-lens.md +1 -1
  173. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  174. package/skills/persona-panel/presets/pm-lens.md +1 -1
  175. package/skills/plan/mode-retro.md +19 -4
  176. package/skills/quality-gates/SKILL.md +6 -4
  177. package/skills/remote-offload/SKILL.md +4 -2
  178. package/skills/session-end/learning-patterns.md +22 -12
  179. package/skills/session-end/metrics-collection.md +5 -6
  180. package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
  181. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  182. package/skills/session-end/references/session-summary-template.md +1 -1
  183. package/skills/session-end/session-metrics-write.md +22 -60
  184. package/skills/session-plan/SKILL.md +0 -3
  185. package/skills/session-start/SKILL.md +6 -0
  186. package/skills/session-start/references/operations-contract.md +4 -0
  187. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  188. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -2
  189. package/skills/test-audit/SKILL.md +152 -0
  190. package/skills/test-audit/references/campaign.md +111 -0
  191. package/skills/test-audit/references/mutate.sh +50 -0
  192. package/skills/wave-executor/SKILL.md +1 -1
  193. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  194. package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
  195. package/skills/wave-executor/references/wave-loop-review.md +3 -5
  196. package/templates/personas/README.md +5 -5
  197. package/templates/personas/accounting-compliance.v1.md +1 -1
  198. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  199. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  200. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  201. package/templates/personas/buyer-p3-build.v1.md +1 -1
  202. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  203. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  204. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  205. package/templates/personas/klima-ai-expert.v1.md +1 -1
  206. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -78,8 +78,57 @@ import { _parseRemoteHosts } from './config/remote-hosts.mjs';
78
78
  import { _parseEvolve, _parseEvolveDecay } from './config/evolve.mjs';
79
79
  import { _parseSkillEvolution } from './config/skill-evolution.mjs';
80
80
  import { _parseDispatcherAutonomy, resolveDispatcherAutonomy } from './config/dispatcher-autonomy.mjs';
81
- import { loadHostPaths, resolveHostPath } from './config/host-paths.mjs';
81
+ import { loadHostPaths, resolveHostPath, resolveVaultIntegrationHost } from './config/host-paths.mjs';
82
82
  import { resolveNamedBaseline } from './named-baseline-resolver.mjs';
83
+ import { expandTilde } from './common.mjs';
84
+
85
+ /**
86
+ * Resolve `vault-dir` and report which tier produced it (agents/vault#319).
87
+ *
88
+ * Precedence (highest first): SO_VAULT_DIR env > owner.yaml `vault-dirs:`
89
+ * path-prefix match against the cwd > owner.yaml `paths.vault-dir` > committed.
90
+ * The env tier stays on top because tests/setup/vault-guard.mjs relies on it to
91
+ * shadow every host-local vault for the whole suite.
92
+ *
93
+ * Lives here, not in config/host-paths.mjs: host-paths.mjs is a direct CP11
94
+ * helper of check-owner-leakage.mjs, whose standalone vendored copy carries only
95
+ * its three direct helpers. The named-baseline-resolver import edge would make
96
+ * CP11 fail closed there (tests/husky/pre-commit-owner-leakage.test.mjs).
97
+ *
98
+ * The two owner.yaml tiers (`match`, `owner`) come back tilde-expanded: owner.yaml
99
+ * is host-local and `~/…` is the natural way to write a path there, but consumers
100
+ * such as vault-mirror.mjs `existsSync(resolve(vaultDir))` would read a literal
101
+ * `~` as `<cwd>/~/…`. The `env` and `committed` tiers keep their pass-through;
102
+ * vault-mirror.mjs expands its `--vault-dir` at entry for every tier anyway.
103
+ *
104
+ * `cwd` in the ctx is a test-only DI seam; production reads `process.cwd()`.
105
+ *
106
+ * @param {string|null|undefined} committed — value the committed Session Config produced
107
+ * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object, cwd?: string }} [ctx] — from loadHostPaths()
108
+ * @returns {{ value: string|null|undefined, source: 'env'|'match'|'owner'|'committed' }}
109
+ */
110
+ function resolveVaultDir(committed, { env = process.env, ownerConfig, cwd } = {}) {
111
+ const isNonBlank = (v) => typeof v === 'string' && v.trim() !== '';
112
+
113
+ const envVal = env?.SO_VAULT_DIR;
114
+ if (isNonBlank(envVal)) return { value: envVal, source: 'env' };
115
+
116
+ const matched = resolveNamedBaseline({
117
+ cwd: cwd ?? process.cwd(),
118
+ ownerConfig,
119
+ env,
120
+ section: 'vault-dirs',
121
+ envKey: 'SO_VAULT_DIR',
122
+ });
123
+ if (matched.source === 'match' && isNonBlank(matched.path)) {
124
+ return { value: expandTilde(matched.path), source: 'match' };
125
+ }
126
+
127
+ const ownerVal = ownerConfig?.paths?.['vault-dir'];
128
+ if (isNonBlank(ownerVal)) return { value: expandTilde(ownerVal), source: 'owner' };
129
+
130
+ return { value: committed, source: 'committed' };
131
+ }
83
132
  // express-path lives one level UP from config/ (see its module header for why):
84
133
  // the parser is a sibling of config/state-md-lock.mjs in every respect except
85
134
  // its directory. Only the pure parser is imported here — `evaluateExpressPath`
@@ -114,8 +163,9 @@ export { readConfigFile } from './config/io.mjs';
114
163
  * hermetic ctx (e.g. `{ env: {}, ownerConfig: undefined }`) when asserting COMMITTED values:
115
164
  * the default reads the real `owner.yaml`, so a host-local `paths:` override would otherwise
116
165
  * bleed into fixture assertions (incident: 2026-07-03 Full-Gate red after the operator set
117
- * `paths.baseline-path` host-locally). `cwd` feeds the `baselines:` match tier (#819) — a
118
- * test-only DI seam; production always resolves it from `process.cwd()`.
166
+ * `paths.baseline-path` host-locally). `cwd` feeds the `baselines:` (#819) and
167
+ * `vault-dirs:` (agents/vault#319) match tiers — a test-only DI seam; production
168
+ * always resolves it from `process.cwd()`.
119
169
  * @returns {object} config object with EXACT same shape as parse-config.sh stdout
120
170
  * @throws if any enum value is invalid
121
171
  */
@@ -230,6 +280,9 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
230
280
  // heavy-repo / worktree-cleanup: HR-003 preflight fields (templates/shared/.claude/rules/heavy-repo.md),
231
281
  // documented but previously unwired — silently dropped by the parser (baseline issue #60).
232
282
  const heavyRepo = _coerceBoolean(kv, 'heavy-repo', false);
283
+ // offload-first (#1465): route offloadable wave roles to a ready remote host
284
+ // even on a `proceed` resource verdict (consumed by wave-resource-gate.mjs).
285
+ const offloadFirst = _coerceBoolean(kv, 'offload-first', false);
233
286
 
234
287
  // List fields
235
288
  const crossRepos = _coerceList(kv, 'cross-repos', undefined);
@@ -331,18 +384,25 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
331
384
  // vault-integration: parsed from full content (block-scoped, avoids the
332
385
  // pre-#593 KV-name collision where `enabled:` was shared with 15+ other
333
386
  // blocks like docs-orchestrator/vault-staleness/slopcheck).
334
- const vaultIntegration = _parseVaultIntegration(mdContent);
335
- // Host-local override (issue #653) — applied here, NOT inside _parseVaultIntegration,
336
- // so claude-md-drift-check (which calls the parser directly) keeps seeing raw values.
337
- vaultIntegration['vault-dir'] = resolveHostPath('vault-dir', vaultIntegration['vault-dir'], hostCtx);
387
+ // Host-local overrides — applied here, NOT inside _parseVaultIntegration, so
388
+ // claude-md-drift-check (which calls the parser directly) keeps seeing raw values.
389
+ // - the enabled/mode switch (SO#1448): env SO_VAULT_INTEGRATION > owner.yaml
390
+ // `vault-integration:` > committed, LOWER-ONLY; `host-override` names the tier.
391
+ // - vault-dir (#653, agents/vault#319): env > owner.yaml `vault-dirs:` cwd match >
392
+ // owner.yaml paths.vault-dir > committed; `vault-dir-source` names the tier.
393
+ const vaultIntegration = resolveVaultIntegrationHost(_parseVaultIntegration(mdContent), hostCtx);
394
+ const vaultIntegrationDir = resolveVaultDir(vaultIntegration['vault-dir'], hostCtx);
395
+ vaultIntegration['vault-dir'] = vaultIntegrationDir.value;
396
+ vaultIntegration['vault-dir-source'] = vaultIntegrationDir.source;
338
397
 
339
398
  // resource-thresholds sub-keys (v3.1.0 env-aware — issue #166)
340
399
  const resourceThresholds = _parseResourceThresholds(kv);
341
400
 
342
401
  // vault-sync: parsed from full content (can live outside Session Config)
343
402
  const vaultSync = _parseVaultSync(mdContent);
344
- // Host-local override (issue #653) — same host source as vault-integration above.
345
- vaultSync['vault-dir'] = resolveHostPath('vault-dir', vaultSync['vault-dir'], hostCtx);
403
+ // Host-local override (issue #653) — same resolver as vault-integration above, so the
404
+ // two blocks cannot disagree about which vault this repo writes to (agents/vault#319).
405
+ vaultSync['vault-dir'] = resolveVaultDir(vaultSync['vault-dir'], hostCtx).value;
346
406
 
347
407
  // drift-check: parsed from full content (standalone top-level block)
348
408
  const driftCheck = _parseDriftCheck(mdContent);
@@ -408,7 +468,7 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
408
468
  // dispatcher-autonomy: opt-in cross-repo dispatcher autonomy block (Epic #673 / issue #679).
409
469
  // Parser stays pure (raw committed value) for claude-md-drift-check raw-value parity;
410
470
  // the host-local override (env > owner.yaml > committed > off) is overlaid onto the
411
- // FINAL object only — mirroring the vault-dir resolveHostPath pattern above. Reuses the
471
+ // FINAL object only — mirroring the vault-dir host-override pattern above. Reuses the
412
472
  // already-loaded hostCtx.ownerConfig so owner.yaml is not read twice.
413
473
  const dispatcherAutonomy = _parseDispatcherAutonomy(mdContent);
414
474
  dispatcherAutonomy.autonomy = resolveDispatcherAutonomy({
@@ -552,6 +612,7 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
552
612
  'resource-awareness': resourceAwareness,
553
613
  'enable-host-banner': enableHostBanner,
554
614
  'heavy-repo': heavyRepo,
615
+ 'offload-first': offloadFirst,
555
616
  'worktree-cleanup': worktreeCleanup,
556
617
  'resource-thresholds': resourceThresholds,
557
618
  'worktree-exclude': worktreeExclude,
@@ -388,7 +388,11 @@ function waveForRecord(root, attribution) {
388
388
  * metrics/events.jsonl` instead of the module-level `SO_PROJECT_DIR` default
389
389
  * (#941). Ignored when `opts.filePath` is given (explicit path wins). This is
390
390
  * the clean interface replacing the hand-built `join(repoRoot, …)` recipes that
391
- * used to open-code this destination at each call-site.
391
+ * used to open-code this destination at each call-site. A relative or empty
392
+ * `repoRoot` is refused with one stderr WARN and nothing is written (no line,
393
+ * no directory, no webhook): joining it would land relative to the working
394
+ * directory — the incident class where an unset env var produced
395
+ * `undefined/…/.orchestrator/metrics/events.jsonl` under the cwd.
392
396
  * @returns {Promise<void>}
393
397
  */
394
398
  export async function emitEvent(type, payload = {}, opts = {}) {
@@ -434,11 +438,26 @@ export async function emitEvent(type, payload = {}, opts = {}) {
434
438
 
435
439
  // Ensure the destination directory exists before appending. Resolution order:
436
440
  // 1. explicit opts.filePath (a pre-resolved path — #611)
437
- // 2. opts.repoRoot → <repoRoot>/.orchestrator/metrics/events.jsonl (#941)
441
+ // 2. opts.repoRoot → <repoRoot>/.orchestrator/metrics/events.jsonl (#941),
442
+ // or refused when relative/empty (WARN, no write — see below)
438
443
  // 3. the SO_PROJECT_DIR default (unchanged for 2-arg callers)
439
444
  // eventsFilePath(undefined) falls through to its SO_PROJECT_DIR default, so a
440
445
  // caller passing neither behaves EXACTLY as before (additive) — except under
441
446
  // the test-only EVENTS_LEDGER_SANDBOX_ENV seam, which only 3. honours.
447
+ // An explicit filePath is never second-guessed. A defined repoRoot that is not
448
+ // absolute would be joined relative to the cwd, so it is refused: WARN and
449
+ // return (hooks must never throw). The value itself is not printed — it may
450
+ // carry a host-local path.
451
+ if (
452
+ opts.filePath === undefined &&
453
+ opts.repoRoot !== undefined &&
454
+ (typeof opts.repoRoot !== 'string' || !path.isAbsolute(opts.repoRoot))
455
+ ) {
456
+ process.stderr.write(
457
+ `[events] ${String(type)} not emitted: opts.repoRoot is not an absolute path; refusing to write relative to the working directory.\n`,
458
+ );
459
+ return;
460
+ }
442
461
  const filePath = opts.filePath ?? eventsFilePath(opts.repoRoot);
443
462
  await fs.mkdir(path.dirname(filePath), { recursive: true });
444
463
  await fs.appendFile(filePath, line, 'utf8');
@@ -0,0 +1,270 @@
1
+ /**
2
+ * fleet-protocol.mjs — Fleet protocol v1: file-based navigator lease + session
3
+ * check-ins under `~/.config/navigator/` (#1462, PRD !53 § 3).
4
+ *
5
+ * The protocol state lives in plain files so every platform (Claude Code, Codex,
6
+ * a headless run) can read and write it without a server:
7
+ *
8
+ * <navigatorDir>/leases/navigator.json — the navigator lease
9
+ * <navigatorDir>/checkin/<session_id>.json — one check-in per session
10
+ * <navigatorDir>/auflagen/<session_id>.json — conditions issued to a session
11
+ *
12
+ * `<navigatorDir>` is `NAVIGATOR_CONFIG_DIR` (trimmed; empty/whitespace counts as
13
+ * unset) or `<home>/.config/navigator`. Files are 0600, directories 0700,
14
+ * timestamps `date -u +%FT%TZ` (`YYYY-MM-DDTHH:MM:SSZ`).
15
+ *
16
+ * "A navigator is active" means exactly one thing: a readable, well-formed lease
17
+ * whose `laeuft_ab` lies in the future. Everything else is fail-closed — an
18
+ * expired lease is `none`, a lease that cannot be read or validated is
19
+ * `unreadable`, and neither is ever `active`.
20
+ *
21
+ * Session ids become file names, so {@link isSafeSessionId} is the security
22
+ * boundary between check-in input and the filesystem: every path builder that
23
+ * takes a session id refuses an unsafe one with a TypeError.
24
+ */
25
+
26
+ import { promises as fs } from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+
30
+ /** Env var that overrides the navigator directory. */
31
+ export const NAVIGATOR_CONFIG_DIR_ENV = 'NAVIGATOR_CONFIG_DIR';
32
+
33
+ /** Minutes a session waits for a navigator before it falls back (PRD !53 § 3). */
34
+ export const CHECKIN_FALLBACK_MIN = 10;
35
+
36
+ const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/;
37
+ const SESSION_ID_MAX = 128;
38
+ const UTC_SECONDS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
39
+
40
+ /** Lease timestamps: UTC with `Z`, seconds or milliseconds — never a local-time reading. */
41
+ const LEASE_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
42
+
43
+ const PLATTFORMEN = ['claude', 'codex', 'kopflos'];
44
+ const MODI = ['housekeeping', 'feature', 'deep', 'operations'];
45
+
46
+ /**
47
+ * Resolve the navigator directory.
48
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
49
+ * @returns {string} absolute path
50
+ */
51
+ export function navigatorDir({ env = process.env, home = os.homedir() } = {}) {
52
+ const raw = env[NAVIGATOR_CONFIG_DIR_ENV];
53
+ const override = typeof raw === 'string' ? raw.trim() : '';
54
+ if (override) return path.resolve(override);
55
+ return path.join(home, '.config', 'navigator');
56
+ }
57
+
58
+ /**
59
+ * Path of the navigator lease file.
60
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
61
+ * @returns {string}
62
+ */
63
+ export function leasePath(opts) {
64
+ return path.join(navigatorDir(opts), 'leases', 'navigator.json');
65
+ }
66
+
67
+ /**
68
+ * True when `id` is safe to use as a single file-name component: only
69
+ * `[A-Za-z0-9._-]`, 1..128 chars, and not `.` / `..`. This excludes `/`, `\`,
70
+ * NUL and every other separator by construction.
71
+ * @param {unknown} id
72
+ * @returns {boolean}
73
+ */
74
+ export function isSafeSessionId(id) {
75
+ return (
76
+ typeof id === 'string' &&
77
+ id.length > 0 &&
78
+ id.length <= SESSION_ID_MAX &&
79
+ id !== '.' &&
80
+ id !== '..' &&
81
+ SESSION_ID_RE.test(id)
82
+ );
83
+ }
84
+
85
+ /** @param {unknown} sessionId */
86
+ function assertSafeSessionId(sessionId) {
87
+ if (!isSafeSessionId(sessionId)) {
88
+ throw new TypeError(`unsafe session id: ${JSON.stringify(String(sessionId)).slice(0, 80)}`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Path of a session's check-in file.
94
+ * @param {string} sessionId
95
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
96
+ * @returns {string}
97
+ * @throws {TypeError} when `sessionId` fails {@link isSafeSessionId}
98
+ */
99
+ export function checkinPath(sessionId, opts) {
100
+ assertSafeSessionId(sessionId);
101
+ return path.join(navigatorDir(opts), 'checkin', `${sessionId}.json`);
102
+ }
103
+
104
+ /**
105
+ * Path of a session's conditions ("Auflagen") file.
106
+ * @param {string} sessionId
107
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
108
+ * @returns {string}
109
+ * @throws {TypeError} when `sessionId` fails {@link isSafeSessionId}
110
+ */
111
+ export function auflagenPath(sessionId, opts) {
112
+ assertSafeSessionId(sessionId);
113
+ return path.join(navigatorDir(opts), 'auflagen', `${sessionId}.json`);
114
+ }
115
+
116
+ /**
117
+ * Format a date as `YYYY-MM-DDTHH:MM:SSZ` (the output of `date -u +%FT%TZ`).
118
+ * @param {Date} [date]
119
+ * @returns {string}
120
+ */
121
+ export function utcSecondsTimestamp(date = new Date()) {
122
+ return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
123
+ }
124
+
125
+ /** @param {unknown} v */
126
+ function isPlainObject(v) {
127
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
128
+ }
129
+
130
+ /** @param {unknown} v */
131
+ function isNonEmptyString(v) {
132
+ return typeof v === 'string' && v.length > 0;
133
+ }
134
+
135
+ /** @param {unknown} v */
136
+ function isIsoTimestamp(v) {
137
+ return typeof v === 'string' && !Number.isNaN(Date.parse(v));
138
+ }
139
+
140
+ function isLeaseTimestamp(v) {
141
+ return typeof v === 'string' && LEASE_TS_RE.test(v) && isIsoTimestamp(v);
142
+ }
143
+
144
+ const REQUIRED_CHECKIN_FIELDS = [
145
+ 'session', 'plattform', 'repo', 'modus', 'auftrag_ref',
146
+ 'kandidaten', 'schreibbereich', 'rueckfall', 'zeit',
147
+ ];
148
+
149
+ /** Repo name shape accepted in a check-in: `name` or `group/name`, no path parts. */
150
+ const REPO_NAME_RE = /^[A-Za-z0-9._-]{1,100}(?:\/[A-Za-z0-9._-]{1,100})?$/;
151
+
152
+ /** Lease `adresse` shape (a ListAgents peer name): no whitespace, no control bytes. */
153
+ const ADRESSE_RE = /^[A-Za-z0-9._:@-]{1,128}$/;
154
+
155
+ /** True for an issue reference: a non-negative integer or a `#123` / `123` string. */
156
+ function isIssueRef(v) {
157
+ if (typeof v === 'number') return Number.isInteger(v) && v >= 0;
158
+ return typeof v === 'string' && /^#?\d{1,9}$/.test(v);
159
+ }
160
+
161
+ /**
162
+ * Per-field type checks. Each returns an error message or null. Applied to every
163
+ * PRESENT field; presence of the required ones is checked separately.
164
+ * @type {Record<string, (v: unknown) => string|null>}
165
+ */
166
+ const CHECKIN_FIELD_CHECKS = {
167
+ session: (v) =>
168
+ isSafeSessionId(v) ? null : 'session must be a safe id: [A-Za-z0-9._-], 1..128 chars, not . or ..',
169
+ plattform: (v) => (PLATTFORMEN.includes(/** @type {string} */ (v)) ? null : `plattform must be one of ${PLATTFORMEN.join('|')}`),
170
+ // A repo NAME (optionally `group/name`), never a filesystem path: the value
171
+ // travels in `orchestrator.fleet.checkin`, which may leave the host over the
172
+ // optional events webhook (docs/events-schema.md: "never the absolute root").
173
+ repo: (v) => (typeof v === 'string' && REPO_NAME_RE.test(v) ? null : 'repo must be a repo name [A-Za-z0-9._-] (optionally group/name), not a path'),
174
+ repo_id: (v) => (v === null || typeof v === 'string' ? null : 'repo_id must be a string or null'),
175
+ worktree: (v) => (typeof v === 'boolean' ? null : 'worktree must be a boolean'),
176
+ modus: (v) => (MODI.includes(/** @type {string} */ (v)) ? null : `modus must be one of ${MODI.join('|')}`),
177
+ auftrag_ref: (v) => (isNonEmptyString(v) ? null : 'auftrag_ref must be a non-empty string'),
178
+ konto_slot: (v) =>
179
+ v === null || typeof v === 'string' || (typeof v === 'number' && Number.isFinite(v))
180
+ ? null
181
+ : 'konto_slot must be a number, a string or null',
182
+ quota: (v) => (typeof v === 'string' ? null : 'quota must be a string'),
183
+ stand: (v) => (isPlainObject(v) ? null : 'stand must be an object'),
184
+ // Issue numbers only (42 or "#42"), at most 64 — same webhook reasoning as `repo`.
185
+ kandidaten: (v) =>
186
+ Array.isArray(v) && v.length <= 64 && v.every(isIssueRef)
187
+ ? null
188
+ : 'kandidaten must be an array of at most 64 issue numbers (42 or "#42")',
189
+ schreibbereich: (v) =>
190
+ Array.isArray(v) && v.every((e) => typeof e === 'string') ? null : 'schreibbereich must be an array of strings',
191
+ bedarf: (v) => (isPlainObject(v) ? null : 'bedarf must be an object'),
192
+ vorab_geschrieben: (v) => (Array.isArray(v) ? null : 'vorab_geschrieben must be an array'),
193
+ rueckfall: (v) => (isNonEmptyString(v) ? null : 'rueckfall must be a non-empty string'),
194
+ zeit: (v) =>
195
+ typeof v === 'string' && UTC_SECONDS_RE.test(v) && isIsoTimestamp(v)
196
+ ? null
197
+ : 'zeit must be a UTC timestamp YYYY-MM-DDTHH:MM:SSZ',
198
+ };
199
+
200
+ /**
201
+ * Validate a check-in record (PRD !53 table 3.2).
202
+ * @param {unknown} obj
203
+ * @returns {string[]} error messages; empty means valid
204
+ */
205
+ export function validateCheckin(obj) {
206
+ if (!isPlainObject(obj)) return ['check-in must be a JSON object'];
207
+ const rec = /** @type {Record<string, unknown>} */ (obj);
208
+ const errors = [];
209
+ for (const field of REQUIRED_CHECKIN_FIELDS) {
210
+ if (rec[field] === undefined) errors.push(`missing required field: ${field}`);
211
+ }
212
+ for (const [field, check] of Object.entries(CHECKIN_FIELD_CHECKS)) {
213
+ if (rec[field] === undefined) continue;
214
+ const err = check(rec[field]);
215
+ if (err) errors.push(err);
216
+ }
217
+ return errors;
218
+ }
219
+
220
+ /**
221
+ * Validate a parsed lease; returns the first problem or null.
222
+ * @param {unknown} lease
223
+ * @returns {string|null}
224
+ */
225
+ function leaseProblem(lease) {
226
+ if (!isPlainObject(lease)) return 'lease is not a JSON object';
227
+ const l = /** @type {Record<string, unknown>} */ (lease);
228
+ // Shape-checked at the source ("escaped at generation"): both values can reach
229
+ // the operator's session-start banner, so control bytes are refused here.
230
+ if (!isSafeSessionId(l.session_id)) return 'lease.session_id missing or not a safe id';
231
+ if (!PLATTFORMEN.includes(/** @type {string} */ (l.plattform))) return `lease.plattform must be one of ${PLATTFORMEN.join('|')}`;
232
+ if (l.adresse !== undefined && l.adresse !== null && !(typeof l.adresse === 'string' && ADRESSE_RE.test(l.adresse))) {
233
+ return 'lease.adresse must be null or a peer name [A-Za-z0-9._:@-] (1..128 chars)';
234
+ }
235
+ if (l.uebergabe_an !== undefined && l.uebergabe_an !== null && typeof l.uebergabe_an !== 'string') {
236
+ return 'lease.uebergabe_an must be a string or null';
237
+ }
238
+ // Strict UTC form: a `Z`-less value would be read in the host's local time and
239
+ // could EXTEND the lease west of UTC — a fail-open reading, hence unreadable.
240
+ if (!isLeaseTimestamp(l.seit)) return 'lease.seit missing or not a UTC timestamp (…Z)';
241
+ if (!isLeaseTimestamp(l.laeuft_ab)) return 'lease.laeuft_ab missing or not a UTC timestamp (…Z)';
242
+ return null;
243
+ }
244
+
245
+ /**
246
+ * Read the navigator lease. Never throws; fail-closed.
247
+ *
248
+ * @param {{ now?: number, env?: Record<string, string|undefined>, home?: string }} [opts]
249
+ * @returns {Promise<{ state: 'active', lease: object } | { state: 'none' } | { state: 'unreadable', reason: string }>}
250
+ */
251
+ export async function readNavigatorLease({ now = Date.now(), env, home } = {}) {
252
+ let raw;
253
+ try {
254
+ raw = await fs.readFile(leasePath({ env, home }), 'utf8');
255
+ } catch (err) {
256
+ if (err && /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return { state: 'none' };
257
+ const code = err && /** @type {NodeJS.ErrnoException} */ (err).code;
258
+ return { state: 'unreadable', reason: `read failed: ${code || 'unknown error'}` };
259
+ }
260
+ let lease;
261
+ try {
262
+ lease = JSON.parse(raw);
263
+ } catch {
264
+ return { state: 'unreadable', reason: 'lease is not valid JSON' };
265
+ }
266
+ const problem = leaseProblem(lease);
267
+ if (problem) return { state: 'unreadable', reason: problem };
268
+ if (Date.parse(lease.laeuft_ab) <= now) return { state: 'none' };
269
+ return { state: 'active', lease };
270
+ }
@@ -5,7 +5,7 @@
5
5
  // Required env: TYPECHECK_CMD, TEST_CMD
6
6
  // Each may equal "skip" or empty string → treated as skip.
7
7
 
8
- import { runCheck } from './gate-helpers.mjs';
8
+ import { runCheck, commandKillFields } from './gate-helpers.mjs';
9
9
 
10
10
  const typecheckCmd = process.env.TYPECHECK_CMD;
11
11
  const testCmd = process.env.TEST_CMD;
@@ -57,8 +57,18 @@ if (testCmd !== 'skip') {
57
57
 
58
58
  const result = {
59
59
  variant: 'baseline',
60
- typecheck: tcResult.status,
61
- test: testResult.status,
60
+ // Each command object also carries its INNER kill ladder (`timed_out`,
61
+ // `kill_signals`, `survivors` pids) when `runCheck` spawned it — omitted for
62
+ // skip/stub (#1457). `run-quality-gate.mjs` joins them into its event.
63
+ typecheck: { status: tcResult.status, ...commandKillFields(tcResult) },
64
+ test: { status: testResult.status, ...commandKillFields(testResult) },
65
+ // Stub map, same shape as gate-full: a command `runCheck` recognised as a
66
+ // stub (`echo …`/`true`) spawned nothing, so `joinGateKillFields` may count
67
+ // it as measured instead of declaring the whole inner ladder unknown.
68
+ stubbed: {
69
+ ...(tcResult.stubbed ? { typecheck: tcResult.stubbed } : {}),
70
+ ...(testResult.stubbed ? { test: testResult.stubbed } : {}),
71
+ },
62
72
  typecheck_output: tcResult.output,
63
73
  test_output: testResult.output,
64
74
  };
@@ -11,6 +11,7 @@ import {
11
11
  extractTestCounts,
12
12
  extractFailedTestFiles,
13
13
  collectDebugArtifacts,
14
+ commandKillFields,
14
15
  } from './gate-helpers.mjs';
15
16
 
16
17
  const typecheckCmd = process.env.TYPECHECK_CMD;
@@ -145,7 +146,10 @@ if (lintResult.stubbed) stubbed.lint = lintResult.stubbed;
145
146
  const output = {
146
147
  variant: 'full-gate',
147
148
  duration_seconds: durationSeconds,
148
- typecheck: { status: tcResult.status, error_count: tcErrorCount },
149
+ // Each command object also carries its INNER kill ladder (`timed_out`,
150
+ // `kill_signals`, `survivors` pids) when `runCheck` spawned it — omitted for
151
+ // skip/stub (#1457). `run-quality-gate.mjs` joins them into its event.
152
+ typecheck: { status: tcResult.status, error_count: tcErrorCount, ...commandKillFields(tcResult) },
149
153
  // `failed` is published explicitly (#967 item 1) rather than left to be
150
154
  // re-derived as `total - passed` downstream: the derivation and the parse can
151
155
  // disagree, and only an explicit third number lets a consumer check
@@ -161,8 +165,9 @@ const output = {
161
165
  // read just passed/failed/total, so the stdout JSON contract stays one
162
166
  // document and the test-case triple keeps `total === passed + failed`.
163
167
  ...fileFields,
168
+ ...commandKillFields(testResult),
164
169
  },
165
- lint: { status: lintResult.status, warnings: lintWarnings },
170
+ lint: { status: lintResult.status, warnings: lintWarnings, ...commandKillFields(lintResult) },
166
171
  debug_artifacts: debugArtifacts,
167
172
  stubbed,
168
173
  };
@@ -123,6 +123,95 @@ export function gateTimeoutEnvelope({ variant, timeoutMs, run }) {
123
123
  };
124
124
  }
125
125
 
126
+ /**
127
+ * The INNER kill ladder of one gate command, in the gate envelope's own
128
+ * snake_case shape (#1457 point 1).
129
+ *
130
+ * `runCheck` knows, per command, whether its per-command ceiling fired, which
131
+ * signals it sent and which pids survived the SIGKILL step. Until this helper
132
+ * that knowledge died inside the gate sub-script, so a command killed by the
133
+ * INNER ceiling reached `orchestrator.quality_gate.failed` as
134
+ * `timed_out: false`. Spread it into the command's envelope object.
135
+ *
136
+ * Absent is not zero: a result that spawned nothing (skip, stub) carries no
137
+ * `timedOut` boolean, and gets NO keys — never `false` / `[]`.
138
+ *
139
+ * @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]}} result
140
+ * A {@link runCheck} result.
141
+ * @returns {{timed_out: boolean, kill_signals: string[], survivors: number[]}|{}}
142
+ * `survivors` are the pids, same as {@link gateTimeoutEnvelope}.
143
+ */
144
+ export function commandKillFields(result) {
145
+ if (typeof result?.timedOut !== 'boolean') return {};
146
+ return {
147
+ timed_out: result.timedOut,
148
+ kill_signals: Array.isArray(result.killSignals) ? [...result.killSignals] : [],
149
+ survivors: Array.isArray(result.survivors) ? [...result.survivors] : [],
150
+ };
151
+ }
152
+
153
+ /** Envelope keys, in execution order, that may carry {@link commandKillFields}. */
154
+ const KILL_FIELD_COMMANDS = ['typecheck', 'test', 'lint'];
155
+
156
+ /**
157
+ * Join the OUTER kill ladder (the gate sub-script as a whole) with the INNER
158
+ * per-command ladders its JSON envelope publishes (#1457 point 1), into the
159
+ * `timed_out` / `survivors` / `kill_signals` keys of
160
+ * `orchestrator.quality_gate.{passed,failed}`.
161
+ *
162
+ * - inner measured (the envelope parses and EVERY command object either carries
163
+ * a boolean `timed_out` or provably spawned nothing — `status: 'skip'` or
164
+ * listed in `stubbed`): `timed_out` = outer OR any inner; `kill_signals` =
165
+ * outer followed by each command's, in execution order; `survivors` = outer
166
+ * count plus every inner pid count.
167
+ * - inner NOT measured (a variant whose envelope does not publish it, an
168
+ * unparseable envelope, or the outer ceiling killed the sub-script before it
169
+ * wrote one): the outer ladder alone when it fired — a kill is a kill — and
170
+ * otherwise NO keys, because `false` / `0` / `[]` would claim that no
171
+ * command was killed when nobody looked.
172
+ *
173
+ * Never throws.
174
+ *
175
+ * @param {object} args
176
+ * @param {{timed_out: boolean, survivors: number, kill_signals: string[]}} args.outer
177
+ * `gateKillFields()` of the sub-script's `spawnInGroup` result.
178
+ * @param {string} args.gateStdout The sub-script's stdout envelope (`''` on an outer kill).
179
+ * @returns {{timed_out: boolean, survivors: number, kill_signals: string[]}|{}}
180
+ */
181
+ export function joinGateKillFields({ outer, gateStdout }) {
182
+ let parsed = null;
183
+ if (typeof gateStdout === 'string' && gateStdout.trim()) {
184
+ try {
185
+ parsed = JSON.parse(gateStdout);
186
+ } catch {
187
+ parsed = null;
188
+ }
189
+ }
190
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
191
+ const names = isObj(parsed) ? KILL_FIELD_COMMANDS.filter((name) => isObj(parsed[name])) : [];
192
+ const stubbed = isObj(parsed?.stubbed) ? parsed.stubbed : {};
193
+ // A command either published its ladder, or provably spawned nothing (skip /
194
+ // stub) — nothing killed is then a measurement too. Any OTHER command (a
195
+ // variant that ran it but does not publish the ladder) makes the inner side
196
+ // unknown as a whole.
197
+ const inner = names.map((name) => parsed[name]).filter((c) => typeof c.timed_out === 'boolean');
198
+ const innerKnown = names.length > 0 && names.every((name) => typeof parsed[name].timed_out === 'boolean'
199
+ || parsed[name].status === 'skip'
200
+ || Boolean(stubbed[name]));
201
+
202
+ if (!innerKnown) return outer?.timed_out === true ? { ...outer } : {};
203
+
204
+ return {
205
+ timed_out: outer?.timed_out === true || inner.some((c) => c.timed_out),
206
+ survivors: (outer?.survivors ?? 0)
207
+ + inner.reduce((n, c) => n + (Array.isArray(c.survivors) ? c.survivors.length : 0), 0),
208
+ kill_signals: [
209
+ ...(outer?.kill_signals ?? []),
210
+ ...inner.flatMap((c) => (Array.isArray(c.kill_signals) ? c.kill_signals : [])),
211
+ ],
212
+ };
213
+ }
214
+
126
215
  /**
127
216
  * Decide WHAT a finished gate sub-script run publishes — stdout, stderr,
128
217
  * exit code and operator warnings — without performing any of the writes.
@@ -614,8 +703,10 @@ export function extractFailedTestFiles(output, cwd = process.cwd()) {
614
703
  *
615
704
  * This function sees a candidate triple and nothing else. It performs no I/O and
616
705
  * reads no files. The following are per-caller INPUT ADAPTERS and must not
617
- * migrate here: JSON-envelope parsing; the `test`-object-vs-status-string
618
- * variant discrimination (non-full-gate variants emit a bare status string);
706
+ * migrate here: JSON-envelope parsing; the `test`-must-be-an-object check
707
+ * (every variant emits an object since #1457 pkt 12; only full-gate's carries
708
+ * counts, so the others fall through to this function's own finite-number
709
+ * refusal);
619
710
  * `test.status ∈ {pass, fail}`; the `parsed.stubbed?.test` short-circuit; the
620
711
  * raw-text tail parse ({@link extractTestCounts}); and the positional evidence
621
712
  * that the test gate ran at all — which each caller expresses by handing over