@dev-loops/core 1.0.2-pre.0 → 1.0.2
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/package.json +3 -1
- package/src/analysis/diff-analyzer.mjs +85 -137
- package/src/claude/asset-generation.mjs +7 -7
- package/src/claude/hook-decisions.mjs +29 -36
- package/src/config/config.mjs +410 -787
- package/src/config/extension-defaults.yaml +17 -0
- package/src/github/closing-ref-guard.mjs +80 -0
- package/src/github/comment-id-guard.mjs +2 -2
- package/src/github/copilot-helpers.mjs +90 -158
- package/src/loop/bash-command-classify.mjs +34 -49
- package/src/loop/conductor-routing.mjs +15 -23
- package/src/loop/copilot-loop-state.mjs +46 -94
- package/src/loop/gate-carry-forward.mjs +2 -2
- package/src/loop/gate-fanin.mjs +252 -442
- package/src/loop/handoff-envelope.mjs +132 -25
- package/src/loop/issue-refinement-artifact.mjs +188 -263
- package/src/loop/lifecycle-state.mjs +10 -21
- package/src/loop/pr-gate-coordination.mjs +37 -37
- package/src/loop/queue-board-sync.mjs +16 -55
- package/src/loop/retrospective-checkpoint.mjs +7 -8
- package/src/loop/review-dispatch-plan.mjs +60 -122
- package/src/loop/review-lineage.mjs +19 -44
- package/src/loop/spec-authority.mjs +39 -69
- package/src/loop/steering.mjs +16 -68
- package/src/loop/worktree-guard.mjs +80 -13
- package/src/projects/list-queue-items.mjs +16 -146
- package/src/projects/move-queue-item.mjs +15 -141
- package/src/projects/projects-access.mjs +202 -0
|
@@ -54,6 +54,16 @@ const STRATEGY_DEFAULT_STOP_RULES = Object.freeze({
|
|
|
54
54
|
],
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
const RECONCILIATION_ACCEPTANCE_TEMPLATE = deepFreeze({
|
|
58
|
+
criteria: [
|
|
59
|
+
{ id: "reconcile", must: "Resolve the reported authoritative-state conflict before selecting or executing a strategy.", severity: "required" },
|
|
60
|
+
],
|
|
61
|
+
evidence: ["commands-run", "validation-output"],
|
|
62
|
+
maxFinalizationTurns: 1,
|
|
63
|
+
needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
|
|
64
|
+
activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
|
|
65
|
+
});
|
|
66
|
+
|
|
57
67
|
// ---------------------------------------------------------------------------
|
|
58
68
|
// Acceptance template table
|
|
59
69
|
// ---------------------------------------------------------------------------
|
|
@@ -131,7 +141,7 @@ register(INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION, "default", {
|
|
|
131
141
|
activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
|
|
132
142
|
});
|
|
133
143
|
|
|
134
|
-
// local_implementation · spike run (SPIKE-RELAXED-GATE-PROFILE
|
|
144
|
+
// local_implementation · spike run (SPIKE-RELAXED-GATE-PROFILE): a
|
|
135
145
|
// spike-mode spin resolves the relaxed `spike` gate profile instead of the
|
|
136
146
|
// default local-implementation gate. Kept as its own acceptance key so the
|
|
137
147
|
// generic default can stay approach-agnostic.
|
|
@@ -305,7 +315,7 @@ function deriveRequiredReads(bundle, resolverOutput) {
|
|
|
305
315
|
}
|
|
306
316
|
|
|
307
317
|
// ---------------------------------------------------------------------------
|
|
308
|
-
// specSource derivation
|
|
318
|
+
// specSource derivation — lightweight PR-body-as-spec
|
|
309
319
|
// ---------------------------------------------------------------------------
|
|
310
320
|
|
|
311
321
|
/**
|
|
@@ -440,7 +450,7 @@ function deriveCwd(bundle, options = {}) {
|
|
|
440
450
|
}
|
|
441
451
|
|
|
442
452
|
/** Repo-relative root for loop-owned worktrees. The `dev-loops/` namespace */
|
|
443
|
-
/** marks them so cleanup can only ever remove its own
|
|
453
|
+
/** marks them so cleanup can only ever remove its own. */
|
|
444
454
|
export const WORKTREE_NAMESPACE = "tmp/worktrees/dev-loops";
|
|
445
455
|
|
|
446
456
|
/**
|
|
@@ -470,7 +480,7 @@ function flattenSlugSegment(s) {
|
|
|
470
480
|
}
|
|
471
481
|
|
|
472
482
|
function buildWorktreeSlug(artifact, kind) {
|
|
473
|
-
// Canonical naming is namespaced + no branch suffix
|
|
483
|
+
// Canonical naming is namespaced + no branch suffix so the path
|
|
474
484
|
// is recomputable from the issue/PR number alone (cleanup can find it).
|
|
475
485
|
if (kind === DEV_LOOP_TARGET_KIND.ISSUE && Number.isInteger(artifact.issue) && artifact.issue > 0) {
|
|
476
486
|
return `issue-${artifact.issue}`;
|
|
@@ -511,7 +521,7 @@ function normalizeGateState(gateState) {
|
|
|
511
521
|
|
|
512
522
|
|
|
513
523
|
/**
|
|
514
|
-
* Normalize the structured retrospective findings
|
|
524
|
+
* Normalize the structured retrospective findings.
|
|
515
525
|
*
|
|
516
526
|
* The retrospective is advisory: it never blocks merge or any lifecycle
|
|
517
527
|
* transition. Its findings travel in the handoff envelope (the conductor's
|
|
@@ -553,7 +563,7 @@ function resolveSubGate(strategy, gateState) {
|
|
|
553
563
|
return "default";
|
|
554
564
|
}
|
|
555
565
|
|
|
556
|
-
/** True when the resolver output identifies a spike-mode run
|
|
566
|
+
/** True when the resolver output identifies a spike-mode run. */
|
|
557
567
|
function isSpikeRun(resolverOutput) {
|
|
558
568
|
return Boolean(resolverOutput && resolverOutput.spikeIntakeState);
|
|
559
569
|
}
|
|
@@ -584,8 +594,45 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
584
594
|
throw new Error("handoff-envelope: resolverOutput is required and must be an object");
|
|
585
595
|
}
|
|
586
596
|
|
|
587
|
-
const
|
|
588
|
-
|
|
597
|
+
const isWrapped = Object.hasOwn(resolverOutput, "bundle");
|
|
598
|
+
if (isWrapped && (!resolverOutput.bundle || typeof resolverOutput.bundle !== "object")) {
|
|
599
|
+
throw new Error("handoff-envelope: resolverOutput.bundle must be an object when present");
|
|
600
|
+
}
|
|
601
|
+
const bundle = isWrapped ? resolverOutput.bundle : resolverOutput;
|
|
602
|
+
const strategy = bundle.selectedStrategy === null
|
|
603
|
+
? null
|
|
604
|
+
: requireString(bundle.selectedStrategy, "resolverOutput.selectedStrategy");
|
|
605
|
+
const routeKind = strategy === null
|
|
606
|
+
? requireString(bundle.routeKind, "resolverOutput.routeKind")
|
|
607
|
+
: (trimmedOrNull(bundle.routeKind) ?? "route");
|
|
608
|
+
const selectedGate = trimmedOrNull(bundle.selectedGate);
|
|
609
|
+
const outerBundleKind = isWrapped ? trimmedOrNull(resolverOutput.bundleKind) : null;
|
|
610
|
+
const nestedBundleKind = trimmedOrNull(bundle.bundleKind);
|
|
611
|
+
if (outerBundleKind && nestedBundleKind && outerBundleKind !== nestedBundleKind) {
|
|
612
|
+
throw new Error(`handoff-envelope: outer bundleKind (${outerBundleKind}) and inner bundleKind (${nestedBundleKind}) must agree`);
|
|
613
|
+
}
|
|
614
|
+
const bundleKind = outerBundleKind ?? nestedBundleKind;
|
|
615
|
+
const isReconciliation = routeKind === "needs_reconcile"
|
|
616
|
+
&& strategy === null
|
|
617
|
+
&& selectedGate === "fail_closed_reconcile"
|
|
618
|
+
&& bundleKind === "needs_reconcile";
|
|
619
|
+
if (isWrapped && isReconciliation && (
|
|
620
|
+
resolverOutput.bundleKind !== "needs_reconcile"
|
|
621
|
+
|| bundle.bundleKind !== "needs_reconcile"
|
|
622
|
+
|| resolverOutput.selectedStrategy !== "none"
|
|
623
|
+
|| bundle.selectedStrategy !== null
|
|
624
|
+
)) {
|
|
625
|
+
throw new Error("handoff-envelope: wrapped needs_reconcile output requires exact outer and inner bundleKind needs_reconcile markers, outer selectedStrategy none, and inner selectedStrategy null");
|
|
626
|
+
}
|
|
627
|
+
if (bundleKind === "needs_reconcile" && !isReconciliation) {
|
|
628
|
+
throw new Error("handoff-envelope: outer/inner bundleKind needs_reconcile requires inner routeKind needs_reconcile, selectedGate fail_closed_reconcile, and selectedStrategy null");
|
|
629
|
+
}
|
|
630
|
+
if (routeKind === "needs_reconcile" && !isReconciliation) {
|
|
631
|
+
throw new Error("handoff-envelope: routeKind needs_reconcile requires outer bundleKind needs_reconcile, selectedGate fail_closed_reconcile, and selectedStrategy null");
|
|
632
|
+
}
|
|
633
|
+
if (strategy === null && !isReconciliation) {
|
|
634
|
+
throw new Error("handoff-envelope: a null resolverOutput.selectedStrategy is allowed only for the canonical needs_reconcile/fail_closed_reconcile tuple");
|
|
635
|
+
}
|
|
589
636
|
const executionMode = requireString(bundle.executionMode, "resolverOutput.executionMode");
|
|
590
637
|
const nextAction = requireString(bundle.nextAction, "resolverOutput.nextAction");
|
|
591
638
|
|
|
@@ -593,27 +640,33 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
593
640
|
if (!repo) throw new Error("handoff-envelope: repo slug is required (owner/name)");
|
|
594
641
|
|
|
595
642
|
const gs = normalizeGateState(gateState);
|
|
596
|
-
// SPIKE-RELAXED-GATE-PROFILE
|
|
643
|
+
// SPIKE-RELAXED-GATE-PROFILE: a spike-mode spin (startup resolver
|
|
597
644
|
// result carrying `spikeIntakeState`) resolves the relaxed `spike` gate
|
|
598
645
|
// profile instead of the default local-implementation gate. The spike
|
|
599
646
|
// marker lives at the TOP level of the resolver output (the bundle does not
|
|
600
647
|
// carry it), so it is read off `resolverOutput` directly.
|
|
601
|
-
const subGate =
|
|
648
|
+
const subGate = isReconciliation
|
|
649
|
+
? selectedGate
|
|
650
|
+
: (strategy === INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION && isSpikeRun(resolverOutput))
|
|
602
651
|
? "spike"
|
|
603
652
|
: resolveSubGate(strategy, gs);
|
|
604
653
|
// Normalize each source independently, then fall back on the normalized result
|
|
605
654
|
// (not the raw value): a present-but-invalid gateState value must NOT shadow a
|
|
606
|
-
// valid options.retrospectiveFindings fallback
|
|
655
|
+
// valid options.retrospectiveFindings fallback.
|
|
607
656
|
const retrospectiveFindings = normalizeRetrospectiveFindings(gateState?.retrospectiveFindings)
|
|
608
657
|
?? normalizeRetrospectiveFindings(options.retrospectiveFindings);
|
|
609
658
|
|
|
610
659
|
const target = deriveTarget(bundle, repo);
|
|
611
660
|
const requiredReads = deriveRequiredReads(bundle, resolverOutput);
|
|
612
|
-
const stopRules =
|
|
661
|
+
const stopRules = isReconciliation
|
|
662
|
+
? ["reconcile", ...(resolveHumanMergeOnly(settings) ? ["merge"] : [])]
|
|
663
|
+
: deriveStopRules(settings, strategy);
|
|
613
664
|
const gateConfig = deriveGateConfig(settings, subGate);
|
|
614
665
|
const derivedCwd = deriveCwd(bundle, { repoRoot: options.repoRoot, worktreeCwd: options.worktreeCwd });
|
|
615
|
-
const template =
|
|
616
|
-
|
|
666
|
+
const template = isReconciliation
|
|
667
|
+
? RECONCILIATION_ACCEPTANCE_TEMPLATE
|
|
668
|
+
: lookupAcceptanceTemplate(strategy, subGate);
|
|
669
|
+
// Lightweight PR-body-as-spec: retarget the phase-doc criterion
|
|
617
670
|
// text to the PR description. Null/phase_doc leaves the criteria untouched, so
|
|
618
671
|
// the non-lightweight path stays byte-identical.
|
|
619
672
|
const specSource = deriveSpecSource(bundle, resolverOutput);
|
|
@@ -623,7 +676,7 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
623
676
|
? { ...options.overrides }
|
|
624
677
|
: undefined;
|
|
625
678
|
|
|
626
|
-
// Sanctioned operation → wrapper command map
|
|
679
|
+
// Sanctioned operation → wrapper command map. Core is
|
|
627
680
|
// consumer-agnostic: it carries whatever map the consumer supplies (the
|
|
628
681
|
// `loop build-envelope` CLI injects this repo's scripts/... paths) so every
|
|
629
682
|
// spawned subagent receives it by DEFAULT. Core defines the SHAPE only —
|
|
@@ -632,7 +685,7 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
632
685
|
? options.sanctionedCommands
|
|
633
686
|
: undefined;
|
|
634
687
|
|
|
635
|
-
// Surface the *effective* async-start posture alongside the *configured* one
|
|
688
|
+
// Surface the *effective* async-start posture alongside the *configured* one. The
|
|
636
689
|
// configured `asyncStartMode` is echoed verbatim from settings (back-compat), but the contract
|
|
637
690
|
// is relaxed at validation time under the Claude harness (resolveEffectiveAsyncStartMode →
|
|
638
691
|
// "allowed" when CLAUDECODE=1). Without surfacing the effective value, a `required` envelope
|
|
@@ -650,6 +703,8 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
650
703
|
maxCopilotRounds: settings?.refinement?.maxCopilotRounds ?? 5,
|
|
651
704
|
executionMode,
|
|
652
705
|
|
|
706
|
+
...(isReconciliation ? { routeKind, selectedStrategy: null } : {}),
|
|
707
|
+
|
|
653
708
|
nextAction,
|
|
654
709
|
requiredReads,
|
|
655
710
|
|
|
@@ -660,7 +715,7 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
660
715
|
requireDraftFirst: settings?.workflow?.requireDraftFirst ?? false,
|
|
661
716
|
|
|
662
717
|
cwd: derivedCwd,
|
|
663
|
-
worktreeRequired:
|
|
718
|
+
worktreeRequired: !isReconciliation,
|
|
664
719
|
|
|
665
720
|
acceptance: {
|
|
666
721
|
criteria: acceptanceCriteria,
|
|
@@ -686,21 +741,21 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
686
741
|
envelope.sanctionedCommands = sanctionedCommands;
|
|
687
742
|
}
|
|
688
743
|
|
|
689
|
-
// Advisory retrospective findings
|
|
744
|
+
// Advisory retrospective findings. Optional structured
|
|
690
745
|
// field carrying the check-retro-tooling.mjs JSON output to the conductor. Never a
|
|
691
746
|
// gate — the conductor surfaces these as an advisory PR comment, not a block.
|
|
692
747
|
if (retrospectiveFindings) {
|
|
693
748
|
envelope.retrospectiveFindings = retrospectiveFindings;
|
|
694
749
|
}
|
|
695
750
|
|
|
696
|
-
// Canonical spec source
|
|
751
|
+
// Canonical spec source. Optional: only set when the resolver
|
|
697
752
|
// marks a lightweight PR-body-as-spec session, so the default (phase-doc) path
|
|
698
753
|
// carries no specSource field and its envelope stays byte-identical.
|
|
699
754
|
if (specSource) {
|
|
700
755
|
envelope.specSource = specSource;
|
|
701
756
|
}
|
|
702
757
|
|
|
703
|
-
//
|
|
758
|
+
// The ONLY per-round-varying block, kept LAST. Every field here changes
|
|
704
759
|
// between builds/rounds (the timestamp, the head SHA, CI status, thread/round
|
|
705
760
|
// counts); isolating them as the envelope's tail keeps everything above a
|
|
706
761
|
// byte-stable prefix that a fresh reviewer spawn can cache-READ instead of
|
|
@@ -832,6 +887,58 @@ export function validateHandoffEnvelope(envelope) {
|
|
|
832
887
|
});
|
|
833
888
|
}
|
|
834
889
|
|
|
890
|
+
// ----- terminal reconciliation tuple -----
|
|
891
|
+
// routeKind/selectedStrategy are omitted for ordinary routed envelopes to
|
|
892
|
+
// preserve the v1 shape. If either is present, both must identify the one
|
|
893
|
+
// supported no-strategy terminal envelope exactly; serialized/tampered
|
|
894
|
+
// envelopes receive the same fail-closed enforcement as the builder.
|
|
895
|
+
const hasRouteKind = Object.hasOwn(envelope, "routeKind");
|
|
896
|
+
const hasSelectedStrategy = Object.hasOwn(envelope, "selectedStrategy");
|
|
897
|
+
if (hasRouteKind || hasSelectedStrategy || envelope.currentGate === "fail_closed_reconcile") {
|
|
898
|
+
const reconciliationStopRulesAreCanonical = Array.isArray(envelope.stopRules)
|
|
899
|
+
&& envelope.stopRules[0] === "reconcile"
|
|
900
|
+
&& (envelope.stopRules.length === 1
|
|
901
|
+
|| (envelope.stopRules.length === 2 && envelope.stopRules[1] === "merge"));
|
|
902
|
+
// Routing invariants only: acceptance prose remains the resolver's
|
|
903
|
+
// business, while nextAction must begin with one of the known fail-closed
|
|
904
|
+
// directives rather than arbitrary non-empty text.
|
|
905
|
+
const reconciliationCriterion = envelope.acceptance?.criteria;
|
|
906
|
+
const reconciliationAcceptanceIsCanonical = Array.isArray(reconciliationCriterion)
|
|
907
|
+
&& reconciliationCriterion.some((criterion) => criterion?.id === "reconcile" && criterion?.severity === "required");
|
|
908
|
+
const normalizedNextAction = typeof envelope.nextAction === "string" ? envelope.nextAction.trim() : "";
|
|
909
|
+
const reconciliationNextActionIsActionable = [
|
|
910
|
+
"Reconcile ",
|
|
911
|
+
"Stop and reconcile ",
|
|
912
|
+
"Complete or explicitly skip ",
|
|
913
|
+
"Local implementation requires worktree isolation",
|
|
914
|
+
].some((directive) => normalizedNextAction.startsWith(directive));
|
|
915
|
+
if (
|
|
916
|
+
envelope.routeKind !== "needs_reconcile"
|
|
917
|
+
|| envelope.selectedStrategy !== null
|
|
918
|
+
|| envelope.currentGate !== "fail_closed_reconcile"
|
|
919
|
+
|| envelope.worktreeRequired !== false
|
|
920
|
+
|| !reconciliationStopRulesAreCanonical
|
|
921
|
+
|| !reconciliationAcceptanceIsCanonical
|
|
922
|
+
|| !reconciliationNextActionIsActionable
|
|
923
|
+
) {
|
|
924
|
+
errors.push({
|
|
925
|
+
field: "routeKind/selectedStrategy/currentGate/worktreeRequired/stopRules/acceptance.criteria",
|
|
926
|
+
reason: "terminal reconciliation must use the exact needs_reconcile / null / fail_closed_reconcile tuple, require no worktree, instruct reconciliation, stop at reconcile (plus optional merge), and carry a required reconcile acceptance criterion",
|
|
927
|
+
got: {
|
|
928
|
+
routeKind: envelope.routeKind,
|
|
929
|
+
selectedStrategy: envelope.selectedStrategy,
|
|
930
|
+
currentGate: envelope.currentGate,
|
|
931
|
+
worktreeRequired: envelope.worktreeRequired,
|
|
932
|
+
stopRules: envelope.stopRules,
|
|
933
|
+
nextAction: envelope.nextAction,
|
|
934
|
+
acceptanceCriteria: envelope.acceptance?.criteria,
|
|
935
|
+
acceptanceEvidence: envelope.acceptance?.evidence,
|
|
936
|
+
maxFinalizationTurns: envelope.acceptance?.maxFinalizationTurns,
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
835
942
|
// ----- requiredReads -----
|
|
836
943
|
if (!Array.isArray(envelope.requiredReads)) {
|
|
837
944
|
errors.push({ field: "requiredReads", reason: "must be an array", got: envelope.requiredReads });
|
|
@@ -931,7 +1038,7 @@ export function validateHandoffEnvelope(envelope) {
|
|
|
931
1038
|
});
|
|
932
1039
|
}
|
|
933
1040
|
|
|
934
|
-
// ----- asyncStartEffective (required field; the harness-resolved posture
|
|
1041
|
+
// ----- asyncStartEffective (required field; the harness-resolved posture) -----
|
|
935
1042
|
if (envelope.asyncStartEffective === undefined || envelope.asyncStartEffective === null) {
|
|
936
1043
|
errors.push({
|
|
937
1044
|
field: "asyncStartEffective",
|
|
@@ -946,7 +1053,7 @@ export function validateHandoffEnvelope(envelope) {
|
|
|
946
1053
|
});
|
|
947
1054
|
}
|
|
948
1055
|
|
|
949
|
-
// ----- retrospectiveFindings (optional, advisory
|
|
1056
|
+
// ----- retrospectiveFindings (optional, advisory) -----
|
|
950
1057
|
if (envelope.retrospectiveFindings !== undefined && envelope.retrospectiveFindings !== null) {
|
|
951
1058
|
const rf = envelope.retrospectiveFindings;
|
|
952
1059
|
if (typeof rf !== "object" || Array.isArray(rf)) {
|
|
@@ -968,7 +1075,7 @@ export function validateHandoffEnvelope(envelope) {
|
|
|
968
1075
|
}
|
|
969
1076
|
}
|
|
970
1077
|
|
|
971
|
-
// ----- specSource (optional —
|
|
1078
|
+
// ----- specSource (optional — lightweight PR-body-as-spec) -----
|
|
972
1079
|
if (envelope.specSource !== undefined && envelope.specSource !== null) {
|
|
973
1080
|
const validSources = [CANONICAL_SPEC_SOURCE.PHASE_DOC, CANONICAL_SPEC_SOURCE.PR_BODY];
|
|
974
1081
|
if (typeof envelope.specSource !== "string" || !validSources.includes(envelope.specSource)) {
|
|
@@ -980,8 +1087,8 @@ export function validateHandoffEnvelope(envelope) {
|
|
|
980
1087
|
}
|
|
981
1088
|
}
|
|
982
1089
|
|
|
983
|
-
// ----- gateState.derivedAt (informational, warn on missing) —
|
|
984
|
-
// volatile timestamp
|
|
1090
|
+
// ----- gateState.derivedAt (informational, warn on missing) — the
|
|
1091
|
+
// volatile timestamp lives in the gateState tail so the rest stays byte-stable -----
|
|
985
1092
|
if (typeof envelope.gateState?.derivedAt !== "string" || !envelope.gateState.derivedAt.trim()) {
|
|
986
1093
|
warnings.push({ field: "gateState.derivedAt", reason: "should be an ISO 8601 timestamp" });
|
|
987
1094
|
}
|