session-orchestrator 3.24.0 → 4.0.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/architecture/SKILL.md +18 -0
- package/.agents/skills/autopilot/SKILL.md +17 -0
- package/.agents/skills/bootstrap/SKILL.md +20 -0
- package/.agents/skills/brainstorm/SKILL.md +22 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +15 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +22 -0
- package/.agents/skills/debug/SKILL.md +22 -0
- package/.agents/skills/discovery/SKILL.md +20 -0
- package/.agents/skills/dispatcher/SKILL.md +15 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +18 -0
- package/.agents/skills/ecosystem-health/SKILL.md +20 -0
- package/.agents/skills/eli5/SKILL.md +20 -0
- package/.agents/skills/eval/SKILL.md +21 -0
- package/.agents/skills/evolve/SKILL.md +21 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +15 -0
- package/.agents/skills/gitlab-ops/SKILL.md +20 -0
- package/.agents/skills/gitlab-portfolio/SKILL.md +15 -0
- package/.agents/skills/grill/SKILL.md +22 -0
- package/.agents/skills/hook-development/SKILL.md +15 -0
- package/.agents/skills/mcp-builder/SKILL.md +15 -0
- package/.agents/skills/memory-cleanup/SKILL.md +21 -0
- package/.agents/skills/mode-selector/SKILL.md +17 -0
- package/.agents/skills/npm-publish/SKILL.md +16 -0
- package/.agents/skills/peekaboo-driver/SKILL.md +18 -0
- package/.agents/skills/persona-panel/SKILL.md +17 -0
- package/.agents/skills/plan/SKILL.md +20 -0
- package/.agents/skills/playwright-driver/SKILL.md +20 -0
- package/.agents/skills/quality-gates/SKILL.md +20 -0
- package/.agents/skills/reconcile/SKILL.md +21 -0
- package/.agents/skills/remote-offload/SKILL.md +20 -0
- package/.agents/skills/repo-audit/SKILL.md +16 -0
- package/.agents/skills/session-end/SKILL.md +20 -0
- package/.agents/skills/session-plan/SKILL.md +20 -0
- package/.agents/skills/session-start/SKILL.md +20 -0
- package/.agents/skills/spinout/SKILL.md +16 -0
- package/.agents/skills/sunset-review/SKILL.md +16 -0
- package/.agents/skills/test-runner/SKILL.md +20 -0
- package/.agents/skills/tmux-layout/SKILL.md +21 -0
- package/.agents/skills/using-orchestrator/SKILL.md +17 -0
- package/.agents/skills/vault-mirror/SKILL.md +15 -0
- package/.agents/skills/vault-sync/SKILL.md +15 -0
- package/.agents/skills/wave-executor/SKILL.md +20 -0
- package/.agents/skills/write-executable-plan/SKILL.md +22 -0
- 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.md +2 -2
- package/.cursor/commands/bootstrap.md +1 -1
- package/.cursor/commands/brainstorm.md +1 -1
- package/.cursor/commands/debug.md +1 -1
- package/.cursor/commands/discovery.md +1 -1
- package/.cursor/commands/dispatcher.md +2 -2
- package/.cursor/commands/eli5.md +2 -2
- package/.cursor/commands/eval.md +2 -2
- package/.cursor/commands/evolve.md +1 -1
- package/.cursor/commands/go.md +1 -1
- package/.cursor/commands/grill.md +2 -2
- package/.cursor/commands/memory-cleanup.md +2 -2
- package/.cursor/commands/persona-panel.md +1 -1
- package/.cursor/commands/plan.md +1 -1
- package/.cursor/commands/portfolio.md +1 -1
- package/.cursor/commands/reconcile.md +2 -2
- package/.cursor/commands/release.md +2 -2
- package/.cursor/commands/session.md +2 -2
- package/.cursor/commands/spinout.md +2 -2
- package/.cursor/commands/sunset-review.md +2 -2
- package/.cursor/commands/templates-ack.md +2 -2
- package/.cursor/commands/test.md +2 -2
- package/.cursor/skills/brainstorm/SKILL.md +1 -1
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/quality-gates/SKILL.md +1 -1
- package/.cursor/skills/remote-offload/SKILL.md +1 -1
- package/.orchestrator/policy/blocked-commands.json +121 -0
- package/.orchestrator/policy/ecosystem.schema.json +66 -0
- package/.orchestrator/policy/quality-gates.example.json +16 -0
- package/.orchestrator/policy/quality-gates.schema.json +38 -0
- package/.orchestrator/policy/templates-policy.json +27 -0
- package/.orchestrator/policy/test-profiles.json +47 -0
- package/AGENTS.md +225 -0
- package/CHANGELOG.md +1125 -2
- package/NOTICE +11 -6
- package/README.md +127 -94
- package/agents/eval-judge.md +1 -1
- package/agents/skill-applied-judge.md +1 -1
- package/assets/wave-lifecycle.svg +98 -0
- package/commands/release.md +6 -3
- package/commands/session.md +18 -3
- package/docs/README.md +4 -0
- package/{agents/AGENTS.md → docs/agent-authoring.md} +19 -26
- package/docs/baseline.md +67 -0
- package/docs/ci-setup.md +108 -62
- package/docs/codex-setup.md +65 -21
- package/docs/components.md +36 -15
- package/docs/cursor-setup.md +6 -2
- package/docs/events-schema.md +9 -6
- package/docs/instruction-delivery.md +62 -0
- package/{agents/memory-proposal-collector.md → docs/memory-proposal-flow.md} +1 -8
- package/docs/migration-v4.md +341 -0
- package/docs/pi-setup.md +6 -1
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +85 -19
- package/docs/scope-collision-guard.md +5 -5
- package/docs/session-config-reference.md +57 -56
- package/docs/session-config-template.md +6 -29
- package/docs/telemetry.md +157 -3
- package/docs/vault-docs-architecture.md +50 -11
- package/hooks/_lib/hook-import-set.json +1487 -0
- package/hooks/_lib/subagent-transcript.mjs +562 -0
- package/hooks/config-protection.mjs +2 -2
- package/hooks/cwd-change-restore.mjs +2 -2
- package/hooks/enforce-commands.mjs +69 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +10 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/loop-guard.mjs +3 -3
- package/hooks/on-session-end.mjs +2 -2
- package/hooks/on-session-start.mjs +103 -2
- package/hooks/on-stop.mjs +36 -11
- package/hooks/operator-steer.mjs +2 -2
- package/hooks/post-bash-write-verify.mjs +85 -0
- package/hooks/post-edit-import-probe.mjs +344 -0
- package/hooks/post-subagent-discovery-validator.mjs +187 -431
- package/hooks/post-tool-batch-wave-signal.mjs +118 -4
- package/hooks/post-tool-failure-corrective-context.mjs +2 -2
- package/hooks/post-tooluse-frontend-slop.mjs +3 -3
- package/hooks/pre-bash-destructive-guard.mjs +39 -13
- package/hooks/skill-invocation-telemetry.mjs +17 -5
- package/hooks/subagent-telemetry.mjs +13 -4
- package/monitors/monitors.json +3 -3
- package/package.json +9 -1
- package/pi/prompts/session.md +2 -2
- package/plugin.json +27 -0
- package/scripts/backfill-abandoned-sessions.mjs +50 -4
- package/scripts/backfill-learnings-from-vault.mjs +9 -3
- package/scripts/dialectic-deriver.mjs +73 -8
- package/scripts/export-hw-learnings.mjs +113 -1
- package/scripts/generate-agents-skills.mjs +378 -0
- package/scripts/generate-cursor-adapter.mjs +45 -8
- package/scripts/generate-hook-import-set.mjs +249 -0
- package/scripts/lib/agent-status.mjs +13 -2
- package/scripts/lib/auto-dream.mjs +38 -36
- package/scripts/lib/autonomy/suitability.mjs +6 -0
- package/scripts/lib/autopilot/loop.mjs +2 -2
- package/scripts/lib/ci-status-banner.mjs +220 -75
- package/scripts/lib/codex/plugin-contract.mjs +82 -6
- package/scripts/lib/config/auto-dream.mjs +2 -1
- package/scripts/lib/config/block-header.mjs +8 -0
- package/scripts/lib/config/block-preprocess.mjs +177 -0
- package/scripts/lib/config/broken-window.mjs +2 -1
- package/scripts/lib/config/cold-start.mjs +2 -1
- package/scripts/lib/config/config-protection.mjs +22 -2
- package/scripts/lib/config/context-coverage.mjs +2 -1
- package/scripts/lib/config/cross-repo.mjs +2 -1
- package/scripts/lib/config/custom-phases.mjs +2 -1
- package/scripts/lib/config/dialectic.mjs +2 -1
- package/scripts/lib/config/discovery-validator.mjs +2 -1
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +24 -1
- package/scripts/lib/config/dispatcher-autonomy.mjs +2 -1
- package/scripts/lib/config/docs-orchestrator.mjs +2 -1
- package/scripts/lib/config/docs-staleness.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +2 -1
- package/scripts/lib/config/eval.mjs +2 -1
- package/scripts/lib/config/events-rotation.mjs +2 -1
- package/scripts/lib/config/evolve.mjs +8 -2
- package/scripts/lib/config/frontend-slop-hook.mjs +7 -3
- package/scripts/lib/config/gitlab-portfolio.mjs +2 -1
- package/scripts/lib/config/handover-gate.mjs +2 -1
- package/scripts/lib/config/health-endpoints.mjs +7 -2
- package/scripts/lib/config/issue-budget.mjs +2 -1
- package/scripts/lib/config/loop-guard.mjs +2 -1
- package/scripts/lib/config/memory.mjs +2 -1
- package/scripts/lib/config/moc-staleness.mjs +2 -1
- package/scripts/lib/config/persona-gate-wave.mjs +2 -1
- package/scripts/lib/config/private-config-dir.mjs +67 -0
- package/scripts/lib/config/reconcile.mjs +2 -1
- package/scripts/lib/config/remote-hosts.mjs +2 -1
- package/scripts/lib/config/section-extractor.mjs +7 -1
- package/scripts/lib/config/skill-evolution.mjs +2 -1
- package/scripts/lib/config/slopcheck.mjs +2 -1
- package/scripts/lib/config/state-md-lock.mjs +2 -1
- package/scripts/lib/config/templates-first.mjs +2 -1
- package/scripts/lib/config/test.mjs +2 -1
- package/scripts/lib/config/vault-integration.mjs +7 -1
- package/scripts/lib/config/vault-mirror-quality.mjs +2 -1
- package/scripts/lib/config/vault-staleness.mjs +2 -1
- package/scripts/lib/config/vault-sync.mjs +2 -1
- package/scripts/lib/config/verification-auto-fix.mjs +2 -1
- package/scripts/lib/config/wave-reviewers.mjs +2 -1
- package/scripts/lib/config/worktree-orphans.mjs +2 -1
- package/scripts/lib/convergence-monitor.mjs +82 -16
- package/scripts/lib/dispatcher/rank.mjs +124 -48
- package/scripts/lib/ecosystem-health.mjs +16 -2
- package/scripts/lib/eval/engine.mjs +9 -1
- package/scripts/lib/eval/session-resolve.mjs +23 -4
- package/scripts/lib/events.mjs +22 -6
- package/scripts/lib/frontmatter-guard.mjs +131 -13
- package/scripts/lib/gates/gate-full.mjs +26 -0
- package/scripts/lib/gates/gate-helpers.mjs +76 -0
- package/scripts/lib/hardware-pattern-detector.mjs +18 -1
- package/scripts/lib/harness-audit/categories/category4.mjs +31 -11
- package/scripts/lib/host-identity.mjs +50 -11
- package/scripts/lib/instruction-budget-guard.mjs +171 -5
- package/scripts/lib/learnings/evolve-telemetry.mjs +178 -0
- package/scripts/lib/learnings/io.mjs +60 -6
- package/scripts/lib/memory-proposals/store.mjs +30 -22
- package/scripts/lib/owner-config-banner.mjs +43 -6
- package/scripts/lib/owner-config-loader.mjs +21 -10
- package/scripts/lib/owner-interview.mjs +3 -3
- package/scripts/lib/owner-yaml.mjs +207 -14
- package/scripts/lib/platform.mjs +108 -15
- package/scripts/lib/plugin-update-banner.mjs +406 -0
- package/scripts/lib/project-hygiene.mjs +38 -2
- package/scripts/lib/qg-command-drift-banner.mjs +50 -12
- package/scripts/lib/quality-gate.mjs +133 -44
- package/scripts/lib/reconcile/emitter.mjs +68 -6
- package/scripts/lib/reconcile/engine.mjs +13 -4
- package/scripts/lib/reconcile/idempotency.mjs +37 -4
- package/scripts/lib/reconcile/writer.mjs +40 -18
- package/scripts/lib/session-close-backfill.mjs +67 -9
- package/scripts/lib/session-id.mjs +12 -23
- package/scripts/lib/session-identity/own-session.mjs +125 -10
- package/scripts/lib/session-lock-shape.mjs +43 -0
- package/scripts/lib/session-lock.mjs +5 -10
- package/scripts/lib/session-registry.mjs +25 -9
- package/scripts/lib/session-schema/constants.mjs +36 -2
- package/scripts/lib/session-schema/validator.mjs +38 -4
- package/scripts/lib/session-start-probes.mjs +18 -1
- package/scripts/lib/sessions-staleness-banner.mjs +18 -11
- package/scripts/lib/skill-health/join.mjs +17 -4
- package/scripts/lib/state-md.mjs +78 -0
- package/scripts/lib/sunset/walker.mjs +6 -0
- package/scripts/lib/telemetry/schema.mjs +181 -9
- package/scripts/lib/telemetry/sync.mjs +368 -12
- package/scripts/lib/validate/check-agents-skills.mjs +327 -0
- package/scripts/lib/validate/check-agents.mjs +3 -3
- package/scripts/lib/validate/check-cursor-adapter.mjs +234 -72
- package/scripts/lib/validate/check-hooks-symmetry.mjs +45 -16
- package/scripts/lib/validate/check-owner-leakage.mjs +281 -20
- package/scripts/lib/validate/check-skill-links.mjs +163 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +47 -28
- package/scripts/lib/validate/check-unwired-features.mjs +0 -2
- package/scripts/lib/validate/check-validator-registration.mjs +10 -4
- package/scripts/lib/validate/enumerate-repo-files.mjs +317 -0
- package/scripts/lib/vault-backfill/template.mjs +63 -6
- package/scripts/lib/vault-mirror/process.mjs +165 -42
- package/scripts/lib/vault-mirror/telemetry.mjs +2 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +127 -18
- package/scripts/lib/wave-executor/dispatch-common.mjs +164 -0
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +7 -142
- package/scripts/lib/wave-executor/remote-dispatch.mjs +5 -7
- package/scripts/lib/wave-resource-gate.mjs +8 -2
- package/scripts/lib/wave-sizing.mjs +4 -1
- package/scripts/lib/wave-transcript-tail.mjs +118 -4
- package/scripts/materialize-wave-scope.mjs +12 -5
- package/scripts/memory-propose.mjs +19 -5
- package/scripts/migrate-cold-start-seed.mjs +4 -1
- package/scripts/parse-config.mjs +60 -3
- package/scripts/release.mjs +337 -29
- package/scripts/repair-invalid-sessions.mjs +3 -3
- package/scripts/run-quality-gate.mjs +128 -11
- package/scripts/sweep-expired-learnings.mjs +90 -0
- package/scripts/sync-vault-schema.mjs +3 -1
- package/scripts/telemetry.mjs +2 -2
- package/scripts/validate-plugin.mjs +161 -0
- package/scripts/validate-wave-scope.mjs +28 -8
- package/scripts/wave-scope-binding.mjs +215 -0
- package/skills/_shared/instruction-file-resolution.md +10 -0
- package/skills/_shared/parallel-aware-preamble.md +1 -0
- package/skills/_shared/platform-tools.md +1 -1
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/architecture/SKILL.md +7 -5
- package/skills/{domain-model/SKILL.md → architecture/references/domain-model.md} +9 -9
- package/skills/autopilot/SKILL.md +4 -18
- package/skills/claude-md-drift-check/SKILL.md +5 -1
- package/skills/claude-md-drift-check/checker.mjs +62 -2
- package/skills/convergence-monitoring/SIGNALS.md +55 -0
- package/skills/discovery/probes/vault-staleness.mjs +37 -13
- package/skills/discovery/probes-arch.md +20 -18
- package/skills/dispatcher/SKILL.md +3 -2
- package/skills/evolve/SKILL.md +65 -26
- package/skills/frontmatter-guard/SKILL.md +11 -5
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +33 -0
- package/skills/remote-offload/SKILL.md +1 -1
- package/skills/session-end/SKILL.md +18 -905
- package/skills/session-end/phase-3-6-tail.md +10 -3
- package/skills/session-end/plan-verification.md +221 -155
- package/skills/session-end/references/phase-2-quality-gate.md +93 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +229 -0
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +120 -0
- package/skills/session-end/references/phase-4b-worktree-orphan-sweep.md +58 -0
- package/skills/session-end/references/phase-5-issue-cleanup.md +104 -0
- package/skills/session-end/references/session-summary-template.md +62 -0
- package/skills/session-plan/SKILL.md +49 -0
- package/skills/session-start/SKILL.md +22 -904
- package/skills/session-start/phase-8-5-express-path.md +1 -1
- package/skills/session-start/references/phase-1-1-dispatcher-autonomy-capture.md +55 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +140 -0
- package/skills/session-start/references/phase-1-5-session-continuity.md +254 -0
- package/skills/session-start/references/phase-1-7-vault-status-board.md +53 -0
- package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +75 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +155 -0
- package/skills/session-start/references/phase-6-5-forced-reads.md +75 -0
- package/skills/session-start/references/phase-6-6-project-intelligence.md +81 -0
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +103 -0
- package/skills/vault-sync/validator.mjs +21 -27
- package/skills/wave-executor/SKILL.md +15 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +612 -0
- package/skills/wave-executor/references/wave-loop-review.md +570 -0
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +162 -0
- package/skills/wave-executor/wave-loop.md +14 -1309
- package/templates/_shared/journey-manifest.md +10 -6
- package/.cursor/commands/autopilot-multi.md +0 -14
- package/.cursor/commands/contract-version-bump.md +0 -14
- package/.cursor/commands/journey-audit.md +0 -14
- package/.cursor/skills/contract-version-bump/SKILL.md +0 -12
- package/.cursor/skills/daily/SKILL.md +0 -12
- package/.cursor/skills/domain-model/SKILL.md +0 -13
- package/.cursor/skills/journey-audit/SKILL.md +0 -13
- package/.cursor/skills/skill-creator/SKILL.md +0 -13
- package/.cursor/skills/ubiquitous-language/SKILL.md +0 -13
- package/commands/autopilot-multi.md +0 -74
- package/commands/contract-version-bump.md +0 -28
- package/commands/journey-audit.md +0 -43
- package/pi/prompts/autopilot-multi.md +0 -12
- package/pi/prompts/contract-version-bump.md +0 -12
- package/pi/prompts/journey-audit.md +0 -12
- package/scripts/autopilot-multi.mjs +0 -885
- package/scripts/backfill-learnings-expires.mjs +0 -196
- package/scripts/backfill-learnings.mjs +0 -203
- package/scripts/fleet-instruction-scan.mjs +0 -141
- package/scripts/lib/autopilot/dep-graph.mjs +0 -417
- package/scripts/lib/autopilot/multi-killswitch.mjs +0 -184
- package/scripts/lib/webhook-url.mjs +0 -105
- package/scripts/lifecycle-sim-v6.mjs +0 -347
- package/scripts/migrate-learnings-jsonl.mjs +0 -189
- package/scripts/migrate-subagents-jsonl.mjs +0 -196
- package/scripts/upload-social-preview.mjs +0 -316
- package/skills/_shared/model-selection.md +0 -64
- package/skills/contract-version-bump/SKILL.md +0 -219
- package/skills/daily/SKILL.md +0 -222
- package/skills/daily/generate.sh +0 -92
- package/skills/daily/templates/daily.md.tpl +0 -36
- package/skills/journey-audit/SKILL.md +0 -270
- package/skills/skill-creator/SKILL.md +0 -168
- package/skills/ubiquitous-language/SKILL.md +0 -97
- package/skills/vault-sync/package-lock.json +0 -40
- /package/skills/{domain-model → architecture/references}/ADR-FORMAT.md +0 -0
- /package/skills/{domain-model → architecture/references}/CONTEXT-FORMAT.md +0 -0
|
@@ -29,12 +29,12 @@
|
|
|
29
29
|
* @typedef {{ repoRoot: string, repoName: string, free: boolean, status: 'frei'|'in-progress'|'force-closed', heartbeat: string|null, sessionId: string|null }} Candidate
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import { readFileSync } from 'node:fs';
|
|
33
32
|
import path from 'node:path';
|
|
34
33
|
|
|
35
34
|
import { scanBacklog } from '../backlog-scan.mjs';
|
|
36
35
|
import { checkCiStatus as realCheckCiStatus } from '../ci-status-banner.mjs';
|
|
37
36
|
import { probe as realProbe, evaluate as realEvaluate, DEFAULT_RESOURCE_THRESHOLDS as CANONICAL_RESOURCE_THRESHOLDS } from '../resource-probe.mjs';
|
|
37
|
+
import { readCanonicalSessions } from '../sessions-canonical.mjs';
|
|
38
38
|
import { isRealSession } from '../session-schema/filters.mjs';
|
|
39
39
|
|
|
40
40
|
/** Staleness cap (days). Beyond this, additional age does not raise the score. */
|
|
@@ -154,17 +154,29 @@ async function defaultFetchPriority(repoRoot, nowMs) {
|
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
156
|
* Default STALENESS source: read `<repoRoot>/.orchestrator/metrics/sessions.jsonl`,
|
|
157
|
-
* find the
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
157
|
+
* find the MOST RECENT REAL (non-phantom) record's timestamp, and compute days
|
|
158
|
+
* since `completed_at` (fallback `started_at`). No file / no parsable REAL
|
|
159
|
+
* record / no timestamp ⇒ `STALENESS_CAP_DAYS` (treat as maximally stale =
|
|
160
|
+
* most worthwhile).
|
|
161
161
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
162
|
+
* Skips `status: 'abandoned'` phantom stubs (#834) — session-close-backfill
|
|
163
|
+
* writes these for sessions that ended without a real close (0 waves, seconds
|
|
164
|
+
* of runtime). Counting one would let a single recent phantom make a
|
|
165
|
+
* genuinely neglected repo look freshly touched, defeating the dispatcher's
|
|
166
|
+
* whole purpose (this is the N=1 extreme case of the phantom-tail problem —
|
|
167
|
+
* one stub is enough to zero out staleness).
|
|
168
|
+
*
|
|
169
|
+
* #1209: takes the MAX timestamp over every canonical REAL record instead of
|
|
170
|
+
* scanning backward through raw file lines from the tail. A raw backward scan
|
|
171
|
+
* trusts FILE POSITION as a proxy for recency, which silently breaks the
|
|
172
|
+
* moment a backfill/repair writer appends an OLDER record after genuinely
|
|
173
|
+
* newer ones already exist — `session-record-repair.mjs` and
|
|
174
|
+
* `migrate-sessions-jsonl.mjs` both do this by design (see
|
|
175
|
+
* `sessions-canonical.mjs`'s module header, "by-design raw" list). A
|
|
176
|
+
* duplicated `session_id` re-appended late at the tail would make the old
|
|
177
|
+
* backward scan report the STALE re-append's date instead of the genuinely
|
|
178
|
+
* newer session's date; `readCanonicalSessions` collapses the duplicate to
|
|
179
|
+
* one record first, so the max-reduce below sees only the true timestamps.
|
|
168
180
|
*
|
|
169
181
|
* @param {string} repoRoot
|
|
170
182
|
* @param {number} nowMs
|
|
@@ -173,33 +185,21 @@ async function defaultFetchPriority(repoRoot, nowMs) {
|
|
|
173
185
|
async function defaultStaleDaysFor(repoRoot, nowMs) {
|
|
174
186
|
try {
|
|
175
187
|
const file = path.join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl');
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
} catch {
|
|
188
|
-
continue; // Skip a corrupt line and try the previous one.
|
|
189
|
-
}
|
|
190
|
-
if (isRealSession(parsed)) {
|
|
191
|
-
last = parsed;
|
|
192
|
-
break;
|
|
193
|
-
}
|
|
188
|
+
const records = readCanonicalSessions({ filePath: file });
|
|
189
|
+
if (records.length === 0) return STALENESS_CAP_DAYS;
|
|
190
|
+
|
|
191
|
+
let latestMs = null;
|
|
192
|
+
for (const rec of records) {
|
|
193
|
+
if (!isRealSession(rec)) continue;
|
|
194
|
+
const iso = rec.completed_at || rec.started_at || null;
|
|
195
|
+
if (!iso || typeof iso !== 'string') continue;
|
|
196
|
+
const t = Date.parse(iso);
|
|
197
|
+
if (Number.isNaN(t)) continue;
|
|
198
|
+
if (latestMs === null || t > latestMs) latestMs = t;
|
|
194
199
|
}
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
const iso = last.completed_at || last.started_at || null;
|
|
198
|
-
if (!iso || typeof iso !== 'string') return STALENESS_CAP_DAYS;
|
|
199
|
-
const t = Date.parse(iso);
|
|
200
|
-
if (Number.isNaN(t)) return STALENESS_CAP_DAYS;
|
|
200
|
+
if (latestMs === null) return STALENESS_CAP_DAYS;
|
|
201
201
|
|
|
202
|
-
const days = (nowMs -
|
|
202
|
+
const days = (nowMs - latestMs) / MS_PER_DAY;
|
|
203
203
|
return days > 0 ? days : 0;
|
|
204
204
|
} catch {
|
|
205
205
|
// No sessions file (or unreadable) ⇒ never worked on ⇒ maximally stale.
|
|
@@ -209,21 +209,75 @@ async function defaultStaleDaysFor(repoRoot, nowMs) {
|
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
211
|
* Default READINESS (CI) source: thin wrapper over `checkCiStatus`.
|
|
212
|
-
*
|
|
213
|
-
*
|
|
212
|
+
*
|
|
213
|
+
* Passes the probe's result through VERBATIM rather than flattening it to a
|
|
214
|
+
* status string (#1031 follow-up). `checkCiStatus` has THREE return states —
|
|
215
|
+
* `null` (benign absence), `{status, …}` (a real reading), and
|
|
216
|
+
* `{severity:'warn', degraded:<reason>}` (the state could NOT be read) — and
|
|
217
|
+
* the old `typeof result.status === 'string' ? result.status : null` collapsed
|
|
218
|
+
* the third onto the first. That was not a scoring bug (both are non-blocking)
|
|
219
|
+
* but it discarded the one thing the operator needs to know: the ranking was
|
|
220
|
+
* computed WITHOUT a CI reading, and why. {@link normalizeCiSignal} in
|
|
221
|
+
* `rankCandidates` performs the shape reduction and keeps the reason.
|
|
222
|
+
*
|
|
223
|
+
* A THROW is the fourth state, and it is NOT the first: `checkCiStatus` is
|
|
224
|
+
* documented never to throw, so an exception here means the probe itself broke
|
|
225
|
+
* (a bad import, an unexpected runtime error). Returning `null` for it would
|
|
226
|
+
* assert measured ABSENCE — the one thing we know is false. It is mapped to the
|
|
227
|
+
* degraded shape instead, so {@link normalizeCiSignal} surfaces it as
|
|
228
|
+
* `ciStatus: 'unknown'` + `ciDegraded: 'probe-threw'` rather than silently.
|
|
214
229
|
*
|
|
215
230
|
* @param {{ repoRoot: string }} args
|
|
216
|
-
* @returns {Promise<'green'|'red'|'unknown'|null>}
|
|
231
|
+
* @returns {Promise<'green'|'red'|'unknown'|null|object>} raw probe result.
|
|
217
232
|
*/
|
|
218
233
|
async function defaultCheckCiStatus({ repoRoot }) {
|
|
219
234
|
try {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return
|
|
235
|
+
return (await realCheckCiStatus({ repoRoot })) ?? null;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
238
|
+
return {
|
|
239
|
+
severity: 'warn',
|
|
240
|
+
ok: false,
|
|
241
|
+
message: `⚠ ci-status: CI probe threw — state UNKNOWN, not "green". ${detail}`,
|
|
242
|
+
degraded: 'probe-threw',
|
|
243
|
+
};
|
|
224
244
|
}
|
|
225
245
|
}
|
|
226
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Reduce whatever a `checkCiStatus` dep returned to the pair the ranking needs:
|
|
249
|
+
* a scoring status and — when the state was UNREADABLE — the reason.
|
|
250
|
+
*
|
|
251
|
+
* Accepts every shape the dep contract permits, old and new:
|
|
252
|
+
* - a bare status string (`'green'`/`'red'`/`'unknown'`) — the shape every
|
|
253
|
+
* injected test dep uses; passed through unchanged.
|
|
254
|
+
* - `null`/`undefined` — genuine absence, no signal, no reason.
|
|
255
|
+
* - `{status: <string>}` — a real reading from `checkCiStatus`.
|
|
256
|
+
* - `{degraded: <reason>}` with no usable `status` — the probe FAILED. Mapped
|
|
257
|
+
* to `'unknown'`, which `scoreCandidate` already treats as non-blocking
|
|
258
|
+
* (`ciFactor` dampens on `'red'` only), so ranking is byte-identical to the
|
|
259
|
+
* previous `null` — but the reason survives into `readiness.ciDegraded`
|
|
260
|
+
* and into a `warnings` entry.
|
|
261
|
+
* - anything else — `null`, as before.
|
|
262
|
+
*
|
|
263
|
+
* `ciDegraded` is `null` (and the caller OMITS the key) whenever the state was
|
|
264
|
+
* readable, so existing strict `toEqual` pins on `signals.readiness` hold.
|
|
265
|
+
*
|
|
266
|
+
* @param {unknown} raw
|
|
267
|
+
* @returns {{ ciStatus: 'green'|'red'|'unknown'|null, ciDegraded: string|null }}
|
|
268
|
+
*/
|
|
269
|
+
export function normalizeCiSignal(raw) {
|
|
270
|
+
if (typeof raw === 'string' && raw) return { ciStatus: raw, ciDegraded: null };
|
|
271
|
+
if (!raw || typeof raw !== 'object') return { ciStatus: null, ciDegraded: null };
|
|
272
|
+
if (typeof raw.status === 'string' && raw.status) {
|
|
273
|
+
return { ciStatus: raw.status, ciDegraded: null };
|
|
274
|
+
}
|
|
275
|
+
if (typeof raw.degraded === 'string' && raw.degraded) {
|
|
276
|
+
return { ciStatus: 'unknown', ciDegraded: raw.degraded };
|
|
277
|
+
}
|
|
278
|
+
return { ciStatus: null, ciDegraded: null };
|
|
279
|
+
}
|
|
280
|
+
|
|
227
281
|
/**
|
|
228
282
|
* Default READINESS (resource) source: probe the host ONCE and evaluate against
|
|
229
283
|
* the canonical default thresholds. This is a HOST-level signal (identical for
|
|
@@ -290,7 +344,7 @@ export function defaultDeps() {
|
|
|
290
344
|
* ranked: Array<{ candidate: Candidate, score: number, signals: {
|
|
291
345
|
* priority: { criticalCount: number, highCount: number } | null,
|
|
292
346
|
* staleDays: number,
|
|
293
|
-
* readiness: { ciStatus: 'green'|'red'|'unknown'|null, resourceVerdict: 'green'|'warn'|'degraded'|'critical' },
|
|
347
|
+
* readiness: { ciStatus: 'green'|'red'|'unknown'|null, resourceVerdict: 'green'|'warn'|'degraded'|'critical', ciDegraded?: string },
|
|
294
348
|
* } }>,
|
|
295
349
|
* warnings: string[],
|
|
296
350
|
* }>}
|
|
@@ -342,18 +396,40 @@ export async function rankCandidates(freeCandidates, opts = {}) {
|
|
|
342
396
|
staleDays = STALENESS_CAP_DAYS;
|
|
343
397
|
}
|
|
344
398
|
|
|
345
|
-
// READINESS — CI status.
|
|
399
|
+
// READINESS — CI status. A degraded probe result ("state unknown") is
|
|
400
|
+
// NON-BLOCKING but not INVISIBLE: it scores like `null`, and reports why.
|
|
401
|
+
// Declared WITHOUT an initialiser on purpose: both paths below assign, and
|
|
402
|
+
// a `= null` seed would quietly re-create a fall-through "absent" default.
|
|
346
403
|
let ciStatus;
|
|
404
|
+
let ciDegraded;
|
|
347
405
|
try {
|
|
348
|
-
ciStatus = await deps.checkCiStatus({ repoRoot });
|
|
406
|
+
({ ciStatus, ciDegraded } = normalizeCiSignal(await deps.checkCiStatus({ repoRoot })));
|
|
349
407
|
} catch {
|
|
350
|
-
|
|
408
|
+
// A THROWING dep is not measured ABSENCE. Leaving `ciDegraded` at null
|
|
409
|
+
// here re-created the exact collapse `normalizeCiSignal` exists to
|
|
410
|
+
// prevent: the ranking silently proceeded as if no CI signal existed,
|
|
411
|
+
// when in truth the probe broke. Scoring is unchanged (`'unknown'` is
|
|
412
|
+
// non-blocking) — only the reason becomes visible.
|
|
413
|
+
ciStatus = 'unknown';
|
|
414
|
+
ciDegraded = 'probe-threw';
|
|
415
|
+
}
|
|
416
|
+
if (ciDegraded) {
|
|
417
|
+
warnings.push(
|
|
418
|
+
`CI state unknown for ${repoName} (${ciDegraded}) — ranked without CI dampening`,
|
|
419
|
+
);
|
|
351
420
|
}
|
|
352
421
|
|
|
353
422
|
const signals = {
|
|
354
423
|
priority,
|
|
355
424
|
staleDays,
|
|
356
|
-
readiness: {
|
|
425
|
+
readiness: {
|
|
426
|
+
ciStatus,
|
|
427
|
+
resourceVerdict,
|
|
428
|
+
// Key OMITTED when the CI state was readable — a strict `toEqual` on
|
|
429
|
+
// `signals.readiness` must not have to know about a field that only
|
|
430
|
+
// exists on the failure path.
|
|
431
|
+
...(ciDegraded ? { ciDegraded } : {}),
|
|
432
|
+
},
|
|
357
433
|
};
|
|
358
434
|
const score = scoreCandidate(signals);
|
|
359
435
|
|
|
@@ -164,13 +164,27 @@ async function watchLoop(intervalS) {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
/**
|
|
167
|
+
* Poll delay.
|
|
168
|
+
*
|
|
169
|
+
* The timer is deliberately NOT `unref()`d: it is the only handle this process
|
|
170
|
+
* holds (the two signal handlers do not keep the loop alive), so an unref'd
|
|
171
|
+
* timer drains the event loop and node exits 0 the instant the first tick is
|
|
172
|
+
* scheduled — a watcher that supervises nothing while looking like a clean
|
|
173
|
+
* shutdown, because it exits 0 with an empty stderr. Measured 2026-09-06 on the
|
|
174
|
+
* unref'd variant: `node scripts/lib/ecosystem-health.mjs --watch --interval=1`
|
|
175
|
+
* returned exit 0 after 48 ms instead of running until SIGTERM. Third copy of
|
|
176
|
+
* the #980 defect A1 measured in `scripts/lib/wave-transcript-tail.mjs` and
|
|
177
|
+
* `scripts/lib/convergence-monitor.mjs`; this one was missed when those two
|
|
178
|
+
* were fixed. Pinned by a DURATION assertion (an exit-code assertion cannot
|
|
179
|
+
* tell a healthy monitor from a dead one) in
|
|
180
|
+
* `tests/lib/ecosystem-health-watch.test.mjs`.
|
|
181
|
+
*
|
|
167
182
|
* @param {number} ms
|
|
168
183
|
* @returns {Promise<void>}
|
|
169
184
|
*/
|
|
170
185
|
function sleep(ms) {
|
|
171
186
|
return new Promise((resolve) => {
|
|
172
|
-
|
|
173
|
-
t.unref?.();
|
|
187
|
+
setTimeout(resolve, ms);
|
|
174
188
|
});
|
|
175
189
|
}
|
|
176
190
|
|
|
@@ -36,6 +36,7 @@ import path from 'node:path';
|
|
|
36
36
|
|
|
37
37
|
import { resolvePluginRoot } from '../common.mjs';
|
|
38
38
|
import { readJsonlFile } from '../io.mjs';
|
|
39
|
+
import { readCanonicalSessions } from '../sessions-canonical.mjs';
|
|
39
40
|
import { buildRunId, CURRENT_STANDARD_VERSION, VALID_MODEL_SOURCES } from './schema.mjs';
|
|
40
41
|
import { resolveSession, computeWindow, findPeerOverlap } from './session-resolve.mjs';
|
|
41
42
|
|
|
@@ -538,7 +539,14 @@ export function evaluateSession(opts = {}) {
|
|
|
538
539
|
|
|
539
540
|
const sessionsPath = path.join(metricsDir, 'sessions.jsonl');
|
|
540
541
|
const eventsPath = path.join(metricsDir, 'events.jsonl');
|
|
541
|
-
|
|
542
|
+
// #1209: sessions.jsonl is APPEND-ONLY (the same physical session can carry
|
|
543
|
+
// more than one line — crash-recovery re-appends, #1068 stub/supersede
|
|
544
|
+
// pairs), so a raw readJsonlFile() left resolveSession()/findPeerOverlap()
|
|
545
|
+
// to hand-roll their own dedup over duplicated / phantom-doubled records.
|
|
546
|
+
// readCanonicalSessions() collapses those first (newest-wins per
|
|
547
|
+
// session_id, #1068 double-stub collapse, supersede removal) — see
|
|
548
|
+
// session-resolve.mjs for how that simplifies both callers below.
|
|
549
|
+
const records = readCanonicalSessions({ filePath: sessionsPath });
|
|
542
550
|
const events = readJsonlFile(eventsPath, { skipInvalid: true });
|
|
543
551
|
|
|
544
552
|
const { record: session, resolvedVia } = resolveSession(records, sessionId);
|
|
@@ -48,11 +48,17 @@ export function resolveSession(records, sessionId) {
|
|
|
48
48
|
|
|
49
49
|
// Explicit selection: the LAST record carrying this session_id (records may be
|
|
50
50
|
// rewritten/backfilled across a session's life; the latest is authoritative).
|
|
51
|
+
//
|
|
52
|
+
// #1209: engine.mjs's production caller now passes CANONICAL records
|
|
53
|
+
// (readCanonicalSessions already resolved newest-wins per session_id before
|
|
54
|
+
// this array reaches here — see sessions-canonical.mjs), so at most one
|
|
55
|
+
// record can carry a given id in practice. `resolveSession` stays a
|
|
56
|
+
// general, pure function — `findLast` keeps the exact "last match wins"
|
|
57
|
+
// semantics for a caller that hands in raw, un-deduped records directly
|
|
58
|
+
// (e.g. the unit tests below), while degenerating to a single candidate on
|
|
59
|
+
// the canonical path.
|
|
51
60
|
if (sessionId) {
|
|
52
|
-
|
|
53
|
-
for (const r of records) {
|
|
54
|
-
if (isPlainObject(r) && r.session_id === sessionId) match = r;
|
|
55
|
-
}
|
|
61
|
+
const match = records.findLast((r) => isPlainObject(r) && r.session_id === sessionId) ?? null;
|
|
56
62
|
if (!match) {
|
|
57
63
|
throw new SessionResolutionError(`session not found: ${sessionId}`);
|
|
58
64
|
}
|
|
@@ -114,6 +120,19 @@ export function computeWindow(record) {
|
|
|
114
120
|
* rewrites of the SAME session) are excluded, as are records without a valid
|
|
115
121
|
* window.
|
|
116
122
|
*
|
|
123
|
+
* #1209: the #1068 double-stub class — two ABANDONED records for ONE physical
|
|
124
|
+
* session (a real semantic `session_id` + a synthetic backfill twin, sharing
|
|
125
|
+
* an exact `started_at`/`completed_at` tuple, see `sessions-canonical.mjs`
|
|
126
|
+
* rule 2) — used to be counted as TWO distinct peers here, because the raw
|
|
127
|
+
* `records` array carried both lines under different ids and this function has
|
|
128
|
+
* no way to know they are the same physical session. With engine.mjs's
|
|
129
|
+
* production caller now passing `readCanonicalSessions()` output,
|
|
130
|
+
* `collapseAbandonedTuples()` has already dropped the synthetic twin before
|
|
131
|
+
* this array reaches here — a double-stub pair can therefore no longer
|
|
132
|
+
* produce a false EXTRA peer (`count` inflated by one) on the canonical path.
|
|
133
|
+
* `findPeerOverlap` itself stays general/pure for callers (including the unit
|
|
134
|
+
* tests below) that pass raw, un-deduped records directly.
|
|
135
|
+
*
|
|
117
136
|
* @param {object[]} records — all sessions.jsonl records.
|
|
118
137
|
* @param {object} resolved — the resolved session record.
|
|
119
138
|
* @returns {{ count: number, peers: string[] }} unique overlapping session_ids.
|
package/scripts/lib/events.mjs
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
|
|
42
42
|
import { promises as fs, existsSync, readFileSync } from 'node:fs';
|
|
43
43
|
import path from 'node:path';
|
|
44
|
-
import {
|
|
44
|
+
import { getProjectDir, SO_SHARED_DIR } from './platform.mjs';
|
|
45
45
|
import { readLock } from './session-lock.mjs';
|
|
46
46
|
import { resolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
|
|
47
47
|
import {
|
|
@@ -70,7 +70,7 @@ import {
|
|
|
70
70
|
* @param {string} [repoRoot=SO_PROJECT_DIR] — project root the events log lives under.
|
|
71
71
|
* @returns {string}
|
|
72
72
|
*/
|
|
73
|
-
export function eventsFilePath(repoRoot =
|
|
73
|
+
export function eventsFilePath(repoRoot = getProjectDir()) {
|
|
74
74
|
return path.join(repoRoot, SO_SHARED_DIR, 'metrics', 'events.jsonl');
|
|
75
75
|
}
|
|
76
76
|
|
|
@@ -152,10 +152,26 @@ const STATE_DIR_CANDIDATES = ['.claude', '.codex', '.cursor', '.pi'];
|
|
|
152
152
|
* if rotation is common, the lock must be refreshed on rotation rather than
|
|
153
153
|
* this comparison widened.
|
|
154
154
|
*
|
|
155
|
+
* **THE manifest-binding writer contract (#1207).** This function is not only
|
|
156
|
+
* `emitEvent()`'s correlation fill — it is the canonical primitive for every
|
|
157
|
+
* caller that needs to name a `.orchestrator/`-adjacent artefact as "mine"
|
|
158
|
+
* without risking a peer's id. `skills/wave-executor/wave-loop.md` § Scope
|
|
159
|
+
* Manifest step 1 calls it directly to derive `wave-scope.json`'s `session_id` /
|
|
160
|
+
* `semantic_session_id` binding — a hand-written prose comparison against
|
|
161
|
+
* STATE.md previously stood in for exactly this check, and (per the STATE.md
|
|
162
|
+
* caveat above) that comparison could not veto a peer-owned lock. Any new
|
|
163
|
+
* writer facing the same "is this working-copy-shared artefact mine to
|
|
164
|
+
* stamp?" question should call this function rather than re-deriving the
|
|
165
|
+
* raw-id-vs-process-local comparison inline (see `scripts/memory-propose.mjs`
|
|
166
|
+
* `resolveRunningWaveId()` for a case that reads the SAME lock but needs the
|
|
167
|
+
* semantic id plus diagnostic detail this function's `{}`-on-any-mismatch
|
|
168
|
+
* contract intentionally does not expose, and keeps its own comparison for
|
|
169
|
+
* that reason).
|
|
170
|
+
*
|
|
155
171
|
* @param {string} [root=SO_PROJECT_DIR] — the repo the record is pinned to.
|
|
156
172
|
* @returns {{session_id?: string, semantic_session_id?: string}}
|
|
157
173
|
*/
|
|
158
|
-
export function attributionForRecord(root =
|
|
174
|
+
export function attributionForRecord(root = getProjectDir()) {
|
|
159
175
|
const attribution = sessionAttribution(root);
|
|
160
176
|
const lockRawId =
|
|
161
177
|
typeof attribution.session_id === 'string' ? attribution.session_id.trim() : '';
|
|
@@ -209,8 +225,8 @@ function waveScopePath(root) {
|
|
|
209
225
|
* keys, for the same reason (a shared file cannot prove which process emits):
|
|
210
226
|
*
|
|
211
227
|
* - manifest classified `own` → fill (as a NUMBER, see below).
|
|
212
|
-
* - anything else → omit. That includes an UNBOUND manifest (no `
|
|
213
|
-
* `
|
|
228
|
+
* - anything else → omit. That includes an UNBOUND manifest (no `session_id` /
|
|
229
|
+
* `semantic_session_id`): since #1123 BOTH writers stamp the binding, so a
|
|
214
230
|
* manifest without one is a peer's or a stale artefact, never a legacy own
|
|
215
231
|
* one. It also includes `unknown` because we cannot resolve our own
|
|
216
232
|
* identity — stricter than `classifyManifestSession()`'s own `unknown`
|
|
@@ -298,7 +314,7 @@ export async function emitEvent(type, payload = {}, opts = {}) {
|
|
|
298
314
|
// A payload that supplies EITHER session key suppresses BOTH: mixing a
|
|
299
315
|
// caller's `session_id` with a lock-derived `semantic_session_id` would
|
|
300
316
|
// silently produce a record whose two id fields name different sessions.
|
|
301
|
-
const attributionRoot = opts.repoRoot ??
|
|
317
|
+
const attributionRoot = opts.repoRoot ?? getProjectDir();
|
|
302
318
|
const correlation = {};
|
|
303
319
|
if (payload.session_id === undefined && payload.semantic_session_id === undefined) {
|
|
304
320
|
Object.assign(correlation, attributionForRecord(attributionRoot));
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Frontmatter-Guard library (issue #328).
|
|
3
3
|
*
|
|
4
|
-
* Reads the canonical vault-frontmatter Zod schema source
|
|
4
|
+
* Reads the canonical vault-frontmatter Zod schema source — when a
|
|
5
|
+
* projects-baseline checkout is reachable on this host — and exposes helpers
|
|
5
6
|
* for generating a contextual schema snippet that can be injected into agent
|
|
6
7
|
* prompts before vault-write tasks.
|
|
7
8
|
*
|
|
@@ -10,15 +11,103 @@
|
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { digestSha256Short } from './crypto-digest-utils.mjs';
|
|
14
|
+
import { resolveHostPath } from './config/host-paths.mjs';
|
|
13
15
|
import { readFileSync, statSync } from 'node:fs';
|
|
14
16
|
import { homedir } from 'node:os';
|
|
15
|
-
import { join } from 'node:path';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
16
19
|
|
|
17
|
-
/**
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
);
|
|
20
|
+
/** Path of the schema source RELATIVE to a projects-baseline checkout root. */
|
|
21
|
+
const SCHEMA_REL_PATH = 'packages/zod-schemas/src/vault-frontmatter.ts';
|
|
22
|
+
|
|
23
|
+
/** This file lives at `<repoRoot>/scripts/lib/` — two levels up is the repo root. */
|
|
24
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Candidate projects-baseline checkout roots.
|
|
28
|
+
*
|
|
29
|
+
* The baseline is OPTIONAL and PRIVATE (see `docs/baseline.md`), so this list
|
|
30
|
+
* must never assume a particular operator layout — it carries no host-specific
|
|
31
|
+
* directory names.
|
|
32
|
+
*
|
|
33
|
+
* When the host-local override (`SO_BASELINE_PATH` / `owner.yaml`
|
|
34
|
+
* `paths.baseline-path`) is set it is used ALONE: probing past a wrong explicit
|
|
35
|
+
* value would silently read a DIFFERENT baseline than the one named. Degrading
|
|
36
|
+
* to `null` (and the documented fallback enum set) is the honest outcome there.
|
|
37
|
+
* Only when nothing is configured do the two CONVENTIONS apply — the sibling
|
|
38
|
+
* checkout `scripts/sync-vault-schema.mjs` already uses, then the legacy
|
|
39
|
+
* `~/Projects` default this module shipped with.
|
|
40
|
+
*
|
|
41
|
+
* @returns {string[]}
|
|
42
|
+
*/
|
|
43
|
+
function baselineCandidates() {
|
|
44
|
+
const configured = resolveHostPath('baseline-path', null);
|
|
45
|
+
if (typeof configured === 'string' && configured.trim() !== '') return [configured.trim()];
|
|
46
|
+
return [
|
|
47
|
+
resolve(REPO_ROOT, '..', 'projects-baseline'),
|
|
48
|
+
join(homedir(), 'Projects', 'projects-baseline'),
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @type {{ resolved: boolean, value: string|null }} */
|
|
53
|
+
const _pathCache = { resolved: false, value: null };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the canonical vault-frontmatter schema source, or `null` when no
|
|
57
|
+
* baseline checkout is reachable on this host.
|
|
58
|
+
*
|
|
59
|
+
* Ceiling: at most two `statSync` calls per invocation, and the result is
|
|
60
|
+
* memoised for the process lifetime. Revisit if the candidate list ever grows
|
|
61
|
+
* past a handful of entries — then it needs a real search, not a probe loop.
|
|
62
|
+
*
|
|
63
|
+
* @param {{ refresh?: boolean }} [opts] — `refresh: true` re-probes (tests only)
|
|
64
|
+
* @returns {string|null}
|
|
65
|
+
*/
|
|
66
|
+
export function resolveSchemaSourcePath({ refresh = false } = {}) {
|
|
67
|
+
if (!refresh && _pathCache.resolved) return _pathCache.value;
|
|
68
|
+
let found = null;
|
|
69
|
+
for (const base of baselineCandidates()) {
|
|
70
|
+
const candidate = join(base, SCHEMA_REL_PATH);
|
|
71
|
+
try {
|
|
72
|
+
if (statSync(candidate).isFile()) {
|
|
73
|
+
found = candidate;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
/* candidate absent — try the next one */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
_pathCache.resolved = true;
|
|
81
|
+
_pathCache.value = found;
|
|
82
|
+
return found;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fallback enum/field set used when no baseline checkout is reachable.
|
|
87
|
+
*
|
|
88
|
+
* Values mirror `skills/vault-sync/validator.mjs` (`vaultNoteTypeSchema` /
|
|
89
|
+
* `vaultNoteStatusSchema`), which is this repo's own in-tree copy of the
|
|
90
|
+
* canonical schema and is what `vault-sync` actually validates against. Using
|
|
91
|
+
* it means a baseline-less host injects a snippet the local validator accepts,
|
|
92
|
+
* rather than throwing.
|
|
93
|
+
*/
|
|
94
|
+
const FALLBACK_SCHEMA = Object.freeze({
|
|
95
|
+
typeEnum: Object.freeze([
|
|
96
|
+
'note', 'daily', 'project', 'person', 'reference',
|
|
97
|
+
'idea', 'learning', 'session', 'peer-card', 'board',
|
|
98
|
+
]),
|
|
99
|
+
statusEnum: Object.freeze([
|
|
100
|
+
'draft', 'active', 'verified', 'archived', 'production',
|
|
101
|
+
'mvp', 'idea', 'maintenance', 'planned', 'paused', 'dead',
|
|
102
|
+
]),
|
|
103
|
+
requiredFields: Object.freeze(['id', 'type', 'created', 'updated']),
|
|
104
|
+
idRegex: '^[a-z0-9]+(?:-[a-z0-9]+)*$',
|
|
105
|
+
tagsRegex: '^[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)*$',
|
|
106
|
+
schemaText: null,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** One WARN per process, not one per call — the condition is constant. */
|
|
110
|
+
let _warnedFallback = false;
|
|
22
111
|
|
|
23
112
|
/**
|
|
24
113
|
* In-memory mtime cache so repeated calls within a single process invocation
|
|
@@ -93,9 +182,12 @@ function _parseSchema(text) {
|
|
|
93
182
|
* @returns {{ typeEnum: string[], statusEnum: string[], requiredFields: string[], idRegex: string, tagsRegex: string, schemaText: string } | null}
|
|
94
183
|
*/
|
|
95
184
|
export function readVaultSchema() {
|
|
185
|
+
const sourcePath = resolveSchemaSourcePath();
|
|
186
|
+
if (sourcePath === null) return null;
|
|
187
|
+
|
|
96
188
|
let mtime;
|
|
97
189
|
try {
|
|
98
|
-
mtime = statSync(
|
|
190
|
+
mtime = statSync(sourcePath).mtimeMs;
|
|
99
191
|
} catch {
|
|
100
192
|
// File missing or inaccessible
|
|
101
193
|
return null;
|
|
@@ -107,7 +199,7 @@ export function readVaultSchema() {
|
|
|
107
199
|
|
|
108
200
|
let text;
|
|
109
201
|
try {
|
|
110
|
-
text = readFileSync(
|
|
202
|
+
text = readFileSync(sourcePath, 'utf8');
|
|
111
203
|
} catch {
|
|
112
204
|
return null;
|
|
113
205
|
}
|
|
@@ -122,10 +214,17 @@ export function readVaultSchema() {
|
|
|
122
214
|
* Compute an 8-character SHA-256 hex prefix of the given schema source text.
|
|
123
215
|
* Stable across calls for the same input — useful as a cache-busting token.
|
|
124
216
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
217
|
+
* Returns `null` when there is NO schema text (absent baseline checkout,
|
|
218
|
+
* `readVaultSchema()` → `null`). Hashing "nothing" previously produced
|
|
219
|
+
* `e3b0c442` — the SHA-256 of the empty string — which is a real-looking token
|
|
220
|
+
* that compares equal across every baseline-less host, so a cache keyed on it
|
|
221
|
+
* would report "schema unchanged" while having measured nothing at all.
|
|
222
|
+
*
|
|
223
|
+
* @param {string|null|undefined} schemaText
|
|
224
|
+
* @returns {string|null} 8-char hex prefix, or `null` when there is no schema
|
|
127
225
|
*/
|
|
128
226
|
export function computeSchemaHash(schemaText) {
|
|
227
|
+
if (typeof schemaText !== 'string' || schemaText.length === 0) return null;
|
|
129
228
|
return digestSha256Short(schemaText);
|
|
130
229
|
}
|
|
131
230
|
|
|
@@ -133,11 +232,30 @@ export function computeSchemaHash(schemaText) {
|
|
|
133
232
|
* Generate a deterministic Markdown snippet documenting the vault frontmatter
|
|
134
233
|
* schema, suitable for injection into agent prompts before vault-write tasks.
|
|
135
234
|
*
|
|
136
|
-
*
|
|
235
|
+
* Degrades instead of throwing when no schema is available: a host without a
|
|
236
|
+
* projects-baseline checkout gets `readVaultSchema() === null`, and destructuring
|
|
237
|
+
* that killed the caller with `Cannot destructure property 'typeEnum' of
|
|
238
|
+
* 'schema' as it is undefined`. The guard below falls back to FALLBACK_SCHEMA
|
|
239
|
+
* and warns ONCE on stderr — an injected snippet that is one schema-version
|
|
240
|
+
* behind is worth incomparably more than a crashed pre-dispatch hook.
|
|
241
|
+
*
|
|
242
|
+
* @param {{ typeEnum: string[], statusEnum: string[], requiredFields: string[], idRegex: string, tagsRegex: string }|null} [schema]
|
|
137
243
|
* @returns {string}
|
|
138
244
|
*/
|
|
139
245
|
export function generateFrontmatterSnippet(schema) {
|
|
140
|
-
|
|
246
|
+
let source = schema;
|
|
247
|
+
if (source === null || typeof source !== 'object') {
|
|
248
|
+
if (!_warnedFallback) {
|
|
249
|
+
_warnedFallback = true;
|
|
250
|
+
process.stderr.write(
|
|
251
|
+
'frontmatter-guard: no projects-baseline schema reachable — using the in-module ' +
|
|
252
|
+
'fallback enum set (see docs/baseline.md). Set SO_BASELINE_PATH or ' +
|
|
253
|
+
'owner.yaml paths.baseline-path to read the canonical schema.\n',
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
source = FALLBACK_SCHEMA;
|
|
257
|
+
}
|
|
258
|
+
const { typeEnum, statusEnum, requiredFields, idRegex, tagsRegex: _tagsRegex } = source;
|
|
141
259
|
|
|
142
260
|
const typeList = typeEnum.map((v) => `\`${v}\``).join(' | ');
|
|
143
261
|
const statusList = statusEnum.map((v) => `\`${v}\``).join(' | ');
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
runCheck,
|
|
10
10
|
extractCount,
|
|
11
11
|
extractTestCounts,
|
|
12
|
+
extractFailedTestFiles,
|
|
12
13
|
collectDebugArtifacts,
|
|
13
14
|
} from './gate-helpers.mjs';
|
|
14
15
|
|
|
@@ -64,11 +65,26 @@ const testCounts =
|
|
|
64
65
|
// from a measured one — with `suite_died: false` derived from it, stating a
|
|
65
66
|
// verdict nobody had checked. Absent, not zero: the same contract this
|
|
66
67
|
// envelope's `counts` field already keeps (`admitSuiteCounts`).
|
|
68
|
+
//
|
|
69
|
+
// `failed_files` (this change) is the fifth member of that same set and joins
|
|
70
|
+
// it for the same reason: it is DERIVED from the runner's file-level report, so
|
|
71
|
+
// it is published exactly when the file-level measurement exists. A count with
|
|
72
|
+
// no name is what made the 2026-09-06 pre-push block unusable —
|
|
73
|
+
// `files_failed: 1` out of 662, and reconstructing WHICH file cost a manual
|
|
74
|
+
// re-materialisation of the tracked tree. An EMPTY array here is meaningful and
|
|
75
|
+
// is NOT the absent case: it says the file-level summary was parsed and no path
|
|
76
|
+
// could be read out of it (a non-vitest reporter, a truncated capture), which
|
|
77
|
+
// is a parser gap worth seeing — the unmeasured case is the absent key.
|
|
78
|
+
const failedTestFiles = testResult.status === 'fail'
|
|
79
|
+
? extractFailedTestFiles(testResult.fullOutput ?? testResult.output ?? '')
|
|
80
|
+
: [];
|
|
81
|
+
|
|
67
82
|
const fileFields = testCounts.files
|
|
68
83
|
? {
|
|
69
84
|
files_total: testCounts.files.total,
|
|
70
85
|
files_passed: testCounts.files.passed,
|
|
71
86
|
files_failed: testCounts.files.failed,
|
|
87
|
+
failed_files: failedTestFiles,
|
|
72
88
|
// Self-diagnosing: true exactly when `status: 'fail'` sits beside a
|
|
73
89
|
// test-case `failed: 0` that a file-level failure explains. Greppable —
|
|
74
90
|
// a consumer no longer has to recompute the contradiction by hand.
|
|
@@ -154,6 +170,16 @@ const failed = [tcResult, testResult, lintResult].some(
|
|
|
154
170
|
// of silence" the hook promises still holds; and the operator gets the failure
|
|
155
171
|
// at the moment of the block instead of a second run to find it.
|
|
156
172
|
if (failed) {
|
|
173
|
+
// Names FIRST, raw output after. Under the pre-push hook the raw block is
|
|
174
|
+
// hundreds of lines; the operator reads the top of it, and the one fact he
|
|
175
|
+
// needs to act (`npx vitest run <file>`) must not sit at the bottom.
|
|
176
|
+
if (failedTestFiles.length > 0) {
|
|
177
|
+
process.stderr.write(
|
|
178
|
+
`\n──── failing test files (${failedTestFiles.length}) ────\n` +
|
|
179
|
+
failedTestFiles.map((f) => ` ${f}\n`).join('') +
|
|
180
|
+
` reproduce: npx vitest run ${failedTestFiles.join(' ')}\n`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
157
183
|
for (const [name, result] of [
|
|
158
184
|
['typecheck', tcResult],
|
|
159
185
|
['test', testResult],
|