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
@@ -36,6 +36,16 @@
36
36
  * dispatcher: (optional; host-local cross-repo dispatcher autonomy override — #679)
37
37
  * autonomy: string ('' = no override; resolver enum off | advisory | autonomous-gated;
38
38
  * precedence env SO_DISPATCHER_AUTONOMY > this > committed > off)
39
+ * vault-integration: (optional; host-local switch for the vault-integration gate — SO#1448)
40
+ * enabled: boolean (false = off on this host; true is ignored — the host may not enable)
41
+ * mode: 'off' | 'warn' | 'strict' (applied only when weaker than the committed mode;
42
+ * precedence env SO_VAULT_INTEGRATION > this > committed.
43
+ * An invalid value drops ONLY this section, see loadOwnerConfig)
44
+ * vault-dirs: (optional list; per-directory vault-dir — agents/vault#319)
45
+ * - path: string (the vault directory)
46
+ * match: { path-prefix: string } (required; cwd directory prefix, first match wins)
47
+ * name: string (optional label, defaults to path)
48
+ * precedence env SO_VAULT_DIR > this > paths.vault-dir > committed
39
49
  *
40
50
  * ── Exports ───────────────────────────────────────────────────────────────────
41
51
  *
@@ -44,7 +54,8 @@
44
54
  * validateOwnerSections(obj) — pure validation, no I/O; bucketed per section (#820)
45
55
  * validateOwnerConfig(obj) — pure validation, no I/O; thin wrapper over validateOwnerSections
46
56
  * loadOwnerConfig({path?}) — reads file; per-section tolerance for OPTIONAL
47
- * sections (paths/dispatcher/vaults/baselines) — #820
57
+ * sections (paths/dispatcher/vault-integration/vaults/
58
+ * baselines/vault-dirs) — #820
48
59
  * writeOwnerConfig(config, {path?}) — validates, writes YAML, creates dir
49
60
  * getDefaults() — returns sensible default config object
50
61
  *
@@ -202,9 +213,12 @@ const REQUIRED_SECTIONS = /** @type {const} */ (['owner', 'tone', 'efficiency',
202
213
  * sections — currently `scripts/lib/owner-config-banner.mjs`, which derived the
203
214
  * #1244 "survived a whole-file discard" set from a hand-mirrored local copy.
204
215
  * Frozen so a consumer cannot mutate the shared array.
216
+ *
217
+ * `vault-integration` has no `getDefaults()` entry: absent means "no host
218
+ * override", so a dropped section simply leaves the committed gate in force.
205
219
  */
206
220
  export const OPTIONAL_OBJECT_SECTIONS = /** @type {const} */ (
207
- Object.freeze(['paths', 'dispatcher'])
221
+ Object.freeze(['paths', 'dispatcher', 'vault-integration'])
208
222
  );
209
223
 
210
224
  /**
@@ -213,7 +227,10 @@ export const OPTIONAL_OBJECT_SECTIONS = /** @type {const} */ (
213
227
  * `parseNamedVaults`/`parseBaselines` — that already drops bad entries with
214
228
  * its own WARN). Surfaced here only via `sectionWarnings`, never dropped.
215
229
  */
216
- const OPTIONAL_LIST_SECTIONS = /** @type {const} */ (['vaults', 'baselines']);
230
+ const OPTIONAL_LIST_SECTIONS = /** @type {const} */ (['vaults', 'baselines', 'vault-dirs']);
231
+
232
+ /** Host-settable vault-integration modes (mirrors VAULT_MODE_VALUES in config-schema.mjs). */
233
+ const VALID_VAULT_INTEGRATION_MODES = ['off', 'warn', 'strict'];
217
234
 
218
235
  /** Canonical validation-order — MUST match the pre-#820 inline validation order. */
219
236
  const SECTION_ORDER = /** @type {const} */ ([
@@ -421,6 +438,35 @@ export function validateOwnerSections(obj) {
421
438
  sections.dispatcher = { valid: errors.length === 0, errors };
422
439
  }
423
440
 
441
+ // ── vault-integration (optional; host-local gate switch — SO#1448) ──────────
442
+ // STRICT enum validation, unlike `dispatcher`: a typo like `mode: of` must not
443
+ // fall through to the committed `strict` silently while the operator believes
444
+ // the host is off. An invalid section is dropped (and reported) on its own —
445
+ // `paths` and every other section survive (#820 per-section tolerance).
446
+ {
447
+ const errors = [];
448
+ const vi = obj['vault-integration'];
449
+ if (vi !== undefined && vi !== null) {
450
+ if (!isPlainObject(vi)) {
451
+ errors.push('vault-integration must be an object when present');
452
+ } else {
453
+ if (vi.enabled !== undefined && vi.enabled !== null && typeof vi.enabled !== 'boolean') {
454
+ errors.push(`vault-integration.enabled must be a boolean, got: ${typeof vi.enabled}`);
455
+ }
456
+ if (
457
+ vi.mode !== undefined &&
458
+ vi.mode !== null &&
459
+ !VALID_VAULT_INTEGRATION_MODES.includes(vi.mode)
460
+ ) {
461
+ errors.push(
462
+ `vault-integration.mode must be one of ${VALID_VAULT_INTEGRATION_MODES.join(', ')}, got: ${JSON.stringify(vi.mode)}`,
463
+ );
464
+ }
465
+ }
466
+ }
467
+ sections['vault-integration'] = { valid: errors.length === 0, errors };
468
+ }
469
+
424
470
  // ── vaults (optional; N named vaults for walk-up resolution — #700) ──────────
425
471
  // Drop-and-WARN on malformed entries is handled by parseNamedVaults() in
426
472
  // named-vault-resolver.mjs. Here we only validate the container shape to
@@ -512,6 +558,47 @@ export function validateOwnerSections(obj) {
512
558
  sections.baselines = { valid: errors.length === 0, errors };
513
559
  }
514
560
 
561
+ // ── vault-dirs (optional; per-directory vault-dir — agents/vault#319) ────────
562
+ // Same entry shape as baselines: (and parsed by the same parseBaselines() with
563
+ // section 'vault-dirs'), except `name` is optional. List section: surfaced via
564
+ // sectionWarnings, never dropped — the resolver drops bad entries itself.
565
+ {
566
+ const errors = [];
567
+ const vaultDirs = obj['vault-dirs'];
568
+ if (vaultDirs !== undefined && vaultDirs !== null) {
569
+ if (!Array.isArray(vaultDirs)) {
570
+ errors.push('vault-dirs must be an array when present');
571
+ } else {
572
+ for (let i = 0; i < vaultDirs.length; i++) {
573
+ const entry = vaultDirs[i];
574
+ if (!isPlainObject(entry)) {
575
+ errors.push(`vault-dirs[${i}] must be an object`);
576
+ continue;
577
+ }
578
+ if (typeof entry.path !== 'string' || entry.path.trim() === '') {
579
+ errors.push(`vault-dirs[${i}].path must be a non-empty string`);
580
+ }
581
+ if (
582
+ entry.name !== undefined &&
583
+ entry.name !== null &&
584
+ (typeof entry.name !== 'string' || entry.name.trim() === '')
585
+ ) {
586
+ errors.push(`vault-dirs[${i}].name must be a non-empty string when present`);
587
+ }
588
+ if (!isPlainObject(entry.match)) {
589
+ errors.push(`vault-dirs[${i}].match must be an object`);
590
+ } else if (
591
+ typeof entry.match['path-prefix'] !== 'string' ||
592
+ entry.match['path-prefix'].trim() === ''
593
+ ) {
594
+ errors.push(`vault-dirs[${i}].match.path-prefix must be a non-empty string`);
595
+ }
596
+ }
597
+ }
598
+ }
599
+ sections['vault-dirs'] = { valid: errors.length === 0, errors };
600
+ }
601
+
515
602
  const errors = SECTION_ORDER.flatMap((name) => sections[name].errors);
516
603
  return { sections, errors };
517
604
  }
@@ -540,10 +627,10 @@ export function validateOwnerConfig(obj) {
540
627
  * Per-section tolerance (#820): an invalid REQUIRED section (owner, tone,
541
628
  * efficiency, hardware-sharing) still discards the whole file (legacy
542
629
  * behaviour, unchanged). An invalid OPTIONAL object section (paths,
543
- * dispatcher) is instead replaced by its default value — the rest of the
630
+ * dispatcher, vault-integration) is instead replaced by its default value — the rest of the
544
631
  * file survives, `source` becomes `'partial'`, and the drop is reported via
545
632
  * `droppedSections` + a stderr WARN. OPTIONAL list sections (vaults,
546
- * baselines) are passed through UNTOUCHED even when strict-invalid — their
633
+ * baselines, vault-dirs) are passed through UNTOUCHED even when strict-invalid — their
547
634
  * consumers already run a lenient parse-at-point-of-use pass — and are
548
635
  * surfaced only via `sectionWarnings` (never dropped, never counted towards
549
636
  * `'partial'`).
@@ -658,6 +745,14 @@ export function loadOwnerConfig(opts = {}) {
658
745
  discardDropped.push({ section: name, errors: sec.errors });
659
746
  }
660
747
  }
748
+ // `vault-dirs:` (agents/vault#319) survives the discard too, raw — exactly as
749
+ // on the required-valid branch below; parseBaselines() drops bad entries at
750
+ // point-of-use. Dropping it would hand a repo under a declared prefix the
751
+ // host-wide `paths.vault-dir` (which DOES survive above): the silent vault
752
+ // override #319 exists to prevent, triggered by an unrelated `tone:` typo.
753
+ if (parsed['vault-dirs'] !== undefined && parsed['vault-dirs'] !== null) {
754
+ discardConfig['vault-dirs'] = parsed['vault-dirs'];
755
+ }
661
756
  const discardResult = { config: discardConfig, source: 'defaults', errors: allErrors };
662
757
  if (discardDropped.length > 0) discardResult.droppedSections = discardDropped;
663
758
  return discardResult;
@@ -673,7 +768,10 @@ export function loadOwnerConfig(opts = {}) {
673
768
  for (const name of OPTIONAL_OBJECT_SECTIONS) {
674
769
  const sec = sections[name];
675
770
  if (sec && !sec.valid) {
676
- config[name] = defaults[name];
771
+ // A section without a default (vault-integration) is removed, not set to
772
+ // `undefined`: js-yaml's dump rejects undefined values on a write-back.
773
+ if (defaults[name] === undefined) delete config[name];
774
+ else config[name] = defaults[name];
677
775
  droppedSections.push({ section: name, errors: sec.errors });
678
776
  const firstError = sec.errors[0] ?? 'invalid section';
679
777
  console.warn(
@@ -261,7 +261,7 @@ export function validatePersonaSpec(rawSpec, sourcePath) {
261
261
  pushError(
262
262
  'model',
263
263
  'enum',
264
- `model must be one of ${[...ALLOWED_MODEL_ALIASES].join('|')} or a full model ID like "claude-opus-4-7" (got ${JSON.stringify(m)})`,
264
+ `model must be one of ${[...ALLOWED_MODEL_ALIASES].join('|')} or a full model ID like "claude-opus-5-5" (got ${JSON.stringify(m)})`,
265
265
  );
266
266
  }
267
267
  }
@@ -27,20 +27,32 @@ import { createHash } from 'node:crypto';
27
27
  import {
28
28
  appendFileSync,
29
29
  existsSync,
30
+ lstatSync,
30
31
  mkdirSync,
31
32
  readFileSync,
33
+ renameSync,
34
+ unlinkSync,
32
35
  writeFileSync,
33
36
  } from 'node:fs';
34
37
  import path from 'node:path';
35
38
 
36
- import { DEFAULT_KILL_GRACE_MS } from './wave-executor/dispatch-common.mjs';
37
-
38
39
  /**
39
- * Re-exported, never re-defined: the SIGTERM→SIGKILL grace is a repo
40
- * convention with its own named ceiling in `dispatch-common.mjs`. A second
41
- * literal here would drift from it silently.
40
+ * Grace period between SIGTERM and SIGKILL, in ms — the repo's one definition,
41
+ * used by the gate's group ladder here and by `wave-executor/foreign-dispatch.mjs`
42
+ * and `remote-dispatch.mjs` through `runChild` (`dispatch-common.mjs`).
43
+ *
44
+ * `child.kill('SIGTERM')` is a REQUEST: a child that installs a handler and
45
+ * ignores it never emits `close`, and `runChild`'s only resolve paths are
46
+ * `close` and `error` — so without escalation a wedged `cursor-agent` hangs
47
+ * `dispatchForeign` forever and takes the whole wave with it. SIGKILL is not
48
+ * catchable, so the escalation always terminates.
49
+ *
50
+ * Named ceiling (BV-004): 10 s is enough for a cooperative child to flush its
51
+ * stream-json tail and exit. Revisit if a well-behaved `cursor-agent` is ever
52
+ * measured needing longer than that to shut down — raise the constant, never
53
+ * drop the escalation.
42
54
  */
43
- export { DEFAULT_KILL_GRACE_MS };
55
+ export const DEFAULT_KILL_GRACE_MS = 10_000;
44
56
 
45
57
  /** Append-only ledger of every gate process THIS repo's orchestrator started.
46
58
  * Gitignored via `.gitignore:125` (`.orchestrator/runtime/`), verified with
@@ -572,7 +584,42 @@ export function readGateProcessLedger(repoRoot, {
572
584
  }
573
585
 
574
586
  /**
575
- * Drop expired and unusable lines from the ledger, rewriting it in place.
587
+ * Replace a regular file's contents atomically: write `<target>.tmp-<pid>` in
588
+ * the same directory, then rename it over the target. Throws instead of
589
+ * writing when the target is a symlink or not a regular file — the caller turns
590
+ * that into its one WARN line.
591
+ *
592
+ * Why not `writeFileSync(target)`: that truncates first, so a kill between the
593
+ * truncate and the write leaves the file empty, and it FOLLOWS a symlink — a
594
+ * ledger linked to `events.jsonl` emptied the link's target (CWE-59, reproduced
595
+ * 2026-10-01). `rename` replaces the directory entry itself, never a link's
596
+ * target, and the `wx` flag refuses a pre-planted file or link at the tmp name.
597
+ * Kept local, not imported, because this module deliberately has no local
598
+ * imports; `orphan-reaper/reaper-audit.mjs` carries the same few lines.
599
+ *
600
+ * @param {string} target
601
+ * @param {string} body
602
+ * @param {{lstatSync: Function, writeFileSync: Function, renameSync: Function, unlinkSync: Function}} io
603
+ */
604
+ function replaceRegularFile(target, body, io) {
605
+ if (!io.lstatSync(target).isFile()) {
606
+ throw new Error('not a regular file (a symlink is never written through) — left untouched');
607
+ }
608
+ const tmp = `${target}.tmp-${process.pid}`;
609
+ try {
610
+ io.writeFileSync(tmp, body, { encoding: 'utf8', flag: 'wx' });
611
+ io.renameSync(tmp, target);
612
+ } catch (err) {
613
+ // EEXIST: the tmp name belongs to someone else — never remove it.
614
+ if (err?.code !== 'EEXIST') {
615
+ try { io.unlinkSync(tmp); } catch { /* nothing was created */ }
616
+ }
617
+ throw err;
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Drop expired and unusable lines from the ledger, rewriting it atomically.
576
623
  * "Unusable" is {@link isValidLedgerRecord}'s verdict — the same one
577
624
  * {@link readGateProcessLedger} applies, so nothing the reader skips survives
578
625
  * the pruner.
@@ -583,6 +630,8 @@ export function readGateProcessLedger(repoRoot, {
583
630
  *
584
631
  * In-process fs only — never a shell `rm`/`mv` (PSA-003, and
585
632
  * `.orchestrator/metrics/**` deletions are a blocked-command rule for a reason).
633
+ * The rewrite goes through {@link replaceRegularFile}: a symlinked or
634
+ * non-regular ledger is refused with one WARN and left untouched.
586
635
  *
587
636
  * Named ceiling (BV-004): read-filter-write is not atomic against a concurrent
588
637
  * append, so a line appended between the read and the write is lost. Acceptable
@@ -595,16 +644,18 @@ export function readGateProcessLedger(repoRoot, {
595
644
  * @param {object} [opts]
596
645
  * @param {number} [opts.nowMs]
597
646
  * @param {number} [opts.maxAgeMs]
598
- * @param {{existsSync: Function, readFileSync: Function, writeFileSync: Function}} [opts.fs] fs seam.
647
+ * @param {{existsSync: Function, readFileSync: Function, writeFileSync: Function,
648
+ * lstatSync: Function, renameSync: Function, unlinkSync: Function}} [opts.fs] fs seam;
649
+ * replaces `node:fs` wholesale, so a partial seam fails closed (WARN, 0).
599
650
  * @returns {number} Number of lines removed (expired + malformed). 0 when the
600
- * ledger is absent, empty, or unreadable.
651
+ * ledger is absent, empty, unreadable, or not a regular file.
601
652
  */
602
653
  export function pruneGateProcessLedger(repoRoot, {
603
654
  nowMs = Date.now(),
604
655
  maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
605
656
  fs: fsSeam,
606
657
  } = {}) {
607
- const io = fsSeam ?? { existsSync, readFileSync, writeFileSync };
658
+ const io = fsSeam ?? { existsSync, readFileSync, writeFileSync, lstatSync, renameSync, unlinkSync };
608
659
  const target = ledgerPathFor(repoRoot);
609
660
  let raw;
610
661
  try {
@@ -634,7 +685,7 @@ export function pruneGateProcessLedger(repoRoot, {
634
685
 
635
686
  if (removed === 0) return 0;
636
687
  try {
637
- io.writeFileSync(target, kept.length > 0 ? `${kept.join('\n')}\n` : '', 'utf8');
688
+ replaceRegularFile(target, kept.length > 0 ? `${kept.join('\n')}\n` : '', io);
638
689
  } catch (err) {
639
690
  process.stderr.write(
640
691
  `process-group: could not prune ${GATE_PROCESS_LEDGER_RELPATH}: ${err?.message ?? String(err)}\n`,
@@ -462,7 +462,9 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
462
462
  * H4 — CI configuration hygiene.
463
463
  *
464
464
  * Measured 5/6. Checks locally declared executable commands without evaluating
465
- * pipeline conditions or external includes. The dependency-audit gap is the load-bearing one: three
465
+ * pipeline conditions or external includes; when no local audit step is found
466
+ * but `.gitlab-ci.yml` declares `include:`, the finding is downgraded to an
467
+ * unverifiable, non-fixable one. The dependency-audit gap is the load-bearing one: three
466
468
  * of the tested repos carried known-vulnerable dependencies that no pipeline
467
469
  * would ever surface.
468
470
  *
@@ -506,6 +508,24 @@ export function checkCiConfig(repoRoot) {
506
508
  }
507
509
 
508
510
  if (ciConfigs.some(({ text }) => text) && !ciConfigs.some(hasAuditStep)) {
511
+ // Jobs supplied via include: (component/local/project/remote/template) are
512
+ // never resolved, so an audit may exist there — report it as unverifiable.
513
+ let includes = 0;
514
+ try {
515
+ const gitlabText = hasGitlab ? ciConfigs[0].text : '';
516
+ const include = yaml.loadAll(gitlabText, undefined, { schema: GITLAB_SCHEMA }).filter(isConfigMap).at(-1)?.include;
517
+ includes = Array.isArray(include) ? include.length : include ? 1 : 0;
518
+ } catch {
519
+ /* unparseable config — keep the plain missing-audit finding */
520
+ }
521
+ if (includes > 0) {
522
+ findings.push({
523
+ check: 'ci-audit-job',
524
+ fixable: false,
525
+ message: `CI dependency-audit step could not be verified: ${includes} include(s) not evaluated (.gitlab-ci.yml include:)`,
526
+ });
527
+ return findings;
528
+ }
509
529
  findings.push({
510
530
  check: 'ci-audit-job',
511
531
  fixable: true,
@@ -63,7 +63,7 @@
63
63
 
64
64
  import { spawnSync } from 'node:child_process';
65
65
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
66
- import { dirname, join } from 'node:path';
66
+ import { dirname, join, resolve } from 'node:path';
67
67
  import { fileURLToPath } from 'node:url';
68
68
 
69
69
  import { emitEvent, sessionAttribution } from './events.mjs';
@@ -120,7 +120,8 @@ const GATE_TIMEOUT_MS = DEFAULT_GATE_TIMEOUT_MS;
120
120
  * @returns {string}
121
121
  */
122
122
  function resolveRepoRoot(explicit) {
123
- if (typeof explicit === 'string' && explicit.trim()) return explicit;
123
+ // Absolute on purpose: emitEvent refuses a relative root (#1468).
124
+ if (typeof explicit === 'string' && explicit.trim()) return resolve(explicit);
124
125
  return process.cwd();
125
126
  }
126
127
 
@@ -773,13 +774,44 @@ function coerceMaxRetries(n) {
773
774
  // Public API
774
775
  // ---------------------------------------------------------------------------
775
776
 
777
+ /**
778
+ * The kill-ladder fields of a `quality_gate.passed/.failed` record (#1439).
779
+ *
780
+ * ONE builder for BOTH emitters — this library's {@link emitGateEvent} and the
781
+ * `scripts/run-quality-gate.mjs` CLI — so the two can never publish different
782
+ * shapes under the same keys. From THIS builder all three keys are always
783
+ * present: `timed_out` `false`, `survivors` `0` and `kill_signals` `[]` are
784
+ * measurements ("not killed"), unlike `counts`, whose absence means "not
785
+ * measured". The CLI then passes the result through `joinGateKillFields()`
786
+ * (`scripts/lib/gates/gate-helpers.mjs`, #1457), which OR-joins the inner
787
+ * per-command ladders and MAY omit the three keys when that inner side was
788
+ * not measured and the outer ladder did not fire.
789
+ *
790
+ * - `timed_out` — the run hit its wall-clock ceiling and was killed (exit 124).
791
+ * - `survivors` — how many pids were still alive after the SIGKILL step; the
792
+ * pids themselves stay in the gate output / diagnostics bundle.
793
+ * - `kill_signals` — the signals actually sent, in order (`SIGTERM`, `SIGKILL`).
794
+ * Copied, because `spawnInGroup` records them at send time into a live array.
795
+ *
796
+ * @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]}|null|undefined} run
797
+ * @returns {{timed_out: boolean, survivors: number, kill_signals: string[]}}
798
+ */
799
+ export function gateKillFields(run) {
800
+ return {
801
+ timed_out: run?.timedOut === true,
802
+ survivors: Array.isArray(run?.survivors) ? run.survivors.length : 0,
803
+ kill_signals: Array.isArray(run?.killSignals) ? [...run.killSignals] : [],
804
+ };
805
+ }
806
+
776
807
  /**
777
808
  * Emit exactly one `orchestrator.quality_gate.{passed,failed}` event per
778
809
  * `runQualityGateWithRetry` CALL (#928b).
779
810
  *
780
811
  * NO DOUBLE-COUNTING. The two gate paths never nest:
781
812
  * - `scripts/run-quality-gate.mjs` spawns `scripts/lib/gates/gate-*.mjs`
782
- * directly and does not import this module;
813
+ * directly; it imports only the pure `gateKillFields` helper from this
814
+ * module, never `runQualityGateWithRetry`, so it never reaches this emitter;
783
815
  * - this module spawns the resolved gate COMMANDS (and `parse-config.mjs`)
784
816
  * directly and does not invoke that wrapper.
785
817
  * A single run therefore passes through exactly one emitter.
@@ -846,8 +878,13 @@ function coerceMaxRetries(n) {
846
878
  * @param {number} attempts
847
879
  * @param {string|null} gate
848
880
  * @param {{passed: number, failed: number, total: number}|null} [counts]
881
+ * @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]}|null} [killRun]
882
+ * the run that decided the verdict — the last attempt's failing gate; `null`
883
+ * on the passing path, where every gate exited 0 and so none was killed
884
+ * (`spawnInGroup` only starts its kill ladder on timeout or overflow, and
885
+ * both force a non-zero exit code).
849
886
  */
850
- async function emitGateEvent(repoRoot, ok, attempts, gate, counts) {
887
+ async function emitGateEvent(repoRoot, ok, attempts, gate, counts, killRun = null) {
851
888
  try {
852
889
  await emitEvent(
853
890
  `orchestrator.quality_gate.${ok ? 'passed' : 'failed'}`,
@@ -857,6 +894,7 @@ async function emitGateEvent(repoRoot, ok, attempts, gate, counts) {
857
894
  attempts,
858
895
  ...(gate ? { gate } : {}),
859
896
  ...(counts ? { counts } : {}),
897
+ ...gateKillFields(killRun),
860
898
  ...sessionAttribution(repoRoot),
861
899
  },
862
900
  { repoRoot },
@@ -985,6 +1023,8 @@ export async function runQualityGateWithRetry(opts) {
985
1023
  exitCode: result.exitCode,
986
1024
  output: result.output,
987
1025
  timedOut: result.timedOut,
1026
+ killSignals: result.killSignals,
1027
+ survivors: result.survivors,
988
1028
  command: cmd,
989
1029
  attempt,
990
1030
  };
@@ -1063,7 +1103,7 @@ export async function runQualityGateWithRetry(opts) {
1063
1103
  `❌ quality-gate exhausted retries (${attempt}), writing diagnostics to ${bundlePath ?? '<unwritable>'}\n`,
1064
1104
  );
1065
1105
 
1066
- await emitGateEvent(repoRoot, false, attempt, lastFailure?.gate ?? null, testCounts);
1106
+ await emitGateEvent(repoRoot, false, attempt, lastFailure?.gate ?? null, testCounts, lastFailure);
1067
1107
 
1068
1108
  const out = {
1069
1109
  ok: false,
@@ -49,7 +49,7 @@ import { ramSnapshot, cpuSnapshot, processCounts, swapUsedMb, memoryPressurePctF
49
49
 
50
50
  export { evaluate, PROCESSES_PER_SESSION, PRESSURE_HARD_PCT, PRESSURE_SOFT_PCT, PRESSURE_HEALTHY_PCT, DEFAULT_RESOURCE_THRESHOLDS } from './resource-probe/evaluate.mjs';
51
51
  export { peerSessionsCount } from './resource-probe/probe-platform.mjs';
52
- export { parseEtimeToMinutes, countZombieProcesses, countProcessMatches, parseSwapUsageOutput, parseMemoryPressureOutput, parseVmStatAvailableGb } from './resource-probe/parsers.mjs';
52
+ export { parseEtimeToMinutes, parseEtimeToSeconds, countZombieProcesses, countProcessMatches, parseSwapUsageOutput, parseMemoryPressureOutput, parseVmStatAvailableGb } from './resource-probe/parsers.mjs';
53
53
 
54
54
  // ---------------------------------------------------------------------------
55
55
  // Public API — probe()