harness-everything 0.9.0 → 0.11.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 (49) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +52 -0
  4. package/ci/contract-integrity.test.js +203 -0
  5. package/ci/self-evolve-loop.test.js +311 -0
  6. package/ci/telemetry.test.js +193 -0
  7. package/contract-integrity/fixtures/consistent-trace.json +94 -0
  8. package/contract-integrity/fixtures/reconciled-trace.json +105 -0
  9. package/contract-integrity/fixtures/ticket-drift-trace.json +94 -0
  10. package/contract-integrity/schemas/trace.schema.json +77 -0
  11. package/contract-integrity/scripts/audit.js +536 -0
  12. package/docs/contract-integrity.md +109 -0
  13. package/docs/telemetry.md +62 -0
  14. package/hooks/hooks.json +43 -0
  15. package/hooks/scripts/contract-test.js +24 -0
  16. package/hooks/scripts/lib/learning-opportunity.js +148 -0
  17. package/hooks/scripts/lib/telemetry.js +377 -0
  18. package/hooks/scripts/rule-of-3-tracker.js +23 -0
  19. package/hooks/scripts/state-persist.js +5 -0
  20. package/hooks/scripts/telemetry-adapter.js +25 -0
  21. package/multi-agent-workspace/SKILL.md +1 -1
  22. package/multi-agent-workspace/scripts/index_memory.js +2 -0
  23. package/opencode-plugin/index.mjs +213 -1
  24. package/opencode-plugin/plugin.json +1 -1
  25. package/package.json +7 -2
  26. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  27. package/plugins/harness-everything/hooks/hooks.json +35 -0
  28. package/plugins/harness-everything/hooks/scripts/contract-test.js +24 -0
  29. package/plugins/harness-everything/hooks/scripts/lib/learning-opportunity.js +148 -0
  30. package/plugins/harness-everything/hooks/scripts/lib/telemetry.js +377 -0
  31. package/plugins/harness-everything/hooks/scripts/rule-of-3-tracker.js +23 -0
  32. package/plugins/harness-everything/hooks/scripts/state-persist.js +5 -0
  33. package/plugins/harness-everything/hooks/scripts/telemetry-adapter.js +25 -0
  34. package/plugins/harness-everything/plugin.json +1 -1
  35. package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
  36. package/plugins/harness-everything/skills/multi-agent-workspace/scripts/index_memory.js +2 -0
  37. package/plugins/harness-everything/skills/self-evolve/SKILL.md +14 -19
  38. package/plugins/harness-everything/skills/self-evolve/references/memory-resolution.md +32 -13
  39. package/plugins/harness-everything/skills/self-evolve/schemas/lesson-candidate.schema.json +101 -0
  40. package/plugins/harness-everything/skills/self-evolve/scripts/lesson-candidate.js +303 -0
  41. package/plugins/harness-everything/skills/self-evolve/scripts/self-regression.js +4 -1
  42. package/self-evolve/SKILL.md +14 -19
  43. package/self-evolve/references/memory-resolution.md +32 -13
  44. package/self-evolve/schemas/lesson-candidate.schema.json +101 -0
  45. package/self-evolve/scripts/lesson-candidate.js +303 -0
  46. package/self-evolve/scripts/self-regression.js +4 -1
  47. package/telemetry/schemas/event.schema.json +39 -0
  48. package/telemetry/scripts/benchmark.js +47 -0
  49. package/telemetry/scripts/report.js +133 -0
@@ -31,7 +31,7 @@
31
31
 
32
32
  import { homedir } from "node:os"
33
33
  import { join, dirname, resolve, basename, relative, isAbsolute } from "node:path"
34
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, realpathSync, readdirSync } from "node:fs"
34
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, rmSync, realpathSync, readdirSync } from "node:fs"
35
35
  import { execSync } from "node:child_process"
36
36
  import { createHash, randomUUID } from "node:crypto"
37
37
 
@@ -348,6 +348,80 @@ function recordCompliance(complianceFile, mutate) {
348
348
  saveJSON(complianceFile, compliance)
349
349
  }
350
350
 
351
+ const TELEMETRY_SCHEMA_VERSION = 1
352
+ const TELEMETRY_EVENT_TYPES = new Set(["skill.invoke", "skill.loaded", "skill.complete", "tool.observed"])
353
+
354
+ function telemetryEnabled() {
355
+ const value = String(process.env.HARNESS_TELEMETRY || "local").toLowerCase()
356
+ return !["0", "false", "off", "disabled", "none"].includes(value)
357
+ }
358
+
359
+ function telemetryLocalId(prefix, value) {
360
+ const text = String(value || "").trim()
361
+ if (!text) return null
362
+ return `${prefix}-${createHash("sha256").update(text).digest("hex").slice(0, 16)}`
363
+ }
364
+
365
+ function telemetrySkillName(value) {
366
+ const text = String(value || "").trim()
367
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(text) ? text.toLowerCase() : null
368
+ }
369
+
370
+ function telemetryToolName(value) {
371
+ const text = String(value || "").trim()
372
+ return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,95}$/.test(text) ? text : null
373
+ }
374
+
375
+ function telemetrySkillVersion(workspace, skillName) {
376
+ if (!skillName) return null
377
+ const candidates = [
378
+ join(workspace, ".claude", "skills", skillName, "SKILL.md"),
379
+ join(workspace, ".agents", "skills", skillName, "SKILL.md"),
380
+ ]
381
+ for (const file of candidates) {
382
+ try {
383
+ const source = readFileSync(file, "utf8")
384
+ const metadata = source.match(/metadata:\s*[\s\S]{0,300}?version:\s*["']?([^\s"'\n]+)["']?/i)
385
+ if (metadata) return metadata[1].slice(0, 40)
386
+ } catch {}
387
+ }
388
+ return null
389
+ }
390
+
391
+ function appendTelemetry(workspace, input) {
392
+ try {
393
+ if (!telemetryEnabled()) return false
394
+ if (!TELEMETRY_EVENT_TYPES.has(input.event)) return false
395
+ const file = join(getStateRoot(workspace), "telemetry", "events.jsonl")
396
+ mkdirSync(dirname(file), { recursive: true })
397
+ appendFileSync(file, JSON.stringify({
398
+ schemaVersion: TELEMETRY_SCHEMA_VERSION,
399
+ eventId: randomUUID(),
400
+ event: input.event,
401
+ host: "opencode",
402
+ observedAt: new Date().toISOString(),
403
+ sessionId: telemetryLocalId("session", input.sessionID || "default"),
404
+ turnId: telemetryLocalId("turn", input.turnID),
405
+ agentId: telemetryLocalId("agent", input.agentID),
406
+ invocationId: input.invocationId || null,
407
+ skillName: input.skillName || null,
408
+ skillVersion: input.skillVersion || null,
409
+ toolName: input.toolName || null,
410
+ status: ["unknown", "success", "failure", "aborted"].includes(input.status) ? input.status : "unknown",
411
+ retryCount: Number.isInteger(input.retryCount) && input.retryCount >= 0 ? input.retryCount : 0,
412
+ timing: {
413
+ skillLoadDurationMs: Number.isFinite(input.skillLoadDurationMs) ? input.skillLoadDurationMs : null,
414
+ activeWindowMs: Number.isFinite(input.activeWindowMs) ? input.activeWindowMs : null,
415
+ attributedToolDurationMs: Number.isFinite(input.attributedToolDurationMs) ? input.attributedToolDurationMs : null,
416
+ },
417
+ reasonCodes: [...new Set((input.reasonCodes || []).map(value => String(value).slice(0, 80)).filter(Boolean))].slice(0, 16),
418
+ }) + "\n", "utf8")
419
+ return true
420
+ } catch {
421
+ return false
422
+ }
423
+ }
424
+
351
425
  export const HarnessEnforcement = async ({ client, directory }) => {
352
426
  // State is resolved per real workspace and per opencode session. The CJS
353
427
  // hooks use the same workspaces/<key>/state/sessions/<id> contract, while
@@ -356,6 +430,139 @@ export const HarnessEnforcement = async ({ client, directory }) => {
356
430
  const workspace = resolve(directory || process.cwd())
357
431
  migrateLegacyFlatState(getStateDir(workspace))
358
432
  const activeVerifications = new Set()
433
+ const telemetryToolStarts = new Map()
434
+ const telemetryPendingSkills = new Map()
435
+ const telemetryActiveSkills = new Map()
436
+
437
+ function telemetryCallId(input) {
438
+ const direct = input && (input.callID || input.callId || input.toolCallID || input.toolCallId)
439
+ if (direct) return telemetryLocalId("call", direct)
440
+ return `fifo:${String(input && input.sessionID || "default")}:${String(input && input.tool || "unknown")}`
441
+ }
442
+
443
+ function telemetryBefore(input) {
444
+ try {
445
+ if (!telemetryEnabled()) return
446
+ const key = telemetryCallId(input)
447
+ const queue = telemetryToolStarts.get(key) || []
448
+ queue.push(Date.now())
449
+ telemetryToolStarts.set(key, queue)
450
+
451
+ if (input.tool !== "skill") return
452
+ const skillName = telemetrySkillName(input.args && (input.args.skill || input.args.name || input.args.skillName))
453
+ if (!skillName) return
454
+ const invocationId = randomUUID()
455
+ const pending = telemetryPendingSkills.get(key) || []
456
+ pending.push({
457
+ invocationId,
458
+ skillName,
459
+ skillVersion: telemetrySkillVersion(workspace, skillName),
460
+ startedAt: Date.now(),
461
+ sessionID: input.sessionID,
462
+ })
463
+ telemetryPendingSkills.set(key, pending)
464
+ appendTelemetry(workspace, {
465
+ event: "skill.invoke",
466
+ sessionID: input.sessionID,
467
+ agentID: input.agentID,
468
+ invocationId,
469
+ skillName,
470
+ skillVersion: pending[pending.length - 1].skillVersion,
471
+ status: "unknown",
472
+ reasonCodes: ["opencode-skill-tool-before"],
473
+ })
474
+ } catch {}
475
+ }
476
+
477
+ function telemetryAfter(input) {
478
+ try {
479
+ if (!telemetryEnabled()) return
480
+ const key = telemetryCallId(input)
481
+ const starts = telemetryToolStarts.get(key) || []
482
+ const startedAt = starts.shift()
483
+ if (starts.length) telemetryToolStarts.set(key, starts)
484
+ else telemetryToolStarts.delete(key)
485
+ const duration = Number.isFinite(startedAt) ? Math.max(0, Date.now() - startedAt) : null
486
+
487
+ if (input.tool === "skill") {
488
+ const pending = telemetryPendingSkills.get(key) || []
489
+ const load = pending.shift()
490
+ if (pending.length) telemetryPendingSkills.set(key, pending)
491
+ else telemetryPendingSkills.delete(key)
492
+ if (load) {
493
+ const activeKey = String(load.sessionID || "default")
494
+ const active = telemetryActiveSkills.get(activeKey) || new Map()
495
+ active.set(load.invocationId, {
496
+ ...load,
497
+ loadedAt: Date.now(),
498
+ skillLoadDurationMs: duration,
499
+ attributedToolDurationMs: 0,
500
+ attributedToolCount: 0,
501
+ })
502
+ telemetryActiveSkills.set(activeKey, active)
503
+ appendTelemetry(workspace, {
504
+ event: "skill.loaded",
505
+ sessionID: load.sessionID,
506
+ invocationId: load.invocationId,
507
+ skillName: load.skillName,
508
+ skillVersion: load.skillVersion,
509
+ status: "unknown",
510
+ skillLoadDurationMs: duration,
511
+ reasonCodes: ["opencode-after-no-normalized-status"],
512
+ })
513
+ }
514
+ return
515
+ }
516
+
517
+ const active = telemetryActiveSkills.get(String(input.sessionID || "default"))
518
+ if (!active || active.size === 0) return
519
+ for (const entry of active.values()) {
520
+ if (Number.isFinite(duration)) {
521
+ entry.attributedToolDurationMs += duration
522
+ entry.attributedToolCount++
523
+ }
524
+ appendTelemetry(workspace, {
525
+ event: "tool.observed",
526
+ sessionID: input.sessionID,
527
+ agentID: input.agentID,
528
+ invocationId: entry.invocationId,
529
+ skillName: entry.skillName,
530
+ skillVersion: entry.skillVersion,
531
+ toolName: telemetryToolName(input.tool),
532
+ status: "unknown",
533
+ attributedToolDurationMs: duration,
534
+ reasonCodes: [
535
+ Number.isFinite(duration) ? "opencode-adapter-duration" : "tool-duration-unavailable",
536
+ "skill-active-window-overlap",
537
+ ],
538
+ })
539
+ }
540
+ } catch {}
541
+ }
542
+
543
+ function telemetryCloseSession(sessionID, status = "unknown", reasonCode = "opencode-session-idle") {
544
+ try {
545
+ const key = String(sessionID || "default")
546
+ const active = telemetryActiveSkills.get(key)
547
+ if (!active) return
548
+ const stoppedAt = Date.now()
549
+ for (const entry of active.values()) {
550
+ appendTelemetry(workspace, {
551
+ event: "skill.complete",
552
+ sessionID,
553
+ invocationId: entry.invocationId,
554
+ skillName: entry.skillName,
555
+ skillVersion: entry.skillVersion,
556
+ status,
557
+ skillLoadDurationMs: entry.skillLoadDurationMs,
558
+ activeWindowMs: Math.max(0, stoppedAt - entry.loadedAt),
559
+ attributedToolDurationMs: entry.attributedToolCount ? entry.attributedToolDurationMs : null,
560
+ reasonCodes: [reasonCode],
561
+ })
562
+ }
563
+ telemetryActiveSkills.delete(key)
564
+ } catch {}
565
+ }
359
566
 
360
567
  function pathsFor(sessionID) {
361
568
  const stateDir = getSessionDir(workspace, sessionID)
@@ -399,6 +606,7 @@ export const HarnessEnforcement = async ({ client, directory }) => {
399
606
 
400
607
  return {
401
608
  "tool.execute.before": async (input, output) => {
609
+ telemetryBefore(input)
402
610
  if (!EDIT_TOOLS.has(input.tool)) return
403
611
  const { breakerFile, reflectionFile } = pathsFor(input.sessionID)
404
612
  const breaker = loadBreakerState(breakerFile)
@@ -417,6 +625,7 @@ export const HarnessEnforcement = async ({ client, directory }) => {
417
625
  },
418
626
 
419
627
  "tool.execute.after": async (input, output) => {
628
+ telemetryAfter(input)
420
629
  if (!EDIT_TOOLS.has(input.tool)) return
421
630
  const { editStateFile, breakerFile, complianceFile, reflectionFile } = pathsFor(input.sessionID)
422
631
 
@@ -444,14 +653,17 @@ export const HarnessEnforcement = async ({ client, directory }) => {
444
653
  const properties = event && event.properties
445
654
  const sessionID = properties && (properties.sessionID || (properties.info && properties.info.id))
446
655
  if (event.type === "session.created") {
656
+ telemetryCloseSession(sessionID, "aborted", "opencode-session-recreated")
447
657
  resetSession(sessionID)
448
658
  return
449
659
  }
450
660
  if (event.type === "session.deleted") {
661
+ telemetryCloseSession(sessionID, "aborted", "opencode-session-deleted")
451
662
  resetSession(sessionID)
452
663
  return
453
664
  }
454
665
  if (event.type !== "session.idle") return
666
+ telemetryCloseSession(sessionID, "unknown", "opencode-session-idle")
455
667
  if (activeVerifications.has(sessionID || "default")) return
456
668
 
457
669
  const activeSession = sessionID || "default"
@@ -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.9.0",
4
+ "version": "0.11.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.9.0",
3
+ "version": "0.11.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": {
@@ -47,7 +47,12 @@
47
47
  "test:ab:run": "node ci/ab-test-harness.js run",
48
48
  "harness:reset": "node hooks/scripts/reset-circuit-breaker.js",
49
49
  "prepare": "husky install",
50
- "commitlint": "commitlint --edit"
50
+ "commitlint": "commitlint --edit",
51
+ "test:telemetry": "node ci/telemetry.test.js",
52
+ "telemetry:report": "node telemetry/scripts/report.js",
53
+ "telemetry:benchmark": "node telemetry/scripts/benchmark.js",
54
+ "test:contract-integrity": "node ci/contract-integrity.test.js",
55
+ "contract:integrity": "node contract-integrity/scripts/audit.js"
51
56
  },
52
57
  "engines": {
53
58
  "node": ">=22"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
@@ -92,6 +92,18 @@
92
92
  "statusMessage": "Checking Harness structured MCP action gate"
93
93
  }
94
94
  ]
95
+ },
96
+ {
97
+ "matcher": "Skill",
98
+ "hooks": [
99
+ {
100
+ "type": "command",
101
+ "command": "node \"${PLUGIN_ROOT}/hooks/scripts/telemetry-adapter.js\"",
102
+ "commandWindows": "node \"${PLUGIN_ROOT}\\hooks\\scripts\\telemetry-adapter.js\"",
103
+ "timeout": 10,
104
+ "statusMessage": "Recording local Harness skill telemetry"
105
+ }
106
+ ]
95
107
  }
96
108
  ],
97
109
  "PermissionRequest": [
@@ -181,6 +193,18 @@
181
193
  "statusMessage": "Recording Harness structured MCP action outcome"
182
194
  }
183
195
  ]
196
+ },
197
+ {
198
+ "matcher": "Skill",
199
+ "hooks": [
200
+ {
201
+ "type": "command",
202
+ "command": "node \"${PLUGIN_ROOT}/hooks/scripts/telemetry-adapter.js\"",
203
+ "commandWindows": "node \"${PLUGIN_ROOT}\\hooks\\scripts\\telemetry-adapter.js\"",
204
+ "timeout": 10,
205
+ "statusMessage": "Recording local Harness skill telemetry"
206
+ }
207
+ ]
184
208
  }
185
209
  ],
186
210
  "SubagentStart": [
@@ -234,6 +258,17 @@
234
258
  "statusMessage": "Checking Harness verification gate"
235
259
  }
236
260
  ]
261
+ },
262
+ {
263
+ "hooks": [
264
+ {
265
+ "type": "command",
266
+ "command": "node \"${PLUGIN_ROOT}/hooks/scripts/telemetry-adapter.js\"",
267
+ "commandWindows": "node \"${PLUGIN_ROOT}\\hooks\\scripts\\telemetry-adapter.js\"",
268
+ "timeout": 10,
269
+ "statusMessage": "Recording local Harness skill telemetry"
270
+ }
271
+ ]
237
272
  }
238
273
  ]
239
274
  }
@@ -23,6 +23,7 @@ const {
23
23
  getWorkerId,
24
24
  } = require('./lib/fable-contracts');
25
25
  const { loadWorkflow, matchingRun, recordBudgetEvent } = require('./lib/workflow-runtime');
26
+ const { createLearningOpportunity } = require('./lib/learning-opportunity');
26
27
 
27
28
  function resultState(payload) {
28
29
  const toolResponse = payload.tool_response || payload.tool_result || {};
@@ -137,7 +138,30 @@ process.stdin.on('end', () => {
137
138
  }
138
139
  }
139
140
  }
141
+ const previousStatus = entry.contract.status;
142
+ const previousVerifiedAt = entry.contract.verifiedAt || entry.contract.updatedAt || null;
140
143
  const contract = updateRunContract(entry, payload, command, sessionId);
144
+ if (previousStatus === 'fail' && contract.status === 'pass') {
145
+ try {
146
+ createLearningOpportunity(payload, {
147
+ triggerType: 'verifier-fail-pass',
148
+ sourceEventIds: [
149
+ `verifier-fail:${contract.runId}:${contract.stageId}:${previousVerifiedAt || 'unknown'}`,
150
+ `verifier-pass:${contract.runId}:${contract.stageId}:${contract.verifiedAt}`,
151
+ ],
152
+ evidence: {
153
+ planId: contract.planId,
154
+ runId: contract.runId,
155
+ stageId: contract.stageId,
156
+ previousStatus: 'fail',
157
+ currentStatus: 'pass',
158
+ verificationEvidenceRef: contract.verificationEvidence,
159
+ },
160
+ });
161
+ } catch (_) {
162
+ // Learning capture is additive; verification disposition stays authoritative.
163
+ }
164
+ }
141
165
  console.log(`[Contract Test] ${contract.planId}/${contract.runId}/${contract.stageId}: ${contract.status.toUpperCase()} ${contract.checkCommand}`);
142
166
  if (contract.status === 'fail') {
143
167
  if (workflowContext.workflow?.workflowId) {
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const {
7
+ getWorkspaceRoot,
8
+ getSessionId,
9
+ getSessionDir,
10
+ } = require('./harness-state');
11
+
12
+ const SCHEMA_VERSION = 1;
13
+ const TRIGGERS = new Set(['rule-of-3-recovery', 'verifier-fail-pass']);
14
+
15
+ function atomicWriteJson(filePath, value) {
16
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
17
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
18
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
19
+ fs.renameSync(temporary, filePath);
20
+ }
21
+
22
+ function readJson(filePath) {
23
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (_) { return null; }
24
+ }
25
+
26
+ function safeText(value, max = 160) {
27
+ const text = String(value || '').replace(/[\r\n\0]+/g, ' ').trim();
28
+ return text ? text.slice(0, max) : null;
29
+ }
30
+
31
+ function safeId(value) {
32
+ const text = safeText(value, 180);
33
+ return text && /^[A-Za-z0-9._:@/-]+$/.test(text) ? text : null;
34
+ }
35
+
36
+ function hash(value) {
37
+ return crypto.createHash('sha256').update(String(value), 'utf8').digest('hex');
38
+ }
39
+
40
+ function workflowIdentity(sessionDir) {
41
+ const workflow = readJson(path.join(sessionDir, 'workflow-run.json'));
42
+ return {
43
+ workflowId: safeId(workflow && workflow.workflowId),
44
+ runId: safeId(workflow && workflow.runId),
45
+ strategy: safeText(workflow && workflow.strategy, 80),
46
+ };
47
+ }
48
+
49
+ function candidateDirectory(sessionDir) {
50
+ return path.join(sessionDir, 'learning-candidates');
51
+ }
52
+
53
+ function sourceIds(values) {
54
+ return [...new Set((values || []).map(value => safeId(value)).filter(Boolean))].slice(0, 16);
55
+ }
56
+
57
+ function minimalEvidence(triggerType, evidence = {}) {
58
+ if (triggerType === 'rule-of-3-recovery') {
59
+ return {
60
+ failureSignature: safeId(evidence.failureSignature),
61
+ failureCategory: safeText(evidence.failureCategory, 40),
62
+ zoomOutCycles: Number.isInteger(evidence.zoomOutCycles) ? evidence.zoomOutCycles : 1,
63
+ recovered: evidence.recovered === true,
64
+ };
65
+ }
66
+ if (triggerType === 'verifier-fail-pass') {
67
+ return {
68
+ planId: safeId(evidence.planId),
69
+ runId: safeId(evidence.runId),
70
+ stageId: safeId(evidence.stageId),
71
+ previousStatus: evidence.previousStatus === 'fail' ? 'fail' : null,
72
+ currentStatus: evidence.currentStatus === 'pass' ? 'pass' : null,
73
+ verificationEvidenceRef: safeText(evidence.verificationEvidenceRef, 240),
74
+ };
75
+ }
76
+ return {};
77
+ }
78
+
79
+ function createLearningOpportunity(payload, input = {}) {
80
+ const triggerType = String(input.triggerType || '');
81
+ if (!TRIGGERS.has(triggerType)) throw new Error(`unsupported learning trigger: ${triggerType}`);
82
+
83
+ const root = getWorkspaceRoot(payload);
84
+ const sessionId = getSessionId(payload);
85
+ const sessionDir = getSessionDir(root, sessionId, payload);
86
+ const identity = workflowIdentity(sessionDir);
87
+ const ids = sourceIds(input.sourceEventIds);
88
+ if (ids.length === 0) throw new Error('learning opportunity requires at least one source event id');
89
+
90
+ const evidence = minimalEvidence(triggerType, input.evidence);
91
+ const fingerprint = hash(JSON.stringify({
92
+ sessionId,
93
+ triggerType,
94
+ sourceEventIds: ids,
95
+ evidence,
96
+ }));
97
+ const candidateId = `lesson-${fingerprint.slice(0, 24)}`;
98
+ const file = path.join(candidateDirectory(sessionDir), `${candidateId}.json`);
99
+ const existing = readJson(file);
100
+ if (existing) return { created: false, file, candidate: existing };
101
+
102
+ const observedAt = new Date().toISOString();
103
+ const candidate = {
104
+ schemaVersion: SCHEMA_VERSION,
105
+ candidateId,
106
+ contentHash: null,
107
+ state: 'proposed',
108
+ trigger: {
109
+ type: triggerType,
110
+ reasonCodes: triggerType === 'rule-of-3-recovery'
111
+ ? ['repeated-failure', 'zoom-out-completed', 'verified-subsequent-success']
112
+ : ['verifier-failed', 'later-verifier-pass'],
113
+ sourceEventIds: ids,
114
+ evidenceClass: triggerType === 'verifier-fail-pass' ? 'objective-transition' : 'runtime-recovery',
115
+ },
116
+ provenance: {
117
+ sessionId,
118
+ workflowId: identity.workflowId,
119
+ runId: identity.runId,
120
+ strategy: identity.strategy,
121
+ },
122
+ evidence,
123
+ generalizedRule: null,
124
+ scope: { task: null, requirement: null, role: null },
125
+ screening: null,
126
+ evaluation: null,
127
+ persistence: null,
128
+ outcome: null,
129
+ history: [
130
+ { state: 'observed', observedAt, reasonCode: 'runtime-learning-opportunity' },
131
+ { state: 'proposed', observedAt, reasonCode: 'objective-recovery-trigger' },
132
+ ],
133
+ createdAt: observedAt,
134
+ updatedAt: observedAt,
135
+ };
136
+ atomicWriteJson(file, candidate);
137
+ return { created: true, file, candidate };
138
+ }
139
+
140
+ module.exports = {
141
+ SCHEMA_VERSION,
142
+ TRIGGERS,
143
+ atomicWriteJson,
144
+ candidateDirectory,
145
+ createLearningOpportunity,
146
+ hash,
147
+ readJson,
148
+ };