specrails-core 5.3.0 → 5.4.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 (117) hide show
  1. package/dist/agent-runtime/capabilities.d.ts +27 -0
  2. package/dist/agent-runtime/capabilities.js +73 -0
  3. package/dist/agent-runtime/capabilities.js.map +1 -0
  4. package/dist/agent-runtime/cli-executor.d.ts +9 -0
  5. package/dist/agent-runtime/cli-executor.js +100 -30
  6. package/dist/agent-runtime/cli-executor.js.map +1 -1
  7. package/dist/agent-runtime/cli.d.ts +1 -0
  8. package/dist/agent-runtime/cli.js +59 -11
  9. package/dist/agent-runtime/cli.js.map +1 -1
  10. package/dist/agent-runtime/codex-schema.d.ts +4 -0
  11. package/dist/agent-runtime/codex-schema.js +31 -0
  12. package/dist/agent-runtime/codex-schema.js.map +1 -0
  13. package/dist/agent-runtime/config.d.ts +4 -0
  14. package/dist/agent-runtime/config.js +110 -9
  15. package/dist/agent-runtime/config.js.map +1 -1
  16. package/dist/agent-runtime/core-host.d.ts +9 -1
  17. package/dist/agent-runtime/core-host.js +55 -20
  18. package/dist/agent-runtime/core-host.js.map +1 -1
  19. package/dist/agent-runtime/efficiency-summary.d.ts +67 -0
  20. package/dist/agent-runtime/efficiency-summary.js +52 -0
  21. package/dist/agent-runtime/efficiency-summary.js.map +1 -0
  22. package/dist/agent-runtime/efficiency-types.d.ts +60 -0
  23. package/dist/agent-runtime/efficiency-types.js +8 -0
  24. package/dist/agent-runtime/efficiency-types.js.map +1 -0
  25. package/dist/agent-runtime/efficiency.d.ts +5 -0
  26. package/dist/agent-runtime/efficiency.js +35 -0
  27. package/dist/agent-runtime/efficiency.js.map +1 -0
  28. package/dist/agent-runtime/evaluation-corpus.d.ts +13 -0
  29. package/dist/agent-runtime/evaluation-corpus.js +8 -0
  30. package/dist/agent-runtime/evaluation-corpus.js.map +1 -0
  31. package/dist/agent-runtime/evaluation.d.ts +43 -0
  32. package/dist/agent-runtime/evaluation.js +171 -0
  33. package/dist/agent-runtime/evaluation.js.map +1 -0
  34. package/dist/agent-runtime/executor-types.d.ts +35 -2
  35. package/dist/agent-runtime/executor-types.js.map +1 -1
  36. package/dist/agent-runtime/executors.d.ts +1 -0
  37. package/dist/agent-runtime/executors.js +4 -0
  38. package/dist/agent-runtime/executors.js.map +1 -1
  39. package/dist/agent-runtime/graph/artifacts.d.ts +8 -22
  40. package/dist/agent-runtime/graph/artifacts.js +99 -73
  41. package/dist/agent-runtime/graph/artifacts.js.map +1 -1
  42. package/dist/agent-runtime/graph/nodes.d.ts +3 -0
  43. package/dist/agent-runtime/graph/nodes.js +73 -26
  44. package/dist/agent-runtime/graph/nodes.js.map +1 -1
  45. package/dist/agent-runtime/graph/roles.d.ts +4 -1
  46. package/dist/agent-runtime/graph/roles.js +100 -36
  47. package/dist/agent-runtime/graph/roles.js.map +1 -1
  48. package/dist/agent-runtime/graph/state.d.ts +8 -0
  49. package/dist/agent-runtime/graph/state.js.map +1 -1
  50. package/dist/agent-runtime/index.d.ts +7 -1
  51. package/dist/agent-runtime/index.js +7 -1
  52. package/dist/agent-runtime/index.js.map +1 -1
  53. package/dist/agent-runtime/kimi-acp.d.ts +4 -0
  54. package/dist/agent-runtime/kimi-acp.js +20 -9
  55. package/dist/agent-runtime/kimi-acp.js.map +1 -1
  56. package/dist/agent-runtime/openai-executor.d.ts +8 -0
  57. package/dist/agent-runtime/openai-executor.js +28 -10
  58. package/dist/agent-runtime/openai-executor.js.map +1 -1
  59. package/dist/agent-runtime/openspec-tool-server.d.ts +1 -0
  60. package/dist/agent-runtime/openspec-tool-server.js +37 -0
  61. package/dist/agent-runtime/openspec-tool-server.js.map +1 -0
  62. package/dist/agent-runtime/openspec.d.ts +123 -0
  63. package/dist/agent-runtime/openspec.js +263 -0
  64. package/dist/agent-runtime/openspec.js.map +1 -0
  65. package/dist/agent-runtime/prompts.d.ts +5 -1
  66. package/dist/agent-runtime/prompts.js +59 -40
  67. package/dist/agent-runtime/prompts.js.map +1 -1
  68. package/dist/agent-runtime/provider-diagnostic.d.ts +2 -0
  69. package/dist/agent-runtime/provider-diagnostic.js +25 -0
  70. package/dist/agent-runtime/provider-diagnostic.js.map +1 -0
  71. package/dist/agent-runtime/repository-context.d.ts +25 -0
  72. package/dist/agent-runtime/repository-context.js +100 -0
  73. package/dist/agent-runtime/repository-context.js.map +1 -0
  74. package/dist/agent-runtime/review-context.d.ts +14 -0
  75. package/dist/agent-runtime/review-context.js +41 -0
  76. package/dist/agent-runtime/review-context.js.map +1 -0
  77. package/dist/agent-runtime/role-routing.d.ts +10 -0
  78. package/dist/agent-runtime/role-routing.js +29 -0
  79. package/dist/agent-runtime/role-routing.js.map +1 -0
  80. package/dist/agent-runtime/role-state.d.ts +19 -0
  81. package/dist/agent-runtime/role-state.js +24 -0
  82. package/dist/agent-runtime/role-state.js.map +1 -0
  83. package/dist/agent-runtime/runtime-identity.d.ts +11 -0
  84. package/dist/agent-runtime/runtime-identity.js +33 -0
  85. package/dist/agent-runtime/runtime-identity.js.map +1 -0
  86. package/dist/agent-runtime/tool-event.d.ts +3 -0
  87. package/dist/agent-runtime/tool-event.js +24 -0
  88. package/dist/agent-runtime/tool-event.js.map +1 -0
  89. package/dist/agent-runtime/verification-plan.d.ts +55 -0
  90. package/dist/agent-runtime/verification-plan.js +206 -0
  91. package/dist/agent-runtime/verification-plan.js.map +1 -0
  92. package/dist/agent-runtime/workflow-types.d.ts +17 -1
  93. package/dist/agent-runtime/workflow.js +28 -1
  94. package/dist/agent-runtime/workflow.js.map +1 -1
  95. package/dist/agent-runtime/workspace-tools.d.ts +4 -0
  96. package/dist/agent-runtime/workspace-tools.js +182 -20
  97. package/dist/agent-runtime/workspace-tools.js.map +1 -1
  98. package/dist/installer/phases/scaffold.js +39 -109
  99. package/dist/installer/phases/scaffold.js.map +1 -1
  100. package/dist/installer/runtime/pipeline-state.d.ts +142 -1
  101. package/dist/installer/runtime/pipeline-state.js +477 -28
  102. package/dist/installer/runtime/pipeline-state.js.map +1 -1
  103. package/docs/agent-runtime-efficiency.md +65 -0
  104. package/docs/agent-runtime.md +86 -15
  105. package/integration-contract.json +2 -1
  106. package/package.json +5 -2
  107. package/schemas/agent-runtime.schema.json +25 -3
  108. package/schemas/fixtures/runtime-efficiency-summary.v1.json +790 -0
  109. package/templates/codex-skills/batch-implement/SKILL.md +33 -58
  110. package/templates/codex-skills/implement/SKILL.md +21 -124
  111. package/templates/codex-skills/retry/SKILL.md +8 -34
  112. package/templates/commands/specrails/batch-implement.md +21 -16
  113. package/templates/commands/specrails/implement.md +17 -276
  114. package/templates/commands/specrails/retry.md +6 -34
  115. package/templates/gemini-commands/batch-implement.toml +34 -28
  116. package/templates/gemini-commands/implement.toml +34 -55
  117. package/templates/gemini-commands/retry.toml +10 -16
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { spawn, spawnSync } from 'node:child_process';
3
3
  import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { StringDecoder } from 'node:string_decoder';
5
6
  const PHASES = ['architect', 'developer', 'reviewer', 'archive', 'ship', 'ci'];
6
7
  // Session identity belongs to the agent transport, not the checked application.
7
8
  // Remove these from check subprocesses too, so ignored identity cannot become
@@ -290,15 +291,16 @@ function untrackedAgentMemory(relative) {
290
291
  // belong to the candidate. Do not hide arbitrary provider directory content.
291
292
  return ['.claude', '.codex', '.gemini', '.kimi-code', '.specrails'].some(provider => relative === provider + '/agent-memory' || relative.startsWith(provider + '/agent-memory/'));
292
293
  }
293
- export function fingerprintCandidate(state) {
294
+ export function candidateManifest(state) {
294
295
  const entries = state.context.repositories.map((repo) => ({
295
296
  id: repo.id, path: repo.path,
296
297
  files: trackedFiles(repo)
297
298
  .filter(({ file, tracked }) => !excluded(state, repo, relativeUnix(file)) && (tracked || !untrackedAgentMemory(relativeUnix(file))))
298
299
  .map(({ file }) => [relativeUnix(file), fileFingerprint(path.join(repo.path, file))]),
299
300
  }));
300
- return digest(canonical(entries));
301
+ return { schemaVersion: 1, scopeHash: state.scopeHash, repositories: entries };
301
302
  }
303
+ export function fingerprintCandidate(state) { return digest(canonical(candidateManifest(state).repositories)); }
302
304
  function activeArtifactPath(state) { return state.archivePath ?? path.join(state.context.artifactRoot, 'openspec', 'changes', state.change); }
303
305
  function artifactFingerprint(state) {
304
306
  const root = activeArtifactPath(state);
@@ -349,9 +351,46 @@ function environmentHash(keys, overrides = {}, env = process.env) {
349
351
  const normalized = normalizeVerificationEnvironment(env);
350
352
  return digest(canonical(Object.fromEntries(keys.map((key) => [key, overrides[key] ?? normalized[key] ?? null]))));
351
353
  }
354
+ /** Host-owned binding, including immutable harness bytes outside the candidate. */
355
+ export function bindVerificationPlan(contextInput, hash, files) {
356
+ const context = validatePipelineContext(contextInput);
357
+ if (!/^[a-f0-9]{64}$/.test(hash) || files.length > 801)
358
+ fail('Invalid verification plan identity');
359
+ for (const file of files) {
360
+ const target = safeChild(pipelineStateDirectory(context), file.path);
361
+ if (!/^[a-f0-9]{64}$/.test(file.hash) || digest(readFileSync(target)) !== file.hash)
362
+ fail('Verification plan source changed');
363
+ }
364
+ locked(context, () => {
365
+ const state = readState(context);
366
+ const binding = { hash, files };
367
+ if (canonical(state.verificationPlan) === canonical(binding))
368
+ return;
369
+ if (state.phases.archive.status === 'done')
370
+ fail('Cannot change the archived verification plan');
371
+ state.verificationPlan = binding;
372
+ state.acceptance = undefined;
373
+ state.archiveApproval = undefined;
374
+ saveState(state);
375
+ });
376
+ }
377
+ function planReasons(state, hash) {
378
+ if (hash !== state.verificationPlan?.hash)
379
+ return ['Verification plan changed'];
380
+ try {
381
+ for (const file of state.verificationPlan?.files ?? []) {
382
+ if (digest(readFileSync(safeChild(pipelineStateDirectory(state.context), file.path))) !== file.hash)
383
+ return ['Verification plan or harness source changed'];
384
+ }
385
+ }
386
+ catch {
387
+ return ['Verification plan or harness source unavailable'];
388
+ }
389
+ return [];
390
+ }
352
391
  function inspectReceipt(state, env = process.env) {
353
392
  const receipt = state.verification;
354
- const reasons = [];
393
+ const reasons = planReasons(state, receipt?.planHash);
355
394
  if (!receipt)
356
395
  return { valid: false, reasons: ['No verification receipt'] };
357
396
  if (!receipt.valid || receipt.kind !== 'full')
@@ -361,6 +400,12 @@ function inspectReceipt(state, env = process.env) {
361
400
  if (receipt.candidateHash !== fingerprintCandidate(state))
362
401
  reasons.push('Candidate files changed');
363
402
  for (const command of receipt.commands) {
403
+ if (command.snapshot?.eligible) {
404
+ // The frozen logical command is recorded with each execution for inspection.
405
+ const snapshot = command.checkDefinition ? verificationSnapshot(state.context, command.checkDefinition) : undefined;
406
+ if (!snapshot?.eligible || snapshot.inputHash !== command.snapshot.inputHash || snapshot.toolchainHash !== command.snapshot.toolchainHash)
407
+ reasons.push('Verification inputs or toolchain changed');
408
+ }
364
409
  if (command.exitCode !== 0)
365
410
  reasons.push('Command failed: ' + command.command);
366
411
  if (command.environmentPolicy !== 'isolated-transport-v1')
@@ -478,6 +523,7 @@ function inspectAcceptance(state) {
478
523
  catch (error) {
479
524
  return { valid: false, status: 'blocked', reasons: [error instanceof Error ? error.message : String(error)] };
480
525
  }
526
+ reasons.push(...planReasons(state, receipt.planHash));
481
527
  if (receipt.scopeHash !== state.scopeHash || receipt.candidateHash !== fingerprintCandidate(state) || receipt.artifactHash !== artifactFingerprint(state))
482
528
  reasons.push('Acceptance evidence is stale');
483
529
  for (const row of receipt.criteria ?? [])
@@ -501,7 +547,7 @@ export function recordAcceptance(contextInput, input) {
501
547
  const state = readState(context);
502
548
  if (state.phases.developer.status !== 'done' || state.phases.archive.status === 'done')
503
549
  fail('Record acceptance after development and before archive');
504
- state.acceptance = { ...report, scopeHash: state.scopeHash, candidateHash: fingerprintCandidate(state), artifactHash: artifactFingerprint(state), recordedAt: new Date().toISOString() };
550
+ state.acceptance = { ...report, ...(state.verificationPlan ? { planHash: state.verificationPlan.hash } : {}), scopeHash: state.scopeHash, candidateHash: fingerprintCandidate(state), artifactHash: artifactFingerprint(state), recordedAt: new Date().toISOString() };
505
551
  // Replacing even just an exception decision requires a new review/approval.
506
552
  for (const phase of PHASES.slice(2)) {
507
553
  if (phase === 'reviewer' && state.phases[phase].status === 'running')
@@ -651,7 +697,7 @@ export function inspectPipeline(contextInput) {
651
697
  delivery: context.ownership.git === 'host' ? 'pending-host' : state.phases.ship.status === 'done' && state.phases.ci.status === 'done' ? 'complete' : 'pending',
652
698
  reasons: [...verification.reasons, ...acceptance.reasons],
653
699
  };
654
- return { schemaVersion: 1, runId: context.runId, change: state.change, context, stateDir: pipelineStateDirectory(context), resumePhase, phases: state.phases, verification, acceptance, completion };
700
+ return { schemaVersion: 1, runId: context.runId, change: state.change, context, stateDir: pipelineStateDirectory(context), planHash: state.verificationPlan?.hash, candidateHash: fingerprintCandidate(state), resumePhase, phases: state.phases, verification, acceptance, completion };
655
701
  }
656
702
  function validateCommand(context, raw) {
657
703
  const command = object(raw);
@@ -667,7 +713,26 @@ function validateCommand(context, raw) {
667
713
  const timeoutMs = command.timeoutMs === undefined ? 15 * 60_000 : Number(command.timeoutMs);
668
714
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2 * 60 * 60_000)
669
715
  fail('Invalid verification timeout');
670
- return { repositoryId: repo.id, command: command.command, args: command.args, cwd, ...(env ? { env: env } : {}), timeoutMs };
716
+ if (command.policy !== undefined) {
717
+ const policy = object(command.policy);
718
+ if (Object.keys(policy).some(key => !['reuse', 'inputs', 'deterministic', 'readOnly', 'toolchainInputs', 'independentGroup', 'resources'].includes(key)))
719
+ fail('Invalid verification policy');
720
+ if (policy.reuse !== undefined && !['never', 'snapshot-local'].includes(String(policy.reuse)))
721
+ fail('Invalid verification reuse policy');
722
+ for (const key of ['deterministic', 'readOnly'])
723
+ if (policy[key] !== undefined && typeof policy[key] !== 'boolean')
724
+ fail('Invalid verification policy flag');
725
+ if (policy.independentGroup !== undefined && (typeof policy.independentGroup !== 'string' || !ID.test(policy.independentGroup)))
726
+ fail('Invalid verification independence group');
727
+ for (const key of ['inputs', 'toolchainInputs', 'resources'])
728
+ if (policy[key] !== undefined && (!Array.isArray(policy[key]) || policy[key].length > 256 || policy[key].some((value) => typeof value !== 'string' || !value.trim() || value.length > 4096 || value.includes('\0'))))
729
+ fail('Invalid verification policy inputs');
730
+ }
731
+ if (command.key !== undefined && (typeof command.key !== 'string' || !ID.test(command.key)))
732
+ fail('Invalid verification key');
733
+ if (command.label !== undefined && (typeof command.label !== 'string' || !command.label.trim() || command.label.length > 256 || command.label.includes('\0')))
734
+ fail('Invalid verification label');
735
+ return { ...(command.key === undefined ? {} : { key: command.key }), ...(command.label === undefined ? {} : { label: command.label }), ...(command.policy === undefined ? {} : { policy: command.policy }), repositoryId: repo.id, command: command.command, args: command.args, cwd, ...(env ? { env: env } : {}), timeoutMs };
671
736
  }
672
737
  /**
673
738
  * Native executables retain structured argv; Windows script shims need cmd.
@@ -702,7 +767,22 @@ export function verificationInvocation(command, args, cwd, platform = process.pl
702
767
  const quote = (value) => '"' + value + '"';
703
768
  return { command: env.ComSpec ?? env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', '"' + [resolved, ...args].map(quote).join(' ') + '"'], windowsVerbatimArguments: true };
704
769
  }
705
- async function executeCheck(command, log, signal) {
770
+ /** Shared persisted-output/provider diagnostic redaction; preserves line structure. */
771
+ export function redactRuntimeText(text, env = process.env) {
772
+ for (const [key, value] of Object.entries(env))
773
+ if (/(token|secret|password|api_?key|credential)/i.test(key) && value && value.length >= 4)
774
+ text = text.split(value).join('[redacted]');
775
+ return text.replace(/Bearer\s+[^\s"']+/gi, 'Bearer [redacted]')
776
+ .replace(/((?:api[_-]?key|(?:access[_-]?)?token|password|secret|authorization)["']?\s*[:=]\s*["']?)[^\s,"'}]+/gi, '$1[redacted]')
777
+ .replace(/https?:\/\/[^\s"'<>]+/gi, value => { try {
778
+ const url = new URL(value);
779
+ return url.origin + url.pathname;
780
+ }
781
+ catch {
782
+ return '[url]';
783
+ } });
784
+ }
785
+ async function executeCheck(command, log, signal, deadline, evidenceId = digest(randomUUID())) {
706
786
  const started = Date.now();
707
787
  const overrides = normalizeVerificationEnvironment(command.env ?? {});
708
788
  const overrideKeys = Object.keys(overrides).sort();
@@ -710,20 +790,32 @@ async function executeCheck(command, log, signal) {
710
790
  const hash = environmentHash(keys);
711
791
  const overridesHash = digest(canonical(overrides));
712
792
  const env = verificationEnvironment(process.env, overrides);
793
+ const configuredTimeoutMs = command.timeoutMs ?? 15 * 60_000;
794
+ const appliedTimeoutMs = Math.max(0, Math.min(configuredTimeoutMs, deadline === undefined ? Infinity : deadline - Date.now()));
795
+ let outcome;
713
796
  let output = '';
797
+ const streams = { stdout: '', stderr: '' };
798
+ let outputTruncated = false;
799
+ const redact = (text) => redactRuntimeText(text, { ...process.env, ...overrides });
714
800
  let exitCode = -1;
715
801
  await new Promise((resolve) => {
716
802
  let child;
717
803
  let timer;
718
804
  let done = false;
805
+ let stopping = false;
806
+ let termination;
719
807
  const finish = (code) => { if (done)
720
- return; done = true; exitCode = code; if (timer)
808
+ return; done = true; exitCode = stopping ? -1 : code; outcome ??= code === 0 ? 'passed' : 'failed'; if (termination)
809
+ clearTimeout(termination); if (timer)
721
810
  clearTimeout(timer); signal?.removeEventListener('abort', abort); resolve(); };
722
811
  const stop = (reason) => {
723
- output += '\n' + reason;
812
+ if (done || stopping)
813
+ return;
814
+ stopping = true;
815
+ output = (output + '\n' + reason).slice(-32_000);
724
816
  if (child?.pid) {
725
817
  if (process.platform === 'win32')
726
- spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true });
818
+ spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 5000 });
727
819
  else {
728
820
  try {
729
821
  process.kill(-child.pid, 'SIGKILL');
@@ -731,13 +823,22 @@ async function executeCheck(command, log, signal) {
731
823
  catch { /* already exited */ }
732
824
  }
733
825
  }
734
- finish(-1);
826
+ // Never publish a receipt while a normally terminating child is alive.
827
+ if (!child?.pid)
828
+ finish(-1);
829
+ else
830
+ termination = setTimeout(() => { outcome = 'interrupted'; child.stdout?.destroy(); child.stderr?.destroy(); child.unref(); finish(-1); }, 5000);
735
831
  };
736
- const abort = () => stop('Verification command cancelled');
832
+ const abort = () => { outcome = 'cancelled'; stop('Verification command cancelled'); };
737
833
  if (signal?.aborted) {
738
834
  abort();
739
835
  return;
740
836
  }
837
+ if (appliedTimeoutMs <= 0) {
838
+ outcome = 'timed-out';
839
+ stop('Workflow verification deadline exhausted');
840
+ return;
841
+ }
741
842
  try {
742
843
  const invocation = verificationInvocation(command.command, command.args, command.cwd, process.platform, env);
743
844
  child = spawn(invocation.command, invocation.args, { windowsVerbatimArguments: invocation.windowsVerbatimArguments, cwd: command.cwd, env, shell: false, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
@@ -747,45 +848,265 @@ async function executeCheck(command, log, signal) {
747
848
  finish(-1);
748
849
  return;
749
850
  }
750
- const receive = (chunk) => { const text = chunk.toString('utf8'); output = (output + text).slice(-32_000); log(text); };
751
- child.stdout?.on('data', receive);
752
- child.stderr?.on('data', receive);
753
- child.on('error', (error) => { output += error.message; finish(-1); });
851
+ for (const stream of ['stdout', 'stderr']) {
852
+ const decoder = new StringDecoder('utf8');
853
+ let liveLine = '', droppingLine = false;
854
+ const receive = (text) => {
855
+ const remaining = 1024 * 1024 - Buffer.byteLength(streams[stream]);
856
+ if (Buffer.byteLength(text) > remaining)
857
+ outputTruncated = true;
858
+ if (remaining > 0) {
859
+ let kept = text.slice(0, remaining);
860
+ while (Buffer.byteLength(kept) > remaining || /[\uD800-\uDBFF]$/.test(kept))
861
+ kept = kept.slice(0, -1);
862
+ streams[stream] += kept;
863
+ }
864
+ output = (output + text).slice(-32_000);
865
+ // Redact whole bounded lines, so chunk boundaries cannot split credentials.
866
+ for (const part of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
867
+ if (!droppingLine)
868
+ liveLine += part;
869
+ if (liveLine.length > 16384) {
870
+ liveLine = '';
871
+ droppingLine = true;
872
+ }
873
+ if (part.endsWith('\n')) {
874
+ try {
875
+ log(droppingLine ? '[Long output line omitted; inspect bounded evidence]\n' : redact(liveLine));
876
+ }
877
+ catch { /* observer */ }
878
+ liveLine = '';
879
+ droppingLine = false;
880
+ }
881
+ }
882
+ };
883
+ child[stream]?.on('data', (chunk) => receive(decoder.write(chunk)));
884
+ child[stream]?.on('end', () => { receive(decoder.end()); if (liveLine) {
885
+ try {
886
+ log(redact(liveLine));
887
+ }
888
+ catch { /* observer */ }
889
+ } });
890
+ }
891
+ child.on('error', (error) => { output = (output + error.message).slice(-32_000); if (!child.pid)
892
+ finish(-1); });
754
893
  child.on('close', (code) => finish(code ?? -1));
755
894
  signal?.addEventListener('abort', abort, { once: true });
756
- timer = setTimeout(() => stop('Verification command timed out'), command.timeoutMs);
895
+ timer = setTimeout(() => { outcome = 'timed-out'; stop('Verification command timed out'); }, appliedTimeoutMs);
896
+ if (signal?.aborted)
897
+ abort();
757
898
  });
758
- return { repositoryId: command.repositoryId, command: command.command, args: command.args, cwd: command.cwd, environmentPolicy: 'isolated-transport-v1', environmentHash: hash, environmentKeys: keys, environmentOverrideKeys: overrideKeys, environmentOverridesHash: overridesHash, exitCode, durationMs: Date.now() - started, output };
899
+ const persisted = (text) => {
900
+ const bytes = Buffer.from(redact(text));
901
+ if (bytes.length > 1024 * 1024)
902
+ outputTruncated = true;
903
+ return new StringDecoder('utf8').write(bytes.subarray(0, 1024 * 1024));
904
+ };
905
+ return { evidenceId, stdout: persisted(streams.stdout), stderr: persisted(streams.stderr), outputTruncated, ...(command.key ? { key: command.key } : {}), ...(command.label ? { label: command.label } : {}), configuredTimeoutMs, appliedTimeoutMs, ...(deadline === undefined ? {} : { deadline }), outcome, repositoryId: command.repositoryId, command: command.command, args: command.args, cwd: command.cwd, environmentPolicy: 'isolated-transport-v1', environmentHash: hash, environmentKeys: keys, environmentOverrideKeys: overrideKeys, environmentOverridesHash: overridesHash, exitCode, durationMs: Date.now() - started, output: redact(output) };
906
+ }
907
+ /** Host opt-in is a declaration of determinism, not inferred hermeticity. */
908
+ export function verificationSnapshot(context, command) {
909
+ const policy = command.policy;
910
+ if (policy?.reuse !== 'snapshot-local')
911
+ return { eligible: false, reason: 'reuse-never' };
912
+ if (policy.resources?.length)
913
+ return { eligible: false, reason: 'reuse-ineligible-external-resources' };
914
+ if (!policy.deterministic || !policy.readOnly || !policy.inputs?.length || !policy.toolchainInputs?.length)
915
+ return { eligible: false, reason: 'reuse-ineligible-incomplete-declaration' };
916
+ const repository = context.repositories.find(repo => repo.id === command.repositoryId);
917
+ if (!repository || !path.isAbsolute(command.command))
918
+ return { eligible: false, reason: 'reuse-ineligible-executable-identity' };
919
+ try {
920
+ let count = 0, total = 0;
921
+ const inventory = (inputs, tools) => {
922
+ const entries = [];
923
+ const seen = new Set();
924
+ const visit = (file) => {
925
+ if (seen.has(file))
926
+ return;
927
+ seen.add(file);
928
+ if (++count > 10000)
929
+ fail('reuse-ineligible-inventory-limit');
930
+ const stat = lstatSync(file);
931
+ if (stat.isSymbolicLink() || realpathSync(file) !== path.resolve(file))
932
+ fail('reuse-ineligible-symlink');
933
+ if (stat.isDirectory()) {
934
+ entries.push([file, 'directory']);
935
+ for (const name of readdirSync(file).sort())
936
+ visit(path.join(file, name));
937
+ }
938
+ else if (stat.isFile()) {
939
+ total += stat.size;
940
+ if (total > 256 * 1024 * 1024 || stat.size > 128 * 1024 * 1024)
941
+ fail('reuse-ineligible-inventory-limit');
942
+ entries.push([file, digest(readFileSync(file)) + ':' + stat.mode]);
943
+ }
944
+ else
945
+ fail('reuse-ineligible-nonfile-input');
946
+ };
947
+ for (const input of inputs) {
948
+ const file = path.resolve(repository.path, input);
949
+ if (!tools && !within(repository.path, file))
950
+ fail('reuse-ineligible-input-scope');
951
+ visit(file);
952
+ }
953
+ return entries.sort(([left], [right]) => left.localeCompare(right));
954
+ };
955
+ const inputs = inventory(policy.inputs, false);
956
+ const tools = inventory(policy.toolchainInputs, true);
957
+ if (!tools.some(([file]) => file === path.resolve(command.command)))
958
+ return { eligible: false, reason: 'reuse-ineligible-executable-not-declared' };
959
+ // A lockfile alone cannot certify ignored dependency contents. Host manifests
960
+ // must include each installed dependency directory this convention exposes.
961
+ for (const directory of ['node_modules', '.venv', 'venv', 'vendor']) {
962
+ const file = path.join(repository.path, directory);
963
+ if (existsSync(file) && ![...inputs, ...tools].some(([item]) => item === file))
964
+ return { eligible: false, reason: 'reuse-ineligible-dependencies-not-declared' };
965
+ }
966
+ return { eligible: true, reason: 'snapshot-local-host-declaration', inputHash: digest(canonical(inputs)), toolchainHash: digest(canonical(tools)) };
967
+ }
968
+ catch (error) {
969
+ return { eligible: false, reason: error instanceof Error && error.message.startsWith('reuse-ineligible-') ? error.message : 'reuse-ineligible-input-unavailable' };
970
+ }
759
971
  }
760
- export async function verifyPipeline(contextInput, raw, log = () => { }, signal) {
972
+ /** Contiguous waves preserve baseline order; an uncertain check is a barrier. */
973
+ export function verificationWaves(commands, maxConcurrency = 1) {
974
+ if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4)
975
+ fail('Verification concurrency must be 1–4');
976
+ const waves = [];
977
+ let current = [];
978
+ for (const command of commands) {
979
+ const policy = command.policy;
980
+ const independent = Boolean(policy?.independentGroup && policy.resources !== undefined);
981
+ const fits = independent && current.length < maxConcurrency && current.every(previous => previous.repositoryId !== command.repositoryId && previous.policy?.independentGroup === policy.independentGroup && previous.policy?.resources !== undefined && !previous.policy.resources.some(resource => policy.resources.includes(resource)));
982
+ if (!fits && current.length) {
983
+ waves.push(current);
984
+ current = [];
985
+ }
986
+ current.push(command);
987
+ if (!independent || current.length === maxConcurrency) {
988
+ waves.push(current);
989
+ current = [];
990
+ }
991
+ }
992
+ if (current.length)
993
+ waves.push(current);
994
+ return waves;
995
+ }
996
+ export async function verifyPipeline(contextInput, raw, log = () => { }, signal, options = {}) {
761
997
  const context = validatePipelineContext(contextInput);
762
998
  const { request, commands, unverifiedRepositories } = verificationPlan(context, raw);
763
- const state = readState(context);
999
+ let previous;
1000
+ const state = locked(context, () => {
1001
+ const current = readState(context);
1002
+ const problems = planReasons(current, typeof request.planHash === 'string' ? request.planHash : undefined);
1003
+ if (problems.length)
1004
+ fail(problems.join('; '));
1005
+ previous = current.verification;
1006
+ if (current.verification)
1007
+ current.verification = { ...current.verification, valid: false, reason: 'Verification started; previous receipt is no longer current' };
1008
+ current.acceptance = undefined;
1009
+ current.archiveApproval = undefined;
1010
+ saveState(current);
1011
+ return current;
1012
+ });
1013
+ const identityProblems = planReasons(state, typeof request.planHash === 'string' ? request.planHash : undefined);
1014
+ if (identityProblems.length)
1015
+ fail(identityProblems.join('; '));
764
1016
  const candidateHash = fingerprintCandidate(state);
765
1017
  const results = [];
766
- for (const command of commands) {
767
- results.push(await executeCheck(command, log, signal));
768
- if (results[results.length - 1].exitCode !== 0)
769
- break;
1018
+ const controller = new AbortController();
1019
+ const abort = () => controller.abort();
1020
+ signal?.addEventListener('abort', abort, { once: true });
1021
+ if (signal?.aborted)
1022
+ abort();
1023
+ try {
1024
+ for (const wave of verificationWaves(commands, options.maxConcurrency)) {
1025
+ if (controller.signal.aborted)
1026
+ break;
1027
+ const settled = await Promise.allSettled(wave.map(async (command) => {
1028
+ try {
1029
+ const prefix = `[verification ${command.repositoryId}/${command.label ?? command.key ?? command.command}] `;
1030
+ const snapshot = verificationSnapshot(context, command);
1031
+ const old = previous?.valid && previous.kind === 'full' && previous.planHash === request.planHash && previous.candidateHash === candidateHash ? previous.commands.find(item => item.key === command.key && command.key !== undefined) : undefined;
1032
+ const reuse = snapshot.eligible && old?.snapshot?.eligible && old.exitCode === 0 && old.checkDefinitionHash === digest(canonical(command)) && old.snapshot.inputHash === snapshot.inputHash && old.snapshot.toolchainHash === snapshot.toolchainHash && old.environmentHash === environmentHash(verificationEnvironmentKeys(process.env, old.environmentOverrideKeys)) && old.environmentOverridesHash === digest(canonical(normalizeVerificationEnvironment(command.env ?? {})));
1033
+ const evidenceId = digest(randomUUID());
1034
+ if (!reuse) {
1035
+ const pending = { repositoryId: command.repositoryId, key: command.key, label: command.label, command: command.command, args: command.args, cwd: command.cwd, environmentHash: '', environmentKeys: [], environmentOverrideKeys: [], environmentOverridesHash: '', evidenceId, pending: true, disposition: 'executed', outcome: 'interrupted', exitCode: -1, durationMs: 0, output: '' };
1036
+ persistCheckEvidence(context, pending, request.planHash ?? digest(canonical(commands)), candidateHash);
1037
+ await options.onEvidence?.('check-started', { executionId: evidenceId, repositoryId: command.repositoryId, checkId: command.key ?? '', label: command.label ?? command.command });
1038
+ }
1039
+ const result = reuse ? { ...old, evidenceId, disposition: 'reused', reusedFrom: old.evidenceId, durationMs: 0, snapshot, reuseReason: 'snapshot-local-identities-match' } : { ...await executeCheck(command, text => log(prefix + text), controller.signal, options.deadline, evidenceId), disposition: 'executed', snapshot, reuseReason: snapshot.eligible ? 'snapshot-local-no-current-match' : snapshot.reason };
1040
+ const { env: _env, ...definition } = command;
1041
+ result.checkDefinition = definition;
1042
+ result.checkDefinitionHash = digest(canonical(command));
1043
+ const after = verificationSnapshot(context, command);
1044
+ if (snapshot.eligible && (!after.eligible || after.inputHash !== snapshot.inputHash || after.toolchainHash !== snapshot.toolchainHash)) {
1045
+ result.exitCode = -1;
1046
+ result.outcome = 'failed';
1047
+ result.reuseReason = 'snapshot-inputs-changed-during-verification';
1048
+ }
1049
+ if (result.exitCode !== 0)
1050
+ controller.abort();
1051
+ persistCheckEvidence(context, result, request.planHash ?? digest(canonical(commands)), candidateHash);
1052
+ await options.onEvidence?.(reuse ? 'check-reused' : 'check-finished', { executionId: evidenceId, repositoryId: command.repositoryId, checkId: command.key ?? '', label: command.label ?? command.command, exitCode: result.exitCode, durationMs: result.durationMs, reason: result.reuseReason ?? null });
1053
+ return result;
1054
+ }
1055
+ catch (error) {
1056
+ controller.abort();
1057
+ throw error;
1058
+ }
1059
+ }));
1060
+ for (const result of settled) {
1061
+ if (result.status === 'fulfilled')
1062
+ results.push(result.value);
1063
+ else
1064
+ controller.abort();
1065
+ }
1066
+ const rejected = settled.find(result => result.status === 'rejected');
1067
+ if (rejected?.status === 'rejected')
1068
+ throw rejected.reason;
1069
+ if (results.some(result => result.exitCode !== 0))
1070
+ break;
1071
+ }
770
1072
  }
771
- return locked(context, () => {
1073
+ finally {
1074
+ signal?.removeEventListener('abort', abort);
1075
+ }
1076
+ const notRunEvidenceIds = [];
1077
+ for (const command of commands.slice(results.length)) {
1078
+ const id = digest(randomUUID());
1079
+ const skipped = { repositoryId: command.repositoryId, key: command.key, label: command.label, command: command.command, args: command.args, cwd: command.cwd, environmentHash: '', environmentKeys: [], environmentOverrideKeys: [], environmentOverridesHash: '', evidenceId: id, disposition: 'not-run', outcome: 'cancelled', exitCode: -1, durationMs: 0, output: 'Not run after cancellation or an earlier failed check' };
1080
+ persistCheckEvidence(context, skipped, request.planHash ?? digest(canonical(commands)), candidateHash);
1081
+ notRunEvidenceIds.push(id);
1082
+ }
1083
+ const receipt = locked(context, () => {
772
1084
  const current = readState(context);
773
- const changed = fingerprintCandidate(current) !== candidateHash || current.revision !== state.revision;
1085
+ const changed = fingerprintCandidate(current) !== candidateHash || current.revision !== state.revision || planReasons(current, request.planHash).length > 0;
774
1086
  const receipt = {
775
- id: randomUUID(), kind: request.kind, scopeHash: state.scopeHash, candidateHash, commands: results,
776
- completedAt: new Date().toISOString(), valid: !changed && results.length === commands.length && results.every((result) => result.exitCode === 0),
1087
+ id: randomUUID(), ...(state.verificationPlan ? { planHash: state.verificationPlan.hash } : {}), kind: request.kind, scopeHash: state.scopeHash, candidateHash, commands: results, ...(notRunEvidenceIds.length ? { notRunEvidenceIds } : {}),
1088
+ completedAt: new Date().toISOString(), valid: !changed && !signal?.aborted && (options.deadline === undefined || Date.now() <= options.deadline) && results.length === commands.length && results.every((result) => result.exitCode === 0),
777
1089
  ...(changed ? { reason: 'Candidate changed during verification' } : results.some((result) => result.exitCode !== 0) ? { reason: 'A verification command failed' } : {}),
778
1090
  ...(unverifiedRepositories.length ? { unverifiedRepositories } : {}),
779
1091
  };
780
1092
  atomicJson(safeChild(pipelineStateDirectory(context), 'receipts/' + receipt.id + '.json'), receipt);
781
1093
  if (receipt.kind === 'full' || !receipt.valid || !current.verification)
782
1094
  current.verification = receipt;
1095
+ else if (previous?.valid && previous.candidateHash === candidateHash)
1096
+ current.verification = previous;
783
1097
  saveState(current);
784
1098
  return receipt;
785
1099
  });
1100
+ if (!receipt.valid)
1101
+ for (const result of results) {
1102
+ await options.onEvidence?.('check-invalidated', { executionId: result.evidenceId ?? '', repositoryId: result.repositoryId, checkId: result.key ?? '', label: result.label ?? result.command, reason: receipt.reason ?? 'Verification did not complete successfully' });
1103
+ }
1104
+ return receipt;
786
1105
  }
787
1106
  function verificationPlan(context, raw) {
788
1107
  const request = object(raw);
1108
+ if (request.planHash !== undefined && (typeof request.planHash !== 'string' || !/^[a-f0-9]{64}$/.test(request.planHash)))
1109
+ fail('Invalid verification plan hash');
789
1110
  const unverified = request.unverified === true;
790
1111
  if (request.unverified !== undefined && typeof request.unverified !== 'boolean')
791
1112
  fail('Verification unverified flag must be boolean');
@@ -998,4 +1319,132 @@ export async function runPipelineCli(argv) {
998
1319
  return 1;
999
1320
  }
1000
1321
  }
1322
+ function evidenceSummary(document) {
1323
+ const { stdout: _out, stderr: _err, sources, ...summary } = document;
1324
+ return { ...summary, sources: sources.map(({ text: _text, ...source }) => source) };
1325
+ }
1326
+ function persistCheckEvidence(context, result, planHash, candidateHash) {
1327
+ const id = result.evidenceId;
1328
+ const planPath = safeChild(pipelineStateDirectory(context), 'verification/plan.json');
1329
+ const plan = existsSync(planPath) ? object(readJson(planPath)) : undefined;
1330
+ const entries = Array.isArray(plan?.entries) ? plan.entries : [];
1331
+ const item = entries.find(entry => object(entry).id === result.key);
1332
+ const sources = [];
1333
+ for (const source of item?.harness?.sources ?? []) {
1334
+ if (sources.length >= 8)
1335
+ fail('Too many evidence sources');
1336
+ const file = safeChild(pipelineStateDirectory(context), `verification/harnesses/${item.id}/${item.harness.hash}/${source.path}`);
1337
+ if (lstatSync(file).size > 65536)
1338
+ fail('Evidence source exceeds size limit');
1339
+ const bytes = readFileSync(file);
1340
+ if (digest(bytes) !== source.hash)
1341
+ fail('Verification source changed during execution');
1342
+ sources.push({ id: digest(id + '\0' + source.path), displayPath: source.path, hash: source.hash, byteCount: bytes.length, text: bytes.toString('utf8') });
1343
+ }
1344
+ const stdout = result.stdout ?? '', stderr = result.stderr ?? '';
1345
+ const document = {
1346
+ schemaVersion: 1, runId: context.runId, id, executionId: id, checkId: result.key ?? digest(canonical([result.repositoryId, result.command, result.args])), repositoryId: result.repositoryId,
1347
+ label: result.label ?? result.command, origin: item?.origins ?? ['host'], required: true, disposition: result.disposition ?? 'executed', ...(result.reusedFrom ? { reusedFrom: result.reusedFrom } : {}),
1348
+ status: result.outcome === 'cancelled' ? 'cancelled' : result.outcome === 'interrupted' ? 'interrupted' : result.exitCode === 0 ? 'passed' : 'failed',
1349
+ ...(result.reuseReason === undefined ? {} : { reuseReason: result.reuseReason }), recordedAt: new Date().toISOString(),
1350
+ execution: { command: redactRuntimeText(result.command), args: result.args.map(arg => redactRuntimeText(arg)), cwd: result.cwd, configuredTimeoutMs: result.configuredTimeoutMs ?? null, appliedTimeoutMs: result.appliedTimeoutMs ?? null, deadline: result.deadline ?? null, outcome: result.outcome ?? null },
1351
+ planHash, candidateHash, exitCode: result.pending ? null : result.exitCode, durationMs: result.pending ? null : result.durationMs, outputTruncated: result.outputTruncated ?? false,
1352
+ stdout, stderr, stdoutBytes: Buffer.byteLength(stdout), stderrBytes: Buffer.byteLength(stderr), stdoutHash: digest(stdout), stderrHash: digest(stderr), sources,
1353
+ };
1354
+ atomicJson(safeChild(pipelineStateDirectory(context), 'verification/evidence/' + id + '.json'), { document, integrity: digest(canonical(document)) });
1355
+ const summary = evidenceSummary(document);
1356
+ atomicJson(safeChild(pipelineStateDirectory(context), 'verification/evidence-index/' + id + '.json'), { summary, integrity: digest(canonical(summary)) });
1357
+ // Receipt/status payloads carry references and short tails only.
1358
+ delete result.stdout;
1359
+ delete result.stderr;
1360
+ }
1361
+ /** Historical read: no live repository validation, source execution or lifecycle mutation. */
1362
+ export function readVerificationEvidence(context, query = {}) {
1363
+ if (!ID.test(context.runId) || !path.isAbsolute(context.backlogRoot))
1364
+ fail('Invalid evidence scope');
1365
+ if (Object.keys(query).some(key => !['id', 'section', 'sourceId', 'cursor', 'limit'].includes(key)))
1366
+ fail('Invalid evidence query');
1367
+ const section = query.section ?? 'summary', limit = query.limit ?? 25;
1368
+ if (!['summary', 'stdout', 'stderr', 'source'].includes(section) || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
1369
+ fail('Invalid evidence section or limit');
1370
+ if ((section === 'source') !== (query.sourceId !== undefined) || (!query.id && section !== 'summary'))
1371
+ fail('Source section requires a registered source ID and evidence ID');
1372
+ for (const value of [query.id, query.sourceId])
1373
+ if (value !== undefined && !/^[a-f0-9]{64}$/.test(value))
1374
+ fail('Invalid opaque evidence ID');
1375
+ const directory = safeChild(context.backlogRoot, '.specrails/pipeline/' + context.runId + '/verification/evidence');
1376
+ const binding = digest(canonical({ runId: context.runId, id: query.id ?? null, section, sourceId: query.sourceId ?? null }));
1377
+ let offset = 0;
1378
+ if (query.cursor !== undefined) {
1379
+ if (typeof query.cursor !== 'string' || query.cursor.length > 1024)
1380
+ fail('Invalid evidence cursor');
1381
+ try {
1382
+ const cursor = JSON.parse(Buffer.from(query.cursor, 'base64url').toString('utf8'));
1383
+ if (cursor.binding !== binding || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0)
1384
+ fail('Invalid evidence cursor');
1385
+ offset = cursor.offset;
1386
+ }
1387
+ catch {
1388
+ fail('Invalid evidence cursor');
1389
+ }
1390
+ }
1391
+ const cursor = (next) => Buffer.from(JSON.stringify({ binding, offset: next })).toString('base64url');
1392
+ const load = (id) => {
1393
+ const file = safeChild(directory, id + '.json');
1394
+ if (lstatSync(file).size > 16 * 1024 * 1024)
1395
+ fail('Evidence exceeds size limit');
1396
+ const envelope = object(readJson(file)), document = envelope.document;
1397
+ if (!document || document.schemaVersion !== 1 || document.runId !== context.runId || document.id !== id || digest(canonical(document)) !== envelope.integrity)
1398
+ fail('Evidence integrity check failed');
1399
+ return document;
1400
+ };
1401
+ if (!existsSync(directory) || (query.id && !existsSync(safeChild(directory, query.id + '.json'))))
1402
+ return { schemaVersion: 1, available: false, items: [], truncated: false };
1403
+ if (!query.id) {
1404
+ const indexDirectory = safeChild(context.backlogRoot, '.specrails/pipeline/' + context.runId + '/verification/evidence-index');
1405
+ if (!existsSync(indexDirectory))
1406
+ return { schemaVersion: 1, available: false, items: [], truncated: false };
1407
+ const ids = readdirSync(indexDirectory).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).sort();
1408
+ if (ids.length > 10000)
1409
+ fail('Evidence index exceeds limit');
1410
+ const selected = ids.slice(offset, offset + limit);
1411
+ return { schemaVersion: 1, available: true, items: selected.map(file => {
1412
+ const indexFile = safeChild(indexDirectory, file);
1413
+ if (lstatSync(indexFile).size > 65536)
1414
+ fail('Evidence summary exceeds limit');
1415
+ const index = object(readJson(indexFile)), summary = object(index.summary);
1416
+ if (summary.id !== file.slice(0, -5) || summary.runId !== context.runId || index.integrity !== digest(canonical(summary)))
1417
+ fail('Evidence index integrity check failed');
1418
+ return summary;
1419
+ }), truncated: offset + selected.length < ids.length, ...(offset + selected.length < ids.length ? { nextCursor: cursor(offset + selected.length) } : {}) };
1420
+ }
1421
+ const document = load(query.id);
1422
+ if (section === 'summary')
1423
+ return { schemaVersion: 1, available: true, items: [evidenceSummary(document)], truncated: false };
1424
+ const source = section === 'source' ? document.sources.find(source => source.id === query.sourceId) : undefined;
1425
+ if (section === 'source' && !source)
1426
+ fail('Source does not belong to this evidence');
1427
+ let outputDocument = document;
1428
+ const visited = new Set([document.id]);
1429
+ while (outputDocument.reusedFrom && section !== 'source') {
1430
+ if (visited.has(outputDocument.reusedFrom) || visited.size > 100)
1431
+ fail('Invalid reused evidence chain');
1432
+ visited.add(outputDocument.reusedFrom);
1433
+ outputDocument = load(outputDocument.reusedFrom);
1434
+ }
1435
+ const content = source?.text ?? (section === 'stdout' ? outputDocument.stdout : outputDocument.stderr);
1436
+ // Character offsets are opaque. Stop at code-point boundaries within 64 KiB.
1437
+ if (offset > content.length || (offset > 0 && /[\uDC00-\uDFFF]/.test(content[offset] ?? '')))
1438
+ fail('Invalid evidence text cursor');
1439
+ let text = '', bytes = 0;
1440
+ for (const character of content.slice(offset)) {
1441
+ const size = Buffer.byteLength(character);
1442
+ if (bytes + size > 65536)
1443
+ break;
1444
+ text += character;
1445
+ bytes += size;
1446
+ }
1447
+ const next = offset + text.length;
1448
+ return { schemaVersion: 1, available: true, text, byteCount: bytes, totalBytes: Buffer.byteLength(content), truncated: next < content.length || document.outputTruncated, ...(next < content.length ? { nextCursor: cursor(next) } : {}) };
1449
+ }
1001
1450
  //# sourceMappingURL=pipeline-state.js.map