harness-everything 0.16.6 → 0.18.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 (37) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +2 -1
  4. package/CHANGELOG.md +34 -0
  5. package/README.md +1 -0
  6. package/behavioral-evals/cases/pressure-status-visibility.yaml +30 -0
  7. package/ci/invariant-routing.test.js +13 -0
  8. package/ci/mechanism-2ai-workspace-fingerprint-batch.test.js +11 -1
  9. package/ci/mechanism-2an-workflow-obligations.test.js +201 -0
  10. package/ci/router-workflow-plan.test.js +3 -0
  11. package/docs/architecture.md +18 -1
  12. package/docs/routing.md +21 -2
  13. package/docs/workflow-runtime.md +15 -0
  14. package/harness-everything/SKILL.md +4 -3
  15. package/harness-everything/references/triage-and-tiers.md +21 -4
  16. package/harness-everything/scripts/kernel-router-core.js +12 -0
  17. package/harness-everything/scripts/kernel-router.js +14 -3
  18. package/harness-everything/scripts/router-contract-core.js +1 -1
  19. package/hooks/scripts/lib/workflow-obligations.js +316 -0
  20. package/hooks/scripts/lib/workflow-runtime.js +1 -1
  21. package/hooks/scripts/workflow-disposition.js +97 -5
  22. package/hooks/scripts/workflow-stop-gate.js +12 -4
  23. package/opencode-plugin/plugin.json +1 -1
  24. package/package.json +1 -1
  25. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  26. package/plugins/harness-everything/hooks/scripts/lib/workflow-obligations.js +316 -0
  27. package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +1 -1
  28. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +97 -5
  29. package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +12 -4
  30. package/plugins/harness-everything/plugin.json +1 -1
  31. package/plugins/harness-everything/scripts/lib/advisory-text.js +12 -2
  32. package/plugins/harness-everything/skills/harness-everything/SKILL.md +4 -3
  33. package/plugins/harness-everything/skills/harness-everything/references/triage-and-tiers.md +21 -4
  34. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router-core.js +12 -0
  35. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +14 -3
  36. package/plugins/harness-everything/skills/harness-everything/scripts/router-contract-core.js +1 -1
  37. package/scripts/lib/advisory-text.js +12 -2
@@ -24,6 +24,7 @@ const { applyEnsemblePolicy } = require('./ensemble-policy');
24
24
  const INVARIANT_TEXT = {
25
25
  'scope-lock': 'Route before execution: stay inside the authorized task/repository scope.',
26
26
  'verify-before-claim': 'Verify before claim: completion claims require objective evidence appropriate to the change.',
27
+ 'visible-status-updates': 'User-visible status is mandatory: use the single Harness Status format with Current, Read/Evidence, and Next at required phase boundaries; add Blocked/Risk only when materially applicable.',
27
28
  'replan-after-repeated-failure': 'Re-plan on repetition: after 3 same-signature failures, stop micro-retrying and zoom out/re-diagnose.',
28
29
  'evaluate-suggestions-before-skip': 'Evaluate before skip: read each suggested skill\'s complete SKILL.md entry/basic flow before omitting it.',
29
30
  'loop-awareness': 'When iterative work runs long, remind the agent to verify assumptions or re-plan; never hard-stop on a count.',
@@ -153,6 +154,7 @@ function printRoutingCheckpoint(plan) {
153
154
  console.log(` - Strategy: ${plan.strategy || 'deferred'}`);
154
155
  console.log(` - Required invariants: ${invariants.length ? invariants.join(', ') : 'none'}`);
155
156
  console.log(` - Suggested skills: ${suggestions.length ? suggestions.join(', ') : 'none'}`);
157
+ console.log(' - User-visible progress: use this checkpoint as source state for the single Harness Status contract below; do not invent a per-skill progress format.');
156
158
  if (suggestions.length > 0) {
157
159
  console.log(' - Suggestion evaluation: MANDATORY. Before skipping any listed skill, read its complete SKILL.md entry and evaluate USE FOR, DO NOT USE FOR, workflow/basic flow, and hard rules.');
158
160
  console.log(' - Skip evidence: do not reject from only the skill name, description, router summary, or a generic "routine/common task" judgement. If the entry cannot be resolved/read, mark it unresolved/unavailable rather than skipped.');
@@ -166,6 +168,16 @@ function printKernelContract(plan) {
166
168
  console.log(` - ${invariant}: ${INVARIANT_TEXT[invariant] || 'Required by the selected workflow plan.'}`);
167
169
  }
168
170
 
171
+ console.log('\n=> USER-VISIBLE HARNESS STATUS CONTRACT (MUST):');
172
+ console.log(' - For non-trivial software/project work, the agent MUST use one compact user-visible status format:');
173
+ console.log(' Harness Status');
174
+ console.log(' - Current: <what is being done now>');
175
+ console.log(' - Read/Evidence: <important files/sources/evidence read or confirmed>');
176
+ console.log(' - Next: <next intended action>');
177
+ console.log(' - Blocked/Risk: <only when materially applicable>');
178
+ console.log(' - Emit it before substantive execution, after a major phase, when direction materially changes, at meaningful long-running phase boundaries, and before final completion (the final response may merge it naturally).');
179
+ console.log(' - This is a semantic communication MUST, not a hard execution lock, counter, or reset condition.');
180
+
169
181
  console.log('\n=> WORKFLOW SKILLS (EVALUATE APPLICABILITY — SELECTED WORKFLOW IS GUIDANCE):');
170
182
  if (Array.isArray(plan.suggestedSkills) && plan.suggestedSkills.length > 0) {
171
183
  const emitted = new Set();
@@ -44,7 +44,8 @@ function isHostNotificationPrompt(prompt) {
44
44
  return /^<task-notification[\s>][\s\S]*<\/task-notification>\s*$/i.test(text);
45
45
  }
46
46
 
47
- function persistWorkflow(plan, payload, prompt) {
47
+ function persistWorkflow(contract, payload, prompt) {
48
+ const plan = contract && contract.workflowPlan;
48
49
  if (!payload || !(payload.session_id || payload.sessionId)) return null;
49
50
  const { runtime, hooksRoot } = loadRuntime();
50
51
  const context = runtime.loadWorkflow(payload);
@@ -83,6 +84,7 @@ function persistWorkflow(plan, payload, prompt) {
83
84
  verification: plan.verification,
84
85
  mutationIsolation: plan.mutationIsolation || { required: false },
85
86
  workflowPlan: plan,
87
+ taskShape: contract.taskShape || null,
86
88
  state: selected ? 'pending' : 'deferred',
87
89
  planningWarnings: plan.fallback?.disposition === 'none' ? [] : [...(plan.fallback?.reasonCodes || [])],
88
90
  runId: null,
@@ -94,6 +96,10 @@ function persistWorkflow(plan, payload, prompt) {
94
96
  hardBoundaries: ['rule-of-3-reflection', 'user-host-permission'],
95
97
  },
96
98
  };
99
+ if (selected && ['tier2', 'tier3'].includes(plan.tier)) {
100
+ const { initializePlanningContract } = require(path.join(hooksRoot, 'lib/workflow-obligations'));
101
+ initializePlanningContract(context, plan);
102
+ }
97
103
  const memoryCapability = issueMemoryCapability(context.workflow);
98
104
  runtime.saveWorkflow(context);
99
105
  return { ...context, hooksRoot, retained: false, memoryCapability };
@@ -115,7 +121,7 @@ function run(raw) {
115
121
  let persisted = null;
116
122
  let failure = null;
117
123
  try {
118
- persisted = persistWorkflow(plan, payload, prompt);
124
+ persisted = persistWorkflow(result?.contract || null, payload, prompt);
119
125
  if (persisted?.retained && persisted.workflow) plan = persisted.workflow.workflowPlan;
120
126
  } catch (error) { failure = error; }
121
127
  if (hostNotification && !persisted?.workflow) {
@@ -146,7 +152,12 @@ function run(raw) {
146
152
  console.log(' - Memory write disposition: ' + (persisted.workflow.workflowPlan.memory?.write || 'none'));
147
153
  if (persisted.memoryCapability) console.log(' - Memory capability (single-use, workflow/session-bound): ' + persisted.memoryCapability);
148
154
  console.log(' - Stage specification: ' + path.join(persisted.sessionDir, 'workflow-stages.json'));
149
- console.log(' - Enter/replan: node "' + controller + '" start --session-id "' + persisted.sessionId + '"');
155
+ if (plan.strategySelection === 'selected' && ['tier2', 'tier3'].includes(plan.tier)) {
156
+ console.log(' - Planning contract: ' + path.join(persisted.sessionDir, 'workflow-obligations.json'));
157
+ console.log(' - Requirements first: decompose intent into requirement fragments, then confirm the smallest sufficient workflow.');
158
+ console.log(' - Record plan: node "' + controller + '" plan --session-id "' + persisted.sessionId + '" --requirements-json "[{\\\"id\\\":\\\"req-1\\\",\\\"summary\\\":\\\"...\\\",\\\"acceptance\\\":\\\"...\\\"}]" --strategy "' + plan.strategy + '" --evidence "<why this workflow fits>"');
159
+ }
160
+ console.log(' - Enter selected workflow after planning: node "' + controller + '" start --session-id "' + persisted.sessionId + '"');
150
161
  console.log(' - Escape one declared stage: node "' + controller + '" escape --session-id "' + persisted.sessionId + '" --stage-id "<id>" --reason-code workflow-uncovered-scope --scope "<uncovered scope>" --evidence "<evidence>"');
151
162
  } else {
152
163
  console.log(' - Runtime state was not persisted; reminders may be less precise, but execution remains available.');
@@ -320,7 +320,7 @@ function selectWorkflowStrategy(input = {}) {
320
320
  function planMetadataForStrategy(strategy) {
321
321
  const base = {
322
322
  patterns: ['router'],
323
- requiredInvariants: ['scope-lock', 'verify-before-claim', 'replan-after-repeated-failure'],
323
+ requiredInvariants: ['scope-lock', 'verify-before-claim', 'replan-after-repeated-failure', 'visible-status-updates'],
324
324
  suggestedSkills: [],
325
325
  maxIterations: null,
326
326
  parallelAllowed: null,
@@ -0,0 +1,316 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { atomicWriteJson, readJson } = require('./fable-contracts');
5
+
6
+ const OBLIGATION_SCHEMA_VERSION = 1;
7
+ const OBLIGATION_FILE = 'workflow-obligations.json';
8
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
9
+ const ALLOWED_ESCAPE_REASONS = new Set(['workflow-uncovered-scope', 'host-capability-unavailable']);
10
+
11
+ function obligationPath(sessionDir) {
12
+ return path.join(sessionDir, OBLIGATION_FILE);
13
+ }
14
+
15
+ function needsPlanningContract(plan) {
16
+ return Boolean(
17
+ plan &&
18
+ plan.strategySelection === 'selected' &&
19
+ ['tier2', 'tier3'].includes(plan.tier) &&
20
+ plan.strategy
21
+ );
22
+ }
23
+
24
+ function needsGenericExecutionObligations(plan) {
25
+ return needsPlanningContract(plan) && !String(plan.strategy).startsWith('fable-');
26
+ }
27
+
28
+ function suggestedBindings(plan, kind) {
29
+ const skills = Array.isArray(plan?.suggestedSkills) ? plan.suggestedSkills : [];
30
+ if (kind === 'verification') return skills.filter(skill => skill === 'verification-loop');
31
+ if (kind === 'execution') return skills.filter(skill => skill !== 'verification-loop');
32
+ return [];
33
+ }
34
+
35
+ function planningObligations() {
36
+ return [
37
+ {
38
+ id: 'decompose',
39
+ kind: 'planning',
40
+ requirement: 'Decompose the user intent into explicit requirement fragments with acceptance evidence.',
41
+ required: true,
42
+ escapable: false,
43
+ suggestedSkills: [],
44
+ },
45
+ {
46
+ id: 'compose',
47
+ kind: 'planning',
48
+ requirement: 'Evaluate the requirement fragments and confirm the smallest sufficient workflow before execution.',
49
+ required: true,
50
+ escapable: false,
51
+ suggestedSkills: [],
52
+ },
53
+ ];
54
+ }
55
+
56
+ function executionObligations(plan) {
57
+ return [
58
+ {
59
+ id: 'execute',
60
+ kind: 'execution',
61
+ requirement: 'Resolve the planned requirement fragments using adaptive Skill/direct-action bindings.',
62
+ required: true,
63
+ escapable: true,
64
+ suggestedSkills: suggestedBindings(plan, 'execution'),
65
+ },
66
+ {
67
+ id: 'verify',
68
+ kind: 'verification',
69
+ requirement: 'Produce objective verification evidence appropriate to the completed requirements before claiming completion.',
70
+ required: true,
71
+ escapable: false,
72
+ suggestedSkills: suggestedBindings(plan, 'verification'),
73
+ },
74
+ ];
75
+ }
76
+
77
+ function newObligation(item) {
78
+ return {
79
+ ...item,
80
+ status: 'pending',
81
+ evidence: null,
82
+ reasonCode: null,
83
+ scope: null,
84
+ updatedAt: null,
85
+ };
86
+ }
87
+
88
+ function initializePlanningContract(context, plan) {
89
+ if (!needsPlanningContract(plan)) return null;
90
+ const existing = readJson(obligationPath(context.sessionDir));
91
+ if (existing && validateObligationSet(context, existing, plan) === null) return existing;
92
+ const now = new Date().toISOString();
93
+ const set = {
94
+ schemaVersion: OBLIGATION_SCHEMA_VERSION,
95
+ workflowId: context.workflow.workflowId,
96
+ sessionId: context.sessionId,
97
+ tier: plan.tier,
98
+ candidateStrategy: plan.strategy,
99
+ revision: context.workflow.revision,
100
+ phase: 'planning',
101
+ createdAt: now,
102
+ requirements: [],
103
+ workflowSelection: {
104
+ candidateStrategy: plan.strategy,
105
+ confirmedStrategy: null,
106
+ evidence: null,
107
+ confirmedAt: null,
108
+ },
109
+ obligations: planningObligations().map(newObligation),
110
+ };
111
+ atomicWriteJson(obligationPath(context.sessionDir), set);
112
+ context.workflow.obligationContract = {
113
+ schemaVersion: OBLIGATION_SCHEMA_VERSION,
114
+ file: OBLIGATION_FILE,
115
+ revision: set.revision,
116
+ };
117
+ return set;
118
+ }
119
+
120
+ function parseRequirements(requirementsJson) {
121
+ let parsed;
122
+ try { parsed = JSON.parse(String(requirementsJson || '')); }
123
+ catch (_) { throw new Error('--requirements-json must be valid JSON'); }
124
+ if (!Array.isArray(parsed) || parsed.length < 1 || parsed.length > 32) {
125
+ throw new Error('--requirements-json must contain 1..32 requirement fragments');
126
+ }
127
+ const ids = new Set();
128
+ return parsed.map((item, index) => {
129
+ if (!item || typeof item !== 'object' || Array.isArray(item)) throw new Error('requirement fragment must be an object');
130
+ const id = String(item.id || '').trim();
131
+ const summary = String(item.summary || '').trim();
132
+ const acceptance = String(item.acceptance || '').trim();
133
+ if (!SAFE_ID.test(id) || ids.has(id)) throw new Error('requirement ids must be unique stable ids');
134
+ if (!summary || !acceptance) throw new Error('each requirement needs summary and acceptance');
135
+ ids.add(id);
136
+ const suggestedSkills = Array.isArray(item.suggestedSkills)
137
+ ? [...new Set(item.suggestedSkills.filter(value => typeof value === 'string' && value.trim()).map(value => value.trim()))]
138
+ : [];
139
+ return { id, order: index + 1, summary, acceptance, suggestedSkills };
140
+ });
141
+ }
142
+
143
+ function recordPlanning(context, requirementsJson, selectedPlan, requestedStrategy, evidence) {
144
+ if (!needsPlanningContract(selectedPlan)) throw new Error('selected workflow does not require a planning contract');
145
+ const loaded = loadObligationSet(context, selectedPlan);
146
+ if (!loaded.set) throw new Error('workflow planning contract unavailable or invalid: ' + loaded.error);
147
+ const requirements = parseRequirements(requirementsJson);
148
+ const requested = String(requestedStrategy || '').trim();
149
+ const planningEvidence = String(evidence || '').trim();
150
+ if (!requested) throw new Error('--strategy is required');
151
+ if (!planningEvidence) throw new Error('--evidence is required');
152
+
153
+ const now = new Date().toISOString();
154
+ loaded.set.requirements = requirements;
155
+ const decompose = loaded.set.obligations.find(item => item.id === 'decompose');
156
+ if (decompose) {
157
+ decompose.status = 'pass';
158
+ decompose.reasonCode = null;
159
+ decompose.evidence = 'requirements:' + requirements.map(item => item.id).join(',');
160
+ decompose.updatedAt = now;
161
+ }
162
+
163
+ const blocked = selectedPlan.fallback?.disposition === 'blocked';
164
+ loaded.set.workflowSelection = {
165
+ candidateStrategy: loaded.set.candidateStrategy,
166
+ requestedStrategy: requested,
167
+ confirmedStrategy: blocked ? null : selectedPlan.strategy,
168
+ evidence: planningEvidence,
169
+ confirmedAt: blocked ? null : now,
170
+ disposition: blocked ? 'blocked' : 'confirmed',
171
+ fallback: selectedPlan.fallback || null,
172
+ };
173
+ const compose = loaded.set.obligations.find(item => item.id === 'compose');
174
+ if (compose) {
175
+ compose.status = blocked ? 'blocked' : 'pass';
176
+ compose.reasonCode = blocked ? 'workflow-selection-blocked' : null;
177
+ compose.evidence = planningEvidence;
178
+ compose.updatedAt = now;
179
+ }
180
+
181
+ if (blocked) {
182
+ context.workflow.planningWarning = 'workflow-selection-blocked';
183
+ delete context.workflow.pendingPlan;
184
+ } else {
185
+ loaded.set.phase = 'planned';
186
+ delete context.workflow.planningWarning;
187
+ if (selectedPlan.strategy !== context.workflow.workflowPlan.strategy ||
188
+ JSON.stringify(selectedPlan) !== JSON.stringify(context.workflow.workflowPlan)) {
189
+ context.workflow.pendingPlan = selectedPlan;
190
+ } else {
191
+ delete context.workflow.pendingPlan;
192
+ }
193
+ }
194
+
195
+ atomicWriteJson(obligationPath(context.sessionDir), loaded.set);
196
+ return { set: loaded.set, blocked, selectedPlan };
197
+ }
198
+
199
+ function materializeExecutionObligations(context, plan) {
200
+ if (!needsGenericExecutionObligations(plan)) return null;
201
+ const loaded = loadObligationSet(context, plan);
202
+ if (!loaded.set) throw new Error('workflow planning contract unavailable or invalid: ' + loaded.error);
203
+ if (planningUnresolved(context, plan).length) throw new Error('requirements/workflow planning must be resolved before start');
204
+ const existing = new Map(loaded.set.obligations.map(item => [item.id, item]));
205
+ for (const item of executionObligations(plan)) {
206
+ if (!existing.has(item.id)) loaded.set.obligations.push(newObligation(item));
207
+ }
208
+ loaded.set.phase = 'execution';
209
+ loaded.set.activeStrategy = plan.strategy;
210
+ loaded.set.revision = context.workflow.revision;
211
+ atomicWriteJson(obligationPath(context.sessionDir), loaded.set);
212
+ return loaded.set;
213
+ }
214
+
215
+ function validateObligationSet(context, set, plan = context.workflow?.pendingPlan || context.workflow?.workflowPlan) {
216
+ if (!set || set.schemaVersion !== OBLIGATION_SCHEMA_VERSION) return 'schema-invalid';
217
+ if (set.workflowId !== context.workflow?.workflowId || set.sessionId !== context.sessionId) return 'correlation-invalid';
218
+ if (!plan || set.tier !== plan.tier) return 'plan-mismatch';
219
+ if (!Array.isArray(set.requirements) || !Array.isArray(set.obligations) || set.obligations.length === 0) return 'obligations-invalid';
220
+ const ids = new Set();
221
+ for (const obligation of set.obligations) {
222
+ if (!obligation || !SAFE_ID.test(obligation.id || '') || ids.has(obligation.id)) return 'obligation-id-invalid';
223
+ ids.add(obligation.id);
224
+ if (!['pending', 'pass', 'escaped', 'blocked'].includes(obligation.status)) return 'obligation-status-invalid';
225
+ }
226
+ return null;
227
+ }
228
+
229
+ function loadObligationSet(context, plan) {
230
+ const set = readJson(obligationPath(context.sessionDir));
231
+ const error = validateObligationSet(context, set, plan);
232
+ return { set: error ? null : set, error };
233
+ }
234
+
235
+ function planningUnresolved(context, plan = context.workflow?.pendingPlan || context.workflow?.workflowPlan) {
236
+ if (!needsPlanningContract(plan)) return [];
237
+ const loaded = loadObligationSet(context, plan);
238
+ if (!loaded.set) return ['planning:' + (loaded.error || 'missing')];
239
+ const unresolved = [];
240
+ for (const id of ['decompose', 'compose']) {
241
+ const obligation = loaded.set.obligations.find(item => item.id === id);
242
+ if (!obligation) unresolved.push(id + ':missing');
243
+ else if (obligation.status !== 'pass' || !obligation.evidence) unresolved.push(id + ':' + obligation.status);
244
+ }
245
+ if (!loaded.set.requirements.length) unresolved.push('requirements:empty');
246
+ if (loaded.set.workflowSelection?.confirmedStrategy !== plan.strategy ||
247
+ loaded.set.workflowSelection?.disposition !== 'confirmed') {
248
+ unresolved.push('workflow-selection:unconfirmed');
249
+ }
250
+ return [...new Set(unresolved)];
251
+ }
252
+
253
+ function updateObligation(context, obligationId, disposition, options = {}) {
254
+ if (!SAFE_ID.test(obligationId || '')) throw new Error('--obligation-id must be a stable obligation id');
255
+ if (!['pass', 'escaped', 'blocked'].includes(disposition)) throw new Error('--disposition must be pass, escaped, or blocked');
256
+ if (['decompose', 'compose'].includes(obligationId)) throw new Error('planning obligations are resolved through the plan command');
257
+ const plan = context.workflow?.pendingPlan || context.workflow?.workflowPlan;
258
+ const loaded = loadObligationSet(context, plan);
259
+ if (!loaded.set) throw new Error('workflow obligation contract unavailable or invalid: ' + loaded.error);
260
+ const obligation = loaded.set.obligations.find(item => item.id === obligationId);
261
+ if (!obligation) throw new Error('unknown workflow obligation: ' + obligationId);
262
+ const evidence = String(options.evidence || '').trim();
263
+ if (!evidence) throw new Error('--evidence is required for every obligation disposition');
264
+ const reasonCode = String(options.reasonCode || '').trim() || null;
265
+ const scope = String(options.scope || '').trim() || null;
266
+ if (disposition === 'escaped') {
267
+ if (!obligation.escapable) throw new Error('this obligation cannot be escaped');
268
+ if (!ALLOWED_ESCAPE_REASONS.has(reasonCode)) throw new Error('escaped obligations require workflow-uncovered-scope or host-capability-unavailable');
269
+ if (!scope) throw new Error('--scope is required when escaping an obligation');
270
+ }
271
+ if (disposition === 'blocked' && !reasonCode) throw new Error('--reason-code is required when blocking an obligation');
272
+ obligation.status = disposition;
273
+ obligation.evidence = evidence;
274
+ obligation.reasonCode = reasonCode;
275
+ obligation.scope = scope;
276
+ obligation.updatedAt = new Date().toISOString();
277
+ atomicWriteJson(obligationPath(context.sessionDir), loaded.set);
278
+ return obligation;
279
+ }
280
+
281
+ function unresolvedObligations(context) {
282
+ const plan = context.workflow?.pendingPlan || context.workflow?.workflowPlan;
283
+ if (!needsPlanningContract(plan)) return [];
284
+ const unresolved = planningUnresolved(context, plan);
285
+ const loaded = loadObligationSet(context, plan);
286
+ if (!loaded.set) return unresolved;
287
+ if (!['running', 'satisfied', 'blocked', 'failed'].includes(context.workflow.state)) {
288
+ unresolved.push('execution:not-started');
289
+ }
290
+ if (needsGenericExecutionObligations(plan)) {
291
+ for (const obligation of loaded.set.obligations.filter(item => ['execute', 'verify'].includes(item.id))) {
292
+ if (obligation.status === 'pending') unresolved.push(obligation.id + ':pending');
293
+ else if (obligation.status === 'blocked') unresolved.push(obligation.id + ':blocked');
294
+ else if (!obligation.evidence) unresolved.push(obligation.id + ':evidence-missing');
295
+ else if (obligation.status === 'escaped' && (!obligation.escapable || !ALLOWED_ESCAPE_REASONS.has(obligation.reasonCode) || !obligation.scope)) {
296
+ unresolved.push(obligation.id + ':escape-invalid');
297
+ }
298
+ }
299
+ }
300
+ return [...new Set(unresolved)];
301
+ }
302
+
303
+ module.exports = {
304
+ OBLIGATION_FILE,
305
+ ALLOWED_ESCAPE_REASONS,
306
+ obligationPath,
307
+ needsPlanningContract,
308
+ needsGenericExecutionObligations,
309
+ initializePlanningContract,
310
+ recordPlanning,
311
+ materializeExecutionObligations,
312
+ loadObligationSet,
313
+ planningUnresolved,
314
+ updateObligation,
315
+ unresolvedObligations,
316
+ };
@@ -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(['start', 'revision', 'escape', 'block']);
10
+ const WORKFLOW_CONTROLLER_COMMANDS = new Set(['plan', 'start', 'revision', 'obligation', 'escape', 'block']);
11
11
 
12
12
  function activeWorkflowPlan(workflow) {
13
13
  if (!workflow) return null;
@@ -6,22 +6,66 @@ const path = require('path');
6
6
  const { loadWorkflow, saveWorkflow, matchingRun, OPEN_STATES, WORKFLOW_CONTROLLER_COMMANDS } = require('./lib/workflow-runtime');
7
7
  const { getWorkspaceRoot, readCurrentSession } = require('./lib/harness-state');
8
8
  const { atomicWriteJson, readJson } = require('./lib/fable-contracts');
9
+ const {
10
+ planningUnresolved,
11
+ recordPlanning,
12
+ materializeExecutionObligations,
13
+ updateObligation,
14
+ } = require('./lib/workflow-obligations');
9
15
 
10
16
  const ALLOWED_ESCAPE_REASONS = new Set(['workflow-uncovered-scope', 'host-capability-unavailable']);
11
17
 
12
18
  function parseArgs(argv) {
13
19
  const args = { command: argv[0] };
14
- const flags = new Map([['--reason-code', 'reasonCode'], ['--scope', 'scope'], ['--evidence', 'evidence'], ['--session-id', 'sessionId'], ['--stage-id', 'stageId']]);
20
+ const flags = new Map([
21
+ ['--reason-code', 'reasonCode'], ['--scope', 'scope'], ['--evidence', 'evidence'],
22
+ ['--session-id', 'sessionId'], ['--stage-id', 'stageId'],
23
+ ['--obligation-id', 'obligationId'], ['--disposition', 'disposition'],
24
+ ['--requirements-json', 'requirementsJson'], ['--strategy', 'strategy'],
25
+ ]);
15
26
  for (let i = 1; i < argv.length; i++) {
16
27
  const key = flags.get(argv[i]);
17
- if (!key || !argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error('unknown or incomplete argument: ' + argv[i]);
28
+ if (!key || argv[i + 1] === undefined || argv[i + 1].startsWith('--')) throw new Error('unknown or incomplete argument: ' + argv[i]);
18
29
  args[key] = argv[++i];
19
30
  }
20
31
  return args;
21
32
  }
22
33
 
23
34
  function usage() {
24
- return `Usage: workflow-disposition.js <${[...WORKFLOW_CONTROLLER_COMMANDS].join('|')}> --session-id <id> [--stage-id <id> --reason-code <reason> --scope <scope> --evidence <evidence>]`;
35
+ return `Usage: workflow-disposition.js <${[...WORKFLOW_CONTROLLER_COMMANDS].join('|')}> --session-id <id> [--requirements-json <json> --strategy <strategy> --obligation-id <id> --disposition <pass|escaped|blocked> --reason-code <reason> --scope <scope> --evidence <evidence>]`;
36
+ }
37
+
38
+ const SELECTABLE_STRATEGIES = new Set(['direct-single', 'iterative-single', 'fable-staged', 'fable-parallel', 'fable-multi-agent-workspace']);
39
+
40
+ function routerContract() {
41
+ for (const relative of ['../../harness-everything/scripts/router-contract.js', '../../skills/harness-everything/scripts/router-contract.js']) {
42
+ const file = path.resolve(__dirname, relative);
43
+ if (fs.existsSync(file)) return require(file);
44
+ }
45
+ throw new Error('router contract unavailable');
46
+ }
47
+
48
+ function recomposePlan(workflow, requestedStrategy) {
49
+ const strategy = String(requestedStrategy || '').trim();
50
+ if (!SELECTABLE_STRATEGIES.has(strategy)) throw new Error('--strategy must name a supported execution topology');
51
+ if (strategy === workflow.workflowPlan.strategy) return workflow.workflowPlan;
52
+ if (!workflow.taskShape || typeof workflow.taskShape !== 'object') {
53
+ throw new Error('workflow task-shape context is unavailable; cannot safely recompose topology');
54
+ }
55
+ const taskShape = {
56
+ ...workflow.taskShape,
57
+ explicitRequest: {
58
+ ...(workflow.taskShape.explicitRequest || {}),
59
+ strategy,
60
+ },
61
+ };
62
+ return routerContract().buildWorkflowPlan({
63
+ routingStatus: workflow.workflowPlan.routingStatus,
64
+ tier: workflow.tier,
65
+ taskShape,
66
+ actionGateReasonCodes: workflow.workflowPlan.actionGate?.reasonCodes || [],
67
+ reasonCodes: ['post-decomposition-workflow-selection'],
68
+ });
25
69
  }
26
70
 
27
71
  function consumer() {
@@ -51,10 +95,31 @@ function main() {
51
95
  const context = loadWorkflow({ session_id: sessionId });
52
96
  const { workflow } = context;
53
97
  if (!workflow || !OPEN_STATES.has(workflow.state)) throw new Error('no unresolved workflow contract exists for this session');
98
+
99
+ if (args.command === 'plan') {
100
+ if (!workflow.workflowId || !workflow.workflowPlan) throw new Error('legacy workflow lacks a correlated plan; cannot plan it implicitly');
101
+ if (!['pending', 'active'].includes(workflow.state)) throw new Error('requirements/workflow composition belongs before execution starts');
102
+ const selectedPlan = recomposePlan(workflow, args.strategy);
103
+ const result = recordPlanning(context, args.requirementsJson, selectedPlan, args.strategy, args.evidence);
104
+ saveWorkflow(context);
105
+ process.stdout.write(JSON.stringify({
106
+ state: workflow.state,
107
+ workflowId: workflow.workflowId,
108
+ requirements: result.set.requirements.map(item => ({ id: item.id, summary: item.summary })),
109
+ workflowSelection: result.set.workflowSelection,
110
+ selectedStrategy: result.selectedPlan.strategy,
111
+ blocked: result.blocked,
112
+ }) + '\n');
113
+ return;
114
+ }
115
+
54
116
  if (args.command === 'start') {
55
117
  if (!workflow.workflowId || !workflow.workflowPlan) throw new Error('legacy workflow lacks a correlated plan; cannot start it implicitly');
56
118
  if (workflow.state === 'running') throw new Error('workflow already running; record a blocker before replanning');
57
119
  const plan = workflow.pendingPlan || workflow.workflowPlan;
120
+ const unresolvedPlanning = planningUnresolved(context, plan);
121
+ if (unresolvedPlanning.length) throw new Error('resolve requirement decomposition/workflow selection first: ' + unresolvedPlanning.join(', '));
122
+
58
123
  if (!String(plan.strategy || '').startsWith('fable-')) {
59
124
  activatePlan(workflow, plan);
60
125
  workflow.revision++;
@@ -62,10 +127,17 @@ function main() {
62
127
  workflow.escapes = [];
63
128
  delete workflow.pendingPlan;
64
129
  delete workflow.blockReason;
130
+ const obligations = materializeExecutionObligations(context, plan);
65
131
  saveWorkflow(context);
66
- process.stdout.write(JSON.stringify({ state: workflow.state, workflowId: workflow.workflowId, revision: workflow.revision }) + '\n');
132
+ process.stdout.write(JSON.stringify({
133
+ state: workflow.state,
134
+ workflowId: workflow.workflowId,
135
+ revision: workflow.revision,
136
+ obligations: obligations ? obligations.obligations.map(item => ({ id: item.id, status: item.status })) : [],
137
+ }) + '\n');
67
138
  return;
68
139
  }
140
+
69
141
  const stages = readJson(path.join(context.sessionDir, 'workflow-stages.json'));
70
142
  if (!Array.isArray(stages) || !stages.length) throw new Error('write a non-empty workflow-stages.json stage array at the session path first');
71
143
  if (stages.some(stage => !stage.checkCommand || !stage.passCondition)) throw new Error('every required stage needs an objective checkCommand and passCondition');
@@ -84,6 +156,25 @@ function main() {
84
156
  workflow.escapes = [];
85
157
  delete workflow.pendingPlan;
86
158
  delete workflow.blockReason;
159
+ } else if (args.command === 'obligation') {
160
+ if (String(workflow.strategy || '').startsWith('fable-')) throw new Error('Fable workflows use correlated stage contracts, not generic execution obligations');
161
+ if (workflow.state !== 'running') throw new Error('start the selected workflow before recording execution obligation dispositions');
162
+ const obligation = updateObligation(context, args.obligationId, args.disposition, {
163
+ evidence: args.evidence,
164
+ reasonCode: args.reasonCode,
165
+ scope: args.scope,
166
+ });
167
+ process.stdout.write(JSON.stringify({
168
+ state: workflow.state,
169
+ workflowId: workflow.workflowId,
170
+ obligation: {
171
+ id: obligation.id,
172
+ status: obligation.status,
173
+ reasonCode: obligation.reasonCode,
174
+ evidence: obligation.evidence,
175
+ },
176
+ }) + '\n');
177
+ return;
87
178
  } else if (args.command === 'revision') {
88
179
  console.error('[Workflow Reminder] Revision requested; no hard revision budget is enforced.');
89
180
  } else if (args.command === 'escape') {
@@ -100,13 +191,14 @@ function main() {
100
191
  };
101
192
  workflow.escapes = [...(workflow.escapes || []).filter(item => item.stageId !== args.stageId), disposition];
102
193
  atomicWriteJson(path.join(match.runRoot, 'escapes', args.stageId + '.json'), disposition);
103
- // Scope-limited exception; never change the entire workflow to escaped.
104
194
  } else if (args.command === 'block') {
105
195
  if (!args.evidence?.trim()) throw new Error('--evidence is required');
106
196
  workflow.state = 'blocked';
107
197
  workflow.blockReason = args.evidence.trim();
108
198
  } else throw new Error(usage());
199
+
109
200
  saveWorkflow(context);
110
201
  process.stdout.write(JSON.stringify({ state: workflow.state, workflowId: workflow.workflowId, runId: workflow.runId, revision: workflow.revision, escapes: workflow.escapes }) + '\n');
111
202
  }
203
+
112
204
  try { main(); } catch (error) { console.error('[Workflow Disposition] ' + error.message); process.exitCode = 2; }
@@ -3,7 +3,10 @@
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');
7
+
6
8
  function warn(message) { console.error('[Workflow Reminder] ' + message); }
9
+
7
10
  function decide(payload) {
8
11
  if (!payload) return;
9
12
  try {
@@ -11,20 +14,24 @@ function decide(payload) {
11
14
  const { workflow, sessionDir } = context;
12
15
  if (!workflow || workflow.state === 'deferred') return;
13
16
  const handoff = readJson(path.join(sessionDir, 'handoff-state.json'));
14
- let unresolved = [];
17
+ let unresolved = planningUnresolved(context);
18
+
15
19
  if (String(workflow.strategy || '').startsWith('fable-')) {
16
20
  const match = matchingRun(context);
17
- unresolved = match ? unresolvedStages(match, workflow, handoff?.lastEditAt || 0) : ['correlated-run-missing'];
21
+ unresolved.push(...(match ? unresolvedStages(match, workflow, handoff?.lastEditAt || 0) : ['correlated-run-missing']));
18
22
  } else {
23
+ unresolved.push(...unresolvedObligations(context));
19
24
  const lastEdit = Math.max(workflow.lastMutationAt || 0, handoff?.lastEditAt || 0);
20
- if (lastEdit && (handoff?.lastVerifyAt || 0) < lastEdit) unresolved = ['verification-after-edit-missing'];
25
+ if (lastEdit && (handoff?.lastVerifyAt || 0) < lastEdit) unresolved.push('verification-after-edit-missing');
21
26
  }
27
+
28
+ unresolved = [...new Set(unresolved)];
22
29
  const wasBlocked = workflow.state === 'blocked';
23
30
  if (wasBlocked) warn('Workflow is marked BLOCKED; report or revisit the recorded blocker, but Harness will not trap the session.');
24
31
  if (unresolved.length) {
25
32
  workflow.unresolved = unresolved;
26
33
  saveWorkflow(context);
27
- warn('Unresolved workflow evidence: ' + unresolved.join(', ') + '. Verify/review before claiming completion.');
34
+ warn('Unresolved workflow evidence: ' + unresolved.join(', ') + '. Resolve/verify before claiming completion.');
28
35
  return;
29
36
  }
30
37
  if (wasBlocked) return;
@@ -34,4 +41,5 @@ function decide(payload) {
34
41
  saveWorkflow(context);
35
42
  } catch (error) { warn(error && error.message ? error.message : String(error)); }
36
43
  }
44
+
37
45
  readHookInput(decide);
@@ -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.16.6",
4
+ "version": "0.18.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.16.6",
3
+ "version": "0.18.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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.16.6",
3
+ "version": "0.18.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",