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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const { contentHash, scoreRuleQuality, screenMemoryForPersistence } = require('./persist-memory');
|
|
8
|
+
|
|
9
|
+
let getWorkspaceRoot;
|
|
10
|
+
let getWorkspaceStateDir;
|
|
11
|
+
try {
|
|
12
|
+
({ getWorkspaceRoot, getWorkspaceStateDir } = require('../../scripts/lib/workspace'));
|
|
13
|
+
} catch (_) {
|
|
14
|
+
getWorkspaceRoot = () => null;
|
|
15
|
+
getWorkspaceStateDir = () => null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SCHEMA_VERSION = 1;
|
|
19
|
+
const TERMINAL = new Set(['rejected', 'validated', 'regressed', 'superseded']);
|
|
20
|
+
const OUTCOME_STATES = new Set(['retrieved', 'validated', 'regressed', 'superseded']);
|
|
21
|
+
|
|
22
|
+
function safeSessionName(value) {
|
|
23
|
+
const text = String(value || 'default').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^\.+/, '').slice(0, 128);
|
|
24
|
+
return text || 'default';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function atomicWriteJson(filePath, value) {
|
|
28
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
29
|
+
const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
30
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
31
|
+
fs.renameSync(temporary, filePath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readJson(filePath) {
|
|
35
|
+
try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (_) { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function candidatePath(workspace, sessionId, candidateId) {
|
|
39
|
+
if (!workspace || !getWorkspaceStateDir(workspace)) throw new Error('workspace state root is unavailable');
|
|
40
|
+
if (!/^lesson-[a-f0-9]{24}$/.test(String(candidateId || ''))) throw new Error('invalid candidate id');
|
|
41
|
+
return path.join(
|
|
42
|
+
getWorkspaceStateDir(workspace),
|
|
43
|
+
'state',
|
|
44
|
+
'sessions',
|
|
45
|
+
safeSessionName(sessionId),
|
|
46
|
+
'learning-candidates',
|
|
47
|
+
`${candidateId}.json`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loadCandidate({ workspace, sessionId, candidateId }) {
|
|
52
|
+
const file = candidatePath(workspace, sessionId, candidateId);
|
|
53
|
+
const candidate = readJson(file);
|
|
54
|
+
if (!candidate || candidate.schemaVersion !== SCHEMA_VERSION || candidate.candidateId !== candidateId) {
|
|
55
|
+
throw new Error('lesson candidate not found or malformed');
|
|
56
|
+
}
|
|
57
|
+
return { file, candidate };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pushState(candidate, state, reasonCode, evidenceRef = null) {
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
candidate.state = state;
|
|
63
|
+
candidate.updatedAt = now;
|
|
64
|
+
candidate.history = [...(candidate.history || []), {
|
|
65
|
+
state,
|
|
66
|
+
observedAt: now,
|
|
67
|
+
reasonCode,
|
|
68
|
+
evidenceRef: evidenceRef || null,
|
|
69
|
+
}].slice(-32);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function replayability(candidate) {
|
|
73
|
+
if (candidate.trigger?.type === 'verifier-fail-pass' &&
|
|
74
|
+
candidate.evidence?.previousStatus === 'fail' &&
|
|
75
|
+
candidate.evidence?.currentStatus === 'pass' &&
|
|
76
|
+
candidate.evidence?.stageId &&
|
|
77
|
+
candidate.evidence?.verificationEvidenceRef) {
|
|
78
|
+
return {
|
|
79
|
+
replayable: true,
|
|
80
|
+
reasonCode: 'objective-verifier-transition-retained',
|
|
81
|
+
method: 'deterministic-transition-check',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (candidate.trigger?.type === 'rule-of-3-recovery') {
|
|
85
|
+
return {
|
|
86
|
+
replayable: false,
|
|
87
|
+
reasonCode: 'linear-recovery-is-not-counterfactual-replay',
|
|
88
|
+
method: 'paired-or-later-recurrence-required',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
replayable: false,
|
|
93
|
+
reasonCode: 'insufficient-structured-evidence',
|
|
94
|
+
method: 'live-evidence-required',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function evaluateCandidate(input) {
|
|
99
|
+
const { file, candidate } = loadCandidate(input);
|
|
100
|
+
if (TERMINAL.has(candidate.state) || candidate.state === 'persisted') {
|
|
101
|
+
throw new Error(`candidate in terminal state: ${candidate.state}`);
|
|
102
|
+
}
|
|
103
|
+
const rule = String(input.rule || '').trim();
|
|
104
|
+
if (!rule) throw new Error('evaluate requires --rule with a generalized reusable lesson');
|
|
105
|
+
|
|
106
|
+
const screening = screenMemoryForPersistence(rule);
|
|
107
|
+
const qualityScore = scoreRuleQuality(rule, []);
|
|
108
|
+
candidate.generalizedRule = rule;
|
|
109
|
+
candidate.contentHash = contentHash(rule);
|
|
110
|
+
candidate.scope = {
|
|
111
|
+
task: String(input.scopeTask || '').trim() || null,
|
|
112
|
+
requirement: String(input.scopeRequirement || '').trim() || null,
|
|
113
|
+
role: String(input.scopeRole || '').trim() || null,
|
|
114
|
+
};
|
|
115
|
+
candidate.screening = {
|
|
116
|
+
allowed: screening.allowed,
|
|
117
|
+
secretReasonCodes: screening.secretReasonCodes,
|
|
118
|
+
injectionReasonCodes: screening.injectionReasonCodes,
|
|
119
|
+
qualityScore,
|
|
120
|
+
};
|
|
121
|
+
pushState(candidate, 'screened', screening.allowed ? 'screening-clear' : 'screening-rejected');
|
|
122
|
+
|
|
123
|
+
if (!screening.allowed || qualityScore < 5) {
|
|
124
|
+
candidate.evaluation = {
|
|
125
|
+
disposition: 'rejected',
|
|
126
|
+
reasonCodes: [
|
|
127
|
+
...screening.secretReasonCodes,
|
|
128
|
+
...screening.injectionReasonCodes,
|
|
129
|
+
...(qualityScore < 5 ? ['quality-below-threshold'] : []),
|
|
130
|
+
],
|
|
131
|
+
replayable: false,
|
|
132
|
+
method: 'screening',
|
|
133
|
+
improvementClaim: false,
|
|
134
|
+
};
|
|
135
|
+
pushState(candidate, 'rejected', 'candidate-screening-or-quality-failed');
|
|
136
|
+
atomicWriteJson(file, candidate);
|
|
137
|
+
return { file, candidate };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const replay = replayability(candidate);
|
|
141
|
+
const disposition = replay.replayable ? 'accepted' : 'inconclusive';
|
|
142
|
+
candidate.evaluation = {
|
|
143
|
+
disposition,
|
|
144
|
+
reasonCodes: [replay.reasonCode],
|
|
145
|
+
replayable: replay.replayable,
|
|
146
|
+
method: replay.method,
|
|
147
|
+
improvementClaim: false,
|
|
148
|
+
note: 'Accepted means eligible for governed memory promotion; it is not a claim of behavioral improvement.',
|
|
149
|
+
};
|
|
150
|
+
pushState(candidate, 'evaluated', replay.reasonCode);
|
|
151
|
+
pushState(candidate, disposition, disposition === 'accepted' ? 'eligible-for-governed-promotion' : 'paired-or-later-evidence-required');
|
|
152
|
+
atomicWriteJson(file, candidate);
|
|
153
|
+
return { file, candidate };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function promoteCandidate(input) {
|
|
157
|
+
const { file, candidate } = loadCandidate(input);
|
|
158
|
+
if (candidate.state !== 'accepted' || candidate.evaluation?.disposition !== 'accepted') {
|
|
159
|
+
throw new Error('only accepted lesson candidates may be promoted');
|
|
160
|
+
}
|
|
161
|
+
if (!candidate.generalizedRule) throw new Error('accepted candidate has no generalized rule');
|
|
162
|
+
if (!input.authorization) throw new Error('promotion requires --authorization from the current #134 workflow contract');
|
|
163
|
+
|
|
164
|
+
const args = [
|
|
165
|
+
path.join(__dirname, 'persist-memory.js'),
|
|
166
|
+
candidate.generalizedRule,
|
|
167
|
+
'--authorization', input.authorization,
|
|
168
|
+
'--source', `lesson-candidate:${candidate.candidateId}`,
|
|
169
|
+
];
|
|
170
|
+
if (candidate.scope?.task) args.push('--scope-task', candidate.scope.task);
|
|
171
|
+
if (candidate.scope?.requirement) args.push('--scope-requirement', candidate.scope.requirement);
|
|
172
|
+
if (candidate.scope?.role) args.push('--scope-role', candidate.scope.role);
|
|
173
|
+
if (input.retentionDays) args.push('--retention-days', String(input.retentionDays));
|
|
174
|
+
|
|
175
|
+
const result = spawnSync(process.execPath, args, {
|
|
176
|
+
cwd: input.workspace,
|
|
177
|
+
encoding: 'utf8',
|
|
178
|
+
env: process.env,
|
|
179
|
+
});
|
|
180
|
+
if (result.status !== 0) {
|
|
181
|
+
candidate.persistence = {
|
|
182
|
+
disposition: 'failed',
|
|
183
|
+
exitCode: result.status,
|
|
184
|
+
reasonCode: 'memory-governance-rejected-promotion',
|
|
185
|
+
};
|
|
186
|
+
atomicWriteJson(file, candidate);
|
|
187
|
+
return { ok: false, exitCode: result.status || 1, stdout: result.stdout, stderr: result.stderr, candidate };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const persisted = /\[Success\] Memory persisted/.test(String(result.stdout || ''));
|
|
191
|
+
let promotionWriter = null;
|
|
192
|
+
if (persisted) {
|
|
193
|
+
const index = readJson(path.join(input.workspace, 'memories', 'repo', 'memory-index.json'));
|
|
194
|
+
const record = index && Array.isArray(index.records)
|
|
195
|
+
? index.records.find(item => item.source === `lesson-candidate:${candidate.candidateId}`)
|
|
196
|
+
: null;
|
|
197
|
+
if (record && record.writer) {
|
|
198
|
+
promotionWriter = {
|
|
199
|
+
sessionId: record.writer.sessionId || null,
|
|
200
|
+
workflowId: record.writer.workflowId || null,
|
|
201
|
+
runId: record.writer.runId || null,
|
|
202
|
+
role: record.writer.role || null,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
candidate.persistence = {
|
|
207
|
+
disposition: persisted ? 'persisted' : 'proposed',
|
|
208
|
+
authorizedBy: 'workflow-memory-capability',
|
|
209
|
+
promotionWriter,
|
|
210
|
+
observedAt: new Date().toISOString(),
|
|
211
|
+
};
|
|
212
|
+
if (persisted) pushState(candidate, 'persisted', 'accepted-and-authorized-memory-write');
|
|
213
|
+
else pushState(candidate, 'accepted', 'memory-governance-held-as-review-candidate');
|
|
214
|
+
atomicWriteJson(file, candidate);
|
|
215
|
+
return { ok: true, exitCode: 0, persisted, stdout: result.stdout, stderr: result.stderr, candidate };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function observeCandidate(input) {
|
|
219
|
+
const { file, candidate } = loadCandidate(input);
|
|
220
|
+
const next = String(input.outcome || '');
|
|
221
|
+
if (!OUTCOME_STATES.has(next)) throw new Error('observe requires retrieved|validated|regressed|superseded');
|
|
222
|
+
if (next === 'retrieved' && !['persisted', 'retrieved'].includes(candidate.state)) {
|
|
223
|
+
throw new Error('only persisted lessons may be marked retrieved');
|
|
224
|
+
}
|
|
225
|
+
if (['validated', 'regressed', 'superseded'].includes(next) && !['persisted', 'retrieved', 'validated', 'regressed'].includes(candidate.state)) {
|
|
226
|
+
throw new Error(`${next} requires persisted/retrieved lesson state`);
|
|
227
|
+
}
|
|
228
|
+
const evidenceRef = String(input.evidence || '').trim();
|
|
229
|
+
if (!evidenceRef) throw new Error('outcome transition requires --evidence');
|
|
230
|
+
candidate.outcome = {
|
|
231
|
+
state: next,
|
|
232
|
+
evidenceRef,
|
|
233
|
+
observedAt: new Date().toISOString(),
|
|
234
|
+
};
|
|
235
|
+
pushState(candidate, next, `lesson-${next}`, evidenceRef);
|
|
236
|
+
atomicWriteJson(file, candidate);
|
|
237
|
+
return { file, candidate };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function listCandidates({ workspace, sessionId }) {
|
|
241
|
+
const base = path.join(getWorkspaceStateDir(workspace), 'state', 'sessions', safeSessionName(sessionId), 'learning-candidates');
|
|
242
|
+
let files = [];
|
|
243
|
+
try { files = fs.readdirSync(base).filter(name => /^lesson-[a-f0-9]{24}\.json$/.test(name)); } catch (_) { return []; }
|
|
244
|
+
return files.map(name => readJson(path.join(base, name))).filter(Boolean);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function option(args, name) {
|
|
248
|
+
const index = args.indexOf(name);
|
|
249
|
+
return index >= 0 ? args[index + 1] : null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseCli(argv) {
|
|
253
|
+
const command = argv[0];
|
|
254
|
+
const workspace = path.resolve(option(argv, '--workspace') || getWorkspaceRoot() || process.cwd());
|
|
255
|
+
const base = {
|
|
256
|
+
command,
|
|
257
|
+
workspace,
|
|
258
|
+
sessionId: option(argv, '--session-id') || 'default',
|
|
259
|
+
candidateId: option(argv, '--candidate-id'),
|
|
260
|
+
};
|
|
261
|
+
return {
|
|
262
|
+
...base,
|
|
263
|
+
rule: option(argv, '--rule'),
|
|
264
|
+
authorization: option(argv, '--authorization'),
|
|
265
|
+
scopeTask: option(argv, '--scope-task'),
|
|
266
|
+
scopeRequirement: option(argv, '--scope-requirement'),
|
|
267
|
+
scopeRole: option(argv, '--scope-role'),
|
|
268
|
+
retentionDays: option(argv, '--retention-days'),
|
|
269
|
+
outcome: option(argv, '--outcome'),
|
|
270
|
+
evidence: option(argv, '--evidence'),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function main() {
|
|
275
|
+
try {
|
|
276
|
+
const args = parseCli(process.argv.slice(2));
|
|
277
|
+
let result;
|
|
278
|
+
if (args.command === 'list') result = listCandidates(args);
|
|
279
|
+
else if (args.command === 'show') result = loadCandidate(args).candidate;
|
|
280
|
+
else if (args.command === 'evaluate') result = evaluateCandidate(args);
|
|
281
|
+
else if (args.command === 'promote') result = promoteCandidate(args);
|
|
282
|
+
else if (args.command === 'observe') result = observeCandidate(args);
|
|
283
|
+
else throw new Error('Usage: lesson-candidate.js <list|show|evaluate|promote|observe> --workspace <root> --session-id <id> [--candidate-id <id>] ...');
|
|
284
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
285
|
+
if (result && result.ok === false) process.exitCode = result.exitCode || 1;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
console.error(`[self-evolve] ${error.message}`);
|
|
288
|
+
process.exitCode = 1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (require.main === module) main();
|
|
293
|
+
|
|
294
|
+
module.exports = {
|
|
295
|
+
SCHEMA_VERSION,
|
|
296
|
+
candidatePath,
|
|
297
|
+
evaluateCandidate,
|
|
298
|
+
listCandidates,
|
|
299
|
+
loadCandidate,
|
|
300
|
+
observeCandidate,
|
|
301
|
+
promoteCandidate,
|
|
302
|
+
replayability,
|
|
303
|
+
};
|
|
@@ -42,7 +42,7 @@ function runNode(label, script, args = [], options = {}) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
console.log('\n[Phase 1] Static Syntax Check...');
|
|
45
|
-
const foldersToScan = ['harness-everything', 'hooks', 'environment-detection', 'self-evolve', 'ci', 'eval-harness', 'scripts', 'bin', 'to-spec', 'to-tickets', 'find-skills', 'fable-mode', 'multi-agent-workspace'];
|
|
45
|
+
const foldersToScan = ['harness-everything', 'hooks', 'environment-detection', 'self-evolve', 'telemetry', 'contract-integrity', 'ci', 'eval-harness', 'scripts', 'bin', 'to-spec', 'to-tickets', 'find-skills', 'fable-mode', 'multi-agent-workspace'];
|
|
46
46
|
const jsFiles = [];
|
|
47
47
|
function walkDir(dir) {
|
|
48
48
|
if (!fs.existsSync(dir)) return;
|
|
@@ -92,8 +92,11 @@ runNode('explicit parallel safety contract', path.join(projectRoot, 'ci', 'route
|
|
|
92
92
|
runNode('workflow-plan consumers', path.join(projectRoot, 'ci', 'workflow-plan-consumers.test.js'));
|
|
93
93
|
runNode('workflow-plan consumer edge contracts', path.join(projectRoot, 'ci', 'workflow-plan-consumers-edge.test.js'));
|
|
94
94
|
runNode('phase 4 ensemble/memory evidence', path.join(projectRoot, 'ci', 'phase4-ensemble-memory.test.js'));
|
|
95
|
+
runNode('self-evolve closed loop phase 1-3', path.join(projectRoot, 'ci', 'self-evolve-loop.test.js'));
|
|
95
96
|
runNode('phase 5 pre-action gate', path.join(projectRoot, 'ci', 'action-gate.test.js'));
|
|
96
97
|
runNode('phase 5 platform evidence boundary', path.join(projectRoot, 'ci', 'action-gate-platform-doc.test.js'));
|
|
98
|
+
runNode('cross-agent telemetry', path.join(projectRoot, 'ci', 'telemetry.test.js'));
|
|
99
|
+
runNode('contract integrity phase 1', path.join(projectRoot, 'ci', 'contract-integrity.test.js'));
|
|
97
100
|
runNode('issue #99 runtime correctness', path.join(projectRoot, 'ci', 'issue99-runtime.test.js'));
|
|
98
101
|
|
|
99
102
|
console.log('\n[Phase 3] Skill Reference and Behavioral Case Checks...');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/dyphn1/Harness-everything/telemetry/schemas/event.schema.json",
|
|
4
|
+
"title": "Harness Telemetry Event",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"schemaVersion", "eventId", "event", "host", "observedAt",
|
|
8
|
+
"sessionId", "turnId", "agentId", "invocationId", "skillName",
|
|
9
|
+
"skillVersion", "toolName", "status", "retryCount", "timing", "reasonCodes"
|
|
10
|
+
],
|
|
11
|
+
"properties": {
|
|
12
|
+
"schemaVersion": { "const": 1 },
|
|
13
|
+
"eventId": { "type": "string", "minLength": 1 },
|
|
14
|
+
"event": { "enum": ["skill.invoke", "skill.loaded", "skill.complete", "tool.observed"] },
|
|
15
|
+
"host": { "enum": ["claude", "codex", "opencode", "unknown"] },
|
|
16
|
+
"observedAt": { "type": "string", "format": "date-time" },
|
|
17
|
+
"sessionId": { "type": ["string", "null"] },
|
|
18
|
+
"turnId": { "type": ["string", "null"] },
|
|
19
|
+
"agentId": { "type": ["string", "null"] },
|
|
20
|
+
"invocationId": { "type": ["string", "null"] },
|
|
21
|
+
"skillName": { "type": ["string", "null"] },
|
|
22
|
+
"skillVersion": { "type": ["string", "null"] },
|
|
23
|
+
"toolName": { "type": ["string", "null"] },
|
|
24
|
+
"status": { "enum": ["unknown", "success", "failure", "aborted"] },
|
|
25
|
+
"retryCount": { "type": "integer", "minimum": 0 },
|
|
26
|
+
"timing": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"required": ["skillLoadDurationMs", "activeWindowMs", "attributedToolDurationMs"],
|
|
29
|
+
"properties": {
|
|
30
|
+
"skillLoadDurationMs": { "type": ["number", "null"], "minimum": 0 },
|
|
31
|
+
"activeWindowMs": { "type": ["number", "null"], "minimum": 0 },
|
|
32
|
+
"attributedToolDurationMs": { "type": ["number", "null"], "minimum": 0 }
|
|
33
|
+
},
|
|
34
|
+
"additionalProperties": false
|
|
35
|
+
},
|
|
36
|
+
"reasonCodes": { "type": "array", "items": { "type": "string" } }
|
|
37
|
+
},
|
|
38
|
+
"additionalProperties": false
|
|
39
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { emitTelemetry } = require('../../hooks/scripts/lib/telemetry');
|
|
8
|
+
const { percentile } = require('./report');
|
|
9
|
+
|
|
10
|
+
function runBenchmark(count = 250) {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-telemetry-bench-'));
|
|
12
|
+
const file = path.join(root, 'events.jsonl');
|
|
13
|
+
const overhead = [];
|
|
14
|
+
try {
|
|
15
|
+
for (let i = 0; i < count; i++) {
|
|
16
|
+
const result = emitTelemetry({
|
|
17
|
+
event: 'tool.observed',
|
|
18
|
+
host: 'unknown',
|
|
19
|
+
sessionId: 'session-benchmark',
|
|
20
|
+
invocationId: 'invocation-benchmark',
|
|
21
|
+
skillName: 'benchmark-skill',
|
|
22
|
+
toolName: 'benchmark-tool',
|
|
23
|
+
status: 'success',
|
|
24
|
+
timing: { attributedToolDurationMs: 1 },
|
|
25
|
+
reasonCodes: ['overhead-benchmark'],
|
|
26
|
+
}, { file });
|
|
27
|
+
if (!result.ok) throw new Error(result.error || 'telemetry write failed');
|
|
28
|
+
overhead.push(result.overheadMs);
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
count,
|
|
32
|
+
p50Ms: percentile(overhead, 50),
|
|
33
|
+
p95Ms: percentile(overhead, 95),
|
|
34
|
+
maxMs: Math.max(...overhead),
|
|
35
|
+
bytes: fs.statSync(file).size,
|
|
36
|
+
};
|
|
37
|
+
} finally {
|
|
38
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (require.main === module) {
|
|
43
|
+
const count = Number(process.argv[2] || 250);
|
|
44
|
+
process.stdout.write(`${JSON.stringify(runBenchmark(Number.isInteger(count) && count > 0 ? count : 250), null, 2)}\n`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { runBenchmark };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const {
|
|
7
|
+
readEvents,
|
|
8
|
+
telemetryFile,
|
|
9
|
+
} = require('../../hooks/scripts/lib/telemetry');
|
|
10
|
+
|
|
11
|
+
function percentile(values, p) {
|
|
12
|
+
const sorted = values.filter(Number.isFinite).slice().sort((a, b) => a - b);
|
|
13
|
+
if (!sorted.length) return null;
|
|
14
|
+
const index = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1);
|
|
15
|
+
return sorted[index];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function metric(values) {
|
|
19
|
+
const clean = values.filter(Number.isFinite);
|
|
20
|
+
return {
|
|
21
|
+
count: clean.length,
|
|
22
|
+
p50: percentile(clean, 50),
|
|
23
|
+
p95: percentile(clean, 95),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildReport(events) {
|
|
28
|
+
const valid = events.filter(event => event && !event.invalid);
|
|
29
|
+
const invalid = events.filter(event => event && event.invalid);
|
|
30
|
+
const groups = new Map();
|
|
31
|
+
|
|
32
|
+
for (const event of valid) {
|
|
33
|
+
const key = `${event.host}::${event.skillName || '(unknown)'}`;
|
|
34
|
+
if (!groups.has(key)) groups.set(key, {
|
|
35
|
+
host: event.host,
|
|
36
|
+
skillName: event.skillName,
|
|
37
|
+
skillVersion: event.skillVersion,
|
|
38
|
+
invocationIds: new Set(),
|
|
39
|
+
invokes: 0,
|
|
40
|
+
loads: 0,
|
|
41
|
+
completes: 0,
|
|
42
|
+
status: { success: 0, failure: 0, aborted: 0, unknown: 0 },
|
|
43
|
+
loadDurations: [],
|
|
44
|
+
activeWindows: [],
|
|
45
|
+
attributedToolDurations: [],
|
|
46
|
+
toolEvents: 0,
|
|
47
|
+
maxRetry: 0,
|
|
48
|
+
});
|
|
49
|
+
const g = groups.get(key);
|
|
50
|
+
if (event.invocationId) g.invocationIds.add(event.invocationId);
|
|
51
|
+
if (event.skillVersion) g.skillVersion = event.skillVersion;
|
|
52
|
+
if (event.event === 'skill.invoke') g.invokes++;
|
|
53
|
+
if (event.event === 'skill.loaded') {
|
|
54
|
+
g.loads++;
|
|
55
|
+
if (Number.isFinite(event.timing.skillLoadDurationMs)) g.loadDurations.push(event.timing.skillLoadDurationMs);
|
|
56
|
+
}
|
|
57
|
+
if (event.event === 'skill.complete') {
|
|
58
|
+
g.completes++;
|
|
59
|
+
g.status[event.status] = (g.status[event.status] || 0) + 1;
|
|
60
|
+
if (Number.isFinite(event.timing.activeWindowMs)) g.activeWindows.push(event.timing.activeWindowMs);
|
|
61
|
+
if (Number.isFinite(event.timing.attributedToolDurationMs)) g.attributedToolDurations.push(event.timing.attributedToolDurationMs);
|
|
62
|
+
}
|
|
63
|
+
if (event.event === 'tool.observed') g.toolEvents++;
|
|
64
|
+
g.maxRetry = Math.max(g.maxRetry, event.retryCount || 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const skills = [...groups.values()].map(g => ({
|
|
68
|
+
host: g.host,
|
|
69
|
+
skillName: g.skillName,
|
|
70
|
+
skillVersion: g.skillVersion,
|
|
71
|
+
invocationCount: g.invocationIds.size || g.invokes,
|
|
72
|
+
invokeEvents: g.invokes,
|
|
73
|
+
loadEvents: g.loads,
|
|
74
|
+
completeEvents: g.completes,
|
|
75
|
+
status: g.status,
|
|
76
|
+
retryMax: g.maxRetry,
|
|
77
|
+
toolEventCount: g.toolEvents,
|
|
78
|
+
skillLoadDurationMs: metric(g.loadDurations),
|
|
79
|
+
activeWindowMs: metric(g.activeWindows),
|
|
80
|
+
attributedToolDurationMs: metric(g.attributedToolDurations),
|
|
81
|
+
})).sort((a, b) => `${a.host}/${a.skillName}`.localeCompare(`${b.host}/${b.skillName}`));
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
schemaVersion: 1,
|
|
85
|
+
eventCount: valid.length,
|
|
86
|
+
invalidEventCount: invalid.length,
|
|
87
|
+
skills,
|
|
88
|
+
semantics: {
|
|
89
|
+
skillLoadDurationMs: 'PreToolUse(Skill) to correlated PostToolUse(Skill); framework/load latency only.',
|
|
90
|
+
activeWindowMs: 'Successful skill load to host Stop/turn boundary; context exposure window, not CPU time.',
|
|
91
|
+
attributedToolDurationMs: 'Sum of host-reported tool durations observed while the skill invocation is active; overlapping skills may each receive the same tool observation.',
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function markdown(report) {
|
|
97
|
+
const lines = [
|
|
98
|
+
'# Harness Telemetry Report',
|
|
99
|
+
'',
|
|
100
|
+
'| Host | Skill | Invocations | Complete | Failure | Load P50/P95 ms | Active P50/P95 ms | Tool P50/P95 ms |',
|
|
101
|
+
'| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
|
102
|
+
];
|
|
103
|
+
for (const row of report.skills) {
|
|
104
|
+
const pair = value => value.count ? `${value.p50}/${value.p95}` : '-';
|
|
105
|
+
lines.push(`| ${row.host} | ${row.skillName || '(unknown)'} | ${row.invocationCount} | ${row.completeEvents} | ${row.status.failure} | ${pair(row.skillLoadDurationMs)} | ${pair(row.activeWindowMs)} | ${pair(row.attributedToolDurationMs)} |`);
|
|
106
|
+
}
|
|
107
|
+
lines.push('', `Events: ${report.eventCount}; invalid lines: ${report.invalidEventCount}.`);
|
|
108
|
+
lines.push('', '> Active window is not CPU/runtime. Attributed tool time is observational and non-exclusive when skills overlap.');
|
|
109
|
+
return `${lines.join('\n')}\n`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function option(args, name) {
|
|
113
|
+
const index = args.indexOf(name);
|
|
114
|
+
return index >= 0 ? args[index + 1] : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function main() {
|
|
118
|
+
try {
|
|
119
|
+
const args = process.argv.slice(2);
|
|
120
|
+
const workspace = option(args, '--workspace');
|
|
121
|
+
const file = option(args, '--file') || (workspace ? telemetryFile(path.resolve(workspace), { cwd: workspace }) : null);
|
|
122
|
+
if (!file) throw new Error('Usage: report.js --file <events.jsonl> [--markdown] or --workspace <root>');
|
|
123
|
+
const report = buildReport(readEvents(path.resolve(file)));
|
|
124
|
+
process.stdout.write(args.includes('--markdown') ? markdown(report) : `${JSON.stringify(report, null, 2)}\n`);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error(`[telemetry] ${error.message}`);
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (require.main === module) main();
|
|
132
|
+
|
|
133
|
+
module.exports = { buildReport, markdown, metric, percentile };
|