@tea-agent/loop-agent 0.16.26 → 0.17.1

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 (104) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +14 -3
  3. package/dist/cli/command-definitions.js +43 -0
  4. package/dist/cli/program.js +26 -0
  5. package/dist/commands/dag-approve.js +4 -0
  6. package/dist/commands/dag-resume.js +1 -0
  7. package/dist/commands/dag-validate.js +6 -0
  8. package/dist/commands/operator.js +44 -0
  9. package/dist/commands/task-contract.js +271 -0
  10. package/dist/executors/dag-pi-executor.js +55 -7
  11. package/dist/executors/pi-executor.js +206 -13
  12. package/dist/executors/pi-sdk-executor.js +21 -6
  13. package/dist/executors/shell-executor.js +55 -8
  14. package/dist/executors/shell-presets.js +16 -3
  15. package/dist/shared/operator/capabilities.js +255 -0
  16. package/dist/shared/operator/envelope.js +59 -0
  17. package/dist/shared/operator/index.js +4 -0
  18. package/dist/shared/operator/registry.js +38 -0
  19. package/dist/shared/operator/types.js +5 -0
  20. package/dist/task/contract/adopt.js +166 -0
  21. package/dist/task/contract/apply.js +326 -0
  22. package/dist/task/contract/canonicalize.js +60 -0
  23. package/dist/task/contract/constants.js +29 -0
  24. package/dist/task/contract/diff.js +177 -0
  25. package/dist/task/contract/hash.js +42 -0
  26. package/dist/task/contract/import-revision.js +96 -0
  27. package/dist/task/contract/index.js +17 -0
  28. package/dist/task/contract/journal.js +155 -0
  29. package/dist/task/contract/lock.js +153 -0
  30. package/dist/task/contract/observe.js +296 -0
  31. package/dist/task/contract/paths.js +19 -0
  32. package/dist/task/contract/project.js +170 -0
  33. package/dist/task/contract/recover.js +312 -0
  34. package/dist/task/contract/request-ledger.js +37 -0
  35. package/dist/task/contract/schema.js +151 -0
  36. package/dist/task/contract/transaction.js +160 -0
  37. package/dist/task/contract/types.js +1 -0
  38. package/dist/task/contract/validate-draft.js +106 -0
  39. package/dist/task/index.js +3 -0
  40. package/dist/task/operator/capabilities.js +6 -0
  41. package/dist/task/operator/envelope.js +2 -0
  42. package/dist/task/operator/index.js +5 -0
  43. package/dist/task/operator/registry.js +2 -0
  44. package/dist/task/operator/types.js +1 -0
  45. package/dist/task/runtime.js +5 -1
  46. package/dist/task/source-references.js +7 -0
  47. package/dist/worker/cli.js +150 -32
  48. package/dist/worker/console/app-data.js +185 -0
  49. package/dist/worker/console/dag-confirmation.js +313 -0
  50. package/dist/worker/console/doctor.js +169 -0
  51. package/dist/worker/console/draft-store.js +80 -0
  52. package/dist/worker/console/index.js +15 -0
  53. package/dist/worker/console/interview/assessment.js +67 -0
  54. package/dist/worker/console/interview/session.js +100 -0
  55. package/dist/worker/console/interview/tools.js +109 -0
  56. package/dist/worker/console/loopback.js +16 -0
  57. package/dist/worker/console/observe-health-match.js +174 -0
  58. package/dist/worker/console/observe-link.js +33 -0
  59. package/dist/worker/console/operation-runner.js +166 -0
  60. package/dist/worker/console/operation-sse.js +158 -0
  61. package/dist/worker/console/operation-store.js +147 -0
  62. package/dist/worker/console/operator-actions.js +769 -0
  63. package/dist/worker/console/pi-readiness.js +266 -0
  64. package/dist/worker/console/recovery-cta.js +133 -0
  65. package/dist/worker/console/repo-fingerprint.js +29 -0
  66. package/dist/worker/console/resource-loader.js +95 -0
  67. package/dist/worker/console/routes.js +368 -0
  68. package/dist/worker/console/security.js +126 -0
  69. package/dist/worker/console/server.js +149 -0
  70. package/dist/worker/console/sibling-controller.js +28 -0
  71. package/dist/worker/console/static/assets/index-BEIdBogJ.js +9 -0
  72. package/dist/worker/console/static/assets/index-Rt0TqimP.css +1 -0
  73. package/dist/worker/console/static/index.html +13 -0
  74. package/dist/worker/console/vite.config.js +27 -0
  75. package/dist/worker/materialize/harness-task-materializer.js +34 -0
  76. package/dist/worker/observe/health.js +57 -0
  77. package/dist/worker/observe/routes.js +16 -9
  78. package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
  79. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
  80. package/dist/workflows/dag/frontend-repair.js +1 -10
  81. package/dist/workflows/dag/init-hybrid.js +269 -116
  82. package/dist/workflows/dag/node-execution.js +25 -1
  83. package/dist/workflows/dag/retry-policy.js +16 -1
  84. package/dist/workflows/dag/runner.js +9 -0
  85. package/dist/workflows/dag/task-contract-binding.js +138 -0
  86. package/dist/workflows/dag/types.js +74 -10
  87. package/dist/workflows/dag/validate.js +25 -7
  88. package/docs/README.md +2 -0
  89. package/docs/architecture/evolution.md +2 -0
  90. package/docs/architecture/system-overview.md +6 -0
  91. package/docs/architecture/worker-and-feature.md +7 -0
  92. package/docs/templates/agent-dag.schema.json +49 -2
  93. package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
  94. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
  95. package/docs/templates/backend-test-dag.json +26 -154
  96. package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
  97. package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
  98. package/package.json +8 -2
  99. package/skills/agent-worker/SKILL.md +1 -0
  100. package/skills/agent-worker/references/agent-worker-operator.md +3 -2
  101. package/skills/frontend-design-review/SKILL.md +25 -16
  102. package/skills/frontend-implementation/references/node-contracts.md +5 -5
  103. package/skills/loop-agent/references/command-reference.md +48 -1
  104. package/skills/loop-agent/references/hybrid-dag.md +4 -4
@@ -50,6 +50,10 @@ export function extractSubagentStats(stdout) {
50
50
  export const DEFAULT_TIMEOUT_MS = 1_800_000;
51
51
  export const MAX_ALLOWED_TIMEOUT_MS = 1_800_000;
52
52
  const PI_BACKEND_ENV = 'CODE_AGENT_PI_BACKEND';
53
+ const OUTPUT_PREVIEW_HEAD_CHARS = 64_000;
54
+ const OUTPUT_PREVIEW_TAIL_CHARS = 64_000;
55
+ const MAX_STREAM_JSONL_LINE_CHARS = 16_000_000;
56
+ const MAX_ASSISTANT_TEXT_CHARS = 16_000_000;
53
57
  const PI_CLI_PACKAGE_NAMES = [
54
58
  '@earendil-works/pi-coding-agent',
55
59
  '@mariozechner/pi-coding-agent',
@@ -186,6 +190,10 @@ function resolvePiCliPath() {
186
190
  path.join(nvmBase, nodeVersion, 'lib', 'node_modules'),
187
191
  ].flatMap((nodeModulesRoot) => PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot(nodeModulesRoot, packageName)));
188
192
  const otherCandidates = [
193
+ // Prefer the project-local Pi dependency. `npm run` adds node_modules/.bin
194
+ // to PATH, but direct invocations from the DAG runner may not, so resolve
195
+ // the package from the current workspace explicitly.
196
+ ...PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot(path.resolve(process.cwd(), 'node_modules'), packageName)),
189
197
  // Global npm installation (non-nvm)
190
198
  ...PI_CLI_PACKAGE_NAMES.map((packageName) => piCliPathFromPackageRoot('/usr/local/lib/node_modules', packageName)),
191
199
  // Bun installation
@@ -327,8 +335,167 @@ function extractAssistantTextFromMessage(message) {
327
335
  function isRecord(value) {
328
336
  return typeof value === 'object' && value !== null;
329
337
  }
338
+ export class BoundedTextPreview {
339
+ label;
340
+ headMax;
341
+ tailMax;
342
+ parts = [];
343
+ head = '';
344
+ tail = '';
345
+ bufferedChars = 0;
346
+ totalChars = 0;
347
+ truncated = false;
348
+ constructor(label, headMax = OUTPUT_PREVIEW_HEAD_CHARS, tailMax = OUTPUT_PREVIEW_TAIL_CHARS) {
349
+ this.label = label;
350
+ this.headMax = headMax;
351
+ this.tailMax = tailMax;
352
+ }
353
+ append(chunk) {
354
+ if (!chunk)
355
+ return;
356
+ this.totalChars += chunk.length;
357
+ if (!this.truncated) {
358
+ if (this.bufferedChars + chunk.length <= this.headMax + this.tailMax) {
359
+ this.parts.push(chunk);
360
+ this.bufferedChars += chunk.length;
361
+ return;
362
+ }
363
+ const combined = this.parts.join('') + chunk;
364
+ this.head = combined.slice(0, this.headMax);
365
+ this.tail = combined.slice(-this.tailMax);
366
+ this.parts = [];
367
+ this.bufferedChars = 0;
368
+ this.truncated = true;
369
+ return;
370
+ }
371
+ this.tail = (this.tail + chunk).slice(-this.tailMax);
372
+ }
373
+ text() {
374
+ if (!this.truncated)
375
+ return this.parts.join('');
376
+ const omitted = Math.max(0, this.totalChars - this.head.length - this.tail.length);
377
+ return [
378
+ this.head,
379
+ `[${this.label} truncated: omitted ${omitted} chars; kept first ${this.head.length} and last ${this.tail.length}]`,
380
+ this.tail,
381
+ ].join('\n');
382
+ }
383
+ }
384
+ export class PiJsonlStreamCollector {
385
+ lineBuffer = '';
386
+ discardUntilNewline = false;
387
+ assistantText = '';
388
+ parsedEvents = 0;
389
+ tokensUsed = 0;
390
+ outputTooLarge = false;
391
+ subagentTotalCalls = 0;
392
+ subagentFailedCalls = 0;
393
+ subagentModes = new Set();
394
+ subagentAgents = new Set();
395
+ append(chunk) {
396
+ if (!chunk)
397
+ return;
398
+ const lines = chunk.split(/\r?\n/);
399
+ for (let index = 0; index < lines.length; index += 1) {
400
+ const part = lines[index] ?? '';
401
+ const isLast = index === lines.length - 1;
402
+ if (this.discardUntilNewline) {
403
+ if (!isLast)
404
+ this.discardUntilNewline = false;
405
+ continue;
406
+ }
407
+ if (this.lineBuffer.length + part.length > MAX_STREAM_JSONL_LINE_CHARS) {
408
+ this.outputTooLarge = true;
409
+ this.lineBuffer = '';
410
+ if (isLast)
411
+ this.discardUntilNewline = true;
412
+ continue;
413
+ }
414
+ this.lineBuffer += part;
415
+ if (!isLast) {
416
+ this.consumeLine(this.lineBuffer);
417
+ this.lineBuffer = '';
418
+ }
419
+ }
420
+ }
421
+ finish() {
422
+ if (!this.discardUntilNewline && this.lineBuffer.trim()) {
423
+ this.consumeLine(this.lineBuffer);
424
+ }
425
+ this.lineBuffer = '';
426
+ return {
427
+ assistantText: this.assistantText.trim(),
428
+ outputTooLarge: this.outputTooLarge,
429
+ parsedEvents: this.parsedEvents,
430
+ subagentStats: this.formatSubagentStats(),
431
+ tokensUsed: this.tokensUsed,
432
+ };
433
+ }
434
+ consumeLine(line) {
435
+ const trimmed = line.trim();
436
+ if (!trimmed)
437
+ return;
438
+ try {
439
+ const event = JSON.parse(trimmed);
440
+ this.parsedEvents += 1;
441
+ this.consumeEvent(event);
442
+ }
443
+ catch {
444
+ // tolerate non-json noise
445
+ }
446
+ }
447
+ consumeEvent(event) {
448
+ const candidate = extractAssistantTextFromEvent(event);
449
+ if (candidate) {
450
+ if (candidate.length > MAX_ASSISTANT_TEXT_CHARS) {
451
+ this.outputTooLarge = true;
452
+ this.assistantText = candidate.slice(0, MAX_ASSISTANT_TEXT_CHARS);
453
+ }
454
+ else {
455
+ this.assistantText = candidate;
456
+ }
457
+ }
458
+ const usage = extractUsageFromEvent(event);
459
+ if (usage !== null)
460
+ this.tokensUsed += usage;
461
+ if (event.type === 'tool_execution_start' && event.toolName === 'subagent') {
462
+ this.subagentTotalCalls += 1;
463
+ if (isRecord(event.input)) {
464
+ if (event.input.agent)
465
+ this.subagentAgents.add(String(event.input.agent));
466
+ if (event.input.tasks)
467
+ this.subagentModes.add('parallel');
468
+ if (event.input.chain)
469
+ this.subagentModes.add('chain');
470
+ if (event.input.agent && !event.input.tasks && !event.input.chain) {
471
+ this.subagentModes.add('single');
472
+ }
473
+ }
474
+ }
475
+ if (event.type === 'tool_execution_end' && event.toolName === 'subagent') {
476
+ if (event.isError === true)
477
+ this.subagentFailedCalls += 1;
478
+ }
479
+ }
480
+ formatSubagentStats() {
481
+ if (this.subagentTotalCalls === 0)
482
+ return 'calls=0';
483
+ const parts = [`calls=${this.subagentTotalCalls}`];
484
+ if (this.subagentFailedCalls > 0)
485
+ parts.push(`${this.subagentFailedCalls} failed`);
486
+ if (this.subagentModes.size > 0)
487
+ parts.push(Array.from(this.subagentModes).sort().join('+'));
488
+ if (this.subagentAgents.size > 0) {
489
+ parts.push(`agents=[${Array.from(this.subagentAgents).sort().join(',')}]`);
490
+ }
491
+ return parts.join(' | ');
492
+ }
493
+ }
494
+ export function createPiJsonlStreamCollector() {
495
+ return new PiJsonlStreamCollector();
496
+ }
330
497
  function withSubagentStats(result) {
331
- return { ...result, subagentStats: extractSubagentStats(result.stdout) };
498
+ return { ...result, subagentStats: result.subagentStats ?? extractSubagentStats(result.stdout) };
332
499
  }
333
500
  function withCliBackend(result) {
334
501
  return { ...result, backend: 'cli' };
@@ -354,7 +521,10 @@ async function runCliOrchestration(options, meta) {
354
521
  const fallbackResult = await executeSingleCliAttempt(options, primary.fallback);
355
522
  attemptedModels.push(fallbackResult.modelDisplay);
356
523
  const combinedStderr = [primaryResult.stderr, fallbackResult.stderr].filter(Boolean).join('\n--- fallback ---\n');
357
- const combinedStdout = [primaryResult.stdout, fallbackResult.stdout].filter(Boolean).join('\n');
524
+ const combinedStdout = [primaryResult.stdout, fallbackResult.stdout].filter(Boolean).join('\n--- fallback ---\n');
525
+ const combinedStdoutTruncated = primaryResult.stdoutTruncated || fallbackResult.stdoutTruncated;
526
+ const combinedStderrTruncated = primaryResult.stderrTruncated || fallbackResult.stderrTruncated;
527
+ const combinedOutputTooLarge = primaryResult.outputTooLarge || fallbackResult.outputTooLarge;
358
528
  let fallbackIssues = [];
359
529
  if (fallbackResult.ok && options.validateOutput) {
360
530
  fallbackIssues = options.validateOutput(fallbackResult.assistantText);
@@ -367,6 +537,9 @@ async function runCliOrchestration(options, meta) {
367
537
  sdkAttempted: meta?.sdkAttempted,
368
538
  stderr: combinedStderr,
369
539
  stdout: combinedStdout,
540
+ stdoutTruncated: combinedStdoutTruncated,
541
+ stderrTruncated: combinedStderrTruncated,
542
+ outputTooLarge: combinedOutputTooLarge,
370
543
  }));
371
544
  }
372
545
  if (fallbackResult.ok && fallbackIssues.length > 0) {
@@ -379,6 +552,9 @@ async function runCliOrchestration(options, meta) {
379
552
  sdkAttempted: meta?.sdkAttempted,
380
553
  stderr: combinedStderr + `\n[invalid-output] ${fallbackIssues.join('; ')}`,
381
554
  stdout: combinedStdout,
555
+ stdoutTruncated: combinedStdoutTruncated,
556
+ stderrTruncated: combinedStderrTruncated,
557
+ outputTooLarge: combinedOutputTooLarge,
382
558
  }));
383
559
  }
384
560
  return withSubagentStats(withCliBackend({
@@ -388,6 +564,9 @@ async function runCliOrchestration(options, meta) {
388
564
  sdkAttempted: meta?.sdkAttempted,
389
565
  stderr: combinedStderr,
390
566
  stdout: combinedStdout,
567
+ stdoutTruncated: combinedStdoutTruncated,
568
+ stderrTruncated: combinedStderrTruncated,
569
+ outputTooLarge: combinedOutputTooLarge,
391
570
  }));
392
571
  }
393
572
  if (primaryInvalidOutput) {
@@ -516,12 +695,18 @@ async function executeSingleCliAttempt(options, modelConfig) {
516
695
  },
517
696
  stdio: ['ignore', 'pipe', 'pipe'],
518
697
  });
519
- let stdout = '';
520
- let stderr = '';
698
+ const stdoutPreview = new BoundedTextPreview('stdout');
699
+ const stderrPreview = new BoundedTextPreview('stderr', 16_000, 16_000);
700
+ const stdoutCollector = new PiJsonlStreamCollector();
521
701
  child.stdout.setEncoding('utf-8');
522
702
  child.stderr.setEncoding('utf-8');
523
- child.stdout.on('data', (chunk) => { stdout += chunk; });
524
- child.stderr.on('data', (chunk) => { stderr += chunk; });
703
+ child.stdout.on('data', (chunk) => {
704
+ stdoutPreview.append(chunk);
705
+ stdoutCollector.append(chunk);
706
+ });
707
+ child.stderr.on('data', (chunk) => {
708
+ stderrPreview.append(chunk);
709
+ });
525
710
  child.on('error', (err) => {
526
711
  if (timeoutHandle)
527
712
  clearTimeout(timeoutHandle);
@@ -535,28 +720,34 @@ async function executeSingleCliAttempt(options, modelConfig) {
535
720
  if (sigkillHandle)
536
721
  clearTimeout(sigkillHandle);
537
722
  const durationMs = Date.now() - startedAt;
538
- const { assistantText, parsedEvents } = extractAssistantTextFromPiJson(stdout);
539
- const tokensUsed = extractTokenUsageFromPiJson(stdout);
723
+ const stdout = stdoutPreview.text();
724
+ const stderr = stderrPreview.text();
725
+ const collected = stdoutCollector.finish();
540
726
  const failureCategory = classifyPiFailure({
541
- assistantText,
727
+ assistantText: collected.assistantText,
542
728
  exitCode: code,
543
729
  stderr,
544
730
  stdout,
545
731
  timedOut,
732
+ outputTooLarge: collected.outputTooLarge,
546
733
  });
547
734
  resolve({
548
- assistantText,
735
+ assistantText: collected.assistantText,
549
736
  command: ['pi', ...piArgs],
550
737
  durationMs,
551
738
  exitCode: code,
552
739
  failureCategory,
553
740
  modelDisplay,
554
- ok: code === 0 && assistantText.length > 0,
555
- parsedEvents,
741
+ ok: code === 0 && collected.assistantText.length > 0 && !collected.outputTooLarge,
742
+ parsedEvents: collected.parsedEvents,
556
743
  stderr,
557
744
  stdout,
745
+ stdoutTruncated: stdoutPreview.truncated,
746
+ stderrTruncated: stderrPreview.truncated,
747
+ outputTooLarge: collected.outputTooLarge,
558
748
  timedOut,
559
- tokensUsed,
749
+ tokensUsed: collected.tokensUsed,
750
+ subagentStats: collected.subagentStats,
560
751
  });
561
752
  });
562
753
  if (timeoutMs > 0) {
@@ -585,6 +776,8 @@ export function shouldRetryWithFallback(stderr, stdout) {
585
776
  export function classifyPiFailure(input) {
586
777
  if (input.timedOut)
587
778
  return 'timeout';
779
+ if (input.outputTooLarge)
780
+ return 'output-too-large';
588
781
  if (input.exitCode === 0 && input.assistantText.trim())
589
782
  return 'success';
590
783
  const combined = `${input.stderr}\n${input.stdout}`.toLowerCase();
@@ -1,6 +1,6 @@
1
1
  import { appendFile, mkdir } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { classifyPiFailure, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from './pi-executor.js';
3
+ import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from './pi-executor.js';
4
4
  import { serializeSessionEvent } from './pi-event-serializer.js';
5
5
  let sdkSessionFactoryOverride;
6
6
  let sdkImportOverrideForTests;
@@ -283,7 +283,8 @@ export async function executeSingleSdkAttempt(options) {
283
283
  const startedAt = Date.now();
284
284
  let timedOut = false;
285
285
  let stderr = '';
286
- const stdoutLines = [];
286
+ const stdoutPreview = new BoundedTextPreview('stdout');
287
+ const stdoutCollector = createPiJsonlStreamCollector();
287
288
  const usageSamples = [];
288
289
  let session;
289
290
  let timeoutHandle;
@@ -307,7 +308,8 @@ export async function executeSingleSdkAttempt(options) {
307
308
  if (!shouldPersistSessionEvent(event))
308
309
  return;
309
310
  const line = serializeSessionEvent(event);
310
- stdoutLines.push(line);
311
+ stdoutPreview.append(`${line}\n`);
312
+ stdoutCollector.append(`${line}\n`);
311
313
  sessionEventAppender?.append(line, event);
312
314
  });
313
315
  const filePrefix = options.attachedFiles.map((file) => `@${file}`).join(' ');
@@ -363,13 +365,23 @@ export async function executeSingleSdkAttempt(options) {
363
365
  }
364
366
  }
365
367
  }
366
- const stdout = stdoutLines.join('\n');
368
+ const stdout = stdoutPreview.text();
367
369
  const durationMs = Date.now() - startedAt;
368
- const { assistantText, parsedEvents } = extractAssistantTextFromPiJson(stdout);
370
+ const collected = stdoutCollector.finish();
371
+ const fallbackParsed = collected.parsedEvents > 0
372
+ ? undefined
373
+ : extractAssistantTextFromPiJson(stdout);
374
+ const assistantText = collected.parsedEvents > 0
375
+ ? collected.assistantText
376
+ : fallbackParsed?.assistantText ?? '';
377
+ const parsedEvents = collected.parsedEvents > 0
378
+ ? collected.parsedEvents
379
+ : fallbackParsed?.parsedEvents ?? 0;
369
380
  const tokensUsed = aggregateSdkTokenUsage(usageSamples);
370
381
  const failureCategory = classifyPiFailure({
371
382
  assistantText,
372
383
  exitCode: timedOut ? 1 : stderr ? 1 : 0,
384
+ outputTooLarge: collected.outputTooLarge,
373
385
  stderr,
374
386
  stdout,
375
387
  timedOut,
@@ -382,11 +394,14 @@ export async function executeSingleSdkAttempt(options) {
382
394
  exitCode: timedOut || stderr ? 1 : 0,
383
395
  failureCategory,
384
396
  modelDisplay,
385
- ok: !timedOut && !stderr && assistantText.length > 0,
397
+ ok: !timedOut && !stderr && assistantText.length > 0 && !collected.outputTooLarge,
386
398
  parsedEvents,
387
399
  stderr,
388
400
  stdout,
401
+ stdoutTruncated: stdoutPreview.truncated,
402
+ outputTooLarge: collected.outputTooLarge,
389
403
  timedOut,
390
404
  tokensUsed,
405
+ subagentStats: collected.subagentStats,
391
406
  };
392
407
  }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
3
- import { readFile, writeFile } from "node:fs/promises";
3
+ import { readFile, stat, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { writeDagNodeTextArtifact } from "../infrastructure/harness/artifact-store.js";
6
6
  import { truncateOutput } from "../shared/output-truncation.js";
@@ -54,6 +54,28 @@ export function resolveShellCwd(root, requestedCwd) {
54
54
  }
55
55
  return resolved;
56
56
  }
57
+ async function resolveJsonArtifactSourceNodeId(input) {
58
+ const candidates = [
59
+ input.fromNodeId,
60
+ ...new Set((input.fallbackFromNodeIds ?? []).filter((nodeId) => nodeId !== input.fromNodeId)),
61
+ ];
62
+ for (const nodeId of candidates) {
63
+ try {
64
+ await stat(path.join(input.runDir, `${nodeId}.json`));
65
+ return nodeId;
66
+ }
67
+ catch (error) {
68
+ if (error &&
69
+ typeof error === "object" &&
70
+ "code" in error &&
71
+ error.code === "ENOENT") {
72
+ continue;
73
+ }
74
+ throw error;
75
+ }
76
+ }
77
+ throw new Error(`missing jsonArtifactGate node output (tried: ${candidates.join(", ")})`);
78
+ }
57
79
  /** Preset commands first, then explicit shell.commands, then verdictGate (deterministic ordering). */
58
80
  export function resolveShellCommands(shell) {
59
81
  const fromPreset = shell.preset ? expandShellPreset(shell.preset) : [];
@@ -421,12 +443,30 @@ async function executeBackendTestPipeline(input, meta) {
421
443
  const selected = await executePipelineCommands(input, meta, [buildBackendTestCanonicalResultFromInitialShellSnippet()]);
422
444
  if (!selected.every((result) => result.ok))
423
445
  throw new Error(selected.find((result) => !result.ok)?.stderr || "canonical result materialization failed");
446
+ const readNodeOutput = async (nodeId) => {
447
+ const record = JSON.parse(await readFile(path.join(meta.runDir, `${nodeId}.json`), "utf8"));
448
+ const text = record.assistantText?.trim() || record.stdout?.trim() || "";
449
+ if (!text)
450
+ throw new Error(`missing auditable node output: ${nodeId}`);
451
+ return text;
452
+ };
453
+ const caseReview = await readNodeOutput("review-backend-cases-pi");
454
+ const reviewVerdicts = caseReview.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("VERDICT:"));
455
+ if (reviewVerdicts.length !== 1 || !["VERDICT: pass", "VERDICT: request-revision"].includes(reviewVerdicts[0])) {
456
+ throw new Error("invalid advisory case review evidence: expected exactly one auditable VERDICT line");
457
+ }
458
+ const traceability = await readNodeOutput("backend-test-traceability-gate-shell");
459
+ if (!traceability.includes("Traceability gate: pass") || !/Matched symbols:\s*\d+/.test(traceability)) {
460
+ throw new Error("invalid traceability evidence: deterministic pass summary is missing");
461
+ }
424
462
  const contracts = path.join(meta.runDir, "contracts");
425
463
  const context = {
426
464
  schemaVersion: 1,
427
465
  result: JSON.parse(await readFile(path.join(contracts, "backend-test-result.json"), "utf8")),
428
466
  manifest: JSON.parse(await readFile(path.join(contracts, "backend-test-case-manifest.json"), "utf8")),
429
467
  classification: JSON.parse(await readFile(path.join(contracts, "backend-test-classification.json"), "utf8")),
468
+ caseReview: { verdict: reviewVerdicts[0].slice("VERDICT: ".length), evidence: caseReview },
469
+ traceability: { status: "pass", evidence: traceability },
430
470
  };
431
471
  outputs.push(`classification=${classification.path}`, ...selected.map((result) => result.stdout), JSON.stringify(context, null, 2));
432
472
  }
@@ -510,13 +550,20 @@ export async function executeDagShellNode(input, meta) {
510
550
  const started = Date.now();
511
551
  try {
512
552
  const gate = shell.jsonArtifactGate;
553
+ const sourceNodeId = gate.fallbackFromNodeIds?.length
554
+ ? await resolveJsonArtifactSourceNodeId({
555
+ runDir: meta.runDir,
556
+ fromNodeId: gate.fromNodeId,
557
+ fallbackFromNodeIds: gate.fallbackFromNodeIds,
558
+ })
559
+ : gate.fromNodeId;
513
560
  let artifact;
514
561
  switch (gate.schemaId) {
515
562
  case "backend-test-analysis-v1":
516
563
  case "backend-test-analysis-v2":
517
564
  artifact = await materializeBackendTestAnalysisContract({
518
565
  runDir: meta.runDir,
519
- fromNodeId: gate.fromNodeId,
566
+ fromNodeId: sourceNodeId,
520
567
  artifactName: gate.artifactName,
521
568
  outputDir: gate.outputDir,
522
569
  sourceBinding: meta.spec.sourceBinding,
@@ -525,7 +572,7 @@ export async function executeDagShellNode(input, meta) {
525
572
  case "backend-test-execution-v1":
526
573
  artifact = await materializeBackendTestExecutionContract({
527
574
  runDir: meta.runDir,
528
- fromNodeId: gate.fromNodeId,
575
+ fromNodeId: sourceNodeId,
529
576
  artifactName: gate.artifactName,
530
577
  outputDir: gate.outputDir,
531
578
  });
@@ -533,7 +580,7 @@ export async function executeDagShellNode(input, meta) {
533
580
  case "backend-test-result-v1":
534
581
  artifact = await materializeBackendTestResultFromRunDir({
535
582
  runDir: meta.runDir,
536
- fromNodeId: gate.fromNodeId,
583
+ fromNodeId: sourceNodeId,
537
584
  artifactName: gate.artifactName,
538
585
  outputDir: gate.outputDir,
539
586
  junitRelativePath: gate.junitRelativePath,
@@ -542,7 +589,7 @@ export async function executeDagShellNode(input, meta) {
542
589
  case "backend-test-classification-v1":
543
590
  artifact = await materializeBackendTestClassification({
544
591
  runDir: meta.runDir,
545
- fromNodeId: gate.fromNodeId,
592
+ fromNodeId: sourceNodeId,
546
593
  artifactName: gate.artifactName,
547
594
  outputDir: gate.outputDir,
548
595
  });
@@ -550,7 +597,7 @@ export async function executeDagShellNode(input, meta) {
550
597
  case "backend-test-semantic-review-v1":
551
598
  artifact = await materializeBackendTestSemanticReview({
552
599
  runDir: meta.runDir,
553
- fromNodeId: gate.fromNodeId,
600
+ fromNodeId: sourceNodeId,
554
601
  artifactName: gate.artifactName,
555
602
  outputDir: gate.outputDir,
556
603
  });
@@ -558,7 +605,7 @@ export async function executeDagShellNode(input, meta) {
558
605
  case "backend-test-case-manifest-v1":
559
606
  artifact = await materializeBackendTestCaseManifest({
560
607
  runDir: meta.runDir,
561
- fromNodeId: gate.fromNodeId,
608
+ fromNodeId: sourceNodeId,
562
609
  artifactName: gate.artifactName,
563
610
  outputDir: gate.outputDir,
564
611
  sourceBinding: meta.spec.sourceBinding,
@@ -567,7 +614,7 @@ export async function executeDagShellNode(input, meta) {
567
614
  case "frontend-implementation-contract-v1":
568
615
  artifact = await materializeFrontendImplementationContract({
569
616
  runDir: meta.runDir,
570
- fromNodeId: gate.fromNodeId,
617
+ fromNodeId: sourceNodeId,
571
618
  artifactName: gate.artifactName,
572
619
  outputDir: gate.outputDir,
573
620
  sourceBinding: meta.spec.sourceBinding,
@@ -60,12 +60,25 @@ export function buildVerdictGateShellCommand(gate) {
60
60
  export function buildRequirementCoverageGateShellCommand(gate) {
61
61
  const label = gate.label ?? "requirement coverage";
62
62
  const requiredJson = escapeShellSingleQuoted(JSON.stringify(gate.requiredIds.map((id) => id.toUpperCase())));
63
- const fileArgs = gate.fromNodeIds.map((nodeId) => `"\${HARNESS_DAG_RUN_DIR}/${nodeId}.json"`).join(" ");
63
+ const sourceConfig = {
64
+ primary: gate.fromNodeIds,
65
+ fallback: [
66
+ ...new Set((gate.fallbackFromNodeIds ?? []).filter((nodeId) => !gate.fromNodeIds.includes(nodeId))),
67
+ ],
68
+ };
64
69
  const program = [
65
70
  'const fs=require("fs");',
71
+ 'const path=require("path");',
66
72
  "const required=JSON.parse(process.argv[1]);",
73
+ `const sources=${JSON.stringify(sourceConfig)};`,
74
+ "let files=sources.primary.map((id)=>path.join(process.env.HARNESS_DAG_RUN_DIR,id+'.json'));",
75
+ "if(sources.fallback.length){",
76
+ " files=[]; const candidates=[sources.primary[0],...sources.fallback];",
77
+ " for(const id of candidates){const file=path.join(process.env.HARNESS_DAG_RUN_DIR,id+'.json'); try{fs.statSync(file);files=[file];break;}catch(error){if(error&&error.code==='ENOENT')continue;throw error;}}",
78
+ ` if(!files.length){console.error(${JSON.stringify(`${label} gate blocked: missing node output (tried: `)}+[sources.primary[0],...sources.fallback].join(', ')+')');process.exit(1);}`,
79
+ "}",
67
80
  "let text='';",
68
- "for(const file of process.argv.slice(2)){",
81
+ "for(const file of files){",
69
82
  " if(!fs.existsSync(file)){console.error('missing requirement coverage node output: '+file);process.exit(1);}",
70
83
  " const raw=JSON.parse(fs.readFileSync(file,'utf8')); text+='\\n'+String(raw.assistantText ?? raw.stdout ?? '');",
71
84
  "}",
@@ -74,5 +87,5 @@ export function buildRequirementCoverageGateShellCommand(gate) {
74
87
  `if(missing.length){console.error(${JSON.stringify(`${label} gate blocked: missing `)}+missing.join(', '));process.exit(1);}`,
75
88
  `console.log(${JSON.stringify(`${label} gate passed: `)}+required.join(', '));`,
76
89
  ].join("");
77
- return `test -n "\${HARNESS_DAG_RUN_DIR:-}" || { echo "missing HARNESS_DAG_RUN_DIR for ${label} gate" >&2; exit 1; }; node -e '${escapeShellSingleQuoted(program)}' '${requiredJson}' ${fileArgs}`;
90
+ return `test -n "\${HARNESS_DAG_RUN_DIR:-}" || { echo "missing HARNESS_DAG_RUN_DIR for ${label} gate" >&2; exit 1; }; node -e '${escapeShellSingleQuoted(program)}' '${requiredJson}'`;
78
91
  }