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