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.
- package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- 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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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`-
|
|
618
|
-
* variant
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
31
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
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
|
}
|