harness-everything 0.31.2 → 0.32.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 (53) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +30 -0
  4. package/bin/cli.js +1 -0
  5. package/ci/lib/isolated-test-process.js +30 -0
  6. package/ci/mechanism-2al-codex-hooks-compat.test.js +14 -14
  7. package/ci/mechanism-2ao-plugin-sync.test.js +19 -2
  8. package/ci/mechanism-2at-fable-execution-phases.test.js +46 -0
  9. package/ci/mechanism-2au-codex-runtime-safety.test.js +340 -0
  10. package/ci/mechanism-2av-test-home-isolation.test.js +48 -0
  11. package/ci/mechanism-2az-step-type-registry.test.js +837 -0
  12. package/ci/mechanism-2ba-step-type-docs.test.js +166 -0
  13. package/ci/mechanism-test.js +2 -2
  14. package/docs/platform-capabilities.md +4 -0
  15. package/docs/platform-compatibility.json +1 -1
  16. package/fable-mode/SKILL.md +1 -1
  17. package/fable-mode/execution-guardrails/SKILL.md +1 -1
  18. package/fable-mode/fable-haiku/SKILL.md +1 -1
  19. package/fable-mode/fable-opus/SKILL.md +1 -1
  20. package/fable-mode/fable-sonnet/SKILL.md +1 -1
  21. package/fable-mode/references/execution-phases.md +93 -25
  22. package/harness-everything/SKILL.md +1 -1
  23. package/harness-everything/references/router-workflow-plan.md +18 -0
  24. package/harness-everything/scripts/kernel-router.js +37 -5
  25. package/hooks/scripts/codex-user-prompt.js +5 -2
  26. package/hooks/scripts/lib/step-types.json +37 -0
  27. package/hooks/scripts/lib/workflow-obligations.js +117 -14
  28. package/hooks/scripts/workflow-disposition.js +16 -9
  29. package/hooks/scripts/workflow-stop-gate.js +31 -8
  30. package/opencode-plugin/plugin.json +1 -1
  31. package/package.json +2 -2
  32. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  33. package/plugins/harness-everything/hooks/scripts/codex-user-prompt.js +5 -2
  34. package/plugins/harness-everything/hooks/scripts/lib/step-types.json +37 -0
  35. package/plugins/harness-everything/hooks/scripts/lib/workflow-obligations.js +117 -14
  36. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +16 -9
  37. package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +31 -8
  38. package/plugins/harness-everything/plugin.json +1 -1
  39. package/plugins/harness-everything/skills/fable-mode/SKILL.md +1 -1
  40. package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
  41. package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
  42. package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
  43. package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
  44. package/plugins/harness-everything/skills/fable-mode/references/execution-phases.md +93 -25
  45. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  46. package/plugins/harness-everything/skills/harness-everything/references/router-workflow-plan.md +18 -0
  47. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +37 -5
  48. package/plugins/harness-everything/skills/self-evolve/SKILL.md +1 -1
  49. package/plugins/harness-everything/skills/self-evolve/scripts/self-regression.js +8 -6
  50. package/sbom.cdx.json +7 -7
  51. package/scripts/codex-hooks-compat.js +265 -25
  52. package/self-evolve/SKILL.md +1 -1
  53. package/self-evolve/scripts/self-regression.js +8 -6
@@ -0,0 +1,837 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Stage A: the registry is declarative; this test exercises the CLI/filesystem
5
+ // boundaries and the source-free packaged hook runtime.
6
+ const assert = require('assert');
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { spawnSync } = require('child_process');
12
+
13
+ const ROOT = path.resolve(__dirname, '..');
14
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-step-type-registry-'));
15
+ const stateHome = path.join(tempRoot, 'state');
16
+ const fakeHome = path.join(tempRoot, 'home');
17
+ fs.mkdirSync(stateHome, { recursive: true });
18
+ fs.mkdirSync(fakeHome, { recursive: true });
19
+ process.env.HARNESS_STATE_HOME = stateHome;
20
+ process.env.HARNESS_WORKSPACE_ROOT = ROOT;
21
+ process.env.HOME = fakeHome;
22
+ process.env.USERPROFILE = fakeHome;
23
+ delete process.env.CLAUDE_PLUGIN_ROOT;
24
+ delete process.env.PLUGIN_ROOT;
25
+
26
+ const kernel = path.join(ROOT, 'harness-everything/scripts/kernel-router.js');
27
+ const controller = path.join(ROOT, 'hooks/scripts/workflow-disposition.js');
28
+ const codexPromptAdapter = path.join(ROOT, 'hooks/scripts/codex-user-prompt.js');
29
+ const sourceRegistryFile = path.join(ROOT, 'hooks/scripts/lib/step-types.json');
30
+ const packageRoot = path.join(ROOT, 'plugins/harness-everything');
31
+ const packageRegistryFile = path.join(packageRoot, 'hooks/scripts/lib/step-types.json');
32
+ const workflowObligations = require(path.join(ROOT, 'hooks/scripts/lib/workflow-obligations.js'));
33
+ const { getSessionDir } = require(path.join(ROOT, 'hooks/scripts/lib/harness-state.js'));
34
+ const compat = require(path.join(ROOT, 'scripts/codex-hooks-compat.js'));
35
+
36
+ const EXPECTED_IDS = ['behavior-change', 'commit', 'doc-change', 'review', 'verification'];
37
+ const LEGACY_2AN_SHA256 = 'fa80a318bcb7e31cde7a05f5a6193656b86ed28976961fe3ea89e3fc6db75cb1';
38
+ const LEGACY_2AR_SHA256 = '45ccb96cd04c7eae38113ee21c634326bdbedbc76a8611d78c1a92d017424120';
39
+ const FIX_PROMPT = 'Fix this checkout bug and add a regression test.';
40
+ let failed = 0;
41
+
42
+ function check(condition, message) {
43
+ if (condition) console.log(' PASS ' + message);
44
+ else { console.error(' FAIL ' + message); failed++; }
45
+ }
46
+
47
+ function checkCase(name, runCase) {
48
+ try { runCase(); check(true, name); }
49
+ catch (error) { check(false, `${name}: ${error && error.message ? error.message : String(error)}`); }
50
+ }
51
+
52
+ function run(script, args = [], workspace = ROOT, input = null) {
53
+ return spawnSync(process.execPath, [script, ...args], {
54
+ cwd: workspace,
55
+ encoding: 'utf8',
56
+ input: input === null ? undefined : JSON.stringify(input),
57
+ env: { ...process.env, HARNESS_STATE_HOME: stateHome, HARNESS_WORKSPACE_ROOT: workspace, HOME: fakeHome, USERPROFILE: fakeHome },
58
+ });
59
+ }
60
+
61
+ function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
62
+
63
+ function routeSession(sessionId, prompt = FIX_PROMPT, workspace = ROOT, runtimeRoot = ROOT) {
64
+ const payload = { session_id: sessionId, cwd: workspace, prompt };
65
+ const routed = run(path.join(runtimeRoot, 'harness-everything/scripts/kernel-router.js'), [], workspace, payload);
66
+ const sessionDir = getSessionDir(workspace, sessionId, payload);
67
+ return {
68
+ payload,
69
+ sessionDir,
70
+ workflowFile: path.join(sessionDir, 'workflow-run.json'),
71
+ obligationsFile: path.join(sessionDir, 'workflow-obligations.json'),
72
+ routed,
73
+ };
74
+ }
75
+
76
+ function planSession(sessionId, requirements, strategy = 'iterative-single', workspace = ROOT, prompt = FIX_PROMPT, evidence = 'author-declared plan for the registry integration fixture', runtimeRoot = ROOT) {
77
+ const session = routeSession(sessionId, prompt, workspace, runtimeRoot);
78
+ const planned = run(path.join(runtimeRoot, 'hooks/scripts/workflow-disposition.js'), [
79
+ 'plan', '--session-id', sessionId,
80
+ '--requirements-json', JSON.stringify(requirements),
81
+ '--strategy', strategy,
82
+ '--evidence', evidence,
83
+ ], workspace);
84
+ return { ...session, planned };
85
+ }
86
+
87
+ function clone(value) { return JSON.parse(JSON.stringify(value)); }
88
+
89
+ function normalizedPath(value, workspace) {
90
+ const roots = [
91
+ { root: workspace, label: '/workspace' },
92
+ { root: path.join(ROOT, 'plugins/harness-everything/skills'), label: '/plugin-skills' },
93
+ { root: path.join(ROOT, 'plugins/harness-everything'), label: '/plugin' },
94
+ { root: path.join(ROOT, 'skills'), label: '/source-skills' },
95
+ { root: ROOT, label: '/source' },
96
+ ];
97
+ const absolute = path.resolve(value);
98
+ for (const candidate of roots) {
99
+ const root = path.resolve(candidate.root);
100
+ const relative = path.relative(root, absolute);
101
+ if (relative === '' || (relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative))) {
102
+ const portableRelative = relative.split(/[\\/]+/).filter(Boolean).join('/');
103
+ return portableRelative ? `${candidate.label}/${portableRelative}` : candidate.label;
104
+ }
105
+ }
106
+ return absolute.split(/[\\/]+/).join('/');
107
+ }
108
+
109
+ function frozenObligation(value, workspace) {
110
+ if (Array.isArray(value)) return value.map(item => frozenObligation(item, workspace));
111
+ if (!value || typeof value !== 'object') return value;
112
+ const result = {};
113
+ for (const [key, item] of Object.entries(value)) {
114
+ if (['createdAt', 'updatedAt', 'confirmedAt'].includes(key)) result[key] = '2026-01-01T00:00:00.000Z';
115
+ else if (key === 'sessionId') result[key] = 'fixed-session';
116
+ else if (key === 'workflowId') result[key] = 'fixed-workflow';
117
+ else if (key === 'revision') result[key] = 1;
118
+ else if (key === 'resolvedPath' && typeof item === 'string') result[key] = normalizedPath(item, workspace);
119
+ else result[key] = frozenObligation(item, workspace);
120
+ }
121
+ return result;
122
+ }
123
+
124
+ function createRuntimeFixture(name) {
125
+ const root = path.join(tempRoot, name);
126
+ fs.mkdirSync(root, { recursive: true });
127
+ for (const directory of ['hooks', 'harness-everything', 'scripts', 'fable-mode']) {
128
+ fs.cpSync(path.join(ROOT, directory), path.join(root, directory), { recursive: true });
129
+ }
130
+ for (const skill of ['git-commit', 'security-review', 'tdd', 'verification-loop']) {
131
+ const source = path.join(ROOT, skill);
132
+ if (fs.existsSync(source)) fs.cpSync(source, path.join(root, skill), { recursive: true });
133
+ }
134
+ const workspace = path.join(tempRoot, `${name}-workspace`);
135
+ fs.mkdirSync(workspace, { recursive: true });
136
+ return {
137
+ root,
138
+ workspace,
139
+ registryFile: path.join(root, 'hooks/scripts/lib/step-types.json'),
140
+ kernel: path.join(root, 'harness-everything/scripts/kernel-router.js'),
141
+ controller: path.join(root, 'hooks/scripts/workflow-disposition.js'),
142
+ stopGate: path.join(root, 'hooks/scripts/workflow-stop-gate.js'),
143
+ codexAdapter: path.join(root, 'hooks/scripts/codex-user-prompt.js'),
144
+ };
145
+ }
146
+
147
+ function sessionFiles(fixture, sessionId, prompt = FIX_PROMPT) {
148
+ const payload = { session_id: sessionId, cwd: fixture.workspace, prompt };
149
+ const sessionDir = getSessionDir(fixture.workspace, sessionId, payload);
150
+ return {
151
+ payload,
152
+ sessionDir,
153
+ workflowFile: path.join(sessionDir, 'workflow-run.json'),
154
+ obligationsFile: path.join(sessionDir, 'workflow-obligations.json'),
155
+ };
156
+ }
157
+
158
+ function semanticRegistryError(result, registryFile) {
159
+ const output = `${result.stdout || ''}\n${result.stderr || ''}`;
160
+ assert.ok(output.includes(registryFile), `diagnostic must name ${registryFile}: ${output}`);
161
+ assert.match(output, /step-type registry/i, output);
162
+ assert.match(output, /Repair action: reinstall or update the Harness plugin so this file is restored; in a Harness source checkout, restore it and run npm run plugin:sync\./, output);
163
+ assert.doesNotMatch(output, /workflow-obligations\.js:\d+|at loadStepTypeRegistry/, output);
164
+ }
165
+
166
+ function frozenObligationHash(file, workspace) {
167
+ const normalized = JSON.stringify(frozenObligation(readJson(file), workspace), null, 2) + '\n';
168
+ return crypto.createHash('sha256').update(normalized).digest('hex');
169
+ }
170
+
171
+ function snapshotTree(root) {
172
+ const entries = [];
173
+ function visit(directory) {
174
+ for (const item of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
175
+ const fullPath = path.join(directory, item.name);
176
+ if (item.isDirectory()) visit(fullPath);
177
+ else if (item.isFile()) entries.push([path.relative(root, fullPath).split(path.sep).join('/'), fs.readFileSync(fullPath).toString('hex')]);
178
+ }
179
+ }
180
+ visit(root);
181
+ return JSON.stringify(entries);
182
+ }
183
+
184
+ function git(root, args) {
185
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', env: { ...process.env, HOME: fakeHome, GIT_CONFIG_NOSYSTEM: '1' } });
186
+ if (result.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`);
187
+ return (result.stdout || '').trim();
188
+ }
189
+
190
+ function createTrackedCompatSource() {
191
+ const repo = path.join(tempRoot, 'tracked-codex-source');
192
+ const source = path.join(repo, 'plugins/harness-everything');
193
+ fs.mkdirSync(path.dirname(source), { recursive: true });
194
+ fs.cpSync(packageRoot, source, { recursive: true });
195
+ git(repo, ['init', '--quiet']);
196
+ git(repo, ['add', '--all']);
197
+ git(repo, ['-c', 'user.name=Step Registry Test', '-c', 'user.email=registry@example.invalid', 'commit', '--quiet', '-m', 'fixture tracked plugin source']);
198
+ return { repo, source };
199
+ }
200
+
201
+ console.log('=== Stage A: workflow step-type registry ===');
202
+
203
+ let registry = null;
204
+ try { registry = readJson(sourceRegistryFile); } catch (_) { /* asserted below as RED */ }
205
+ checkCase('registry file exists with exactly five declarative step types', () => {
206
+ assert.ok(Array.isArray(registry), 'step-types.json must contain an array');
207
+ assert.deepStrictEqual(registry.map(entry => entry.id).sort(), EXPECTED_IDS);
208
+ for (const entry of registry) assert.deepStrictEqual(Object.keys(entry).sort(), [
209
+ 'evidenceHint', 'forbiddenBindings', 'id', 'optionalBindings', 'requiredBindings',
210
+ ]);
211
+ });
212
+
213
+ checkCase('registry defaults preserve the four existing types and leave doc-change binding-free', () => {
214
+ assert.ok(Array.isArray(registry), 'registry is readable');
215
+ assert.strictEqual(typeof workflowObligations.validateStepTypeRegistry, 'function', 'validator is exposed for deterministic contract checks');
216
+ const entries = workflowObligations.validateStepTypeRegistry(registry);
217
+ const byId = new Map(entries.map(entry => [entry.id, entry]));
218
+ assert.deepStrictEqual(byId.get('review').requiredBindings, []);
219
+ assert.deepStrictEqual(byId.get('review').optionalBindings, []);
220
+ assert.deepStrictEqual(byId.get('review').forbiddenBindings, ['tdd']);
221
+ assert.deepStrictEqual(byId.get('behavior-change').requiredBindings, ['tdd']);
222
+ assert.deepStrictEqual(byId.get('behavior-change').optionalBindings, []);
223
+ assert.deepStrictEqual(byId.get('verification').requiredBindings, ['verification-loop']);
224
+ assert.deepStrictEqual(byId.get('commit').requiredBindings, ['git-commit']);
225
+ assert.deepStrictEqual(byId.get('doc-change').requiredBindings, []);
226
+ assert.deepStrictEqual(byId.get('doc-change').optionalBindings, []);
227
+ assert.deepStrictEqual(byId.get('doc-change').forbiddenBindings, []);
228
+ for (const entry of entries) assert.ok(entry.evidenceHint.trim(), `${entry.id} has a non-empty hint`);
229
+ });
230
+
231
+ checkCase('registry validation rejects duplicate IDs deterministically', () => {
232
+ assert.ok(Array.isArray(registry), 'registry is readable');
233
+ const validate = workflowObligations.validateStepTypeRegistry;
234
+ assert.strictEqual(typeof validate, 'function');
235
+ const invalid = [...clone(registry), clone(registry.find(entry => entry.id === 'review'))];
236
+ function errorOf(value) { try { validate(value); return ''; } catch (error) { return error.message; } }
237
+ const first = errorOf(invalid);
238
+ const reversed = errorOf([...invalid].reverse());
239
+ assert.match(first, /duplicate.*review/i);
240
+ assert.strictEqual(reversed, first, 'error is independent of source entry order');
241
+ });
242
+
243
+ checkCase('registry validation rejects malformed binding declarations deterministically', () => {
244
+ assert.ok(Array.isArray(registry), 'registry is readable');
245
+ const validate = workflowObligations.validateStepTypeRegistry;
246
+ assert.strictEqual(typeof validate, 'function');
247
+ const invalid = clone(registry);
248
+ invalid.find(entry => entry.id === 'doc-change').requiredBindings = [null, 'bad id'];
249
+ function errorOf(value) { try { validate(value); return ''; } catch (error) { return error.message; } }
250
+ const first = errorOf(invalid);
251
+ const reversed = errorOf([...invalid].reverse());
252
+ assert.match(first, /doc-change.*requiredBindings/i);
253
+ assert.strictEqual(reversed, first, 'malformed binding error is deterministic');
254
+ });
255
+
256
+ checkCase('registry validation rejects required, optional, and forbidden binding conflicts', () => {
257
+ assert.ok(Array.isArray(registry), 'registry is readable');
258
+ const validate = workflowObligations.validateStepTypeRegistry;
259
+ assert.strictEqual(typeof validate, 'function');
260
+ for (const [left, right] of [
261
+ ['requiredBindings', 'optionalBindings'],
262
+ ['requiredBindings', 'forbiddenBindings'],
263
+ ['optionalBindings', 'forbiddenBindings'],
264
+ ]) {
265
+ const invalid = clone(registry);
266
+ const entry = invalid.find(value => value.id === 'doc-change');
267
+ entry[left] = ['same-binding'];
268
+ entry[right] = ['same-binding'];
269
+ assert.throws(() => validate(invalid), /conflict/i, `${left}/${right} conflict must fail`);
270
+ }
271
+ });
272
+
273
+ checkCase('doc-change is author-declared, needs no default TDD, and remains a pending execution step', () => {
274
+ const session = planSession('registry-doc-change', [
275
+ { id: 'req-docs', stepType: 'doc-change', summary: 'Update the docs', acceptance: 'the contract text is clear' },
276
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify the docs', acceptance: 'links and examples are checked' },
277
+ ], 'iterative-single', ROOT, 'Fix this checkout bug and update its documentation.');
278
+ assert.strictEqual(session.routed.status, 0, session.routed.stderr);
279
+ assert.strictEqual(session.planned.status, 0, session.planned.stderr);
280
+ const state = readJson(session.obligationsFile);
281
+ const docs = state.requirements[0];
282
+ assert.deepStrictEqual(state.requirements.map(item => item.stepType), ['doc-change', 'verification']);
283
+ assert.deepStrictEqual(docs.requiredBindings, []);
284
+ assert.deepStrictEqual(docs.optionalBindings, []);
285
+ assert.strictEqual(docs.status, 'pending');
286
+ assert.strictEqual(docs.evidence, null, 'an evidence hint is not evidence');
287
+ assert.strictEqual(state.schemaVersion, 1);
288
+ const started = run(controller, ['start', '--session-id', 'registry-doc-change']);
289
+ assert.strictEqual(started.status, 0, started.stderr);
290
+ const active = JSON.parse(started.stdout).activeStep;
291
+ assert.strictEqual(active.stepType, 'doc-change');
292
+ const passed = run(controller, [
293
+ 'step', '--session-id', 'registry-doc-change', '--step-id', 'req-docs', '--disposition', 'pass', '--evidence', 'doc acceptance checks are complete',
294
+ ]);
295
+ assert.strictEqual(passed.status, 0, passed.stderr);
296
+ const afterPass = readJson(session.obligationsFile);
297
+ const execute = afterPass.obligations.find(item => item.id === 'execute');
298
+ assert.strictEqual(execute.status, 'pass', 'doc-change contributes to the existing execution aggregate');
299
+ assert.match(execute.evidence, /req-docs/);
300
+ });
301
+
302
+ checkCase('doc-change requires verification in non-Fable plans without adding an ordering rule', () => {
303
+ const missingVerify = planSession('registry-doc-change-no-verify', [
304
+ { id: 'req-docs', stepType: 'doc-change', summary: 'Update the docs', acceptance: 'the contract text is clear' },
305
+ ]);
306
+ assert.strictEqual(missingVerify.planned.status, 2);
307
+ assert.match(missingVerify.planned.stderr, /explicit verification step/);
308
+ const reviewOnly = planSession('registry-review-only', [
309
+ { id: 'req-review', stepType: 'review', summary: 'Review the change', acceptance: 'the change is reviewed' },
310
+ ]);
311
+ assert.strictEqual(reviewOnly.planned.status, 2);
312
+ assert.match(reviewOnly.planned.stderr, /explicit verification step/);
313
+ const verifyOnly = planSession('registry-verify-only', [
314
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify existing behavior', acceptance: 'objective checks pass' },
315
+ ]);
316
+ assert.strictEqual(verifyOnly.planned.status, 0, verifyOnly.planned.stderr);
317
+ });
318
+
319
+ checkCase('explicit TDD is legal on doc-change while review continues to forbid it', () => {
320
+ const docChange = planSession('registry-doc-change-explicit-tdd', [
321
+ { id: 'req-docs', stepType: 'doc-change', summary: 'Update docs with a test', acceptance: 'the docs are accurate', optionalBindings: ['tdd'] },
322
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify the docs', acceptance: 'links and examples are checked' },
323
+ ]);
324
+ assert.strictEqual(docChange.planned.status, 0, docChange.planned.stderr);
325
+ const optional = readJson(docChange.obligationsFile).requirements[0].optionalBindings;
326
+ assert.deepStrictEqual(optional.map(binding => binding.id), ['tdd']);
327
+ for (const field of ['requiredBindings', 'optionalBindings']) {
328
+ const declarations = { [field]: ['tdd'] };
329
+ const review = planSession(`registry-review-tdd-${field}`, [
330
+ { id: 'req-review', stepType: 'review', summary: 'Review the change', acceptance: 'the change is reviewed', ...declarations },
331
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify the change', acceptance: 'objective checks pass' },
332
+ ]);
333
+ assert.strictEqual(review.planned.status, 2);
334
+ assert.match(review.planned.stderr, /review steps cannot bind tdd/);
335
+ }
336
+ });
337
+
338
+ checkCase('registry optional defaults precede declarations and suggestions, with required-id deduplication', () => {
339
+ const fixture = createRuntimeFixture('registry-optional-default-fixture');
340
+ const fixtureRegistry = readJson(fixture.registryFile);
341
+ fixtureRegistry.find(entry => entry.id === 'doc-change').optionalBindings = ['review-guidance', 'security-review', 'tdd'];
342
+ fs.writeFileSync(fixture.registryFile, JSON.stringify(fixtureRegistry, null, 2) + '\n');
343
+ const session = planSession('registry-optional-default-fixture', [
344
+ {
345
+ id: 'req-docs', stepType: 'doc-change', summary: 'Update the docs', acceptance: 'the contract text is clear',
346
+ requiredBindings: ['tdd'], optionalBindings: ['security-review'], suggestedSkills: ['review-guidance', 'security-review'],
347
+ },
348
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify the docs', acceptance: 'links and examples are checked' },
349
+ ], 'iterative-single', fixture.workspace, FIX_PROMPT, 'fixture registry tests optional defaults', fixture.root);
350
+ assert.strictEqual(session.routed.status, 0, session.routed.stderr);
351
+ assert.strictEqual(session.planned.status, 0, session.planned.stderr);
352
+ const doc = readJson(session.obligationsFile).requirements[0];
353
+ assert.deepStrictEqual(doc.requiredBindings.map(binding => binding.id), ['tdd']);
354
+ assert.deepStrictEqual(doc.optionalBindings.map(binding => binding.id), ['review-guidance', 'security-review']);
355
+ });
356
+
357
+ const FABLE_STRATEGIES = ['fable-staged', 'fable-parallel', 'fable-multi-agent-workspace'];
358
+ const UNTYPED_REQUIREMENTS = [{ id: 'req-work', summary: 'Do the work', acceptance: 'the outcome is verified' }];
359
+ const TYPED_FABLE_REQUIREMENTS = [{ id: 'req-docs', stepType: 'doc-change', summary: 'Update the docs', acceptance: 'the contract text is clear', optionalBindings: ['tdd'] }];
360
+ const FABLE_STAGES = [
361
+ {
362
+ stageId: 'implement', goal: 'implement a bounded change', agent: 'fable-worker-sonnet', task: 'implement the change',
363
+ dependsOn: [], writeSet: ['src/change.js'], inputs: [], expectedOutputs: ['src/change.js'], requiredBindings: [], optionalBindings: [],
364
+ checkCommand: 'node fixture-check.js', passCondition: 'exit 0',
365
+ },
366
+ {
367
+ stageId: 'verify', goal: 'verify the implementation', agent: 'fable-verifier', task: 'independently check the implementation',
368
+ dependsOn: ['implement'], writeSet: [], inputs: ['src/change.js'], expectedOutputs: ['verification evidence'], requiredBindings: [], optionalBindings: [],
369
+ checkCommand: 'node verifier-check.js', passCondition: 'exit 0',
370
+ },
371
+ ];
372
+
373
+ // Runs one stage check through the contract hook, which records the correlated stage evidence.
374
+ function runContractCheck(fixture, sessionId, workerId, command) {
375
+ return run(path.join(fixture.root, 'hooks', 'scripts', 'contract-test.js'), [], fixture.workspace, {
376
+ tool_name: 'Bash', session_id: sessionId, cwd: fixture.workspace, worker_id: workerId,
377
+ tool_input: { command }, tool_response: { stdout: `${command} passed`, exitCode: 0 },
378
+ });
379
+ }
380
+
381
+ // A persisted legacy untyped single-agent plan: the typed plan rewritten to the pre-registry obligation shape.
382
+ function legacyUntypedPlan(fixture, sessionId) {
383
+ const session = planSession(sessionId, [
384
+ { id: 'req-docs', stepType: 'doc-change', summary: 'Update the docs', acceptance: 'the contract text is clear' },
385
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify the docs', acceptance: 'links and examples are checked' },
386
+ ], 'iterative-single', fixture.workspace, FIX_PROMPT, 'prepare legacy untyped registry-fault fixture', fixture.root);
387
+ const set = readJson(session.obligationsFile);
388
+ set.stepBindingsEnabled = false;
389
+ set.activeStepId = null;
390
+ set.requirements = set.requirements.map(item => ({
391
+ id: item.id, order: item.order, summary: item.summary, acceptance: item.acceptance, suggestedSkills: item.suggestedSkills || [],
392
+ }));
393
+ fs.writeFileSync(session.obligationsFile, JSON.stringify(set, null, 2));
394
+ return session;
395
+ }
396
+
397
+ function plannedFableSession(fixture, sessionId, requirements, strategy) {
398
+ const session = planSession(sessionId, requirements, strategy, fixture.workspace, FIX_PROMPT, 'prepare Fable start registry-fault fixture', fixture.root);
399
+ fs.writeFileSync(path.join(session.sessionDir, 'workflow-stages.json'), JSON.stringify(FABLE_STAGES, null, 2));
400
+ return session;
401
+ }
402
+
403
+ function createFableRun(fixture, sessionId) {
404
+ const { prepareRun } = require(path.join(fixture.root, 'fable-mode', 'scripts', 'workflow-plan-consumer.js'));
405
+ fs.mkdirSync(path.join(fixture.workspace, 'tdd'), { recursive: true });
406
+ fs.writeFileSync(path.join(fixture.workspace, 'tdd', 'SKILL.md'), '# TDD\n');
407
+ const workflowId = `workflow-${sessionId}`;
408
+ const workflowPlan = { strategySelection: 'selected', strategy: 'fable-staged', tier: 'tier3', verification: { independent: false } };
409
+ const run = prepareRun({
410
+ routerContract: { workflowPlan },
411
+ workspaceRoot: fixture.workspace,
412
+ runId: `${sessionId}-run`,
413
+ sessionId,
414
+ workflowId,
415
+ stages: [{
416
+ stageId: 'implement', goal: 'implement a bounded change', agent: 'fable-worker-sonnet', task: 'use declared guidance and verify',
417
+ dependsOn: [], writeSet: ['src/change.js'], inputs: [], expectedOutputs: ['src/change.js'],
418
+ requiredBindings: [{ id: 'tdd', path: 'tdd/SKILL.md' }], checkCommand: 'node fixture-check.js', passCondition: 'exit 0',
419
+ }, {
420
+ stageId: 'verify', goal: 'verify the implementation', agent: 'fable-verifier', task: 'independently check the implementation',
421
+ dependsOn: ['implement'], writeSet: [], inputs: ['src/change.js'], expectedOutputs: ['verification evidence'],
422
+ requiredBindings: [{ id: 'verification-loop', path: 'verification-loop/SKILL.md' }], checkCommand: 'node verifier-check.js', passCondition: 'exit 0',
423
+ }],
424
+ });
425
+ const sessionDir = getSessionDir(fixture.workspace, sessionId);
426
+ fs.mkdirSync(sessionDir, { recursive: true });
427
+ const workflowFile = path.join(sessionDir, 'workflow-run.json');
428
+ fs.writeFileSync(workflowFile, JSON.stringify({
429
+ schemaVersion: 2, sessionId, workflowId, state: 'running', strategy: 'fable-staged', tier: 'tier3', revision: 1,
430
+ workflowPlan, runId: run.runId, escapes: [],
431
+ }, null, 2));
432
+ return { payload: { session_id: sessionId, cwd: fixture.workspace, prompt: FIX_PROMPT }, workflowFile };
433
+ }
434
+
435
+ for (const registryFault of ['missing', 'malformed']) {
436
+ const fixture = createRuntimeFixture(`registry-${registryFault}-fixture`);
437
+ const retained = planSession(`registry-${registryFault}-retained`, [
438
+ { id: 'req-docs', stepType: 'doc-change', summary: 'Update the docs', acceptance: 'the contract text is clear' },
439
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify the docs', acceptance: 'links and examples are checked' },
440
+ ], 'iterative-single', fixture.workspace, FIX_PROMPT, 'prepare retained registry-fault fixture', fixture.root);
441
+ checkCase(`registry ${registryFault} retained-session precondition`, () => {
442
+ assert.strictEqual(retained.routed.status, 0, retained.routed.stderr);
443
+ assert.strictEqual(retained.planned.status, 0, retained.planned.stderr);
444
+ });
445
+ const typedRequirements = [
446
+ { id: 'req-docs', stepType: 'doc-change', summary: 'Update the docs', acceptance: 'the contract text is clear' },
447
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify the docs', acceptance: 'links and examples are checked' },
448
+ ];
449
+ // Prepared while the registry is healthy: a started typed workflow, a pending block target, and a Fable run.
450
+ const running = planSession(`registry-${registryFault}-running`, typedRequirements, 'iterative-single', fixture.workspace, FIX_PROMPT, 'prepare running typed registry-fault fixture', fixture.root);
451
+ const started = run(fixture.controller, ['start', '--session-id', running.payload.session_id], fixture.workspace);
452
+ const blockTarget = planSession(`registry-${registryFault}-block`, typedRequirements, 'iterative-single', fixture.workspace, FIX_PROMPT, 'prepare block registry-fault fixture', fixture.root);
453
+ const fable = createFableRun(fixture, `registry-${registryFault}-fable`);
454
+ // Registry-independent fixtures, prepared while healthy and exercised under the fault.
455
+ const legacyStart = legacyUntypedPlan(fixture, `registry-${registryFault}-legacy-start`);
456
+ const legacyRunning = legacyUntypedPlan(fixture, `registry-${registryFault}-legacy-running`);
457
+ const legacyRunningStart = run(fixture.controller, ['start', '--session-id', legacyRunning.payload.session_id], fixture.workspace);
458
+ const fableUntypedPlans = FABLE_STRATEGIES.map(strategy => ({
459
+ strategy, session: routeSession(`registry-${registryFault}-fable-untyped-plan-${strategy}`, FIX_PROMPT, fixture.workspace, fixture.root),
460
+ }));
461
+ const fableStarts = FABLE_STRATEGIES.map(strategy => ({
462
+ strategy,
463
+ untyped: plannedFableSession(fixture, `registry-${registryFault}-fable-untyped-start-${strategy}`, UNTYPED_REQUIREMENTS, strategy),
464
+ typed: plannedFableSession(fixture, `registry-${registryFault}-fable-typed-start-${strategy}`, TYPED_FABLE_REQUIREMENTS, strategy),
465
+ }));
466
+ const fableControls = FABLE_STRATEGIES.flatMap(strategy => [
467
+ { name: `${strategy} untyped`, session: plannedFableSession(fixture, `registry-${registryFault}-fable-untyped-control-${strategy}`, UNTYPED_REQUIREMENTS, strategy) },
468
+ { name: `${strategy} typed`, session: plannedFableSession(fixture, `registry-${registryFault}-fable-typed-control-${strategy}`, TYPED_FABLE_REQUIREMENTS, strategy) },
469
+ ]).map(control => ({ ...control, start: run(fixture.controller, ['start', '--session-id', control.session.payload.session_id], fixture.workspace) }));
470
+ // Ready registry-independent workflows: every required obligation or stage passes before the fault.
471
+ const readyLegacy = legacyUntypedPlan(fixture, `registry-${registryFault}-legacy-ready`);
472
+ const readyLegacyStart = run(fixture.controller, ['start', '--session-id', readyLegacy.payload.session_id], fixture.workspace);
473
+ const readyLegacyPasses = ['execute', 'verify'].map(obligationId => run(fixture.controller, [
474
+ 'obligation', '--session-id', readyLegacy.payload.session_id, '--obligation-id', obligationId,
475
+ '--disposition', 'pass', '--evidence', `${obligationId} evidence for the ready untyped fixture`,
476
+ ], fixture.workspace));
477
+ const readyFable = plannedFableSession(fixture, `registry-${registryFault}-fable-ready`, UNTYPED_REQUIREMENTS, 'fable-staged');
478
+ const readyFableStart = run(fixture.controller, ['start', '--session-id', readyFable.payload.session_id], fixture.workspace);
479
+ const readyFableChecks = [
480
+ runContractCheck(fixture, readyFable.payload.session_id, 'worker-1', 'node fixture-check.js'),
481
+ runContractCheck(fixture, readyFable.payload.session_id, 'worker-2', 'node verifier-check.js'),
482
+ ];
483
+ checkCase(`registry ${registryFault} ready registry-independent workflows are fully evidenced before the fault`, () => {
484
+ assert.strictEqual(readyLegacyStart.status, 0, readyLegacyStart.stderr);
485
+ for (const pass of readyLegacyPasses) assert.strictEqual(pass.status, 0, pass.stderr);
486
+ assert.strictEqual(readyFableStart.status, 0, readyFableStart.stderr);
487
+ for (const check of readyFableChecks) assert.strictEqual(check.status, 0, check.stderr);
488
+ });
489
+
490
+ checkCase(`registry ${registryFault} registry-independent fixtures are prepared and healthy controls start`, () => {
491
+ assert.strictEqual(legacyStart.planned.status, 0, legacyStart.planned.stderr);
492
+ assert.strictEqual(legacyRunningStart.status, 0, legacyRunningStart.stderr);
493
+ for (const { session } of fableUntypedPlans) assert.strictEqual(session.routed.status, 0, session.routed.stderr);
494
+ for (const { untyped, typed } of fableStarts) {
495
+ assert.strictEqual(untyped.planned.status, 0, untyped.planned.stderr);
496
+ assert.strictEqual(typed.planned.status, 0, typed.planned.stderr);
497
+ }
498
+ for (const control of fableControls) assert.strictEqual(control.start.status, 0, `${control.name} healthy start: ${control.start.stderr}`);
499
+ });
500
+ checkCase(`registry ${registryFault} running, block, and Fable fixtures are prepared while the registry is healthy`, () => {
501
+ assert.strictEqual(running.planned.status, 0, running.planned.stderr);
502
+ assert.strictEqual(started.status, 0, started.stderr);
503
+ assert.strictEqual(blockTarget.planned.status, 0, blockTarget.planned.stderr);
504
+ });
505
+ const beforeWorkflow = fs.readFileSync(retained.workflowFile);
506
+ const beforeObligations = fs.readFileSync(retained.obligationsFile);
507
+ const registryBytes = fs.readFileSync(fixture.registryFile);
508
+ if (registryFault === 'missing') fs.rmSync(fixture.registryFile);
509
+ else fs.writeFileSync(fixture.registryFile, '{broken');
510
+
511
+ checkCase(`registry ${registryFault} retained router keeps active-step failure visible and state unchanged`, () => {
512
+ const routed = run(fixture.kernel, [], fixture.workspace, retained.payload);
513
+ assert.strictEqual(routed.status, 0, routed.stderr);
514
+ semanticRegistryError(routed, fixture.registryFile);
515
+ assert.match(routed.stdout, /active[- ]step context unavailable/i);
516
+ assert.deepStrictEqual(fs.readFileSync(retained.workflowFile), beforeWorkflow);
517
+ assert.deepStrictEqual(fs.readFileSync(retained.obligationsFile), beforeObligations);
518
+ });
519
+
520
+ checkCase(`registry ${registryFault} retained Codex adapter exposes the actionable context`, () => {
521
+ const result = run(fixture.codexAdapter, [], fixture.workspace, retained.payload);
522
+ assert.strictEqual(result.status, 0, result.stderr);
523
+ const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
524
+ semanticRegistryError({ stdout: context }, fixture.registryFile);
525
+ assert.match(context, /active[- ]step context unavailable/i);
526
+ assert.deepStrictEqual(fs.readFileSync(retained.workflowFile), beforeWorkflow);
527
+ assert.deepStrictEqual(fs.readFileSync(retained.obligationsFile), beforeObligations);
528
+ });
529
+
530
+ const fresh = sessionFiles(fixture, `registry-${registryFault}-fresh`);
531
+ const currentSessionFile = path.join(path.dirname(path.dirname(fresh.sessionDir)), 'current-session');
532
+ const currentSessionBefore = fs.existsSync(currentSessionFile) ? fs.readFileSync(currentSessionFile) : null;
533
+ function assertCurrentSessionUnchanged() {
534
+ assert.strictEqual(fs.existsSync(currentSessionFile), currentSessionBefore !== null);
535
+ if (currentSessionBefore !== null) assert.deepStrictEqual(fs.readFileSync(currentSessionFile), currentSessionBefore);
536
+ }
537
+ checkCase(`registry ${registryFault} fresh router reports failure without creating a workflow`, () => {
538
+ const routed = run(fixture.kernel, [], fixture.workspace, fresh.payload);
539
+ assert.strictEqual(routed.status, 0, routed.stderr);
540
+ semanticRegistryError(routed, fixture.registryFile);
541
+ assertCurrentSessionUnchanged();
542
+ assert.ok(!fs.existsSync(fresh.workflowFile), 'a failed fresh route must not persist a default workflow');
543
+ assert.ok(!fs.existsSync(fresh.obligationsFile), 'a failed fresh route must not persist a default obligation plan');
544
+ });
545
+
546
+ checkCase(`registry ${registryFault} fresh Codex adapter includes the failure in additionalContext`, () => {
547
+ const result = run(fixture.codexAdapter, [], fixture.workspace, fresh.payload);
548
+ assert.strictEqual(result.status, 0, result.stderr);
549
+ const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext;
550
+ semanticRegistryError({ stdout: context }, fixture.registryFile);
551
+ assertCurrentSessionUnchanged();
552
+ });
553
+
554
+ checkCase(`registry ${registryFault} Stop hook prints a reminder and preserves its existing exit code/state`, () => {
555
+ const stop = run(fixture.stopGate, [], fixture.workspace, retained.payload);
556
+ assert.strictEqual(stop.status, 0, stop.stderr);
557
+ semanticRegistryError(stop, fixture.registryFile);
558
+ assert.deepStrictEqual(fs.readFileSync(retained.workflowFile), beforeWorkflow);
559
+ assert.deepStrictEqual(fs.readFileSync(retained.obligationsFile), beforeObligations);
560
+ });
561
+
562
+ checkCase(`registry ${registryFault} controller catches load failure with its existing error exit code`, () => {
563
+ const result = run(fixture.controller, [
564
+ 'plan', '--session-id', retained.payload.session_id,
565
+ '--requirements-json', JSON.stringify([
566
+ { id: 'req-docs', stepType: 'doc-change', summary: 'Update the docs', acceptance: 'the contract text is clear' },
567
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify the docs', acceptance: 'links and examples are checked' },
568
+ ]),
569
+ '--strategy', 'iterative-single', '--evidence', 'exercise controller registry-load failure',
570
+ ], fixture.workspace);
571
+ assert.strictEqual(result.status, 2);
572
+ semanticRegistryError(result, fixture.registryFile);
573
+ assert.deepStrictEqual(fs.readFileSync(retained.workflowFile), beforeWorkflow);
574
+ assert.deepStrictEqual(fs.readFileSync(retained.obligationsFile), beforeObligations);
575
+ });
576
+ checkCase(`registry ${registryFault} controller --help prints usage with exit 0 without loading the registry`, () => {
577
+ const help = run(fixture.controller, ['--help'], fixture.workspace);
578
+ assert.strictEqual(help.status, 0, help.stderr);
579
+ assert.match(help.stdout, /Usage: workflow-disposition\.js/);
580
+ assert.doesNotMatch(`${help.stdout}\n${help.stderr}`, /step-type registry/);
581
+ });
582
+
583
+ checkCase(`registry ${registryFault} controller block records the blocked disposition with exit 0`, () => {
584
+ const blocked = run(fixture.controller, ['block', '--session-id', blockTarget.payload.session_id, '--evidence', 'registry fault: blocked for owner review'], fixture.workspace);
585
+ assert.strictEqual(blocked.status, 0, blocked.stderr);
586
+ const workflow = readJson(blockTarget.workflowFile);
587
+ assert.strictEqual(workflow.state, 'blocked');
588
+ assert.strictEqual(workflow.blockReason, 'registry fault: blocked for owner review');
589
+ });
590
+
591
+ checkCase(`registry ${registryFault} Fable stage-binding resolves with exit 0 and no registry`, () => {
592
+ const result = run(fixture.controller, [
593
+ 'stage-binding', '--session-id', fable.payload.session_id, '--stage-id', 'implement', '--binding-id', 'tdd',
594
+ '--disposition', 'loaded', '--evidence', 'Read tdd/SKILL.md before implementation.',
595
+ ], fixture.workspace);
596
+ assert.strictEqual(result.status, 0, result.stderr);
597
+ assert.strictEqual(JSON.parse(result.stdout).binding.status, 'loaded');
598
+ });
599
+
600
+ checkCase(`registry ${registryFault} Fable escape persists on the workflow with exit 0 and no registry`, () => {
601
+ const result = run(fixture.controller, [
602
+ 'escape', '--session-id', fable.payload.session_id, '--stage-id', 'implement', '--reason-code', 'workflow-uncovered-scope',
603
+ '--scope', 'fixture-only uncovered scope', '--evidence', 'registry fault fixture escape',
604
+ ], fixture.workspace);
605
+ assert.strictEqual(result.status, 0, result.stderr);
606
+ assert.ok(readJson(fable.workflowFile).escapes.some(item => item.stageId === 'implement'), 'escape must persist for implement');
607
+ });
608
+
609
+ const stopPayload = session => ({ hook_event_name: 'Stop', session_id: session.payload.session_id, cwd: fixture.workspace });
610
+ const registryFree = output => assert.doesNotMatch(output, /step-type registry|step-type-registry-unavailable/, output);
611
+
612
+ checkCase(`registry ${registryFault} ready untyped generic workflow completes on Stop without a registry read`, () => {
613
+ const stop = run(fixture.stopGate, [], fixture.workspace, stopPayload(readyLegacy));
614
+ assert.strictEqual(stop.status, 0, stop.stderr);
615
+ registryFree(`${stop.stdout}\n${stop.stderr}`);
616
+ assert.strictEqual(readJson(readyLegacy.workflowFile).state, 'satisfied', 'a fully evidenced untyped workflow must complete');
617
+ });
618
+
619
+ checkCase(`registry ${registryFault} ready Fable workflow completes on Stop without a registry read`, () => {
620
+ const stop = run(fixture.stopGate, [], fixture.workspace, stopPayload(readyFable));
621
+ assert.strictEqual(stop.status, 0, stop.stderr);
622
+ registryFree(`${stop.stdout}\n${stop.stderr}`);
623
+ assert.strictEqual(readJson(readyFable.workflowFile).state, 'satisfied', 'a fully evidenced Fable workflow must complete');
624
+ });
625
+
626
+ checkCase(`registry ${registryFault} unresolved Fable verifier evidence stays visible on Stop without a registry diagnostic`, () => {
627
+ const stop = run(fixture.stopGate, [], fixture.workspace, stopPayload(fable));
628
+ assert.strictEqual(stop.status, 0, stop.stderr);
629
+ registryFree(`${stop.stdout}\n${stop.stderr}`);
630
+ assert.match(stop.stderr, /verify:verification-unresolved/);
631
+ assert.notStrictEqual(readJson(fable.workflowFile).state, 'satisfied', 'an unresolved Fable workflow must never be marked satisfied');
632
+ });
633
+
634
+ checkCase(`registry ${registryFault} typed start, step, and binding still fail with the actionable registry error`, () => {
635
+ for (const args of [
636
+ ['start', '--session-id', retained.payload.session_id],
637
+ ['step', '--session-id', running.payload.session_id, '--step-id', 'req-docs', '--disposition', 'pass', '--evidence', 'fixture step'],
638
+ ['binding', '--session-id', running.payload.session_id, '--step-id', 'req-docs', '--binding-id', 'verification-loop', '--disposition', 'not-needed', '--evidence', 'fixture binding'],
639
+ ]) {
640
+ const result = run(fixture.controller, args, fixture.workspace);
641
+ assert.strictEqual(result.status, 2, `${args[0]} exited ${result.status}: ${result.stderr}`);
642
+ semanticRegistryError(result, fixture.registryFile);
643
+ }
644
+ });
645
+
646
+ checkCase(`registry ${registryFault} legacy untyped single-agent start exits 0 without a registry read`, () => {
647
+ const result = run(fixture.controller, ['start', '--session-id', legacyStart.payload.session_id], fixture.workspace);
648
+ assert.strictEqual(result.status, 0, result.stderr);
649
+ assert.strictEqual(readJson(legacyStart.workflowFile).state, 'running');
650
+ });
651
+
652
+ checkCase(`registry ${registryFault} legacy untyped single-agent execute pass exits 0 without a registry read`, () => {
653
+ const result = run(fixture.controller, [
654
+ 'obligation', '--session-id', legacyRunning.payload.session_id, '--obligation-id', 'execute',
655
+ '--disposition', 'pass', '--evidence', 'legacy execution evidence',
656
+ ], fixture.workspace);
657
+ assert.strictEqual(result.status, 0, result.stderr);
658
+ });
659
+
660
+ for (const { strategy, session } of fableUntypedPlans) {
661
+ checkCase(`registry ${registryFault} untyped ${strategy} plan exits 0 without a registry read`, () => {
662
+ const result = run(fixture.controller, [
663
+ 'plan', '--session-id', session.payload.session_id,
664
+ '--requirements-json', JSON.stringify(UNTYPED_REQUIREMENTS),
665
+ '--strategy', strategy, '--evidence', 'untyped Fable plan under registry fault',
666
+ ], fixture.workspace);
667
+ assert.strictEqual(result.status, 0, result.stderr);
668
+ });
669
+ }
670
+
671
+ for (const { strategy, untyped, typed } of fableStarts) {
672
+ checkCase(`registry ${registryFault} pending ${strategy} start exits 0 for typed metadata and untyped plans`, () => {
673
+ for (const session of [typed, untyped]) {
674
+ const result = run(fixture.controller, ['start', '--session-id', session.payload.session_id], fixture.workspace);
675
+ assert.strictEqual(result.status, 0, `${session.payload.session_id}: ${result.stderr}`);
676
+ assert.strictEqual(readJson(session.workflowFile).state, 'running');
677
+ }
678
+ });
679
+ }
680
+
681
+ if (registryFault === 'malformed') fs.writeFileSync(fixture.registryFile, registryBytes);
682
+ }
683
+
684
+ checkCase('unknown step types list sorted registry IDs and leave state files byte-identical', () => {
685
+ const session = routeSession('registry-unknown-type');
686
+ assert.strictEqual(session.routed.status, 0, session.routed.stderr);
687
+ const before = snapshotTree(stateHome);
688
+ const unknownRequirements = [
689
+ { id: 'req-unknown', stepType: 'unknown-kind', summary: 'Do something', acceptance: 'the result is checked' },
690
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify it', acceptance: 'objective checks pass' },
691
+ ];
692
+ const first = run(controller, [
693
+ 'plan', '--session-id', 'registry-unknown-type', '--requirements-json', JSON.stringify(unknownRequirements),
694
+ '--strategy', 'iterative-single', '--evidence', 'exercise deterministic unknown-type failure',
695
+ ]);
696
+ const second = run(controller, [
697
+ 'plan', '--session-id', 'registry-unknown-type', '--requirements-json', JSON.stringify(unknownRequirements),
698
+ '--strategy', 'iterative-single', '--evidence', 'exercise deterministic unknown-type failure',
699
+ ]);
700
+ assert.strictEqual(first.status, 2);
701
+ assert.strictEqual(first.stdout, '');
702
+ assert.deepStrictEqual(second.stderr, first.stderr);
703
+ assert.ok(first.stderr.includes(EXPECTED_IDS.join(', ')), first.stderr);
704
+ assert.strictEqual(snapshotTree(stateHome), before, 'workflow, obligation, and state files stay byte-identical');
705
+ });
706
+
707
+ checkCase('a documentation task does not gain an undeclared doc-change step', () => {
708
+ const session = routeSession('registry-no-inference', 'Fix this checkout bug and update its documentation.');
709
+ assert.strictEqual(session.routed.status, 0, session.routed.stderr);
710
+ const initial = readJson(session.obligationsFile);
711
+ assert.deepStrictEqual(initial.requirements, [], 'routing only creates the planning contract');
712
+ const plan = run(controller, [
713
+ 'plan', '--session-id', 'registry-no-inference', '--requirements-json', JSON.stringify([
714
+ { id: 'req-review', stepType: 'review', summary: 'Review the docs change', acceptance: 'the change is reviewed' },
715
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify the docs', acceptance: 'the links are valid' },
716
+ ]), '--strategy', 'iterative-single', '--evidence', 'author deliberately declares review and verification only',
717
+ ]);
718
+ assert.strictEqual(plan.status, 0, plan.stderr);
719
+ assert.deepStrictEqual(readJson(session.obligationsFile).requirements.map(item => item.stepType), ['review', 'verification']);
720
+ });
721
+
722
+ function legacyRequirements2an() {
723
+ return [
724
+ { id: 'req-review', stepType: 'review', summary: 'Inspect the existing checkout change', acceptance: 'the relevant diff has been reviewed', optionalBindings: ['security-review', 'review-guidance'] },
725
+ { id: 'req-change', stepType: 'behavior-change', summary: 'Correct the checkout behavior', acceptance: 'the reported behavior is corrected' },
726
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify the checkout change', acceptance: 'objective verification evidence is recorded' },
727
+ { id: 'req-commit', stepType: 'commit', summary: 'Commit the requested change', acceptance: 'the authorized commit is recorded' },
728
+ ];
729
+ }
730
+
731
+ function legacyRequirements2ar() {
732
+ return [
733
+ { id: 'req-change', stepType: 'behavior-change', summary: 'Correct the checkout behavior', acceptance: 'the behavior is corrected', requiredBindings: [{ id: 'domain-reference', path: 'docs/pr303-missing-reference.md' }], optionalBindings: [{ id: 'routing-reference', path: 'docs/routing.md' }] },
734
+ { id: 'req-verify', stepType: 'verification', summary: 'Verify the change', acceptance: 'verification evidence is recorded' },
735
+ ];
736
+ }
737
+
738
+ checkCase('existing 2an four-type obligation bytes match the frozen pre-registry baseline', () => {
739
+ const result = planSession('registry-legacy-2an', legacyRequirements2an(), 'iterative-single', ROOT, FIX_PROMPT,
740
+ 'the request contains review, behavior-change, verification, and commit steps');
741
+ assert.strictEqual(result.routed.status, 0, result.routed.stderr);
742
+ assert.strictEqual(result.planned.status, 0, result.planned.stderr);
743
+ assert.strictEqual(frozenObligationHash(result.obligationsFile, ROOT), LEGACY_2AN_SHA256);
744
+ });
745
+
746
+ checkCase('existing 2ar binding obligation bytes match the frozen pre-registry baseline', () => {
747
+ const workspace = path.join(tempRoot, '2ar-empty-workspace');
748
+ fs.mkdirSync(workspace, { recursive: true });
749
+ const result = planSession('registry-legacy-2ar', legacyRequirements2ar(), 'iterative-single', workspace, FIX_PROMPT,
750
+ 'behavior change then verification');
751
+ assert.strictEqual(result.routed.status, 0, result.routed.stderr);
752
+ assert.strictEqual(result.planned.status, 0, result.planned.stderr);
753
+ assert.strictEqual(frozenObligationHash(result.obligationsFile, workspace), LEGACY_2AR_SHA256);
754
+ });
755
+
756
+ checkCase('schema v1 persisted plans load without rewriting bytes after volatile fields are frozen', () => {
757
+ const result = planSession('registry-old-plan-load', legacyRequirements2an());
758
+ assert.strictEqual(result.routed.status, 0, result.routed.stderr);
759
+ assert.strictEqual(result.planned.status, 0, result.planned.stderr);
760
+ const workflow = readJson(result.workflowFile);
761
+ const obligations = readJson(result.obligationsFile);
762
+ const stableId = '00000000-0000-4000-8000-000000000326';
763
+ workflow.workflowId = stableId;
764
+ obligations.workflowId = stableId;
765
+ const freeze = value => {
766
+ if (Array.isArray(value)) return value.forEach(freeze);
767
+ if (!value || typeof value !== 'object') return;
768
+ for (const [key, item] of Object.entries(value)) {
769
+ if (['createdAt', 'updatedAt', 'confirmedAt'].includes(key)) value[key] = '2026-01-01T00:00:00.000Z';
770
+ else if (key === 'revision') value[key] = 1;
771
+ else freeze(item);
772
+ }
773
+ };
774
+ freeze(workflow);
775
+ freeze(obligations);
776
+ obligations.revision = workflow.revision;
777
+ fs.writeFileSync(result.workflowFile, JSON.stringify(workflow, null, 2) + '\n');
778
+ fs.writeFileSync(result.obligationsFile, JSON.stringify(obligations, null, 2) + '\n');
779
+ const before = fs.readFileSync(result.obligationsFile);
780
+ const reloaded = run(kernel, [], ROOT, result.payload);
781
+ assert.strictEqual(reloaded.status, 0, reloaded.stderr);
782
+ assert.deepStrictEqual(fs.readFileSync(result.obligationsFile), before);
783
+ assert.strictEqual(readJson(result.obligationsFile).schemaVersion, 1);
784
+ });
785
+
786
+ checkCase('all three Fable strategies preserve typed and untyped plan behavior', () => {
787
+ for (const strategy of ['fable-staged', 'fable-parallel', 'fable-multi-agent-workspace']) {
788
+ const typed = planSession(`registry-${strategy}-typed`, [
789
+ { id: 'req-docs', stepType: 'doc-change', summary: 'Update the docs', acceptance: 'the contract text is clear', optionalBindings: ['tdd'] },
790
+ ], strategy);
791
+ assert.strictEqual(typed.routed.status, 0, typed.routed.stderr);
792
+ assert.strictEqual(typed.planned.status, 0, `${strategy} typed: ${typed.planned.stderr}`);
793
+ const typedState = readJson(typed.obligationsFile);
794
+ assert.strictEqual(typedState.stepBindingsEnabled, false);
795
+ assert.strictEqual(typedState.activeStepId, null);
796
+ assert.deepStrictEqual(typedState.obligations.map(item => item.id), ['decompose', 'compose']);
797
+ assert.deepStrictEqual(typedState.requirements[0].optionalBindings.map(binding => [binding.id, binding.status]), [['tdd', 'pending']]);
798
+ assert.ok(!fs.existsSync(path.join(typed.sessionDir, 'workflow-stages.json')), `${strategy} registry recognition adds no Fable stage`);
799
+
800
+ const untyped = planSession(`registry-${strategy}-untyped`, [
801
+ { id: 'req-work', summary: 'Do the work', acceptance: 'the outcome is verified' },
802
+ ], strategy);
803
+ assert.strictEqual(untyped.routed.status, 0, untyped.routed.stderr);
804
+ assert.strictEqual(untyped.planned.status, 0, `${strategy} untyped: ${untyped.planned.stderr}`);
805
+ const untypedState = readJson(untyped.obligationsFile);
806
+ assert.strictEqual(untypedState.stepBindingsEnabled, false);
807
+ assert.strictEqual(untypedState.requirements[0].stepType, undefined);
808
+ assert.deepStrictEqual(untypedState.obligations.map(item => item.id), ['decompose', 'compose']);
809
+ }
810
+ });
811
+
812
+ checkCase('Claude hook copy is byte-identical and a tracked Codex runtime includes the registry', () => {
813
+ assert.ok(fs.existsSync(packageRegistryFile), 'plugin:sync must ship hooks/scripts/lib/step-types.json');
814
+ assert.deepStrictEqual(fs.readFileSync(packageRegistryFile), fs.readFileSync(sourceRegistryFile));
815
+ const { repo, source } = createTrackedCompatSource();
816
+ const tracked = git(repo, ['ls-files', '--', 'plugins/harness-everything/hooks/scripts/lib/step-types.json']);
817
+ assert.strictEqual(tracked, 'plugins/harness-everything/hooks/scripts/lib/step-types.json');
818
+ const codexHome = path.join(tempRoot, 'codex-home');
819
+ const installed = compat.install({ codexHome, sourcePluginRoot: source });
820
+ const runtimeRegistry = path.join(installed.runtimeDir, 'hooks/scripts/lib/step-types.json');
821
+ assert.ok(fs.existsSync(runtimeRegistry), `tracked runtime file list omitted step-types.json: ${installed.runtimeDir}`);
822
+ assert.deepStrictEqual(fs.readFileSync(runtimeRegistry), fs.readFileSync(sourceRegistryFile));
823
+ console.log(` CODEX_COMPAT_TRACKED_FILE ${tracked}`);
824
+ console.log(` CODEX_COMPAT_RUNTIME_FILE ${path.relative(installed.runtimeDir, runtimeRegistry).split(path.sep).join('/')}`);
825
+ const runtimeHelp = spawnSync(process.execPath, [path.join(installed.runtimeDir, 'hooks/scripts/workflow-disposition.js'), '--help'], {
826
+ cwd: tempRoot,
827
+ encoding: 'utf8',
828
+ env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome, HARNESS_STATE_HOME: path.join(tempRoot, 'runtime-state') },
829
+ });
830
+ assert.strictEqual(runtimeHelp.status, 0, runtimeHelp.stderr);
831
+ assert.match(runtimeHelp.stdout, /^Usage: workflow-disposition\.js/);
832
+ compat.uninstall({ codexHome });
833
+ });
834
+
835
+ fs.rmSync(tempRoot, { recursive: true, force: true });
836
+ console.log(`\n${failed === 0 ? 'PASS' : 'FAIL'}: Stage A workflow step-type registry (${failed} failures)`);
837
+ process.exit(failed === 0 ? 0 : 1);