@wundam/orchex 1.0.0-rc.1 → 1.0.0-rc.2

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/dist/artifacts.js CHANGED
@@ -4,7 +4,8 @@ import { StreamArtifactSchema } from './types.js';
4
4
  import { getArtifactPath, loadManifest } from './manifest.js';
5
5
  import { checkOwnership as checkOwnershipEnhanced, } from './ownership.js';
6
6
  import { createLogger } from './logging.js';
7
- import { runCommands } from './commands.js';
7
+ import { runCommand, runCommands } from './commands.js';
8
+ import { jsonrepair } from 'jsonrepair';
8
9
  const log = createLogger('artifacts');
9
10
  // ============================================================================
10
11
  // Read & Validate
@@ -296,10 +297,11 @@ function repairInvalidEscapes(json) {
296
297
  }
297
298
  /**
298
299
  * Parse JSON string, normalize, and validate against schema.
299
- * Returns null on any failure.
300
+ * Returns null artifact on any failure.
300
301
  */
301
302
  function parseAndValidate(jsonStr, wasRepaired) {
302
303
  let parsed;
304
+ let usedJsonRepair = false;
303
305
  // Attempt 1: Direct parse
304
306
  try {
305
307
  parsed = JSON.parse(jsonStr);
@@ -314,19 +316,29 @@ function parseAndValidate(jsonStr, wasRepaired) {
314
316
  catch { /* still invalid */ }
315
317
  }
316
318
  }
319
+ // Attempt 3: jsonrepair — handles trailing commas, single quotes, unquoted keys, comments
320
+ if (!parsed) {
321
+ try {
322
+ const repaired = jsonrepair(jsonStr);
323
+ parsed = JSON.parse(repaired);
324
+ usedJsonRepair = true;
325
+ log.info('artifact_json_repaired: jsonrepair recovered malformed JSON');
326
+ }
327
+ catch { /* jsonrepair could not fix it either */ }
328
+ }
317
329
  if (!parsed) {
318
330
  if (!wasRepaired) {
319
331
  log.warn('Artifact block contains invalid JSON');
320
332
  }
321
- return null;
333
+ return { artifact: null, usedJsonRepair: false };
322
334
  }
323
335
  normalizeArtifactInput(parsed);
324
336
  const result = StreamArtifactSchema.safeParse(parsed);
325
337
  if (!result.success) {
326
338
  log.warn({ issues: result.error.issues, rawKeys: Object.keys(parsed) }, 'Artifact validation failed after normalization');
327
- return null;
339
+ return { artifact: null, usedJsonRepair: false };
328
340
  }
329
- return result.data;
341
+ return { artifact: result.data, usedJsonRepair };
330
342
  }
331
343
  /**
332
344
  * Extract artifact JSON from an agent's response text with full diagnostics.
@@ -337,9 +349,9 @@ export function extractArtifactWithDiagnostics(agentOutput) {
337
349
  // Strategy 1: Exact match (current behavior, fast path)
338
350
  const exactMatch = agentOutput.match(/```orchex-artifact\s*\n([\s\S]*?)\n?```/);
339
351
  if (exactMatch) {
340
- const artifact = parseAndValidate(exactMatch[1].trim(), false);
352
+ const { artifact, usedJsonRepair } = parseAndValidate(exactMatch[1].trim(), false);
341
353
  if (artifact) {
342
- return { artifact, diagnostic: { strategy: 'exact', jsonRepaired: false, rawLength } };
354
+ return { artifact, diagnostic: { strategy: 'exact', jsonRepaired: usedJsonRepair, rawLength } };
343
355
  }
344
356
  // Matched the fence but JSON was invalid — report as exact strategy with error
345
357
  return {
@@ -368,17 +380,17 @@ export function extractArtifactWithDiagnostics(agentOutput) {
368
380
  // Remove trailing ``` if present (could be at end of string without newline)
369
381
  jsonContent = jsonContent.replace(/```\s*$/, '').trim();
370
382
  // Try parsing as-is first
371
- const directArtifact = parseAndValidate(jsonContent, false);
372
- if (directArtifact) {
373
- return { artifact: directArtifact, diagnostic: { strategy: 'fence_recovery', jsonRepaired: false, rawLength } };
383
+ const directResult = parseAndValidate(jsonContent, false);
384
+ if (directResult.artifact) {
385
+ return { artifact: directResult.artifact, diagnostic: { strategy: 'fence_recovery', jsonRepaired: directResult.usedJsonRepair, rawLength } };
374
386
  }
375
387
  // Try repairing truncated JSON
376
388
  const repaired = repairTruncatedJson(jsonContent);
377
389
  if (repaired) {
378
- const repairedArtifact = parseAndValidate(repaired, true);
379
- if (repairedArtifact) {
390
+ const repairedResult = parseAndValidate(repaired, true);
391
+ if (repairedResult.artifact) {
380
392
  log.info('artifact_json_repaired: truncated JSON was recovered');
381
- return { artifact: repairedArtifact, diagnostic: { strategy: 'fence_recovery', jsonRepaired: true, rawLength } };
393
+ return { artifact: repairedResult.artifact, diagnostic: { strategy: 'fence_recovery', jsonRepaired: true, rawLength } };
382
394
  }
383
395
  }
384
396
  return {
@@ -432,6 +444,167 @@ export function checkOwnership(operations, owns, options) {
432
444
  export function checkOwnershipDetailed(operations, owns, options) {
433
445
  return checkOwnershipEnhanced(operations, owns, options);
434
446
  }
447
+ /**
448
+ * Check if TypeScript syntax validation is available in this project.
449
+ * Requires tsconfig.json to exist (indicates a TS project).
450
+ */
451
+ async function isTscAvailable(projectDir) {
452
+ try {
453
+ await fs.access(path.join(projectDir, 'tsconfig.json'));
454
+ return true;
455
+ }
456
+ catch {
457
+ return false;
458
+ }
459
+ }
460
+ /**
461
+ * Filter tsc output to only include error lines referencing specific files.
462
+ * tsc error format: `path/to/file.ts(line,col): error TSxxxx: message`
463
+ * Strips node_modules errors and errors from files not in the target set.
464
+ */
465
+ function filterTscErrors(output, targetFiles) {
466
+ return output
467
+ .split('\n')
468
+ .filter(line => {
469
+ // Skip blank lines and node_modules errors
470
+ if (!line.trim() || line.startsWith('node_modules/'))
471
+ return false;
472
+ // Match tsc error format: `relative/path.ts(line,col):`
473
+ const match = line.match(/^(.+?)\(\d+,\d+\):/);
474
+ if (!match)
475
+ return false;
476
+ return targetFiles.has(match[1]);
477
+ })
478
+ .join('\n');
479
+ }
480
+ /**
481
+ * Resolve which TS files fall inside tsconfig's `include` paths.
482
+ * Files outside include (e.g. tests/) need per-file fallback validation.
483
+ */
484
+ async function resolveTsconfigInclude(projectDir) {
485
+ try {
486
+ const raw = await fs.readFile(path.join(projectDir, 'tsconfig.json'), 'utf-8');
487
+ const tsconfig = JSON.parse(raw);
488
+ return tsconfig.include ?? [];
489
+ }
490
+ catch {
491
+ return [];
492
+ }
493
+ }
494
+ /**
495
+ * Validate syntax of files modified by artifact operations.
496
+ *
497
+ * Strategy:
498
+ * - JSON: in-process JSON.parse (fast, no child process)
499
+ * - JS/MJS/CJS: node --check (per-file syntax check)
500
+ * - TS/TSX inside tsconfig include: project-wide `tsc --noEmit --project tsconfig.json`,
501
+ * filtered to only report errors from stream-owned files. This respects skipLibCheck,
502
+ * global type augmentations (declare global), and include/exclude paths.
503
+ * - TS/TSX outside tsconfig include (e.g. tests/): per-file `tsc --noEmit
504
+ * --isolatedModules --skipLibCheck`, filtered to exclude node_modules errors.
505
+ *
506
+ * Returns valid:true if all files pass or have unsupported extensions.
507
+ */
508
+ export async function validateSyntax(projectDir, operations) {
509
+ const errors = [];
510
+ // Collect unique files to validate (skip deletes)
511
+ const filesToCheck = [...new Set(operations
512
+ .filter(op => op.type !== 'delete')
513
+ .map(op => op.path))];
514
+ // Partition TS files vs non-TS files
515
+ const tsFiles = [];
516
+ const otherFiles = [];
517
+ for (const f of filesToCheck) {
518
+ const ext = path.extname(f).toLowerCase();
519
+ if (ext === '.ts' || ext === '.tsx') {
520
+ tsFiles.push(f);
521
+ }
522
+ else {
523
+ otherFiles.push(f);
524
+ }
525
+ }
526
+ // Only attempt TS validation if the project has a tsconfig.json
527
+ const hasTsc = tsFiles.length > 0 ? await isTscAvailable(projectDir) : false;
528
+ // --- TypeScript validation (project-aware) ---
529
+ if (hasTsc && tsFiles.length > 0) {
530
+ const includePaths = await resolveTsconfigInclude(projectDir);
531
+ // Split TS files into "inside tsconfig include" vs "outside" (e.g. tests/)
532
+ const insideProject = [];
533
+ const outsideProject = [];
534
+ for (const f of tsFiles) {
535
+ const isIncluded = includePaths.length === 0 || includePaths.some(inc => f.startsWith(inc.replace(/\/?\*\*.*$/, '')));
536
+ if (isIncluded) {
537
+ insideProject.push(f);
538
+ }
539
+ else {
540
+ outsideProject.push(f);
541
+ }
542
+ }
543
+ // Project-wide tsc for files inside tsconfig include
544
+ if (insideProject.length > 0) {
545
+ const targetSet = new Set(insideProject);
546
+ const result = await runCommand('npx tsc --noEmit --project tsconfig.json', projectDir, { timeoutMs: 15000 });
547
+ if (!result.success) {
548
+ if (result.exitCode === null) {
549
+ log.debug('tsc_project_unavailable');
550
+ }
551
+ else {
552
+ const rawOutput = (result.stderr || result.stdout || '').trim();
553
+ const filtered = filterTscErrors(rawOutput, targetSet);
554
+ if (filtered.trim()) {
555
+ errors.push(`${insideProject.join(', ')}: syntax validation failed — ${filtered}`);
556
+ }
557
+ }
558
+ }
559
+ }
560
+ // Per-file fallback for files outside tsconfig include (tests, etc.)
561
+ for (const filePath of outsideProject) {
562
+ const fullPath = path.join(projectDir, filePath);
563
+ const result = await runCommand(`npx tsc --noEmit --isolatedModules --skipLibCheck "${fullPath}"`, projectDir, { timeoutMs: 5000 });
564
+ if (!result.success) {
565
+ if (result.exitCode === null) {
566
+ log.debug({ filePath }, 'syntax_validator_unavailable');
567
+ continue;
568
+ }
569
+ const rawOutput = (result.stderr || result.stdout || '').trim();
570
+ const targetSet = new Set([filePath, fullPath]);
571
+ const filtered = filterTscErrors(rawOutput, targetSet);
572
+ if (filtered.trim()) {
573
+ errors.push(`${filePath}: syntax validation failed — ${filtered}`);
574
+ }
575
+ }
576
+ }
577
+ }
578
+ // --- Non-TS file validation (JSON, JS) ---
579
+ for (const filePath of otherFiles) {
580
+ const ext = path.extname(filePath).toLowerCase();
581
+ const fullPath = path.join(projectDir, filePath);
582
+ // JSON: in-process parse (no child process needed)
583
+ if (ext === '.json') {
584
+ try {
585
+ const content = await fs.readFile(fullPath, 'utf-8');
586
+ JSON.parse(content);
587
+ }
588
+ catch (err) {
589
+ errors.push(`${filePath}: JSON syntax error — ${err instanceof Error ? err.message : err}`);
590
+ }
591
+ continue;
592
+ }
593
+ // JS: node --check
594
+ if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
595
+ const result = await runCommand(`node --check "${fullPath}"`, projectDir, { timeoutMs: 5000 });
596
+ if (!result.success) {
597
+ if (result.exitCode === null) {
598
+ log.debug({ filePath, error: result.error }, 'syntax_validator_unavailable');
599
+ continue;
600
+ }
601
+ const detail = (result.stderr || result.stdout || '').trim();
602
+ errors.push(`${filePath}: syntax validation failed — ${detail}`);
603
+ }
604
+ }
605
+ }
606
+ return { valid: errors.length === 0, errors };
607
+ }
435
608
  /**
436
609
  * Apply a stream's artifact to the project codebase.
437
610
  * Creates a backup before applying. Rolls back on error.
@@ -492,6 +665,18 @@ export async function applyArtifact(projectDir, streamId, options = {}) {
492
665
  await applyOperation(projectDir, op);
493
666
  appliedCount++;
494
667
  }
668
+ // Post-apply syntax validation — revert if broken syntax applied
669
+ const syntaxResult = await validateSyntax(projectDir, artifact.operations);
670
+ if (!syntaxResult.valid) {
671
+ log.warn({ streamId, errors: syntaxResult.errors }, 'syntax_validation_failed');
672
+ await revertStreamBackup(projectDir, backup);
673
+ return {
674
+ success: false,
675
+ streamId,
676
+ appliedOps: appliedCount,
677
+ error: `Syntax validation failed, rolled back: ${syntaxResult.errors.join('; ')}`,
678
+ };
679
+ }
495
680
  return { success: true, streamId, appliedOps: appliedCount };
496
681
  }
497
682
  catch (err) {
@@ -708,6 +893,138 @@ export async function createStreamBackup(projectDir, streamId, operations) {
708
893
  }
709
894
  return { streamId, entries, createdFiles };
710
895
  }
896
+ const BACKUPS_DIR = 'backups';
897
+ function backupsPath(projectDir) {
898
+ return path.join(projectDir, '.orchex', 'active', BACKUPS_DIR);
899
+ }
900
+ function backupFilePath(projectDir, streamId) {
901
+ return path.join(backupsPath(projectDir), `${streamId}.json`);
902
+ }
903
+ /**
904
+ * Persist a stream backup to disk as JSON.
905
+ * Stored in .orchex/active/backups/<streamId>.json
906
+ */
907
+ export async function writeBackup(projectDir, backup) {
908
+ const dir = backupsPath(projectDir);
909
+ await fs.mkdir(dir, { recursive: true });
910
+ await fs.writeFile(backupFilePath(projectDir, backup.streamId), JSON.stringify(backup), 'utf-8');
911
+ }
912
+ /**
913
+ * Read a single backup from disk. Returns null if not found.
914
+ */
915
+ export async function readBackup(projectDir, streamId) {
916
+ try {
917
+ const raw = await fs.readFile(backupFilePath(projectDir, streamId), 'utf-8');
918
+ return JSON.parse(raw);
919
+ }
920
+ catch {
921
+ return null;
922
+ }
923
+ }
924
+ /**
925
+ * Read all persisted backups from disk.
926
+ */
927
+ export async function readAllBackups(projectDir) {
928
+ const dir = backupsPath(projectDir);
929
+ try {
930
+ const files = await fs.readdir(dir);
931
+ const backups = [];
932
+ for (const file of files) {
933
+ if (!file.endsWith('.json'))
934
+ continue;
935
+ try {
936
+ const raw = await fs.readFile(path.join(dir, file), 'utf-8');
937
+ backups.push(JSON.parse(raw));
938
+ }
939
+ catch {
940
+ // Skip corrupt files
941
+ }
942
+ }
943
+ return backups;
944
+ }
945
+ catch {
946
+ return [];
947
+ }
948
+ }
949
+ /**
950
+ * Delete a single backup file.
951
+ */
952
+ export async function deleteBackup(projectDir, streamId) {
953
+ try {
954
+ await fs.unlink(backupFilePath(projectDir, streamId));
955
+ }
956
+ catch {
957
+ // Already gone — no-op
958
+ }
959
+ }
960
+ function isolationStatePath(projectDir) {
961
+ return path.join(projectDir, '.orchex', 'active', '.isolation-state');
962
+ }
963
+ /**
964
+ * Write isolation state to disk before each dangerous phase.
965
+ * If the process crashes, recovery can read this to know which
966
+ * stream was being tested and restore from its disk backup.
967
+ */
968
+ export async function writeIsolationState(projectDir, streamId, phase) {
969
+ const state = { streamId, phase, timestamp: new Date().toISOString() };
970
+ const dir = path.dirname(isolationStatePath(projectDir));
971
+ await fs.mkdir(dir, { recursive: true });
972
+ await fs.writeFile(isolationStatePath(projectDir), JSON.stringify(state), 'utf-8');
973
+ }
974
+ /**
975
+ * Read isolation state. Returns null if no state file exists.
976
+ */
977
+ export async function readIsolationState(projectDir) {
978
+ try {
979
+ const raw = await fs.readFile(isolationStatePath(projectDir), 'utf-8');
980
+ return JSON.parse(raw);
981
+ }
982
+ catch {
983
+ return null;
984
+ }
985
+ }
986
+ /**
987
+ * Clear isolation state after successful completion or recovery.
988
+ */
989
+ export async function clearIsolationState(projectDir) {
990
+ try {
991
+ await fs.unlink(isolationStatePath(projectDir));
992
+ }
993
+ catch {
994
+ // Already gone
995
+ }
996
+ }
997
+ /**
998
+ * Recover from a crash that happened during verify isolation.
999
+ * Reads .isolation-state to determine which stream was being tested,
1000
+ * then restores it from its disk backup.
1001
+ *
1002
+ * Returns true if recovery was performed, false if no recovery needed.
1003
+ */
1004
+ export async function recoverFromIsolationCrash(projectDir) {
1005
+ const state = await readIsolationState(projectDir);
1006
+ if (!state)
1007
+ return false;
1008
+ log.warn({ streamId: state.streamId, phase: state.phase }, 'Recovering from isolation crash');
1009
+ const backup = await readBackup(projectDir, state.streamId);
1010
+ if (!backup) {
1011
+ log.warn({ streamId: state.streamId }, 'No disk backup found for crashed isolation stream');
1012
+ await clearIsolationState(projectDir);
1013
+ return false;
1014
+ }
1015
+ // If we crashed during 'reverting' or 'testing', the files may be in a
1016
+ // reverted state. We need to restore the stream's changes.
1017
+ // If we crashed during 'restoring', the restore may be partial — re-apply.
1018
+ try {
1019
+ await restoreStreamBackup(projectDir, backup);
1020
+ log.info({ streamId: state.streamId }, 'Isolation crash recovery: stream restored from disk backup');
1021
+ }
1022
+ catch (err) {
1023
+ log.error({ streamId: state.streamId, error: err.message }, 'Isolation crash recovery failed');
1024
+ }
1025
+ await clearIsolationState(projectDir);
1026
+ return true;
1027
+ }
711
1028
  /**
712
1029
  * Revert a stream's changes using its backup.
713
1030
  * Restores modified files and removes created files.
@@ -771,27 +1088,80 @@ export async function restoreStreamBackup(projectDir, backup) {
771
1088
  // ============================================================================
772
1089
  // Verify Isolation
773
1090
  // ============================================================================
1091
+ /**
1092
+ * Execute a function with only the specified stream's changes on disk.
1093
+ * Temporarily reverts all other streams' backups, runs fn, then restores them.
1094
+ * Uses crash-recovery state machine (.isolation-state) for safety.
1095
+ *
1096
+ * If otherBackups is empty, fn is called directly without isolation.
1097
+ */
1098
+ export async function withStreamIsolation(projectDir, streamId, otherBackups, fn) {
1099
+ if (otherBackups.length === 0) {
1100
+ return fn();
1101
+ }
1102
+ // Save current state of files modified by other streams (for restore)
1103
+ const currentStates = [];
1104
+ try {
1105
+ // Phase 1: Revert all other streams' changes
1106
+ await writeIsolationState(projectDir, streamId, 'reverting');
1107
+ for (const backup of otherBackups) {
1108
+ const currentState = await createStreamBackup(projectDir, `${backup.streamId}-current`, backup.entries.map(e => ({ type: 'edit', path: e.path, edits: [] })));
1109
+ for (const createdFile of backup.createdFiles) {
1110
+ try {
1111
+ const content = await fs.readFile(path.join(projectDir, createdFile), 'utf-8');
1112
+ currentState.entries.push({ path: createdFile, content });
1113
+ }
1114
+ catch {
1115
+ // File doesn't exist
1116
+ }
1117
+ }
1118
+ currentStates.push(currentState);
1119
+ await revertStreamBackup(projectDir, backup);
1120
+ }
1121
+ // Phase 2: Run fn with only this stream's changes on disk
1122
+ await writeIsolationState(projectDir, streamId, 'testing');
1123
+ return await fn();
1124
+ }
1125
+ finally {
1126
+ // Phase 3: ALWAYS restore all other streams' changes
1127
+ await writeIsolationState(projectDir, streamId, 'restoring');
1128
+ for (const state of currentStates) {
1129
+ await restoreStreamBackup(projectDir, state);
1130
+ }
1131
+ await clearIsolationState(projectDir);
1132
+ }
1133
+ }
774
1134
  /**
775
1135
  * Isolate which stream(s) caused a verify failure by temporarily
776
1136
  * reverting each stream's changes and re-running its verify commands.
777
1137
  *
778
- * Returns a map of streamId → verdict:
779
- * - 'guilty': reverting this stream made verify pass
780
- * - 'innocent': reverting this stream didn't fix verify
781
- * - 'unknown': couldn't determine (interaction between streams)
1138
+ * Returns an IsolationResult with:
1139
+ * - verdicts: streamId → 'guilty' | 'innocent' | 'unknown'
1140
+ * - fileOverlaps: files modified by multiple streams (diagnostic hint)
782
1141
  */
783
- export async function isolateVerifyFailure(projectDir, backups, failedVerifyMap) {
1142
+ export async function isolateVerifyFailure(projectDir, backups, failedVerifyMap, options) {
784
1143
  const verdicts = new Map();
785
1144
  // Single stream: it's trivially the cause
786
1145
  if (backups.length === 1) {
787
1146
  verdicts.set(backups[0].streamId, 'guilty');
788
- return verdicts;
1147
+ return { verdicts, fileOverlaps: [] };
789
1148
  }
1149
+ const deadline = Date.now() + (options?.timeoutMs ?? 120_000);
790
1150
  // Collect all unique failed commands for cross-stream testing
791
1151
  const allFailedCmds = [...new Set([...failedVerifyMap.values()].flat())];
792
1152
  // For each stream, temporarily revert its changes and re-run verify
793
1153
  let foundGuilty = false;
794
1154
  for (const backup of backups) {
1155
+ // Check if we've exceeded the timeout
1156
+ if (Date.now() >= deadline) {
1157
+ // Mark all remaining untested streams as unknown
1158
+ for (const b of backups) {
1159
+ if (!verdicts.has(b.streamId)) {
1160
+ verdicts.set(b.streamId, 'unknown');
1161
+ }
1162
+ }
1163
+ break;
1164
+ }
795
1165
  // Save current state of files this stream modified
796
1166
  const currentState = await createStreamBackup(projectDir, `${backup.streamId}-current`, backup.entries.map(e => ({ type: 'edit', path: e.path, edits: [] })));
797
1167
  // Also track created files that currently exist
@@ -804,16 +1174,27 @@ export async function isolateVerifyFailure(projectDir, backups, failedVerifyMap)
804
1174
  // File doesn't exist, nothing to save
805
1175
  }
806
1176
  }
807
- // Revert this stream's changes
808
- await revertStreamBackup(projectDir, backup);
809
- // Use this stream's own failed verify commands if it has them,
810
- // otherwise use all failed commands (cross-stream check)
811
- const ownFailedCmds = failedVerifyMap.get(backup.streamId);
812
- const cmdsToTest = ownFailedCmds ?? allFailedCmds;
813
- const results = await runCommands(cmdsToTest, projectDir);
814
- const passed = results.every(r => r.success);
815
- // Restore this stream's changes
816
- await restoreStreamBackup(projectDir, currentState);
1177
+ let passed = false;
1178
+ try {
1179
+ // Revert this stream's changes
1180
+ await writeIsolationState(projectDir, backup.streamId, 'reverting');
1181
+ await revertStreamBackup(projectDir, backup);
1182
+ // Use this stream's own failed verify commands if it has them,
1183
+ // otherwise use all failed commands (cross-stream check)
1184
+ const ownFailedCmds = failedVerifyMap.get(backup.streamId);
1185
+ const cmdsToTest = ownFailedCmds ?? allFailedCmds;
1186
+ await writeIsolationState(projectDir, backup.streamId, 'testing');
1187
+ const remainingMs = deadline - Date.now();
1188
+ const perCmdTimeout = Math.max(remainingMs, 1000);
1189
+ const results = await runCommands(cmdsToTest, projectDir, { timeoutMs: perCmdTimeout });
1190
+ passed = results.every(r => r.success);
1191
+ }
1192
+ finally {
1193
+ // ALWAYS restore — even on timeout or error
1194
+ await writeIsolationState(projectDir, backup.streamId, 'restoring');
1195
+ await restoreStreamBackup(projectDir, currentState);
1196
+ }
1197
+ await clearIsolationState(projectDir);
817
1198
  if (passed) {
818
1199
  verdicts.set(backup.streamId, 'guilty');
819
1200
  foundGuilty = true;
@@ -828,5 +1209,27 @@ export async function isolateVerifyFailure(projectDir, backups, failedVerifyMap)
828
1209
  verdicts.set(backup.streamId, 'unknown');
829
1210
  }
830
1211
  }
831
- return verdicts;
1212
+ // Final cleanup — ensure state file is removed
1213
+ await clearIsolationState(projectDir);
1214
+ // Compute file overlaps for diagnostic hints
1215
+ const fileToStreams = new Map();
1216
+ for (const backup of backups) {
1217
+ for (const entry of backup.entries) {
1218
+ const streams = fileToStreams.get(entry.path) ?? [];
1219
+ streams.push(backup.streamId);
1220
+ fileToStreams.set(entry.path, streams);
1221
+ }
1222
+ for (const created of backup.createdFiles) {
1223
+ const streams = fileToStreams.get(created) ?? [];
1224
+ streams.push(backup.streamId);
1225
+ fileToStreams.set(created, streams);
1226
+ }
1227
+ }
1228
+ const fileOverlaps = [];
1229
+ for (const [filePath, streams] of fileToStreams) {
1230
+ if (streams.length > 1) {
1231
+ fileOverlaps.push({ path: filePath, streams });
1232
+ }
1233
+ }
1234
+ return { verdicts, fileOverlaps };
832
1235
  }
package/dist/config.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- export declare const PRODUCTION_URL = "https://api.orchex.dev";
2
+ export declare const PRODUCTION_URL = "https://orchex.dev";
3
3
  export declare const LLMProviderSchema: z.ZodEnum<["anthropic", "openai", "gemini", "ollama", "deepseek"]>;
4
4
  export type LLMProvider = z.infer<typeof LLMProviderSchema>;
5
5
  /**
@@ -42,7 +42,7 @@ export declare const TelemetryConfigSchema: z.ZodObject<{
42
42
  }>;
43
43
  export declare const ConfigSchema: z.ZodObject<{
44
44
  mode: z.ZodDefault<z.ZodEnum<["local", "cloud"]>>;
45
- apiUrl: z.ZodDefault<z.ZodString>;
45
+ apiUrl: z.ZodDefault<z.ZodEffects<z.ZodString, string, string>>;
46
46
  apiKey: z.ZodOptional<z.ZodString>;
47
47
  /** User's subscription tier (synced from cloud on login) */
48
48
  tier: z.ZodDefault<z.ZodEnum<["free", "pro", "team", "enterprise"]>>;
@@ -98,3 +98,7 @@ export declare function loadConfig(): Promise<Config>;
98
98
  * Save partial config. Merges with existing config.
99
99
  */
100
100
  export declare function saveConfig(partial: Partial<Config>): Promise<Config>;
101
+ /**
102
+ * Returns config with apiKey masked for safe display (CLI output, logs).
103
+ */
104
+ export declare function maskConfigForDisplay(config: Config): Record<string, unknown>;
package/dist/config.js CHANGED
@@ -4,7 +4,7 @@ import * as os from 'os';
4
4
  import { z } from 'zod';
5
5
  import { TierIdSchema } from './tiers.js';
6
6
  // Well-known URLs
7
- export const PRODUCTION_URL = 'https://api.orchex.dev';
7
+ export const PRODUCTION_URL = 'https://orchex.dev';
8
8
  // ============================================================================
9
9
  // LLM Provider Configuration
10
10
  // ============================================================================
@@ -117,7 +117,7 @@ export const TelemetryConfigSchema = z.object({
117
117
  });
118
118
  export const ConfigSchema = z.object({
119
119
  mode: z.enum(['local', 'cloud']).default('local'),
120
- apiUrl: z.string().url().default(PRODUCTION_URL),
120
+ apiUrl: z.string().url().refine((u) => u.startsWith('https://') || u.startsWith('http://localhost') || u.startsWith('http://127.0.0.1'), { message: 'apiUrl must use https:// (http://localhost and http://127.0.0.1 are allowed for development)' }).default(PRODUCTION_URL),
121
121
  apiKey: z.string().optional(),
122
122
  /** User's subscription tier (synced from cloud on login) */
123
123
  tier: TierIdSchema.default('free'),
@@ -170,3 +170,12 @@ export async function saveConfig(partial) {
170
170
  await fs.writeFile(configPath(), JSON.stringify(validated, null, 2), { encoding: 'utf-8', mode: 0o600 });
171
171
  return validated;
172
172
  }
173
+ /**
174
+ * Returns config with apiKey masked for safe display (CLI output, logs).
175
+ */
176
+ export function maskConfigForDisplay(config) {
177
+ return {
178
+ ...config,
179
+ apiKey: config.apiKey ? '...' + config.apiKey.slice(-6) : undefined,
180
+ };
181
+ }
@@ -14,8 +14,9 @@ export declare function buildStreamContext(projectDir: string, owns: string[], r
14
14
  export declare function buildDependencyContext(projectDir: string, deps: string[], manifest: Manifest): Promise<string>;
15
15
  /**
16
16
  * Build output instructions: artifact format, rules, constraints.
17
+ * Optionally includes provider-specific guidance for known LLM weaknesses.
17
18
  */
18
- export declare function buildInstructions(streamId: string, owns: string[]): string;
19
+ export declare function buildInstructions(streamId: string, owns: string[], provider?: string): string;
19
20
  /**
20
21
  * Build the complete prompt for an agent, assembling all 4 context layers.
21
22
  */