session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -0,0 +1,288 @@
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
+ * Check-in and lease timestamps are UTC with `Z`, in a form per file: the
15
+ * check-in's `zeit` is seconds, `date -u +%FT%TZ` (`YYYY-MM-DDTHH:MM:SSZ`,
16
+ * stamped by the CLI); the lease's `seit` / `laeuft_ab` are seconds or
17
+ * milliseconds (`LEASE_TS_RE`); the auflagen content belongs to the navigator.
18
+ *
19
+ * "A navigator is active" means exactly one thing: a readable, well-formed lease
20
+ * whose `laeuft_ab` lies in the future. Everything else is fail-closed — an
21
+ * expired lease is `none`, a lease that cannot be read or validated is
22
+ * `unreadable`, and neither is ever `active`.
23
+ *
24
+ * Session ids become file names, so {@link isSafeSessionId} is the security
25
+ * boundary between check-in input and the filesystem: every path builder that
26
+ * takes a session id refuses an unsafe one with a TypeError.
27
+ */
28
+
29
+ import { promises as fs } from 'node:fs';
30
+ import os from 'node:os';
31
+ import path from 'node:path';
32
+
33
+ /** Env var that overrides the navigator directory. */
34
+ export const NAVIGATOR_CONFIG_DIR_ENV = 'NAVIGATOR_CONFIG_DIR';
35
+
36
+ /** Minutes a session waits for a navigator before it falls back (PRD !53 § 3). */
37
+ export const CHECKIN_FALLBACK_MIN = 10;
38
+
39
+ const SESSION_ID_RE = /^[A-Za-z0-9._-]+$/;
40
+ const SESSION_ID_MAX = 128;
41
+ const UTC_SECONDS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
42
+
43
+ /** Lease timestamps: UTC with `Z`, seconds or milliseconds — never a local-time reading. */
44
+ const LEASE_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
45
+
46
+ const PLATTFORMEN = ['claude', 'codex', 'kopflos'];
47
+ const MODI = ['housekeeping', 'feature', 'deep', 'operations'];
48
+
49
+ /**
50
+ * Resolve the navigator directory.
51
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
52
+ * @returns {string} absolute path
53
+ */
54
+ export function navigatorDir({ env = process.env, home = os.homedir() } = {}) {
55
+ const raw = env[NAVIGATOR_CONFIG_DIR_ENV];
56
+ const override = typeof raw === 'string' ? raw.trim() : '';
57
+ if (override) return path.resolve(override);
58
+ return path.join(home, '.config', 'navigator');
59
+ }
60
+
61
+ /**
62
+ * Path of the navigator lease file.
63
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
64
+ * @returns {string}
65
+ */
66
+ export function leasePath(opts) {
67
+ return path.join(navigatorDir(opts), 'leases', 'navigator.json');
68
+ }
69
+
70
+ /**
71
+ * True when `id` is safe to use as a single file-name component: only
72
+ * `[A-Za-z0-9._-]`, 1..128 chars, and not `.` / `..`. This excludes `/`, `\`,
73
+ * NUL and every other separator by construction.
74
+ * @param {unknown} id
75
+ * @returns {boolean}
76
+ */
77
+ export function isSafeSessionId(id) {
78
+ return (
79
+ typeof id === 'string' &&
80
+ id.length > 0 &&
81
+ id.length <= SESSION_ID_MAX &&
82
+ id !== '.' &&
83
+ id !== '..' &&
84
+ SESSION_ID_RE.test(id)
85
+ );
86
+ }
87
+
88
+ /** @param {unknown} sessionId */
89
+ function assertSafeSessionId(sessionId) {
90
+ if (!isSafeSessionId(sessionId)) {
91
+ throw new TypeError(`unsafe session id: ${JSON.stringify(String(sessionId)).slice(0, 80)}`);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Path of a session's check-in file.
97
+ * @param {string} sessionId
98
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
99
+ * @returns {string}
100
+ * @throws {TypeError} when `sessionId` fails {@link isSafeSessionId}
101
+ */
102
+ export function checkinPath(sessionId, opts) {
103
+ assertSafeSessionId(sessionId);
104
+ return path.join(navigatorDir(opts), 'checkin', `${sessionId}.json`);
105
+ }
106
+
107
+ /**
108
+ * Path of a session's conditions ("Auflagen") file.
109
+ * @param {string} sessionId
110
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
111
+ * @returns {string}
112
+ * @throws {TypeError} when `sessionId` fails {@link isSafeSessionId}
113
+ */
114
+ export function auflagenPath(sessionId, opts) {
115
+ assertSafeSessionId(sessionId);
116
+ return path.join(navigatorDir(opts), 'auflagen', `${sessionId}.json`);
117
+ }
118
+
119
+ /**
120
+ * Format a date as `YYYY-MM-DDTHH:MM:SSZ` (the output of `date -u +%FT%TZ`).
121
+ * @param {Date} [date]
122
+ * @returns {string}
123
+ */
124
+ export function utcSecondsTimestamp(date = new Date()) {
125
+ return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
126
+ }
127
+
128
+ /**
129
+ * When the Standard-Auflagen apply to a check-in written at `zeit` if no
130
+ * `auflagen/<session_id>.json` has appeared: {@link CHECKIN_FALLBACK_MIN} later
131
+ * while a navigator is active (it may still write one), and at `zeit` itself
132
+ * otherwise — with no active navigator nobody will, so the fallback is due at
133
+ * once (#1501 item 8: this deadline lived only as prose in session-start).
134
+ * @param {string} zeit the check-in's `zeit`, as {@link utcSecondsTimestamp} writes it
135
+ * @param {'active'|'none'|'unreadable'} navigatorState from {@link readNavigatorLease}
136
+ * @returns {string} same form as `zeit`
137
+ */
138
+ export function fallbackDueAt(zeit, navigatorState) {
139
+ if (navigatorState !== 'active') return zeit;
140
+ return utcSecondsTimestamp(new Date(Date.parse(zeit) + CHECKIN_FALLBACK_MIN * 60_000));
141
+ }
142
+
143
+ /** @param {unknown} v */
144
+ function isPlainObject(v) {
145
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
146
+ }
147
+
148
+ /** @param {unknown} v */
149
+ function isNonEmptyString(v) {
150
+ return typeof v === 'string' && v.length > 0;
151
+ }
152
+
153
+ /** @param {unknown} v */
154
+ function isIsoTimestamp(v) {
155
+ return typeof v === 'string' && !Number.isNaN(Date.parse(v));
156
+ }
157
+
158
+ function isLeaseTimestamp(v) {
159
+ return typeof v === 'string' && LEASE_TS_RE.test(v) && isIsoTimestamp(v);
160
+ }
161
+
162
+ const REQUIRED_CHECKIN_FIELDS = [
163
+ 'session', 'plattform', 'repo', 'modus', 'auftrag_ref',
164
+ 'kandidaten', 'schreibbereich', 'rueckfall', 'zeit',
165
+ ];
166
+
167
+ /** Repo name shape accepted in a check-in: `name` or `group/name`, no path parts. */
168
+ const REPO_NAME_RE = /^[A-Za-z0-9._-]{1,100}(?:\/[A-Za-z0-9._-]{1,100})?$/;
169
+
170
+ /** Lease `adresse` shape (a ListAgents peer name): no whitespace, no control bytes. */
171
+ const ADRESSE_RE = /^[A-Za-z0-9._:@-]{1,128}$/;
172
+
173
+ /** True for an issue reference: a non-negative integer or a `#123` / `123` string. */
174
+ function isIssueRef(v) {
175
+ if (typeof v === 'number') return Number.isInteger(v) && v >= 0;
176
+ return typeof v === 'string' && /^#?\d{1,9}$/.test(v);
177
+ }
178
+
179
+ /**
180
+ * Per-field type checks. Each returns an error message or null. Applied to every
181
+ * PRESENT field; presence of the required ones is checked separately.
182
+ * @type {Record<string, (v: unknown) => string|null>}
183
+ */
184
+ const CHECKIN_FIELD_CHECKS = {
185
+ session: (v) =>
186
+ isSafeSessionId(v) ? null : 'session must be a safe id: [A-Za-z0-9._-], 1..128 chars, not . or ..',
187
+ plattform: (v) => (PLATTFORMEN.includes(/** @type {string} */ (v)) ? null : `plattform must be one of ${PLATTFORMEN.join('|')}`),
188
+ // A repo NAME (optionally `group/name`), never a filesystem path: the value
189
+ // travels in `orchestrator.fleet.checkin`, which may leave the host over the
190
+ // optional events webhook (docs/events-schema.md: "never the absolute root").
191
+ 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'),
192
+ repo_id: (v) => (v === null || typeof v === 'string' ? null : 'repo_id must be a string or null'),
193
+ worktree: (v) => (typeof v === 'boolean' ? null : 'worktree must be a boolean'),
194
+ modus: (v) => (MODI.includes(/** @type {string} */ (v)) ? null : `modus must be one of ${MODI.join('|')}`),
195
+ auftrag_ref: (v) => (isNonEmptyString(v) ? null : 'auftrag_ref must be a non-empty string'),
196
+ konto_slot: (v) =>
197
+ v === null || typeof v === 'string' || (typeof v === 'number' && Number.isFinite(v))
198
+ ? null
199
+ : 'konto_slot must be a number, a string or null',
200
+ quota: (v) => (typeof v === 'string' ? null : 'quota must be a string'),
201
+ stand: (v) => (isPlainObject(v) ? null : 'stand must be an object'),
202
+ // Issue numbers only (42 or "#42"), at most 64 — same webhook reasoning as `repo`.
203
+ kandidaten: (v) =>
204
+ Array.isArray(v) && v.length <= 64 && v.every(isIssueRef)
205
+ ? null
206
+ : 'kandidaten must be an array of at most 64 issue numbers (42 or "#42")',
207
+ schreibbereich: (v) =>
208
+ Array.isArray(v) && v.every((e) => typeof e === 'string') ? null : 'schreibbereich must be an array of strings',
209
+ bedarf: (v) => (isPlainObject(v) ? null : 'bedarf must be an object'),
210
+ vorab_geschrieben: (v) => (Array.isArray(v) ? null : 'vorab_geschrieben must be an array'),
211
+ rueckfall: (v) => (isNonEmptyString(v) ? null : 'rueckfall must be a non-empty string'),
212
+ zeit: (v) =>
213
+ typeof v === 'string' && UTC_SECONDS_RE.test(v) && isIsoTimestamp(v)
214
+ ? null
215
+ : 'zeit must be a UTC timestamp YYYY-MM-DDTHH:MM:SSZ',
216
+ };
217
+
218
+ /**
219
+ * Validate a check-in record (PRD !53 table 3.2).
220
+ * @param {unknown} obj
221
+ * @returns {string[]} error messages; empty means valid
222
+ */
223
+ export function validateCheckin(obj) {
224
+ if (!isPlainObject(obj)) return ['check-in must be a JSON object'];
225
+ const rec = /** @type {Record<string, unknown>} */ (obj);
226
+ const errors = [];
227
+ for (const field of REQUIRED_CHECKIN_FIELDS) {
228
+ if (rec[field] === undefined) errors.push(`missing required field: ${field}`);
229
+ }
230
+ for (const [field, check] of Object.entries(CHECKIN_FIELD_CHECKS)) {
231
+ if (rec[field] === undefined) continue;
232
+ const err = check(rec[field]);
233
+ if (err) errors.push(err);
234
+ }
235
+ return errors;
236
+ }
237
+
238
+ /**
239
+ * Validate a parsed lease; returns the first problem or null.
240
+ * @param {unknown} lease
241
+ * @returns {string|null}
242
+ */
243
+ function leaseProblem(lease) {
244
+ if (!isPlainObject(lease)) return 'lease is not a JSON object';
245
+ const l = /** @type {Record<string, unknown>} */ (lease);
246
+ // Shape-checked at the source ("escaped at generation"): both values can reach
247
+ // the operator's session-start banner, so control bytes are refused here.
248
+ if (!isSafeSessionId(l.session_id)) return 'lease.session_id missing or not a safe id';
249
+ if (!PLATTFORMEN.includes(/** @type {string} */ (l.plattform))) return `lease.plattform must be one of ${PLATTFORMEN.join('|')}`;
250
+ if (l.adresse !== undefined && l.adresse !== null && !(typeof l.adresse === 'string' && ADRESSE_RE.test(l.adresse))) {
251
+ return 'lease.adresse must be null or a peer name [A-Za-z0-9._:@-] (1..128 chars)';
252
+ }
253
+ if (l.uebergabe_an !== undefined && l.uebergabe_an !== null && typeof l.uebergabe_an !== 'string') {
254
+ return 'lease.uebergabe_an must be a string or null';
255
+ }
256
+ // Strict UTC form: a `Z`-less value would be read in the host's local time and
257
+ // could EXTEND the lease west of UTC — a fail-open reading, hence unreadable.
258
+ if (!isLeaseTimestamp(l.seit)) return 'lease.seit missing or not a UTC timestamp (…Z)';
259
+ if (!isLeaseTimestamp(l.laeuft_ab)) return 'lease.laeuft_ab missing or not a UTC timestamp (…Z)';
260
+ return null;
261
+ }
262
+
263
+ /**
264
+ * Read the navigator lease. Never throws; fail-closed.
265
+ *
266
+ * @param {{ now?: number, env?: Record<string, string|undefined>, home?: string }} [opts]
267
+ * @returns {Promise<{ state: 'active', lease: object } | { state: 'none' } | { state: 'unreadable', reason: string }>}
268
+ */
269
+ export async function readNavigatorLease({ now = Date.now(), env, home } = {}) {
270
+ let raw;
271
+ try {
272
+ raw = await fs.readFile(leasePath({ env, home }), 'utf8');
273
+ } catch (err) {
274
+ if (err && /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return { state: 'none' };
275
+ const code = err && /** @type {NodeJS.ErrnoException} */ (err).code;
276
+ return { state: 'unreadable', reason: `read failed: ${code || 'unknown error'}` };
277
+ }
278
+ let lease;
279
+ try {
280
+ lease = JSON.parse(raw);
281
+ } catch {
282
+ return { state: 'unreadable', reason: 'lease is not valid JSON' };
283
+ }
284
+ const problem = leaseProblem(lease);
285
+ if (problem) return { state: 'unreadable', reason: problem };
286
+ if (Date.parse(lease.laeuft_ab) <= now) return { state: 'none' };
287
+ return { state: 'active', lease };
288
+ }
@@ -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
@@ -6,7 +6,7 @@
6
6
  // Required env: TYPECHECK_CMD, TEST_CMD
7
7
  // Optional env: FILES (comma-separated), SESSION_START_REF
8
8
 
9
- import { runCheck, resolveTestFiles, extractErrorLinesJson } from './gate-helpers.mjs';
9
+ import { runCheck, resolveTestFiles, extractErrorLinesJson, commandKillFields } from './gate-helpers.mjs';
10
10
 
11
11
  const typecheckCmd = process.env.TYPECHECK_CMD;
12
12
  const testCmd = process.env.TEST_CMD;
@@ -49,41 +49,32 @@ if (!testCmd) {
49
49
 
50
50
  const start = Date.now();
51
51
 
52
- let testStatus;
52
+ // A skipped test run spawned nothing: `{ status: 'skip' }`, no kill keys.
53
+ let testResult = { status: 'skip' };
53
54
  let errors = [];
54
55
 
55
56
  // --- typecheck (always runs unless cmd is "skip") ---
56
57
  const tcResult = await runCheck(typecheckCmd, CHECK_OPTS);
57
- const tcStatus = tcResult.status;
58
- if (tcStatus === 'fail') {
58
+ if (tcResult.status === 'fail') {
59
59
  errors = errors.concat(extractErrorLinesJson(tcResult.output, /error TS\d+/));
60
60
  }
61
61
 
62
62
  // --- test (scoped to changed/specified files) ---
63
- if (testCmd === 'skip') {
64
- testStatus = 'skip';
65
- } else {
63
+ if (testCmd !== 'skip') {
66
64
  const testFiles = await resolveTestFiles(files, sessionStartRef);
67
65
  if (testFiles.length > 0) {
68
66
  const fileArgs = testFiles.join(' ');
69
- const testResult = await runCheck(`${testCmd} -- ${fileArgs}`, CHECK_OPTS);
70
- testStatus = testResult.status;
71
- if (testStatus === 'fail') {
72
- const testErrors = extractErrorLinesJson(testResult.output, /(fail|error|FAIL)/i);
73
- errors = errors.concat(testErrors);
74
- }
67
+ testResult = await runCheck(`${testCmd} -- ${fileArgs}`, CHECK_OPTS);
75
68
  } else if (!files && !sessionStartRef) {
76
69
  // No FILES or SESSION_START_REF supplied: run the full test suite
77
- const testResult = await runCheck(testCmd, CHECK_OPTS);
78
- testStatus = testResult.status;
79
- if (testStatus === 'fail') {
80
- const testErrors = extractErrorLinesJson(testResult.output, /(fail|error|FAIL)/i);
81
- errors = errors.concat(testErrors);
82
- }
70
+ testResult = await runCheck(testCmd, CHECK_OPTS);
83
71
  } else {
84
72
  // Files/ref supplied but no test files found — skip
85
73
  process.stderr.write('warn: No test files found for incremental run; skipping tests\n');
86
- testStatus = 'skip';
74
+ }
75
+ if (testResult.status === 'fail') {
76
+ const testErrors = extractErrorLinesJson(testResult.output, /(fail|error|FAIL)/i);
77
+ errors = errors.concat(testErrors);
87
78
  }
88
79
  }
89
80
 
@@ -92,8 +83,18 @@ const duration_seconds = Math.round((Date.now() - start) / 1000);
92
83
  const result = {
93
84
  variant: 'incremental',
94
85
  duration_seconds,
95
- typecheck: tcStatus,
96
- test: testStatus,
86
+ // Each command object also carries its INNER kill ladder (`timed_out`,
87
+ // `kill_signals`, `survivors` pids) when `runCheck` spawned it — omitted for
88
+ // skip/stub (#1457). `run-quality-gate.mjs` joins them into its event.
89
+ typecheck: { status: tcResult.status, ...commandKillFields(tcResult) },
90
+ test: { status: testResult.status, ...commandKillFields(testResult) },
91
+ // Stub map, same shape as gate-full: a command `runCheck` recognised as a
92
+ // stub (`echo …`/`true`) spawned nothing, so `joinGateKillFields` may count
93
+ // it as measured instead of declaring the whole inner ladder unknown.
94
+ stubbed: {
95
+ ...(tcResult.stubbed ? { typecheck: tcResult.stubbed } : {}),
96
+ ...(testResult.stubbed ? { test: testResult.stubbed } : {}),
97
+ },
97
98
  errors,
98
99
  };
99
100
 
@@ -9,7 +9,7 @@
9
9
  * Part of v3.2 shell-helper port migration (issue #218 / #317).
10
10
  */
11
11
 
12
- import { runCheck, csvToJsonArray } from './gate-helpers.mjs';
12
+ import { runCheck, csvToJsonArray, commandKillFields } from './gate-helpers.mjs';
13
13
 
14
14
  const typecheckCmd = process.env.TYPECHECK_CMD;
15
15
  const testCmd = process.env.TEST_CMD;
@@ -59,20 +59,29 @@ if (files.length === 0) {
59
59
 
60
60
  // Typecheck runs on the whole project, not per-file
61
61
  const tcResult = await runCheck(typecheckCmd, CHECK_OPTS);
62
- const tcStatus = tcResult.status;
63
62
 
64
- let testStatus = 'skip';
63
+ // A skipped test run spawned nothing: `{ status: 'skip' }`, no kill keys.
64
+ let testResult = { status: 'skip' };
65
65
 
66
66
  if (testCmd !== 'skip' && files.length > 0) {
67
67
  const fileArgs = files.join(' ');
68
- const testResult = await runCheck(`${testCmd} -- ${fileArgs}`, CHECK_OPTS);
69
- testStatus = testResult.status;
68
+ testResult = await runCheck(`${testCmd} -- ${fileArgs}`, CHECK_OPTS);
70
69
  }
71
70
 
72
71
  const result = {
73
72
  variant: 'per-file',
74
- typecheck: tcStatus,
75
- test: testStatus,
73
+ // Each command object also carries its INNER kill ladder (`timed_out`,
74
+ // `kill_signals`, `survivors` pids) when `runCheck` spawned it — omitted for
75
+ // skip/stub (#1457). `run-quality-gate.mjs` joins them into its event.
76
+ typecheck: { status: tcResult.status, ...commandKillFields(tcResult) },
77
+ test: { status: testResult.status, ...commandKillFields(testResult) },
78
+ // Stub map, same shape as gate-full: a command `runCheck` recognised as a
79
+ // stub (`echo …`/`true`) spawned nothing, so `joinGateKillFields` may count
80
+ // it as measured instead of declaring the whole inner ladder unknown.
81
+ stubbed: {
82
+ ...(tcResult.stubbed ? { typecheck: tcResult.stubbed } : {}),
83
+ ...(testResult.stubbed ? { test: testResult.stubbed } : {}),
84
+ },
76
85
  files,
77
86
  };
78
87
 
@@ -34,6 +34,7 @@ export { assertNodeVersion, assertDepInstalled, checkEnvironment } from './env-c
34
34
 
35
35
  // B) Scope / pattern primitives
36
36
  export {
37
+ /** @deprecated Use findOwnScopeFile from scope-gate.mjs because first-file selection ignores manifest ownership and can select a peer session's manifest. */
37
38
  findScopeFile,
38
39
  getEnforcementLevel,
39
40
  gateEnabled,
@@ -27,8 +27,11 @@ export const DEFAULT_LOCK_TTL_HOURS = 4;
27
27
  /**
28
28
  * Heartbeat-based liveness for a parsed session.lock — a self-contained mirror
29
29
  * of session-lock.mjs `isLockLive` (which is the SSOT for the semantics). A lock
30
- * is live when (now - last_heartbeat) < ttl_hours; falls back to started_at for
31
- * v1 locks. Returns false for malformed input (→ treated as an orphaned lease).
30
+ * is live when (now - last_heartbeat) < ttl_hours. A lock without
31
+ * `last_heartbeat` is not live — no `started_at` fallback since the #595 sunset
32
+ * (2026-10-02), same as the SSOT. Nor is one stamped more than 5 minutes in the
33
+ * future (#1494 — the SSOT's FUTURE_HEARTBEAT_TOLERANCE_MS). Returns false for
34
+ * malformed input (→ treated as an orphaned lease).
32
35
  *
33
36
  * Exported (additive-only) so tests/lib/lock-ttl-parity.test.mjs can drive both
34
37
  * this mirror and the SSOT with identical fixtures and assert identical verdicts.
@@ -39,11 +42,10 @@ export const DEFAULT_LOCK_TTL_HOURS = 4;
39
42
  */
40
43
  export function lockIsLive(lock, nowMs) {
41
44
  if (!lock || typeof lock !== 'object') return false;
42
- const hb = (typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0)
43
- ? lock.last_heartbeat
44
- : lock.started_at;
45
- const ms = Date.parse(hb);
45
+ if (typeof lock.last_heartbeat !== 'string' || lock.last_heartbeat.length === 0) return false;
46
+ const ms = Date.parse(lock.last_heartbeat);
46
47
  if (Number.isNaN(ms)) return false;
48
+ if (ms > nowMs + 5 * 60 * 1000) return false;
47
49
  const ttlHours = typeof lock.ttl_hours === 'number' ? lock.ttl_hours : DEFAULT_LOCK_TTL_HOURS;
48
50
  return (nowMs - ms) < ttlHours * 3600 * 1000;
49
51
  }