harness-everything 0.37.0 → 0.38.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.
Files changed (31) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +16 -0
  4. package/ci/fixtures/doc-obligations/reconciliation-prep/cases.json +70 -11
  5. package/ci/fixtures/doc-obligations/reconciliation-prep/pending-red.json +24 -556
  6. package/ci/fixtures/doc-obligations/reconciliation-s1/cases.json +6842 -0
  7. package/ci/fixtures/doc-obligations/reconciliation-s1/fail-session-writes.js +116 -0
  8. package/ci/lib/doc-reconciliation-prep-fixtures.js +116 -10
  9. package/ci/mechanism-325-3b-preparation.test.js +8 -8
  10. package/ci/mechanism-325-3b-s1-review.test.js +831 -0
  11. package/ci/mechanism-325-3b-s1.test.js +188 -0
  12. package/ci/prep/doc-reconciliation-red.js +2 -1
  13. package/docs/doc-workflow-reconciliation.md +217 -26
  14. package/docs/document-lifecycle.md +4 -4
  15. package/harness-everything/SKILL.md +1 -1
  16. package/harness-everything/scripts/doc-lifecycle.js +1 -1
  17. package/hooks/scripts/lib/doc-reconciliation-pilot.js +152 -0
  18. package/hooks/scripts/lib/workflow-obligations.js +44 -0
  19. package/hooks/scripts/workflow-disposition.js +2 -0
  20. package/hooks/scripts/workflow-stop-gate.js +24 -1
  21. package/opencode-plugin/plugin.json +1 -1
  22. package/package.json +1 -1
  23. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  24. package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-pilot.js +152 -0
  25. package/plugins/harness-everything/hooks/scripts/lib/workflow-obligations.js +44 -0
  26. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +2 -0
  27. package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +24 -1
  28. package/plugins/harness-everything/plugin.json +1 -1
  29. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  30. package/plugins/harness-everything/skills/harness-everything/scripts/doc-lifecycle.js +1 -1
  31. package/sbom.cdx.json +7 -7
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ // #325 PR #351 review follow-up ("Unwritable session store"): test-only preload that simulates an
4
+ // unwritable session store for one child process, without chmod (cross-platform).
5
+ //
6
+ // Loaded through NODE_OPTIONS=--require <this file> onto the real Stop entry point by
7
+ // ci/mechanism-325-3b-s1-review.test.js. The integration test passes the target session directory in the
8
+ // HARNESS_S1_REVIEW_SESSION_DIR environment variable. While that variable names a directory, every
9
+ // write/rename/open-for-write/unlink whose target is inside it fails with EACCES and nothing else is
10
+ // intercepted: reads, mkdir, and writes outside the session directory keep working, so a failed write
11
+ // cannot be confused with a skipped or missing one. Without the variable this preload is inert.
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ (function install() {
17
+ const sessionDirectory = process.env.HARNESS_S1_REVIEW_SESSION_DIR;
18
+ if (!sessionDirectory) return;
19
+ let resolvedDirectory;
20
+ try { resolvedDirectory = path.resolve(sessionDirectory); } catch (_) { return; }
21
+ if (!resolvedDirectory || resolvedDirectory === '/') return;
22
+
23
+ function unwritable(target) {
24
+ if (target === undefined || target === null) return false;
25
+ try {
26
+ const resolved = path.resolve(String(target));
27
+ const relative = path.relative(resolvedDirectory, resolved);
28
+ return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
29
+ } catch (_) { return false; }
30
+ }
31
+
32
+ function writeTargetOpenFlags(flags) {
33
+ // open()/openSync() only fail for write-capable flags; 'r' stays readable.
34
+ const value = String(flags || 'r');
35
+ return value.startsWith('w') || value.startsWith('a') || value.includes('+') || value.includes('x');
36
+ }
37
+
38
+ function eacces(method, target) {
39
+ const error = new Error(`EACCES: permission denied, ${method} ${target} (simulated session store write failure)`);
40
+ error.code = 'EACCES';
41
+ error.errno = -13;
42
+ error.syscall = method;
43
+ error.path = String(target);
44
+ return error;
45
+ }
46
+
47
+ const originalWriteFileSync = fs.writeFileSync;
48
+ fs.writeFileSync = function (file, ...rest) {
49
+ if (unwritable(file)) throw eacces('write', file);
50
+ return originalWriteFileSync.call(fs, file, ...rest);
51
+ };
52
+
53
+ const originalRenameSync = fs.renameSync;
54
+ fs.renameSync = function (from, to, ...rest) {
55
+ if (unwritable(from) || unwritable(to)) throw eacces('rename', from);
56
+ return originalRenameSync.call(fs, from, to, ...rest);
57
+ };
58
+
59
+ const originalOpenSync = fs.openSync;
60
+ fs.openSync = function (file, flags, ...rest) {
61
+ if (unwritable(file) && writeTargetOpenFlags(flags)) throw eacces('open', file);
62
+ return originalOpenSync.call(fs, file, flags, ...rest);
63
+ };
64
+
65
+ const originalUnlinkSync = fs.unlinkSync;
66
+ fs.unlinkSync = function (file, ...rest) {
67
+ if (unwritable(file)) throw eacces('unlink', file);
68
+ return originalUnlinkSync.call(fs, file, ...rest);
69
+ };
70
+
71
+ // Callback forms used by async production paths, if any.
72
+ const normalizeCallback = args => (typeof args[args.length - 1] === 'function' ? args[args.length - 1] : () => {});
73
+ const originalWriteFile = fs.writeFile;
74
+ fs.writeFile = function (file, data, ...rest) {
75
+ const callback = normalizeCallback(rest);
76
+ if (unwritable(file)) return process.nextTick(() => callback(eacces('write', file)));
77
+ return originalWriteFile.call(fs, file, data, ...rest);
78
+ };
79
+ const originalRename = fs.rename;
80
+ fs.rename = function (from, to, ...rest) {
81
+ const callback = normalizeCallback(rest);
82
+ if (unwritable(from) || unwritable(to)) return process.nextTick(() => callback(eacces('rename', from)));
83
+ return originalRename.call(fs, from, to, ...rest);
84
+ };
85
+ const originalUnlink = fs.unlink;
86
+ fs.unlink = function (file, ...rest) {
87
+ const callback = normalizeCallback(rest);
88
+ if (unwritable(file)) return process.nextTick(() => callback(eacces('unlink', file)));
89
+ return originalUnlink.call(fs, file, ...rest);
90
+ };
91
+
92
+ // fs.promises equivalents, if any production path uses them.
93
+ const promises = fs.promises || null;
94
+ if (promises) {
95
+ const originalPromisesWriteFile = promises.writeFile;
96
+ promises.writeFile = function (file, data, ...rest) {
97
+ if (unwritable(file)) return Promise.reject(eacces('write', file));
98
+ return originalPromisesWriteFile.call(promises, file, data, ...rest);
99
+ };
100
+ const originalPromisesRename = promises.rename;
101
+ promises.rename = function (from, to, ...rest) {
102
+ if (unwritable(from) || unwritable(to)) return Promise.reject(eacces('rename', from));
103
+ return originalPromisesRename.call(promises, from, to, ...rest);
104
+ };
105
+ const originalPromisesUnlink = promises.unlink;
106
+ promises.unlink = function (file, ...rest) {
107
+ if (unwritable(file)) return Promise.reject(eacces('unlink', file));
108
+ return originalPromisesUnlink.call(promises, file, ...rest);
109
+ };
110
+ const originalPromisesOpen = promises.open;
111
+ promises.open = function (file, flags, ...rest) {
112
+ if (unwritable(file) && writeTargetOpenFlags(flags)) return Promise.reject(eacces('open', file));
113
+ return originalPromisesOpen.call(promises, file, flags, ...rest);
114
+ };
115
+ }
116
+ })();
@@ -5,10 +5,12 @@
5
5
  // Each replay creates a fresh workspace and state home, runs ordered actions through one explicitly
6
6
  // selected distribution, and returns a replay root with the L1 shape:
7
7
  // { actions: { [actionId]: { exitCode, signal, error, errorCode, stdout, stderr, json, state, pilot } }, state, pilot, pilotReportOrigin }.
8
- // Nothing here is production code. The pilot switch is passed to children only as a request; no reader for
9
- // it exists in this preparation slice, so ON replays run the legacy code path. Fixture operations
8
+ // Nothing here is production code. The pilot switch is passed to children through their environment; the S1
9
+ // runtime reads it and, when ON, runs the fail-closed no-credit gate. Fixture operations
10
10
  // (provideEvidence, mutateIdentity, timestampHints) write simulated records only inside the replay's own
11
- // temporary directories. They are contract fixtures, not production commands, and nothing reads them yet.
11
+ // temporary directories. They are contract fixtures, not production commands; no S1 runtime reads them.
12
+ // seedState writes one allowlisted session file (workflow-stages.json) into the replay's own state home, so a case can
13
+ // reach a correlated Fable stage contract. It is test-only; no production code reads or writes through it.
12
14
 
13
15
  const crypto = require('crypto');
14
16
  const fs = require('fs');
@@ -18,9 +20,16 @@ const { spawnSync } = require('child_process');
18
20
 
19
21
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
20
22
  const PILOT_SWITCH = 'HARNESS_DOC_RECONCILIATION_PILOT';
21
- const FIXTURE_OPS = new Set(['provideEvidence', 'mutateIdentity', 'timestampHints']);
22
- const CONTROLLER_OPS = new Set(['plan', 'start', 'binding', 'step', 'obligation']);
23
- const ALL_OPS = new Set(['route', 'stop', ...CONTROLLER_OPS, ...FIXTURE_OPS]);
23
+ // Session-directory file the S1 runtime writes its pilot report to. Read-only here; only a report carrying
24
+ // reportOrigin: runtime is accepted.
25
+ const PILOT_REPORT_FILE = 'doc-reconciliation-pilot.json';
26
+ const BASELINE_AUTHOR = Object.freeze({ name: 'doc-recon-replay', email: 'doc-recon-replay@invalid' });
27
+ const BASELINE_DATE = '2026-01-01T00:00:00Z';
28
+ const FIXTURE_OPS = new Set(['provideEvidence', 'mutateIdentity', 'timestampHints', 'seedState', 'gitCommit']);
29
+ const CONTROLLER_OPS = new Set(['plan', 'start', 'binding', 'stageBinding', 'step', 'obligation']);
30
+ const ALL_OPS = new Set(['route', 'stop', 'stopGeneric', ...CONTROLLER_OPS, ...FIXTURE_OPS]);
31
+ // The only session files a case may seed, and only inside its own temporary state home.
32
+ const SEEDABLE_FILES = new Set(['workflow-stages.json']);
24
33
 
25
34
  // Explicit distributions. Every entry point is named by absolute path; no module is reused across legs.
26
35
  const DISTRIBUTIONS = Object.freeze({
@@ -28,13 +37,15 @@ const DISTRIBUTIONS = Object.freeze({
28
37
  root: REPO_ROOT,
29
38
  controller: path.join(REPO_ROOT, 'hooks', 'scripts', 'workflow-disposition.js'),
30
39
  stopGate: path.join(REPO_ROOT, 'hooks', 'scripts', 'workflow-stop-gate.js'),
31
- kernel: path.join(REPO_ROOT, 'harness-everything', 'scripts', 'kernel-router.js')
40
+ kernel: path.join(REPO_ROOT, 'harness-everything', 'scripts', 'kernel-router.js'),
41
+ genericStopGate: path.join(REPO_ROOT, 'hooks', 'scripts', 'stop-gate.js')
32
42
  },
33
43
  packaged: {
34
44
  root: path.join(REPO_ROOT, 'plugins', 'harness-everything'),
35
45
  controller: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'workflow-disposition.js'),
36
46
  stopGate: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'workflow-stop-gate.js'),
37
- kernel: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'skills', 'harness-everything', 'scripts', 'kernel-router.js')
47
+ kernel: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'skills', 'harness-everything', 'scripts', 'kernel-router.js'),
48
+ genericStopGate: path.join(REPO_ROOT, 'plugins', 'harness-everything', 'hooks', 'scripts', 'stop-gate.js')
38
49
  }
39
50
  });
40
51
 
@@ -196,6 +207,29 @@ function runFixture(handle, action, pilot) {
196
207
  fs.writeFileSync(target, String(p.content), 'utf8');
197
208
  return fixtureResult(handle, pilot, { op: 'mutateIdentity', kind: p.kind, path: p.path });
198
209
  }
210
+ if (action.op === 'gitCommit') {
211
+ // Commits the replay workspace's current tree into HEAD mid-workflow. The baseline recorded at plan is not moved by
212
+ // this fixture: the case exists to show that scope is still assessed against that recorded baseline. A clean tree
213
+ // makes `git commit` fail, so a misused op is an infrastructure fault, not a silent no-op.
214
+ if (typeof p.message !== 'string' || !p.message.trim()) throw new Error('gitCommit needs a message');
215
+ git(handle.workspace, ['add', '-A']);
216
+ git(handle.workspace, [
217
+ '-c', `user.name=${BASELINE_AUTHOR.name}`, '-c', `user.email=${BASELINE_AUTHOR.email}`, '-c', 'commit.gpgsign=false',
218
+ 'commit', '-q', '--no-verify', '-m', p.message
219
+ ]);
220
+ return fixtureResult(handle, pilot, { op: 'gitCommit' });
221
+ }
222
+ if (action.op === 'seedState') {
223
+ // Writes one allowlisted session file into the replay's own state home. The session must already exist, so the
224
+ // seeded file lands where the runtime reads it; nothing outside the replay's state home is reachable.
225
+ if (!SEEDABLE_FILES.has(p.file)) throw new Error(`seedState may only write ${[...SEEDABLE_FILES].join(', ')}`);
226
+ const dir = sessionDirOf(handle);
227
+ if (!dir) throw new Error('seedState needs an existing session');
228
+ const target = path.join(dir, p.file);
229
+ if (!target.startsWith(handle.stateHome + path.sep)) throw new Error('seedState target escaped the replay state home');
230
+ fs.writeFileSync(target, JSON.stringify(p.json, null, 2), 'utf8');
231
+ return fixtureResult(handle, pilot, { op: 'seedState', file: p.file });
232
+ }
199
233
  if (action.op === 'timestampHints') {
200
234
  const dir = sessionDirOf(handle);
201
235
  if (!dir) throw new Error('timestampHints needs an existing session');
@@ -222,6 +256,9 @@ function runAction(handle, action, pilot) {
222
256
  result = runScript(handle, scriptFor(handle, 'route', dist.kernel), [], { session_id: sid, cwd: handle.workspace, prompt: p.prompt }, pilot);
223
257
  } else if (action.op === 'stop') {
224
258
  result = runScript(handle, scriptFor(handle, 'stop', dist.stopGate), [], { session_id: sid, cwd: handle.workspace, hook_event_name: 'Stop' }, pilot);
259
+ } else if (action.op === 'stopGeneric') {
260
+ // The second Stop surface (verification reminder). It must exit 0 in every case, like the workflow surface.
261
+ result = runScript(handle, scriptFor(handle, 'stopGeneric', dist.genericStopGate), [], { session_id: sid, cwd: handle.workspace, hook_event_name: 'Stop' }, pilot);
225
262
  } else if (action.op === 'plan') {
226
263
  result = runScript(handle, scriptFor(handle, 'plan', dist.controller), [
227
264
  'plan', '--session-id', sid, '--requirements-json', JSON.stringify(p.requirements),
@@ -234,6 +271,12 @@ function runAction(handle, action, pilot) {
234
271
  'binding', '--session-id', sid, '--step-id', p.stepId, '--binding-id', p.bindingId,
235
272
  '--disposition', p.disposition, '--evidence', p.evidence
236
273
  ], undefined, pilot);
274
+ } else if (action.op === 'stageBinding') {
275
+ // Correlated Fable stage binding (the stage-binding command). Generic binding refuses Fable workflows.
276
+ result = runScript(handle, scriptFor(handle, 'stageBinding', dist.controller), [
277
+ 'stage-binding', '--session-id', sid, '--stage-id', p.stageId, '--binding-id', p.bindingId,
278
+ '--disposition', p.disposition, '--evidence', p.evidence
279
+ ], undefined, pilot);
237
280
  } else if (action.op === 'step') {
238
281
  result = runScript(handle, scriptFor(handle, 'step', dist.controller), [
239
282
  'step', '--session-id', sid, '--step-id', p.stepId, '--disposition', p.disposition, '--evidence', p.evidence
@@ -267,6 +310,69 @@ function effectivePilot(action, replayPilot) {
267
310
  return Object.prototype.hasOwnProperty.call(action, 'pilot') ? action.pilot : replayPilot;
268
311
  }
269
312
 
313
+ // Runs git in a replay workspace. Inherited GIT_* variables are removed, so a hook or CI environment cannot point git
314
+ // at another repository. Commit dates are fixed so identical inputs give the same baseline commit on both legs.
315
+ function git(cwd, args) {
316
+ const env = { ...process.env };
317
+ for (const key of Object.keys(env)) {
318
+ if (key.startsWith('GIT_')) delete env[key];
319
+ }
320
+ env.GIT_AUTHOR_DATE = BASELINE_DATE;
321
+ env.GIT_COMMITTER_DATE = BASELINE_DATE;
322
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', env, timeout: CHILD_LIMITS.timeoutMs, maxBuffer: CHILD_LIMITS.maxBuffer });
323
+ if (result.error || result.status !== 0) {
324
+ const detail = result.error ? result.error.message : String(result.stderr || '').split('\n')[0];
325
+ throw new Error(`git failed in the replay workspace: ${detail}`);
326
+ }
327
+ return result.stdout;
328
+ }
329
+
330
+ function writeWorkspaceFiles(workspace, files) {
331
+ for (const [rel, content] of Object.entries(files)) {
332
+ if (typeof rel !== 'string' || path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
333
+ throw new Error(`input path must be workspace-relative: ${rel}`);
334
+ }
335
+ const target = path.join(workspace, rel);
336
+ if (content === null) {
337
+ fs.rmSync(target, { force: true });
338
+ continue;
339
+ }
340
+ if (typeof content !== 'string') throw new Error(`input file ${rel} must be a string or null`);
341
+ fs.mkdirSync(path.dirname(target), { recursive: true });
342
+ fs.writeFileSync(target, content, 'utf8');
343
+ }
344
+ }
345
+
346
+ // Materialises a case's declared file inputs before any action runs. Non-empty baseFiles become a committed Git
347
+ // baseline, so `plan` records a real HEAD. candidateFiles then become the working tree, and a null value deletes a
348
+ // baseline file. Without baseFiles the workspace stays non-Git, so its baseline is unavailable. Fixture maps alone are
349
+ // not runtime authority; the runtime collects scope itself.
350
+ function materialiseInput(handle, input) {
351
+ const base = (input && input.baseFiles) || {};
352
+ const candidate = (input && input.candidateFiles) || {};
353
+ if (Object.keys(base).length) {
354
+ git(handle.workspace, ['init', '-q']);
355
+ writeWorkspaceFiles(handle.workspace, base);
356
+ git(handle.workspace, ['add', '-A']);
357
+ git(handle.workspace, [
358
+ '-c', `user.name=${BASELINE_AUTHOR.name}`, '-c', `user.email=${BASELINE_AUTHOR.email}`, '-c', 'commit.gpgsign=false',
359
+ 'commit', '-q', '--no-verify', '-m', 'baseline'
360
+ ]);
361
+ }
362
+ writeWorkspaceFiles(handle.workspace, candidate);
363
+ }
364
+
365
+ // Read-only extraction of a runtime pilot report from the session directory. Only a report that carries
366
+ // reportOrigin: runtime is accepted. Otherwise the replay reports no pilot with origin harness; nothing is synthesized.
367
+ function runtimePilotOf(handle) {
368
+ const dir = sessionDirOf(handle);
369
+ const report = dir ? readJsonOrNull(path.join(dir, PILOT_REPORT_FILE)) : null;
370
+ if (report && typeof report === 'object' && !Array.isArray(report) && report.reportOrigin === 'runtime') {
371
+ return { pilot: report, pilotReportOrigin: 'runtime' };
372
+ }
373
+ return { pilot: null, pilotReportOrigin: 'harness' };
374
+ }
375
+
270
376
  // Replays one case through one distribution. Actions run in order and the replay stops at its first infrastructure
271
377
  // fault: the faulted action is recorded, no later action runs, and the temporary directories are still removed.
272
378
  // A crash is recorded, not hidden.
@@ -275,6 +381,7 @@ function replayCase(caseDef, distribution, options = {}) {
275
381
  const mode = options.pilot === undefined ? 'off' : 'on';
276
382
  const handle = { ...createSession(distribution, caseDef.id, options.pilot), distribution, limits };
277
383
  try {
384
+ materialiseInput(handle, caseDef.input);
278
385
  const actions = {};
279
386
  for (const action of caseDef.actions) {
280
387
  if (Object.prototype.hasOwnProperty.call(actions, action.id)) throw new Error(`duplicate action id ${action.id}`);
@@ -287,8 +394,7 @@ function replayCase(caseDef, distribution, options = {}) {
287
394
  return {
288
395
  actions,
289
396
  state: snapshot(handle),
290
- pilot: null,
291
- pilotReportOrigin: 'harness'
397
+ ...runtimePilotOf(handle)
292
398
  };
293
399
  } finally {
294
400
  disposeSession(handle);
@@ -29,6 +29,7 @@ const SHARED_FILES = [
29
29
  ['hooks/scripts/lib/workflow-obligations.js', 'plugins/harness-everything/hooks/scripts/lib/workflow-obligations.js'],
30
30
  ['hooks/scripts/workflow-disposition.js', 'plugins/harness-everything/hooks/scripts/workflow-disposition.js'],
31
31
  ['hooks/scripts/workflow-stop-gate.js', 'plugins/harness-everything/hooks/scripts/workflow-stop-gate.js'],
32
+ ['hooks/scripts/lib/doc-reconciliation-pilot.js', 'plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-pilot.js'],
32
33
  ];
33
34
 
34
35
  let failed = 0;
@@ -91,7 +92,7 @@ checkCase('expected-RED report: legs agree, controls are green, no pilot credit,
91
92
  assert.strictEqual(item.parity, 'match', `${item.caseId} source and packaged legs agree`);
92
93
  }
93
94
  const controls = report.cases.filter(item => item.status === 'CONTROL_PASS').map(item => item.caseId).sort();
94
- assert.deepStrictEqual(controls, ['P02d', 'P06', 'P07u']);
95
+ assert.deepStrictEqual(controls, ['P01', 'P02a', 'P02b', 'P02c', 'P02d', 'P02f', 'P02g', 'P04', 'P06', 'P07-fable-multi-agent-workspace', 'P07-fable-parallel', 'P07-fable-staged', 'P07u']);
95
96
  });
96
97
 
97
98
  checkCase('gap registry: every entry is qualified and the expected-RED mode accounts for all of them', () => {
@@ -102,8 +103,8 @@ checkCase('gap registry: every entry is qualified and the expected-RED mode acco
102
103
  });
103
104
 
104
105
  checkCase('strict mode fails on the recorded gaps and preserves the RED log', () => {
105
- const log = path.join(workDir, 'p01-red.json');
106
- const run = runRunner(['--strict', '--case', 'P01', '--log', log]);
106
+ const log = path.join(workDir, 'p02e-red.json');
107
+ const run = runRunner(['--strict', '--case', 'P02e', '--log', log]);
107
108
  assert.strictEqual(run.status, 1, `strict mode exits 1 on gaps: ${run.stdout}`);
108
109
  const saved = JSON.parse(fs.readFileSync(log, 'utf8'));
109
110
  assert.ok(saved.cases[0].assertions.some(entry => entry.status === 'gap-qualified'), 'the log keeps the qualified gaps');
@@ -116,8 +117,8 @@ checkCase('malformed registry is an infrastructure error, not a RED pass', () =>
116
117
  });
117
118
 
118
119
  checkCase('a registry that omits a gap fails expected-RED mode', () => {
119
- const file = writeRegistry('omit-gap.json', copy => { copy.entries = copy.entries.filter(entry => entry.assertionId !== 'P01.on.stop-unsatisfied' || entry.distribution !== 'source'); });
120
- const run = runRunner(['--expect-red', '--case', 'P01', '--registry', file]);
120
+ const file = writeRegistry('omit-gap.json', copy => { copy.entries = copy.entries.filter(entry => entry.assertionId !== 'P02e.on.review-pass-exit' || entry.distribution !== 'source'); });
121
+ const run = runRunner(['--expect-red', '--case', 'P02e', '--registry', file]);
121
122
  assert.strictEqual(run.status, 1, `an unqualified gap fails: ${run.stdout}`);
122
123
  });
123
124
 
@@ -152,9 +153,8 @@ checkCase('negative control: an abnormal route in P01 is infrastructure, and --e
152
153
  assert.deepStrictEqual(after.map(item => [item.id, item.pass, item.actual]), before.map(item => [item.id, item.pass, item.actual]),
153
154
  `${leg}: the later assertions evaluate exactly as in the unmodified replay`);
154
155
  for (const item of after) {
155
- const entry = p01Entries.find(candidate => candidate.assertionId === `P01.on.${item.id}` && candidate.distribution === leg);
156
- assert.ok(entry && JSON.stringify(fx.normalize(entry.actual)) === JSON.stringify(fx.normalize(item.actual)),
157
- `${leg}: ${item.id} still matches its registry actual, so only the infrastructure rule can stop qualification`);
156
+ assert.strictEqual(item.pass, true,
157
+ `${leg}: ${item.id} remains strict S1 GREEN, so only the infrastructure rule can stop the replay`);
158
158
  }
159
159
  }
160
160
  const result = runner.classifyCase(p01, injected, p01Entries);