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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +52 -0
- package/ci/contract-integrity.test.js +203 -0
- package/ci/self-evolve-loop.test.js +311 -0
- package/ci/telemetry.test.js +193 -0
- package/contract-integrity/fixtures/consistent-trace.json +94 -0
- package/contract-integrity/fixtures/reconciled-trace.json +105 -0
- package/contract-integrity/fixtures/ticket-drift-trace.json +94 -0
- package/contract-integrity/schemas/trace.schema.json +77 -0
- package/contract-integrity/scripts/audit.js +536 -0
- package/docs/contract-integrity.md +109 -0
- package/docs/telemetry.md +62 -0
- package/hooks/hooks.json +43 -0
- package/hooks/scripts/contract-test.js +24 -0
- package/hooks/scripts/lib/learning-opportunity.js +148 -0
- package/hooks/scripts/lib/telemetry.js +377 -0
- package/hooks/scripts/rule-of-3-tracker.js +23 -0
- package/hooks/scripts/state-persist.js +5 -0
- package/hooks/scripts/telemetry-adapter.js +25 -0
- package/multi-agent-workspace/SKILL.md +1 -1
- package/multi-agent-workspace/scripts/index_memory.js +2 -0
- package/opencode-plugin/index.mjs +213 -1
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +7 -2
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/hooks.json +35 -0
- package/plugins/harness-everything/hooks/scripts/contract-test.js +24 -0
- package/plugins/harness-everything/hooks/scripts/lib/learning-opportunity.js +148 -0
- package/plugins/harness-everything/hooks/scripts/lib/telemetry.js +377 -0
- package/plugins/harness-everything/hooks/scripts/rule-of-3-tracker.js +23 -0
- package/plugins/harness-everything/hooks/scripts/state-persist.js +5 -0
- package/plugins/harness-everything/hooks/scripts/telemetry-adapter.js +25 -0
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/multi-agent-workspace/SKILL.md +1 -1
- package/plugins/harness-everything/skills/multi-agent-workspace/scripts/index_memory.js +2 -0
- package/plugins/harness-everything/skills/self-evolve/SKILL.md +14 -19
- package/plugins/harness-everything/skills/self-evolve/references/memory-resolution.md +32 -13
- package/plugins/harness-everything/skills/self-evolve/schemas/lesson-candidate.schema.json +101 -0
- package/plugins/harness-everything/skills/self-evolve/scripts/lesson-candidate.js +303 -0
- package/plugins/harness-everything/skills/self-evolve/scripts/self-regression.js +4 -1
- package/self-evolve/SKILL.md +14 -19
- package/self-evolve/references/memory-resolution.md +32 -13
- package/self-evolve/schemas/lesson-candidate.schema.json +101 -0
- package/self-evolve/scripts/lesson-candidate.js +303 -0
- package/self-evolve/scripts/self-regression.js +4 -1
- package/telemetry/schemas/event.schema.json +39 -0
- package/telemetry/scripts/benchmark.js +47 -0
- 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"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harness-everything",
|
|
3
|
-
"version": "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"
|
|
@@ -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
|
+
};
|