thoth-agents 0.6.0 → 0.8.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 (85) hide show
  1. package/README.md +7 -1
  2. package/dist/agents/prompt-dialects.d.ts +3 -1
  3. package/dist/{chunk-OW6I7ZUU.js → chunk-4DZSWTUM.js} +337 -155
  4. package/dist/{chunk-7YCSKK2X.js → chunk-5LW4CNA5.js} +4053 -2989
  5. package/dist/{chunk-U7FNFTM5.js → chunk-P7R4GDSO.js} +39 -38
  6. package/dist/{chunk-MRFCA3U2.js → chunk-ZLEMG37R.js} +2 -1
  7. package/dist/cli/codex-config-io.d.ts +34 -2
  8. package/dist/cli/codex-hook-consent.d.ts +5 -0
  9. package/dist/cli/codex-hook-trust.d.ts +23 -0
  10. package/dist/cli/codex-install.d.ts +21 -6
  11. package/dist/cli/codex-lifecycle.d.ts +13 -0
  12. package/dist/cli/codex-plugin-install.d.ts +21 -0
  13. package/dist/cli/index.js +538 -43
  14. package/dist/cli/managed-state-io.d.ts +19 -0
  15. package/dist/cli/operations/codex.d.ts +2 -0
  16. package/dist/cli/operations/types.d.ts +1 -1
  17. package/dist/cli/pi-install.d.ts +1 -0
  18. package/dist/cli/safe-atomic-write.d.ts +2 -0
  19. package/dist/cli/tui/index.js +56 -5
  20. package/dist/cli/tui/operations.d.ts +3 -1
  21. package/dist/cli/types.d.ts +4 -1
  22. package/dist/config/constants.d.ts +30 -3
  23. package/dist/config/schema.d.ts +4 -0
  24. package/dist/harness/adapters/claude-code.d.ts +1 -1
  25. package/dist/harness/adapters/codex-legacy-agents.d.ts +31 -0
  26. package/dist/harness/adapters/codex-surfaces.d.ts +1 -1
  27. package/dist/harness/adapters/codex.d.ts +3 -1
  28. package/dist/harness/codex-hook-script.d.ts +2 -0
  29. package/dist/harness/core/agent-pack.d.ts +8 -2
  30. package/dist/harness/core/result-contract.d.ts +366 -0
  31. package/dist/harness/core/sdd.d.ts +1 -1
  32. package/dist/harness/writers/codex-plugin-package.d.ts +7 -0
  33. package/dist/harness/writers/pi-agent.d.ts +1 -0
  34. package/dist/index.js +2 -2
  35. package/dist/pi.js +2 -2
  36. package/package.json +5 -3
  37. package/pi/.thoth-agents-assets.json +5 -5
  38. package/pi/agents/thoth-designer.md +15 -20
  39. package/pi/agents/thoth-explorer.md +15 -19
  40. package/pi/agents/thoth-librarian.md +15 -19
  41. package/pi/agents/thoth-oracle.md +30 -22
  42. package/pi/agents/thoth-worker.md +13 -18
  43. package/plugin/.claude-plugin/.thoth-agents-plugin-assets.json +6 -6
  44. package/plugin/.claude-plugin/plugin.json +1 -1
  45. package/plugin/.codex-plugin/.thoth-agents-plugin-assets.json +48 -0
  46. package/plugin/.codex-plugin/plugin.json +3 -2
  47. package/plugin/agents/designer.md +9 -14
  48. package/plugin/agents/explorer.md +10 -14
  49. package/plugin/agents/librarian.md +9 -13
  50. package/plugin/agents/oracle.md +24 -18
  51. package/plugin/agents/orchestrator.md +38 -61
  52. package/plugin/agents/worker.md +9 -14
  53. package/plugin/codex-hooks/context.cjs +22 -0
  54. package/plugin/codex-hooks/hooks.json +32 -0
  55. package/plugin/codex-hooks/payloads/designer.md +72 -0
  56. package/plugin/codex-hooks/payloads/explorer.md +76 -0
  57. package/plugin/codex-hooks/payloads/librarian.md +76 -0
  58. package/plugin/codex-hooks/payloads/oracle.md +83 -0
  59. package/plugin/codex-hooks/payloads/root.md +105 -0
  60. package/plugin/codex-hooks/payloads/worker.md +72 -0
  61. package/plugin/skills/plan-reviewer/SKILL.md +16 -8
  62. package/plugin/skills/thoth-archive/SKILL.md +2 -1
  63. package/plugin/skills/thoth-constitution/SKILL.md +1 -1
  64. package/plugin/skills/thoth-constitution/templates/constitution.md +1 -1
  65. package/plugin/skills/thoth-init/SKILL.md +1 -1
  66. package/plugin/skills/thoth-sdd/SKILL.md +67 -2
  67. package/plugin/skills/thoth-sdd/references/phases/verify.md +11 -1
  68. package/plugin/skills/thoth-sdd/scripts/result-contract.json +210 -0
  69. package/plugin/skills/thoth-sdd/scripts/validate-result.d.mts +47 -0
  70. package/plugin/skills/thoth-sdd/scripts/validate-result.mjs +390 -0
  71. package/plugin/skills/thoth-sdd/scripts/validate.mjs +213 -8
  72. package/plugin/skills/thoth-sdd/templates/change.md +8 -0
  73. package/skills/plan-reviewer/SKILL.md +16 -8
  74. package/skills/thoth-archive/SKILL.md +2 -1
  75. package/skills/thoth-constitution/SKILL.md +1 -1
  76. package/skills/thoth-constitution/templates/constitution.md +1 -1
  77. package/skills/thoth-init/SKILL.md +1 -1
  78. package/skills/thoth-sdd/SKILL.md +67 -2
  79. package/skills/thoth-sdd/references/phases/verify.md +11 -1
  80. package/skills/thoth-sdd/scripts/result-contract.json +210 -0
  81. package/skills/thoth-sdd/scripts/validate-result.d.mts +47 -0
  82. package/skills/thoth-sdd/scripts/validate-result.mjs +390 -0
  83. package/skills/thoth-sdd/scripts/validate.mjs +213 -8
  84. package/skills/thoth-sdd/templates/change.md +8 -0
  85. package/thoth-agents.schema.json +14 -0
@@ -0,0 +1,390 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const contract = JSON.parse(
6
+ readFileSync(new URL('./result-contract.json', import.meta.url), 'utf8'),
7
+ );
8
+ const childPath = (path, key) =>
9
+ `${path}/${String(key).replaceAll('~', '~0').replaceAll('/', '~1')}`;
10
+
11
+ /** Parse JSON ourselves so duplicate keys cannot be erased by JSON.parse. */
12
+ function parseStrictJSON(source) {
13
+ let cursor = 0;
14
+ const fail = (code, path, message) => {
15
+ throw { code, path, message };
16
+ };
17
+ const whitespace = () => {
18
+ while (/[\t\n\r ]/.test(source[cursor] ?? '\0')) cursor++;
19
+ };
20
+ const syntax = (path) =>
21
+ fail('JSON_SYNTAX', path, `Invalid JSON at offset ${cursor}.`);
22
+ function string(path) {
23
+ const start = cursor++;
24
+ while (cursor < source.length) {
25
+ const char = source[cursor++];
26
+ if (char === '\\') cursor++;
27
+ else if (char === '"') {
28
+ try {
29
+ return JSON.parse(source.slice(start, cursor));
30
+ } catch {
31
+ syntax(path);
32
+ }
33
+ }
34
+ }
35
+ syntax(path);
36
+ }
37
+ function value(path, depth) {
38
+ if (depth > 256)
39
+ fail('JSON_SYNTAX', path, 'JSON nesting exceeds 256 levels.');
40
+ whitespace();
41
+ const char = source[cursor];
42
+ if (char === '"') return string(path);
43
+ if (char === '{' || char === '[') {
44
+ const isObject = char === '{';
45
+ const close = isObject ? '}' : ']';
46
+ const result = isObject ? Object.create(null) : [];
47
+ const keys = new Set();
48
+ cursor++;
49
+ whitespace();
50
+ if (source[cursor] === close) {
51
+ cursor++;
52
+ return result;
53
+ }
54
+ for (;;) {
55
+ let key = result.length;
56
+ if (isObject) {
57
+ if (source[cursor] !== '"') syntax(path);
58
+ key = string(path);
59
+ if (keys.has(key))
60
+ fail(
61
+ 'JSON_DUPLICATE_KEY',
62
+ childPath(path, key),
63
+ `Duplicate key ${JSON.stringify(key)}.`,
64
+ );
65
+ keys.add(key);
66
+ whitespace();
67
+ if (source[cursor++] !== ':') syntax(path);
68
+ }
69
+ result[key] = value(childPath(path, key), depth + 1);
70
+ whitespace();
71
+ if (source[cursor] === close) {
72
+ cursor++;
73
+ return result;
74
+ }
75
+ if (source[cursor++] !== ',') syntax(path);
76
+ whitespace();
77
+ }
78
+ }
79
+ const token =
80
+ /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(
81
+ source.slice(cursor),
82
+ );
83
+ if (!token) syntax(path);
84
+ cursor += token[0].length;
85
+ const result = JSON.parse(token[0]);
86
+ if (typeof result === 'number' && !Number.isFinite(result)) syntax(path);
87
+ return result;
88
+ }
89
+ const result = value('', 0);
90
+ whitespace();
91
+ if (cursor !== source.length) syntax('');
92
+ return result;
93
+ }
94
+
95
+ function checkExpectations(options, issue) {
96
+ if (
97
+ options.noRecord &&
98
+ (options.recordSha256 !== undefined ||
99
+ (options.reviewKind !== undefined &&
100
+ options.reviewKind !== contract.rules.recordBindingReviewKind))
101
+ )
102
+ issue(
103
+ 'EXPECTATION_INVALID',
104
+ '',
105
+ 'No-record mode requires final-verification without an expected digest.',
106
+ );
107
+ const expectations = [
108
+ ['role', Object.keys(contract.roles)],
109
+ ['reviewKind', Object.keys(contract.reviewKinds)],
110
+ ];
111
+ for (const [key, values] of expectations) {
112
+ if (options[key] !== undefined && !values.includes(options[key]))
113
+ issue('EXPECTATION_INVALID', '', `Unknown expected ${key}.`);
114
+ }
115
+ if (
116
+ options.recordSha256 !== undefined &&
117
+ !/^[a-f0-9]{64}$/.test(options.recordSha256)
118
+ )
119
+ issue(
120
+ 'EXPECTATION_INVALID',
121
+ '',
122
+ 'Expected digest must be 64 lowercase hex characters.',
123
+ );
124
+ }
125
+
126
+ export function validateResult(message, options = {}) {
127
+ const issues = [];
128
+ const issue = (code, path, text) =>
129
+ issues.push({ code, path, message: text });
130
+ const invalid = () => ({ valid: false, issues });
131
+ checkExpectations(options, issue);
132
+ if (issues.length) return invalid();
133
+ const trimmed = typeof message === 'string' ? message.trim() : '';
134
+ const block = new RegExp(
135
+ `^\`\`\`${contract.fence}\\r?\\n([\\s\\S]*?)\\r?\\n\`\`\`$`,
136
+ ).exec(trimmed);
137
+ if (!block || (trimmed.match(/^```.*$/gm) ?? []).length !== 2) {
138
+ issue(
139
+ 'ENVELOPE_FORMAT',
140
+ '',
141
+ 'Expected exactly one thoth-result fence and no surrounding prose.',
142
+ );
143
+ return invalid();
144
+ }
145
+ let envelope;
146
+ try {
147
+ envelope = parseStrictJSON(block[1]);
148
+ } catch (error) {
149
+ issues.push(error);
150
+ return invalid();
151
+ }
152
+ function check(value, schema, path) {
153
+ const type =
154
+ value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
155
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
156
+ if (!types.includes(type)) {
157
+ issue('TYPE_MISMATCH', path, `Expected ${types.join(' or ')}.`);
158
+ return;
159
+ }
160
+ if ('const' in schema && value !== schema.const)
161
+ issue('INVALID_VALUE', path, 'Value differs from required constant.');
162
+ if (schema.enum && !schema.enum.includes(value))
163
+ issue('INVALID_VALUE', path, 'Value is not in the allowed enumeration.');
164
+ if (type === 'string') {
165
+ if (schema.minLength && value.length < schema.minLength)
166
+ issue('INVALID_VALUE', path, 'String must not be empty.');
167
+ if (schema.pattern && !new RegExp(schema.pattern).test(value))
168
+ issue(
169
+ 'INVALID_VALUE',
170
+ path,
171
+ 'String does not match the required pattern.',
172
+ );
173
+ }
174
+ if (type === 'array') {
175
+ value.forEach((item, index) => {
176
+ check(item, schema.items, childPath(path, index));
177
+ });
178
+ }
179
+ if (type === 'object') {
180
+ for (const key of schema.required)
181
+ if (!Object.hasOwn(value, key))
182
+ issue(
183
+ 'REQUIRED_FIELD',
184
+ childPath(path, key),
185
+ 'Required field is missing.',
186
+ );
187
+ for (const key of Object.keys(value)) {
188
+ if (!Object.hasOwn(schema.properties, key))
189
+ issue(
190
+ 'UNDECLARED_FIELD',
191
+ childPath(path, key),
192
+ 'Undeclared field is forbidden.',
193
+ );
194
+ else check(value[key], schema.properties[key], childPath(path, key));
195
+ }
196
+ }
197
+ }
198
+ const roleSchema =
199
+ envelope &&
200
+ typeof envelope.role === 'string' &&
201
+ Object.hasOwn(contract.roles, envelope.role)
202
+ ? contract.roles[envelope.role]
203
+ : { properties: {}, required: [] };
204
+ check(
205
+ envelope,
206
+ {
207
+ ...contract.common,
208
+ properties: { ...contract.common.properties, ...roleSchema.properties },
209
+ required: [...contract.common.required, ...roleSchema.required],
210
+ },
211
+ '',
212
+ );
213
+ if (issues.length) return invalid();
214
+ if (
215
+ options.noRecord &&
216
+ envelope.review.kind !== contract.rules.recordBindingReviewKind
217
+ ) {
218
+ issue(
219
+ 'EXPECTATION_INVALID',
220
+ '/review/kind',
221
+ 'No-record mode requires final-verification.',
222
+ );
223
+ return invalid();
224
+ }
225
+ if (options.role && envelope.role !== options.role)
226
+ issue('ROLE_MISMATCH', '/role', 'Role does not match assignment.');
227
+ if (options.reviewKind && envelope.review.kind !== options.reviewKind)
228
+ issue(
229
+ 'REVIEW_KIND_MISMATCH',
230
+ '/review/kind',
231
+ 'Review kind does not match assignment.',
232
+ );
233
+ const ids = new Set();
234
+ envelope.evidence.forEach((entry, index) => {
235
+ if (contract.rules.evidenceIdUnique && ids.has(entry.id))
236
+ issue(
237
+ 'EVIDENCE_ID_DUPLICATE',
238
+ `/evidence/${index}/id`,
239
+ 'Evidence ids must be unique.',
240
+ );
241
+ ids.add(entry.id);
242
+ });
243
+ for (const field of contract.rules.evidenceReferences) {
244
+ envelope[field].forEach((entry, index) => {
245
+ entry.evidenceIds.forEach((id, ref) => {
246
+ if (!ids.has(id))
247
+ issue(
248
+ 'EVIDENCE_REFERENCE',
249
+ `/${field}/${index}/evidenceIds/${ref}`,
250
+ 'Reference does not identify supplied evidence.',
251
+ );
252
+ });
253
+ });
254
+ }
255
+ const { kind, verdict } = envelope.review;
256
+ if (!contract.reviewKinds[kind].verdicts.includes(verdict))
257
+ issue(
258
+ 'VERDICT_MISMATCH',
259
+ '/review/verdict',
260
+ 'Verdict is not allowed for this review kind.',
261
+ );
262
+ if (contract.rules.positiveVerdicts.includes(verdict)) {
263
+ if (envelope.status !== contract.rules.positiveStatus)
264
+ issue(
265
+ 'VERDICT_STATUS',
266
+ '/status',
267
+ 'Positive verdict requires complete status.',
268
+ );
269
+ envelope.risks.forEach((risk, index) => {
270
+ if (!contract.rules.positiveAllowsBlockingRisks && risk.blocking)
271
+ issue(
272
+ 'BLOCKING_RISK',
273
+ `/risks/${index}/blocking`,
274
+ 'Positive verdict forbids blocking risks.',
275
+ );
276
+ });
277
+ }
278
+ if (options.noRecord) {
279
+ for (const key of contract.rules.recordBindingForbiddenUnderNoRecord) {
280
+ if (Object.hasOwn(envelope.review, key))
281
+ issue(
282
+ 'RECORD_BINDING_FORBIDDEN',
283
+ `/review/${key}`,
284
+ 'No-record mode forbids reviewed record binding.',
285
+ );
286
+ }
287
+ } else if (envelope.review.kind === contract.rules.recordBindingReviewKind) {
288
+ for (const key of contract.rules.recordBindingRequiredFields) {
289
+ if (!envelope.review[key])
290
+ issue(
291
+ 'RECORD_BINDING_REQUIRED',
292
+ `/review/${key}`,
293
+ 'Final verification requires reviewed record binding.',
294
+ );
295
+ }
296
+ }
297
+ if (options.recordSha256 !== undefined) {
298
+ if (envelope.review.kind !== contract.rules.recordBindingReviewKind)
299
+ issue(
300
+ 'REVIEW_KIND_MISMATCH',
301
+ '/review/kind',
302
+ 'Record binding requires final-verification.',
303
+ );
304
+ if (
305
+ envelope.review.reviewedRecordSha256 &&
306
+ envelope.review.reviewedRecordSha256 !== options.recordSha256
307
+ )
308
+ issue(
309
+ 'RECORD_DIGEST_MISMATCH',
310
+ '/review/reviewedRecordSha256',
311
+ 'Digest does not match assignment.',
312
+ );
313
+ }
314
+ return issues.length ? invalid() : { valid: true, envelope, issues };
315
+ }
316
+
317
+ export const RESULT_VALIDATOR_USAGE =
318
+ 'node validate-result.mjs [--role oracle] [--review-kind plan-review|final-verification] [--record-sha256 <hex>] [--no-record] [--input <path>]';
319
+ function main(args) {
320
+ const options = {};
321
+ let input;
322
+ const names = {
323
+ '--role': 'role',
324
+ '--review-kind': 'reviewKind',
325
+ '--record-sha256': 'recordSha256',
326
+ '--no-record': 'noRecord',
327
+ '--input': 'input',
328
+ };
329
+ const seen = new Set();
330
+ for (let index = 0; index < args.length; index++) {
331
+ const flag = args[index];
332
+ if (
333
+ !Object.hasOwn(names, flag) ||
334
+ seen.has(flag) ||
335
+ (flag !== '--no-record' &&
336
+ (!args[index + 1] || args[index + 1].startsWith('--')))
337
+ ) {
338
+ console.log(
339
+ JSON.stringify({
340
+ valid: false,
341
+ issues: [
342
+ { code: 'CLI_USAGE', path: '', message: RESULT_VALIDATOR_USAGE },
343
+ ],
344
+ }),
345
+ );
346
+ return 2;
347
+ }
348
+ seen.add(flag);
349
+ if (flag === '--no-record') {
350
+ options.noRecord = true;
351
+ continue;
352
+ }
353
+ const value = args[++index];
354
+ if (flag === '--input') input = value;
355
+ else options[names[flag]] = value;
356
+ }
357
+ const expectationIssues = [];
358
+ checkExpectations(options, (code, path, message) => {
359
+ expectationIssues.push({ code, path, message });
360
+ });
361
+ if (expectationIssues.length) {
362
+ console.log(JSON.stringify({ valid: false, issues: expectationIssues }));
363
+ return 2;
364
+ }
365
+ let message;
366
+ try {
367
+ message = readFileSync(input ?? 0, 'utf8');
368
+ } catch {
369
+ console.log(
370
+ JSON.stringify({
371
+ valid: false,
372
+ issues: [
373
+ { code: 'CLI_INPUT', path: '', message: 'Unable to read input.' },
374
+ ],
375
+ }),
376
+ );
377
+ return 2;
378
+ }
379
+ const result = validateResult(message, options);
380
+ console.log(JSON.stringify(result));
381
+ if (result.valid) return 0;
382
+ return result.issues.some((issue) => issue.code === 'EXPECTATION_INVALID')
383
+ ? 2
384
+ : 1;
385
+ }
386
+ if (
387
+ process.argv[1] &&
388
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
389
+ )
390
+ process.exitCode = main(process.argv.slice(2));
@@ -17,6 +17,7 @@ import {
17
17
  parseRequirementDelta,
18
18
  preflightRequirementDeltas,
19
19
  } from './durable-deltas.mjs';
20
+ import { validateResult } from './validate-result.mjs';
20
21
 
21
22
  const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
22
23
  const gates = [
@@ -261,10 +262,16 @@ function preflightDeltas(root, deltas, errors, warnings, recordName) {
261
262
  }
262
263
 
263
264
  function settled(text) {
264
- return (
265
- Boolean(text?.trim()) &&
266
- !/\b(?:pending|unresolved|unknown|ask|tbd|todo)\b/i.test(text)
267
- );
265
+ if (!text?.trim() || /\bTBD\b/.test(text)) return false;
266
+ return !text.split(/\r?\n/).some((line) => {
267
+ const content = line
268
+ .replace(/\*\*|__/g, '')
269
+ .trim()
270
+ .replace(/^(?:[-+*]|\d+[.)])\s+/, '')
271
+ .replace(/^\[[ xX]\]\s*/, '')
272
+ .replace(/^AC-\d+:\s*/, '');
273
+ return /^(?:TBD|PENDING)(?=:|\s*$)/i.test(content);
274
+ });
268
275
  }
269
276
 
270
277
  function coverageErrors(text, ids, errors, recordName) {
@@ -293,6 +300,184 @@ function coverageErrors(text, ids, errors, recordName) {
293
300
  return rows;
294
301
  }
295
302
 
303
+ // Whole-file textual binding: Markdown containers never hide candidates or markers.
304
+ function scanOracleEnvelope(text) {
305
+ let offset = 0;
306
+ const lines = text.split(/(?<=\n)/).map((raw) => {
307
+ const entry = { raw, offset, line: raw.replace(/\r?\n$/, '') };
308
+ if (offset === 0) entry.line = entry.line.replace(/^\uFEFF/, '');
309
+ offset += raw.length;
310
+ return entry;
311
+ });
312
+ const markers = [];
313
+ const fences = [];
314
+ const authorization = lines.findIndex(
315
+ ({ line }) => line === '## Authorization',
316
+ );
317
+ const verification = lines.findIndex(
318
+ ({ line }) => line === '## Verification',
319
+ );
320
+ const closeout = lines.findIndex(
321
+ ({ line }, index) => index > verification && line === '## Closeout',
322
+ );
323
+ const verificationEnd = lines.findIndex(
324
+ ({ line }, index) => index > verification && line.startsWith('## '),
325
+ );
326
+ for (const [index, { line }] of lines.entries()) {
327
+ if (line.trim() === '**Oracle envelope**:') markers.push(index);
328
+ const opening =
329
+ /^(\s*)(?:[>*+-]\s*|\d+[.)]\s*)*(`{3,}|~{3,})\s*thoth-result/i.exec(line);
330
+ if (!opening) continue;
331
+ let end = index + 1;
332
+ for (; end < lines.length; end++) {
333
+ const closing = /^(\s*)(`{3,}|~{3,})[ \t]*$/.exec(lines[end].line);
334
+ if (
335
+ closing &&
336
+ closing[1] === opening[1] &&
337
+ closing[2][0] === opening[2][0] &&
338
+ closing[2].length >= opening[2].length
339
+ )
340
+ break;
341
+ }
342
+ fences.push({
343
+ index,
344
+ end,
345
+ closed: end < lines.length,
346
+ block: lines
347
+ .slice(index, end + 1)
348
+ .map(({ raw }) => raw)
349
+ .join(''),
350
+ });
351
+ }
352
+ let next = markers[0] + 1;
353
+ while (next < lines.length && !lines[next].line.trim()) next++;
354
+ const positioned =
355
+ verification >= 0 &&
356
+ closeout > verification &&
357
+ markers[0] > verification &&
358
+ markers[0] < verificationEnd &&
359
+ fences[0]?.index === next &&
360
+ next < verificationEnd &&
361
+ (!fences[0]?.closed || fences[0].end < verificationEnd);
362
+ return {
363
+ markers,
364
+ fences,
365
+ positioned,
366
+ authorizationOffset: lines[authorization]?.offset,
367
+ };
368
+ }
369
+
370
+ function validateOracleEnvelope({
371
+ extraction,
372
+ verificationFields,
373
+ recordSha256,
374
+ reviewedDigest,
375
+ expectedRecord,
376
+ errors,
377
+ recordName,
378
+ }) {
379
+ const { markers, fences, positioned } = extraction;
380
+ if (markers.length > 1 || fences.length > 1) {
381
+ errors.push(
382
+ issue(
383
+ 'SDD-ORACLE-ENVELOPE-DUPLICATE',
384
+ 'Exactly one Oracle envelope marker and block are required',
385
+ recordName,
386
+ ),
387
+ );
388
+ return;
389
+ }
390
+ if (!markers.length || !fences.length || !positioned) {
391
+ errors.push(
392
+ issue(
393
+ 'SDD-ORACLE-ENVELOPE-MISSING',
394
+ 'Oracle envelope marker followed by a final-verification block is required',
395
+ recordName,
396
+ ),
397
+ );
398
+ return;
399
+ }
400
+ if (!fences[0].closed) {
401
+ errors.push(
402
+ issue(
403
+ 'SDD-ORACLE-ENVELOPE-INVALID',
404
+ 'Oracle envelope fence is unclosed',
405
+ recordName,
406
+ ),
407
+ );
408
+ return;
409
+ }
410
+ // Preserve the original wrapper and line endings for shared-contract validation.
411
+ const result = validateResult(fences[0].block, {
412
+ reviewKind: 'final-verification',
413
+ recordSha256: recordSha256,
414
+ });
415
+ if (!result.valid) {
416
+ errors.push(
417
+ issue(
418
+ 'SDD-ORACLE-ENVELOPE-INVALID',
419
+ result.issues
420
+ .map((entry) => `${entry.code}: ${entry.message}`)
421
+ .join('; '),
422
+ recordName,
423
+ ),
424
+ );
425
+ if (
426
+ result.issues.some((entry) => entry.code === 'RECORD_DIGEST_MISMATCH')
427
+ ) {
428
+ errors.push(
429
+ issue(
430
+ 'SDD-ORACLE-ENVELOPE-DIGEST-MISMATCH',
431
+ 'Oracle envelope digest does not match the reviewed record prefix',
432
+ recordName,
433
+ ),
434
+ );
435
+ }
436
+ return;
437
+ }
438
+ const { review } = result.envelope;
439
+ if (review.verdict !== 'PASS') {
440
+ errors.push(
441
+ issue(
442
+ 'SDD-ORACLE-ENVELOPE-NOT-PASS',
443
+ 'Oracle final-verification verdict must be PASS',
444
+ recordName,
445
+ ),
446
+ );
447
+ }
448
+ if (review.reviewedRecord !== expectedRecord) {
449
+ errors.push(
450
+ issue(
451
+ 'SDD-ORACLE-ENVELOPE-RECORD-MISMATCH',
452
+ `Oracle envelope must review ${expectedRecord}`,
453
+ recordName,
454
+ ),
455
+ );
456
+ }
457
+ if (review.reviewedRecordSha256 !== reviewedDigest) {
458
+ errors.push(
459
+ issue(
460
+ 'SDD-ORACLE-ENVELOPE-DIGEST-MISMATCH',
461
+ 'Oracle envelope and Reviewed record SHA-256 field must agree',
462
+ recordName,
463
+ ),
464
+ );
465
+ }
466
+ const verdictLines = verificationFields.match(/^\*\*Verdict\*\*:.*$/gm) ?? [];
467
+ if (
468
+ verdictLines.length !== 1 ||
469
+ verdictLines[0] !== `**Verdict**: ${review.verdict}`
470
+ ) {
471
+ errors.push(
472
+ issue(
473
+ 'SDD-ORACLE-ENVELOPE-VERDICT-MISMATCH',
474
+ 'Oracle envelope and Verdict field must agree',
475
+ recordName,
476
+ ),
477
+ );
478
+ }
479
+ }
480
+
296
481
  export function validate({ change, through }) {
297
482
  if (!gates.includes(through)) throw new Error('Invalid SDD validation gate');
298
483
  const errors = [];
@@ -358,7 +543,7 @@ export function validate({ change, through }) {
358
543
  !new RegExp(
359
544
  `^# Change: ${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`,
360
545
  'm',
361
- ).test(text) ||
546
+ ).test(text.replace(/^\uFEFF/, '')) ||
362
547
  !/^\*\*Classification\*\*: substantial$/m.test(text) ||
363
548
  !/^\*\*Scope\*\*: (?:local|coordinated|cross-cutting)$/m.test(text) ||
364
549
  !/^\*\*Uncertainty\*\*: (?:low|medium|high)$/m.test(text) ||
@@ -534,9 +719,18 @@ export function validate({ change, through }) {
534
719
  }
535
720
  const verificationText =
536
721
  section(text, 'Verification', { trim: false }) ?? '';
537
- const verified = verificationText.trim();
722
+ // Envelope contents are not record fields, AC rows, or reviewed source lines.
723
+ const extraction = scanOracleEnvelope(text);
724
+ const verificationFields =
725
+ through === 'closeout'
726
+ ? verificationText.replace(extraction.fences[0]?.block ?? '', '')
727
+ : verificationText;
728
+ const verified = verificationFields.trim();
538
729
  if (through === 'closeout') {
539
- const prefix = text.slice(0, authorizationHeading?.index ?? text.length);
730
+ const prefix = text.slice(
731
+ 0,
732
+ extraction.authorizationOffset ?? text.length,
733
+ );
540
734
  if (
541
735
  !/^\*\*Reviewer\*\*: oracle$/m.test(verified) ||
542
736
  !/^\*\*Independent from implementer\*\*: Yes$/m.test(verified) ||
@@ -551,7 +745,8 @@ export function validate({ change, through }) {
551
745
  );
552
746
  }
553
747
  const digestLines =
554
- verificationText.match(/^\*\*Reviewed record SHA-256\*\*:.*$/gm) ?? [];
748
+ verificationFields.match(/^\*\*Reviewed record SHA-256\*\*:.*$/gm) ??
749
+ [];
555
750
  const reviewedDigest =
556
751
  digestLines.length === 1
557
752
  ? /^\*\*Reviewed record SHA-256\*\*: ([a-f0-9]{64})$/.exec(
@@ -567,6 +762,16 @@ export function validate({ change, through }) {
567
762
  ),
568
763
  );
569
764
  }
765
+ validateOracleEnvelope({
766
+ extraction,
767
+ verificationFields,
768
+ recordSha256: sha(prefix),
769
+ reviewedDigest,
770
+ // Archived records retain the active path reviewed by the Oracle.
771
+ expectedRecord: `.thoth/changes/${id}/${id}.md`,
772
+ errors,
773
+ recordName,
774
+ });
570
775
  const rows = verified
571
776
  .split(/\r?\n/)
572
777
  .filter((line) => /^- AC-/.test(line));
@@ -82,6 +82,14 @@ exactly one SHA field line; put notes on separate lines below the fields.
82
82
  **Verdict**: PENDING
83
83
  **Reviewed record SHA-256**: PENDING
84
84
 
85
+ **Oracle envelope**:
86
+
87
+ ```thoth-result
88
+ PENDING
89
+ ```
90
+
91
+ After PASS, root pastes the Oracle's unchanged final-verification envelope here.
92
+
85
93
  - AC-1: PENDING | check | evidence
86
94
  - Source: relative/path | sha256:<actual-source-digest>
87
95
  - Source: .thoth/specs/<capability>/spec.md | sha256:<actual-source-digest>
@@ -207,6 +207,13 @@
207
207
  }
208
208
  ]
209
209
  },
210
+ "skills": {
211
+ "type": "array",
212
+ "items": {
213
+ "type": "string",
214
+ "minLength": 1
215
+ }
216
+ },
210
217
  "temperature": {
211
218
  "type": "number",
212
219
  "minimum": 0,
@@ -262,6 +269,13 @@
262
269
  }
263
270
  ]
264
271
  },
272
+ "skills": {
273
+ "type": "array",
274
+ "items": {
275
+ "type": "string",
276
+ "minLength": 1
277
+ }
278
+ },
265
279
  "temperature": {
266
280
  "type": "number",
267
281
  "minimum": 0,