ai-maestro 1.0.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 (81) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +15 -0
  3. package/README.md +727 -0
  4. package/bin/maestro.mjs +246 -0
  5. package/package.json +29 -0
  6. package/src/checkpoint.mjs +102 -0
  7. package/src/codex-diagnostics.mjs +682 -0
  8. package/src/codex-home-inspect.mjs +252 -0
  9. package/src/codex-home.mjs +258 -0
  10. package/src/commands.mjs +809 -0
  11. package/src/config.mjs +11 -0
  12. package/src/debug.mjs +164 -0
  13. package/src/encoding-guard.mjs +125 -0
  14. package/src/engines/ai-router-engine.mjs +37 -0
  15. package/src/engines/codex-engine.mjs +21 -0
  16. package/src/engines/engine.mjs +21 -0
  17. package/src/engines/registry.mjs +29 -0
  18. package/src/files.mjs +65 -0
  19. package/src/format.mjs +132 -0
  20. package/src/lock.mjs +439 -0
  21. package/src/memory-log.mjs +18 -0
  22. package/src/memory.mjs +1 -0
  23. package/src/orchestration/attempt-chain-runtime.mjs +627 -0
  24. package/src/orchestration/budget-manager.mjs +121 -0
  25. package/src/orchestration/capability-registry.mjs +108 -0
  26. package/src/orchestration/consolidator.mjs +772 -0
  27. package/src/orchestration/delegation-executor.mjs +262 -0
  28. package/src/orchestration/delegation-manager.mjs +116 -0
  29. package/src/orchestration/deployment-intent.mjs +36 -0
  30. package/src/orchestration/engine-history.mjs +110 -0
  31. package/src/orchestration/engine-policy.mjs +73 -0
  32. package/src/orchestration/engine-selector.mjs +187 -0
  33. package/src/orchestration/execution-context.mjs +45 -0
  34. package/src/orchestration/failure-classifier.mjs +136 -0
  35. package/src/orchestration/failure-evidence.mjs +237 -0
  36. package/src/orchestration/fallback-chain-lock.mjs +217 -0
  37. package/src/orchestration/fallback-chain-trail.mjs +218 -0
  38. package/src/orchestration/fallback-executor.mjs +146 -0
  39. package/src/orchestration/fallback-graph.mjs +106 -0
  40. package/src/orchestration/fallback-recommendation.mjs +73 -0
  41. package/src/orchestration/file-conflict-detector.mjs +126 -0
  42. package/src/orchestration/host-validation.mjs +241 -0
  43. package/src/orchestration/orchestration-loop.mjs +1971 -0
  44. package/src/orchestration/orchestration-runtime.mjs +1019 -0
  45. package/src/orchestration/orchestration-scheduler.mjs +135 -0
  46. package/src/orchestration/orchestration-trail.mjs +192 -0
  47. package/src/orchestration/preflight.mjs +212 -0
  48. package/src/orchestration/provider-health.mjs +566 -0
  49. package/src/orchestration/provider-router.mjs +352 -0
  50. package/src/orchestration/rc-check-adapters.mjs +817 -0
  51. package/src/orchestration/rc-check.mjs +544 -0
  52. package/src/orchestration/rc-policy.mjs +200 -0
  53. package/src/orchestration/runtime-gate.mjs +146 -0
  54. package/src/orchestration/sector-managers.mjs +215 -0
  55. package/src/orchestration/self-hosting-canary.mjs +231 -0
  56. package/src/orchestration/self-hosting-readiness.mjs +244 -0
  57. package/src/orchestration/spec-planner.mjs +877 -0
  58. package/src/orchestration/subtask-planner.mjs +176 -0
  59. package/src/orchestration/task-classifier.mjs +241 -0
  60. package/src/orchestration/verifier.mjs +1608 -0
  61. package/src/orchestration-commands.mjs +279 -0
  62. package/src/planner.mjs +38 -0
  63. package/src/project.mjs +1 -0
  64. package/src/run-diagnostics.mjs +641 -0
  65. package/src/runner.mjs +243 -0
  66. package/src/safety.mjs +116 -0
  67. package/src/schema.mjs +371 -0
  68. package/src/session-commands.mjs +521 -0
  69. package/src/shell.mjs +93 -0
  70. package/src/smart-planner.mjs +249 -0
  71. package/src/task-commands.mjs +1182 -0
  72. package/src/tasks.mjs +134 -0
  73. package/src/ui/commands.mjs +76 -0
  74. package/src/ui/events.mjs +45 -0
  75. package/src/ui/public/app.js +600 -0
  76. package/src/ui/public/index.html +88 -0
  77. package/src/ui/public/style.css +460 -0
  78. package/src/ui/server.mjs +112 -0
  79. package/src/ui/state.mjs +504 -0
  80. package/src/usage.mjs +178 -0
  81. package/src/workspace-diff.mjs +228 -0
@@ -0,0 +1,641 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { execFileRaw } from './shell.mjs';
4
+ import { stripAccents } from './smart-planner.mjs';
5
+ import { stripBom } from './files.mjs';
6
+
7
+ const ANALYSIS_ONLY_ACCEPTANCE_PATTERN = /nao alterar codigo/;
8
+ const ANALYSIS_ONLY_KEYWORD_PATTERN = /\b(mapear|analisar|identificar|localizar|diagnosticar|listar|relatorio)\b|nao alterar|sem alterar/;
9
+
10
+ export function isAnalysisOnlyTask(task) {
11
+ if (!task) {
12
+ return false;
13
+ }
14
+ if (task.requestKind === 'analysis-only') {
15
+ return true;
16
+ }
17
+ const acceptanceText = stripAccents((task.acceptance || []).join(' ').toLowerCase());
18
+ if (ANALYSIS_ONLY_ACCEPTANCE_PATTERN.test(acceptanceText)) {
19
+ return true;
20
+ }
21
+ const text = stripAccents(((task.title || '') + ' ' + (task.description || '')).toLowerCase());
22
+ return ANALYSIS_ONLY_KEYWORD_PATTERN.test(text);
23
+ }
24
+
25
+ const UNSUPPORTED_TOOL_PATTERNS = [
26
+ /unsupported call: apply_patch/i,
27
+ /cannot update goal because this thread has no goal/i,
28
+ /error=unsupported call/i,
29
+ // Codex's internal plan tool rejecting a status Maestro uses internally,
30
+ // e.g. "unknown variant `blocked`, expected one of `pending`,
31
+ // `in_progress`, `completed`". Maestro statuses must be mapped at the
32
+ // boundary (see mapStatusToCodexPlanVariant in task-commands.mjs); when
33
+ // this error still shows up, it must at least be flagged.
34
+ /unknown variant/i,
35
+ /ParserError/,
36
+ /MissingExpressionAfterOperator/
37
+ ];
38
+
39
+ export function detectUnsupportedToolCall(output) {
40
+ const text = String(output || '');
41
+ return UNSUPPORTED_TOOL_PATTERNS.some(pattern => pattern.test(text));
42
+ }
43
+
44
+ const CLAIM_PATTERN = /\b(updated|created|modified|alterado|atualizado|criado|completed|done)\b/i;
45
+
46
+ // Phrases where the worker states, in prose, that it did NOT or could NOT
47
+ // complete the task -- independent of exit code. A worker's own CLI process
48
+ // can exit 0 (it didn't crash) while its final answer plainly says it
49
+ // failed; that must never read as `completed`. Kept narrow and anchored to
50
+ // completion/action verbs so we don't flag unrelated uses of "failure",
51
+ // "falha" or "não foi possível" (e.g. "não foi possível reproduzir o bug",
52
+ // "a falha esperada no teste", "corrigi o erro que fazia o processo
53
+ // falhar") -- see tests for the exact false-positive cases this must avoid.
54
+ const EXPLICIT_FAILURE_PATTERNS = [
55
+ /\bnao consegui\b/i,
56
+ /\bnao foi possivel (conclu\w*|criar|executar|rodar|completar|finalizar|implementar|acessar|modificar|alterar)\b/i,
57
+ /\bfalhei ao\b/i,
58
+ /\ba implementacao nao foi realizada\b/i,
59
+ /\bunable to complete\b/i,
60
+ /\bcould not complete\b/i,
61
+ /\bfailed to implement\b/i,
62
+ /\bi was unable to\b/i,
63
+ /\bno changes were made\b/i,
64
+ /\bcould not access the filesystem\b/i,
65
+ /\bpermission denied\b/i,
66
+ /\bsandbox acl\b/i,
67
+ /\bworker failed\b/i,
68
+ /\bcould not (run|modify|create)\b/i,
69
+ // Hotfix A.2: the Windows-sandbox ACL failure and the way workers phrase
70
+ // it. "I cannot read/write ... files" is anchored to the first person so
71
+ // prose about a bug ("the parser cannot read files with BOM") does not
72
+ // trip it.
73
+ /\bi am blocked\b/i,
74
+ /\bi cannot (read|write|access|modify|create)\b/i,
75
+ /\bhelper_unknown_error\b/i,
76
+ /apply deny-read acls/i
77
+ ];
78
+
79
+ function splitSentences(text) {
80
+ const parts = String(text || '')
81
+ .split(/(?<=[.!?\n])\s+|\r?\n+/)
82
+ .map(part => part.trim())
83
+ .filter(Boolean);
84
+ return parts.length ? parts : [String(text || '')];
85
+ }
86
+
87
+ export function detectExplicitFailure(output) {
88
+ const evidence = [];
89
+ for (const sentence of splitSentences(output)) {
90
+ const normalized = stripAccents(sentence.toLowerCase());
91
+ if (EXPLICIT_FAILURE_PATTERNS.some(pattern => pattern.test(normalized))) {
92
+ evidence.push(sentence);
93
+ }
94
+ }
95
+ return { explicitWorkerFailure: evidence.length > 0, explicitFailureEvidence: evidence };
96
+ }
97
+
98
+ export function buildRunDiagnostics({ output, unsupportedToolCall, workspaceChanged, noChangeExpected = false }) {
99
+ const text = String(output || '');
100
+ const warnings = [];
101
+ if (unsupportedToolCall) {
102
+ warnings.push('worker used an unsupported tool call (e.g. apply_patch)');
103
+ }
104
+ const claimsSuccess = CLAIM_PATTERN.test(text);
105
+ // For an analysis-only or memory/documentation task, no workspace change is
106
+ // the expected, correct outcome, not a sign the worker is lying: analysis
107
+ // tasks aren't supposed to touch code, and memory-task success normally
108
+ // lives entirely inside .memory/, which is excluded from the workspace
109
+ // diff by design. Only flag it when the worker also used a tool that's
110
+ // known to fail.
111
+ const noVerifiedChange = noChangeExpected ? false : !workspaceChanged;
112
+ const suspectedFalsePositive = claimsSuccess && (unsupportedToolCall || noVerifiedChange);
113
+ if (suspectedFalsePositive) {
114
+ warnings.push('worker claimed success but the change could not be verified');
115
+ }
116
+ const { explicitWorkerFailure, explicitFailureEvidence } = detectExplicitFailure(text);
117
+ if (explicitWorkerFailure) {
118
+ warnings.push('worker explicitly reported it could not complete the task: ' + explicitFailureEvidence[0]);
119
+ }
120
+ return {
121
+ warnings,
122
+ unsupportedToolCall: !!unsupportedToolCall,
123
+ suspectedFalsePositive,
124
+ explicitWorkerFailure,
125
+ explicitFailureEvidence
126
+ };
127
+ }
128
+
129
+ // These match the actual PowerShell error text produced when a Unix
130
+ // command is really executed. Generic patterns like "cat >" or "<< EOF"
131
+ // are deliberately excluded: they also appear verbatim in our own worker
132
+ // prompt (as examples of what NOT to do), which Codex echoes back into
133
+ // its transcript, so matching them there flags the prompt itself instead
134
+ // of anything the worker actually ran.
135
+ const WINDOWS_SHELL_MISMATCH_PATTERNS = [
136
+ /não é possível localizar um parâmetro que coincida com o nome de parâmetro 'la'/i,
137
+ /tail\s*:\s*O termo 'tail' não é reconhecido/i,
138
+ /Operador '<' reservado para uso futuro/i,
139
+ /Especificação de arquivo ausente após o operador de redirecionamento/i
140
+ ];
141
+
142
+ export function detectWindowsShellMismatch(output) {
143
+ const text = String(output || '');
144
+ return WINDOWS_SHELL_MISMATCH_PATTERNS.some(pattern => pattern.test(text));
145
+ }
146
+
147
+ async function checkNodeSyntax(filePath) {
148
+ const result = await execFileRaw(process.execPath, ['--check', filePath], { quiet: true });
149
+ return result.code === 0 ? { ok: true } : { ok: false, error: summarizeError(result.stderr) };
150
+ }
151
+
152
+ async function checkJsonSyntax(filePath) {
153
+ try {
154
+ JSON.parse(stripBom(await fs.readFile(filePath, 'utf-8')));
155
+ return { ok: true };
156
+ } catch (error) {
157
+ return { ok: false, error: error.message };
158
+ }
159
+ }
160
+
161
+ async function checkPythonSyntax(filePath) {
162
+ try {
163
+ const result = await execFileRaw('python', ['-m', 'py_compile', filePath], { quiet: true });
164
+ return result.code === 0 ? { ok: true } : { ok: false, error: summarizeError(result.stderr) };
165
+ } catch (error) {
166
+ if (error.code === 'ENOENT') {
167
+ return { ok: true, skipped: true, error: 'python not available' };
168
+ }
169
+ return { ok: false, error: error.message };
170
+ }
171
+ }
172
+
173
+ async function checkNonEmptyFile(filePath) {
174
+ try {
175
+ const content = await fs.readFile(filePath, 'utf-8');
176
+ return content.trim().length > 0 ? { ok: true } : { ok: false, error: 'file is empty' };
177
+ } catch (error) {
178
+ return { ok: false, error: error.message };
179
+ }
180
+ }
181
+
182
+ function summarizeError(text) {
183
+ return String(text || '').split(/\r?\n/).filter(Boolean).slice(0, 3).join(' | ');
184
+ }
185
+
186
+ export async function validateChangedFile(relPath) {
187
+ const fullPath = path.resolve(relPath);
188
+ const ext = path.extname(relPath).toLowerCase();
189
+ let result;
190
+ if (ext === '.js' || ext === '.mjs') {
191
+ result = await checkNodeSyntax(fullPath);
192
+ } else if (ext === '.json') {
193
+ result = await checkJsonSyntax(fullPath);
194
+ } else if (ext === '.py') {
195
+ result = await checkPythonSyntax(fullPath);
196
+ } else if (ext === '.html' || ext === '.css') {
197
+ result = await checkNonEmptyFile(fullPath);
198
+ } else {
199
+ result = { ok: true, skipped: true };
200
+ }
201
+ return { path: relPath, type: ext || 'unknown', ...result };
202
+ }
203
+
204
+ export async function validateChangedFiles(filePaths = []) {
205
+ const syntaxValidation = await Promise.all(filePaths.map(validateChangedFile));
206
+ const syntaxValidationFailed = syntaxValidation.some(entry => entry.ok === false);
207
+ return { syntaxValidation, syntaxValidationFailed };
208
+ }
209
+
210
+ function normalizeRelPath(value) {
211
+ return String(value || '').replace(/\\/g, '/').replace(/^\.\//, '').trim();
212
+ }
213
+
214
+ function isExplicitFilePath(value) {
215
+ const normalized = normalizeRelPath(value);
216
+ return Boolean(normalized) && !normalized.endsWith('/') && path.extname(normalized).length > 0;
217
+ }
218
+
219
+ function isSafeRelativePath(value) {
220
+ const normalized = normalizeRelPath(value);
221
+ return Boolean(normalized) &&
222
+ !path.isAbsolute(normalized) &&
223
+ !normalized.split('/').includes('..') &&
224
+ !normalized.includes('\0');
225
+ }
226
+
227
+ function isMissingPathError(error) {
228
+ return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
229
+ }
230
+
231
+ function containmentPath(value) {
232
+ const resolved = path.resolve(value);
233
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
234
+ }
235
+
236
+ function isContainedPath(root, target) {
237
+ const relative = path.relative(containmentPath(root), containmentPath(target));
238
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
239
+ }
240
+
241
+ async function realPathForPossiblyMissingTarget(absolutePath) {
242
+ const targetPath = path.resolve(absolutePath);
243
+ let existingPath = targetPath;
244
+
245
+ while (true) {
246
+ try {
247
+ const realAncestor = await fs.realpath(existingPath);
248
+ const missingTail = path.relative(existingPath, targetPath);
249
+ if (missingTail && (missingTail.startsWith('..') || path.isAbsolute(missingTail))) {
250
+ return null;
251
+ }
252
+ return missingTail ? path.resolve(realAncestor, missingTail) : realAncestor;
253
+ } catch (error) {
254
+ if (!isMissingPathError(error)) {
255
+ return null;
256
+ }
257
+ const parent = path.dirname(existingPath);
258
+ if (parent === existingPath) {
259
+ return null;
260
+ }
261
+ existingPath = parent;
262
+ }
263
+ }
264
+ }
265
+
266
+ async function realContained(projectRoot, absolutePath) {
267
+ const logicalRoot = path.resolve(projectRoot);
268
+ const logicalTarget = path.resolve(absolutePath);
269
+ if (!isContainedPath(logicalRoot, logicalTarget)) {
270
+ return false;
271
+ }
272
+
273
+ let realRoot;
274
+ try {
275
+ realRoot = await fs.realpath(logicalRoot);
276
+ } catch (error) {
277
+ return false;
278
+ }
279
+
280
+ let realTarget;
281
+ try {
282
+ realTarget = await fs.realpath(logicalTarget);
283
+ } catch (error) {
284
+ if (!isMissingPathError(error)) {
285
+ return false;
286
+ }
287
+ realTarget = await realPathForPossiblyMissingTarget(logicalTarget);
288
+ }
289
+
290
+ return Boolean(realTarget) && isContainedPath(realRoot, realTarget);
291
+ }
292
+
293
+ function uniqueSorted(values) {
294
+ return [...new Set((values || []).filter(Boolean).map(String))].sort();
295
+ }
296
+
297
+ function normalizeTaskText(task) {
298
+ return stripAccents([
299
+ task && task.title,
300
+ task && task.description,
301
+ ...(Array.isArray(task && task.acceptance) ? task.acceptance : [])
302
+ ].filter(Boolean).join('\n').toLowerCase());
303
+ }
304
+
305
+ function requiredExportsFromTask(task) {
306
+ const exports = [];
307
+ const acceptance = Array.isArray(task && task.acceptance) ? task.acceptance : [];
308
+ for (const item of acceptance) {
309
+ const match = String(item || '').match(/\bExportar\s+([A-Za-z_$][\w$]*)\b/i);
310
+ if (match) exports.push(match[1]);
311
+ }
312
+ return exports;
313
+ }
314
+
315
+ async function requiredExportsFromSourceSpec(task, projectRoot) {
316
+ if (!task || !isSafeRelativePath(task.sourceSpec || '')) return [];
317
+ try {
318
+ const specPath = path.resolve(projectRoot, normalizeRelPath(task.sourceSpec));
319
+ const relative = path.relative(projectRoot, specPath);
320
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return [];
321
+ if (!await realContained(projectRoot, specPath)) return [];
322
+ const content = await fs.readFile(specPath, 'utf-8');
323
+ const exports = [];
324
+ const pattern = /^\s*export\s+function\s+([A-Za-z_$][\w$]*)\s*\(/gm;
325
+ let match;
326
+ while ((match = pattern.exec(content)) !== null) {
327
+ exports.push(match[1]);
328
+ }
329
+ return exports;
330
+ } catch (error) {
331
+ return [];
332
+ }
333
+ }
334
+
335
+ function hasExport(content, exportedName) {
336
+ const escaped = exportedName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
337
+ return new RegExp(`\\bexport\\s+(?:async\\s+)?function\\s+${escaped}\\s*\\(`).test(content) ||
338
+ new RegExp(`\\bexport\\s+(?:const|let|var)\\s+${escaped}\\b`).test(content) ||
339
+ new RegExp(`\\bexport\\s*\\{[^}]*\\b${escaped}\\b[^}]*\\}`).test(content);
340
+ }
341
+
342
+ function isTestFile(file) {
343
+ return /(\.test|\.spec)\.[cm]?[jt]s$/i.test(file) || file.startsWith('tests/');
344
+ }
345
+
346
+ function placeholderReasons(content) {
347
+ const normalized = stripAccents(String(content || '').toLowerCase());
348
+ const reasons = [];
349
+ const patterns = [
350
+ [/\bplaceholder\b/, 'placeholder marker present'],
351
+ [/\btodo\b/, 'TODO marker present'],
352
+ [/\bstub\b/, 'stub marker present'],
353
+ [/\bnot implemented\b/, 'not implemented marker present'],
354
+ [/\bimplementacao pendente\b/, 'pending implementation marker present'],
355
+ [/\bestrutura inicial\b/, 'initial structure marker present']
356
+ ];
357
+ for (const [pattern, reason] of patterns) {
358
+ if (pattern.test(normalized)) reasons.push(reason);
359
+ }
360
+ return reasons;
361
+ }
362
+
363
+ function countTestCalls(content) {
364
+ const matches = String(content || '').match(/\b(?:test|it)\s*\(/g);
365
+ return matches ? matches.length : 0;
366
+ }
367
+
368
+ function inspectFileContent({ file, content, taskText }) {
369
+ const failures = [];
370
+ const warnings = [];
371
+ const ext = path.extname(file).toLowerCase();
372
+ const trimmed = String(content || '').trim();
373
+ if (!trimmed) {
374
+ failures.push(`expected file is empty: ${file}`);
375
+ return { failures, warnings };
376
+ }
377
+
378
+ for (const reason of placeholderReasons(content)) {
379
+ failures.push(`${reason}: ${file}`);
380
+ }
381
+
382
+ if ((taskText.includes('date.now') || taskText.includes('timestamp') || taskText.includes('deterministic')) &&
383
+ /\b(Date\.now|new Date)\s*\(/.test(content)) {
384
+ failures.push(`non-deterministic clock usage in expected file: ${file}`);
385
+ }
386
+
387
+ if (ext === '.mjs' || ext === '.js') {
388
+ if (!/\bexport\b/.test(content) && !/\bimport\b/.test(content)) {
389
+ warnings.push(`module has no import/export evidence: ${file}`);
390
+ }
391
+ }
392
+
393
+ if (isTestFile(file)) {
394
+ if (!/\bnode:test\b/.test(content) && countTestCalls(content) === 0) {
395
+ failures.push(`test file has no executable test cases: ${file}`);
396
+ }
397
+ if (!/\bassert\b/.test(content) && !/\bexpect\s*\(/.test(content)) {
398
+ failures.push(`test file has no assertions: ${file}`);
399
+ }
400
+ }
401
+
402
+ return { failures, warnings };
403
+ }
404
+
405
+ export async function verifyDeliverableEvidence({
406
+ task,
407
+ projectRoot = process.cwd(),
408
+ changedFiles = [],
409
+ createdFiles = [],
410
+ deletedFiles = [],
411
+ outOfScopeChanges = [],
412
+ syntaxValidation = [],
413
+ hostValidationFallback = null,
414
+ noChangeExpected = false
415
+ } = {}) {
416
+ const expectedFiles = uniqueSorted((task && Array.isArray(task.files) ? task.files : [])
417
+ .map(normalizeRelPath)
418
+ .filter(isExplicitFilePath));
419
+ const changedSet = new Set(uniqueSorted([...changedFiles, ...createdFiles].map(normalizeRelPath)));
420
+ const deletedSet = new Set(uniqueSorted(deletedFiles.map(normalizeRelPath)));
421
+ const failures = [];
422
+ const warnings = [];
423
+ const acceptedEvidence = [];
424
+ const missingEvidence = [];
425
+ const readableContents = [];
426
+ const taskText = normalizeTaskText(task);
427
+
428
+ for (const file of uniqueSorted(outOfScopeChanges.map(normalizeRelPath))) {
429
+ failures.push(`out-of-scope change detected: ${file}`);
430
+ }
431
+
432
+ if (isAnalysisOnlyTask(task)) {
433
+ const outcome = failures.length > 0 ? 'rejected' : 'not_applicable';
434
+ return {
435
+ schemaVersion: 1,
436
+ outcome,
437
+ required: failures.length > 0,
438
+ expectedFiles,
439
+ requiredExports: [],
440
+ failures: uniqueSorted(failures),
441
+ warnings: uniqueSorted(warnings),
442
+ acceptedEvidence,
443
+ missingEvidence,
444
+ source: 'deliverable-verification'
445
+ };
446
+ }
447
+
448
+ if (expectedFiles.length === 0) {
449
+ if (noChangeExpected) {
450
+ const outcome = failures.length > 0 ? 'rejected' : 'not_applicable';
451
+ return {
452
+ schemaVersion: 1,
453
+ outcome,
454
+ required: failures.length > 0,
455
+ failures: uniqueSorted(failures),
456
+ warnings: uniqueSorted(warnings),
457
+ acceptedEvidence,
458
+ missingEvidence,
459
+ source: 'deliverable-verification'
460
+ };
461
+ }
462
+
463
+ const evidenceFiles = uniqueSorted([...changedFiles, ...createdFiles].map(normalizeRelPath).filter(isExplicitFilePath));
464
+ if (evidenceFiles.length === 0) {
465
+ missingEvidence.push('task declares no expected files and no workspace evidence was found');
466
+ if (hostValidationFallback && hostValidationFallback.used && hostValidationFallback.result === 'passed') {
467
+ warnings.push('host validation passed but did not provide workspace evidence for expected files');
468
+ }
469
+ const outcome = failures.length > 0 ? 'rejected' : 'inconclusive';
470
+ return {
471
+ schemaVersion: 1,
472
+ outcome,
473
+ required: true,
474
+ failures: uniqueSorted(failures),
475
+ warnings: uniqueSorted(warnings),
476
+ acceptedEvidence,
477
+ missingEvidence: uniqueSorted(missingEvidence),
478
+ source: 'deliverable-verification'
479
+ };
480
+ }
481
+
482
+ for (const file of evidenceFiles) {
483
+ if (!isSafeRelativePath(file)) {
484
+ failures.push(`unsafe workspace evidence path: ${file}`);
485
+ continue;
486
+ }
487
+ if (deletedSet.has(file)) {
488
+ failures.push(`workspace evidence file was deleted: ${file}`);
489
+ continue;
490
+ }
491
+
492
+ const absolutePath = path.resolve(projectRoot, file);
493
+ const relative = path.relative(projectRoot, absolutePath);
494
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
495
+ failures.push(`workspace evidence resolves outside workspace: ${file}`);
496
+ continue;
497
+ }
498
+ if (!await realContained(projectRoot, absolutePath)) {
499
+ failures.push(`workspace evidence resolves outside workspace: ${file}`);
500
+ continue;
501
+ }
502
+
503
+ try {
504
+ const stat = await fs.stat(absolutePath);
505
+ if (!stat.isFile()) {
506
+ failures.push(`workspace evidence path is not a file: ${file}`);
507
+ continue;
508
+ }
509
+ if (stat.size === 0) {
510
+ failures.push(`expected file is empty: ${file}`);
511
+ continue;
512
+ }
513
+ const content = await fs.readFile(absolutePath, 'utf-8');
514
+ const inspected = inspectFileContent({ file, content, taskText });
515
+ failures.push(...inspected.failures);
516
+ warnings.push(...inspected.warnings);
517
+ acceptedEvidence.push(`file:${file}:size:${stat.size}`);
518
+ } catch (error) {
519
+ failures.push(`workspace evidence file cannot be read: ${file}: ${error.message}`);
520
+ }
521
+ }
522
+
523
+ for (const entry of Array.isArray(syntaxValidation) ? syntaxValidation : []) {
524
+ if (entry && entry.ok === false && evidenceFiles.includes(normalizeRelPath(entry.path || entry.file))) {
525
+ failures.push(`syntax validation failed for workspace evidence file: ${normalizeRelPath(entry.path || entry.file)}`);
526
+ }
527
+ }
528
+
529
+ let outcome = 'verified';
530
+ if (failures.length > 0) {
531
+ outcome = 'rejected';
532
+ } else if (warnings.length > 0) {
533
+ outcome = 'verified_with_warnings';
534
+ }
535
+
536
+ return {
537
+ schemaVersion: 1,
538
+ outcome,
539
+ required: true,
540
+ failures: uniqueSorted(failures),
541
+ warnings: uniqueSorted(warnings),
542
+ acceptedEvidence: uniqueSorted(acceptedEvidence),
543
+ missingEvidence: uniqueSorted(missingEvidence),
544
+ source: 'deliverable-verification'
545
+ };
546
+ }
547
+
548
+ const requiredExports = uniqueSorted([
549
+ ...requiredExportsFromTask(task),
550
+ ...await requiredExportsFromSourceSpec(task, projectRoot)
551
+ ]);
552
+
553
+ for (const file of expectedFiles) {
554
+ if (!isSafeRelativePath(file)) {
555
+ failures.push(`unsafe expected file path: ${file}`);
556
+ continue;
557
+ }
558
+ if (deletedSet.has(file)) {
559
+ failures.push(`expected file was deleted: ${file}`);
560
+ continue;
561
+ }
562
+ if (!changedSet.has(file)) {
563
+ missingEvidence.push(`expected file has no workspace evidence: ${file}`);
564
+ }
565
+
566
+ const absolutePath = path.resolve(projectRoot, file);
567
+ const relative = path.relative(projectRoot, absolutePath);
568
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
569
+ failures.push(`expected file resolves outside workspace: ${file}`);
570
+ continue;
571
+ }
572
+ if (!await realContained(projectRoot, absolutePath)) {
573
+ failures.push(`expected file resolves outside workspace: ${file}`);
574
+ continue;
575
+ }
576
+
577
+ try {
578
+ const stat = await fs.stat(absolutePath);
579
+ if (!stat.isFile()) {
580
+ failures.push(`expected path is not a file: ${file}`);
581
+ continue;
582
+ }
583
+ if (stat.size === 0) {
584
+ failures.push(`expected file is empty: ${file}`);
585
+ continue;
586
+ }
587
+ const content = await fs.readFile(absolutePath, 'utf-8');
588
+ readableContents.push({ file, content });
589
+ const inspected = inspectFileContent({ file, content, taskText });
590
+ failures.push(...inspected.failures);
591
+ warnings.push(...inspected.warnings);
592
+ acceptedEvidence.push(`file:${file}:size:${stat.size}`);
593
+ } catch (error) {
594
+ failures.push(`expected file cannot be read: ${file}: ${error.message}`);
595
+ }
596
+ }
597
+
598
+ const exportCandidateFiles = expectedFiles.filter(file =>
599
+ ['.mjs', '.js'].includes(path.extname(file).toLowerCase()) && !isTestFile(file)
600
+ );
601
+ if (exportCandidateFiles.length > 0) {
602
+ const exportCandidateContents = readableContents.filter(entry => exportCandidateFiles.includes(entry.file));
603
+ for (const exportedName of requiredExports) {
604
+ if (!exportCandidateContents.some(entry => hasExport(entry.content, exportedName))) {
605
+ failures.push(`required export missing in expected implementation files: ${exportedName}`);
606
+ }
607
+ }
608
+ }
609
+
610
+ for (const entry of Array.isArray(syntaxValidation) ? syntaxValidation : []) {
611
+ if (entry && entry.ok === false && expectedFiles.includes(normalizeRelPath(entry.path || entry.file))) {
612
+ failures.push(`syntax validation failed for expected file: ${normalizeRelPath(entry.path || entry.file)}`);
613
+ }
614
+ }
615
+
616
+ if (hostValidationFallback && hostValidationFallback.used && hostValidationFallback.result === 'passed' && missingEvidence.length > 0) {
617
+ warnings.push('host validation passed but did not provide workspace evidence for expected files');
618
+ }
619
+
620
+ let outcome = 'verified';
621
+ if (failures.length > 0) {
622
+ outcome = 'rejected';
623
+ } else if (missingEvidence.length > 0) {
624
+ outcome = 'inconclusive';
625
+ } else if (warnings.length > 0) {
626
+ outcome = 'verified_with_warnings';
627
+ }
628
+
629
+ return {
630
+ schemaVersion: 1,
631
+ outcome,
632
+ required: true,
633
+ expectedFiles,
634
+ requiredExports,
635
+ failures: uniqueSorted(failures),
636
+ warnings: uniqueSorted(warnings),
637
+ acceptedEvidence: uniqueSorted(acceptedEvidence),
638
+ missingEvidence: uniqueSorted(missingEvidence),
639
+ source: 'deliverable-verification'
640
+ };
641
+ }