harness-everything 0.8.1 → 0.9.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 (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +34 -0
  4. package/ci/action-gate.test.js +82 -2
  5. package/ci/phase4-ensemble-memory.test.js +128 -10
  6. package/ci/router-workflow-plan.test.js +23 -0
  7. package/ci/workflow-execution-contract.test.js +73 -0
  8. package/ci/workflow-plan-consumers.test.js +18 -0
  9. package/fable-mode/SKILL.md +1 -1
  10. package/fable-mode/agents/fable-orchestrator.md +9 -7
  11. package/fable-mode/execution-guardrails/SKILL.md +1 -1
  12. package/fable-mode/fable-haiku/SKILL.md +1 -1
  13. package/fable-mode/fable-opus/SKILL.md +1 -1
  14. package/fable-mode/fable-sonnet/SKILL.md +1 -1
  15. package/fable-mode/scripts/workflow-plan-consumer.js +10 -7
  16. package/harness-everything/SKILL.md +1 -1
  17. package/harness-everything/schemas/router-task-shape.schema.json +4 -2
  18. package/harness-everything/schemas/router-workflow-plan.schema.json +3 -2
  19. package/harness-everything/scripts/kernel-router-core.js +2 -0
  20. package/harness-everything/scripts/kernel-router.js +25 -2
  21. package/harness-everything/scripts/router-contract-core.js +25 -4
  22. package/harness-everything/scripts/tier-router.js +19 -1
  23. package/hooks/hooks.json +3 -3
  24. package/hooks/scripts/action-gate.js +111 -2
  25. package/hooks/scripts/contract-test.js +4 -1
  26. package/hooks/scripts/lib/workflow-runtime.js +153 -1
  27. package/hooks/scripts/subagent-scope-guard.js +20 -3
  28. package/hooks/scripts/workflow-disposition.js +11 -4
  29. package/hooks/scripts/workflow-gate.js +4 -1
  30. package/multi-agent-workspace/SKILL.md +2 -2
  31. package/multi-agent-workspace/scripts/index_memory.js +107 -1
  32. package/opencode-plugin/plugin.json +1 -1
  33. package/package.json +1 -1
  34. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  35. package/plugins/harness-everything/hooks/hooks.json +24 -0
  36. package/plugins/harness-everything/hooks/scripts/action-gate.js +111 -2
  37. package/plugins/harness-everything/hooks/scripts/contract-test.js +4 -1
  38. package/plugins/harness-everything/hooks/scripts/lib/workflow-runtime.js +153 -1
  39. package/plugins/harness-everything/hooks/scripts/subagent-scope-guard.js +20 -3
  40. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +11 -4
  41. package/plugins/harness-everything/hooks/scripts/workflow-gate.js +4 -1
  42. package/plugins/harness-everything/plugin.json +1 -1
  43. package/plugins/harness-everything/skills/fable-mode/SKILL.md +1 -1
  44. package/plugins/harness-everything/skills/fable-mode/agents/fable-orchestrator.md +9 -7
  45. package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
  46. package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
  47. package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
  48. package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
  49. package/plugins/harness-everything/skills/fable-mode/scripts/workflow-plan-consumer.js +10 -7
  50. package/plugins/harness-everything/skills/harness-everything/SKILL.md +1 -1
  51. package/plugins/harness-everything/skills/harness-everything/schemas/router-task-shape.schema.json +4 -2
  52. package/plugins/harness-everything/skills/harness-everything/schemas/router-workflow-plan.schema.json +3 -2
  53. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router-core.js +2 -0
  54. package/plugins/harness-everything/skills/harness-everything/scripts/kernel-router.js +25 -2
  55. package/plugins/harness-everything/skills/harness-everything/scripts/router-contract-core.js +25 -4
  56. package/plugins/harness-everything/skills/harness-everything/scripts/tier-router.js +19 -1
  57. package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +2 -2
  58. package/plugins/harness-everything/skills/multi-agent-workspace/scripts/index_memory.js +107 -1
  59. package/plugins/harness-everything/skills/self-evolve/SKILL.md +6 -6
  60. package/plugins/harness-everything/skills/self-evolve/references/memory-resolution.md +22 -33
  61. package/plugins/harness-everything/skills/self-evolve/scripts/persist-memory.js +336 -57
  62. package/plugins/harness-everything/skills/self-evolve/scripts/self-regression.js +1 -0
  63. package/self-evolve/SKILL.md +6 -6
  64. package/self-evolve/references/memory-resolution.md +22 -33
  65. package/self-evolve/scripts/persist-memory.js +336 -57
  66. package/self-evolve/scripts/self-regression.js +1 -0
@@ -155,20 +155,23 @@ function validateParallelBatch(stages) {
155
155
  }
156
156
  }
157
157
 
158
- function dependencyBatches(stages, validateWrites) {
158
+ function dependencyBatches(stages, validateWrites, maxBatchSize = Infinity) {
159
159
  const byId = new Map(stages.map(stage => [stage.stageId, stage]));
160
160
  const remaining = new Set(byId.keys());
161
161
  const completed = new Set();
162
162
  const batches = [];
163
+ const boundedSize = Number.isInteger(maxBatchSize) && maxBatchSize > 0 ? maxBatchSize : Infinity;
163
164
 
164
165
  while (remaining.size > 0) {
165
166
  const ready = [...remaining]
166
167
  .filter(stageId => byId.get(stageId).dependsOn.every(dep => completed.has(dep)))
167
168
  .sort();
168
169
  if (ready.length === 0) throw new Error('unresolved or cyclic dependency graph');
169
- const batch = ready.map(stageId => byId.get(stageId));
170
- if (validateWrites) validateParallelBatch(batch);
171
- batches.push(batch.map(stage => stage.stageId));
170
+ const readyStages = ready.map(stageId => byId.get(stageId));
171
+ if (validateWrites) validateParallelBatch(readyStages);
172
+ for (let offset = 0; offset < readyStages.length; offset += boundedSize) {
173
+ batches.push(readyStages.slice(offset, offset + boundedSize).map(stage => stage.stageId));
174
+ }
172
175
  for (const stageId of ready) {
173
176
  remaining.delete(stageId);
174
177
  completed.add(stageId);
@@ -177,8 +180,8 @@ function dependencyBatches(stages, validateWrites) {
177
180
  return batches;
178
181
  }
179
182
 
180
- function topologicalBatches(stages) {
181
- return dependencyBatches(stages, true);
183
+ function topologicalBatches(stages, maxWorkers) {
184
+ return dependencyBatches(stages, true, maxWorkers);
182
185
  }
183
186
 
184
187
  function sequentialBatches(stages) {
@@ -222,7 +225,7 @@ function prepareRun({ routerContract, stages, workspaceRoot, runId, sessionId =
222
225
  if (fs.existsSync(runRoot)) throw new Error(`runId already exists in this workspace: ${resolvedRunId}`);
223
226
 
224
227
  const batches = plan.strategy === 'fable-parallel'
225
- ? topologicalBatches(normalizedStages)
228
+ ? topologicalBatches(normalizedStages, plan.limits && plan.limits.maxWorkers)
226
229
  : sequentialBatches(normalizedStages);
227
230
 
228
231
  const createdAt = new Date().toISOString();
@@ -4,7 +4,7 @@ description: "Route software/project work through the Harness kernel: classify T
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.8.1
7
+ version: 0.9.0
8
8
  ---
9
9
 
10
10
  # Harness Everything
@@ -91,7 +91,8 @@
91
91
  "crossDomain",
92
92
  "highUncertainty",
93
93
  "irreversibleAction",
94
- "externalSideEffect"
94
+ "externalSideEffect",
95
+ "memoryPersistenceRequested"
95
96
  ],
96
97
  "additionalProperties": false,
97
98
  "properties": {
@@ -113,7 +114,8 @@
113
114
  "crossDomain": { "type": "boolean" },
114
115
  "highUncertainty": { "type": "boolean" },
115
116
  "irreversibleAction": { "type": "boolean" },
116
- "externalSideEffect": { "type": "boolean" }
117
+ "externalSideEffect": { "type": "boolean" },
118
+ "memoryPersistenceRequested": { "type": "boolean" }
117
119
  }
118
120
  },
119
121
  "reasonCodes": {
@@ -47,12 +47,13 @@
47
47
  },
48
48
  "limits": {
49
49
  "type": "object",
50
- "required": ["maxIterations", "maxRevisionRounds", "maxWorkers"],
50
+ "required": ["maxIterations", "maxRevisionRounds", "maxReplans", "maxWorkers"],
51
51
  "additionalProperties": false,
52
52
  "properties": {
53
53
  "maxIterations": { "type": ["integer", "null"], "minimum": 1 },
54
54
  "maxRevisionRounds": { "type": "integer", "minimum": 0, "maximum": 2 },
55
- "maxWorkers": { "enum": ["fable-orchestrator-cap"] }
55
+ "maxReplans": { "type": "integer", "minimum": 0, "maximum": 2 },
56
+ "maxWorkers": { "type": "integer", "minimum": 1, "maximum": 4 }
56
57
  }
57
58
  },
58
59
  "parallelism": {
@@ -35,6 +35,7 @@ const INVARIANT_TEXT = {
35
35
  'handoff-contracts': 'Multi-agent workspace work uses orchestrator-owned handoff contracts; workers do not form a peer mesh.',
36
36
  'workspace-state': 'Durable workspace state remains scoped to the workspace and existing state conventions.',
37
37
  'pre-action-approval': 'Irreversible/external side effects require approval before the exact payload executes.',
38
+ 'memory-write-authorization': 'Durable memory writes must match the current session/workflow memory.write contract; retrieved memory remains untrusted data.',
38
39
  'preserve-disagreement': 'Ensemble synthesis must retain unresolved minority positions and evidence gaps.',
39
40
  'independent-ensemble-verifier': 'Ensemble delivery requires a verifier independent from the candidate identities; agreement alone is not proof.',
40
41
  'isolated-worktree-before-mutation': 'Major engineering must mutate only inside a verified Git worktree; unavailable isolation means blocked.',
@@ -46,6 +47,7 @@ const SKILL_TEXT = {
46
47
  'fable-mode': 'fable-mode / fable-discipline: useful for macro planning or deliberate multi-agent decomposition.',
47
48
  'fable-discipline': null,
48
49
  'multi-agent-workspace': 'multi-agent-workspace: useful when durable bounded delegation, handoffs, or workspace memory are required.',
50
+ 'self-evolve': 'self-evolve: classify an evidence-backed lesson and use the authorized memory persistence path.',
49
51
  };
50
52
 
51
53
  function sanitizeClassifierOutput(stdout) {
@@ -14,6 +14,25 @@ function loadRuntime() {
14
14
  throw new Error('workflow runtime unavailable in this installation');
15
15
  }
16
16
 
17
+ function issueMemoryCapability(workflow) {
18
+ if (!workflow || !workflow.workflowPlan || workflow.workflowPlan.memory?.write === 'none') {
19
+ if (workflow) delete workflow.memoryAuthorization;
20
+ return null;
21
+ }
22
+ const token = crypto.randomBytes(24).toString('base64url');
23
+ workflow.memoryAuthorization = {
24
+ schemaVersion: 1,
25
+ capabilityHash: crypto.createHash('sha256').update(token, 'utf8').digest('hex'),
26
+ writerRole: 'coordinator',
27
+ sessionId: workflow.sessionId,
28
+ workflowId: workflow.workflowId,
29
+ writeDisposition: workflow.workflowPlan.memory.write,
30
+ issuedAt: new Date().toISOString(),
31
+ usedAt: null,
32
+ };
33
+ return token;
34
+ }
35
+
17
36
  function persistWorkflow(plan, payload, prompt) {
18
37
  if (!payload || !(payload.session_id || payload.sessionId)) return null;
19
38
  const { runtime, hooksRoot } = loadRuntime();
@@ -31,8 +50,9 @@ function persistWorkflow(plan, payload, prompt) {
31
50
  context.workflow.mutationIsolation = plan.mutationIsolation;
32
51
  }
33
52
  context.workflow.lastPromptHash = crypto.createHash('sha256').update(prompt).digest('hex').slice(0, 24);
53
+ const memoryCapability = issueMemoryCapability(context.workflow);
34
54
  runtime.saveWorkflow(context);
35
- return { ...context, hooksRoot, retained: true };
55
+ return { ...context, hooksRoot, retained: true, memoryCapability };
36
56
  }
37
57
  const selected = plan.strategySelection === 'selected' && Boolean(plan.strategy);
38
58
  const now = Date.now();
@@ -61,8 +81,9 @@ function persistWorkflow(plan, payload, prompt) {
61
81
  reasoningPolicy: 'model-controls-how',
62
82
  },
63
83
  };
84
+ const memoryCapability = issueMemoryCapability(context.workflow);
64
85
  runtime.saveWorkflow(context);
65
- return { ...context, hooksRoot, retained: false };
86
+ return { ...context, hooksRoot, retained: false, memoryCapability };
66
87
  }
67
88
 
68
89
  function run(raw) {
@@ -99,6 +120,8 @@ function run(raw) {
99
120
  if (persisted) {
100
121
  const controller = path.join(persisted.hooksRoot, 'workflow-disposition.js');
101
122
  console.log(' - Workflow id: ' + persisted.workflow.workflowId);
123
+ console.log(' - Memory write disposition: ' + (persisted.workflow.workflowPlan.memory?.write || 'none'));
124
+ if (persisted.memoryCapability) console.log(' - Memory capability (single-use, workflow/session-bound): ' + persisted.memoryCapability);
102
125
  console.log(' - Stage specification: ' + path.join(persisted.sessionDir, 'workflow-stages.json'));
103
126
  console.log(' - Enter/replan: node "' + controller + '" start --session-id "' + persisted.sessionId + '"');
104
127
  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>"');
@@ -5,6 +5,9 @@ const path = require('path');
5
5
 
6
6
  const SCHEMA_VERSION = 1;
7
7
  const ITERATIVE_MAX_ITERATIONS = 8;
8
+ const WORKFLOW_MAX_REVISION_ROUNDS = 2;
9
+ const FABLE_MAX_REPLANS = 2;
10
+ const FABLE_MAX_WORKERS = 4;
8
11
  const TIERS = new Set(['tier1', 'tier2', 'tier3', 'unclassified']);
9
12
  const ROUTING_STATUSES = new Set(['ok', 'degraded']);
10
13
  const STRATEGIES = new Set([
@@ -154,6 +157,7 @@ function buildTaskShape(input = {}) {
154
157
  highUncertainty: Boolean(signals.highUncertainty),
155
158
  irreversibleAction: Boolean(signals.irreversibleAction),
156
159
  externalSideEffect: Boolean(signals.externalSideEffect),
160
+ memoryPersistenceRequested: Boolean(signals.memoryPersistenceRequested),
157
161
  },
158
162
  reasonCodes: uniqueReasonCodes(input.reasonCodes),
159
163
  };
@@ -418,6 +422,14 @@ function buildWorkflowPlan(input = {}) {
418
422
 
419
423
  const requiredInvariants = [...meta.requiredInvariants];
420
424
  const patterns = [...meta.patterns];
425
+ const suggestedSkills = [...meta.suggestedSkills];
426
+ const memoryPersistenceRequested = Boolean(taskShape.observedSignals && taskShape.observedSignals.memoryPersistenceRequested);
427
+ const memoryProhibited = Boolean(taskShape.explicitRequest && taskShape.explicitRequest.prohibitions && taskShape.explicitRequest.prohibitions.includes('memory'));
428
+ const memoryWrite = memoryProhibited ? 'none' : (memoryPersistenceRequested ? 'persist-via-self-evolve' : meta.memoryWrite);
429
+ if (memoryPersistenceRequested && !memoryProhibited) {
430
+ requiredInvariants.push('memory-write-authorization');
431
+ suggestedSkills.push('self-evolve');
432
+ }
421
433
  if (actionGateRequired) {
422
434
  requiredInvariants.push('pre-action-approval');
423
435
  patterns.push('verifier-gated');
@@ -438,6 +450,8 @@ function buildWorkflowPlan(input = {}) {
438
450
  ...selection.reasonCodes,
439
451
  ...fallback.reasonCodes,
440
452
  ...actionGateReasonCodes,
453
+ ...(memoryPersistenceRequested ? ['memory-persistence-requested'] : []),
454
+ ...(memoryPersistenceRequested && memoryProhibited ? ['memory-persistence-prohibited'] : []),
441
455
  ]);
442
456
 
443
457
  return {
@@ -448,7 +462,7 @@ function buildWorkflowPlan(input = {}) {
448
462
  strategySelection: selection.strategySelection,
449
463
  patterns: Array.from(new Set(patterns)),
450
464
  requiredInvariants: Array.from(new Set(requiredInvariants)),
451
- suggestedSkills: Array.from(new Set(meta.suggestedSkills)),
465
+ suggestedSkills: Array.from(new Set(suggestedSkills)),
452
466
  actionGate: {
453
467
  required: actionGateRequired,
454
468
  reasonCodes: actionGateReasonCodes,
@@ -457,8 +471,9 @@ function buildWorkflowPlan(input = {}) {
457
471
  },
458
472
  limits: {
459
473
  maxIterations: meta.maxIterations,
460
- maxRevisionRounds: 2,
461
- maxWorkers: 'fable-orchestrator-cap',
474
+ maxRevisionRounds: WORKFLOW_MAX_REVISION_ROUNDS,
475
+ maxReplans: FABLE_MAX_REPLANS,
476
+ maxWorkers: FABLE_MAX_WORKERS,
462
477
  },
463
478
  parallelism: {
464
479
  allowed: meta.parallelAllowed,
@@ -472,7 +487,7 @@ function buildWorkflowPlan(input = {}) {
472
487
  memory: {
473
488
  read: meta.memoryRead,
474
489
  scope: 'task-relevant-only',
475
- write: meta.memoryWrite,
490
+ write: memoryWrite,
476
491
  },
477
492
  verification: {
478
493
  mode: meta.verificationMode,
@@ -553,6 +568,9 @@ function validateWorkflowPlan(plan) {
553
568
  if (plan.actionGate && plan.actionGate.required && plan.actionGate.disposition !== 'pending-approval') errors.push('required actionGate must be pending-approval before execution');
554
569
  if (!plan.limits || !Object.prototype.hasOwnProperty.call(plan.limits, 'maxIterations')) errors.push('limits.maxIterations is required');
555
570
  if (plan.strategy === 'iterative-single' && (!Number.isInteger(plan.limits.maxIterations) || plan.limits.maxIterations < 1)) errors.push('iterative-single requires maxIterations');
571
+ if (!Number.isInteger(plan.limits?.maxRevisionRounds) || plan.limits.maxRevisionRounds < 0) errors.push('limits.maxRevisionRounds must be a non-negative integer');
572
+ if (!Number.isInteger(plan.limits?.maxReplans) || plan.limits.maxReplans < 0) errors.push('limits.maxReplans must be a non-negative integer');
573
+ if (!Number.isInteger(plan.limits?.maxWorkers) || plan.limits.maxWorkers < 1) errors.push('limits.maxWorkers must be a positive integer');
556
574
  if (plan.strategy === 'fable-parallel' && (!plan.parallelism || plan.parallelism.allowed !== true)) errors.push('fable-parallel requires parallelism.allowed=true');
557
575
  if (!plan.fallback || !['none', 'reduced', 'blocked'].includes(plan.fallback.disposition)) errors.push('fallback.disposition is invalid');
558
576
  if (!Array.isArray(plan.reasonCodes)) errors.push('reasonCodes must be an array');
@@ -597,6 +615,9 @@ function writeRouterContract(contractPath, contract) {
597
615
  module.exports = {
598
616
  SCHEMA_VERSION,
599
617
  ITERATIVE_MAX_ITERATIONS,
618
+ WORKFLOW_MAX_REVISION_ROUNDS,
619
+ FABLE_MAX_REPLANS,
620
+ FABLE_MAX_WORKERS,
600
621
  buildRouterContract,
601
622
  buildTaskShape,
602
623
  buildWorkflowPlan,
@@ -74,6 +74,14 @@ function detectProhibitions(prompt) {
74
74
  return rules.filter(([, regex]) => regex.test(prompt)).map(([name]) => name);
75
75
  }
76
76
 
77
+ function detectMemoryPersistenceRequest(prompt) {
78
+ const text = String(prompt || '');
79
+ return /^\s*self-evolve\b/i.test(text) ||
80
+ /\b(?:run|use|invoke|execute)\s+(?:the\s+)?self-evolve\b/i.test(text) ||
81
+ /\b(?:persist|save|record|remember)\b.{0,40}\b(?:lesson|memory|rule|insight)\b/i.test(text) ||
82
+ /(?:執行|使用).{0,8}自我進化|(?:記住|保存|持久化|記錄).{0,20}(?:教訓|記憶|規則|經驗)/i.test(text);
83
+ }
84
+
77
85
  function detectActionGateReasons(prompt) {
78
86
  const reasons = [];
79
87
  const irreversible = [
@@ -226,6 +234,7 @@ function run(userPrompt, context) {
226
234
  console.log(`[Tier Routing Pre-check]`);
227
235
 
228
236
  const promptLower = userPrompt.toLowerCase();
237
+ const memoryPersistenceRequested = detectMemoryPersistenceRequest(userPrompt);
229
238
  const loadedConfig = loadRoutingConfig();
230
239
  const routingConfig = loadedConfig.config;
231
240
  const TIER3_KEYWORDS = routingConfig.tiers.tier3 || [];
@@ -251,7 +260,12 @@ function run(userPrompt, context) {
251
260
  let rationale = 'No structural/testing signals matched; unclassified is not equivalent to trivial.';
252
261
  const reasonCodes = [...loadedConfig.reasonCodes, 'no-classification-signal'];
253
262
 
254
- if (isTrivialDocsEdit) {
263
+ if (memoryPersistenceRequested && !hasMacroSignal && !hasTier3Keyword && !hasTier2Keyword) {
264
+ recommendedTier = 'Tier 1 (Trivial)';
265
+ rationale = 'Explicit bounded self-evolve memory persistence request.';
266
+ reasonCodes.splice(loadedConfig.reasonCodes.length);
267
+ addReason(reasonCodes, 'memory-persistence-requested');
268
+ } else if (isTrivialDocsEdit) {
255
269
  recommendedTier = 'Tier 1 (Trivial)';
256
270
  rationale = 'Single documentation typo detected - direct edit, no checklist required.';
257
271
  reasonCodes.splice(loadedConfig.reasonCodes.length);
@@ -409,11 +423,15 @@ function run(userPrompt, context) {
409
423
  highUncertainty,
410
424
  irreversibleAction,
411
425
  externalSideEffect,
426
+ memoryPersistenceRequested,
412
427
  highRisk: irreversibleAction || externalSideEffect,
413
428
  },
414
429
  });
415
430
 
416
431
  console.log(`\n=> WORKFLOW STRATEGY: ${contract.workflowPlan.strategy || 'deferred'}`);
432
+ if (contract.workflowPlan.memory.write !== 'none') {
433
+ console.log(`=> MEMORY WRITE: ${contract.workflowPlan.memory.write}`);
434
+ }
417
435
  if (contract.workflowPlan.actionGate.required) {
418
436
  console.log(`=> ACTION GATE: required (${contract.workflowPlan.actionGate.reasonCodes.join(', ')}); disposition=pending-approval`);
419
437
  }
package/hooks/hooks.json CHANGED
@@ -38,7 +38,7 @@
38
38
  "id": "harness:pre:workflow-gate"
39
39
  },
40
40
  {
41
- "matcher": "Bash|PowerShell",
41
+ "matcher": "Bash|PowerShell|mcp__.*",
42
42
  "hooks": [
43
43
  {
44
44
  "type": "command",
@@ -138,7 +138,7 @@
138
138
  ],
139
139
  "PostToolUse": [
140
140
  {
141
- "matcher": "Bash|PowerShell",
141
+ "matcher": "Bash|PowerShell|mcp__.*",
142
142
  "hooks": [
143
143
  {
144
144
  "type": "command",
@@ -206,7 +206,7 @@
206
206
  ],
207
207
  "PostToolUseFailure": [
208
208
  {
209
- "matcher": "Bash|PowerShell",
209
+ "matcher": "Bash|PowerShell|mcp__.*",
210
210
  "hooks": [
211
211
  {
212
212
  "type": "command",
@@ -16,6 +16,8 @@ const VALID_SCOPES = new Set(['always', 'outside-scratch']);
16
16
  const POLICY_ENV = 'HARNESS_ACTION_GATE_POLICY';
17
17
  const DEFER_POLICY = 'defer-to-host';
18
18
  const ALWAYS_ASK_POLICY = 'always-ask';
19
+ const STRUCTURED_GATED_ACTIONS = new Set(['delete', 'publish', 'deploy', 'send', 'mutate']);
20
+ const STRUCTURED_SAFE_ACTIONS = new Set(['read']);
19
21
 
20
22
  // Fail-safe defaults are used only when the configured table is missing or
21
23
  // invalid. A valid-but-empty table stays empty so CI can catch policy erasure.
@@ -47,12 +49,113 @@ function toolInputOf(payload) {
47
49
  return (payload && (payload.tool_input || payload.toolInput || payload.input)) || {};
48
50
  }
49
51
 
52
+ function stableValue(value) {
53
+ if (Array.isArray(value)) return value.map(stableValue);
54
+ if (!value || typeof value !== 'object') return value;
55
+ return Object.keys(value).sort().reduce((acc, key) => {
56
+ acc[key] = stableValue(value[key]);
57
+ return acc;
58
+ }, {});
59
+ }
60
+
50
61
  function exactPayloadText(payload) {
51
62
  const input = toolInputOf(payload);
52
63
  const tool = toolNameOf(payload);
53
64
  if (tool === 'Bash' || tool === 'PowerShell') return String(input.command || '');
54
65
  if (tool === 'apply_patch') return String(input.patch || input.command || input.content || '');
55
- return JSON.stringify(input);
66
+ return JSON.stringify(stableValue(input));
67
+ }
68
+
69
+ function explicitActionDescriptor(payload) {
70
+ const input = toolInputOf(payload);
71
+ const candidates = [
72
+ payload && (payload.action_descriptor || payload.actionDescriptor),
73
+ input && (input.action_descriptor || input.actionDescriptor),
74
+ payload && payload.harness && (payload.harness.action_descriptor || payload.harness.actionDescriptor),
75
+ ];
76
+ return candidates.find(value => value && typeof value === 'object' && !Array.isArray(value)) || null;
77
+ }
78
+
79
+ function inferToolFamily(tool, explicit) {
80
+ if (explicit && ['shell', 'mcp', 'local-function'].includes(explicit.toolFamily)) return explicit.toolFamily;
81
+ if (tool === 'Bash' || tool === 'PowerShell') return 'shell';
82
+ if (/^mcp(?:__|_)/i.test(tool)) return 'mcp';
83
+ return 'local-function';
84
+ }
85
+
86
+ function inferStructuredAction(tool, explicit) {
87
+ const requested = explicit && String(explicit.action || '').toLowerCase();
88
+ if (['read', 'delete', 'publish', 'deploy', 'send', 'mutate', 'unknown'].includes(requested)) return requested;
89
+ const name = String(tool || '').toLowerCase();
90
+ if (/(?:^|[_-])(get|list|read|search|find|fetch|show|inspect)(?:$|[_-])/.test(name)) return 'read';
91
+ if (/(?:delete|remove|destroy|drop|purge)/.test(name)) return 'delete';
92
+ if (/(?:publish|release_create|create_release|release-publish)/.test(name)) return 'publish';
93
+ if (/(?:deploy|apply_production|promote)/.test(name)) return 'deploy';
94
+ if (/(?:send|email|message|notify|post_message)/.test(name)) return 'send';
95
+ if (/(?:create|update|write|edit|mutate|set_|add_|merge|close_)/.test(name)) return 'mutate';
96
+ return 'unknown';
97
+ }
98
+
99
+ function safeResource(input, explicit) {
100
+ const direct = explicit && explicit.resource;
101
+ if (typeof direct === 'string' && direct.trim()) return direct.trim().slice(0, 200);
102
+ for (const [key, value] of Object.entries(input || {})) {
103
+ if (/token|secret|password|credential|authorization|api[_-]?key/i.test(key)) continue;
104
+ if (!/resource|path|target|repo|repository|channel|issue|file|name|id$/i.test(key)) continue;
105
+ if (typeof value === 'string' || typeof value === 'number') return String(value).slice(0, 200);
106
+ }
107
+ return null;
108
+ }
109
+
110
+ function normalizeActionDescriptor(payload) {
111
+ const tool = toolNameOf(payload);
112
+ const input = toolInputOf(payload);
113
+ const explicit = explicitActionDescriptor(payload);
114
+ const toolFamily = inferToolFamily(tool, explicit);
115
+ const action = inferStructuredAction(tool, explicit);
116
+ const classification = STRUCTURED_GATED_ACTIONS.has(action)
117
+ ? 'gated'
118
+ : (STRUCTURED_SAFE_ACTIONS.has(action) ? 'safe' : 'unknown');
119
+ const reasonCodes = [];
120
+ if (explicit) reasonCodes.push('explicit-structured-action');
121
+ else if (action !== 'unknown') reasonCodes.push('tool-identity-inference');
122
+ else reasonCodes.push('structured-action-unknown');
123
+ return {
124
+ schemaVersion: 1,
125
+ toolFamily,
126
+ toolName: tool,
127
+ action,
128
+ resource: safeResource(input, explicit),
129
+ payloadHash: hashExact(exactPayloadText(payload)),
130
+ classification,
131
+ reasonCodes,
132
+ };
133
+ }
134
+
135
+ function structuredRule(payload) {
136
+ const descriptor = normalizeActionDescriptor(payload);
137
+ if (descriptor.toolFamily === 'shell') return { descriptor, rule: null };
138
+ if (descriptor.classification === 'safe') return { descriptor, rule: null };
139
+ if (descriptor.classification === 'gated') {
140
+ return {
141
+ descriptor,
142
+ rule: {
143
+ id: `structured-${descriptor.action}`,
144
+ reason: `Structured ${descriptor.action} action requires pre-action authorization.`,
145
+ },
146
+ };
147
+ }
148
+ const hint = routerActionGateHint(payload);
149
+ if (hint && hint.required) {
150
+ return {
151
+ descriptor,
152
+ rule: {
153
+ id: 'structured-unknown-required',
154
+ reason: 'The workflow requires an action gate, but this structured tool action is unknown.',
155
+ },
156
+ };
157
+ }
158
+ return { descriptor, rule: null };
56
159
  }
57
160
 
58
161
  function validateRule(rule) {
@@ -306,11 +409,13 @@ function recordDecision(payload, rule, table, host, disposition, options = {}, e
306
409
  const exact = exactPayloadText(payload);
307
410
  const commandHash = hashExact(exact);
308
411
  const now = new Date().toISOString();
412
+ const descriptor = normalizeActionDescriptor(payload);
309
413
  const record = {
310
414
  schemaVersion: 1,
311
415
  toolName: toolNameOf(payload),
312
416
  toolUseId: (payload && (payload.tool_use_id || payload.toolUseId)) || null,
313
417
  commandHash,
418
+ actionDescriptor: descriptor,
314
419
  matchedRule: rule.id,
315
420
  ruleReason: rule.reason,
316
421
  disposition,
@@ -332,7 +437,8 @@ function recordDecision(payload, rule, table, host, disposition, options = {}, e
332
437
  function evaluatePreToolUse(payload, options = {}) {
333
438
  const context = sessionContext(payload, options.sessionDir);
334
439
  const table = options.ruleTable || loadRuleTable(options.rulePath || RULES_PATH);
335
- const rule = classify(payload, table.rules, context.sessionDir);
440
+ const structured = structuredRule(payload);
441
+ const rule = structured.rule || classify(payload, table.rules, context.sessionDir);
336
442
  if (!rule) {
337
443
  return {
338
444
  kind: 'allow',
@@ -341,6 +447,7 @@ function evaluatePreToolUse(payload, options = {}) {
341
447
  stderr: table.degraded ? `[Harness actionGate] ${table.degradationReason}; built-in defaults active.` : null,
342
448
  rule: null,
343
449
  table,
450
+ descriptor: structured.descriptor,
344
451
  };
345
452
  }
346
453
 
@@ -535,4 +642,6 @@ module.exports = {
535
642
  routerActionGateHint,
536
643
  scratchRoots,
537
644
  validateRuleTable,
645
+ normalizeActionDescriptor,
646
+ structuredRule,
538
647
  };
@@ -22,7 +22,7 @@ const {
22
22
  listRunContracts,
23
23
  getWorkerId,
24
24
  } = require('./lib/fable-contracts');
25
- const { loadWorkflow, matchingRun } = require('./lib/workflow-runtime');
25
+ const { loadWorkflow, matchingRun, recordBudgetEvent } = require('./lib/workflow-runtime');
26
26
 
27
27
  function resultState(payload) {
28
28
  const toolResponse = payload.tool_response || payload.tool_result || {};
@@ -140,6 +140,9 @@ process.stdin.on('end', () => {
140
140
  const contract = updateRunContract(entry, payload, command, sessionId);
141
141
  console.log(`[Contract Test] ${contract.planId}/${contract.runId}/${contract.stageId}: ${contract.status.toUpperCase()} ${contract.checkCommand}`);
142
142
  if (contract.status === 'fail') {
143
+ if (workflowContext.workflow?.workflowId) {
144
+ recordBudgetEvent(workflowContext, 'revision', { evidence: `${contract.stageId}:${contract.evidence || 'verification-failed'}` });
145
+ }
143
146
  console.error(`Evidence: ${contract.evidence || '(no output captured)'}`);
144
147
  console.error(`Do not build on this stage's output until it passes. Artifact: ${contract.outputPath || '(no path recorded)'}`);
145
148
  process.exit(2);