@gethmy/harness 1.2.0 → 1.3.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.
@@ -1,7 +1,17 @@
1
1
  /**
2
- * Evidence collector for the `oracle_passed` gate. It runs only AFTER the stage's
3
- * subagent has exited (see stage-run.ts): the held test and a model must never
4
- * share a filesystem window.
2
+ * Evidence collectors for the two held-test gates `oracle_passed` (green: the
3
+ * held test must pass) and `oracle_red` (#921, red: it must run and fail). Both
4
+ * run only AFTER the stage's subagent has exited (see stage-run.ts): the held
5
+ * test and a model must never share a filesystem window.
6
+ *
7
+ * ONE held test serves both. The author stage writes it keyed to the downstream
8
+ * stage whose gate is `oracle_passed`; the red gate reads that same row one stage
9
+ * earlier (`OracleDeps.targetStageId`). So a `Fix a Bug` chain is red-green over a
10
+ * single contract: the author's test must fail before the fix and pass after it,
11
+ * and the implementer never sees it in either direction.
12
+ *
13
+ * The two polarities are NOT mirror images, and `HeldOracleCollector`'s doc
14
+ * comment explains where they diverge and why the divergence is one-directional.
5
15
  *
6
16
  * It produces evidence and nothing else — `gateEvaluate` in @harmony/shared turns
7
17
  * it into a verdict. A missing oracle is `blocked`, never `passed`: a gate with no
@@ -27,28 +37,89 @@ import type {
27
37
  GateEvidenceCollector,
28
38
  GateEvidenceContext,
29
39
  } from "@harmony/shared";
40
+ import { GATE_CONFIG_ERROR_MARK } from "./gate-config-error.js";
30
41
  import { log } from "./log.js";
31
- import type { HeldOracle, OracleDeps } from "./oracle.js";
42
+ import {
43
+ gradeOracleRed,
44
+ type HeldOracle,
45
+ type OracleDeps,
46
+ type OracleRunSummary,
47
+ } from "./oracle.js";
32
48
 
33
49
  const TAG = "oracle-collector";
34
50
 
35
- export class OracleCollector implements GateEvidenceCollector {
36
- readonly kind = "oracle_passed" as const;
51
+ /** The digest identifying the exact contract a verdict graded (card #927). */
52
+ interface OracleIdentity {
53
+ oracleId: string | null;
54
+ contentHash: string;
55
+ }
56
+
57
+ /**
58
+ * The shared half of both held-test gates: fetch the oracle, place it, run it,
59
+ * remove it — always. Only the two things that genuinely differ per polarity are
60
+ * abstract:
61
+ *
62
+ * - {@link oracleStageId} — WHICH stage the oracle row is keyed to. The green
63
+ * gate's own stage IS the target; the red gate's is one stage earlier.
64
+ * - {@link verdict} — how a completed run is graded.
65
+ *
66
+ * Everything else is deliberately NOT duplicated. `removeBestEffort` and the
67
+ * `finally` that calls it are the security-critical part of this module (a held
68
+ * test left in the worktree gets auto-committed and hands the implementer the
69
+ * one file it must never see), so there is exactly one copy of them and both
70
+ * gates inherit it. A second polarity that quietly forgot the `finally` is the
71
+ * failure this shape exists to make impossible.
72
+ */
73
+ abstract class HeldOracleCollector {
74
+ constructor(protected readonly deps: OracleDeps) {}
37
75
 
38
- constructor(private readonly deps: OracleDeps) {}
76
+ /** Which stage's held oracle this gate grades. `null` ⇒ nothing to grade. */
77
+ protected abstract oracleStageId(context: GateEvidenceContext): string | null;
78
+
79
+ /** Grade a run that produced an exit code. Never sees a run that threw. */
80
+ protected abstract verdict(args: {
81
+ oracle: HeldOracle;
82
+ identity: OracleIdentity;
83
+ exitCode: number;
84
+ /** stdout + stderr merged — the operator's log. NOT the verdict channel. */
85
+ output: string;
86
+ /**
87
+ * The runner's own machine report, read from a file outside the worktree
88
+ * (#1019) — the red gate's only graded input. `null` ⇒ no trustworthy
89
+ * count, which that gate turns into `blocked`.
90
+ */
91
+ report: OracleRunSummary | null;
92
+ }): GateEvidence;
39
93
 
40
94
  async collect(context: GateEvidenceContext): Promise<GateEvidence> {
95
+ const stageId = this.oracleStageId(context);
96
+ if (!stageId) {
97
+ // Reached only by a red gate on a stage with no downstream
98
+ // `oracle_passed` stage — a playbook shape defect, not a fact about the
99
+ // card's code. So it carries the #823 config-error mark: re-running the
100
+ // stage cannot change the answer, and the engine should hold for a human
101
+ // instead of spending the card's whole attempt budget rediscovering it.
102
+ const reason =
103
+ `No stage downstream of ${context.stageId} declares an \`oracle_passed\` gate, ` +
104
+ "so there is no held test for this gate to grade. A red gate needs a later stage that runs the same test green.";
105
+ log.warn(TAG, `${reason} — blocked (config)`);
106
+ return {
107
+ result: "blocked",
108
+ structured: { reason, ...GATE_CONFIG_ERROR_MARK },
109
+ };
110
+ }
111
+
41
112
  const oracle = await this.deps.fetchOracle(
42
113
  context.cardId,
43
- context.stageId,
114
+ stageId,
44
115
  this.deps.sessionId,
45
116
  );
46
117
  if (!oracle) {
47
- log.info(TAG, `No oracle held for stage ${context.stageId} — blocked`);
118
+ log.info(TAG, `No oracle held for stage ${stageId} — blocked`);
48
119
  return {
49
120
  result: "blocked",
50
121
  structured: {
51
- reason: `No oracle is held for stage ${context.stageId}.`,
122
+ reason: `No oracle is held for stage ${stageId}.`,
52
123
  },
53
124
  };
54
125
  }
@@ -68,7 +139,7 @@ export class OracleCollector implements GateEvidenceCollector {
68
139
  };
69
140
  await this.deps.place(this.deps.repoPath, oracle);
70
141
  try {
71
- const { exitCode, output } = await this.deps.run(
142
+ const { exitCode, output, report } = await this.deps.run(
72
143
  this.deps.repoPath,
73
144
  oracle,
74
145
  );
@@ -81,18 +152,13 @@ export class OracleCollector implements GateEvidenceCollector {
81
152
  } else {
82
153
  log.warn(TAG, logLine);
83
154
  }
84
- return {
85
- result: exitCode === 0 ? "passed" : "failed",
86
- structured: {
87
- oracle: {
88
- exitCode,
89
- path: oracle.path,
90
- ...identity,
91
- output:
92
- "withheld — oracle_passed is a secrecy gate; see the motor's local log",
93
- },
94
- },
95
- };
155
+ return this.verdict({
156
+ oracle,
157
+ identity,
158
+ exitCode,
159
+ output,
160
+ report: report ?? null,
161
+ });
96
162
  } catch (err) {
97
163
  const message = errText(err);
98
164
  log.warn(TAG, `Oracle run threw: ${message} — blocked`);
@@ -157,6 +223,161 @@ export class OracleCollector implements GateEvidenceCollector {
157
223
  }
158
224
  }
159
225
 
226
+ /**
227
+ * The GREEN gate: `oracle_passed`. The held test must pass against the
228
+ * implementer's worktree.
229
+ *
230
+ * Grades on the exit code alone, unchanged since Task 12. That is safe here
231
+ * precisely because it is the conservative direction: a run that could not
232
+ * start exits non-zero and reads as `failed`, which under-grants. Do not
233
+ * "improve" this to consult the runner's summary — the red gate needs that
234
+ * because its safe direction is the opposite one, and giving both gates the
235
+ * same machinery would trade a proven behavior for a symmetry nobody needs.
236
+ */
237
+ export class OracleCollector
238
+ extends HeldOracleCollector
239
+ implements GateEvidenceCollector
240
+ {
241
+ readonly kind = "oracle_passed" as const;
242
+
243
+ /** The stage running this gate IS the stage the oracle is keyed to. */
244
+ protected oracleStageId(context: GateEvidenceContext): string | null {
245
+ return context.stageId;
246
+ }
247
+
248
+ protected verdict({
249
+ oracle,
250
+ identity,
251
+ exitCode,
252
+ }: {
253
+ oracle: HeldOracle;
254
+ identity: OracleIdentity;
255
+ exitCode: number;
256
+ output: string;
257
+ report: OracleRunSummary | null;
258
+ }): GateEvidence {
259
+ return {
260
+ result: exitCode === 0 ? "passed" : "failed",
261
+ structured: {
262
+ oracle: {
263
+ exitCode,
264
+ path: oracle.path,
265
+ ...identity,
266
+ output:
267
+ "withheld — oracle_passed is a secrecy gate; see the motor's local log",
268
+ },
269
+ },
270
+ };
271
+ }
272
+ }
273
+
274
+ /**
275
+ * The RED gate: `oracle_red` (#921). The same held test must RUN and FAIL
276
+ * against the current tree — the reproduction half of red-green.
277
+ *
278
+ * Why this is not `oracle_passed` with the verdict flipped: a red gate passes on
279
+ * failure, so anything it misreads as a failure it OVER-grants. And the exit code
280
+ * cannot tell a real failure from a runner that never started — measured against
281
+ * the real allow-listed runners, `npx --no-install vitest` with vitest absent,
282
+ * `bun test` on an unmatched path, and a genuine assertion failure all exit 1.
283
+ *
284
+ * So a pass here requires a positive count of failing tests from the runner's own
285
+ * machine report, and every case where that cannot be read is `blocked` with a
286
+ * reason naming which case it was. `blocked` can never satisfy a gate, so the
287
+ * gate is unsatisfiable exactly when it cannot be trusted.
288
+ *
289
+ * That report is read from a FILE the runner writes outside the worktree (#1019),
290
+ * never from the child's stdout or stderr. The held test shares those streams
291
+ * with the runner, and the drain window after the leader exits is precisely where
292
+ * a detached grandchild gets the last word — so no in-band parser could refuse
293
+ * the forgery.
294
+ *
295
+ * HOW FAR THAT GOES: it closes the stream forgeries outright, and it does NOT
296
+ * make the verdict unforgeable. The held test runs as the motor's own user and
297
+ * can write the report file itself — the directory is findable by a fixed name
298
+ * prefix, and pre-writing the report then dropping write permission needs no
299
+ * race at all. `runHeldOracle`'s mode check refuses that shape; a writer that
300
+ * leaves the modes alone still wins. This gate is evidence against a careless
301
+ * author, not a hostile one. `oracle.ts` has the full account, measurements
302
+ * included, above `OracleRunnerSpec`.
303
+ *
304
+ * Secrecy is inherited whole. The report is parsed in memory and only its two
305
+ * counts reach the persisted evidence — never the assertion text, which is itself
306
+ * a partial oracle. `runHeldOracle` removes the report directory on every path
307
+ * (best effort, and logged when it fails), which matters because vitest's JSON
308
+ * report carries `failureMessages` verbatim. The full output still goes to the
309
+ * motor's local log for a human operator, exactly as the green gate does.
310
+ */
311
+ export class OracleRedCollector
312
+ extends HeldOracleCollector
313
+ implements GateEvidenceCollector
314
+ {
315
+ readonly kind = "oracle_red" as const;
316
+
317
+ /**
318
+ * The DOWNSTREAM stage's id, not this stage's: the author writes one held test
319
+ * keyed to the stage that must later pass it green, and this gate reads that
320
+ * same row early. See `OracleDeps.targetStageId`.
321
+ */
322
+ protected oracleStageId(): string | null {
323
+ return this.deps.targetStageId ?? null;
324
+ }
325
+
326
+ protected verdict({
327
+ oracle,
328
+ identity,
329
+ exitCode,
330
+ report,
331
+ }: {
332
+ oracle: HeldOracle;
333
+ identity: OracleIdentity;
334
+ exitCode: number;
335
+ output: string;
336
+ report: OracleRunSummary | null;
337
+ }): GateEvidence {
338
+ // The runner's own MACHINE REPORT, and nothing else (#1019). There is
339
+ // deliberately no fallback to the merged buffer or to the runner's verdict
340
+ // stream: the held test shares both of those with the runner, and a
341
+ // fallback is all an attacker needs — make the report unreadable, and the
342
+ // gate drops back to a channel it can write. An unreadable report is
343
+ // `blocked`, which can never satisfy a gate.
344
+ //
345
+ // `output` stays in the signature because the base class logs it for the
346
+ // operator; this gate does not read it.
347
+ const graded = gradeOracleRed(report, exitCode);
348
+ const base = { exitCode, path: oracle.path, ...identity };
349
+
350
+ if (graded.outcome === "no_verdict") {
351
+ log.warn(TAG, `Red gate for ${oracle.path}: ${graded.reason} — blocked`);
352
+ return {
353
+ result: "blocked",
354
+ // NOT marked as a config error (#823 rule 1: opt in, and only for
355
+ // static config). A summary the motor could not read may well be a
356
+ // runner the next attempt resolves, or a transient tool failure — the
357
+ // retryable default is the honest one. The one genuinely static case,
358
+ // a red gate with no downstream green stage, is marked in `collect`.
359
+ structured: { oracle: base, reason: graded.reason },
360
+ };
361
+ }
362
+
363
+ // Counts only — never the assertion text. `total`/`failed` say THAT the
364
+ // held test failed, which is the verdict; they say nothing about WHAT it
365
+ // asserts, which is the secret.
366
+ return {
367
+ result: graded.outcome === "reproduced" ? "passed" : "failed",
368
+ structured: {
369
+ oracle: {
370
+ ...base,
371
+ testsRun: graded.summary.total,
372
+ testsFailed: graded.summary.failed,
373
+ output:
374
+ "withheld — oracle_red is a secrecy gate; see the motor's local log",
375
+ },
376
+ },
377
+ };
378
+ }
379
+ }
380
+
160
381
  function errText(err: unknown): string {
161
382
  return err instanceof Error ? err.message : String(err);
162
383
  }