session-orchestrator 5.7.0 → 5.9.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 +3 -0
- package/.agents/skills/architecture/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +1 -0
- package/.agents/skills/eval/SKILL.md +1 -1
- package/.agents/skills/frontmatter-guard/SKILL.md +1 -0
- package/.agents/skills/gitlab-portfolio/SKILL.md +1 -0
- package/.agents/skills/hook-development/SKILL.md +1 -0
- package/.agents/skills/mcp-builder/SKILL.md +1 -0
- package/.agents/skills/tmux-layout/SKILL.md +3 -0
- package/.agents/skills/tmux-layout/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +1 -0
- package/.agents/skills/vault-sync/SKILL.md +1 -0
- package/.agents/skills/write-executable-plan/SKILL.md +3 -0
- package/.agents/skills/write-executable-plan/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/architecture/SKILL.md +3 -0
- package/.codex-plugin/skills/architecture/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -0
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/frontmatter-guard/SKILL.md +1 -0
- package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +1 -0
- package/.codex-plugin/skills/hook-development/SKILL.md +1 -0
- package/.codex-plugin/skills/mcp-builder/SKILL.md +1 -0
- package/.codex-plugin/skills/tmux-layout/SKILL.md +3 -0
- package/.codex-plugin/skills/tmux-layout/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/vault-mirror/SKILL.md +1 -0
- package/.codex-plugin/skills/vault-sync/SKILL.md +1 -0
- package/.codex-plugin/skills/write-executable-plan/SKILL.md +3 -0
- package/.codex-plugin/skills/write-executable-plan/agents/openai.yaml +5 -0
- package/.cursor/commands/architecture.md +13 -0
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/tmux-layout.md +13 -0
- package/.cursor/commands/write-executable-plan.md +13 -0
- package/.cursor/skills/architecture/SKILL.md +0 -1
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/tmux-layout/SKILL.md +0 -1
- package/.cursor/skills/write-executable-plan/SKILL.md +0 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +68 -0
- package/README.md +13 -9
- package/agents/eval-judge.md +5 -5
- package/docs/baseline.md +4 -3
- package/docs/codex-setup.md +6 -4
- package/docs/components.md +5 -4
- package/docs/events-schema.md +45 -4
- package/docs/github-mirror-protection.md +3 -2
- package/docs/install.md +39 -3
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +2 -2
- package/docs/session-config-reference.md +7 -5
- package/docs/session-config-template.md +3 -2
- package/evals/README.md +60 -0
- package/evals/instruction-ablation/README.md +147 -0
- package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
- package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
- package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
- package/evals/instruction-ablation/run.mjs +336 -0
- package/evals/psa-003-foreign-work/case.yaml +16 -0
- package/evals/psa-003-foreign-work/fixture.sh +12 -0
- package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
- package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
- package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
- package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
- package/evals/psa-003-foreign-work/prompt.md +6 -0
- package/hooks/_lib/hook-import-set.json +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/post-tool-batch-wave-signal.mjs +1 -0
- package/hooks/post-tooluse-frontend-slop.mjs +11 -1
- package/hooks/pre-task-scope-disjoint.mjs +8 -6
- package/monitors/monitors.json +1 -1
- package/package.json +3 -1
- package/pi/prompts/architecture.md +11 -0
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/tmux-layout.md +11 -0
- package/pi/prompts/write-executable-plan.md +11 -0
- package/scripts/backfill-abandoned-sessions.mjs +2 -2
- package/scripts/backfill-sessions.mjs +9 -7
- package/scripts/codex-install.mjs +108 -23
- package/scripts/emit-session.mjs +15 -2
- package/scripts/fleet-auflagen.mjs +54 -0
- package/scripts/generate-agents-skills.mjs +9 -16
- package/scripts/generate-codex-skills.mjs +8 -6
- package/scripts/generate-cursor-adapter.mjs +9 -74
- package/scripts/generate-pi-prompts.mjs +8 -74
- package/scripts/github-protection-audit.mjs +8 -0
- package/scripts/lib/auto-dialectic.mjs +10 -9
- package/scripts/lib/auto-dream.mjs +8 -10
- package/scripts/lib/autopilot/telemetry.mjs +12 -6
- package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
- package/scripts/lib/ci-status-banner.mjs +38 -5
- package/scripts/lib/config/drift-check.mjs +1 -1
- package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/convergence-monitor.mjs +5 -3
- package/scripts/lib/ecosystem-health.mjs +5 -3
- package/scripts/lib/eval/engine.mjs +61 -27
- package/scripts/lib/eval/judge.mjs +14 -8
- package/scripts/lib/eval/session-resolve.mjs +7 -2
- package/scripts/lib/events-retention-banner.mjs +5 -2
- package/scripts/lib/events-schema.mjs +8 -0
- package/scripts/lib/events.mjs +19 -5
- package/scripts/lib/fleet-protocol.mjs +98 -1
- package/scripts/lib/instruction-budget-guard.mjs +25 -18
- package/scripts/lib/io.mjs +172 -35
- package/scripts/lib/learnings/candidates.mjs +7 -28
- package/scripts/lib/learnings/io.mjs +7 -2
- package/scripts/lib/learnings/judgment.mjs +5 -8
- package/scripts/lib/learnings/select.mjs +3 -3
- package/scripts/lib/maintenance-due-banner.mjs +2 -2
- package/scripts/lib/memory-proposals/sink.mjs +5 -6
- package/scripts/lib/memory-proposals/store.mjs +3 -5
- package/scripts/lib/mirror-issues-banner.mjs +11 -1
- package/scripts/lib/native-command.mjs +48 -0
- package/scripts/lib/orphan-reaper/scan.mjs +29 -3
- package/scripts/lib/peer-cards/writer.mjs +6 -18
- package/scripts/lib/platform.mjs +113 -4
- package/scripts/lib/plugin-package-stage.mjs +169 -0
- package/scripts/lib/plugin-surface.mjs +121 -0
- package/scripts/lib/quality-gate-wave.mjs +40 -0
- package/scripts/lib/quality-gate.mjs +36 -30
- package/scripts/lib/reconcile/idempotency.mjs +9 -10
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +48 -11
- package/scripts/lib/reconcile/writer.mjs +11 -17
- package/scripts/lib/rule-loader.mjs +32 -6
- package/scripts/lib/scope-echo.mjs +12 -2
- package/scripts/lib/scope-gate.mjs +2 -1
- package/scripts/lib/session-close-backfill.mjs +3 -4
- package/scripts/lib/session-id.mjs +2 -1
- package/scripts/lib/session-record-repair.mjs +28 -21
- package/scripts/lib/session-schema/constants.mjs +20 -5
- package/scripts/lib/session-schema/validator.mjs +12 -0
- package/scripts/lib/session-start-probes.mjs +22 -7
- package/scripts/lib/sessions-staleness-banner.mjs +2 -3
- package/scripts/lib/skill-evolution/engine.mjs +6 -9
- package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
- package/scripts/lib/slopcheck.mjs +9 -7
- package/scripts/lib/sunset/walker.mjs +3 -3
- package/scripts/lib/telemetry/sync.mjs +3 -1
- package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
- package/scripts/lib/test-sibling-coverage.mjs +167 -0
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
- package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
- package/scripts/lib/user-invocable-skills.mjs +37 -10
- package/scripts/lib/ux-grill/run-record.mjs +5 -3
- package/scripts/lib/validate/check-banner-parity.mjs +1 -0
- package/scripts/lib/validate/check-skills.mjs +12 -2
- package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
- package/scripts/lib/validate/check-test-value-bans.mjs +3 -2
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/vault-archive.mjs +2 -4
- package/scripts/lib/vault-mirror/process.mjs +207 -26
- package/scripts/lib/vault-mirror/utils.mjs +16 -1
- package/scripts/lib/vault-sync-baseline.mjs +9 -8
- package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
- package/scripts/lib/wave-resource-gate.mjs +2 -1
- package/scripts/lib/wave-transcript-tail.mjs +8 -4
- package/scripts/lib/worktree/meta.mjs +7 -3
- package/scripts/mcp-server.mjs +158 -0
- package/scripts/migrate-sessions-jsonl.mjs +7 -5
- package/scripts/migrate-vault-paths.mjs +7 -4
- package/scripts/print-learnings-index.mjs +2 -1
- package/scripts/release.mjs +4 -1
- package/scripts/relocate-vault-corpus.mjs +5 -3
- package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
- package/scripts/run-quality-gate.mjs +9 -39
- package/scripts/self-update.mjs +179 -39
- package/scripts/site-numbers.mjs +8 -3
- package/scripts/sweep-expired-learnings.mjs +6 -4
- package/scripts/sync-vault-schema.mjs +3 -7
- package/scripts/windows-setup.mjs +45 -0
- package/skills/_shared/fleet-protocol.md +10 -0
- package/skills/architecture/SKILL.md +1 -0
- package/skills/bootstrap/_shared-template.md +5 -2
- package/skills/claude-md-drift-check/SKILL.md +4 -1
- package/skills/claude-md-drift-check/checker.mjs +83 -10
- package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
- package/skills/eval/SKILL.md +12 -13
- package/skills/eval/rubric-v3.md +521 -0
- package/skills/frontmatter-guard/SKILL.md +1 -0
- package/skills/gitlab-portfolio/SKILL.md +1 -0
- package/skills/hook-development/SKILL.md +1 -0
- package/skills/mcp-builder/SKILL.md +1 -0
- package/skills/remote-offload/SKILL.md +1 -1
- package/skills/repo-audit/SKILL.md +2 -2
- package/skills/session-end/SKILL.md +32 -1
- package/skills/session-end/metrics-collection.md +15 -9
- package/skills/session-end/vault-operations.md +12 -2
- package/skills/session-start/SKILL.md +2 -2
- package/skills/session-start/phase-7-1-premise-check.md +2 -1
- package/skills/session-start/references/operations-contract.md +4 -1
- package/skills/tmux-layout/SKILL.md +1 -0
- package/skills/vault-mirror/SKILL.md +1 -0
- package/skills/vault-sync/SKILL.md +6 -6
- package/skills/vault-sync/validator.sh +29 -10
- package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
- package/skills/wave-executor/references/wave-loop-review.md +13 -7
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +4 -4
- package/skills/write-executable-plan/SKILL.md +1 -0
|
@@ -57,9 +57,10 @@
|
|
|
57
57
|
* (absence reads as `'written'`, the only outcome that existed then).
|
|
58
58
|
*/
|
|
59
59
|
|
|
60
|
-
import {
|
|
61
|
-
import {
|
|
62
|
-
import { createHash
|
|
60
|
+
import { readFileSync } from 'node:fs';
|
|
61
|
+
import { isAbsolute, join } from 'node:path';
|
|
62
|
+
import { createHash } from 'node:crypto';
|
|
63
|
+
import { atomicWriteWithBackup } from '../io.mjs';
|
|
63
64
|
|
|
64
65
|
/** Default repo-relative location of the reconcile-candidate work-queue. */
|
|
65
66
|
export const DEFAULT_STORE_PATH = '.orchestrator/runtime/reconcile-candidates.jsonl';
|
|
@@ -159,18 +160,16 @@ function readStore(absPath) {
|
|
|
159
160
|
* @returns {{ ok: true, lines: number } | { ok: false, reason: 'fs-error', error: string }}
|
|
160
161
|
*/
|
|
161
162
|
function writeStore(absPath, records) {
|
|
163
|
+
let content;
|
|
162
164
|
try {
|
|
163
|
-
const dir = dirname(absPath);
|
|
164
|
-
mkdirSync(dir, { recursive: true });
|
|
165
165
|
const body = records.map((r) => JSON.stringify(r)).join('\n');
|
|
166
|
-
|
|
167
|
-
const tmpFile = join(dir, `.reconcile-candidates.${randomBytes(6).toString('hex')}.tmp`);
|
|
168
|
-
writeFileSync(tmpFile, content, { encoding: 'utf8' });
|
|
169
|
-
renameSync(tmpFile, absPath);
|
|
170
|
-
return { ok: true, lines: records.length };
|
|
166
|
+
content = records.length > 0 ? body + '\n' : '';
|
|
171
167
|
} catch (err) {
|
|
172
168
|
return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
|
|
173
169
|
}
|
|
170
|
+
// mkdir -p + tmp + rename; its failure envelope is this function's own.
|
|
171
|
+
const res = atomicWriteWithBackup(absPath, content, { tmpPrefix: '.reconcile-candidates.tmp' });
|
|
172
|
+
return res.ok ? { ok: true, lines: records.length } : res;
|
|
174
173
|
}
|
|
175
174
|
|
|
176
175
|
/**
|
|
@@ -146,6 +146,7 @@ import { join, resolve, sep } from 'node:path';
|
|
|
146
146
|
import { atomicWriteWithBackup } from '../io.mjs';
|
|
147
147
|
import { readLearnings } from '../learnings/io.mjs';
|
|
148
148
|
import { listMachineGeneratedRules } from '../instruction-budget-guard.mjs';
|
|
149
|
+
import { parseGlobsFrontmatter, ruleExpiryInstantMs } from '../rule-loader.mjs';
|
|
149
150
|
import { markCandidateProcessed } from './idempotency.mjs';
|
|
150
151
|
|
|
151
152
|
/** Repo-relative learnings store — the only source of a per-entry date. */
|
|
@@ -290,6 +291,18 @@ export function parseConsolidatedRule(content) {
|
|
|
290
291
|
break;
|
|
291
292
|
}
|
|
292
293
|
}
|
|
294
|
+
// The VALUE is taken the loader's way (quote-stripped): a hand-edited
|
|
295
|
+
// `"2026-06-01"` would otherwise reach Date.parse with its quotes and be read
|
|
296
|
+
// as LOCAL midnight — up to 26 h before the loader stops injecting the rule.
|
|
297
|
+
// The line index above stays the rewrite anchor.
|
|
298
|
+
if (expiresAtLine >= 0) {
|
|
299
|
+
try {
|
|
300
|
+
const loaderValue = parseGlobsFrontmatter(String(content ?? '')).meta['expires-at'];
|
|
301
|
+
if (typeof loaderValue === 'string') expiresAt = loaderValue;
|
|
302
|
+
} catch {
|
|
303
|
+
// frontmatter the loader rejects — keep the raw value (fail-open downstream)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
293
306
|
|
|
294
307
|
const bodyStart = frontmatterEnd >= 0 ? frontmatterEnd + 1 : 0;
|
|
295
308
|
for (let i = bodyStart; i < lines.length; i += 1) {
|
|
@@ -403,12 +416,23 @@ function dateOnly(iso) {
|
|
|
403
416
|
* `unresolvedPairIds`), so a file whose ids all fail to resolve yields `null`
|
|
404
417
|
* rather than a guess.
|
|
405
418
|
*
|
|
419
|
+
* One exclusion: a markers-only pair whose date lies before `cutoffMs` is
|
|
420
|
+
* dropped. Its substance is already gone, so its elapsed date protects nothing
|
|
421
|
+
* — yet left in, it pins the raise target at or below a header that has already
|
|
422
|
+
* passed, and `rule-loader.mjs` excludes the whole file forever (live
|
|
423
|
+
* 2026-10-04: `guard-design.md` header 2026-10-04 pinned by markers-only
|
|
424
|
+
* `d86c9b5f-…`, next absorbed date 2026-10-07). The pair itself stays in the
|
|
425
|
+
* file and in `N` — it is still a dedupe marker. Expired SUBSTANTIVE pairs stay
|
|
426
|
+
* in the population: raising past one would revive prose not yet swept.
|
|
427
|
+
*
|
|
406
428
|
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
407
429
|
* @param {Map<string, string>} expiryById
|
|
430
|
+
* @param {number} cutoffMs expiry cutoff (now minus grace)
|
|
408
431
|
* @returns {string|null}
|
|
409
432
|
*/
|
|
410
|
-
function earliestResolvableDate(parsed, expiryById) {
|
|
433
|
+
function earliestResolvableDate(parsed, expiryById, cutoffMs) {
|
|
411
434
|
const dates = parsed.pairs
|
|
435
|
+
.filter((p) => !p.markersOnly || !(Date.parse(expiryById.get(p.id) ?? '') < cutoffMs))
|
|
412
436
|
.map((p) => expiryById.get(p.id))
|
|
413
437
|
.filter((v) => typeof v === 'string' && Number.isFinite(Date.parse(v)))
|
|
414
438
|
.sort();
|
|
@@ -423,15 +447,20 @@ function earliestResolvableDate(parsed, expiryById) {
|
|
|
423
447
|
* outlives its content is never lowered here — see the module header for why
|
|
424
448
|
* the two directions are not symmetric.
|
|
425
449
|
*
|
|
450
|
+
* Already-expired markers-only pairs do not hold the target down (see
|
|
451
|
+
* {@link earliestResolvableDate}); they stay in the file as dedupe markers and
|
|
452
|
+
* keep counting toward `N`.
|
|
453
|
+
*
|
|
426
454
|
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
427
455
|
* @param {Map<string, string>} expiryById
|
|
456
|
+
* @param {number} cutoffMs expiry cutoff (now minus grace)
|
|
428
457
|
* @returns {string|null}
|
|
429
458
|
*/
|
|
430
|
-
function headerRaiseTarget(parsed, expiryById) {
|
|
459
|
+
function headerRaiseTarget(parsed, expiryById, cutoffMs) {
|
|
431
460
|
if (!parsed.expiresAt || parsed.expiresAtLine < 0) return null;
|
|
432
461
|
const headerMs = Date.parse(parsed.expiresAt);
|
|
433
462
|
if (!Number.isFinite(headerMs)) return null;
|
|
434
|
-
const earliest = earliestResolvableDate(parsed, expiryById);
|
|
463
|
+
const earliest = earliestResolvableDate(parsed, expiryById, cutoffMs);
|
|
435
464
|
if (earliest === null) return null;
|
|
436
465
|
const earliestMs = Date.parse(earliest);
|
|
437
466
|
if (!Number.isFinite(earliestMs) || earliestMs <= headerMs) return null;
|
|
@@ -450,15 +479,20 @@ function headerRaiseTarget(parsed, expiryById) {
|
|
|
450
479
|
*
|
|
451
480
|
* Deliberately NOT the `newExpiresAt` population: that one excludes expired
|
|
452
481
|
* pairs so a rewritten file is not instantly expired again. This is a
|
|
453
|
-
* diagnostic about the file AS COMMITTED, so it excludes nothing
|
|
482
|
+
* diagnostic about the file AS COMMITTED, so it excludes nothing — except an
|
|
483
|
+
* already-expired markers-only pair, the same exclusion the raise target makes.
|
|
484
|
+
* Without it, every file the raise (or an expired-entries rewrite) moved past
|
|
485
|
+
* such a pair would carry a permanent advisory naming the elapsed date, i.e.
|
|
486
|
+
* an instruction to lower the header back to where the loader drops the file.
|
|
454
487
|
*
|
|
455
488
|
* @param {ReturnType<typeof parseConsolidatedRule>} parsed
|
|
456
489
|
* @param {Map<string, string>} expiryById
|
|
490
|
+
* @param {number} cutoffMs expiry cutoff (now minus grace)
|
|
457
491
|
* @returns {string|undefined} undefined when there is nothing to report
|
|
458
492
|
*/
|
|
459
|
-
function headerAdvisory(parsed, expiryById) {
|
|
493
|
+
function headerAdvisory(parsed, expiryById, cutoffMs) {
|
|
460
494
|
if (!parsed.expiresAt) return undefined;
|
|
461
|
-
const earliest = earliestResolvableDate(parsed, expiryById);
|
|
495
|
+
const earliest = earliestResolvableDate(parsed, expiryById, cutoffMs);
|
|
462
496
|
if (earliest === null || earliest === parsed.expiresAt) return undefined;
|
|
463
497
|
return `header expires-at ${parsed.expiresAt} != earliest resolvable absorbed date ${earliest}`;
|
|
464
498
|
}
|
|
@@ -587,8 +621,8 @@ export async function planRuleExpirySweep(opts = {}) {
|
|
|
587
621
|
// most likely to carry a stale header, `test-hygiene.md` among them — could
|
|
588
622
|
// never receive one. Measured 2026-09-17: the advisory reached 3 of 7 files
|
|
589
623
|
// where 6 of 7 have the discrepancy.
|
|
590
|
-
const advisory = headerAdvisory(parsed, expiryById);
|
|
591
|
-
const raiseTo = headerRaiseTarget(parsed, expiryById);
|
|
624
|
+
const advisory = headerAdvisory(parsed, expiryById, cutoffMs);
|
|
625
|
+
const raiseTo = headerRaiseTarget(parsed, expiryById, cutoffMs);
|
|
592
626
|
|
|
593
627
|
if (
|
|
594
628
|
parsed.counterLine === -1 &&
|
|
@@ -596,7 +630,7 @@ export async function planRuleExpirySweep(opts = {}) {
|
|
|
596
630
|
parsed.pairs.length === 1 &&
|
|
597
631
|
substantive.length === 1 &&
|
|
598
632
|
parsed.expiresAt !== null &&
|
|
599
|
-
|
|
633
|
+
ruleExpiryInstantMs(parsed.expiresAt) !== null
|
|
600
634
|
) {
|
|
601
635
|
// SINGLE-ENTRY file (#1513): one learning, one pair, no `### ` entries,
|
|
602
636
|
// no counter sentence — the shape the reconciliation engine writes per
|
|
@@ -612,7 +646,10 @@ export async function planRuleExpirySweep(opts = {}) {
|
|
|
612
646
|
// before the unlink, so `/reconcile` does not re-propose it.
|
|
613
647
|
const pair = substantive[0];
|
|
614
648
|
const storeAt = expiryById.has(pair.id) ? Date.parse(expiryById.get(pair.id)) : Number.NaN;
|
|
615
|
-
|
|
649
|
+
// The file's own `expires-at` goes through the loader's predicate: a bare
|
|
650
|
+
// date is inclusive (#1521), so the file is not deleted while
|
|
651
|
+
// `rule-loader.mjs` still injects it on its last day.
|
|
652
|
+
const fileAt = ruleExpiryInstantMs(parsed.expiresAt);
|
|
616
653
|
const effectiveAt = Number.isFinite(storeAt) ? Math.max(storeAt, fileAt) : fileAt;
|
|
617
654
|
const base = {
|
|
618
655
|
file: rule.file,
|
|
@@ -623,7 +660,7 @@ export async function planRuleExpirySweep(opts = {}) {
|
|
|
623
660
|
headings: 0,
|
|
624
661
|
substantivePairs: 1,
|
|
625
662
|
};
|
|
626
|
-
if (effectiveAt
|
|
663
|
+
if (effectiveAt <= cutoffMs) {
|
|
627
664
|
plans.push({
|
|
628
665
|
...base,
|
|
629
666
|
expiredPairIds: [pair.id],
|
|
@@ -39,9 +39,10 @@
|
|
|
39
39
|
* pushes an error string.
|
|
40
40
|
*
|
|
41
41
|
* Atomic write strategy (rule files):
|
|
42
|
-
* -
|
|
43
|
-
*
|
|
44
|
-
* so the rule file is never partially visible.
|
|
42
|
+
* - `atomicWriteWithBackup` (`../io.mjs`): content goes to a sibling
|
|
43
|
+
* `.<file>.tmp.<hex>`, then is renamed over the final path. Same-filesystem
|
|
44
|
+
* rename is atomic on POSIX, so the rule file is never partially visible.
|
|
45
|
+
* Mirrors idempotency.mjs pattern.
|
|
45
46
|
*
|
|
46
47
|
* JSONL append strategy (rejected log):
|
|
47
48
|
* - `appendFileSync` for the rejected log — each record is a self-contained line,
|
|
@@ -63,17 +64,16 @@ import {
|
|
|
63
64
|
writeFileSync,
|
|
64
65
|
readFileSync,
|
|
65
66
|
readdirSync,
|
|
66
|
-
renameSync,
|
|
67
67
|
appendFileSync,
|
|
68
68
|
realpathSync,
|
|
69
69
|
rmSync,
|
|
70
70
|
statSync,
|
|
71
71
|
} from 'node:fs';
|
|
72
|
-
import { randomBytes } from 'node:crypto';
|
|
73
72
|
import { tmpdir } from 'node:os';
|
|
74
73
|
import path from 'node:path';
|
|
75
74
|
|
|
76
75
|
import { withFileLock } from '../file-lock.mjs';
|
|
76
|
+
import { atomicWriteWithBackup, envelopeToError } from '../io.mjs';
|
|
77
77
|
import { validatePathInsideProject } from '../path-utils.mjs';
|
|
78
78
|
import { parseGlobsFrontmatter } from '../rule-loader.mjs';
|
|
79
79
|
import { markCandidateProcessed } from './idempotency.mjs';
|
|
@@ -308,24 +308,18 @@ function resolveDest(item, target, prep, roots, errors) {
|
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
/**
|
|
311
|
-
* Write `content` to `destPath` atomically via tmp+rename
|
|
311
|
+
* Write `content` to `destPath` atomically via tmp+rename
|
|
312
|
+
* (`atomicWriteWithBackup`, random tmp suffix, tmp unlinked on failure).
|
|
312
313
|
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* under the lock this should not happen, but the pattern is cheap).
|
|
316
|
-
*
|
|
317
|
-
* Throws on filesystem errors — callers must catch.
|
|
314
|
+
* Throws on filesystem errors — callers must catch. The helper returns an
|
|
315
|
+
* envelope; `envelopeToError` re-raises it with the same message and `code`.
|
|
318
316
|
*
|
|
319
317
|
* @param {string} destPath - absolute path of the target rule file.
|
|
320
318
|
* @param {string} content - UTF-8 text content to write.
|
|
321
319
|
*/
|
|
322
320
|
function writeTextAtomic(destPath, content) {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
const suffix = randomBytes(4).toString('hex');
|
|
326
|
-
const tmpPath = `${destPath}.${suffix}.tmp`;
|
|
327
|
-
writeFileSync(tmpPath, content, 'utf8');
|
|
328
|
-
renameSync(tmpPath, destPath);
|
|
321
|
+
const res = atomicWriteWithBackup(destPath, content, { tmpPrefix: `.${path.basename(destPath)}.tmp` });
|
|
322
|
+
if (!res.ok) throw envelopeToError(res);
|
|
329
323
|
}
|
|
330
324
|
|
|
331
325
|
/**
|
|
@@ -48,8 +48,9 @@
|
|
|
48
48
|
* Deterministic gating (issue #694) runs after a successful frontmatter parse
|
|
49
49
|
* and applies to BOTH always-on and glob-matched candidate rules — a rule must
|
|
50
50
|
* pass ALL active gates to be included:
|
|
51
|
-
* - Expiry: a rule
|
|
52
|
-
*
|
|
51
|
+
* - Expiry: a rule whose parseable `expires-at` has passed is EXCLUDED (with
|
|
52
|
+
* a mandatory stderr WARN). A bare date is inclusive — the rule loads
|
|
53
|
+
* through that whole UTC day ({@link ruleExpiryInstantMs}, #1521). A malformed `expires-at` never
|
|
53
54
|
* excludes (fail-open).
|
|
54
55
|
* - Mode-gating: when the `mode` param is non-null and the rule declares a
|
|
55
56
|
* `mode` that differs, the rule is EXCLUDED. A null `mode` param disables
|
|
@@ -411,16 +412,41 @@ function metaToEntryFields(meta) {
|
|
|
411
412
|
* @param {unknown} rawExpiry - the raw `expires-at` frontmatter value (or undefined)
|
|
412
413
|
* @param {number} [now] - epoch ms; defaults to `Date.now()`
|
|
413
414
|
* @param {(raw: unknown) => void} [onUnparseable] - called when `expires-at` is present but unparseable
|
|
414
|
-
* @returns {boolean} true only when a PARSEABLE `expires-at`
|
|
415
|
+
* @returns {boolean} true only when `now` has reached a PARSEABLE `expires-at`'s {@link ruleExpiryInstantMs}
|
|
415
416
|
*/
|
|
416
417
|
export function isRuleExpired(rawExpiry, now = Date.now(), onUnparseable) {
|
|
417
418
|
if (rawExpiry === undefined || rawExpiry === null) return false;
|
|
418
|
-
const ts =
|
|
419
|
-
if (
|
|
419
|
+
const ts = ruleExpiryInstantMs(rawExpiry);
|
|
420
|
+
if (ts === null) {
|
|
420
421
|
if (typeof onUnparseable === 'function') onUnparseable(rawExpiry);
|
|
421
422
|
return false;
|
|
422
423
|
}
|
|
423
|
-
return ts
|
|
424
|
+
return ts <= now;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const BARE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
428
|
+
const MS_PER_DAY = 86_400_000;
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The instant (epoch ms) from which a rule with this `expires-at` counts as
|
|
432
|
+
* expired, or `null` when the value is absent or unparseable.
|
|
433
|
+
*
|
|
434
|
+
* `expires-at` is INCLUSIVE (#1521): a bare `YYYY-MM-DD` keeps the rule live
|
|
435
|
+
* through the whole of that UTC day, so the instant is the FOLLOWING UTC
|
|
436
|
+
* midnight. A full timestamp is taken as written. Every consumer that judges
|
|
437
|
+
* "expired" — the loader gate above, `instruction-budget-guard.mjs`
|
|
438
|
+
* (maintenance probe S7) and `reconcile/rule-expiry-sweep.mjs` — goes through
|
|
439
|
+
* this one function so the three never disagree about the boundary day.
|
|
440
|
+
*
|
|
441
|
+
* @param {unknown} rawExpiry
|
|
442
|
+
* @returns {number|null}
|
|
443
|
+
*/
|
|
444
|
+
export function ruleExpiryInstantMs(rawExpiry) {
|
|
445
|
+
if (rawExpiry === undefined || rawExpiry === null) return null;
|
|
446
|
+
const text = String(rawExpiry).trim();
|
|
447
|
+
const ts = Date.parse(text);
|
|
448
|
+
if (Number.isNaN(ts)) return null;
|
|
449
|
+
return BARE_DATE_RE.test(text) ? ts + MS_PER_DAY : ts;
|
|
424
450
|
}
|
|
425
451
|
|
|
426
452
|
/**
|
|
@@ -420,7 +420,8 @@ export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFi
|
|
|
420
420
|
* @param {number} params.wave
|
|
421
421
|
* @param {string} params.stateDir
|
|
422
422
|
* @param {string} [params.session] semantic (or raw) session id to filter on
|
|
423
|
-
* @param {string} [params.eventsPath] defaults to `.orchestrator/metrics/events.jsonl
|
|
423
|
+
* @param {string} [params.eventsPath] defaults to the cwd-relative `.orchestrator/metrics/events.jsonl`;
|
|
424
|
+
* the CLI passes `eventsFilePath(<state-dir>/..)` (the repo's one ledger, #1514)
|
|
424
425
|
* @param {typeof readFileSync} [params.readFile]
|
|
425
426
|
* @param {typeof readdirSync} [params.readDir]
|
|
426
427
|
* @returns {{wave: number, transport: 'observable'|'unobservable', dispatches: number,
|
|
@@ -639,11 +640,20 @@ async function mainVerify(args) {
|
|
|
639
640
|
return 1;
|
|
640
641
|
}
|
|
641
642
|
|
|
643
|
+
// #1514 point 1 — read the ledger the send-side hook WRITES: the one ledger
|
|
644
|
+
// of the repo this state dir belongs to (`eventsFilePath(<root>)`, a linked
|
|
645
|
+
// worktree mapped to its main checkout), not a cwd-relative path that, run
|
|
646
|
+
// from an entered worktree, found 0 `scope_checked` records. Resolved here,
|
|
647
|
+
// not in `verifyWaveScope`, so this module stays pure (stdlib +
|
|
648
|
+
// `crypto-digest-utils.mjs`) for the hook that imports it.
|
|
649
|
+
const eventsPath = typeof args.events === 'string'
|
|
650
|
+
? args.events
|
|
651
|
+
: (await import('./events.mjs')).eventsFilePath(resolve(stateDir, '..'));
|
|
642
652
|
const report = verifyWaveScope({
|
|
643
653
|
wave,
|
|
644
654
|
stateDir,
|
|
645
655
|
session: typeof args.session === 'string' ? args.session : undefined,
|
|
646
|
-
eventsPath
|
|
656
|
+
eventsPath,
|
|
647
657
|
});
|
|
648
658
|
|
|
649
659
|
if (args.emit) {
|
|
@@ -405,7 +405,8 @@ export function testSiblingExpansionApplies(opts = {}) {
|
|
|
405
405
|
* tests/lib/X.test.mjs`). The DATED figures — population, both ratios, the
|
|
406
406
|
* measuring command and the SHA — live in ONE place:
|
|
407
407
|
* skills/wave-executor/references/wave-loop-scope-manifest.md § Test-Sibling
|
|
408
|
-
* Expansion (#970)
|
|
408
|
+
* Expansion (#970), measured by `scripts/lib/test-sibling-coverage.mjs` (#1030 P3).
|
|
409
|
+
* Cite and re-measure them there, never restate them here: an
|
|
409
410
|
* undated copy drifted to a second, contradicting set of numbers (#1026.5,
|
|
410
411
|
* PSA-006 item 4). The glob's failure mode is HARMLESS — it grants write access
|
|
411
412
|
* to files that may not exist. A computed concrete path is wrong more often AND
|
|
@@ -66,7 +66,7 @@ import { parseStateMd as defaultParseStateMd } from './state-md/yaml-parser.mjs'
|
|
|
66
66
|
import { canonicalizeSessions } from './sessions-canonical.mjs';
|
|
67
67
|
// Already in the hook import set (`emitEvent` lives there): the rotation-aware
|
|
68
68
|
// reader adds no new file to the SessionStart/SessionEnd hook graph.
|
|
69
|
-
import { scanEventsBackwards } from './events.mjs';
|
|
69
|
+
import { eventsFilePath, scanEventsBackwards } from './events.mjs'; // eventsFilePath: #1514, the repo's one ledger
|
|
70
70
|
// Leaf constants module (no imports of its own) and ALREADY in the hook import
|
|
71
71
|
// set via session-schema/validator.mjs — importing it here adds no new file to
|
|
72
72
|
// the SessionStart/SessionEnd hook graph. The profile enum must not be
|
|
@@ -114,7 +114,6 @@ const EVENT_STOPPED = 'orchestrator.session.stopped';
|
|
|
114
114
|
const EVENT_TURN_STOPPED = 'orchestrator.turn.stopped';
|
|
115
115
|
const EVENT_ENDED = 'orchestrator.session.ended';
|
|
116
116
|
|
|
117
|
-
const EVENTS_REL = ['.orchestrator', 'metrics', 'events.jsonl'];
|
|
118
117
|
const SESSIONS_REL = ['.orchestrator', 'metrics', 'sessions.jsonl'];
|
|
119
118
|
const BACKFILL_LOG_REL = ['.orchestrator', 'metrics', 'session-close-backfill.log'];
|
|
120
119
|
|
|
@@ -973,7 +972,7 @@ export async function backfillAbandonedSession({
|
|
|
973
972
|
return { action: 'skipped-no-identifier' };
|
|
974
973
|
}
|
|
975
974
|
|
|
976
|
-
const eventsPath =
|
|
975
|
+
const eventsPath = eventsFilePath(repoRoot);
|
|
977
976
|
const sessionsPath = path.join(repoRoot, ...SESSIONS_REL);
|
|
978
977
|
|
|
979
978
|
// -- Resolve the SEMANTIC record id (sessions.jsonl key) ----------------
|
|
@@ -1368,7 +1367,7 @@ export async function backfillCompletedFromStateMd({
|
|
|
1368
1367
|
if (supersedes) recordId = supersedes;
|
|
1369
1368
|
|
|
1370
1369
|
// -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
|
|
1371
|
-
const eventsPath =
|
|
1370
|
+
const eventsPath = eventsFilePath(repoRoot);
|
|
1372
1371
|
const gathered = gatherSessionEvents(readFileSync, eventsPath, { sessionId: nativeId, semanticSessionId });
|
|
1373
1372
|
|
|
1374
1373
|
// -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
import { readFile } from 'node:fs/promises';
|
|
43
43
|
import path from 'node:path';
|
|
44
44
|
|
|
45
|
+
import { eventsFilePath } from './events.mjs';
|
|
45
46
|
import { readCanonicalSessions } from './sessions-canonical.mjs';
|
|
46
47
|
import { readTailWindow } from './tail-window.mjs';
|
|
47
48
|
import { withStateMdLock } from './session-lock.mjs';
|
|
@@ -207,7 +208,7 @@ const EVENTS_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
|
|
|
207
208
|
* @returns {Promise<string[]>} Array of semantic_session_id strings (may include duplicates).
|
|
208
209
|
*/
|
|
209
210
|
async function readSessionIdsFromEvents(repoRoot) {
|
|
210
|
-
const filePath =
|
|
211
|
+
const filePath = eventsFilePath(repoRoot); // #1514 point 1: the repo's one ledger
|
|
211
212
|
|
|
212
213
|
let window;
|
|
213
214
|
try {
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
import fs from 'node:fs';
|
|
69
69
|
import path from 'node:path';
|
|
70
70
|
|
|
71
|
+
import { atomicWriteWithBackup, envelopeToError } from './io.mjs';
|
|
71
72
|
import { validateSession as defaultValidateSession } from './session-schema/validator.mjs';
|
|
72
73
|
import { serializeSessionLineChecked as defaultSerialize } from './session-schema.mjs';
|
|
73
74
|
import { checkSessionsIntegrity as defaultCheckIntegrity } from './sessions-integrity-banner.mjs';
|
|
@@ -123,12 +124,11 @@ const INCOMPLETE_FIELD_ORDER = Object.freeze([
|
|
|
123
124
|
const MS_PRECISION_FIELDS = Object.freeze(['started_at', 'completed_at', 'lease_acquired_at']);
|
|
124
125
|
|
|
125
126
|
/**
|
|
126
|
-
* ISO-8601 UTC with MORE than three fractional digits
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* stay for the validator to reject: there is no defensible default for them.
|
|
127
|
+
* ISO-8601 UTC with MORE than three fractional digits and exactly `Z` or
|
|
128
|
+
* `+00:00` (#1525). Group 1 is the value cut at the millisecond. Nonzero
|
|
129
|
+
* offsets, other timezone spellings and shorter fractions stay unrepaired.
|
|
130
130
|
*/
|
|
131
|
-
const ISO_UTC_SUB_MS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})\d+Z$/;
|
|
131
|
+
const ISO_UTC_SUB_MS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})\d+(?:Z|\+00:00)$/;
|
|
132
132
|
|
|
133
133
|
// ---------------------------------------------------------------------------
|
|
134
134
|
// Helpers
|
|
@@ -154,7 +154,13 @@ function isCount(v) {
|
|
|
154
154
|
function truncateToMs(value) {
|
|
155
155
|
if (typeof value !== 'string') return null;
|
|
156
156
|
const m = ISO_UTC_SUB_MS_RE.exec(value);
|
|
157
|
-
|
|
157
|
+
if (!m) return null;
|
|
158
|
+
const canonical = `${m[1]}Z`;
|
|
159
|
+
const ms = Date.parse(canonical);
|
|
160
|
+
// Date.parse rolls over e.g. April 31, non-leap February 29 and 24:00.
|
|
161
|
+
// Equality with the original calendar components refuses those repairs.
|
|
162
|
+
if (!Number.isFinite(ms) || new Date(ms).toISOString() !== canonical) return null;
|
|
163
|
+
return canonical;
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
function orderIncompleteFields(fields) {
|
|
@@ -390,10 +396,16 @@ export function repairRecord(record) {
|
|
|
390
396
|
preserveRaw(out, 'completed_at', record.completed_at, rescued);
|
|
391
397
|
const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
|
|
392
398
|
const startedMs = Date.parse(out.started_at);
|
|
393
|
-
|
|
399
|
+
const endedTruncated = truncateToMs(out.ended_at);
|
|
400
|
+
if (typeof out.ended_at === 'string' &&
|
|
401
|
+
(!Number.isFinite(endedMs) ||
|
|
402
|
+
(ISO_UTC_SUB_MS_RE.test(out.ended_at) && endedTruncated === null))) {
|
|
403
|
+
// Preserve an invalid legacy measurement for the final schema gate to
|
|
404
|
+
// reject; do not turn it into a valid default or a rolled-over date.
|
|
405
|
+
out.completed_at = out.ended_at;
|
|
406
|
+
} else if (Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs) {
|
|
394
407
|
// The COPY is cut to milliseconds; `ended_at` itself is not a validated
|
|
395
408
|
// field and keeps its original bytes, so nothing needs a sidecar here.
|
|
396
|
-
const endedTruncated = truncateToMs(out.ended_at);
|
|
397
409
|
if (endedTruncated !== null) precisionCut = true;
|
|
398
410
|
out.completed_at = endedTruncated ?? out.ended_at;
|
|
399
411
|
} else {
|
|
@@ -716,7 +728,6 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
|
|
|
716
728
|
renameSync = fs.renameSync,
|
|
717
729
|
copyFileSync = fs.copyFileSync,
|
|
718
730
|
unlinkSync = fs.unlinkSync,
|
|
719
|
-
existsSync = fs.existsSync,
|
|
720
731
|
} = deps;
|
|
721
732
|
|
|
722
733
|
const { checkIntegrity = defaultCheckIntegrity } = deps;
|
|
@@ -751,18 +762,14 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
|
|
|
751
762
|
}
|
|
752
763
|
|
|
753
764
|
// -- atomic swap ----------------------------------------------------------
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
/* best-effort cleanup */
|
|
763
|
-
}
|
|
764
|
-
throw err;
|
|
765
|
-
}
|
|
765
|
+
// The helper unlinks its tmp on failure; the backup above keeps its own
|
|
766
|
+
// `.bak-<compact stamp>` schema, so the helper takes none. The injected fs
|
|
767
|
+
// seams (`deps`) are threaded through so a test fake still sees the write.
|
|
768
|
+
const res = atomicWriteWithBackup(file, text, {
|
|
769
|
+
tmpPrefix: `.${path.basename(file)}.tmp`,
|
|
770
|
+
fs: { writeFileSync, renameSync, unlinkSync },
|
|
771
|
+
});
|
|
772
|
+
if (!res.ok) throw envelopeToError(res);
|
|
766
773
|
|
|
767
774
|
// -- post-verification ----------------------------------------------------
|
|
768
775
|
const verdict = verifyWritten({
|
|
@@ -162,11 +162,11 @@ export const AGENT_SUMMARY_FIELDS = Object.freeze(['complete', 'partial', 'faile
|
|
|
162
162
|
* #964). These are NOT in REQUIRED_FIELDS — older entries lacking them validate
|
|
163
163
|
* cleanly. Validator: see `_validateOptionalFields` in validator.mjs.
|
|
164
164
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
165
|
+
* Exhaustive since #986: every optional key `validateSession` inspects is
|
|
166
|
+
* declared here. tests/lib/session-schema/constants.test.mjs takes that census
|
|
167
|
+
* from the validator itself (a Proxy records the keys it reads) and also pins
|
|
168
|
+
* the declared-but-unchecked remainder (`session_start_ref`, `raw_session_id`,
|
|
169
|
+
* `_repair_source`).
|
|
170
170
|
*/
|
|
171
171
|
export const OPTIONAL_FIELDS = Object.freeze([
|
|
172
172
|
'agent_identity',
|
|
@@ -252,4 +252,19 @@ export const OPTIONAL_FIELDS = Object.freeze([
|
|
|
252
252
|
// 2026-10-01), so the counters say which of the two it is.
|
|
253
253
|
'cost_records_priced',
|
|
254
254
|
'cost_records_total',
|
|
255
|
+
// #986 — shape-checked by `_validateOptionalFields` since before this list
|
|
256
|
+
// existed, never declared until the census test made the gap visible.
|
|
257
|
+
'discovery_stats',
|
|
258
|
+
'review_stats',
|
|
259
|
+
'platform',
|
|
260
|
+
'branch',
|
|
261
|
+
'base_branch',
|
|
262
|
+
'notes',
|
|
263
|
+
'duration_seconds',
|
|
264
|
+
'issues_closed',
|
|
265
|
+
'issues_created',
|
|
266
|
+
// `express_path` (canonical boolean, legacy {activated} object tolerated) and
|
|
267
|
+
// its forensic sidecar `_express_path_detail` written by `normalizeSession`.
|
|
268
|
+
'express_path',
|
|
269
|
+
'_express_path_detail',
|
|
255
270
|
]);
|
|
@@ -182,6 +182,12 @@ function _validateTimestamps(entry) {
|
|
|
182
182
|
`started_at must match ISO-8601 UTC ms format (YYYY-MM-DDTHH:MM:SS[.SSS]Z), got: ${entry.started_at}`
|
|
183
183
|
);
|
|
184
184
|
}
|
|
185
|
+
// Date.parse normalizes impossible calendar dates; compare without mutating the record.
|
|
186
|
+
const startedCanonical = entry.started_at.includes('.')
|
|
187
|
+
? entry.started_at : entry.started_at.replace(/Z$/, '.000Z');
|
|
188
|
+
if (new Date(startedMs).toISOString() !== startedCanonical) {
|
|
189
|
+
throw new ValidationError(`started_at must be a valid calendar timestamp, got: ${entry.started_at}`);
|
|
190
|
+
}
|
|
185
191
|
if (typeof entry.completed_at !== 'string') {
|
|
186
192
|
throw new ValidationError('completed_at must be an ISO timestamp string');
|
|
187
193
|
}
|
|
@@ -194,6 +200,12 @@ function _validateTimestamps(entry) {
|
|
|
194
200
|
`completed_at must match ISO-8601 UTC ms format (YYYY-MM-DDTHH:MM:SS[.SSS]Z), got: ${entry.completed_at}`
|
|
195
201
|
);
|
|
196
202
|
}
|
|
203
|
+
// Date.parse normalizes impossible calendar dates; compare without mutating the record.
|
|
204
|
+
const completedCanonical = entry.completed_at.includes('.')
|
|
205
|
+
? entry.completed_at : entry.completed_at.replace(/Z$/, '.000Z');
|
|
206
|
+
if (new Date(completedMs).toISOString() !== completedCanonical) {
|
|
207
|
+
throw new ValidationError(`completed_at must be a valid calendar timestamp, got: ${entry.completed_at}`);
|
|
208
|
+
}
|
|
197
209
|
if (completedMs < startedMs) {
|
|
198
210
|
throw new ValidationError(
|
|
199
211
|
`completed_at (${entry.completed_at}) must be >= started_at (${entry.started_at})`
|
|
@@ -50,7 +50,7 @@ import path from 'node:path';
|
|
|
50
50
|
import { pathToFileURL } from 'node:url';
|
|
51
51
|
import { promisify } from 'node:util';
|
|
52
52
|
|
|
53
|
-
import { emitEvent } from './events.mjs';
|
|
53
|
+
import { emitEvent, eventsFilePath } from './events.mjs';
|
|
54
54
|
import { ARCHIVE_DIR_NAME, ARCHIVE_NAME_RE } from './events-schema.mjs';
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -258,10 +258,11 @@ function commitRunsCommand(gh, sha) {
|
|
|
258
258
|
* @returns {string}
|
|
259
259
|
*/
|
|
260
260
|
function ciUnknownHint(result) {
|
|
261
|
-
|
|
262
|
-
//
|
|
263
|
-
const gh = result
|
|
261
|
+
// Every GitHub unknown reason needs a GitHub hint, including running,
|
|
262
|
+
// queued and cancelled checks that do not trigger a pushed-commit query.
|
|
263
|
+
const gh = result?.details?.cliUsed === 'gh';
|
|
264
264
|
const onDemand = gh ? 'run `gh run list` on demand' : CI_UNKNOWN_HINT_DEFAULT;
|
|
265
|
+
if (!PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) return onDemand;
|
|
265
266
|
const pushed = result.pushed && typeof result.pushed === 'object' ? result.pushed : null;
|
|
266
267
|
// Pure (#1396): the SHA is whatever the follow-up resolved inside the budget.
|
|
267
268
|
// Without `pushed` (the follow-up overran or threw before resolving it) the
|
|
@@ -275,6 +276,20 @@ function ciUnknownHint(result) {
|
|
|
275
276
|
return `last pushed: ${short} (pipeline not checked; run \`${commitRunsCommand(gh, sha)}\`)`;
|
|
276
277
|
}
|
|
277
278
|
|
|
279
|
+
/** Additive GitHub diagnostics; absent counts leave existing banners unchanged. */
|
|
280
|
+
function checkRunCountsText(result) {
|
|
281
|
+
const counts = result?.details?.checkRunCounts;
|
|
282
|
+
if (!counts || result?.details?.cliUsed !== 'gh') return '';
|
|
283
|
+
const parts = [
|
|
284
|
+
['success', 'successful'], ['failure', 'failed/action required'],
|
|
285
|
+
['inProgress', 'in progress'], ['queued', 'queued'],
|
|
286
|
+
['cancelled', 'cancelled'], ['skipped', 'skipped'],
|
|
287
|
+
['neutral', 'neutral'], ['waiting', 'waiting'], ['other', 'unrecognised'],
|
|
288
|
+
].filter(([key]) => Number.isSafeInteger(counts[key]) && counts[key] > 0)
|
|
289
|
+
.map(([key, label]) => `${label}: ${counts[key]}`);
|
|
290
|
+
return parts.length ? ` — Check runs: ${parts.join(', ')}` : '';
|
|
291
|
+
}
|
|
292
|
+
|
|
278
293
|
/**
|
|
279
294
|
* "Green, but allow_failure jobs failed" — the ONE predicate both the
|
|
280
295
|
* `ci-status` renderer and its `severityOf` use (#1333). Two predicates once
|
|
@@ -584,7 +599,7 @@ export const PROBES = [
|
|
|
584
599
|
// #1337: a RED pushed commit is an alert, and the banner says what the
|
|
585
600
|
// rule judges (HR-106) — same 🚨 as a red HEAD.
|
|
586
601
|
const mark = r.pushed?.verdict?.status === 'red' ? '🚨' : '⚠';
|
|
587
|
-
return `${mark} ci-status: CI status for HEAD could not be determined (${reason}) — ${ciUnknownHint(r)}`;
|
|
602
|
+
return `${mark} ci-status: CI status for HEAD could not be determined (${reason})${checkRunCountsText(r)} — ${ciUnknownHint(r)}`;
|
|
588
603
|
}
|
|
589
604
|
if (r.status === 'red') {
|
|
590
605
|
const pid = r.details?.currentPipelineId ?? '?';
|
|
@@ -592,7 +607,7 @@ export const PROBES = [
|
|
|
592
607
|
? ` — last green: #${r.lastGreen.pipelineId} (commit ${String(r.lastGreen.sha ?? '').slice(0, 7)}, ${r.redCount} pipelines ago)`
|
|
593
608
|
: '';
|
|
594
609
|
const job = r.failingJobName ? ` Failing job: ${r.failingJobName}` : '';
|
|
595
|
-
return `🚨 CI RED on HEAD (pipeline #${pid})${green}.${job}`;
|
|
610
|
+
return `🚨 CI RED on HEAD (pipeline #${pid})${green}.${job}${checkRunCountsText(r)}`;
|
|
596
611
|
}
|
|
597
612
|
if (r.status === 'green') {
|
|
598
613
|
const lines = [];
|
|
@@ -762,7 +777,7 @@ export const PROBES = [
|
|
|
762
777
|
fn: 'checkEventsRetention',
|
|
763
778
|
network: false,
|
|
764
779
|
precondition: ({ repoRoot }) => {
|
|
765
|
-
const metrics = path.
|
|
780
|
+
const metrics = path.dirname(eventsFilePath(repoRoot)); // #1514: the ledger checkEventsRetention reads
|
|
766
781
|
return existsSync(path.join(metrics, 'events.jsonl')) || hasRotationArchive(metrics)
|
|
767
782
|
? null
|
|
768
783
|
: 'no-events-ledger';
|