harness-everything 0.30.10 → 0.31.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 (134) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +1 -1
  4. package/CHANGELOG.md +24 -4
  5. package/README.md +8 -9
  6. package/VERIFICATION.md +1 -1
  7. package/benchmarks/results/live-host/claude-2026-10-08-issue-297/README.md +27 -0
  8. package/benchmarks/results/pr-303-review-2026-10-13/README.md +81 -0
  9. package/benchmarks/results/pr-303-review-2026-10-13/ci/run.json +1330 -0
  10. package/benchmarks/results/pr-303-review-2026-10-13/diagnostics/mechanism-35-clean-env.log +1 -0
  11. package/benchmarks/results/pr-303-review-2026-10-13/diagnostics/mechanism-35-inherited-flag.log +34 -0
  12. package/benchmarks/results/pr-303-review-2026-10-13/diagnostics/verify-gate-captured.json +46 -0
  13. package/benchmarks/results/pr-303-review-2026-10-13/diagnostics/verify-gate-diagnosis.md +13 -0
  14. package/benchmarks/results/pr-303-review-2026-10-13/diagnostics/verify-gate.json +46 -0
  15. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/summary.json +57 -0
  16. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-collision.log +8 -0
  17. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-consistency.log +624 -0
  18. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-docs-capabilities.log +131 -0
  19. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-platform-compatibility.log +9 -0
  20. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-plugin-openai.log +6 -0
  21. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-plugin-submission.log +5 -0
  22. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-references.log +6 -0
  23. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-release.log +73 -0
  24. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-repo-contract.log +5 -0
  25. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-routing-invariants.log +785 -0
  26. package/benchmarks/results/pr-303-review-2026-10-13/gates/contracts/test-routing-skills.log +44 -0
  27. package/benchmarks/results/pr-303-review-2026-10-13/gates/final-fast/summary.json +12 -0
  28. package/benchmarks/results/pr-303-review-2026-10-13/gates/final-fast/test-consistency.log +624 -0
  29. package/benchmarks/results/pr-303-review-2026-10-13/gates/final-fast/test-release.log +73 -0
  30. package/benchmarks/results/pr-303-review-2026-10-13/gates/heavy/summary.json +79 -0
  31. package/benchmarks/results/pr-303-review-2026-10-13/gates/heavy/test-mechanism.log +1737 -0
  32. package/benchmarks/results/pr-303-review-2026-10-13/gates/heavy/test-mutations.log +16 -0
  33. package/benchmarks/results/pr-303-review-2026-10-13/gates/heavy/test.log +3297 -0
  34. package/benchmarks/results/pr-303-review-2026-10-13/manifest.json +319 -0
  35. package/benchmarks/results/pr-303-review-2026-10-13/regression/binding-resolution-green.log +36 -0
  36. package/benchmarks/results/pr-303-review-2026-10-13/regression/determinism.json +174 -0
  37. package/benchmarks/results/pr-303-review-2026-10-13/regression/quality-evidence.json +314 -0
  38. package/benchmarks/results/pr-303-review-2026-10-13/regression/quality-report.json +638 -0
  39. package/benchmarks/results/pr-303-review-2026-10-13/regression/red.log +240 -0
  40. package/benchmarks/results/pr-303-review-2026-10-13/regression/untyped-fable-probe.json +22 -0
  41. package/benchmarks/results/pr-303-review-2026-10-13/regression/workflow-obligations-green.log +254 -0
  42. package/benchmarks/results/pr-303-review-2026-10-13/verification/mechanism-35-clean-env-fixed.log +1 -0
  43. package/benchmarks/results/pr-303-review-2026-10-13/verification/mechanism-35-inherited-flag-fixed.log +1 -0
  44. package/benchmarks/results/pr-303-review-2026-10-13/verification/nested-test.meta.json +8 -0
  45. package/benchmarks/results/pr-303-review-2026-10-13/verification/nested-test.stderr.log +90 -0
  46. package/benchmarks/results/pr-303-review-2026-10-13/verification/nested-test.stdout.log +3186 -0
  47. package/benchmarks/results/pr-303-review-2026-10-13/verification/verify-gate-final.json +46 -0
  48. package/bin/cli.js +1 -1
  49. package/ci/invariant-routing.test.js +7 -4
  50. package/ci/mechanism-2ag-router-worktree-contract.test.js +2 -2
  51. package/ci/mechanism-2ai-workspace-fingerprint-batch.test.js +2 -1
  52. package/ci/mechanism-2an-workflow-obligations.test.js +352 -3
  53. package/ci/mechanism-2aq-fable-stage-bindings.test.js +168 -0
  54. package/ci/mechanism-2ar-binding-resolution.test.js +211 -0
  55. package/ci/mechanism-35-verification-contract.test.js +1 -1
  56. package/ci/mutation-check.js +2 -0
  57. package/ci/mutation-loader.js +4 -0
  58. package/ci/openai-plugin-package.test.js +7 -5
  59. package/ci/prompt-contract-regressions.test.js +7 -6
  60. package/ci/router-workflow-plan.test.js +109 -14
  61. package/ci/skill-routing-check.js +44 -15
  62. package/ci/workflow-plan-consumers.test.js +41 -1
  63. package/docs/architecture.md +10 -10
  64. package/docs/enforcement-locks.md +1 -1
  65. package/docs/mechanism-first-skill-mesh.md +12 -11
  66. package/docs/openai-plugin.md +1 -1
  67. package/docs/philosophy.md +7 -6
  68. package/docs/platform-capabilities.md +7 -5
  69. package/docs/routing.md +16 -17
  70. package/docs/system-one-observations.md +1 -1
  71. package/docs/system-one-routing.md +10 -7
  72. package/docs/system-one-suggestion-gates.md +5 -5
  73. package/docs/workflow-runtime.md +6 -2
  74. package/docs/workflows/harness-everything.md +14 -14
  75. package/evals/harness-everything/tasks/positive-tier1.yaml +1 -1
  76. package/evals/harness-everything/tasks/positive-tier2.yaml +1 -1
  77. package/evals/harness-everything/tasks/positive-tier3.yaml +1 -1
  78. package/fable-mode/CONTRACT-FORMAT.md +4 -1
  79. package/fable-mode/SKILL.md +6 -8
  80. package/fable-mode/agents/fable-orchestrator.md +10 -3
  81. package/fable-mode/execution-guardrails/SKILL.md +1 -1
  82. package/fable-mode/fable-haiku/SKILL.md +1 -1
  83. package/fable-mode/fable-opus/SKILL.md +1 -1
  84. package/fable-mode/fable-sonnet/SKILL.md +1 -1
  85. package/fable-mode/scripts/workflow-plan-consumer.js +101 -2
  86. package/harness-everything/SKILL.md +8 -7
  87. package/harness-everything/references/router-workflow-plan.md +11 -6
  88. package/harness-everything/references/triage-and-tiers.md +21 -23
  89. package/harness-everything/schemas/router-workflow-plan.schema.json +2 -0
  90. package/harness-everything/scripts/kernel-router-core.js +52 -55
  91. package/harness-everything/scripts/kernel-router.js +48 -5
  92. package/harness-everything/scripts/router-contract-core.js +7 -7
  93. package/harness-everything/scripts/router-contract.js +1 -3
  94. package/harness-everything/scripts/routing-keywords.json +417 -121
  95. package/harness-everything/scripts/tier-router.js +64 -113
  96. package/hooks/scripts/contract-test.js +29 -3
  97. package/hooks/scripts/lib/binding-resolver.js +76 -0
  98. package/hooks/scripts/lib/fable-contracts.js +1 -1
  99. package/hooks/scripts/lib/workflow-obligations.js +292 -7
  100. package/hooks/scripts/lib/workflow-runtime.js +8 -1
  101. package/hooks/scripts/workflow-disposition.js +116 -2
  102. package/opencode-plugin/plugin.json +1 -1
  103. package/package.json +1 -1
  104. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  105. package/plugins/harness-everything/hooks/scripts/contract-test.js +29 -3
  106. package/plugins/harness-everything/hooks/scripts/lib/binding-resolver.js +76 -0
  107. package/plugins/harness-everything/hooks/scripts/lib/fable-contracts.js +1 -1
  108. package/plugins/harness-everything/hooks/scripts/lib/workflow-obligations.js +292 -7
  109. package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +8 -1
  110. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +116 -2
  111. package/plugins/harness-everything/plugin.json +1 -1
  112. package/plugins/harness-everything/scripts/lib/advisory-text.js +2 -2
  113. package/plugins/harness-everything/scripts/lib/generated-skills.js +78 -0
  114. package/plugins/harness-everything/skills/fable-mode/CONTRACT-FORMAT.md +4 -1
  115. package/plugins/harness-everything/skills/fable-mode/SKILL.md +6 -8
  116. package/plugins/harness-everything/skills/fable-mode/agents/fable-orchestrator.md +10 -3
  117. package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
  118. package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
  119. package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
  120. package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
  121. package/plugins/harness-everything/skills/fable-mode/scripts/workflow-plan-consumer.js +101 -2
  122. package/plugins/harness-everything/skills/harness-everything/SKILL.md +8 -7
  123. package/plugins/harness-everything/skills/harness-everything/references/router-workflow-plan.md +11 -6
  124. package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +21 -23
  125. package/plugins/harness-everything/skills/harness-everything/schemas/router-workflow-plan.schema.json +2 -0
  126. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router-core.js +52 -55
  127. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +48 -5
  128. package/plugins/harness-everything/skills/harness-everything/scripts/router-contract-core.js +7 -7
  129. package/plugins/harness-everything/skills/harness-everything/scripts/router-contract.js +1 -3
  130. package/plugins/harness-everything/skills/harness-everything/scripts/routing-keywords.json +417 -121
  131. package/plugins/harness-everything/skills/harness-everything/scripts/tier-router.js +64 -113
  132. package/sbom.cdx.json +7 -7
  133. package/scripts/lib/advisory-text.js +2 -2
  134. package/scripts/lib/generated-skills.js +78 -0
@@ -39,16 +39,26 @@ function resultState(payload) {
39
39
  return { failed, exitCode, evidence };
40
40
  }
41
41
 
42
+ function unresolvedBindings(contract) {
43
+ const bindings = [...(contract.requiredBindings || []), ...(contract.optionalBindings || [])];
44
+ return bindings.filter(binding => binding.required !== false
45
+ ? binding.status !== 'loaded'
46
+ : !['loaded', 'not-needed'].includes(binding.status));
47
+ }
48
+
42
49
  function updateRunContract(entry, payload, command, sessionId) {
43
50
  const { failed, exitCode, evidence } = resultState(payload);
51
+ const unresolved = unresolvedBindings(entry.contract);
52
+ const status = failed ? 'fail' : unresolved.length ? 'binding-unresolved' : 'pass';
44
53
  const observedAt = new Date().toISOString();
45
54
  const contract = {
46
55
  ...entry.contract,
47
56
  workerId: entry.contract.workerId || getWorkerId(payload),
48
- status: failed ? 'fail' : 'pass',
57
+ status,
49
58
  lastObservedCommand: command,
50
59
  lastObservedExitCode: exitCode ?? null,
51
60
  evidence,
61
+ unresolvedBindings: unresolved.map(binding => binding.id),
52
62
  verifiedAt: observedAt,
53
63
  updatedAt: observedAt,
54
64
  };
@@ -63,7 +73,9 @@ function updateRunContract(entry, payload, command, sessionId) {
63
73
  sessionId: sessionId || contract.sessionId || null,
64
74
  workerId: contract.workerId || null,
65
75
  checkCommand: command,
66
- status: failed ? 'fail' : 'pass',
76
+ status,
77
+ checkStatus: failed ? 'fail' : 'pass',
78
+ unresolvedBindings: unresolved.map(binding => binding.id),
67
79
  exitCode: exitCode ?? null,
68
80
  evidence,
69
81
  observedAt,
@@ -72,6 +84,15 @@ function updateRunContract(entry, payload, command, sessionId) {
72
84
  return contract;
73
85
  }
74
86
 
87
+ function activeFableStages(runRoot) {
88
+ for (const relative of ['../../fable-mode/scripts/workflow-plan-consumer.js', '../../skills/fable-mode/scripts/workflow-plan-consumer.js']) {
89
+ const file = path.resolve(__dirname, relative);
90
+ if (!fs.existsSync(file)) continue;
91
+ try { return require(file).activeStageBindings(runRoot); } catch (_) { return []; }
92
+ }
93
+ return [];
94
+ }
95
+
75
96
  function resolveLegacyContract(stateRoot, command, payload) {
76
97
  const contractsDir = path.join(stateRoot, 'contracts');
77
98
  let files = [];
@@ -110,7 +131,7 @@ process.stdin.on('end', () => {
110
131
  const workflowContext = loadWorkflow(payload);
111
132
  const activeRun = workflowContext.workflow?.workflowId ? matchingRun(workflowContext) : null;
112
133
  const runMatches = listRunContracts(stateRoot).filter(({ contract }) =>
113
- (contract.workflowId ? ['pending', 'planned', 'running', 'fail', 'pass'] : ['pending', 'planned', 'running']).includes(contract.status) && commandMatches(contract, command) &&
134
+ (contract.workflowId ? ['pending', 'planned', 'running', 'fail', 'pass', 'binding-unresolved'] : ['pending', 'planned', 'running', 'fail', 'binding-unresolved']).includes(contract.status) && commandMatches(contract, command) &&
114
135
  (!workflowContext.workflow?.workflowId || (activeRun && contract.runId === activeRun.run.runId && contract.sessionId === sessionId))
115
136
  );
116
137
 
@@ -163,6 +184,11 @@ process.stdin.on('end', () => {
163
184
  }
164
185
  }
165
186
  console.log(`[Contract Test] ${contract.planId}/${contract.runId}/${contract.stageId}: ${contract.status.toUpperCase()} ${contract.checkCommand}`);
187
+ if (contract.status === 'binding-unresolved') {
188
+ console.error(`Resolve declared stage bindings before this stage can pass: ${contract.unresolvedBindings.join(', ')}`);
189
+ }
190
+ const activeStages = activeFableStages(entry.runRoot);
191
+ if (activeStages.length) console.log('[Contract Test] Active stage bindings: ' + JSON.stringify(activeStages));
166
192
  if (contract.status === 'fail') {
167
193
  console.error(`Evidence: ${contract.evidence || '(no output captured)'}`);
168
194
  console.error(`Review this failed stage before relying on its output. Artifact: ${contract.outputPath || '(no path recorded)'}`);
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ // Resolves a declared step/stage binding to a real, readable file. A binding
4
+ // path is availability evidence only after it resolves under the workspace or
5
+ // an installed Harness skill root; path syntax alone proves nothing.
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ function loadGeneratedSkills() {
10
+ const file = path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'generated-skills.js');
11
+ return fs.existsSync(file) ? require(file) : null;
12
+ }
13
+
14
+ // Source checkout and Claude plugin keep skills at the package root; the
15
+ // Codex plugin keeps them under skills/. Host-provided plugin roots win.
16
+ function packageRoots() {
17
+ const packageRoot = path.resolve(__dirname, '..', '..', '..');
18
+ const roots = [];
19
+ for (const root of [process.env.CLAUDE_PLUGIN_ROOT, process.env.PLUGIN_ROOT, packageRoot]) {
20
+ if (!root) continue;
21
+ roots.push(path.resolve(root), path.resolve(root, 'skills'));
22
+ }
23
+ return [...new Set(roots)];
24
+ }
25
+
26
+ function readableFile(file) {
27
+ try {
28
+ if (!fs.statSync(file).isFile()) return false;
29
+ fs.accessSync(file, fs.constants.R_OK);
30
+ return true;
31
+ } catch (_) {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ // Registered Harness bindings and the path each one loads.
37
+ const PACKAGED_BINDING_PATHS = {
38
+ tdd: 'tdd/SKILL.md',
39
+ 'verification-loop': 'verification-loop/SKILL.md',
40
+ 'git-commit': 'git-commit/SKILL.md',
41
+ 'security-review': 'security-review/SKILL.md',
42
+ 'review-guidance': 'harness-everything/references/triage-and-tiers.md',
43
+ };
44
+ const SKILL_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
45
+
46
+ // Only a recognized Harness binding may search installation roots: a
47
+ // registered id at its registered path, or a skill id naming its own
48
+ // `<id>/SKILL.md`. Any other path is a workspace reference and must never
49
+ // resolve to an unrelated file that happens to exist in the Harness package.
50
+ function isPackagedBinding(binding) {
51
+ if (!binding.path) return false;
52
+ if (PACKAGED_BINDING_PATHS[binding.id]) return PACKAGED_BINDING_PATHS[binding.id] === binding.path;
53
+ return SKILL_ID.test(binding.id || '') && binding.path === `${binding.id}/SKILL.md`;
54
+ }
55
+
56
+ // Returns { availability, file }: `available` with an absolute readable file,
57
+ // `missing` when a declared path resolves nowhere, or `unknown` when neither a
58
+ // path nor a registered generated skill names a location.
59
+ function resolveBinding(binding, workspaceRoot) {
60
+ const roots = isPackagedBinding(binding) ? [...packageRoots(), workspaceRoot] : [workspaceRoot];
61
+ if (binding.path) {
62
+ for (const root of roots.filter(Boolean)) {
63
+ const file = path.resolve(root, binding.path);
64
+ if (readableFile(file)) return { availability: 'available', file };
65
+ }
66
+ return { availability: 'missing', file: null };
67
+ }
68
+ const generated = loadGeneratedSkills()?.findGeneratedSkill(binding.id, workspaceRoot);
69
+ if (generated) {
70
+ const file = path.join(generated.dirPath, 'SKILL.md');
71
+ return readableFile(file) ? { availability: 'available', file } : { availability: 'missing', file: null };
72
+ }
73
+ return { availability: 'unknown', file: null };
74
+ }
75
+
76
+ module.exports = { resolveBinding, packageRoots, isPackagedBinding, PACKAGED_BINDING_PATHS };
@@ -102,7 +102,7 @@ function stagesForChangedPath(filePath, contractEntries) {
102
102
 
103
103
  function getActiveRunContracts(stateRoot, sessionId) {
104
104
  return listRunContracts(stateRoot).filter(({ contract }) => {
105
- if (!['pending', 'planned', 'running'].includes(contract.status)) return false;
105
+ if (!['pending', 'planned', 'running', 'fail', 'binding-unresolved'].includes(contract.status)) return false;
106
106
  return !sessionId || !contract.sessionId || contract.sessionId === sessionId;
107
107
  });
108
108
  }
@@ -2,11 +2,19 @@
2
2
 
3
3
  const path = require('path');
4
4
  const { atomicWriteJson, readJson } = require('./fable-contracts');
5
+ const { resolveBinding, PACKAGED_BINDING_PATHS: BINDING_PATHS } = require('./binding-resolver');
5
6
 
6
7
  const OBLIGATION_SCHEMA_VERSION = 1;
7
8
  const OBLIGATION_FILE = 'workflow-obligations.json';
8
9
  const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
9
10
  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
+ };
10
18
 
11
19
  function obligationPath(sessionDir) {
12
20
  return path.join(sessionDir, OBLIGATION_FILE);
@@ -53,7 +61,7 @@ function planningObligations() {
53
61
  ];
54
62
  }
55
63
 
56
- function executionObligations(plan) {
64
+ function executionObligations(plan, stepBindingsEnabled = false) {
57
65
  return [
58
66
  {
59
67
  id: 'execute',
@@ -61,7 +69,7 @@ function executionObligations(plan) {
61
69
  requirement: 'Resolve the planned requirement fragments using adaptive Skill/direct-action bindings.',
62
70
  required: true,
63
71
  escapable: true,
64
- suggestedSkills: suggestedBindings(plan, 'execution'),
72
+ suggestedSkills: stepBindingsEnabled ? [] : suggestedBindings(plan, 'execution'),
65
73
  },
66
74
  {
67
75
  id: 'verify',
@@ -69,7 +77,7 @@ function executionObligations(plan) {
69
77
  requirement: 'Produce objective verification evidence appropriate to the completed requirements before claiming completion.',
70
78
  required: true,
71
79
  escapable: false,
72
- suggestedSkills: suggestedBindings(plan, 'verification'),
80
+ suggestedSkills: stepBindingsEnabled ? [] : suggestedBindings(plan, 'verification'),
73
81
  },
74
82
  ];
75
83
  }
@@ -117,7 +125,7 @@ function initializePlanningContract(context, plan) {
117
125
  return set;
118
126
  }
119
127
 
120
- function parseRequirements(requirementsJson) {
128
+ function parseRequirements(requirementsJson, workspaceRoot) {
121
129
  let parsed;
122
130
  try { parsed = JSON.parse(String(requirementsJson || '')); }
123
131
  catch (_) { throw new Error('--requirements-json must be valid JSON'); }
@@ -136,15 +144,135 @@ function parseRequirements(requirementsJson) {
136
144
  const suggestedSkills = Array.isArray(item.suggestedSkills)
137
145
  ? [...new Set(item.suggestedSkills.filter(value => typeof value === 'string' && value.trim()).map(value => value.trim()))]
138
146
  : [];
139
- return { id, order: index + 1, summary, acceptance, suggestedSkills };
147
+ const rawStepType = item.stepType === undefined ? '' : String(item.stepType).trim();
148
+ if (rawStepType && !STEP_TYPES.has(rawStepType)) throw new Error(`unsupported requirement stepType: ${rawStepType}`);
149
+ for (const field of ['requiredBindings', 'optionalBindings']) {
150
+ if (Object.prototype.hasOwnProperty.call(item, field) && !Array.isArray(item[field])) {
151
+ throw new Error(`${id}.${field} must be an array`);
152
+ }
153
+ }
154
+ const requirement = { id, order: index + 1, summary, acceptance, suggestedSkills };
155
+ if (!rawStepType) return requirement;
156
+
157
+ const requiredBindings = normalizeBindingDeclarations([
158
+ ...(REQUIRED_BINDINGS_BY_STEP[rawStepType] || []),
159
+ ...(item.requiredBindings || []),
160
+ ]);
161
+ const requiredIds = new Set(requiredBindings.map(binding => binding.id));
162
+ const optionalBindings = normalizeBindingDeclarations([
163
+ ...(item.optionalBindings || []),
164
+ ...suggestedSkills,
165
+ ]).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
+ }
169
+ requirement.stepType = rawStepType;
170
+ requirement.status = 'pending';
171
+ requirement.evidence = null;
172
+ requirement.reasonCode = null;
173
+ requirement.updatedAt = null;
174
+ requirement.requiredBindings = requiredBindings.map(binding => newBinding(binding.id, true, binding.path, workspaceRoot));
175
+ requirement.optionalBindings = optionalBindings.map(binding => newBinding(binding.id, false, binding.path, workspaceRoot));
176
+ return requirement;
177
+ });
178
+ }
179
+
180
+ function normalizeBindingPath(value) {
181
+ if (typeof value !== 'string' || !value.trim()) throw new Error('binding paths must be non-empty repository-relative strings');
182
+ const normalized = value.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/g, '');
183
+ const parts = normalized.split('/');
184
+ if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized) ||
185
+ parts.some(part => !part || part === '.' || part === '..') || /[*?\[\]]/.test(normalized)) {
186
+ throw new Error(`binding path must be a concrete repository-relative path: ${value}`);
187
+ }
188
+ return normalized;
189
+ }
190
+
191
+ function normalizeBindingDeclarations(values) {
192
+ const result = [];
193
+ const byId = new Map();
194
+ for (const value of values || []) {
195
+ const bindingId = typeof value === 'string' ? value.trim()
196
+ : value && typeof value === 'object' && typeof value.id === 'string' ? value.id.trim() : '';
197
+ if (!SAFE_ID.test(bindingId)) throw new Error('binding ids must be stable identifier strings');
198
+ const bindingPath = value && typeof value === 'object' && value.path !== undefined && value.path !== null
199
+ ? normalizeBindingPath(value.path)
200
+ : null;
201
+ if (byId.has(bindingId)) {
202
+ const existing = byId.get(bindingId);
203
+ if (existing.path && bindingPath && existing.path !== bindingPath) throw new Error(`binding ${bindingId} declares conflicting paths`);
204
+ if (!existing.path && bindingPath) existing.path = bindingPath;
205
+ continue;
206
+ }
207
+ const declaration = { id: bindingId, path: bindingPath };
208
+ byId.set(bindingId, declaration);
209
+ result.push(declaration);
210
+ }
211
+ return result;
212
+ }
213
+
214
+ function newBinding(id, required, declaredPath = null, workspaceRoot = null) {
215
+ if (declaredPath && BINDING_PATHS[id] && declaredPath !== BINDING_PATHS[id]) {
216
+ throw new Error(`binding ${id} must use its registered path ${BINDING_PATHS[id]}`);
217
+ }
218
+ const bindingPath = declaredPath || BINDING_PATHS[id] || null;
219
+ const resolution = resolveBinding({ id, path: bindingPath }, workspaceRoot);
220
+ return {
221
+ id,
222
+ required,
223
+ status: 'pending',
224
+ path: bindingPath,
225
+ availability: resolution.availability,
226
+ resolvedPath: resolution.file,
227
+ evidence: null,
228
+ reasonCode: null,
229
+ updatedAt: null,
230
+ };
231
+ }
232
+
233
+ function activeStepRecord(set) {
234
+ if (!set?.stepBindingsEnabled) return null;
235
+ return set.requirements.find(item => item.stepType && item.status !== 'pass') || null;
236
+ }
237
+
238
+ function activeStepSnapshot(set) {
239
+ const step = activeStepRecord(set);
240
+ if (!step) return null;
241
+ const visibleBinding = binding => ({
242
+ id: binding.id,
243
+ status: binding.status,
244
+ availability: binding.availability,
245
+ path: binding.path,
246
+ resolvedPath: binding.resolvedPath || null,
140
247
  });
248
+ return {
249
+ id: step.id,
250
+ order: step.order,
251
+ stepType: step.stepType,
252
+ summary: step.summary,
253
+ completionCriteria: step.acceptance,
254
+ status: step.status,
255
+ requiredBindings: step.requiredBindings.map(visibleBinding),
256
+ optionalBindings: step.optionalBindings.map(visibleBinding),
257
+ };
141
258
  }
142
259
 
143
260
  function recordPlanning(context, requirementsJson, selectedPlan, requestedStrategy, evidence) {
144
261
  if (!needsPlanningContract(selectedPlan)) throw new Error('selected workflow does not require a planning contract');
145
262
  const loaded = loadObligationSet(context, selectedPlan);
146
263
  if (!loaded.set) throw new Error('workflow planning contract unavailable or invalid: ' + loaded.error);
147
- const requirements = parseRequirements(requirementsJson);
264
+ const requirements = parseRequirements(requirementsJson, context.root);
265
+ const hasTypedSteps = requirements.some(item => item.stepType);
266
+ if (hasTypedSteps && requirements.some(item => !item.stepType)) {
267
+ throw new Error('all requirement fragments must declare stepType when step bindings are used');
268
+ }
269
+ const isFable = String(selectedPlan.strategy || '').startsWith('fable-');
270
+ if (!isFable && !hasTypedSteps) {
271
+ throw new Error('selected single-agent workflows require ordered stepType values: review, behavior-change, verification, or commit');
272
+ }
273
+ if (!isFable && !requirements.some(item => item.stepType === 'verification')) {
274
+ throw new Error('step-bound workflows require an explicit verification step');
275
+ }
148
276
  const requested = String(requestedStrategy || '').trim();
149
277
  const planningEvidence = String(evidence || '').trim();
150
278
  if (!requested) throw new Error('--strategy is required');
@@ -152,6 +280,8 @@ function recordPlanning(context, requirementsJson, selectedPlan, requestedStrate
152
280
 
153
281
  const now = new Date().toISOString();
154
282
  loaded.set.requirements = requirements;
283
+ loaded.set.stepBindingsEnabled = hasTypedSteps && !isFable;
284
+ loaded.set.activeStepId = null;
155
285
  const decompose = loaded.set.obligations.find(item => item.id === 'decompose');
156
286
  if (decompose) {
157
287
  decompose.status = 'pass';
@@ -202,12 +332,18 @@ function materializeExecutionObligations(context, plan) {
202
332
  if (!loaded.set) throw new Error('workflow planning contract unavailable or invalid: ' + loaded.error);
203
333
  if (planningUnresolved(context, plan).length) throw new Error('requirements/workflow planning must be resolved before start');
204
334
  const existing = new Map(loaded.set.obligations.map(item => [item.id, item]));
205
- for (const item of executionObligations(plan)) {
335
+ for (const item of executionObligations(plan, loaded.set.stepBindingsEnabled === true)) {
206
336
  if (!existing.has(item.id)) loaded.set.obligations.push(newObligation(item));
337
+ else if (loaded.set.stepBindingsEnabled === true) {
338
+ const current = existing.get(item.id);
339
+ current.suggestedSkills = [];
340
+ }
207
341
  }
342
+ syncTypedAggregateObligations(loaded.set);
208
343
  loaded.set.phase = 'execution';
209
344
  loaded.set.activeStrategy = plan.strategy;
210
345
  loaded.set.revision = context.workflow.revision;
346
+ loaded.set.activeStepId = activeStepRecord(loaded.set)?.id || null;
211
347
  atomicWriteJson(obligationPath(context.sessionDir), loaded.set);
212
348
  return loaded.set;
213
349
  }
@@ -223,6 +359,30 @@ function validateObligationSet(context, set, plan = context.workflow?.pendingPla
223
359
  ids.add(obligation.id);
224
360
  if (!['pending', 'pass', 'escaped', 'blocked'].includes(obligation.status)) return 'obligation-status-invalid';
225
361
  }
362
+ if (set.stepBindingsEnabled === true) {
363
+ if (set.requirements.some(item => !item || !STEP_TYPES.has(item.stepType) ||
364
+ !['pending', 'pass', 'blocked'].includes(item.status) || !Array.isArray(item.requiredBindings) ||
365
+ !Array.isArray(item.optionalBindings))) return 'steps-invalid';
366
+ for (const step of set.requirements) {
367
+ for (const binding of [...step.requiredBindings, ...step.optionalBindings]) {
368
+ if (!binding || !SAFE_ID.test(binding.id || '') || !['pending', 'loaded', 'not-needed', 'unavailable'].includes(binding.status)) {
369
+ return 'step-binding-invalid';
370
+ }
371
+ if (binding.path !== null && typeof binding.path === 'string') {
372
+ try { if (normalizeBindingPath(binding.path) !== binding.path) return 'step-binding-path-invalid'; }
373
+ catch (_) { return 'step-binding-path-invalid'; }
374
+ } else if (binding.path !== null) return 'step-binding-path-invalid';
375
+ if (binding.resolvedPath !== undefined && binding.resolvedPath !== null && typeof binding.resolvedPath !== 'string') return 'step-binding-path-invalid';
376
+ if (binding.status === 'loaded' && !binding.path && !binding.resolvedPath) return 'step-binding-unknown-loaded';
377
+ }
378
+ if (step.status === 'pass' && (!step.evidence ||
379
+ step.requiredBindings.some(binding => binding.status !== 'loaded') ||
380
+ step.optionalBindings.some(binding => !['loaded', 'not-needed'].includes(binding.status)))) {
381
+ return 'step-resolution-invalid';
382
+ }
383
+ }
384
+ if (set.phase === 'execution' && set.activeStepId !== (activeStepRecord(set)?.id || null)) return 'active-step-invalid';
385
+ }
226
386
  return null;
227
387
  }
228
388
 
@@ -278,6 +438,113 @@ function updateObligation(context, obligationId, disposition, options = {}) {
278
438
  return obligation;
279
439
  }
280
440
 
441
+ function updateBinding(context, stepId, bindingId, disposition, options = {}) {
442
+ if (!SAFE_ID.test(stepId || '') || !SAFE_ID.test(bindingId || '')) throw new Error('step and binding ids must be stable identifiers');
443
+ if (!['loaded', 'not-needed', 'unavailable'].includes(disposition)) {
444
+ throw new Error('--disposition must be loaded, not-needed, or unavailable');
445
+ }
446
+ const plan = context.workflow?.pendingPlan || context.workflow?.workflowPlan;
447
+ const loaded = loadObligationSet(context, plan);
448
+ if (!loaded.set) throw new Error('workflow obligation contract unavailable or invalid: ' + loaded.error);
449
+ if (!loaded.set.stepBindingsEnabled) throw new Error('workflow has no active-step binding contract');
450
+ const step = activeStepRecord(loaded.set);
451
+ if (!step || step.id !== stepId) throw new Error('bindings may only be resolved for the active step');
452
+ const binding = [...step.requiredBindings, ...step.optionalBindings].find(item => item.id === bindingId);
453
+ if (!binding) throw new Error('binding is not declared on the active step');
454
+ const evidence = String(options.evidence || '').trim();
455
+ if (!evidence) throw new Error('--evidence is required for every binding disposition');
456
+ if (binding.required && disposition === 'not-needed') throw new Error('required bindings cannot be marked not-needed');
457
+ if (!binding.required && disposition === 'unavailable') throw new Error('optional bindings require loaded or not-needed disposition');
458
+ if (disposition === 'loaded') {
459
+ const resolution = resolveBinding(binding, context.root);
460
+ if (resolution.availability !== 'available') {
461
+ const reasonCode = resolution.availability === 'unknown' ? 'binding-path-unknown' : 'binding-path-missing';
462
+ if (binding.required) {
463
+ binding.status = 'unavailable';
464
+ binding.availability = resolution.availability;
465
+ binding.resolvedPath = null;
466
+ binding.evidence = evidence;
467
+ binding.reasonCode = reasonCode;
468
+ binding.updatedAt = new Date().toISOString();
469
+ atomicWriteJson(obligationPath(context.sessionDir), loaded.set);
470
+ }
471
+ throw new Error(resolution.availability === 'unknown'
472
+ ? `${binding.required ? 'required' : 'optional'} binding ${binding.id} has no registered load path`
473
+ : `${binding.required ? 'required' : 'optional'} binding ${binding.id} path ${binding.path || binding.id} does not resolve to a readable file`);
474
+ }
475
+ binding.availability = 'available';
476
+ binding.resolvedPath = resolution.file;
477
+ }
478
+ binding.status = disposition;
479
+ binding.evidence = evidence;
480
+ binding.reasonCode = disposition === 'unavailable' ? String(options.reasonCode || 'binding-unavailable') : null;
481
+ binding.updatedAt = new Date().toISOString();
482
+ atomicWriteJson(obligationPath(context.sessionDir), loaded.set);
483
+ return { binding, activeStep: activeStepSnapshot(loaded.set) };
484
+ }
485
+
486
+ function syncTypedAggregateObligations(set) {
487
+ if (set.stepBindingsEnabled !== true) return;
488
+ const steps = set.requirements.filter(item => item?.stepType);
489
+ const groups = [
490
+ {
491
+ id: 'execute',
492
+ steps: steps.filter(step => step.stepType !== 'verification'),
493
+ evidencePrefix: 'typed-execution-steps',
494
+ },
495
+ {
496
+ id: 'verify',
497
+ steps: steps.filter(step => step.stepType === 'verification'),
498
+ evidencePrefix: 'typed-verification-steps',
499
+ },
500
+ ];
501
+ const now = new Date().toISOString();
502
+ for (const group of groups) {
503
+ const noExecutionStep = group.id === 'execute' && group.steps.length === 0;
504
+ if (!noExecutionStep && (!group.steps.length || !group.steps.every(step => step.status === 'pass' && step.evidence))) continue;
505
+ const obligation = set.obligations.find(item => item.id === group.id);
506
+ if (!obligation) continue;
507
+ obligation.status = 'pass';
508
+ obligation.evidence = noExecutionStep
509
+ ? 'typed-execution-steps:none (verification-only workflow)'
510
+ : `${group.evidencePrefix}:${group.steps.map(step => step.id).join(',')}`;
511
+ obligation.reasonCode = null;
512
+ obligation.scope = null;
513
+ obligation.updatedAt = now;
514
+ }
515
+ }
516
+
517
+ function updateStep(context, stepId, disposition, options = {}) {
518
+ if (!SAFE_ID.test(stepId || '')) throw new Error('--step-id must be a stable requirement id');
519
+ if (!['pass', 'blocked'].includes(disposition)) throw new Error('--disposition must be pass or blocked');
520
+ const plan = context.workflow?.pendingPlan || context.workflow?.workflowPlan;
521
+ const loaded = loadObligationSet(context, plan);
522
+ if (!loaded.set) throw new Error('workflow obligation contract unavailable or invalid: ' + loaded.error);
523
+ if (!loaded.set.stepBindingsEnabled) throw new Error('workflow has no active-step contract');
524
+ const step = activeStepRecord(loaded.set);
525
+ if (!step || step.id !== stepId) throw new Error('only the active step may receive a disposition');
526
+ const evidence = String(options.evidence || '').trim();
527
+ if (!evidence) throw new Error('--evidence is required for every step disposition');
528
+ const reasonCode = String(options.reasonCode || '').trim() || null;
529
+ if (disposition === 'blocked' && !reasonCode) throw new Error('--reason-code is required when blocking a step');
530
+ if (disposition === 'pass') {
531
+ const unresolvedRequired = step.requiredBindings.filter(binding => binding.status !== 'loaded');
532
+ const unresolvedOptional = step.optionalBindings.filter(binding => !['loaded', 'not-needed'].includes(binding.status));
533
+ if (unresolvedRequired.length || unresolvedOptional.length) {
534
+ const ids = [...unresolvedRequired, ...unresolvedOptional].map(binding => binding.id);
535
+ throw new Error('resolve active-step bindings before passing the step: ' + ids.join(', '));
536
+ }
537
+ }
538
+ step.status = disposition;
539
+ step.evidence = evidence;
540
+ step.reasonCode = reasonCode;
541
+ step.updatedAt = new Date().toISOString();
542
+ syncTypedAggregateObligations(loaded.set);
543
+ loaded.set.activeStepId = activeStepRecord(loaded.set)?.id || null;
544
+ atomicWriteJson(obligationPath(context.sessionDir), loaded.set);
545
+ return { step, activeStep: activeStepSnapshot(loaded.set) };
546
+ }
547
+
281
548
  function unresolvedObligations(context) {
282
549
  const plan = context.workflow?.pendingPlan || context.workflow?.workflowPlan;
283
550
  if (!needsPlanningContract(plan)) return [];
@@ -297,6 +564,21 @@ function unresolvedObligations(context) {
297
564
  }
298
565
  }
299
566
  }
567
+ if (loaded.set.stepBindingsEnabled === true) {
568
+ const activeStep = activeStepRecord(loaded.set);
569
+ if (activeStep) {
570
+ if (activeStep.status === 'blocked') unresolved.push('step:' + activeStep.id + ':blocked');
571
+ else unresolved.push('step:' + activeStep.id + ':pending');
572
+ for (const binding of activeStep.requiredBindings) {
573
+ if (binding.status !== 'loaded') unresolved.push('binding:' + activeStep.id + ':' + binding.id + ':' + binding.status);
574
+ }
575
+ for (const binding of activeStep.optionalBindings) {
576
+ if (!['loaded', 'not-needed'].includes(binding.status)) {
577
+ unresolved.push('optional-binding:' + activeStep.id + ':' + binding.id + ':' + binding.status);
578
+ }
579
+ }
580
+ }
581
+ }
300
582
  return [...new Set(unresolved)];
301
583
  }
302
584
 
@@ -312,5 +594,8 @@ module.exports = {
312
594
  loadObligationSet,
313
595
  planningUnresolved,
314
596
  updateObligation,
597
+ updateBinding,
598
+ updateStep,
599
+ activeStepSnapshot,
315
600
  unresolvedObligations,
316
601
  };
@@ -7,7 +7,7 @@ const { atomicWriteJson, readJson } = require('./fable-contracts');
7
7
 
8
8
  const OPEN_STATES = new Set(['pending', 'active', 'running', 'failed', 'blocked', 'escaped']);
9
9
  const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
- const WORKFLOW_CONTROLLER_COMMANDS = new Set(['plan', 'start', 'revision', 'obligation', 'escape', 'block']);
10
+ const WORKFLOW_CONTROLLER_COMMANDS = new Set(['plan', 'start', 'revision', 'obligation', 'binding', 'stage-binding', 'step', 'escape', 'block']);
11
11
 
12
12
  function activeWorkflowPlan(workflow) {
13
13
  if (!workflow) return null;
@@ -99,6 +99,13 @@ function unresolvedStages(match, workflow, lastEditAt = 0) {
99
99
  const escape = (workflow.escapes || []).find(item => item.stageId === stageId && item.runId === match.run.runId);
100
100
  if (escape && escape.evidence && escape.uncoveredScope &&
101
101
  ['workflow-uncovered-scope', 'host-capability-unavailable'].includes(escape.reasonCode)) continue;
102
+ const bindings = [...(contract.requiredBindings || []), ...(contract.optionalBindings || [])];
103
+ const unresolvedBindings = bindings.filter(binding => binding.required !== false
104
+ ? binding.status !== 'loaded'
105
+ : !['loaded', 'not-needed'].includes(binding.status));
106
+ if (unresolvedBindings.length) {
107
+ unresolved.push(`${stageId}:binding-unresolved:${unresolvedBindings.map(binding => binding.id).join(',')}`);
108
+ }
102
109
  const evidence = readJson(path.join(match.runRoot, 'evidence', `${stageId}.json`));
103
110
  if (contract.status !== 'pass' || !contract.checkCommand || !evidence || evidence.status !== 'pass' ||
104
111
  evidence.exitCode !== 0 || evidence.planId !== contract.planId || evidence.runId !== contract.runId ||