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
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
3
4
  const path = require('path');
4
5
  const { atomicWriteJson, readJson } = require('./fable-contracts');
5
6
  const { resolveBinding, PACKAGED_BINDING_PATHS: BINDING_PATHS } = require('./binding-resolver');
@@ -8,13 +9,102 @@ const OBLIGATION_SCHEMA_VERSION = 1;
8
9
  const OBLIGATION_FILE = 'workflow-obligations.json';
9
10
  const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
11
  const ALLOWED_ESCAPE_REASONS = new Set(['workflow-uncovered-scope', 'host-capability-unavailable']);
11
- const STEP_TYPES = new Set(['review', 'behavior-change', 'verification', 'commit']);
12
- const REQUIRED_BINDINGS_BY_STEP = {
13
- review: [],
14
- 'behavior-change': ['tdd'],
15
- verification: ['verification-loop'],
16
- commit: ['git-commit'],
17
- };
12
+ const STEP_TYPE_REGISTRY_FILE = path.join(__dirname, 'step-types.json');
13
+
14
+ function validateStepTypeRegistry(registry) {
15
+ const errors = [];
16
+ if (!Array.isArray(registry) || registry.length === 0) {
17
+ throw new Error('invalid step-type registry: expected a non-empty array');
18
+ }
19
+ const idCounts = new Map();
20
+ const bindingFields = ['requiredBindings', 'optionalBindings', 'forbiddenBindings'];
21
+ for (const entry of registry) {
22
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
23
+ errors.push('entry must be an object');
24
+ continue;
25
+ }
26
+ const id = typeof entry.id === 'string' ? entry.id : '';
27
+ const validId = SAFE_ID.test(id) && id === id.trim();
28
+ const label = validId ? `step type ${id}` : 'step type with invalid id';
29
+ if (!validId) errors.push(`${label} must declare a stable id`);
30
+ else idCounts.set(id, (idCounts.get(id) || 0) + 1);
31
+ if (typeof entry.evidenceHint !== 'string' || !entry.evidenceHint.trim()) {
32
+ errors.push(`${label} evidenceHint must be a non-empty string`);
33
+ }
34
+ const valuesByField = new Map();
35
+ for (const field of bindingFields) {
36
+ if (!Array.isArray(entry[field])) {
37
+ errors.push(`${label} ${field} must be an array`);
38
+ valuesByField.set(field, []);
39
+ continue;
40
+ }
41
+ const values = [];
42
+ for (const binding of entry[field]) {
43
+ if (typeof binding !== 'string' || !SAFE_ID.test(binding) || binding !== binding.trim()) {
44
+ errors.push(`${label} ${field} entries must be stable binding ids`);
45
+ continue;
46
+ }
47
+ values.push(binding);
48
+ }
49
+ const unique = [...new Set(values)].sort();
50
+ if (unique.length !== values.length) errors.push(`${label} ${field} contains duplicate binding ids`);
51
+ valuesByField.set(field, unique);
52
+ }
53
+ for (const [left, right] of [
54
+ ['requiredBindings', 'optionalBindings'],
55
+ ['requiredBindings', 'forbiddenBindings'],
56
+ ['optionalBindings', 'forbiddenBindings'],
57
+ ]) {
58
+ const rightValues = new Set(valuesByField.get(right) || []);
59
+ for (const binding of valuesByField.get(left) || []) {
60
+ if (rightValues.has(binding)) errors.push(`${label} has conflicting ${left} and ${right} default for ${binding}`);
61
+ }
62
+ }
63
+ }
64
+ for (const id of [...idCounts.keys()].sort()) {
65
+ if (idCounts.get(id) > 1) errors.push(`duplicate step type id ${id}`);
66
+ }
67
+ if (errors.length) throw new Error('invalid step-type registry: ' + [...new Set(errors)].sort().join('; '));
68
+ return registry.map(entry => ({
69
+ id: entry.id,
70
+ requiredBindings: [...entry.requiredBindings],
71
+ optionalBindings: [...entry.optionalBindings],
72
+ forbiddenBindings: [...entry.forbiddenBindings],
73
+ evidenceHint: entry.evidenceHint.trim(),
74
+ })).sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
75
+ }
76
+
77
+ const STEP_TYPE_REGISTRY_UNAVAILABLE = 'STEP_TYPE_REGISTRY_UNAVAILABLE';
78
+
79
+ function readStepTypeRegistry(registryFile = STEP_TYPE_REGISTRY_FILE) {
80
+ const resolvedRegistryFile = path.resolve(registryFile);
81
+ try {
82
+ const parsed = JSON.parse(fs.readFileSync(resolvedRegistryFile, 'utf8'));
83
+ return validateStepTypeRegistry(parsed);
84
+ } catch (error) {
85
+ const unavailable = new Error(
86
+ `cannot load step-type registry ${resolvedRegistryFile}: ${error.message}. ` +
87
+ `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.`,
88
+ );
89
+ unavailable.code = STEP_TYPE_REGISTRY_UNAVAILABLE;
90
+ throw unavailable;
91
+ }
92
+ }
93
+
94
+ // Loaded on first typed use only, so registry-independent planning and execution never read it.
95
+ let stepTypeRegistry = null;
96
+ function loadStepTypeRegistry() {
97
+ if (!stepTypeRegistry) stepTypeRegistry = readStepTypeRegistry();
98
+ return stepTypeRegistry;
99
+ }
100
+
101
+ function stepTypeIds() {
102
+ return loadStepTypeRegistry().map(entry => entry.id).sort();
103
+ }
104
+
105
+ function stepTypeIndex() {
106
+ return new Map(loadStepTypeRegistry().map(entry => [entry.id, entry]));
107
+ }
18
108
 
19
109
  function obligationPath(sessionDir) {
20
110
  return path.join(sessionDir, OBLIGATION_FILE);
@@ -125,6 +215,14 @@ function initializePlanningContract(context, plan) {
125
215
  return set;
126
216
  }
127
217
 
218
+ function typedStepType(rawStepType) {
219
+ const registered = stepTypeIndex();
220
+ if (!registered.has(rawStepType)) {
221
+ throw new Error(`unsupported requirement stepType: ${rawStepType}; registered step types: ${stepTypeIds().join(', ')}`);
222
+ }
223
+ return registered.get(rawStepType);
224
+ }
225
+
128
226
  function parseRequirements(requirementsJson, workspaceRoot) {
129
227
  let parsed;
130
228
  try { parsed = JSON.parse(String(requirementsJson || '')); }
@@ -145,7 +243,7 @@ function parseRequirements(requirementsJson, workspaceRoot) {
145
243
  ? [...new Set(item.suggestedSkills.filter(value => typeof value === 'string' && value.trim()).map(value => value.trim()))]
146
244
  : [];
147
245
  const rawStepType = item.stepType === undefined ? '' : String(item.stepType).trim();
148
- if (rawStepType && !STEP_TYPES.has(rawStepType)) throw new Error(`unsupported requirement stepType: ${rawStepType}`);
246
+ const stepType = rawStepType ? typedStepType(rawStepType) : null;
149
247
  for (const field of ['requiredBindings', 'optionalBindings']) {
150
248
  if (Object.prototype.hasOwnProperty.call(item, field) && !Array.isArray(item[field])) {
151
249
  throw new Error(`${id}.${field} must be an array`);
@@ -155,17 +253,18 @@ function parseRequirements(requirementsJson, workspaceRoot) {
155
253
  if (!rawStepType) return requirement;
156
254
 
157
255
  const requiredBindings = normalizeBindingDeclarations([
158
- ...(REQUIRED_BINDINGS_BY_STEP[rawStepType] || []),
256
+ ...(stepType.requiredBindings || []),
159
257
  ...(item.requiredBindings || []),
160
258
  ]);
161
259
  const requiredIds = new Set(requiredBindings.map(binding => binding.id));
162
260
  const optionalBindings = normalizeBindingDeclarations([
261
+ ...(stepType.optionalBindings || []),
163
262
  ...(item.optionalBindings || []),
164
263
  ...suggestedSkills,
165
264
  ]).filter(binding => !requiredIds.has(binding.id));
166
- if (rawStepType === 'review' && [...requiredBindings, ...optionalBindings].some(binding => binding.id === 'tdd')) {
167
- throw new Error('review steps cannot bind tdd');
168
- }
265
+ const forbiddenBinding = [...requiredBindings, ...optionalBindings]
266
+ .find(binding => stepType.forbiddenBindings.includes(binding.id));
267
+ if (forbiddenBinding) throw new Error(`${rawStepType} steps cannot bind ${forbiddenBinding.id}`);
169
268
  requirement.stepType = rawStepType;
170
269
  requirement.status = 'pending';
171
270
  requirement.evidence = null;
@@ -268,7 +367,7 @@ function recordPlanning(context, requirementsJson, selectedPlan, requestedStrate
268
367
  }
269
368
  const isFable = String(selectedPlan.strategy || '').startsWith('fable-');
270
369
  if (!isFable && !hasTypedSteps) {
271
- throw new Error('selected single-agent workflows require ordered stepType values: review, behavior-change, verification, or commit');
370
+ throw new Error(`selected single-agent workflows require ordered stepType values: ${stepTypeIds().join(', ')}`);
272
371
  }
273
372
  if (!isFable && !requirements.some(item => item.stepType === 'verification')) {
274
373
  throw new Error('step-bound workflows require an explicit verification step');
@@ -360,7 +459,8 @@ function validateObligationSet(context, set, plan = context.workflow?.pendingPla
360
459
  if (!['pending', 'pass', 'escaped', 'blocked'].includes(obligation.status)) return 'obligation-status-invalid';
361
460
  }
362
461
  if (set.stepBindingsEnabled === true) {
363
- if (set.requirements.some(item => !item || !STEP_TYPES.has(item.stepType) ||
462
+ const registered = stepTypeIndex();
463
+ if (set.requirements.some(item => !item || !registered.has(item.stepType) ||
364
464
  !['pending', 'pass', 'blocked'].includes(item.status) || !Array.isArray(item.requiredBindings) ||
365
465
  !Array.isArray(item.optionalBindings))) return 'steps-invalid';
366
466
  for (const step of set.requirements) {
@@ -585,6 +685,9 @@ function unresolvedObligations(context) {
585
685
  module.exports = {
586
686
  OBLIGATION_FILE,
587
687
  ALLOWED_ESCAPE_REASONS,
688
+ loadStepTypeRegistry,
689
+ stepTypeIds,
690
+ validateStepTypeRegistry,
588
691
  obligationPath,
589
692
  needsPlanningContract,
590
693
  needsGenericExecutionObligations,
@@ -7,15 +7,20 @@ const { loadWorkflow, saveWorkflow, matchingRun, OPEN_STATES, WORKFLOW_CONTROLLE
7
7
  const { getWorkspaceRoot, readCurrentSession } = require('./lib/harness-state');
8
8
  const { atomicWriteJson, readJson } = require('./lib/fable-contracts');
9
9
  const { resolveBinding } = require('./lib/binding-resolver');
10
- const {
11
- planningUnresolved,
12
- recordPlanning,
13
- materializeExecutionObligations,
14
- updateObligation,
15
- updateBinding,
16
- updateStep,
17
- activeStepSnapshot,
18
- } = require('./lib/workflow-obligations');
10
+
11
+ let obligationApi;
12
+ function workflowObligations() {
13
+ if (!obligationApi) obligationApi = require('./lib/workflow-obligations');
14
+ return obligationApi;
15
+ }
16
+
17
+ const planningUnresolved = (...args) => workflowObligations().planningUnresolved(...args);
18
+ const recordPlanning = (...args) => workflowObligations().recordPlanning(...args);
19
+ const materializeExecutionObligations = (...args) => workflowObligations().materializeExecutionObligations(...args);
20
+ const updateObligation = (...args) => workflowObligations().updateObligation(...args);
21
+ const updateBinding = (...args) => workflowObligations().updateBinding(...args);
22
+ const updateStep = (...args) => workflowObligations().updateStep(...args);
23
+ const activeStepSnapshot = (...args) => workflowObligations().activeStepSnapshot(...args);
19
24
 
20
25
  const ALLOWED_ESCAPE_REASONS = new Set(['workflow-uncovered-scope', 'host-capability-unavailable']);
21
26
 
@@ -149,6 +154,8 @@ function activatePlan(workflow, plan) {
149
154
  }
150
155
 
151
156
  function main() {
157
+ // The step-type registry loads lazily through the wrappers above, only for commands that
158
+ // consume typed obligations; the top-level handler turns a load failure into one semantic line.
152
159
  if (process.argv[2] === '--help' || process.argv[2] === '-h') {
153
160
  process.stdout.write(usage() + '\n');
154
161
  return;
@@ -3,35 +3,58 @@
3
3
  const path = require('path');
4
4
  const { readJson } = require('./lib/fable-contracts');
5
5
  const { loadWorkflow, saveWorkflow, matchingRun, unresolvedStages, readHookInput } = require('./lib/workflow-runtime');
6
- const { planningUnresolved, unresolvedObligations } = require('./lib/workflow-obligations');
6
+
7
+ const REGISTRY_UNAVAILABLE = 'step-type-registry-unavailable';
8
+ const STEP_TYPE_REGISTRY_UNAVAILABLE = 'STEP_TYPE_REGISTRY_UNAVAILABLE';
7
9
 
8
10
  function warn(message) { console.error('[Workflow Reminder] ' + message); }
9
11
 
10
12
  function decide(payload) {
11
13
  if (!payload) return;
12
14
  try {
15
+ const { planningUnresolved, unresolvedObligations } = require('./lib/workflow-obligations');
13
16
  const context = loadWorkflow(payload);
14
17
  const { workflow, sessionDir } = context;
15
18
  if (!workflow || workflow.state === 'deferred') return;
16
19
  const handoff = readJson(path.join(sessionDir, 'handoff-state.json'));
17
- let unresolved = planningUnresolved(context);
20
+ const unresolved = [];
21
+ const diagnostics = [];
22
+ let requiredFault = false;
23
+
24
+ // Only a validation that actually reads the registry can fail here. It records the
25
+ // semantic diagnostic and the unresolved token itself; other checks keep running.
26
+ const required = check => {
27
+ try {
28
+ unresolved.push(...check());
29
+ } catch (error) {
30
+ if (error.code !== STEP_TYPE_REGISTRY_UNAVAILABLE) throw error;
31
+ diagnostics.push(error.message);
32
+ unresolved.push(REGISTRY_UNAVAILABLE);
33
+ requiredFault = true;
34
+ }
35
+ };
18
36
 
37
+ required(() => planningUnresolved(context));
19
38
  if (String(workflow.strategy || '').startsWith('fable-')) {
20
39
  const match = matchingRun(context);
21
40
  unresolved.push(...(match ? unresolvedStages(match, workflow, handoff?.lastEditAt || 0) : ['correlated-run-missing']));
22
41
  } else {
23
- unresolved.push(...unresolvedObligations(context));
42
+ required(() => unresolvedObligations(context));
24
43
  const lastEdit = Math.max(workflow.lastMutationAt || 0, handoff?.lastEditAt || 0);
25
44
  if (lastEdit && (handoff?.lastVerifyAt || 0) < lastEdit) unresolved.push('verification-after-edit-missing');
26
45
  }
27
46
 
28
- unresolved = [...new Set(unresolved)];
47
+ const pending = [...new Set(unresolved)];
29
48
  const wasBlocked = workflow.state === 'blocked';
30
49
  if (wasBlocked) warn('Workflow is marked BLOCKED; report or revisit the recorded blocker, but Harness will not trap the session.');
31
- if (unresolved.length) {
32
- workflow.unresolved = unresolved;
33
- saveWorkflow(context);
34
- warn('Unresolved workflow evidence: ' + unresolved.join(', ') + '. Resolve/verify before claiming completion.');
50
+ [...new Set(diagnostics)].forEach(message => warn(message));
51
+ if (pending.length) {
52
+ // A failed required validation leaves the workflow file as recorded; it is never marked satisfied.
53
+ if (!requiredFault) {
54
+ workflow.unresolved = pending;
55
+ saveWorkflow(context);
56
+ }
57
+ warn('Unresolved workflow evidence: ' + pending.join(', ') + '. Resolve/verify before claiming completion.');
35
58
  return;
36
59
  }
37
60
  if (wasBlocked) return;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-enforcement",
3
3
  "description": "Repository inventory for the opencode enforcement plugin and its standalone helper",
4
- "version": "0.31.2",
4
+ "version": "0.32.0",
5
5
  "runtime": "index.mjs",
6
6
  "hooks": {
7
7
  "verify": "hooks/verify.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.31.2",
3
+ "version": "0.32.0",
4
4
  "description": "Harness OS: An Orchestrated Agent Operating System for AI-driven Development",
5
5
  "main": "harness-everything/scripts/bootstrap.js",
6
6
  "bin": {
@@ -63,7 +63,7 @@
63
63
  "waza:check": "node scripts/waza-harness-check.js",
64
64
  "test:waza:compat": "node ci/mechanism-2aj-waza-reference-adapter.test.js",
65
65
  "codex:hooks": "node scripts/codex-hooks-compat.js",
66
- "test:codex:hooks-compat": "node ci/mechanism-2al-codex-hooks-compat.test.js"
66
+ "test:codex:hooks-compat": "node ci/mechanism-2al-codex-hooks-compat.test.js && node ci/mechanism-2au-codex-runtime-safety.test.js"
67
67
  },
68
68
  "engines": {
69
69
  "node": ">=22"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.31.2",
3
+ "version": "0.32.0",
4
4
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
5
5
  "author": {
6
6
  "name": "Miya Daniel",
@@ -24,8 +24,11 @@ function finish() {
24
24
  clearTimeout(timer);
25
25
  if (!ROUTER) process.exit(0);
26
26
  const result = spawnSync(process.execPath, [ROUTER], { input, encoding: 'utf8', env: process.env, timeout: 25000 });
27
- if (result.stderr) process.stderr.write(result.stderr);
28
- const context = String(result.stdout || '').trim();
27
+ const stderr = String(result.stderr || '');
28
+ if (stderr) process.stderr.write(stderr);
29
+ const registryDiagnostics = stderr.split(/\r?\n/)
30
+ .filter(line => line.includes('cannot load step-type registry') && /(repair|restore|plugin:sync)/i.test(line));
31
+ const context = [String(result.stdout || '').trim(), ...registryDiagnostics].filter(Boolean).join('\n').trim();
29
32
  if (context) {
30
33
  process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: context } }));
31
34
  }
@@ -0,0 +1,37 @@
1
+ [
2
+ {
3
+ "id": "behavior-change",
4
+ "requiredBindings": ["tdd"],
5
+ "optionalBindings": [],
6
+ "forbiddenBindings": [],
7
+ "evidenceHint": "Evidence should show the requested behavior changed."
8
+ },
9
+ {
10
+ "id": "commit",
11
+ "requiredBindings": ["git-commit"],
12
+ "optionalBindings": [],
13
+ "forbiddenBindings": [],
14
+ "evidenceHint": "Evidence should identify the authorized commit."
15
+ },
16
+ {
17
+ "id": "doc-change",
18
+ "requiredBindings": [],
19
+ "optionalBindings": [],
20
+ "forbiddenBindings": [],
21
+ "evidenceHint": "Evidence should show the requested document update."
22
+ },
23
+ {
24
+ "id": "review",
25
+ "requiredBindings": [],
26
+ "optionalBindings": [],
27
+ "forbiddenBindings": ["tdd"],
28
+ "evidenceHint": "Evidence should identify the reviewed scope and findings."
29
+ },
30
+ {
31
+ "id": "verification",
32
+ "requiredBindings": ["verification-loop"],
33
+ "optionalBindings": [],
34
+ "forbiddenBindings": [],
35
+ "evidenceHint": "Evidence should record objective checks appropriate to the change."
36
+ }
37
+ ]
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
3
4
  const path = require('path');
4
5
  const { atomicWriteJson, readJson } = require('./fable-contracts');
5
6
  const { resolveBinding, PACKAGED_BINDING_PATHS: BINDING_PATHS } = require('./binding-resolver');
@@ -8,13 +9,102 @@ const OBLIGATION_SCHEMA_VERSION = 1;
8
9
  const OBLIGATION_FILE = 'workflow-obligations.json';
9
10
  const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
11
  const ALLOWED_ESCAPE_REASONS = new Set(['workflow-uncovered-scope', 'host-capability-unavailable']);
11
- const STEP_TYPES = new Set(['review', 'behavior-change', 'verification', 'commit']);
12
- const REQUIRED_BINDINGS_BY_STEP = {
13
- review: [],
14
- 'behavior-change': ['tdd'],
15
- verification: ['verification-loop'],
16
- commit: ['git-commit'],
17
- };
12
+ const STEP_TYPE_REGISTRY_FILE = path.join(__dirname, 'step-types.json');
13
+
14
+ function validateStepTypeRegistry(registry) {
15
+ const errors = [];
16
+ if (!Array.isArray(registry) || registry.length === 0) {
17
+ throw new Error('invalid step-type registry: expected a non-empty array');
18
+ }
19
+ const idCounts = new Map();
20
+ const bindingFields = ['requiredBindings', 'optionalBindings', 'forbiddenBindings'];
21
+ for (const entry of registry) {
22
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
23
+ errors.push('entry must be an object');
24
+ continue;
25
+ }
26
+ const id = typeof entry.id === 'string' ? entry.id : '';
27
+ const validId = SAFE_ID.test(id) && id === id.trim();
28
+ const label = validId ? `step type ${id}` : 'step type with invalid id';
29
+ if (!validId) errors.push(`${label} must declare a stable id`);
30
+ else idCounts.set(id, (idCounts.get(id) || 0) + 1);
31
+ if (typeof entry.evidenceHint !== 'string' || !entry.evidenceHint.trim()) {
32
+ errors.push(`${label} evidenceHint must be a non-empty string`);
33
+ }
34
+ const valuesByField = new Map();
35
+ for (const field of bindingFields) {
36
+ if (!Array.isArray(entry[field])) {
37
+ errors.push(`${label} ${field} must be an array`);
38
+ valuesByField.set(field, []);
39
+ continue;
40
+ }
41
+ const values = [];
42
+ for (const binding of entry[field]) {
43
+ if (typeof binding !== 'string' || !SAFE_ID.test(binding) || binding !== binding.trim()) {
44
+ errors.push(`${label} ${field} entries must be stable binding ids`);
45
+ continue;
46
+ }
47
+ values.push(binding);
48
+ }
49
+ const unique = [...new Set(values)].sort();
50
+ if (unique.length !== values.length) errors.push(`${label} ${field} contains duplicate binding ids`);
51
+ valuesByField.set(field, unique);
52
+ }
53
+ for (const [left, right] of [
54
+ ['requiredBindings', 'optionalBindings'],
55
+ ['requiredBindings', 'forbiddenBindings'],
56
+ ['optionalBindings', 'forbiddenBindings'],
57
+ ]) {
58
+ const rightValues = new Set(valuesByField.get(right) || []);
59
+ for (const binding of valuesByField.get(left) || []) {
60
+ if (rightValues.has(binding)) errors.push(`${label} has conflicting ${left} and ${right} default for ${binding}`);
61
+ }
62
+ }
63
+ }
64
+ for (const id of [...idCounts.keys()].sort()) {
65
+ if (idCounts.get(id) > 1) errors.push(`duplicate step type id ${id}`);
66
+ }
67
+ if (errors.length) throw new Error('invalid step-type registry: ' + [...new Set(errors)].sort().join('; '));
68
+ return registry.map(entry => ({
69
+ id: entry.id,
70
+ requiredBindings: [...entry.requiredBindings],
71
+ optionalBindings: [...entry.optionalBindings],
72
+ forbiddenBindings: [...entry.forbiddenBindings],
73
+ evidenceHint: entry.evidenceHint.trim(),
74
+ })).sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
75
+ }
76
+
77
+ const STEP_TYPE_REGISTRY_UNAVAILABLE = 'STEP_TYPE_REGISTRY_UNAVAILABLE';
78
+
79
+ function readStepTypeRegistry(registryFile = STEP_TYPE_REGISTRY_FILE) {
80
+ const resolvedRegistryFile = path.resolve(registryFile);
81
+ try {
82
+ const parsed = JSON.parse(fs.readFileSync(resolvedRegistryFile, 'utf8'));
83
+ return validateStepTypeRegistry(parsed);
84
+ } catch (error) {
85
+ const unavailable = new Error(
86
+ `cannot load step-type registry ${resolvedRegistryFile}: ${error.message}. ` +
87
+ `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.`,
88
+ );
89
+ unavailable.code = STEP_TYPE_REGISTRY_UNAVAILABLE;
90
+ throw unavailable;
91
+ }
92
+ }
93
+
94
+ // Loaded on first typed use only, so registry-independent planning and execution never read it.
95
+ let stepTypeRegistry = null;
96
+ function loadStepTypeRegistry() {
97
+ if (!stepTypeRegistry) stepTypeRegistry = readStepTypeRegistry();
98
+ return stepTypeRegistry;
99
+ }
100
+
101
+ function stepTypeIds() {
102
+ return loadStepTypeRegistry().map(entry => entry.id).sort();
103
+ }
104
+
105
+ function stepTypeIndex() {
106
+ return new Map(loadStepTypeRegistry().map(entry => [entry.id, entry]));
107
+ }
18
108
 
19
109
  function obligationPath(sessionDir) {
20
110
  return path.join(sessionDir, OBLIGATION_FILE);
@@ -125,6 +215,14 @@ function initializePlanningContract(context, plan) {
125
215
  return set;
126
216
  }
127
217
 
218
+ function typedStepType(rawStepType) {
219
+ const registered = stepTypeIndex();
220
+ if (!registered.has(rawStepType)) {
221
+ throw new Error(`unsupported requirement stepType: ${rawStepType}; registered step types: ${stepTypeIds().join(', ')}`);
222
+ }
223
+ return registered.get(rawStepType);
224
+ }
225
+
128
226
  function parseRequirements(requirementsJson, workspaceRoot) {
129
227
  let parsed;
130
228
  try { parsed = JSON.parse(String(requirementsJson || '')); }
@@ -145,7 +243,7 @@ function parseRequirements(requirementsJson, workspaceRoot) {
145
243
  ? [...new Set(item.suggestedSkills.filter(value => typeof value === 'string' && value.trim()).map(value => value.trim()))]
146
244
  : [];
147
245
  const rawStepType = item.stepType === undefined ? '' : String(item.stepType).trim();
148
- if (rawStepType && !STEP_TYPES.has(rawStepType)) throw new Error(`unsupported requirement stepType: ${rawStepType}`);
246
+ const stepType = rawStepType ? typedStepType(rawStepType) : null;
149
247
  for (const field of ['requiredBindings', 'optionalBindings']) {
150
248
  if (Object.prototype.hasOwnProperty.call(item, field) && !Array.isArray(item[field])) {
151
249
  throw new Error(`${id}.${field} must be an array`);
@@ -155,17 +253,18 @@ function parseRequirements(requirementsJson, workspaceRoot) {
155
253
  if (!rawStepType) return requirement;
156
254
 
157
255
  const requiredBindings = normalizeBindingDeclarations([
158
- ...(REQUIRED_BINDINGS_BY_STEP[rawStepType] || []),
256
+ ...(stepType.requiredBindings || []),
159
257
  ...(item.requiredBindings || []),
160
258
  ]);
161
259
  const requiredIds = new Set(requiredBindings.map(binding => binding.id));
162
260
  const optionalBindings = normalizeBindingDeclarations([
261
+ ...(stepType.optionalBindings || []),
163
262
  ...(item.optionalBindings || []),
164
263
  ...suggestedSkills,
165
264
  ]).filter(binding => !requiredIds.has(binding.id));
166
- if (rawStepType === 'review' && [...requiredBindings, ...optionalBindings].some(binding => binding.id === 'tdd')) {
167
- throw new Error('review steps cannot bind tdd');
168
- }
265
+ const forbiddenBinding = [...requiredBindings, ...optionalBindings]
266
+ .find(binding => stepType.forbiddenBindings.includes(binding.id));
267
+ if (forbiddenBinding) throw new Error(`${rawStepType} steps cannot bind ${forbiddenBinding.id}`);
169
268
  requirement.stepType = rawStepType;
170
269
  requirement.status = 'pending';
171
270
  requirement.evidence = null;
@@ -268,7 +367,7 @@ function recordPlanning(context, requirementsJson, selectedPlan, requestedStrate
268
367
  }
269
368
  const isFable = String(selectedPlan.strategy || '').startsWith('fable-');
270
369
  if (!isFable && !hasTypedSteps) {
271
- throw new Error('selected single-agent workflows require ordered stepType values: review, behavior-change, verification, or commit');
370
+ throw new Error(`selected single-agent workflows require ordered stepType values: ${stepTypeIds().join(', ')}`);
272
371
  }
273
372
  if (!isFable && !requirements.some(item => item.stepType === 'verification')) {
274
373
  throw new Error('step-bound workflows require an explicit verification step');
@@ -360,7 +459,8 @@ function validateObligationSet(context, set, plan = context.workflow?.pendingPla
360
459
  if (!['pending', 'pass', 'escaped', 'blocked'].includes(obligation.status)) return 'obligation-status-invalid';
361
460
  }
362
461
  if (set.stepBindingsEnabled === true) {
363
- if (set.requirements.some(item => !item || !STEP_TYPES.has(item.stepType) ||
462
+ const registered = stepTypeIndex();
463
+ if (set.requirements.some(item => !item || !registered.has(item.stepType) ||
364
464
  !['pending', 'pass', 'blocked'].includes(item.status) || !Array.isArray(item.requiredBindings) ||
365
465
  !Array.isArray(item.optionalBindings))) return 'steps-invalid';
366
466
  for (const step of set.requirements) {
@@ -585,6 +685,9 @@ function unresolvedObligations(context) {
585
685
  module.exports = {
586
686
  OBLIGATION_FILE,
587
687
  ALLOWED_ESCAPE_REASONS,
688
+ loadStepTypeRegistry,
689
+ stepTypeIds,
690
+ validateStepTypeRegistry,
588
691
  obligationPath,
589
692
  needsPlanningContract,
590
693
  needsGenericExecutionObligations,
@@ -7,15 +7,20 @@ const { loadWorkflow, saveWorkflow, matchingRun, OPEN_STATES, WORKFLOW_CONTROLLE
7
7
  const { getWorkspaceRoot, readCurrentSession } = require('./lib/harness-state');
8
8
  const { atomicWriteJson, readJson } = require('./lib/fable-contracts');
9
9
  const { resolveBinding } = require('./lib/binding-resolver');
10
- const {
11
- planningUnresolved,
12
- recordPlanning,
13
- materializeExecutionObligations,
14
- updateObligation,
15
- updateBinding,
16
- updateStep,
17
- activeStepSnapshot,
18
- } = require('./lib/workflow-obligations');
10
+
11
+ let obligationApi;
12
+ function workflowObligations() {
13
+ if (!obligationApi) obligationApi = require('./lib/workflow-obligations');
14
+ return obligationApi;
15
+ }
16
+
17
+ const planningUnresolved = (...args) => workflowObligations().planningUnresolved(...args);
18
+ const recordPlanning = (...args) => workflowObligations().recordPlanning(...args);
19
+ const materializeExecutionObligations = (...args) => workflowObligations().materializeExecutionObligations(...args);
20
+ const updateObligation = (...args) => workflowObligations().updateObligation(...args);
21
+ const updateBinding = (...args) => workflowObligations().updateBinding(...args);
22
+ const updateStep = (...args) => workflowObligations().updateStep(...args);
23
+ const activeStepSnapshot = (...args) => workflowObligations().activeStepSnapshot(...args);
19
24
 
20
25
  const ALLOWED_ESCAPE_REASONS = new Set(['workflow-uncovered-scope', 'host-capability-unavailable']);
21
26
 
@@ -149,6 +154,8 @@ function activatePlan(workflow, plan) {
149
154
  }
150
155
 
151
156
  function main() {
157
+ // The step-type registry loads lazily through the wrappers above, only for commands that
158
+ // consume typed obligations; the top-level handler turns a load failure into one semantic line.
152
159
  if (process.argv[2] === '--help' || process.argv[2] === '-h') {
153
160
  process.stdout.write(usage() + '\n');
154
161
  return;