session-orchestrator 3.22.0 → 3.24.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* express-path.mjs — the Express Path activation DECISION plus its record (#214, #1119).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this module exists at all
|
|
5
|
+
*
|
|
6
|
+
* The Express Path (session-start Phase 8.5) was built as prose and nothing else.
|
|
7
|
+
* Two measurements on 2026-08-23 pinned what that cost:
|
|
8
|
+
*
|
|
9
|
+
* grep -c "express" scripts/lib/config.mjs → 0 (506 lines)
|
|
10
|
+
* grep -c "express" .orchestrator/metrics/events.jsonl → 0 (whole history)
|
|
11
|
+
*
|
|
12
|
+
* The second number is the real defect. `express-path.enabled` had no parser, so
|
|
13
|
+
* the documented default was prose rather than a value — but even a wired key only
|
|
14
|
+
* answers "what was configured". Nothing anywhere answered "did it fire?", and
|
|
15
|
+
* 22 of the last 30 sessions ran wave-less housekeeping without a single record
|
|
16
|
+
* saying whether the path applied to any of them.
|
|
17
|
+
*
|
|
18
|
+
* So the decision lives HERE, in code, and records itself as it is made. The prose
|
|
19
|
+
* in `skills/session-start/phase-8-5-express-path.md` points at this function; it
|
|
20
|
+
* does not re-implement the rule and it does not emit the event. A telemetry
|
|
21
|
+
* emitter that fires only when a coordinator reads the right paragraph is not
|
|
22
|
+
* wired — that is the same class of defect wave 2 of this session repaired for 18
|
|
23
|
+
* discovery probes.
|
|
24
|
+
*
|
|
25
|
+
* ## Where the rule comes from
|
|
26
|
+
*
|
|
27
|
+
* The three activation conditions are quoted verbatim from
|
|
28
|
+
* `skills/session-start/phase-8-5-express-path.md:9-11` and cross-checked against
|
|
29
|
+
* `docs/session-config-reference.md:1532-1536`. Both sources agree. The condition
|
|
30
|
+
* matrix in the same two files (spec `:47-53`, reference `:1557-1563`) supplies the
|
|
31
|
+
* fourth input, `parallelAgentsRequired`, which condition 3 carries as its second
|
|
32
|
+
* clause ("AND no parallel agents are required").
|
|
33
|
+
*
|
|
34
|
+
* ## Module placement — a deviation, and its cause
|
|
35
|
+
*
|
|
36
|
+
* Every other top-level Session Config block parser lives at
|
|
37
|
+
* `scripts/lib/config/<key>.mjs` (42 files). `_parseExpressPath` sits here instead
|
|
38
|
+
* because this agent's declared file scope for #1119 named `scripts/lib/express-path.mjs`
|
|
39
|
+
* and did not include `scripts/lib/config/`. The parser is otherwise a byte-for-byte
|
|
40
|
+
* sibling of `config/state-md-lock.mjs` — same block walk, same `matchBlockHeader`
|
|
41
|
+
* adoption (#830), same tolerant fallbacks. It imports only `./config/block-header.mjs`,
|
|
42
|
+
* which has zero imports by design, so no cycle is possible in either direction.
|
|
43
|
+
* Revisit trigger: if a later session is free to touch `scripts/lib/config/`, move
|
|
44
|
+
* `_parseExpressPath` to `scripts/lib/config/express-path.mjs` and leave only
|
|
45
|
+
* `evaluateExpressPath` here.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { matchBlockHeader } from './config/block-header.mjs';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The one event name this module emits. Named, not inlined, so a consumer that
|
|
52
|
+
* queries the ledger and the producer that writes it cannot drift apart.
|
|
53
|
+
* Matches `ORCHESTRATOR_EVENT_RE` in `events-schema.mjs`.
|
|
54
|
+
*/
|
|
55
|
+
export const EXPRESS_PATH_EVENT = 'orchestrator.express_path.evaluated';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Condition 3, first clause: "Agreed issue scope is ≤ 3 issues"
|
|
59
|
+
* (`skills/session-start/phase-8-5-express-path.md:11`).
|
|
60
|
+
*/
|
|
61
|
+
export const EXPRESS_PATH_MAX_TASKS = 3;
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Config parsing
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse the top-level `express-path:` YAML block from markdown content.
|
|
69
|
+
* Independent of the `## Session Config` section boundary, exactly like the
|
|
70
|
+
* sibling block parsers under `scripts/lib/config/`.
|
|
71
|
+
*
|
|
72
|
+
* Defaults:
|
|
73
|
+
* enabled: true (documented in docs/session-config-reference.md:1528 —
|
|
74
|
+
* "opt-in by default, opt-out via express-path.enabled: false")
|
|
75
|
+
*
|
|
76
|
+
* Because the default is `true`, only a literal `false` flips it — mirroring
|
|
77
|
+
* `config/state-md-lock.mjs`, whose `enabled` default is also `true`. (The
|
|
78
|
+
* `=== 'true'` form used by `config/discovery-validator.mjs` is the opposite
|
|
79
|
+
* convention and belongs to a default-`false` key.)
|
|
80
|
+
*
|
|
81
|
+
* Tolerant parser: malformed values silently fall back to the default.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} content — full file contents
|
|
84
|
+
* @returns {{ enabled: boolean }}
|
|
85
|
+
*/
|
|
86
|
+
export function _parseExpressPath(content) {
|
|
87
|
+
const defaults = {
|
|
88
|
+
enabled: true,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const lines = content.split(/\r?\n/);
|
|
92
|
+
let inBlock = false;
|
|
93
|
+
const blockLines = [];
|
|
94
|
+
|
|
95
|
+
for (const rawLine of lines) {
|
|
96
|
+
const line = rawLine.replace(/\r$/, '');
|
|
97
|
+
if (!inBlock) {
|
|
98
|
+
if (matchBlockHeader(line, 'express-path')) inBlock = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (line.length > 0 && !/^\s/.test(line)) break;
|
|
102
|
+
blockLines.push(line);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (blockLines.length === 0) return defaults;
|
|
106
|
+
|
|
107
|
+
let epEnabled = true;
|
|
108
|
+
|
|
109
|
+
for (const rawLine of blockLines) {
|
|
110
|
+
const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
|
|
111
|
+
if (!clean.trim()) continue;
|
|
112
|
+
|
|
113
|
+
const kvMatch = clean.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
|
|
114
|
+
if (!kvMatch) continue;
|
|
115
|
+
|
|
116
|
+
const k = kvMatch[1];
|
|
117
|
+
let v = kvMatch[2].trim();
|
|
118
|
+
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
|
|
119
|
+
else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
|
|
120
|
+
|
|
121
|
+
switch (k) {
|
|
122
|
+
case 'enabled':
|
|
123
|
+
// Default is true → only flip to false on explicit "false"
|
|
124
|
+
epEnabled = v.toLowerCase() !== 'false';
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
enabled: epEnabled,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// The decision
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read `express-path.enabled` out of a `parseSessionConfig()` result.
|
|
140
|
+
*
|
|
141
|
+
* Returns `undefined` — NOT `true` — when the flag was never measured, so the
|
|
142
|
+
* caller can distinguish "the operator set true" from "nobody looked". The
|
|
143
|
+
* documented default is applied to the DECISION; the absent measurement is kept
|
|
144
|
+
* out of the RECORD.
|
|
145
|
+
*
|
|
146
|
+
* @param {unknown} config — a `parseSessionConfig()` result, or anything else
|
|
147
|
+
* @returns {boolean|undefined}
|
|
148
|
+
*/
|
|
149
|
+
function _readEnabledFlag(config) {
|
|
150
|
+
if (config === null || typeof config !== 'object') return undefined;
|
|
151
|
+
const block = /** @type {Record<string, unknown>} */ (config)['express-path'];
|
|
152
|
+
if (block === null || typeof block !== 'object') return undefined;
|
|
153
|
+
const value = /** @type {Record<string, unknown>} */ (block).enabled;
|
|
154
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build the event payload. Every optional field is spread conditionally:
|
|
159
|
+
* a value that was not measured is OMITTED, never encoded as `0`/`false`.
|
|
160
|
+
* An absent key is the only honest encoding of "not measured" — the same
|
|
161
|
+
* contract `sessionAttribution()` in `events.mjs` follows for session ids.
|
|
162
|
+
*
|
|
163
|
+
* @param {object} ctx
|
|
164
|
+
* @returns {Record<string, unknown>}
|
|
165
|
+
*/
|
|
166
|
+
function _buildPayload(ctx) {
|
|
167
|
+
const { activated, reasons, enabledMeasured, sessionType, taskCount, parallelAgentsRequired } = ctx;
|
|
168
|
+
const sessionTypeMeasured = typeof sessionType === 'string' && sessionType.trim() !== '';
|
|
169
|
+
const taskCountMeasured = Number.isInteger(taskCount) && taskCount >= 0;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
activated,
|
|
173
|
+
reasons,
|
|
174
|
+
...(enabledMeasured === undefined ? {} : { enabled: enabledMeasured }),
|
|
175
|
+
...(sessionTypeMeasured ? { session_type: sessionType.trim() } : {}),
|
|
176
|
+
...(taskCountMeasured ? { task_count: taskCount } : {}),
|
|
177
|
+
...(typeof parallelAgentsRequired === 'boolean'
|
|
178
|
+
? { parallel_agents_required: parallelAgentsRequired }
|
|
179
|
+
: {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Append the evaluation record. Best-effort in the strict sense: it can never
|
|
185
|
+
* change, delay past its own await, or throw into the verdict.
|
|
186
|
+
*
|
|
187
|
+
* `events.mjs` is imported LAZILY on purpose, and the reason is measured rather
|
|
188
|
+
* than stylistic. `scripts/lib/config.mjs` imports this module for its parser,
|
|
189
|
+
* and its static import graph is 48 files today with no telemetry or platform
|
|
190
|
+
* modules in it. A static `import './events.mjs'` would add `platform.mjs`,
|
|
191
|
+
* which runs filesystem walk-ups at MODULE LOAD (`SO_PROJECT_DIR =
|
|
192
|
+
* resolveProjectDir(...)`) — paid by every hook and script that merely reads
|
|
193
|
+
* config. The lazy form also puts the telemetry module's own load inside the
|
|
194
|
+
* try/catch, so a broken events stack cannot stop the decision from being made.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} ctx — see `_buildPayload`, plus `repoRoot`
|
|
197
|
+
* @returns {Promise<void>}
|
|
198
|
+
*/
|
|
199
|
+
async function _emitEvaluated(ctx) {
|
|
200
|
+
const { repoRoot } = ctx;
|
|
201
|
+
|
|
202
|
+
// Refuse the SO_PROJECT_DIR fallback rather than guess a destination. Without
|
|
203
|
+
// an explicit repoRoot, `emitEvent` writes to whatever tree the ambient env
|
|
204
|
+
// resolves to — which in wave 1 of this session put a synthetic record into
|
|
205
|
+
// the operator's real fleet ledger. A skipped record is recoverable; a record
|
|
206
|
+
// in the wrong ledger is not. Diagnostics on stderr, never stdout.
|
|
207
|
+
if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
|
|
208
|
+
process.stderr.write(
|
|
209
|
+
`express-path: skipped ${EXPRESS_PATH_EVENT} — no repoRoot given; ` +
|
|
210
|
+
'refusing the ambient SO_PROJECT_DIR destination (#941).\n',
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const { emitEvent, sessionAttribution } = await import('./events.mjs');
|
|
217
|
+
await emitEvent(
|
|
218
|
+
EXPRESS_PATH_EVENT,
|
|
219
|
+
{ ..._buildPayload(ctx), ...sessionAttribution(repoRoot) },
|
|
220
|
+
{ repoRoot },
|
|
221
|
+
);
|
|
222
|
+
} catch {
|
|
223
|
+
// Best-effort telemetry — the verdict below is authoritative and is
|
|
224
|
+
// returned whether or not the ledger accepted the record.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @typedef {object} ExpressPathVerdict
|
|
230
|
+
* @property {boolean} activated — true only when every condition below holds.
|
|
231
|
+
* @property {string[]} reasons — when NOT activated, the blocking condition
|
|
232
|
+
* codes and nothing else: the answer to "why didn't it fire". When activated,
|
|
233
|
+
* the satisfied codes, since all of them decided jointly. Stable, greppable
|
|
234
|
+
* slugs — they are the query key for the ledger.
|
|
235
|
+
*/
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Evaluate the Express Path activation conditions and record the evaluation.
|
|
239
|
+
*
|
|
240
|
+
* The three conditions, quoted from `skills/session-start/phase-8-5-express-path.md:9-11`:
|
|
241
|
+
*
|
|
242
|
+
* 1. "`express-path.enabled` is `true` in Session Config (default: `true` —
|
|
243
|
+
* opt-in by default, opt-out via `express-path.enabled: false`)."
|
|
244
|
+
* 2. "Session type is `housekeeping` (the user confirmed `housekeeping` in Phase 8)."
|
|
245
|
+
* 3. "Agreed issue scope is ≤ 3 issues AND no parallel agents are required
|
|
246
|
+
* (i.e., tasks are sequential, no wave decomposition needed)."
|
|
247
|
+
*
|
|
248
|
+
* Every condition is evaluated — none short-circuits — because a run blocked by
|
|
249
|
+
* two conditions is a different fact from one blocked by a single condition, and
|
|
250
|
+
* the ledger is the only place that difference can ever be read.
|
|
251
|
+
*
|
|
252
|
+
* Fail-closed on an unmeasured input: an unknown session type or an unknown task
|
|
253
|
+
* count BLOCKS activation (`session-type-unknown` / `task-count-unknown`) rather
|
|
254
|
+
* than defaulting into a fast path that skips every quality gate. `enabled` is
|
|
255
|
+
* the one exception — its documented default is `true`, so an absent config
|
|
256
|
+
* applies that default to the decision while omitting `enabled` from the record.
|
|
257
|
+
*
|
|
258
|
+
* @param {object} [opts]
|
|
259
|
+
* @param {string} [opts.repoRoot] — repo whose `.orchestrator/metrics/events.jsonl`
|
|
260
|
+
* receives the record. REQUIRED for the record to be written; when absent the
|
|
261
|
+
* verdict is still returned and a WARN goes to stderr (see `_emitEvaluated`).
|
|
262
|
+
* @param {object} [opts.config] — a `parseSessionConfig()` result. Read for
|
|
263
|
+
* `config['express-path'].enabled` only.
|
|
264
|
+
* @param {string} [opts.sessionType] — the session type confirmed in Phase 8.
|
|
265
|
+
* @param {number} [opts.taskCount] — agreed issue/task scope (non-negative integer).
|
|
266
|
+
* @param {boolean} [opts.parallelAgentsRequired] — whether the agreed tasks need
|
|
267
|
+
* parallel agents. Omitting it asserts nothing: the condition is treated as
|
|
268
|
+
* satisfied (`parallel-agents-not-asserted`) and the field is left out of the
|
|
269
|
+
* record, because "nobody said parallel agents are needed" is not the same
|
|
270
|
+
* claim as "it was measured that none are needed".
|
|
271
|
+
* @returns {Promise<ExpressPathVerdict>}
|
|
272
|
+
*/
|
|
273
|
+
export async function evaluateExpressPath(opts = {}) {
|
|
274
|
+
const { repoRoot, config, sessionType, taskCount, parallelAgentsRequired } = opts;
|
|
275
|
+
|
|
276
|
+
const enabledMeasured = _readEnabledFlag(config);
|
|
277
|
+
const enabled = enabledMeasured === undefined ? true : enabledMeasured;
|
|
278
|
+
|
|
279
|
+
/** @type {string[]} */ const satisfied = [];
|
|
280
|
+
/** @type {string[]} */ const blocking = [];
|
|
281
|
+
|
|
282
|
+
// Condition 1 — express-path.enabled
|
|
283
|
+
if (enabled) satisfied.push('enabled');
|
|
284
|
+
else blocking.push('disabled-by-config');
|
|
285
|
+
|
|
286
|
+
// Condition 2 — session type is housekeeping
|
|
287
|
+
if (typeof sessionType !== 'string' || sessionType.trim() === '') {
|
|
288
|
+
blocking.push('session-type-unknown');
|
|
289
|
+
} else if (sessionType.trim() === 'housekeeping') {
|
|
290
|
+
satisfied.push('session-type-housekeeping');
|
|
291
|
+
} else {
|
|
292
|
+
blocking.push('session-type-not-housekeeping');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Condition 3a — scope ≤ EXPRESS_PATH_MAX_TASKS issues
|
|
296
|
+
if (!Number.isInteger(taskCount) || taskCount < 0) {
|
|
297
|
+
blocking.push('task-count-unknown');
|
|
298
|
+
} else if (taskCount > EXPRESS_PATH_MAX_TASKS) {
|
|
299
|
+
blocking.push('scope-exceeds-limit');
|
|
300
|
+
} else {
|
|
301
|
+
satisfied.push('scope-within-limit');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Condition 3b — no parallel agents required
|
|
305
|
+
if (parallelAgentsRequired === true) {
|
|
306
|
+
blocking.push('parallel-agents-required');
|
|
307
|
+
} else if (parallelAgentsRequired === false) {
|
|
308
|
+
satisfied.push('no-parallel-agents');
|
|
309
|
+
} else {
|
|
310
|
+
satisfied.push('parallel-agents-not-asserted');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const activated = blocking.length === 0;
|
|
314
|
+
const reasons = activated ? satisfied : blocking;
|
|
315
|
+
|
|
316
|
+
await _emitEvaluated({
|
|
317
|
+
repoRoot,
|
|
318
|
+
activated,
|
|
319
|
+
reasons,
|
|
320
|
+
enabledMeasured,
|
|
321
|
+
sessionType,
|
|
322
|
+
taskCount,
|
|
323
|
+
parallelAgentsRequired,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return { activated, reasons };
|
|
327
|
+
}
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
* 4. live holder OR cross-host → poll until a deadline;
|
|
13
13
|
* 5. owner-guarded release.
|
|
14
14
|
*
|
|
15
|
-
* This module is the single home for that skeleton. It is a PURE primitive:
|
|
16
|
-
* it imports ONLY scripts/lib/io.mjs (for writeJsonAtomicSync)
|
|
15
|
+
* This module is the single home for that skeleton. It is a near-PURE primitive:
|
|
16
|
+
* it imports ONLY scripts/lib/io.mjs (for writeJsonAtomicSync), the two host
|
|
17
|
+
* identity helpers from scripts/lib/host-identity.mjs (#1072 — `os.hostname()`
|
|
18
|
+
* is not stable on a single machine), and Node stdlib.
|
|
17
19
|
* It does NOT import session-lock.mjs — instead `isPidAliveOnHost` is MOVED here
|
|
18
20
|
* and re-exported from session-lock.mjs, so the dependency edge points
|
|
19
21
|
* file-lock → io, never the reverse (no import cycle).
|
|
@@ -35,6 +37,7 @@ import path from 'node:path';
|
|
|
35
37
|
import crypto from 'node:crypto';
|
|
36
38
|
|
|
37
39
|
import { writeJsonAtomicSync } from './io.mjs';
|
|
40
|
+
import { hostnamesMatch, lockHostCandidate, stableHostname } from './host-identity.mjs';
|
|
38
41
|
|
|
39
42
|
// ---------------------------------------------------------------------------
|
|
40
43
|
// PID liveness (moved here from session-lock.mjs in #630)
|
|
@@ -139,7 +142,17 @@ function parseBody(raw) {
|
|
|
139
142
|
* @returns {{ stale: boolean, reason: string }}
|
|
140
143
|
*/
|
|
141
144
|
function isExistingStale(existing, staleCheck, staleMs, lockPath) {
|
|
142
|
-
|
|
145
|
+
// #1072: compare on the normalised/alias-aware identity, never on a raw
|
|
146
|
+
// `os.hostname()` string. A raw comparison reports `cross-host` for THIS
|
|
147
|
+
// machine's own lock whenever the hostname has flipped spelling since it was
|
|
148
|
+
// written, and `cross-host` disables stale detection entirely (below) — the
|
|
149
|
+
// lock then survives its holder for the full timeout. `host_id` is preferred
|
|
150
|
+
// when present because it is already normalised; `host` is the pre-#1072
|
|
151
|
+
// fallback, so old lock files need no migration. `lockHostCandidate` owns
|
|
152
|
+
// that choice for all six lock-family call sites — and falls back on an
|
|
153
|
+
// EMPTY `host_id` too, which a bare `??` would have short-circuited into a
|
|
154
|
+
// false `cross-host` verdict against this machine's own lock.
|
|
155
|
+
const sameHost = hostnamesMatch(lockHostCandidate(existing), os.hostname());
|
|
143
156
|
if (!sameHost) return { stale: false, reason: 'cross-host' };
|
|
144
157
|
|
|
145
158
|
if (staleCheck === 'none') return { stale: false, reason: 'live' };
|
|
@@ -294,7 +307,10 @@ export function tryAcquireFileLock(lockPath, opts = {}) {
|
|
|
294
307
|
|
|
295
308
|
const body = {
|
|
296
309
|
pid: process.pid,
|
|
310
|
+
// `host` stays the RAW hostname (on-the-wire field other readers pin);
|
|
311
|
+
// `host_id` is the additive normalised twin the comparisons above use.
|
|
297
312
|
host: os.hostname(),
|
|
313
|
+
host_id: stableHostname(),
|
|
298
314
|
acquiredAt: new Date().toISOString(),
|
|
299
315
|
...(meta && typeof meta === 'object' ? meta : {}),
|
|
300
316
|
};
|
|
@@ -407,7 +423,9 @@ export function releaseFileLock(lockPath, opts = {}) {
|
|
|
407
423
|
const expectedHolder = typeof holder === 'string' && holder.length > 0 ? holder : null;
|
|
408
424
|
const ownerMatch = expectedHolder !== null
|
|
409
425
|
? lock.holder === expectedHolder
|
|
410
|
-
:
|
|
426
|
+
// #1072: a raw hostname comparison here makes a process unable to release
|
|
427
|
+
// its OWN lock after the hostname flips spelling mid-session.
|
|
428
|
+
: lock.pid === process.pid && hostnamesMatch(lockHostCandidate(lock), os.hostname());
|
|
411
429
|
|
|
412
430
|
if (!ownerMatch) return { ok: false, reason: 'not-owner' };
|
|
413
431
|
|
|
@@ -40,14 +40,42 @@ const tcErrorCount =
|
|
|
40
40
|
: 0;
|
|
41
41
|
|
|
42
42
|
// --- Test ---
|
|
43
|
-
// NOTE: `
|
|
44
|
-
// bottom of this file and drives `process.exit(failed ? 2 : 0)`.
|
|
45
|
-
// would corrupt the gate's exit code.
|
|
43
|
+
// NOTE: `testCounts`, NOT `failed` — a local `failed` is already bound near
|
|
44
|
+
// the bottom of this file and drives `process.exit(failed ? 2 : 0)`.
|
|
45
|
+
// Shadowing it would corrupt the gate's exit code.
|
|
46
46
|
const testResult = runCheck(testCmd);
|
|
47
|
-
const
|
|
47
|
+
const testCounts =
|
|
48
48
|
testResult.status !== 'skip'
|
|
49
49
|
? extractTestCounts(testResult.fullOutput ?? testResult.output)
|
|
50
|
-
: { passed: 0, failed: 0, total: 0 };
|
|
50
|
+
: { passed: 0, failed: 0, total: 0, files: null };
|
|
51
|
+
|
|
52
|
+
// A suite that dies at import time (a bad import, a syntax error in a test
|
|
53
|
+
// file) is counted on vitest's `Test Files` line, not its `Tests` line —
|
|
54
|
+
// vitest omits `N failed` from `Tests` when zero individual test CASES ran.
|
|
55
|
+
// That leaves `status: 'fail'` sitting beside `failed: 0`, which reads as "no
|
|
56
|
+
// failures" even though the gate is about to block. `suite_died` names the
|
|
57
|
+
// contradiction explicitly, in the JSON itself, rather than leaving it
|
|
58
|
+
// inferable only by cross-referencing three separate fields (#1149).
|
|
59
|
+
//
|
|
60
|
+
// All FOUR of these fields are file-measurement-derived, so all four are
|
|
61
|
+
// published together or not at all. Only vitest prints a `Test Files` line; a
|
|
62
|
+
// non-vitest runner or a terse reporter prints none, and the previous
|
|
63
|
+
// zero-filled `files_*: 0` was an UNMEASURED zero the envelope could not tell
|
|
64
|
+
// from a measured one — with `suite_died: false` derived from it, stating a
|
|
65
|
+
// verdict nobody had checked. Absent, not zero: the same contract this
|
|
66
|
+
// envelope's `counts` field already keeps (`admitSuiteCounts`).
|
|
67
|
+
const fileFields = testCounts.files
|
|
68
|
+
? {
|
|
69
|
+
files_total: testCounts.files.total,
|
|
70
|
+
files_passed: testCounts.files.passed,
|
|
71
|
+
files_failed: testCounts.files.failed,
|
|
72
|
+
// Self-diagnosing: true exactly when `status: 'fail'` sits beside a
|
|
73
|
+
// test-case `failed: 0` that a file-level failure explains. Greppable —
|
|
74
|
+
// a consumer no longer has to recompute the contradiction by hand.
|
|
75
|
+
suite_died:
|
|
76
|
+
testResult.status === 'fail' && testCounts.failed === 0 && testCounts.files.failed > 0,
|
|
77
|
+
}
|
|
78
|
+
: {};
|
|
51
79
|
|
|
52
80
|
// --- Lint ---
|
|
53
81
|
const lintResult = runCheck(lintCmd);
|
|
@@ -79,9 +107,15 @@ const output = {
|
|
|
79
107
|
// `passed + failed === total` as a real producer/consumer drift guard.
|
|
80
108
|
test: {
|
|
81
109
|
status: testResult.status,
|
|
82
|
-
total:
|
|
83
|
-
passed:
|
|
84
|
-
failed:
|
|
110
|
+
total: testCounts.total,
|
|
111
|
+
passed: testCounts.passed,
|
|
112
|
+
failed: testCounts.failed,
|
|
113
|
+
// File-level counts + `suite_died` (#1149) — present only when a
|
|
114
|
+
// `Test Files` line was actually parsed; see `fileFields` above.
|
|
115
|
+
// Additive keys only: `admitSuiteCounts` / `suiteCountsFromGateStdout`
|
|
116
|
+
// read just passed/failed/total, so the stdout JSON contract stays one
|
|
117
|
+
// document and the test-case triple keeps `total === passed + failed`.
|
|
118
|
+
...fileFields,
|
|
85
119
|
},
|
|
86
120
|
lint: { status: lintResult.status, warnings: lintWarnings },
|
|
87
121
|
debug_artifacts: debugArtifacts,
|
|
@@ -94,4 +128,43 @@ process.stdout.write(JSON.stringify(output) + '\n');
|
|
|
94
128
|
const failed = [tcResult, testResult, lintResult].some(
|
|
95
129
|
(r) => r.status === 'fail',
|
|
96
130
|
);
|
|
131
|
+
|
|
132
|
+
// --- Failure disclosure ---
|
|
133
|
+
// A gate that BLOCKS and says nothing is not concise, it is unusable. Measured
|
|
134
|
+
// 2026-08-23: a pre-push run emitted exactly
|
|
135
|
+
// {"test":{"status":"fail","total":14671,"passed":14671,"failed":0}, …}
|
|
136
|
+
// and nothing else. `failed: 0` beside `status: fail` was not a contradiction
|
|
137
|
+
// in the data — it was the SHAPE of the report: `extractTestCounts` reads
|
|
138
|
+
// vitest's `Tests …` summary line, and vitest OMITS the `N failed` segment
|
|
139
|
+
// when no individual test case failed. A suite that dies at import time is
|
|
140
|
+
// counted on the `Test Files …` line instead, which this payload had no field
|
|
141
|
+
// for. So a file-level failure was, by construction, reported as zero
|
|
142
|
+
// failures. The JSON envelope now carries `files_failed` and `suite_died`
|
|
143
|
+
// (#1149) so that contradiction is published rather than only inferable —
|
|
144
|
+
// but the envelope still does not name WHICH file died.
|
|
145
|
+
//
|
|
146
|
+
// Reconstructing which file died then cost a full manual re-materialisation of
|
|
147
|
+
// the tracked tree. That is the cost this block removes: on failure the raw
|
|
148
|
+
// output of every failing gate goes to STDERR, where the JSON contract on
|
|
149
|
+
// STDOUT is untouched and every existing consumer keeps parsing one line.
|
|
150
|
+
//
|
|
151
|
+
// stderr, not stdout, and only on failure, for three reasons that are all
|
|
152
|
+
// contract-preserving: `scripts/run-quality-gate.mjs` and the pre-push hook
|
|
153
|
+
// parse stdout as ONE JSON document; a green run stays silent, so the "~2 min
|
|
154
|
+
// of silence" the hook promises still holds; and the operator gets the failure
|
|
155
|
+
// at the moment of the block instead of a second run to find it.
|
|
156
|
+
if (failed) {
|
|
157
|
+
for (const [name, result] of [
|
|
158
|
+
['typecheck', tcResult],
|
|
159
|
+
['test', testResult],
|
|
160
|
+
['lint', lintResult],
|
|
161
|
+
]) {
|
|
162
|
+
if (result.status !== 'fail') continue;
|
|
163
|
+
const raw = result.fullOutput ?? result.output ?? '';
|
|
164
|
+
process.stderr.write(`\n──── ${name} FAILED — raw output ────\n`);
|
|
165
|
+
process.stderr.write(raw.length > 0 ? raw : '(the gate captured no output)\n');
|
|
166
|
+
process.stderr.write(`──── end ${name} ────\n`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
97
170
|
process.exit(failed ? 2 : 0);
|
|
@@ -154,7 +154,35 @@ export function extractCount(output, pattern) {
|
|
|
154
154
|
const TEST_SUMMARY_LINE = /^\s*Tests\b/;
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
|
-
*
|
|
157
|
+
* Matches vitest's FILE-level summary line (`Test Files 8 failed | 595 passed (603)`).
|
|
158
|
+
*
|
|
159
|
+
* Distinct from {@link TEST_SUMMARY_LINE} (the TEST-CASE line, `Tests ...`): a
|
|
160
|
+
* suite that dies at import time is counted HERE, and the `Tests` line then
|
|
161
|
+
* carries no `N failed` segment at all (#1149) — vitest omits it whenever zero
|
|
162
|
+
* individual test cases ran. Last matching line wins (the final summary after
|
|
163
|
+
* any rerun), mirroring `TEST_SUMMARY_LINE`'s own convention.
|
|
164
|
+
*/
|
|
165
|
+
const TEST_FILES_SUMMARY_LINE = /^\s*Test Files\b/;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse a `<N> passed` / `<N> failed` pair out of one summary line/scope.
|
|
169
|
+
* `total` is their sum (skipped excluded) — the same rule the top-level
|
|
170
|
+
* test-case triple in {@link extractTestCounts} publishes.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} scope
|
|
173
|
+
* @returns {{ passed: number, failed: number, total: number }}
|
|
174
|
+
*/
|
|
175
|
+
function parseCountTriple(scope) {
|
|
176
|
+
const passMatch = scope.match(/(\d+)\s+passed/);
|
|
177
|
+
const failMatch = scope.match(/(\d+)\s+failed/);
|
|
178
|
+
const passed = passMatch ? parseInt(passMatch[1], 10) : 0;
|
|
179
|
+
const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
|
|
180
|
+
return { passed, failed, total: passed + failed };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Parse test-runner output for pass/fail/total TEST-CASE counts, plus the
|
|
185
|
+
* vitest FILE-level triple when a `Test Files` summary line is present.
|
|
158
186
|
*
|
|
159
187
|
* ## Which line is parsed
|
|
160
188
|
*
|
|
@@ -168,10 +196,38 @@ const TEST_SUMMARY_LINE = /^\s*Tests\b/;
|
|
|
168
196
|
* A naive whole-output scan hits the FILE count (550) and publishes it as the
|
|
169
197
|
* test count — the number then rides `gate-full.mjs`'s `test.passed` into the
|
|
170
198
|
* `orchestrator.quality_gate.*` event stream looking authoritative. So: when a
|
|
171
|
-
* `Tests`-anchored summary line exists, ONLY that line is parsed
|
|
172
|
-
* which is the final summary after any
|
|
173
|
-
* no such line falls back to scanning
|
|
174
|
-
* bare `"42 passed"` / `"10 passed, 5
|
|
199
|
+
* `Tests`-anchored summary line exists, ONLY that line is parsed for the
|
|
200
|
+
* test-case triple (the LAST one, which is the final summary after any
|
|
201
|
+
* rerun). Terse or non-vitest output with no such line falls back to scanning
|
|
202
|
+
* the whole string, which preserves the bare `"42 passed"` / `"10 passed, 5
|
|
203
|
+
* failed"` forms.
|
|
204
|
+
*
|
|
205
|
+
* The `Test Files` line is parsed the same way, into a SEPARATE nested `files`
|
|
206
|
+
* triple — never folded into `failed`/`total`. `total === passed + failed` is
|
|
207
|
+
* load-bearing for the test-case triple (`admitSuiteCounts`, and historically
|
|
208
|
+
* `failed = total - passed` in `run-quality-gate.mjs`); mixing file counts in
|
|
209
|
+
* would either break that invariant or invent phantom test-case failures. A
|
|
210
|
+
* suite that dies at import is invisible on the `Tests` line (vitest omits
|
|
211
|
+
* `N failed` there when zero test cases ran) and visible only on `Test
|
|
212
|
+
* Files` — #1149, measured as
|
|
213
|
+
* `{"test":{"status":"fail","total":14671,"passed":14671,"failed":0}}`, with
|
|
214
|
+
* no field anywhere naming the 8 files that actually died.
|
|
215
|
+
* `gate-full.mjs` derives a `suite_died` boolean from `files.failed` for
|
|
216
|
+
* exactly this shape.
|
|
217
|
+
*
|
|
218
|
+
* ## `files` is NULL when no `Test Files` line was seen — never a zero triple
|
|
219
|
+
*
|
|
220
|
+
* Only vitest prints that line. A non-vitest runner, a terse reporter, or a
|
|
221
|
+
* fail-fast crash produces output with no file-level summary at all, and the
|
|
222
|
+
* function used to publish `files_total/passed/failed: 0` there — an UNMEASURED
|
|
223
|
+
* zero, byte-identical in the envelope to a measured one, from which
|
|
224
|
+
* `gate-full.mjs` then derived `suite_died: false` as if it had checked. That is
|
|
225
|
+
* the same "absent is not zero" defect `admitSuiteCounts` exists to prevent one
|
|
226
|
+
* field over, so it uses the same channel: `null` means "no file-level
|
|
227
|
+
* measurement", and the caller OMITS the keys rather than zero-filling them.
|
|
228
|
+
* The test-case triple keeps its zeros — those are the caller's fallback, not a
|
|
229
|
+
* parse result, and `total: 0` is already the "no counts" signal
|
|
230
|
+
* `admitSuiteCounts` rejects.
|
|
175
231
|
*
|
|
176
232
|
* ## What `total` means — passed + failed, NOT vitest's parenthesised number
|
|
177
233
|
*
|
|
@@ -189,22 +245,27 @@ const TEST_SUMMARY_LINE = /^\s*Tests\b/;
|
|
|
189
245
|
* see. Add it together with a consumer, never ahead of one.
|
|
190
246
|
*
|
|
191
247
|
* @param {string} output - Captured test-runner stdout/stderr (or a tail of it).
|
|
192
|
-
* @returns {{ passed: number, failed: number, total: number
|
|
248
|
+
* @returns {{ passed: number, failed: number, total: number, files: {passed: number, failed: number, total: number}|null }}
|
|
249
|
+
* Test-case `total === passed + failed`. `files` is `null` when the output
|
|
250
|
+
* carried no `Test Files` summary line; when present,
|
|
251
|
+
* `files.total === files.passed + files.failed`.
|
|
193
252
|
*/
|
|
194
253
|
export function extractTestCounts(output) {
|
|
195
|
-
|
|
254
|
+
const EMPTY = { passed: 0, failed: 0, total: 0, files: null };
|
|
255
|
+
if (!output) return EMPTY;
|
|
196
256
|
|
|
197
|
-
const
|
|
257
|
+
const lines = output.split('\n');
|
|
258
|
+
const summaryLines = lines.filter((line) => TEST_SUMMARY_LINE.test(line));
|
|
198
259
|
const scope = summaryLines.length > 0 ? summaryLines[summaryLines.length - 1] : output;
|
|
260
|
+
const { passed, failed, total } = parseCountTriple(scope);
|
|
199
261
|
|
|
200
|
-
const
|
|
201
|
-
const
|
|
262
|
+
const fileSummaryLines = lines.filter((line) => TEST_FILES_SUMMARY_LINE.test(line));
|
|
263
|
+
const fileScope = fileSummaryLines.length > 0 ? fileSummaryLines[fileSummaryLines.length - 1] : '';
|
|
264
|
+
// No `Test Files` line → NOT MEASURED. See the docstring section above: a zero
|
|
265
|
+
// triple here would be indistinguishable from a real all-passed-zero-files run.
|
|
266
|
+
const files = fileScope ? parseCountTriple(fileScope) : null;
|
|
202
267
|
|
|
203
|
-
|
|
204
|
-
const failed = failMatch ? parseInt(failMatch[1], 10) : 0;
|
|
205
|
-
const total = passed + failed;
|
|
206
|
-
|
|
207
|
-
return { passed, failed, total };
|
|
268
|
+
return { passed, failed, total, files };
|
|
208
269
|
}
|
|
209
270
|
|
|
210
271
|
/**
|