@dzhechkov/harness-core 0.5.0 → 0.5.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.
Files changed (96) hide show
  1. package/.dz-manifest.json +339 -75
  2. package/README.md +47 -4
  3. package/dist/codex-hooks-assets.d.ts.map +1 -1
  4. package/dist/codex-hooks-assets.js +39 -2
  5. package/dist/codex-hooks-assets.js.map +1 -1
  6. package/dist/codex-hooks-verify.d.ts +23 -2
  7. package/dist/codex-hooks-verify.d.ts.map +1 -1
  8. package/dist/codex-hooks-verify.js +29 -0
  9. package/dist/codex-hooks-verify.js.map +1 -1
  10. package/dist/codex-hooks.d.ts +90 -7
  11. package/dist/codex-hooks.d.ts.map +1 -1
  12. package/dist/codex-hooks.js +171 -21
  13. package/dist/codex-hooks.js.map +1 -1
  14. package/dist/feature-adr-routing.d.ts +22 -0
  15. package/dist/feature-adr-routing.d.ts.map +1 -1
  16. package/dist/feature-adr-routing.js +45 -0
  17. package/dist/feature-adr-routing.js.map +1 -1
  18. package/dist/index.d.ts +11 -4
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +17 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/loop-blobs.generated.d.ts +1 -1
  23. package/dist/loop-blobs.generated.d.ts.map +1 -1
  24. package/dist/loop-blobs.generated.js +12 -3
  25. package/dist/loop-blobs.generated.js.map +1 -1
  26. package/dist/loop-plan.d.ts +70 -0
  27. package/dist/loop-plan.d.ts.map +1 -1
  28. package/dist/loop-plan.js +103 -0
  29. package/dist/loop-plan.js.map +1 -1
  30. package/dist/loop-render.d.ts.map +1 -1
  31. package/dist/loop-render.js +38 -130
  32. package/dist/loop-render.js.map +1 -1
  33. package/dist/loop-run-semantics.d.ts +130 -0
  34. package/dist/loop-run-semantics.d.ts.map +1 -0
  35. package/dist/loop-run-semantics.js +257 -0
  36. package/dist/loop-run-semantics.js.map +1 -0
  37. package/dist/loop-trace.d.ts +106 -5
  38. package/dist/loop-trace.d.ts.map +1 -1
  39. package/dist/loop-trace.js +151 -18
  40. package/dist/loop-trace.js.map +1 -1
  41. package/dist/managed-hooks.d.ts +10 -0
  42. package/dist/managed-hooks.d.ts.map +1 -1
  43. package/dist/managed-hooks.js +17 -5
  44. package/dist/managed-hooks.js.map +1 -1
  45. package/dist/named-lock.d.ts +57 -0
  46. package/dist/named-lock.d.ts.map +1 -0
  47. package/dist/named-lock.js +247 -0
  48. package/dist/named-lock.js.map +1 -0
  49. package/dist/operations.d.ts +81 -5
  50. package/dist/operations.d.ts.map +1 -1
  51. package/dist/operations.js +356 -38
  52. package/dist/operations.js.map +1 -1
  53. package/dist/parity.d.ts +70 -2
  54. package/dist/parity.d.ts.map +1 -1
  55. package/dist/parity.js +133 -2
  56. package/dist/parity.js.map +1 -1
  57. package/dist/qe-bridge.d.ts +291 -0
  58. package/dist/qe-bridge.d.ts.map +1 -0
  59. package/dist/qe-bridge.js +538 -0
  60. package/dist/qe-bridge.js.map +1 -0
  61. package/dist/score.d.ts.map +1 -1
  62. package/dist/score.js +43 -9
  63. package/dist/score.js.map +1 -1
  64. package/dist/trace-corroborate.d.ts +48 -0
  65. package/dist/trace-corroborate.d.ts.map +1 -0
  66. package/dist/trace-corroborate.js +172 -0
  67. package/dist/trace-corroborate.js.map +1 -0
  68. package/dist/workflow-run-dispatch.d.ts +230 -0
  69. package/dist/workflow-run-dispatch.d.ts.map +1 -0
  70. package/dist/workflow-run-dispatch.js +363 -0
  71. package/dist/workflow-run-dispatch.js.map +1 -0
  72. package/dist/workflow-run.d.ts +513 -0
  73. package/dist/workflow-run.d.ts.map +1 -0
  74. package/dist/workflow-run.js +1377 -0
  75. package/dist/workflow-run.js.map +1 -0
  76. package/package.json +2 -2
  77. package/sbom.json +740 -80
  78. package/src/codex-hooks-assets.ts +39 -2
  79. package/src/codex-hooks-verify.ts +55 -2
  80. package/src/codex-hooks.ts +172 -20
  81. package/src/feature-adr-routing.ts +55 -0
  82. package/src/index.ts +46 -1
  83. package/src/loop-blobs.generated.ts +12 -3
  84. package/src/loop-plan.ts +185 -0
  85. package/src/loop-render.ts +38 -128
  86. package/src/loop-run-semantics.ts +278 -0
  87. package/src/loop-trace.ts +207 -16
  88. package/src/managed-hooks.ts +26 -5
  89. package/src/named-lock.ts +277 -0
  90. package/src/operations.ts +441 -40
  91. package/src/parity.ts +177 -2
  92. package/src/qe-bridge.ts +737 -0
  93. package/src/score.ts +50 -9
  94. package/src/trace-corroborate.ts +205 -0
  95. package/src/workflow-run-dispatch.ts +459 -0
  96. package/src/workflow-run.ts +1773 -0
package/src/score.ts CHANGED
@@ -64,15 +64,28 @@ function evidenceLine(text: string, re: RegExp): string | null {
64
64
  * heuristic table). A line whose match is preceded by a negation word is skipped. Heuristic — but
65
65
  * the failure mode flips from a silent false pass to a visible miss the shown evidence exposes.
66
66
  */
67
+ // The DEFAULT vocabulary — verbs and determiners that deny the sentence they sit in.
67
68
  const NEGATION_RE = /\b(no|not|never|without|wasn'?t|isn'?t)\b/i;
68
- function evidenceLinePositive(text: string, re: RegExp): string | null {
69
+
70
+ /**
71
+ * The default vocabulary plus the negative QUANTIFIERS. Opt-in per site, because a quantifier
72
+ * negates a NOUN, not the claim: "None of the mutants survived; discrimination §42 is proven by the
73
+ * red run" is idiomatic POSITIVE evidence that the wide list silently discarded (QE B-F2 —
74
+ * negating-the-mutants is not negating-the-proof). It is passed only where a red test demanded it:
75
+ * "Nothing was MEASURED in this round" scored as proof of measurement, because the word boundary in
76
+ * `\bno\b` does NOT match "Nothing". Hedges like "skipped" stay out of both lists — they routinely
77
+ * appear inside genuine evidence lines.
78
+ */
79
+ const NEGATION_QUANTIFIED_RE = /\b(no|not|never|without|nothing|none|neither|nor|nobody|wasn'?t|isn'?t)\b/i;
80
+
81
+ function evidenceLinePositive(text: string, re: RegExp, negationRe: RegExp = NEGATION_RE): string | null {
69
82
  for (const line of text.split('\n')) {
70
83
  if (!re.test(line)) continue;
71
84
  // Whole-line negation: "Codex was NOT used" carries its negation AFTER the match, so a
72
85
  // before-the-match check missed it. The trade is deliberate: a genuine line that happens to
73
86
  // contain a negation is SKIPPED (a visible miss the evidence exposes) rather than a negated
74
87
  // line being ACCEPTED (a silent false pass).
75
- if (NEGATION_RE.test(line)) continue;
88
+ if (negationRe.test(line)) continue;
76
89
  return line.trim().slice(0, 140);
77
90
  }
78
91
  return null;
@@ -104,16 +117,25 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
104
117
  if (adrText === '') {
105
118
  add('adr-confirmation', 'ADR present, property → named test', 'absent', 'no 03_adr/*.md artifact');
106
119
  } else {
107
- const conf = evidenceLine(adrText, /^##+\s*Confirmation/i);
120
+ // POSITIVE (QE B-F2 reversed my first call, which exempted this site as "structural"). The
121
+ // heading regex allows a SUFFIX, so `## Confirmation — not yet performed` — the realistic
122
+ // placeholder an unfinished ADR carries — scored a full PASS. Heading presence is structural;
123
+ // heading TEXT is not, and this one can deny itself. Default (narrow) vocabulary: a heading is
124
+ // a fragment, and the quantifiers only appear in prose. Pinned both ways by tests.
125
+ const conf = evidenceLinePositive(adrText, /^##+\s*Confirmation/i);
108
126
  add(
109
127
  'adr-confirmation',
110
128
  'ADR present, property → named test',
111
129
  conf !== null ? 'pass' : 'partial',
112
- conf ?? 'ADR exists but has no Confirmation section — the load-bearing property names no test',
130
+ conf ?? 'ADR exists but has no (non-negated) Confirmation heading — the load-bearing property names no test',
113
131
  );
114
132
  }
115
133
 
116
134
  // 2. Discrimination — proof the test can FAIL (the §42 gate, or an explicit mutation proof).
135
+ // Default (narrow) vocabulary ON PURPOSE (QE B-F2): mutation evidence is written by negating the
136
+ // MUTANTS — "None of the mutants survived", "neither mutant escaped" — which is the proof, not
137
+ // its denial. The quantifiers would discard exactly the strongest lines this discipline exists
138
+ // to find. "No discrimination proof was performed" is still caught by the narrow list.
117
139
  const discr =
118
140
  evidenceLinePositive(allText, /discrimination|§42/i) ??
119
141
  evidenceLinePositive(allText, /mutation[s]?\s.*(prov|kill)|mutant[s]?\s.*(kill|red)|RED on the old|goes? RED|failed as expected/i);
@@ -130,6 +152,13 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
130
152
  add('cross-model-qe', 'independent cross-model review with a grade', 'absent', 'no 08_qe_report.md artifact');
131
153
  } else {
132
154
  const crossLine = evidenceLinePositive(qeText, /codex|gpt-|cross-model/i);
155
+ // EXEMPT from the negation filter (wave1-scorer-negation, per-site review): this line is a
156
+ // DISPLAY LOCATOR, not a verdict input — the verdict above rests on `crossLine` (already
157
+ // positive-filtered) AND on `grade`, parsed from the whole report. A letter grade is a
158
+ // structural token ("Grade: D"); there is no idiomatic "no Grade: D". Filtering here would only
159
+ // drop the most common real grade line ("**Grade: B** — no blockers remain") from the shown
160
+ // evidence for zero change in verdict. Pinned by a test. Residual, flagged not hidden:
161
+ // `extractQeGrade` itself is negation-blind and stays so — out of FR-B1's scope.
133
162
  const gradeLine = grade !== null ? evidenceLine(qeText, GRADE_RE) : null;
134
163
  add(
135
164
  'cross-model-qe',
@@ -145,8 +174,11 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
145
174
 
146
175
  // 4. Live verification — the property was observed, not inferred. The health-advisor 1.2.0 QE
147
176
  // report is the cautionary case: "✅ (mechanism)" with no live evidence shipped a dead feature.
148
- const live = evidenceLine(qeText, /MEASURED|verified live|VERIFIED LIVE|reproducer/i);
149
- const liveAnywhere = live ?? evidenceLine(allText, /MEASURED|verified live|VERIFIED LIVE|reproducer/i);
177
+ // POSITIVE (wave1-scorer-negation): "nothing was MEASURED" / "no reproducer was run" is the
178
+ // claim's exact opposite and used to score as proof of it (the crossrt-1 6/7 shape).
179
+ const live = evidenceLinePositive(qeText, /MEASURED|verified live|VERIFIED LIVE|reproducer/i, NEGATION_QUANTIFIED_RE);
180
+ const liveAnywhere =
181
+ live ?? evidenceLinePositive(allText, /MEASURED|verified live|VERIFIED LIVE|reproducer/i, NEGATION_QUANTIFIED_RE);
150
182
  add(
151
183
  'live-verification',
152
184
  'claims verified by running, not by reasoning',
@@ -155,7 +187,10 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
155
187
  );
156
188
 
157
189
  // 5. README-first — the docs travelled in the same change.
158
- const readme = evidenceLine(qeText + '\n' + manifestText, /README/);
190
+ // POSITIVE (wave1-scorer-negation): THE acid-A5 defect. crossrt-1-agents-md scored here over
191
+ // its own finding "README-first not satisfied — no README was touched": a negation rendered as
192
+ // a checkmark. A README mention is not a README update.
193
+ const readme = evidenceLinePositive(qeText + '\n' + manifestText, /README/, NEGATION_QUANTIFIED_RE);
159
194
  add(
160
195
  'readme-first',
161
196
  'READMEs updated in the same change',
@@ -164,8 +199,14 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
164
199
  );
165
200
 
166
201
  // 6. The learning loop — Step-0 recall folded in, Step-8 lessons taught.
167
- const recalled = evidenceLine(complexityText + '\n' + allText, /LEARNED_PATTERNS|dz recall|recalled/i);
168
- const taught = evidenceLine(allText, /lesson[s]? taught|dz teach|taught \(/i);
202
+ // POSITIVE both halves (wave1-scorer-negation): "recall was not performed" and "no lessons were
203
+ // taught (dz teach skipped)" both matched the plain regexes and scored the loop as RUN.
204
+ const recalled = evidenceLinePositive(
205
+ complexityText + '\n' + allText,
206
+ /LEARNED_PATTERNS|dz recall|recalled/i,
207
+ NEGATION_QUANTIFIED_RE,
208
+ );
209
+ const taught = evidenceLinePositive(allText, /lesson[s]? taught|dz teach|taught \(/i, NEGATION_QUANTIFIED_RE);
169
210
  add(
170
211
  'learning-loop',
171
212
  'Step-0 recall used; Step-8 lessons taught',
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `trace-corroborate` — the Claude host's OWN records, for the half of a trace they can witness.
3
+ *
4
+ * ADR-002. Pure over already-read strings: no `fs`, so it is testable with fixtures alone.
5
+ *
6
+ * The design is shaped by one MEASURED fact and one refuted design. The fact: the trace and the
7
+ * host's `journal.jsonl` share NO identifier — trace dispatch events carry `invocationId` /
8
+ * `stepId`, the journal carries `agentId` and a `v2:<sha>` key. There is no run nonce to bind them
9
+ * with, and we do not control the host's format, so DIRECTORY CONTAINMENT is the only binding
10
+ * available and every result says so. The refuted design: a bare `agrees` over "agent set +
11
+ * wall-clock order", which a cross-family reviewer defeated with a trace that matched on agents
12
+ * while inventing a join, a gate redo, a typed pause and a file deliverable — every consequential
13
+ * claim fabricated, the verdict green. Hence `agreesWithinScope`, and hence `notWitnessed` being
14
+ * non-empty BY TYPE rather than by discipline.
15
+ */
16
+
17
+ /** Non-empty by construction: a tuple type, so no edit can empty it and silently unscope a result. */
18
+ export type NotWitnessed = readonly ['join', 'gate-redo', 'typed-pause', 'file-deliverable'];
19
+ export const NOT_WITNESSED: NotWitnessed = ['join', 'gate-redo', 'typed-pause', 'file-deliverable'] as const;
20
+
21
+ export type WitnessedClaim = 'agent-multiset' | 'agent-count' | 'wall-clock-order';
22
+ export const WITNESSED: readonly WitnessedClaim[] = ['agent-multiset', 'agent-count', 'wall-clock-order'] as const;
23
+
24
+ /** Deliberately NOT `agrees`. The scope lives in the word, so a stored verdict carries it too. */
25
+ export type CorroborationVerdict = 'agreesWithinScope' | 'disagrees' | 'inconclusive';
26
+
27
+ export interface CorroborationResult {
28
+ verdict: CorroborationVerdict;
29
+ /** The ONLY binding available — see the module note. Never omitted. */
30
+ binding: 'by-directory';
31
+ hostDir: string;
32
+ witnessed: readonly WitnessedClaim[];
33
+ notWitnessed: NotWitnessed;
34
+ /** Why, in words, for the human-readable report. */
35
+ detail: string;
36
+ /** Counts, so a caller can render the disagreement rather than re-deriving it. */
37
+ traceAgentCount: number;
38
+ hostAgentCount: number;
39
+ }
40
+
41
+ /** One host record set, already read from disk by the caller. */
42
+ export interface HostRecords {
43
+ /** Raw `journal.jsonl` text, or null when the file is absent/unreadable. */
44
+ journal: string | null;
45
+ /** Raw `agent-<id>.jsonl` texts, keyed by agent id. Empty when none were found. */
46
+ agentTranscripts: Record<string, string>;
47
+ }
48
+
49
+ /** The trace side, projected by the caller: the agent ids the trace claims took part, in order. */
50
+ export interface TraceAgentProjection {
51
+ agentIds: string[];
52
+ }
53
+
54
+ /**
55
+ * Read the agent ids the host journal STARTED, or null when the journal cannot be trusted to be
56
+ * complete. QE round 1 closed three ways this used to lie:
57
+ * H4 — a `started` row whose `agentId` is not a string was silently DROPPED, so a malformed row
58
+ * that may well represent a real extra agent made the sets look equal.
59
+ * M2 — a non-empty journal with zero usable start rows returned `[]`, and an empty trace then
60
+ * "agreed" with it. A journal that records no starts is not evidence that none happened.
61
+ * M3 — `JSON.parse('null')` is valid JSON, and indexing the result threw.
62
+ */
63
+ function journalAgentIds(journal: string): string[] | null {
64
+ const ids: string[] = [];
65
+ let sawAny = false;
66
+ for (const line of journal.split('\n')) {
67
+ const t = line.trim();
68
+ if (t === '') continue;
69
+ sawAny = true;
70
+ let o: unknown;
71
+ try {
72
+ o = JSON.parse(t);
73
+ } catch {
74
+ // A malformed journal is INCONCLUSIVE, not "the agents we could still parse". A partially
75
+ // readable independent record is not an independent record.
76
+ return null;
77
+ }
78
+ // M3: `null`, a number and a string are all valid JSON and none of them are records.
79
+ if (typeof o !== 'object' || o === null || Array.isArray(o)) return null;
80
+ const rec = o as Record<string, unknown>;
81
+ if (rec['type'] !== 'started') continue;
82
+ // H4: a start row we cannot read is a start row we cannot account for.
83
+ if (typeof rec['agentId'] !== 'string' || rec['agentId'] === '') return null;
84
+ ids.push(rec['agentId']);
85
+ }
86
+ // M2: a non-empty journal that yielded no starts tells us nothing about how many agents ran.
87
+ if (!sawAny || ids.length === 0) return null;
88
+ return ids;
89
+ }
90
+
91
+ /**
92
+ * The FIRST readable timestamp in a transcript, or null when the answer cannot be trusted.
93
+ *
94
+ * QE round 1 / M1: this used to skip unparseable lines and keep scanning. But an unparseable line
95
+ * EARLIER in the file may carry an earlier timestamp — so a corrupt prefix does not just cost us a
96
+ * line, it invalidates the whole "first" claim. Returning the next readable stamp presents a
97
+ * possibly-late time as the earliest one, which is exactly how a wrong order reads as right.
98
+ */
99
+ function firstTimestamp(text: string): number | null {
100
+ for (const line of text.split('\n')) {
101
+ const t = line.trim();
102
+ if (t === '') continue;
103
+ let o: unknown;
104
+ try {
105
+ o = JSON.parse(t);
106
+ } catch {
107
+ return null; // a corrupt line before any stamp ⇒ the earliest is unknowable
108
+ }
109
+ if (typeof o !== 'object' || o === null || Array.isArray(o)) return null;
110
+ const ts = (o as Record<string, unknown>)['timestamp'];
111
+ if (typeof ts === 'string') {
112
+ const ms = Date.parse(ts);
113
+ if (Number.isFinite(ms)) return ms;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function multisetEqual(a: string[], b: string[]): boolean {
120
+ if (a.length !== b.length) return false;
121
+ const count = new Map<string, number>();
122
+ for (const x of a) count.set(x, (count.get(x) ?? 0) + 1);
123
+ for (const x of b) {
124
+ const n = count.get(x);
125
+ if (n === undefined || n === 0) return false;
126
+ count.set(x, n - 1);
127
+ }
128
+ return true;
129
+ }
130
+
131
+ export function corroborate(trace: TraceAgentProjection, host: HostRecords, hostDir: string): CorroborationResult {
132
+ const base = {
133
+ binding: 'by-directory' as const,
134
+ hostDir,
135
+ witnessed: WITNESSED,
136
+ notWitnessed: NOT_WITNESSED,
137
+ traceAgentCount: trace.agentIds.length,
138
+ };
139
+
140
+ if (host.journal === null) {
141
+ return { ...base, verdict: 'inconclusive', hostAgentCount: 0, detail: 'no host journal at ' + hostDir + ' — absent evidence is never agreement' };
142
+ }
143
+ const hostIds = journalAgentIds(host.journal);
144
+ if (hostIds === null) {
145
+ return { ...base, verdict: 'inconclusive', hostAgentCount: 0, detail: 'the host journal is empty or malformed — a partially readable record is not an independent one' };
146
+ }
147
+
148
+ // COUNT and MULTISET before ordering: a mismatch here is a real disagreement, and comparing the
149
+ // order of two different sets would be meaningless anyway.
150
+ if (!multisetEqual(trace.agentIds, hostIds)) {
151
+ return {
152
+ ...base,
153
+ verdict: 'disagrees',
154
+ hostAgentCount: hostIds.length,
155
+ detail: `the trace claims ${trace.agentIds.length} agent run(s), the host journal records ${hostIds.length}` +
156
+ (trace.agentIds.length === hostIds.length ? ' — same count, different ids' : ''),
157
+ };
158
+ }
159
+
160
+ // Wall-clock order, from the per-agent transcripts (the journal carries no ts). An agent with no
161
+ // readable timestamp makes the ORDER unwitnessable — inconclusive, not a pass on the rest.
162
+ //
163
+ // QE round 1 / M4: `host.agentTranscripts[id]` walks the PROTOTYPE, so an agent literally named
164
+ // `__proto__` (or `constructor`, `toString`, …) read a function off Object.prototype and threw.
165
+ // An own-property check is the fix; the id is data from an outside file and must be treated as such.
166
+ const own = (o: Record<string, string>, k: string): string | undefined =>
167
+ Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined;
168
+
169
+ const stamps: Array<{ id: string; at: number }> = [];
170
+ for (const id of hostIds) {
171
+ const text = own(host.agentTranscripts, id);
172
+ const at = typeof text !== 'string' ? null : firstTimestamp(text);
173
+ if (at === null) {
174
+ return { ...base, verdict: 'inconclusive', hostAgentCount: hostIds.length, detail: `no readable timestamp for agent ${id} — the wall-clock order cannot be witnessed` };
175
+ }
176
+ stamps.push({ id, at });
177
+ }
178
+
179
+ // QE round 1 / H3: two agents sharing a timestamp make their relative order UNKNOWABLE. A stable
180
+ // sort preserved the journal's own order and reported agreement — the sort's tie-breaking rule
181
+ // was silently doing duty as evidence.
182
+ const sorted = [...stamps].sort((a, b) => a.at - b.at);
183
+ for (let i = 1; i < sorted.length; i++) {
184
+ if (sorted[i]!.at === sorted[i - 1]!.at) {
185
+ return { ...base, verdict: 'inconclusive', hostAgentCount: hostIds.length, detail: `agents ${sorted[i - 1]!.id} and ${sorted[i]!.id} share a timestamp — their relative order cannot be witnessed` };
186
+ }
187
+ }
188
+
189
+ // QE round 1 / H2: compare ELEMENT-WISE. `join('|')` collapsed ['x','x|x'] and ['x|x','x'] to the
190
+ // same string, so a reversed order read as agreement — a delimiter chosen for display doing duty
191
+ // as an equality operator.
192
+ const byClock = sorted.map((sv) => sv.id);
193
+ const orderMatches = byClock.length === trace.agentIds.length && byClock.every((id, i) => id === trace.agentIds[i]);
194
+ if (!orderMatches) {
195
+ return { ...base, verdict: 'disagrees', hostAgentCount: hostIds.length, detail: 'the host wall-clock order of the agents differs from the order the trace claims' };
196
+ }
197
+
198
+ return {
199
+ ...base,
200
+ verdict: 'agreesWithinScope',
201
+ hostAgentCount: hostIds.length,
202
+ detail: 'the host records agree on which agents ran and in what order. They CANNOT witness ' +
203
+ NOT_WITNESSED.join(', ') + ' — a fabricated one of those would still land here, which is why this is never a bare "agrees"',
204
+ };
205
+ }