solmate-skills 2.0.12 → 2.0.13
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/AGENTS.md +9 -0
- package/CLAUDE.md +13 -3
- package/README.md +91 -5
- package/USAGE.md +118 -12
- package/bin/cli.js +171 -1
- package/bin/harness-artifact.js +621 -0
- package/bin/harness-artifact.test.js +520 -0
- package/bin/harness-check.js +373 -0
- package/bin/harness-check.test.js +159 -0
- package/bin/test.js +2 -0
- package/docs/01_Concept_Design/01_AGENT_HARNESS_REQUIREMENTS_ANALYSIS.md +162 -0
- package/docs/03_Technical_Specs/01_AGENT_HARNESS_ARCHITECTURE.md +376 -0
- package/docs/03_Technical_Specs/assets/01_agent_harness_data_flow.svg +94 -0
- package/docs/04_Logic_Progress/00_BACKLOG.md +339 -0
- package/docs/04_Logic_Progress/01_EXECUTION_PLAN.md +160 -0
- package/docs/04_Logic_Progress/03_DECISION_LOG.md +98 -0
- package/docs/05_QA_Validation/01_TEST_SCENARIOS.md +313 -0
- package/docs/05_QA_Validation/02_AGENT_HARNESS_DESIGN_REVIEW.md +100 -0
- package/docs/05_QA_Validation/03_AGENT_HARNESS_CONTRACT_QA.md +96 -0
- package/manage-collaboration/SKILL.md +2 -0
- package/package.json +6 -4
- package/rules-docs/SKILL.md +15 -1
- package/rules-product/SKILL.md +30 -6
- package/rules-product/agents/openai.yaml +2 -2
- package/rules-workflow/SKILL.md +32 -7
- package/rules-workflow/adapters/claude/solmate-context-reader.md +17 -0
- package/rules-workflow/adapters/claude/solmate-implementer.md +20 -0
- package/rules-workflow/adapters/claude/solmate-verifier.md +21 -0
- package/rules-workflow/agents/openai.yaml +2 -2
- package/rules-workflow/resources/agent-harness-contract.md +187 -0
- package/rules-workflow/resources/agent-harness-v1.schema.json +432 -0
- package/verify-docs/SKILL.md +33 -6
- package/verify-docs/agents/openai.yaml +2 -2
- package/verify-implementation/SKILL.md +14 -2
- package/verify-implementation/agents/openai.yaml +2 -2
- package/verify-skills/SKILL.md +27 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const CONTRACT_PATH = path.join(
|
|
5
|
+
__dirname,
|
|
6
|
+
'..',
|
|
7
|
+
'rules-workflow',
|
|
8
|
+
'resources',
|
|
9
|
+
'agent-harness-v1.schema.json',
|
|
10
|
+
);
|
|
11
|
+
const ARTIFACT_TYPES = new Set(['manifest', 'message', 'events']);
|
|
12
|
+
const DYNAMIC_BLOCK_STATES = new Set(['BLOCKED_DECISION', 'BLOCKED_DEPENDENCY', 'DEGRADED']);
|
|
13
|
+
const SKIPPABLE_RECEIPTS = new Set(['REQUIREMENTS', 'DESIGN']);
|
|
14
|
+
const DATE_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/;
|
|
15
|
+
|
|
16
|
+
let cachedContract;
|
|
17
|
+
|
|
18
|
+
function loadContract() {
|
|
19
|
+
if (!cachedContract) {
|
|
20
|
+
cachedContract = JSON.parse(fs.readFileSync(CONTRACT_PATH, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
return cachedContract;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isPlainObject(value) {
|
|
26
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveReference(contract, reference) {
|
|
30
|
+
if (!reference.startsWith('#/')) {
|
|
31
|
+
throw new Error(`Unsupported schema reference: ${reference}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return reference.slice(2).split('/').reduce((current, segment) => {
|
|
35
|
+
if (!current || !Object.prototype.hasOwnProperty.call(current, segment)) {
|
|
36
|
+
throw new Error(`Missing schema reference: ${reference}`);
|
|
37
|
+
}
|
|
38
|
+
return current[segment];
|
|
39
|
+
}, contract);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function valueMatchesType(value, type) {
|
|
43
|
+
if (type === 'object') return isPlainObject(value);
|
|
44
|
+
if (type === 'array') return Array.isArray(value);
|
|
45
|
+
if (type === 'integer') return Number.isInteger(value);
|
|
46
|
+
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
|
|
47
|
+
if (type === 'null') return value === null;
|
|
48
|
+
return typeof value === type;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isValidDateTime(value) {
|
|
52
|
+
const match = DATE_TIME_PATTERN.exec(value);
|
|
53
|
+
if (!match) return false;
|
|
54
|
+
|
|
55
|
+
const year = Number(match[1]);
|
|
56
|
+
const month = Number(match[2]);
|
|
57
|
+
const day = Number(match[3]);
|
|
58
|
+
const hour = Number(match[4]);
|
|
59
|
+
const minute = Number(match[5]);
|
|
60
|
+
const second = Number(match[6]);
|
|
61
|
+
const offsetHour = match[7] === undefined ? 0 : Number(match[7]);
|
|
62
|
+
const offsetMinute = match[8] === undefined ? 0 : Number(match[8]);
|
|
63
|
+
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
64
|
+
const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
65
|
+
|
|
66
|
+
return year >= 1
|
|
67
|
+
&& month >= 1
|
|
68
|
+
&& month <= 12
|
|
69
|
+
&& day >= 1
|
|
70
|
+
&& day <= daysInMonth[month - 1]
|
|
71
|
+
&& hour <= 23
|
|
72
|
+
&& minute <= 59
|
|
73
|
+
&& second <= 59
|
|
74
|
+
&& offsetHour <= 23
|
|
75
|
+
&& offsetMinute <= 59;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateSchema(value, schema, errors, location, contract) {
|
|
79
|
+
if (schema.$ref) {
|
|
80
|
+
validateSchema(value, resolveReference(contract, schema.$ref), errors, location, contract);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (Object.prototype.hasOwnProperty.call(schema, 'const') && value !== schema.const) {
|
|
85
|
+
errors.push(`${location} must equal ${JSON.stringify(schema.const)}.`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
90
|
+
errors.push(`${location} must be one of: ${schema.enum.join(', ')}.`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (schema.type && !valueMatchesType(value, schema.type)) {
|
|
95
|
+
errors.push(`${location} must be ${schema.type}.`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (schema.type === 'object') {
|
|
100
|
+
for (const field of schema.required || []) {
|
|
101
|
+
if (!Object.prototype.hasOwnProperty.call(value, field)) {
|
|
102
|
+
errors.push(`${location}.${field} is required.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const [field, fieldValue] of Object.entries(value)) {
|
|
107
|
+
const properties = schema.properties || {};
|
|
108
|
+
if (!Object.prototype.hasOwnProperty.call(properties, field)) {
|
|
109
|
+
if (schema.additionalProperties === false) {
|
|
110
|
+
errors.push(`${location}.${field} is not allowed.`);
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const fieldSchema = properties[field];
|
|
115
|
+
validateSchema(fieldValue, fieldSchema, errors, `${location}.${field}`, contract);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (schema.type === 'array') {
|
|
120
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
121
|
+
errors.push(`${location} must contain at least ${schema.minItems} item(s).`);
|
|
122
|
+
}
|
|
123
|
+
if (schema.uniqueItems) {
|
|
124
|
+
const serialized = value.map(item => JSON.stringify(item));
|
|
125
|
+
if (new Set(serialized).size !== serialized.length) {
|
|
126
|
+
errors.push(`${location} must not contain duplicate items.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (schema.items) {
|
|
130
|
+
value.forEach((item, index) => {
|
|
131
|
+
validateSchema(item, schema.items, errors, `${location}[${index}]`, contract);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (schema.type === 'string') {
|
|
137
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
138
|
+
errors.push(`${location} must contain at least ${schema.minLength} character(s).`);
|
|
139
|
+
}
|
|
140
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
|
|
141
|
+
errors.push(`${location} has an invalid format.`);
|
|
142
|
+
}
|
|
143
|
+
if (schema.format === 'date-time' && !isValidDateTime(value)) {
|
|
144
|
+
errors.push(`${location} must use the Solmate canonical date-time profile.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if ((schema.type === 'integer' || schema.type === 'number')
|
|
149
|
+
&& schema.minimum !== undefined
|
|
150
|
+
&& value < schema.minimum) {
|
|
151
|
+
errors.push(`${location} must be at least ${schema.minimum}.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isSafeProjectPath(value, options = {}) {
|
|
156
|
+
const { allowRecursive = false } = options;
|
|
157
|
+
if (typeof value !== 'string' || value.length === 0 || value.includes('\0')) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const recursive = value === '**' || value.endsWith('/**');
|
|
162
|
+
const nonRecursivePart = value === '**'
|
|
163
|
+
? ''
|
|
164
|
+
: recursive ? value.slice(0, -3) : value;
|
|
165
|
+
if (path.isAbsolute(value)
|
|
166
|
+
|| path.win32.isAbsolute(value)
|
|
167
|
+
|| value.includes('\\')
|
|
168
|
+
|| /^[a-z][a-z0-9+.-]*:/i.test(value)
|
|
169
|
+
|| /[*?[\]{}]/.test(nonRecursivePart)
|
|
170
|
+
|| (!allowRecursive && recursive)
|
|
171
|
+
|| value.endsWith('/')
|
|
172
|
+
|| nonRecursivePart.startsWith('./')
|
|
173
|
+
|| nonRecursivePart.endsWith('/')
|
|
174
|
+
|| nonRecursivePart.includes('//')) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (value === '**') return allowRecursive;
|
|
179
|
+
if (!nonRecursivePart || nonRecursivePart === '.') return false;
|
|
180
|
+
if (nonRecursivePart.split('/').includes('..')) return false;
|
|
181
|
+
return path.posix.normalize(nonRecursivePart) === nonRecursivePart;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function validateProjectPaths(value, errors, location, options = {}) {
|
|
185
|
+
if (!Array.isArray(value)) return;
|
|
186
|
+
value.forEach((projectPath, index) => {
|
|
187
|
+
if (!isSafeProjectPath(projectPath, options)) {
|
|
188
|
+
errors.push(`${location}[${index}] must be a safe project-relative path.`);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizeOwnershipScope(value) {
|
|
194
|
+
if (typeof value !== 'string') return null;
|
|
195
|
+
const recursive = value === '**' || value.endsWith('/**');
|
|
196
|
+
const rawBase = value === '**' ? '' : recursive ? value.slice(0, -3) : value;
|
|
197
|
+
const normalizedBase = rawBase ? path.posix.normalize(rawBase).replace(/\/+$/, '') : '';
|
|
198
|
+
return {
|
|
199
|
+
base: normalizedBase === '.' ? '' : normalizedBase,
|
|
200
|
+
recursive,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function ownershipScopesOverlap(left, right) {
|
|
205
|
+
const a = normalizeOwnershipScope(left);
|
|
206
|
+
const b = normalizeOwnershipScope(right);
|
|
207
|
+
if (!a || !b) return false;
|
|
208
|
+
if (a.base === b.base) return true;
|
|
209
|
+
if (a.recursive && a.base === '') return true;
|
|
210
|
+
if (b.recursive && b.base === '') return true;
|
|
211
|
+
if (a.recursive && b.base.startsWith(`${a.base}/`)) return true;
|
|
212
|
+
return b.recursive && a.base.startsWith(`${b.base}/`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function validateManifest(manifest, contract = loadContract()) {
|
|
216
|
+
const errors = [];
|
|
217
|
+
validateSchema(manifest, contract.$defs.manifest, errors, '$', contract);
|
|
218
|
+
if (!isPlainObject(manifest)) return errors;
|
|
219
|
+
|
|
220
|
+
const roles = Array.isArray(manifest.roles) ? manifest.roles : [];
|
|
221
|
+
const roleMap = new Map();
|
|
222
|
+
for (const role of roles) {
|
|
223
|
+
if (!isPlainObject(role) || typeof role.role_id !== 'string') continue;
|
|
224
|
+
if (roleMap.has(role.role_id)) {
|
|
225
|
+
errors.push(`$.roles contains duplicate role_id: ${role.role_id}.`);
|
|
226
|
+
} else {
|
|
227
|
+
roleMap.set(role.role_id, role);
|
|
228
|
+
}
|
|
229
|
+
if (role.activation === 'SKIPPED' && !String(role.reason || '').trim()) {
|
|
230
|
+
errors.push(`Skipped role ${role.role_id} must include a reason.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (['code', 'deploy'].includes(manifest.work_type)) {
|
|
235
|
+
for (const roleId of contract['x-core-roles']) {
|
|
236
|
+
const role = roleMap.get(roleId);
|
|
237
|
+
if (!role || role.activation !== 'ACTIVE') {
|
|
238
|
+
errors.push(`Core role ${roleId} must be ACTIVE for ${manifest.work_type} work.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const activeRoles = new Set(
|
|
244
|
+
roles.filter(role => role && role.activation === 'ACTIVE').map(role => role.role_id),
|
|
245
|
+
);
|
|
246
|
+
if (isPlainObject(manifest.topology)
|
|
247
|
+
&& Number.isInteger(manifest.topology.agent_count)
|
|
248
|
+
&& manifest.topology.agent_count !== activeRoles.size) {
|
|
249
|
+
errors.push('$.topology.agent_count must equal the number of ACTIVE roles.');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const ownership = Array.isArray(manifest.write_ownership) ? manifest.write_ownership : [];
|
|
253
|
+
const scopes = [];
|
|
254
|
+
const readOnlyRoles = new Set(contract['x-read-only-roles']);
|
|
255
|
+
ownership.forEach((entry, entryIndex) => {
|
|
256
|
+
if (!isPlainObject(entry)) return;
|
|
257
|
+
if (!activeRoles.has(entry.role_id)) {
|
|
258
|
+
errors.push(`Write owner ${entry.role_id} must be an ACTIVE role.`);
|
|
259
|
+
}
|
|
260
|
+
if (readOnlyRoles.has(entry.role_id)) {
|
|
261
|
+
errors.push(`Read-only role ${entry.role_id} cannot own write paths.`);
|
|
262
|
+
}
|
|
263
|
+
validateProjectPaths(
|
|
264
|
+
entry.paths,
|
|
265
|
+
errors,
|
|
266
|
+
`$.write_ownership[${entryIndex}].paths`,
|
|
267
|
+
{ allowRecursive: true },
|
|
268
|
+
);
|
|
269
|
+
for (const ownedPath of Array.isArray(entry.paths) ? entry.paths : []) {
|
|
270
|
+
if (typeof ownedPath !== 'string') continue;
|
|
271
|
+
for (const existing of scopes) {
|
|
272
|
+
if (existing.roleId !== entry.role_id
|
|
273
|
+
&& ownershipScopesOverlap(existing.path, ownedPath)) {
|
|
274
|
+
errors.push(`Write scope ${ownedPath} for ${entry.role_id} overlaps ${existing.path} owned by ${existing.roleId}.`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
scopes.push({ roleId: entry.role_id, path: ownedPath });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const documents = Array.isArray(manifest.canonical_documents)
|
|
282
|
+
? manifest.canonical_documents
|
|
283
|
+
: [];
|
|
284
|
+
const documentPaths = new Set();
|
|
285
|
+
documents.forEach((document, index) => {
|
|
286
|
+
if (!isPlainObject(document)) return;
|
|
287
|
+
if (documentPaths.has(document.path)) {
|
|
288
|
+
errors.push(`$.canonical_documents contains duplicate path: ${document.path}.`);
|
|
289
|
+
}
|
|
290
|
+
documentPaths.add(document.path);
|
|
291
|
+
validateProjectPaths([document.path], errors, `$.canonical_documents[${index}].path`);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const receipts = Array.isArray(manifest.receipts) ? manifest.receipts : [];
|
|
295
|
+
const receiptMap = new Map();
|
|
296
|
+
receipts.forEach((receipt, index) => {
|
|
297
|
+
if (!isPlainObject(receipt)) return;
|
|
298
|
+
if (receipt.type !== 'ERROR' && receiptMap.has(receipt.type)) {
|
|
299
|
+
errors.push(`$.receipts contains duplicate ${receipt.type} receipt.`);
|
|
300
|
+
}
|
|
301
|
+
if (receipt.type !== 'ERROR') receiptMap.set(receipt.type, receipt);
|
|
302
|
+
if (receipt.status === 'SKIPPED' && !String(receipt.reason || '').trim()) {
|
|
303
|
+
errors.push(`Skipped ${receipt.type} receipt must include a reason.`);
|
|
304
|
+
}
|
|
305
|
+
validateProjectPaths([receipt.artifact_ref], errors, `$.receipts[${index}].artifact_ref`);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const requiredReceipts = contract['x-manifest-receipts'][manifest.current_state] || [];
|
|
309
|
+
for (const receiptType of requiredReceipts) {
|
|
310
|
+
const receipt = receiptMap.get(receiptType);
|
|
311
|
+
const skippable = SKIPPABLE_RECEIPTS.has(receiptType)
|
|
312
|
+
&& receipt
|
|
313
|
+
&& receipt.status === 'SKIPPED'
|
|
314
|
+
&& String(receipt.reason || '').trim();
|
|
315
|
+
if (!receipt || (receipt.status !== 'PASS' && !skippable)) {
|
|
316
|
+
errors.push(`${manifest.current_state} requires ${receiptType} receipt evidence.`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return errors;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function validateMessage(message, manifest, contract = loadContract()) {
|
|
324
|
+
const errors = [];
|
|
325
|
+
validateSchema(message, contract.$defs.message, errors, '$', contract);
|
|
326
|
+
if (!isPlainObject(message)) return errors;
|
|
327
|
+
|
|
328
|
+
validateProjectPaths(message.artifact_refs, errors, '$.artifact_refs');
|
|
329
|
+
validateProjectPaths(message.evidence_refs, errors, '$.evidence_refs');
|
|
330
|
+
|
|
331
|
+
const recipients = Array.isArray(message.to) ? message.to : [];
|
|
332
|
+
if (recipients.includes(message.from)) {
|
|
333
|
+
errors.push('A message cannot be addressed to its sender.');
|
|
334
|
+
}
|
|
335
|
+
if (['REQUEST', 'REWORK_REQUEST'].includes(message.type) && message.from !== 'coordinator') {
|
|
336
|
+
errors.push(`${message.type} messages may only be sent by coordinator.`);
|
|
337
|
+
}
|
|
338
|
+
if (message.from !== 'coordinator'
|
|
339
|
+
&& !recipients.includes('coordinator')
|
|
340
|
+
&& !['STATUS', 'QUESTION'].includes(message.type)) {
|
|
341
|
+
errors.push('Direct peer messages may only use STATUS or QUESTION.');
|
|
342
|
+
}
|
|
343
|
+
if (message.from !== 'coordinator'
|
|
344
|
+
&& !recipients.includes('coordinator')
|
|
345
|
+
&& !['INFO', 'PENDING'].includes(message.status)) {
|
|
346
|
+
errors.push('Direct peer messages may only use INFO or PENDING status.');
|
|
347
|
+
}
|
|
348
|
+
if (['DECISION_REQUIRED', 'HANDOFF', 'FINDING', 'RESULT'].includes(message.type)
|
|
349
|
+
&& message.from !== 'coordinator'
|
|
350
|
+
&& !recipients.includes('coordinator')) {
|
|
351
|
+
errors.push(`${message.type} messages must route through coordinator.`);
|
|
352
|
+
}
|
|
353
|
+
if (['HANDOFF', 'RESULT'].includes(message.type)
|
|
354
|
+
&& (message.artifact_refs || []).length === 0
|
|
355
|
+
&& (message.evidence_refs || []).length === 0) {
|
|
356
|
+
errors.push(`${message.type} messages require an artifact or evidence reference.`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (manifest && isPlainObject(manifest)) {
|
|
360
|
+
if (message.task_id !== manifest.task_id) {
|
|
361
|
+
errors.push('Message task_id must match the manifest task_id.');
|
|
362
|
+
}
|
|
363
|
+
if (message.attempt !== manifest.active_attempt) {
|
|
364
|
+
errors.push('Message attempt must match the manifest active_attempt.');
|
|
365
|
+
}
|
|
366
|
+
const activeRoles = new Set(
|
|
367
|
+
(manifest.roles || [])
|
|
368
|
+
.filter(role => role && role.activation === 'ACTIVE')
|
|
369
|
+
.map(role => role.role_id),
|
|
370
|
+
);
|
|
371
|
+
for (const roleId of [message.from, ...recipients]) {
|
|
372
|
+
if (!activeRoles.has(roleId)) {
|
|
373
|
+
errors.push(`Message role ${roleId} is not ACTIVE in the manifest.`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return errors;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function validateEvent(event, contract = loadContract()) {
|
|
382
|
+
const errors = [];
|
|
383
|
+
validateSchema(event, contract.$defs.event, errors, '$', contract);
|
|
384
|
+
if (!isPlainObject(event)) return errors;
|
|
385
|
+
|
|
386
|
+
validateProjectPaths(event.artifact_refs, errors, '$.artifact_refs');
|
|
387
|
+
validateProjectPaths(event.evidence_refs, errors, '$.evidence_refs');
|
|
388
|
+
|
|
389
|
+
if (event.actor !== 'coordinator') {
|
|
390
|
+
errors.push('Only coordinator may record state transitions.');
|
|
391
|
+
}
|
|
392
|
+
const legalNextStates = contract['x-state-transitions'][event.from] || [];
|
|
393
|
+
if (!legalNextStates.includes(event.to)) {
|
|
394
|
+
errors.push(`Illegal state transition: ${event.from} -> ${event.to}.`);
|
|
395
|
+
}
|
|
396
|
+
if (event.from === event.to) {
|
|
397
|
+
errors.push('A state event must change the state.');
|
|
398
|
+
}
|
|
399
|
+
if (DYNAMIC_BLOCK_STATES.has(event.to) && event.resume_state !== event.from) {
|
|
400
|
+
errors.push(`${event.to} requires resume_state to equal the previous state.`);
|
|
401
|
+
}
|
|
402
|
+
if (!DYNAMIC_BLOCK_STATES.has(event.to) && event.resume_state !== undefined) {
|
|
403
|
+
errors.push('resume_state is only allowed when entering a dynamic blocked state.');
|
|
404
|
+
}
|
|
405
|
+
for (const field of contract['x-event-evidence'][event.to] || []) {
|
|
406
|
+
if (!Array.isArray(event[field]) || event[field].length === 0) {
|
|
407
|
+
errors.push(`${event.from} -> ${event.to} requires ${field}.`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return errors;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function validateEvents(events, manifest, contract = loadContract()) {
|
|
415
|
+
const errors = [];
|
|
416
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
417
|
+
return ['events.jsonl must contain at least one state event.'];
|
|
418
|
+
}
|
|
419
|
+
if (isPlainObject(events[0]) && events[0].from !== 'INTAKE') {
|
|
420
|
+
errors.push('line 1: the event log must start from INTAKE.');
|
|
421
|
+
}
|
|
422
|
+
if (isPlainObject(events[0]) && events[0].attempt !== 1) {
|
|
423
|
+
errors.push('line 1: the event log must start with attempt 1.');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const eventIds = new Set();
|
|
427
|
+
|
|
428
|
+
events.forEach((event, index) => {
|
|
429
|
+
validateEvent(event, contract).forEach(error => errors.push(`line ${index + 1}: ${error}`));
|
|
430
|
+
if (!isPlainObject(event)) return;
|
|
431
|
+
if (eventIds.has(event.event_id)) {
|
|
432
|
+
errors.push(`line ${index + 1}: event_id must be unique.`);
|
|
433
|
+
}
|
|
434
|
+
eventIds.add(event.event_id);
|
|
435
|
+
if (event.sequence !== index + 1) {
|
|
436
|
+
errors.push(`line ${index + 1}: sequence must equal ${index + 1}.`);
|
|
437
|
+
}
|
|
438
|
+
if (index === 0) return;
|
|
439
|
+
|
|
440
|
+
const previous = events[index - 1];
|
|
441
|
+
if (!isPlainObject(previous)) return;
|
|
442
|
+
if (event.task_id !== previous.task_id) {
|
|
443
|
+
errors.push(`line ${index + 1}: task_id must match earlier events.`);
|
|
444
|
+
}
|
|
445
|
+
if (event.from !== previous.to) {
|
|
446
|
+
errors.push(`line ${index + 1}: from must equal the previous event to state.`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const expectedAttempt = previous.to === 'REWORK' && event.to === 'IMPLEMENTING'
|
|
450
|
+
? previous.attempt + 1
|
|
451
|
+
: previous.attempt;
|
|
452
|
+
if (event.attempt !== expectedAttempt) {
|
|
453
|
+
errors.push(`line ${index + 1}: attempt must be ${expectedAttempt}.`);
|
|
454
|
+
}
|
|
455
|
+
if (DYNAMIC_BLOCK_STATES.has(previous.to)
|
|
456
|
+
&& event.to !== 'CANCELLED'
|
|
457
|
+
&& event.to !== previous.resume_state) {
|
|
458
|
+
errors.push(`line ${index + 1}: ${previous.to} must resume to ${previous.resume_state}.`);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
if (!events.every(isPlainObject)) {
|
|
463
|
+
return errors;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (manifest && isPlainObject(manifest)) {
|
|
467
|
+
const first = events[0];
|
|
468
|
+
const last = events[events.length - 1];
|
|
469
|
+
if (first.task_id !== manifest.task_id) {
|
|
470
|
+
errors.push('Event task_id must match the manifest task_id.');
|
|
471
|
+
}
|
|
472
|
+
if (last.to !== manifest.current_state) {
|
|
473
|
+
errors.push('Manifest current_state must match the final event state.');
|
|
474
|
+
}
|
|
475
|
+
if (last.attempt !== manifest.active_attempt) {
|
|
476
|
+
errors.push('Manifest active_attempt must match the final event attempt.');
|
|
477
|
+
}
|
|
478
|
+
const coordinator = (manifest.roles || [])
|
|
479
|
+
.find(role => role && role.role_id === 'coordinator');
|
|
480
|
+
if (!coordinator || coordinator.activation !== 'ACTIVE') {
|
|
481
|
+
errors.push('Event validation requires an ACTIVE coordinator in the manifest.');
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return errors;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function readJson(filePath) {
|
|
489
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function readJsonLines(filePath) {
|
|
493
|
+
const lines = fs.readFileSync(filePath, 'utf8')
|
|
494
|
+
.split(/\r?\n/)
|
|
495
|
+
.filter(line => line.trim());
|
|
496
|
+
return lines.map((line, index) => {
|
|
497
|
+
try {
|
|
498
|
+
return JSON.parse(line);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
const wrapped = new Error(`Invalid JSON on line ${index + 1}: ${error.message}`);
|
|
501
|
+
wrapped.cause = error;
|
|
502
|
+
throw wrapped;
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function checkHarnessArtifact(options) {
|
|
508
|
+
const cwd = options.cwd || process.cwd();
|
|
509
|
+
const artifactType = options.artifactType;
|
|
510
|
+
const mode = options.mode || 'warning';
|
|
511
|
+
|
|
512
|
+
if (!ARTIFACT_TYPES.has(artifactType)) {
|
|
513
|
+
return { operationalError: true, valid: false, errors: [`Unknown harness artifact type: ${artifactType}`] };
|
|
514
|
+
}
|
|
515
|
+
if (!['warning', 'blocking'].includes(mode)) {
|
|
516
|
+
return { operationalError: true, valid: false, errors: [`Unknown harness mode: ${mode}`] };
|
|
517
|
+
}
|
|
518
|
+
if (!options.filePath) {
|
|
519
|
+
return { operationalError: true, valid: false, errors: ['A harness artifact path is required.'] };
|
|
520
|
+
}
|
|
521
|
+
if (artifactType !== 'manifest' && !options.manifestPath) {
|
|
522
|
+
return {
|
|
523
|
+
operationalError: true,
|
|
524
|
+
valid: false,
|
|
525
|
+
errors: [`--manifest is required when validating ${artifactType}.`],
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const filePath = path.resolve(cwd, options.filePath);
|
|
530
|
+
if (!fs.existsSync(filePath)) {
|
|
531
|
+
return { operationalError: true, valid: false, errors: [`Harness artifact not found: ${filePath}`] };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
let manifest;
|
|
535
|
+
let value;
|
|
536
|
+
try {
|
|
537
|
+
if (options.manifestPath) {
|
|
538
|
+
const manifestPath = path.resolve(cwd, options.manifestPath);
|
|
539
|
+
if (!fs.existsSync(manifestPath)) {
|
|
540
|
+
return { operationalError: true, valid: false, errors: [`Manifest not found: ${manifestPath}`] };
|
|
541
|
+
}
|
|
542
|
+
manifest = readJson(manifestPath);
|
|
543
|
+
const manifestErrors = validateManifest(manifest);
|
|
544
|
+
if (manifestErrors.length > 0) {
|
|
545
|
+
return {
|
|
546
|
+
artifactType,
|
|
547
|
+
mode,
|
|
548
|
+
filePath,
|
|
549
|
+
manifestPath,
|
|
550
|
+
valid: false,
|
|
551
|
+
errors: manifestErrors.map(error => `Manifest: ${error}`),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
value = artifactType === 'events' ? readJsonLines(filePath) : readJson(filePath);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
return {
|
|
558
|
+
operationalError: true,
|
|
559
|
+
valid: false,
|
|
560
|
+
errors: [`Unable to parse harness artifact: ${error.message}`],
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
let errors;
|
|
565
|
+
try {
|
|
566
|
+
if (artifactType === 'manifest') errors = validateManifest(value);
|
|
567
|
+
if (artifactType === 'message') errors = validateMessage(value, manifest);
|
|
568
|
+
if (artifactType === 'events') errors = validateEvents(value, manifest);
|
|
569
|
+
} catch (error) {
|
|
570
|
+
return {
|
|
571
|
+
operationalError: true,
|
|
572
|
+
valid: false,
|
|
573
|
+
errors: [`Unable to validate harness artifact: ${error.message}`],
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
artifactType,
|
|
579
|
+
mode,
|
|
580
|
+
filePath,
|
|
581
|
+
manifestPath: options.manifestPath ? path.resolve(cwd, options.manifestPath) : undefined,
|
|
582
|
+
valid: errors.length === 0,
|
|
583
|
+
errors,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function formatHarnessArtifactResult(result) {
|
|
588
|
+
if (result.operationalError) {
|
|
589
|
+
return ['Harness artifact check: ERROR', ...result.errors.map(error => `- ${error}`)].join('\n');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const status = result.valid
|
|
593
|
+
? 'PASS'
|
|
594
|
+
: result.mode === 'blocking' ? 'BLOCK' : 'WARN';
|
|
595
|
+
const lines = [
|
|
596
|
+
`Harness artifact ${result.artifactType}: ${status}`,
|
|
597
|
+
`Mode: ${result.mode}`,
|
|
598
|
+
`File: ${result.filePath}`,
|
|
599
|
+
];
|
|
600
|
+
if (result.manifestPath) lines.push(`Manifest: ${result.manifestPath}`);
|
|
601
|
+
for (const error of result.errors) lines.push(`- ${error}`);
|
|
602
|
+
return lines.join('\n');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function getHarnessArtifactExitCode(result) {
|
|
606
|
+
if (result.operationalError) return 2;
|
|
607
|
+
if (!result.valid && result.mode === 'blocking') return 1;
|
|
608
|
+
return 0;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
module.exports = {
|
|
612
|
+
checkHarnessArtifact,
|
|
613
|
+
formatHarnessArtifactResult,
|
|
614
|
+
getHarnessArtifactExitCode,
|
|
615
|
+
loadContract,
|
|
616
|
+
ownershipScopesOverlap,
|
|
617
|
+
validateEvent,
|
|
618
|
+
validateEvents,
|
|
619
|
+
validateManifest,
|
|
620
|
+
validateMessage,
|
|
621
|
+
};
|