agent-scenario-loop 0.1.4 → 0.1.6

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.
@@ -7,6 +7,7 @@ declare const ARTIFACT_FILENAMES: {
7
7
  liveProofSet: string;
8
8
  plannerCompatibility: string;
9
9
  projectValidation: string;
10
+ runPlan: string;
10
11
  verdict: string;
11
12
  };
12
13
  declare const PROFILE_ARTIFACT_FILENAMES: {
@@ -34,6 +35,7 @@ type ArtifactLayout = {
34
35
  liveProofSet: string;
35
36
  plannerCompatibility: string;
36
37
  projectValidation: string;
38
+ runPlan: string;
37
39
  raw: string;
38
40
  captures: string;
39
41
  signals: {
@@ -13,6 +13,7 @@ const ARTIFACT_FILENAMES = {
13
13
  liveProofSet: 'live-proof-set.json',
14
14
  plannerCompatibility: 'planner-compatibility.json',
15
15
  projectValidation: 'project-validation.json',
16
+ runPlan: 'run-plan.json',
16
17
  verdict: 'verdict.json',
17
18
  };
18
19
  exports.ARTIFACT_FILENAMES = ARTIFACT_FILENAMES;
@@ -49,6 +50,7 @@ function createArtifactLayout({ outputDir }) {
49
50
  liveProofSet: path.join(outputDir, ARTIFACT_FILENAMES.liveProofSet),
50
51
  plannerCompatibility: path.join(outputDir, ARTIFACT_FILENAMES.plannerCompatibility),
51
52
  projectValidation: path.join(outputDir, ARTIFACT_FILENAMES.projectValidation),
53
+ runPlan: path.join(outputDir, ARTIFACT_FILENAMES.runPlan),
52
54
  raw: path.join(outputDir, 'raw'),
53
55
  captures: path.join(outputDir, 'captures'),
54
56
  signals: {
@@ -724,14 +724,15 @@ function validatePrimaryRunner({ runner, errors, }) {
724
724
  * @returns {string[]}
725
725
  */
726
726
  function resolveEffectivePlatforms({ scenario, runner, platform, errors, }) {
727
- const scenarioPlatforms = asArray(scenario?.platforms);
727
+ const declaredScenarioPlatforms = asArray(scenario?.platforms);
728
728
  const runnerPlatforms = asArray(runner?.platforms);
729
+ const scenarioPlatforms = declaredScenarioPlatforms.length > 0 ? declaredScenarioPlatforms : runnerPlatforms;
729
730
  if (platform) {
730
- if (!scenarioPlatforms.includes(platform)) {
731
+ if (declaredScenarioPlatforms.length > 0 && !declaredScenarioPlatforms.includes(platform)) {
731
732
  errors.push(createIssue('platform_not_supported_by_scenario', 'The scenario does not support the selected platform.', {
732
733
  scenarioId: getScenarioId(scenario),
733
734
  platform,
734
- supportedPlatforms: uniqueSorted(scenarioPlatforms),
735
+ supportedPlatforms: uniqueSorted(declaredScenarioPlatforms),
735
736
  }));
736
737
  }
737
738
  if (!runnerPlatforms.includes(platform)) {
@@ -17,7 +17,7 @@ const fs = require('node:fs');
17
17
  const path = require('node:path');
18
18
  const { hasHelpFlag } = require('./cli');
19
19
  const { ANDROID_DEVICE_EPOCH_MS_PLACEHOLDER, parsePositiveInteger, runAndroidAdbPreflight, } = require('./android-adb');
20
- const { parseArgs, readScalarArg, runProfileMobile, usage, } = require('./profile-mobile');
20
+ const { parseArgs, readScalarArg, resolveArtifactRoot, resolveProfileScenarioName, runProfileCompatibilityPreflight, runProfileMobile, usage, } = require('./profile-mobile');
21
21
  exports.parseArgs = parseArgs;
22
22
  exports.usage = usage;
23
23
  const { buildScenarioExecutionPlan } = require('../core/execution-plan');
@@ -46,6 +46,17 @@ const MANIFEST_LIFECYCLE_PHASES = new Set([
46
46
  'reboot',
47
47
  'relaunch',
48
48
  ]);
49
+ const ANDROID_PROFILE_RUNNER_CAPABILITIES = {
50
+ schemaVersion: '1.0.0',
51
+ runnerId: 'android-adb-profile-runner',
52
+ kind: 'primary',
53
+ platforms: ['android'],
54
+ capabilities: ['launch', 'sessionControl', 'command', 'logCapture', 'artifactWrite'],
55
+ driverActions: ['tap', 'scroll', 'assertVisible', 'inspectTree', 'screenshot', 'record', 'readLogs'],
56
+ artifactOutputs: ['logs', 'signals', 'screenshot', 'video', 'uiTree'],
57
+ uiContexts: ['app'],
58
+ lifecycle: ['prepare', 'launch', 'startSession', 'executeStep', 'waitForTruthEvent', 'captureEvidence', 'stopSession', 'finalize'],
59
+ };
49
60
  /**
50
61
  * Reads and parses a JSON object from disk.
51
62
  *
@@ -753,8 +764,32 @@ async function runProfileAndroid(args, options = {}) {
753
764
  const config = readJson(path.resolve(args.config));
754
765
  const scenario = readJson(path.resolve(args.scenario));
755
766
  const runId = typeof args['run-id'] === 'string' ? args['run-id'] : createRunId();
767
+ const scenarioName = resolveProfileScenarioName({ scenario, scenarioPath: path.resolve(args.scenario) });
768
+ const artifactRoot = resolveArtifactRoot({
769
+ args,
770
+ config,
771
+ configPath: path.resolve(args.config),
772
+ platform: 'android',
773
+ });
756
774
  const adbCaptureEnabled = isEnabled(args['adb-capture']);
757
775
  const agentDeviceCaptureEnabled = isEnabled(args['agent-device-capture']);
776
+ const driverSteps = adbCaptureEnabled ? resolveAndroidAdbDriverSteps(scenario) : [];
777
+ if (adbCaptureEnabled) {
778
+ const driverStepErrors = validateAndroidAdbDriverSteps(driverSteps);
779
+ if (driverStepErrors.length > 0) {
780
+ throw new Error(`Invalid Android adb driver step metadata: ${driverStepErrors.join(' ')}`);
781
+ }
782
+ }
783
+ await runProfileCompatibilityPreflight({
784
+ args,
785
+ artifactRoot,
786
+ platform: 'android',
787
+ primaryRunner: ANDROID_PROFILE_RUNNER_CAPABILITIES,
788
+ runDir: path.join(artifactRoot, scenarioName, runId),
789
+ runId,
790
+ scenario,
791
+ scenarioName,
792
+ });
758
793
  const profileSessionEnabled = isEnabled(args['profile-session']);
759
794
  const profileSessionStorageEnabled = isEnabled(args['android-profile-session-storage']);
760
795
  const profileSessionStorageKey = readStringArgOrEnv(args['android-profile-session-storage-key'], [
@@ -793,14 +828,6 @@ async function runProfileAndroid(args, options = {}) {
793
828
  'ASL_ANDROID_ADB_COMMAND_TIMEOUT_MS',
794
829
  'ASL_EXAMPLE_ANDROID_ADB_COMMAND_TIMEOUT_MS',
795
830
  ]), 30000);
796
- const scenarioName = typeof scenario.name === 'string' ? scenario.name : path.basename(args.scenario, '.json');
797
- const driverSteps = adbCaptureEnabled ? resolveAndroidAdbDriverSteps(scenario) : [];
798
- if (adbCaptureEnabled) {
799
- const driverStepErrors = validateAndroidAdbDriverSteps(driverSteps);
800
- if (driverStepErrors.length > 0) {
801
- throw new Error(`Invalid Android adb driver step metadata: ${driverStepErrors.join(' ')}`);
802
- }
803
- }
804
831
  const profileSessionCommands = profileSessionEnabled ? resolveAndroidAdbProfileCommands(scenario) : [];
805
832
  const commandWaitMs = parsePositiveInteger(readScalarArg(args['command-wait-ms']), 250);
806
833
  const profileSessionDeepLinks = profileSessionEnabled && !profileSessionStorageEnabled
@@ -18,7 +18,7 @@ const fs = require('node:fs');
18
18
  const path = require('node:path');
19
19
  const { hasHelpFlag } = require('./cli');
20
20
  const { buildScenarioExecutionPlan } = require('../core/execution-plan');
21
- const { buildProfileHealth, buildProfileVerdict, buildVerdictBudgetChecks, parseArgs, readScalarArg, runProfileCli, runProfileMobile, usage, } = require('./profile-mobile');
21
+ const { buildProfileHealth, buildProfileVerdict, buildVerdictBudgetChecks, parseArgs, readScalarArg, resolveArtifactRoot, resolveProfileScenarioName, runProfileCompatibilityPreflight, runProfileCli, runProfileMobile, usage, } = require('./profile-mobile');
22
22
  exports.buildProfileHealth = buildProfileHealth;
23
23
  exports.buildProfileVerdict = buildProfileVerdict;
24
24
  exports.buildVerdictBudgetChecks = buildVerdictBudgetChecks;
@@ -52,6 +52,17 @@ const MANIFEST_LIFECYCLE_PHASES = new Set([
52
52
  'reboot',
53
53
  'relaunch',
54
54
  ]);
55
+ const IOS_PROFILE_RUNNER_CAPABILITIES = {
56
+ schemaVersion: '1.0.0',
57
+ runnerId: 'ios-simctl-profile-runner',
58
+ kind: 'primary',
59
+ platforms: ['ios'],
60
+ capabilities: ['launch', 'sessionControl', 'command', 'logCapture', 'artifactWrite'],
61
+ driverActions: ['tap', 'scroll', 'assertVisible', 'inspectTree', 'screenshot', 'readLogs'],
62
+ artifactOutputs: ['logs', 'signals', 'screenshot', 'uiTree'],
63
+ uiContexts: ['app'],
64
+ lifecycle: ['prepare', 'launch', 'startSession', 'executeStep', 'waitForTruthEvent', 'captureEvidence', 'stopSession', 'finalize'],
65
+ };
55
66
  /**
56
67
  * Reads and parses a JSON object from disk.
57
68
  *
@@ -608,6 +619,23 @@ async function runProfileIos(args, options = {}) {
608
619
  const config = readJson(path.resolve(args.config));
609
620
  const scenario = readJson(path.resolve(args.scenario));
610
621
  const runId = typeof args['run-id'] === 'string' ? args['run-id'] : createRunId();
622
+ const scenarioName = resolveProfileScenarioName({ scenario, scenarioPath: path.resolve(args.scenario) });
623
+ const artifactRoot = resolveArtifactRoot({
624
+ args,
625
+ config,
626
+ configPath: path.resolve(args.config),
627
+ platform: 'ios',
628
+ });
629
+ await runProfileCompatibilityPreflight({
630
+ args,
631
+ artifactRoot,
632
+ platform: 'ios',
633
+ primaryRunner: IOS_PROFILE_RUNNER_CAPABILITIES,
634
+ runDir: path.join(artifactRoot, scenarioName, runId),
635
+ runId,
636
+ scenario,
637
+ scenarioName,
638
+ });
611
639
  const profileSessionEnabled = isEnabled(args['profile-session']);
612
640
  const profileSessionStorageEnabled = isEnabled(args['profile-session-storage']);
613
641
  const profileSessionStorageKey = readStringArgOrEnv(args['ios-profile-session-storage-key'], [
@@ -638,7 +666,6 @@ async function runProfileIos(args, options = {}) {
638
666
  'ASL_IOS_DEV_CLIENT_WAIT_MS',
639
667
  'ASL_EXAMPLE_IOS_DEV_CLIENT_WAIT_MS',
640
668
  ]), 1000);
641
- const scenarioName = typeof scenario.name === 'string' ? scenario.name : path.basename(args.scenario, '.json');
642
669
  const profileSessionCommands = profileSessionEnabled ? resolveIosSimctlProfileCommands(scenario) : [];
643
670
  const iosDevClientDeepLinks = iosDevClientUrl
644
671
  ? [
@@ -10,6 +10,7 @@ type CliArgs = {
10
10
  events?: string | boolean;
11
11
  out?: string | boolean;
12
12
  provider?: CliArgValue;
13
+ 'profile-session-entries'?: string | boolean;
13
14
  'run-id'?: string | boolean;
14
15
  signal?: CliArgValue;
15
16
  [key: string]: CliArgValue | undefined;
@@ -19,6 +20,16 @@ type ProfileRunResult = {
19
20
  health: Record<string, unknown>;
20
21
  verdict: Record<string, unknown>;
21
22
  };
23
+ type CompatibilityPreflightOptions = {
24
+ args: CliArgs;
25
+ artifactRoot: string;
26
+ platform: ProfilePlatform;
27
+ primaryRunner: Record<string, unknown>;
28
+ runDir: string;
29
+ runId: string;
30
+ scenario: Record<string, unknown>;
31
+ scenarioName: string;
32
+ };
22
33
  type ProfilePlatform = 'android' | 'ios';
23
34
  type ProfileMobileOptions = {
24
35
  commandTransport?: string;
@@ -259,6 +270,26 @@ declare function resolveEventLogPath({ args, platform }: {
259
270
  args: CliArgs;
260
271
  platform: ProfilePlatform;
261
272
  }): string | null;
273
+ /**
274
+ * Resolves the profile scenario name from modern or legacy scenario identity fields.
275
+ *
276
+ * @param {{scenario: Record<string, unknown>, scenarioPath: string}} options
277
+ * @returns {string}
278
+ */
279
+ declare function resolveProfileScenarioName({ scenario, scenarioPath, }: {
280
+ scenario: Record<string, unknown>;
281
+ scenarioPath: string;
282
+ }): string;
283
+ /**
284
+ * Runs planner compatibility before a live profile capture starts.
285
+ *
286
+ * Failed compatibility writes classified artifacts in the profile run folder so
287
+ * agents can stop before adb, simctl, or provider work consumes runtime time.
288
+ *
289
+ * @param {CompatibilityPreflightOptions} options
290
+ * @returns {Promise<void>}
291
+ */
292
+ declare function runProfileCompatibilityPreflight({ args, artifactRoot, platform, primaryRunner, runDir, runId, scenario, scenarioName, }: CompatibilityPreflightOptions): Promise<void>;
262
293
  /**
263
294
  * Creates a stable fingerprint for the scenario contract used by one run.
264
295
  *
@@ -286,5 +317,5 @@ declare function runProfileCli({ argv, binaryName, defaultDriver, platform, }: {
286
317
  defaultDriver: string;
287
318
  platform: ProfilePlatform;
288
319
  }): Promise<void>;
289
- export { buildProfileHealth, buildProviderCommandFailureHealth, buildProfileVerdict, buildVerdictBudgetChecks, parseArgs, buildEvidenceAttachmentManifest, readScalarArg, resolveAppId, resolveArtifactRoot, resolveAttachedEvidence, resolveComparisonLane, resolveEventLogPath, resolveInteractionDriver, runProfileCli, runProfileMobile, hashScenarioContract, usage, };
320
+ export { buildProfileHealth, buildProviderCommandFailureHealth, buildProfileVerdict, buildVerdictBudgetChecks, parseArgs, buildEvidenceAttachmentManifest, readScalarArg, resolveAppId, resolveArtifactRoot, resolveAttachedEvidence, resolveComparisonLane, resolveEventLogPath, resolveInteractionDriver, resolveProfileScenarioName, runProfileCompatibilityPreflight, runProfileCli, runProfileMobile, hashScenarioContract, usage, };
290
321
  export type { CliArgs, ProfileMobileOptions, ProfilePlatform, ProfileRunResult, };
@@ -14,11 +14,13 @@ exports.resolveAttachedEvidence = resolveAttachedEvidence;
14
14
  exports.resolveComparisonLane = resolveComparisonLane;
15
15
  exports.resolveEventLogPath = resolveEventLogPath;
16
16
  exports.resolveInteractionDriver = resolveInteractionDriver;
17
+ exports.resolveProfileScenarioName = resolveProfileScenarioName;
18
+ exports.runProfileCompatibilityPreflight = runProfileCompatibilityPreflight;
17
19
  exports.runProfileCli = runProfileCli;
18
20
  exports.runProfileMobile = runProfileMobile;
19
21
  exports.hashScenarioContract = hashScenarioContract;
20
22
  exports.usage = usage;
21
- const { execFile } = require('node:child_process');
23
+ const { spawn } = require('node:child_process');
22
24
  const fs = require('node:fs');
23
25
  const fsp = require('node:fs/promises');
24
26
  const path = require('node:path');
@@ -26,12 +28,14 @@ const crypto = require('node:crypto');
26
28
  const { buildAgentSummaryMarkdown } = require('../core/agent-summary');
27
29
  const { createArtifactLayout } = require('../core/artifact-layout');
28
30
  const { writeJsonArtifact, writeTextArtifact } = require('../core/artifact-writer');
31
+ const { buildCompatibilityHealth, buildUnevaluatedVerdict, evaluateRunnerCompatibility, } = require('../core/planner');
29
32
  const { buildBudgetVerdict, buildCausalRun, buildCausalTimeline, buildManifest, buildMetricsFromProfileEvents, buildSummaryMarkdown, extractProfileEvents, extractProfileSessionEntries, } = require('../core/artifact-contract');
30
33
  const { SCHEMAS, assertValidJson } = require('../core/schema-validator');
31
34
  const { writeUsage } = require('./cli');
32
35
  const CAPTURE_EVIDENCE_KINDS = new Set(['screenshot', 'uiTree', 'video']);
33
36
  const PROVIDER_EVIDENCE_KINDS = new Set(['accessibility', 'logs', 'profiler']);
34
37
  const SIGNAL_EVIDENCE_KINDS = new Set(['js', 'memory', 'network']);
38
+ const DEFAULT_PROVIDER_COMMAND_TIMEOUT_MS = 180_000;
35
39
  /**
36
40
  * Prints CLI usage to stderr.
37
41
  *
@@ -145,6 +149,15 @@ function readRepeatableArgValues(args, key) {
145
149
  return entry;
146
150
  });
147
151
  }
152
+ /**
153
+ * Reads whether a boolean-style CLI flag was supplied.
154
+ *
155
+ * @param {CliArgValue | undefined} value
156
+ * @returns {boolean}
157
+ */
158
+ function isEnabled(value) {
159
+ return value === true || value === 'true';
160
+ }
148
161
  /**
149
162
  * Parses a `kind:path` evidence attachment value.
150
163
  *
@@ -176,23 +189,83 @@ async function hashFileSha256(filePath) {
176
189
  return crypto.createHash('sha256').update(content).digest('hex');
177
190
  }
178
191
  /**
179
- * Runs one provider command without a shell and captures its output.
192
+ * Resolves the timeout applied to provider commands.
180
193
  *
181
- * @param {{command: string, args: string[], cwd?: string, env?: Record<string, string>}} options
194
+ * @returns {number}
195
+ */
196
+ function resolveProviderCommandTimeoutMs() {
197
+ return readPositiveInteger(process.env.ASL_PROVIDER_COMMAND_TIMEOUT_MS, DEFAULT_PROVIDER_COMMAND_TIMEOUT_MS);
198
+ }
199
+ /**
200
+ * Runs one provider command without a shell, streaming output to raw files.
201
+ *
202
+ * @param {{command: string, args: string[], cwd?: string, env?: Record<string, string>, stderrPath: string, stdoutPath: string, timeoutMs: number}} options
182
203
  * @returns {Promise<ProviderCommandResult>}
183
204
  */
184
- function execProviderCommand({ args, command, cwd, env, }) {
205
+ function execProviderCommand({ args, command, cwd, env, stderrPath, stdoutPath, timeoutMs, }) {
185
206
  return new Promise((resolve) => {
186
- execFile(command, args, {
207
+ const child = spawn(command, args, {
187
208
  ...(cwd ? { cwd } : {}),
188
209
  env: env ? { ...process.env, ...env } : process.env,
189
- }, (error, stdout, stderr) => {
210
+ shell: false,
211
+ stdio: ['ignore', 'pipe', 'pipe'],
212
+ });
213
+ const stdoutChunks = [];
214
+ const stderrChunks = [];
215
+ let timedOut = false;
216
+ let settled = false;
217
+ const timeout = setTimeout(() => {
218
+ timedOut = true;
219
+ child.kill('SIGTERM');
220
+ setTimeout(() => {
221
+ if (!settled) {
222
+ child.kill('SIGKILL');
223
+ }
224
+ }, 1000).unref();
225
+ }, timeoutMs);
226
+ timeout.unref();
227
+ child.stdout.on('data', (chunk) => {
228
+ stdoutChunks.push(chunk);
229
+ fs.appendFileSync(stdoutPath, chunk);
230
+ });
231
+ child.stderr.on('data', (chunk) => {
232
+ stderrChunks.push(chunk);
233
+ fs.appendFileSync(stderrPath, chunk);
234
+ });
235
+ child.on('error', (error) => {
236
+ if (settled) {
237
+ return;
238
+ }
239
+ clearTimeout(timeout);
240
+ settled = true;
241
+ const stderr = error.message;
242
+ fs.appendFileSync(stderrPath, `${stderr}\n`, 'utf8');
243
+ resolve({
244
+ args,
245
+ command,
246
+ exitCode: 1,
247
+ signal: null,
248
+ stderr,
249
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
250
+ timedOut,
251
+ });
252
+ });
253
+ child.on('close', (exitCode, signal) => {
254
+ if (settled) {
255
+ return;
256
+ }
257
+ clearTimeout(timeout);
258
+ settled = true;
259
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
260
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
190
261
  resolve({
191
262
  args,
192
263
  command,
193
- exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
264
+ exitCode: typeof exitCode === 'number' ? exitCode : timedOut ? 124 : 1,
265
+ signal,
194
266
  stderr,
195
267
  stdout,
268
+ timedOut,
196
269
  });
197
270
  });
198
271
  });
@@ -384,32 +457,66 @@ async function executeProviderCommands({ args, layout, platform, runDir, runId,
384
457
  ? resolveProviderPath({ context, manifestDir, value: providerCommand.cwd })
385
458
  : manifestDir;
386
459
  const resolvedEnv = Object.fromEntries(Object.entries(providerCommand.env ?? {}).map(([key, value]) => [key, applyProviderPlaceholders(value, context)]));
460
+ const commandRecordFileName = `${providerId}-${providerCommand.id}.json`;
461
+ const stdoutFileName = `${providerId}-${providerCommand.id}.stdout.txt`;
462
+ const stderrFileName = `${providerId}-${providerCommand.id}.stderr.txt`;
463
+ const commandRecordPath = path.join(commandRecordDir, commandRecordFileName);
464
+ const stdoutPath = path.join(commandRecordDir, stdoutFileName);
465
+ const stderrPath = path.join(commandRecordDir, stderrFileName);
466
+ const timeoutMs = resolveProviderCommandTimeoutMs();
467
+ const startedAt = new Date().toISOString();
468
+ await fsp.writeFile(stdoutPath, '', 'utf8');
469
+ await fsp.writeFile(stderrPath, '', 'utf8');
470
+ await fsp.writeFile(commandRecordPath, `${JSON.stringify({
471
+ args: resolvedArgs,
472
+ command: resolvedCommand,
473
+ phase: providerCommand.phase,
474
+ providerId,
475
+ startedAt,
476
+ status: 'started',
477
+ stderrPath: `raw/provider-commands/${stderrFileName}`,
478
+ stdoutPath: `raw/provider-commands/${stdoutFileName}`,
479
+ timeoutMs,
480
+ }, null, 2)}\n`, 'utf8');
387
481
  const commandResult = await execProviderCommand({
388
482
  args: resolvedArgs,
389
483
  command: resolvedCommand,
390
484
  cwd: resolvedCwd,
391
485
  env: resolvedEnv,
486
+ stderrPath,
487
+ stdoutPath,
488
+ timeoutMs,
392
489
  });
393
- const commandRecordFileName = `${providerId}-${providerCommand.id}.json`;
394
- const commandRecordPath = path.join(commandRecordDir, commandRecordFileName);
395
490
  await fsp.writeFile(commandRecordPath, `${JSON.stringify({
396
491
  args: commandResult.args,
397
492
  command: commandResult.command,
493
+ endedAt: new Date().toISOString(),
398
494
  exitCode: commandResult.exitCode,
399
495
  phase: providerCommand.phase,
400
496
  providerId,
497
+ signal: commandResult.signal,
401
498
  stderr: commandResult.stderr,
499
+ stderrPath: `raw/provider-commands/${stderrFileName}`,
500
+ status: commandResult.timedOut ? 'timed_out' : commandResult.exitCode === 0 ? 'completed' : 'failed',
402
501
  stdout: commandResult.stdout,
502
+ stdoutPath: `raw/provider-commands/${stdoutFileName}`,
503
+ timedOut: commandResult.timedOut,
504
+ timeoutMs,
403
505
  }, null, 2)}\n`, 'utf8');
404
506
  if (commandResult.exitCode !== 0) {
507
+ const timedOut = commandResult.timedOut;
405
508
  failures.push({
406
509
  commandId: providerCommand.id,
407
- code: 'provider_command_failed',
510
+ code: timedOut ? 'provider_liveness_timeout' : 'provider_command_failed',
408
511
  exitCode: commandResult.exitCode,
409
- message: `Evidence provider command ${providerId}/${providerCommand.id} failed with exit code ${commandResult.exitCode}.`,
512
+ message: timedOut
513
+ ? `Evidence provider command ${providerId}/${providerCommand.id} did not finish before the ${timeoutMs}ms timeout.`
514
+ : `Evidence provider command ${providerId}/${providerCommand.id} failed with exit code ${commandResult.exitCode}.`,
410
515
  name: 'evidence_provider_command_completed',
411
- nextAction: `Inspect raw/provider-commands/${commandRecordFileName}, fix the provider command or its environment, then rerun the profile.`,
412
- nextActionCode: 'fix_provider_command',
516
+ nextAction: timedOut
517
+ ? `Inspect raw/provider-commands/${commandRecordFileName}, raw/provider-commands/${stdoutFileName}, and raw/provider-commands/${stderrFileName}; fix the provider liveness issue or increase ASL_PROVIDER_COMMAND_TIMEOUT_MS only if the provider is making progress.`
518
+ : `Inspect raw/provider-commands/${commandRecordFileName}, fix the provider command or its environment, then rerun the profile.`,
519
+ nextActionCode: timedOut ? 'fix_provider_liveness' : 'fix_provider_command',
413
520
  phase: providerCommand.phase,
414
521
  providerId,
415
522
  rawPath: `raw/provider-commands/${commandRecordFileName}`,
@@ -608,6 +715,127 @@ function toPortablePathReference(targetPath) {
608
715
  }
609
716
  return path.basename(targetPath);
610
717
  }
718
+ /**
719
+ * Resolves the evidence input mode before profile parsing starts.
720
+ *
721
+ * @param {{args: CliArgs, platform: ProfilePlatform}} options
722
+ * @returns {string}
723
+ */
724
+ function resolveProfileInputMode({ args, platform }) {
725
+ if (typeof args.events === 'string') {
726
+ return 'fixture-event-log';
727
+ }
728
+ if (platform === 'android') {
729
+ if (typeof args['adb-artifacts'] === 'string') {
730
+ return 'adb-sidecar';
731
+ }
732
+ if (isEnabled(args['adb-capture'])) {
733
+ return 'adb-live-capture';
734
+ }
735
+ }
736
+ if (typeof args['simctl-artifacts'] === 'string') {
737
+ return 'simctl-sidecar';
738
+ }
739
+ if (isEnabled(args['simctl-capture'])) {
740
+ return 'simctl-live-capture';
741
+ }
742
+ return 'no-profile-evidence';
743
+ }
744
+ /**
745
+ * Reads unique scenario step kinds for early operator visibility.
746
+ *
747
+ * @param {Record<string, unknown>} scenario
748
+ * @returns {string[]}
749
+ */
750
+ function readScenarioStepKinds(scenario) {
751
+ if (!Array.isArray(scenario.steps)) {
752
+ return [];
753
+ }
754
+ return Array.from(new Set(scenario.steps
755
+ .filter(isRecord)
756
+ .map((step) => step.kind)
757
+ .filter((kind) => typeof kind === 'string')))
758
+ .sort();
759
+ }
760
+ /**
761
+ * Reads wait milestone ids from scenario steps.
762
+ *
763
+ * @param {Record<string, unknown>} scenario
764
+ * @returns {string[]}
765
+ */
766
+ function readScenarioWaitMilestones(scenario) {
767
+ if (!Array.isArray(scenario.steps)) {
768
+ return [];
769
+ }
770
+ return Array.from(new Set(scenario.steps
771
+ .filter(isRecord)
772
+ .filter((step) => step.kind === 'waitForMilestone')
773
+ .map((step) => step.milestone)
774
+ .filter((milestone) => typeof milestone === 'string')))
775
+ .sort();
776
+ }
777
+ /**
778
+ * Builds the early run plan artifact before provider commands or event parsing.
779
+ *
780
+ * @param {{args: CliArgs, artifactRoot: string, comparisonLane?: string | undefined, expectedIterations: number, interactionDriver: string, layout: ReturnType<typeof createArtifactLayout>, milestoneEventsPerIteration: number, options: ProfileMobileOptions, profileScenario: Record<string, unknown>, runDir: string, runId: string, scenarioHash: string, scenarioPath: string}} options
781
+ * @returns {ProfileRunPlan}
782
+ */
783
+ function buildProfileRunPlan({ args, artifactRoot, comparisonLane, expectedIterations, interactionDriver, layout, milestoneEventsPerIteration, options, profileScenario, runDir, runId, scenarioHash, scenarioPath, }) {
784
+ return {
785
+ artifactVersion: '1.0.0',
786
+ runId,
787
+ scenarioId: resolveProfileScenarioName({ scenario: profileScenario, scenarioPath }),
788
+ scenarioHash,
789
+ platform: options.platform,
790
+ inputMode: resolveProfileInputMode({ args, platform: options.platform }),
791
+ artifactRoot,
792
+ runDir,
793
+ interactionDriver,
794
+ ...(comparisonLane ? { comparisonLane } : {}),
795
+ expectedIterations,
796
+ milestoneEventsPerIteration,
797
+ commandTransport: resolveCommandTransport({ args, interactionDriver, options }),
798
+ providers: readRepeatableArgValues(args, 'provider').map((providerPath) => ({
799
+ path: toPortablePathReference(path.resolve(providerPath)),
800
+ })),
801
+ requestedDiagnostics: {
802
+ required: Array.from(readScenarioStringSet(profileScenario, ['artifacts', 'required'])).sort(),
803
+ optional: Array.from(readScenarioStringSet(profileScenario, ['artifacts', 'optional'])).sort(),
804
+ },
805
+ scenarioShape: {
806
+ budgets: Array.isArray(profileScenario.budgets) ? profileScenario.budgets.length : 0,
807
+ steps: Array.isArray(profileScenario.steps) ? profileScenario.steps.length : 0,
808
+ stepKinds: readScenarioStepKinds(profileScenario),
809
+ waitForMilestones: readScenarioWaitMilestones(profileScenario),
810
+ },
811
+ evidenceSources: {
812
+ ...(typeof args.events === 'string' ? { events: toPortablePathReference(path.resolve(args.events)) } : {}),
813
+ ...(typeof args['profile-session-entries'] === 'string'
814
+ ? { profileSessionEntries: toPortablePathReference(path.resolve(args['profile-session-entries'])) }
815
+ : {}),
816
+ ...(typeof args['adb-artifacts'] === 'string'
817
+ ? { adbArtifacts: toPortablePathReference(path.resolve(args['adb-artifacts'])) }
818
+ : {}),
819
+ ...(typeof args['simctl-artifacts'] === 'string'
820
+ ? { simctlArtifacts: toPortablePathReference(path.resolve(args['simctl-artifacts'])) }
821
+ : {}),
822
+ adbCapture: isEnabled(args['adb-capture']),
823
+ simctlCapture: isEnabled(args['simctl-capture']),
824
+ signals: readRepeatableArgValues(args, 'signal').length,
825
+ captures: readRepeatableArgValues(args, 'capture').length,
826
+ },
827
+ };
828
+ }
829
+ /**
830
+ * Writes the early profile run plan artifact and a compact status heartbeat.
831
+ *
832
+ * @param {{layout: ReturnType<typeof createArtifactLayout>, plan: ProfileRunPlan}} options
833
+ * @returns {Promise<void>}
834
+ */
835
+ async function writeProfileRunPlan({ layout, plan, }) {
836
+ await fsp.writeFile(layout.runPlan, `${JSON.stringify(plan, null, 2)}\n`, 'utf8');
837
+ process.stderr.write(`profile run plan: ${plan.platform}/${plan.scenarioId} mode=${plan.inputMode} providers=${plan.providers.length} requiredDiagnostics=${plan.requestedDiagnostics.required.length} runPlan=${path.relative(process.cwd(), layout.runPlan)}\n`);
838
+ }
611
839
  /**
612
840
  * Returns a path reference from one run folder to an external sidecar.
613
841
  *
@@ -1535,6 +1763,66 @@ function resolveProfileScenarioName({ scenario, scenarioPath, }) {
1535
1763
  }
1536
1764
  return path.basename(scenarioPath, '.json');
1537
1765
  }
1766
+ /**
1767
+ * Reads evidence-provider manifests for profile compatibility preflight.
1768
+ *
1769
+ * @param {CliArgs} args
1770
+ * @returns {Record<string, unknown>[]}
1771
+ */
1772
+ function readEvidenceProviderManifests(args) {
1773
+ return readRepeatableArgValues(args, 'provider').map((providerPath, index) => assertValidJson(readJson(path.resolve(providerPath)), SCHEMAS.runnerCapabilities, `Evidence provider manifest ${index + 1}`));
1774
+ }
1775
+ /**
1776
+ * Runs planner compatibility before a live profile capture starts.
1777
+ *
1778
+ * Failed compatibility writes classified artifacts in the profile run folder so
1779
+ * agents can stop before adb, simctl, or provider work consumes runtime time.
1780
+ *
1781
+ * @param {CompatibilityPreflightOptions} options
1782
+ * @returns {Promise<void>}
1783
+ */
1784
+ async function runProfileCompatibilityPreflight({ args, artifactRoot, platform, primaryRunner, runDir, runId, scenario, scenarioName, }) {
1785
+ const layout = createArtifactLayout({ outputDir: runDir });
1786
+ const compatibility = evaluateRunnerCompatibility({
1787
+ scenario,
1788
+ runner: primaryRunner,
1789
+ evidenceProviders: readEvidenceProviderManifests(args),
1790
+ platform,
1791
+ });
1792
+ await writeJsonArtifact({
1793
+ filePath: layout.plannerCompatibility,
1794
+ value: compatibility,
1795
+ schema: {
1796
+ type: 'object',
1797
+ additionalProperties: true,
1798
+ },
1799
+ label: 'Planner compatibility artifact',
1800
+ });
1801
+ if (compatibility.compatible) {
1802
+ process.stderr.write(`profile preflight passed: ${platform}/${scenarioName} artifactRoot=${artifactRoot} planner=${path.relative(process.cwd(), layout.plannerCompatibility)}\n`);
1803
+ return;
1804
+ }
1805
+ const health = buildCompatibilityHealth({ scenario, runId, compatibility });
1806
+ const verdict = buildUnevaluatedVerdict({ scenario, runId, health });
1807
+ const agentSummary = buildAgentSummaryMarkdown({ health, verdict });
1808
+ await writeJsonArtifact({
1809
+ filePath: layout.health,
1810
+ value: health,
1811
+ schema: SCHEMAS.health,
1812
+ label: 'Health artifact',
1813
+ });
1814
+ await writeJsonArtifact({
1815
+ filePath: layout.verdict,
1816
+ value: verdict,
1817
+ schema: SCHEMAS.verdict,
1818
+ label: 'Verdict artifact',
1819
+ });
1820
+ await writeTextArtifact({
1821
+ filePath: layout.agentSummary,
1822
+ content: agentSummary,
1823
+ });
1824
+ throw new Error(`Profile compatibility preflight failed; inspect ${runDir}/agent-summary.md.`);
1825
+ }
1538
1826
  /**
1539
1827
  * Serializes JSON with stable object key ordering for reproducible hashes.
1540
1828
  *
@@ -1992,6 +2280,22 @@ async function runProfileMobile(args, options) {
1992
2280
  await ensureDir(layout.signals.js);
1993
2281
  await ensureDir(layout.signals.memory);
1994
2282
  await ensureDir(layout.signals.network);
2283
+ const runPlan = buildProfileRunPlan({
2284
+ args,
2285
+ artifactRoot,
2286
+ comparisonLane,
2287
+ expectedIterations,
2288
+ interactionDriver,
2289
+ layout,
2290
+ milestoneEventsPerIteration,
2291
+ options,
2292
+ profileScenario,
2293
+ runDir,
2294
+ runId,
2295
+ scenarioHash,
2296
+ scenarioPath,
2297
+ });
2298
+ await writeProfileRunPlan({ layout, plan: runPlan });
1995
2299
  const providerExecution = await executeProviderCommands({
1996
2300
  args,
1997
2301
  layout,
@@ -15,6 +15,14 @@ type FailedRunOutput = {
15
15
  * @returns {NodeJS.ProcessEnv}
16
16
  */
17
17
  declare function createRehearsalEnv(tempRoot: string): NodeJS.ProcessEnv;
18
+ /**
19
+ * Resolves an existing tarball from the environment, when a parent release gate
20
+ * has already packed the current package.
21
+ *
22
+ * @param {NodeJS.ProcessEnv} env
23
+ * @returns {string | null}
24
+ */
25
+ declare function resolveProvidedTarball(env: NodeJS.ProcessEnv): string | null;
18
26
  /**
19
27
  * Runs a command and returns stdout while preserving child stderr on failure.
20
28
  *
@@ -116,4 +124,4 @@ declare function rehearseConsumerInstall({ appRoot, env, tarballPath, }: {
116
124
  env: NodeJS.ProcessEnv;
117
125
  tarballPath: string;
118
126
  }): void;
119
- export { createRehearsalEnv, mergeGeneratedScripts, packageBinPath, packPackage, readJson, rehearseConsumerInstall, replaceConfigPlaceholders, run, runExpectFailure, writeFakeArgent, writeExistingAppFixture, writeProfileEventFixtures, writeJson, };
127
+ export { createRehearsalEnv, mergeGeneratedScripts, packageBinPath, packPackage, readJson, rehearseConsumerInstall, replaceConfigPlaceholders, resolveProvidedTarball, run, runExpectFailure, writeFakeArgent, writeExistingAppFixture, writeProfileEventFixtures, writeJson, };
@@ -8,6 +8,7 @@ exports.packPackage = packPackage;
8
8
  exports.readJson = readJson;
9
9
  exports.rehearseConsumerInstall = rehearseConsumerInstall;
10
10
  exports.replaceConfigPlaceholders = replaceConfigPlaceholders;
11
+ exports.resolveProvidedTarball = resolveProvidedTarball;
11
12
  exports.run = run;
12
13
  exports.runExpectFailure = runExpectFailure;
13
14
  exports.writeFakeArgent = writeFakeArgent;
@@ -39,6 +40,22 @@ function createRehearsalEnv(tempRoot) {
39
40
  env.npm_config_update_notifier = 'false';
40
41
  return env;
41
42
  }
43
+ /**
44
+ * Resolves an existing tarball from the environment, when a parent release gate
45
+ * has already packed the current package.
46
+ *
47
+ * @param {NodeJS.ProcessEnv} env
48
+ * @returns {string | null}
49
+ */
50
+ function resolveProvidedTarball(env) {
51
+ const tarballPath = env.ASL_PACKAGE_TARBALL ? path.resolve(env.ASL_PACKAGE_TARBALL) : '';
52
+ if (!tarballPath) {
53
+ return null;
54
+ }
55
+ assert.equal(fs.existsSync(tarballPath), true, `ASL_PACKAGE_TARBALL does not exist: ${tarballPath}`);
56
+ assert.match(path.basename(tarballPath), /^agent-scenario-loop-.+\.tgz$/u, 'ASL_PACKAGE_TARBALL must point to an agent-scenario-loop tarball');
57
+ return tarballPath;
58
+ }
42
59
  /**
43
60
  * Resolves the per-command timeout for package gate child processes.
44
61
  *
@@ -729,7 +746,7 @@ function main() {
729
746
  const appRoot = path.join(tempRoot, 'existing-mobile-app');
730
747
  fs.mkdirSync(appRoot, { recursive: true });
731
748
  try {
732
- const tarballPath = packPackage({
749
+ const tarballPath = resolveProvidedTarball(env) ?? packPackage({
733
750
  env,
734
751
  packageRoot,
735
752
  packDir: path.join(tempRoot, 'pack'),
@@ -36,6 +36,14 @@ declare function isAllowedPackedFile(filePath: string): boolean;
36
36
  * @returns {NodeJS.ProcessEnv}
37
37
  */
38
38
  declare function createSmokeEnv(tempRoot: string): NodeJS.ProcessEnv;
39
+ /**
40
+ * Resolves an existing tarball from the environment, when a parent release gate
41
+ * has already packed the current package.
42
+ *
43
+ * @param {NodeJS.ProcessEnv} env
44
+ * @returns {string | null}
45
+ */
46
+ declare function resolveProvidedTarball(env: NodeJS.ProcessEnv): string | null;
39
47
  /**
40
48
  * Runs a command and returns stdout while preserving stderr for failures.
41
49
  *
@@ -93,4 +101,4 @@ declare function typescriptBinPath(repoRoot: string): string;
93
101
  * @returns {void}
94
102
  */
95
103
  declare function main(): void;
96
- export { createSmokeEnv, listFiles, main, isAllowedPackedFile, packageBinPath, run, runExpectFailure, typescriptBinPath, writeFakeAdb, writeSmokeRun, };
104
+ export { createSmokeEnv, listFiles, main, isAllowedPackedFile, packageBinPath, resolveProvidedTarball, run, runExpectFailure, typescriptBinPath, writeFakeAdb, writeSmokeRun, };
@@ -6,6 +6,7 @@ exports.listFiles = listFiles;
6
6
  exports.main = main;
7
7
  exports.isAllowedPackedFile = isAllowedPackedFile;
8
8
  exports.packageBinPath = packageBinPath;
9
+ exports.resolveProvidedTarball = resolveProvidedTarball;
9
10
  exports.run = run;
10
11
  exports.runExpectFailure = runExpectFailure;
11
12
  exports.typescriptBinPath = typescriptBinPath;
@@ -137,6 +138,22 @@ function createSmokeEnv(tempRoot) {
137
138
  env.npm_config_update_notifier = 'false';
138
139
  return env;
139
140
  }
141
+ /**
142
+ * Resolves an existing tarball from the environment, when a parent release gate
143
+ * has already packed the current package.
144
+ *
145
+ * @param {NodeJS.ProcessEnv} env
146
+ * @returns {string | null}
147
+ */
148
+ function resolveProvidedTarball(env) {
149
+ const tarballPath = env.ASL_PACKAGE_TARBALL ? path.resolve(env.ASL_PACKAGE_TARBALL) : '';
150
+ if (!tarballPath) {
151
+ return null;
152
+ }
153
+ assert.equal(fs.existsSync(tarballPath), true, `ASL_PACKAGE_TARBALL does not exist: ${tarballPath}`);
154
+ assert.match(path.basename(tarballPath), /^agent-scenario-loop-.+\.tgz$/u, 'ASL_PACKAGE_TARBALL must point to an agent-scenario-loop tarball');
155
+ return tarballPath;
156
+ }
140
157
  /**
141
158
  * Resolves the per-command timeout for package gate child processes.
142
159
  *
@@ -556,14 +573,18 @@ function main() {
556
573
  try {
557
574
  assert.equal(fs.existsSync(path.join(repoRoot, '.npmignore')), true, 'root .npmignore must exist so npm pack never falls back to .gitignore');
558
575
  assert.equal(fs.existsSync(path.join(repoRoot, 'examples', 'mobile-app', '.npmignore')), true, 'example app .npmignore must exist so npm pack never falls back to the app-local .gitignore');
559
- const packOutput = run('npm', ['pack', '--pack-destination', packDir], {
560
- cwd: repoRoot,
561
- env,
562
- });
563
- const tarballName = packOutput.trim().split(/\n/u).pop();
564
- assert.ok(tarballName, 'npm pack did not print a tarball name');
565
- const tarballPath = path.join(packDir, tarballName);
566
- assert.equal(fs.existsSync(tarballPath), true, `missing packed tarball: ${tarballPath}`);
576
+ const providedTarballPath = resolveProvidedTarball(env);
577
+ const tarballPath = providedTarballPath ?? (() => {
578
+ const packOutput = run('npm', ['pack', '--pack-destination', packDir], {
579
+ cwd: repoRoot,
580
+ env,
581
+ });
582
+ const tarballName = packOutput.trim().split(/\n/u).pop();
583
+ assert.ok(tarballName, 'npm pack did not print a tarball name');
584
+ const packedTarballPath = path.join(packDir, tarballName);
585
+ assert.equal(fs.existsSync(packedTarballPath), true, `missing packed tarball: ${packedTarballPath}`);
586
+ return packedTarballPath;
587
+ })();
567
588
  run('npm', ['init', '-y'], {
568
589
  cwd: installDir,
569
590
  env,
@@ -1888,7 +1909,7 @@ function main() {
1888
1909
  "assert.deepEqual(documentedRunnerSubpaths(), exportedRunnerSubpaths(), 'installed docs/api.md runner subpaths must match package exports');",
1889
1910
  "assert.equal(asl.ARTIFACT_LAYOUT_VERSION, '1.0.0');",
1890
1911
  "assert.equal(asl.ARTIFACT_FILENAMES.health, 'health.json');",
1891
- "assert.deepEqual(asl.ARTIFACT_FILENAMES, { agentSummary: 'agent-summary.md', comparison: 'comparison.json', health: 'health.json', liveProof: 'live-proof.json', liveProofSet: 'live-proof-set.json', plannerCompatibility: 'planner-compatibility.json', projectValidation: 'project-validation.json', verdict: 'verdict.json' });",
1912
+ "assert.deepEqual(asl.ARTIFACT_FILENAMES, { agentSummary: 'agent-summary.md', comparison: 'comparison.json', health: 'health.json', liveProof: 'live-proof.json', liveProofSet: 'live-proof-set.json', plannerCompatibility: 'planner-compatibility.json', projectValidation: 'project-validation.json', runPlan: 'run-plan.json', verdict: 'verdict.json' });",
1892
1913
  "assert.equal(asl.PROFILE_ARTIFACT_FILENAMES.metrics, 'metrics.json');",
1893
1914
  "assert.deepEqual(asl.PROFILE_ARTIFACT_FILENAMES, { budgetVerdict: 'budget-verdict.json', causalRun: 'causal-run.json', manifest: 'manifest.json', metrics: 'metrics.json', summary: 'summary.md' });",
1894
1915
  "assert.equal(typeof asl.createArtifactLayout, 'function');",
@@ -1901,6 +1922,7 @@ function main() {
1901
1922
  "assert.equal(layout.liveProofSet, 'run/live-proof-set.json');",
1902
1923
  "assert.equal(layout.plannerCompatibility, 'run/planner-compatibility.json');",
1903
1924
  "assert.equal(layout.projectValidation, 'run/project-validation.json');",
1925
+ "assert.equal(layout.runPlan, 'run/run-plan.json');",
1904
1926
  "assert.equal(layout.raw, 'run/raw');",
1905
1927
  "assert.equal(layout.captures, 'run/captures');",
1906
1928
  "assert.equal(layout.signals.js, 'run/signals/js');",
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ type RunOptions = {
3
+ cwd: string;
4
+ env: NodeJS.ProcessEnv;
5
+ };
6
+ /**
7
+ * Creates a clean npm environment for repeatable release gates.
8
+ *
9
+ * @param {string} tempRoot
10
+ * @returns {NodeJS.ProcessEnv}
11
+ */
12
+ declare function createReleaseEnv(tempRoot: string): NodeJS.ProcessEnv;
13
+ /**
14
+ * Resolves the per-command timeout for release gate child processes.
15
+ *
16
+ * @param {NodeJS.ProcessEnv} env
17
+ * @returns {number}
18
+ */
19
+ declare function resolveCommandTimeoutMs(env: NodeJS.ProcessEnv): number;
20
+ /**
21
+ * Runs a release command with inherited output.
22
+ *
23
+ * @param {string} command
24
+ * @param {string[]} args
25
+ * @param {RunOptions} options
26
+ * @returns {void}
27
+ */
28
+ declare function run(command: string, args: string[], options: RunOptions): void;
29
+ /**
30
+ * Packs the package once for downstream release gates.
31
+ *
32
+ * @param {{env: NodeJS.ProcessEnv, packageRoot: string, packDir: string}} options
33
+ * @returns {string}
34
+ */
35
+ declare function packReleasePackage({ env, packageRoot, packDir, }: {
36
+ env: NodeJS.ProcessEnv;
37
+ packageRoot: string;
38
+ packDir: string;
39
+ }): string;
40
+ /**
41
+ * Runs the full release check while reusing one package tarball for install
42
+ * smoke and consumer rehearsal.
43
+ *
44
+ * @returns {void}
45
+ */
46
+ declare function main(): void;
47
+ export { createReleaseEnv, main, packReleasePackage, resolveCommandTimeoutMs, run, };
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.createReleaseEnv = createReleaseEnv;
5
+ exports.main = main;
6
+ exports.packReleasePackage = packReleasePackage;
7
+ exports.resolveCommandTimeoutMs = resolveCommandTimeoutMs;
8
+ exports.run = run;
9
+ const assert = require('node:assert/strict');
10
+ const fs = require('node:fs');
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+ const { execFileSync } = require('node:child_process');
14
+ const DEFAULT_COMMAND_TIMEOUT_MS = 180_000;
15
+ /**
16
+ * Creates a clean npm environment for repeatable release gates.
17
+ *
18
+ * @param {string} tempRoot
19
+ * @returns {NodeJS.ProcessEnv}
20
+ */
21
+ function createReleaseEnv(tempRoot) {
22
+ const env = { ...process.env };
23
+ for (const key of Object.keys(env)) {
24
+ if (key.toLowerCase().startsWith('npm_config_')) {
25
+ delete env[key];
26
+ }
27
+ }
28
+ env.npm_config_audit = 'false';
29
+ env.npm_config_cache = path.join(tempRoot, 'npm-cache');
30
+ env.npm_config_fund = 'false';
31
+ env.npm_config_update_notifier = 'false';
32
+ return env;
33
+ }
34
+ /**
35
+ * Resolves the per-command timeout for release gate child processes.
36
+ *
37
+ * @param {NodeJS.ProcessEnv} env
38
+ * @returns {number}
39
+ */
40
+ function resolveCommandTimeoutMs(env) {
41
+ const timeoutMs = Number.parseInt(env.ASL_PACKAGE_GATE_TIMEOUT_MS ?? '', 10);
42
+ return Number.isFinite(timeoutMs) && timeoutMs > 0
43
+ ? timeoutMs
44
+ : DEFAULT_COMMAND_TIMEOUT_MS;
45
+ }
46
+ /**
47
+ * Runs a release command with inherited output.
48
+ *
49
+ * @param {string} command
50
+ * @param {string[]} args
51
+ * @param {RunOptions} options
52
+ * @returns {void}
53
+ */
54
+ function run(command, args, options) {
55
+ execFileSync(command, args, {
56
+ cwd: options.cwd,
57
+ env: options.env,
58
+ stdio: 'inherit',
59
+ timeout: resolveCommandTimeoutMs(options.env),
60
+ });
61
+ }
62
+ /**
63
+ * Packs the package once for downstream release gates.
64
+ *
65
+ * @param {{env: NodeJS.ProcessEnv, packageRoot: string, packDir: string}} options
66
+ * @returns {string}
67
+ */
68
+ function packReleasePackage({ env, packageRoot, packDir, }) {
69
+ fs.mkdirSync(packDir, { recursive: true });
70
+ const packOutput = execFileSync('npm', ['pack', '--pack-destination', packDir], {
71
+ cwd: packageRoot,
72
+ encoding: 'utf8',
73
+ env,
74
+ stdio: ['ignore', 'pipe', 'inherit'],
75
+ timeout: resolveCommandTimeoutMs(env),
76
+ });
77
+ const tarballName = packOutput.trim().split(/\n/u).pop();
78
+ assert.ok(tarballName, 'npm pack did not print a tarball name');
79
+ const tarballPath = path.join(packDir, tarballName);
80
+ assert.equal(fs.existsSync(tarballPath), true, `missing packed tarball: ${tarballPath}`);
81
+ return tarballPath;
82
+ }
83
+ /**
84
+ * Runs the full release check while reusing one package tarball for install
85
+ * smoke and consumer rehearsal.
86
+ *
87
+ * @returns {void}
88
+ */
89
+ function main() {
90
+ const repoRoot = path.resolve(__dirname, '..', '..');
91
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'asl-release-check-'));
92
+ const env = createReleaseEnv(tempRoot);
93
+ try {
94
+ run('pnpm', ['test'], { cwd: repoRoot, env });
95
+ run(process.execPath, [path.join(repoRoot, 'dist', 'scripts', 'release-readiness.js')], { cwd: repoRoot, env });
96
+ const tarballPath = packReleasePackage({
97
+ env,
98
+ packageRoot: repoRoot,
99
+ packDir: path.join(tempRoot, 'pack'),
100
+ });
101
+ const gateEnv = {
102
+ ...env,
103
+ ASL_PACKAGE_TARBALL: tarballPath,
104
+ };
105
+ run(process.execPath, [path.join(repoRoot, 'dist', 'scripts', 'package-smoke.js')], { cwd: repoRoot, env: gateEnv });
106
+ run(process.execPath, [path.join(repoRoot, 'dist', 'scripts', 'consumer-rehearsal.js')], { cwd: repoRoot, env: gateEnv });
107
+ process.stdout.write(`release check passed: ${tarballPath}\n`);
108
+ fs.rmSync(tempRoot, { recursive: true, force: true });
109
+ }
110
+ catch (error) {
111
+ console.error(`release check temp kept at: ${tempRoot}`);
112
+ throw error;
113
+ }
114
+ }
115
+ if (require.main === module) {
116
+ main();
117
+ }
@@ -367,7 +367,7 @@ function assertReleaseScripts(packageJson) {
367
367
  const scripts = getStringMap(packageJson, 'scripts');
368
368
  assert.equal(scripts.prepublishOnly, 'pnpm release:check');
369
369
  assert.equal(scripts['release:readiness'], 'pnpm build && node dist/scripts/release-readiness.js');
370
- assert.equal(scripts['release:check'], 'pnpm test && pnpm release:readiness && pnpm package:smoke && pnpm consumer:rehearse');
370
+ assert.equal(scripts['release:check'], 'pnpm build && node dist/scripts/release-check.js');
371
371
  assert.equal(scripts['package:smoke'], 'pnpm build && node dist/scripts/package-smoke.js');
372
372
  assert.equal(scripts['consumer:rehearse'], 'pnpm build && node dist/scripts/consumer-rehearsal.js');
373
373
  assert.equal(scripts['downstream:local-package'], 'pnpm build && node dist/scripts/downstream-local-package-gate.js');
@@ -380,6 +380,24 @@ function assertReleaseScripts(packageJson) {
380
380
  assert.match(scripts['example:mobile:live-proof'], /--fail-on-regression/u);
381
381
  assert.match(scripts['example:app:start:isolated'], /--port 8097 --host localhost --clear/u);
382
382
  }
383
+ /**
384
+ * Asserts that pushed semver tags synchronize GitHub Releases from npm package truth.
385
+ *
386
+ * @param {string} repoRoot
387
+ * @returns {void}
388
+ */
389
+ function assertGithubReleaseWorkflow(repoRoot) {
390
+ const workflowPath = path.join(repoRoot, '.github', 'workflows', 'github-release.yml');
391
+ const workflow = fs.readFileSync(workflowPath, 'utf8');
392
+ assert.match(workflow, /tags:\n\s+- 'v\*\.\*\.\*'/u);
393
+ assert.match(workflow, /contents: write/u);
394
+ assert.match(workflow, /VERSION="\$\{GITHUB_REF_NAME#v\}"/u);
395
+ assert.match(workflow, /PACKAGE_VERSION="\$\(node -p "require\('\.\/package\.json'\)\.version"\)"/u);
396
+ assert.match(workflow, /npm view agent-scenario-loop@"\$VERSION" version/u);
397
+ assert.match(workflow, /gh release create "\$GITHUB_REF_NAME"/u);
398
+ assert.match(workflow, /--generate-notes/u);
399
+ assert.match(workflow, /--verify-tag/u);
400
+ }
383
401
  /**
384
402
  * Asserts that public binaries and package subpath exports map to built files.
385
403
  *
@@ -508,6 +526,7 @@ function main() {
508
526
  const packageJson = readJsonObject(path.join(repoRoot, 'package.json'));
509
527
  assertPublicPackageMetadata(packageJson);
510
528
  assertReleaseScripts(packageJson);
529
+ assertGithubReleaseWorkflow(repoRoot);
511
530
  assertPublicEntrypoints(packageJson);
512
531
  assertPublicApiDocs(packageJson, repoRoot);
513
532
  assertPackageFileList(packageJson);
@@ -16,6 +16,12 @@ Package gates run child package-manager and CLI commands with a bounded timeout.
16
16
  ASL_PACKAGE_GATE_TIMEOUT_MS=300000 pnpm consumer:rehearse
17
17
  ```
18
18
 
19
+ When a parent release gate has already packed the current package, pass that tarball through `ASL_PACKAGE_TARBALL` to reuse it instead of packing again:
20
+
21
+ ```bash
22
+ ASL_PACKAGE_TARBALL=/path/to/agent-scenario-loop-0.1.4.tgz pnpm consumer:rehearse
23
+ ```
24
+
19
25
  ## Downstream Local-Package Gate
20
26
 
21
27
  Before publishing a release candidate, validate the packed local package inside at least one real downstream app when that app has already adopted durable ASL scenarios. This catches package, runner, schema, and helper regressions before npm distribution.
@@ -35,6 +35,8 @@ pnpm check-plan -- --scenario examples/scenarios/mobile/app-startup.json --runne
35
35
 
36
36
  This validates the input manifests, writes schema-checked `health.json` and `verdict.json`, writes `agent-summary.md`, and includes the raw planner match in `planner-compatibility.json`.
37
37
 
38
+ Live profile wrappers also run this compatibility check before adb, simctl, agent-device, or provider capture starts. A compatible run writes `planner-compatibility.json` as the first profile artifact, then continues into the platform capture. An incompatible run writes failed `health.json`, inconclusive `verdict.json`, `agent-summary.md`, and the planner artifact in the profile run folder, then exits before touching the device runtime. This keeps missing required diagnostics, unsupported platforms, and impossible runner/provider plans out of the long capture loop.
39
+
38
40
  ## Host/Device Access
39
41
 
40
42
  Keep deterministic validation and live device proof as separate execution lanes.
@@ -338,7 +340,7 @@ Before publishing, run:
338
340
  pnpm release:check
339
341
  ```
340
342
 
341
- That gate runs tests, readiness checks, package smoke, installed-binary checks, fake-device example proofs, schema/example/template/doc packaging checks, and the packed-package consumer rehearsal.
343
+ That gate builds the release scripts, runs tests and readiness checks, packs the package once, then reuses that tarball for package smoke, installed-binary checks, fake-device example proofs, schema/example/template/doc packaging checks, and the packed-package consumer rehearsal. Reusing one tarball keeps the release path closer to npm publish behavior and avoids repeated clean/build/pack cycles.
342
344
 
343
345
  Package smoke and consumer rehearsal keep child commands bounded so package-manager stalls fail with the temporary rehearsal directory preserved. Set `ASL_PACKAGE_GATE_TIMEOUT_MS` to raise the per-command timeout when a local registry, proxy, or cold package cache is slow:
344
346
 
@@ -346,6 +348,10 @@ Package smoke and consumer rehearsal keep child commands bounded so package-mana
346
348
  ASL_PACKAGE_GATE_TIMEOUT_MS=300000 pnpm release:check
347
349
  ```
348
350
 
351
+ ## Run Plan First
352
+
353
+ Profile runs write `run-plan.json` before provider commands, evidence ingest, and final health classification. Inspect it first when a live loop stalls or fails early: it records the scenario id, scenario hash, input mode (`fixture-event-log`, `adb-sidecar`, `simctl-sidecar`, or live capture), expected iterations, command transport, provider manifests, requested diagnostics, and evidence source paths. The profile CLIs also print a compact run-plan heartbeat to stderr while keeping stdout reserved for the run directory.
354
+
349
355
  ## Side References
350
356
 
351
357
  - [Consumer App Rehearsal](consumer-rehearsal.md) for adoption inside an existing app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-scenario-loop",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "description": "Scenario orchestration and evidence collection for agent-driven software development. Bring your own runner. Keep your scenarios. Keep your evidence.",
6
6
  "license": "MIT",
@@ -232,7 +232,7 @@
232
232
  "downstream:local-package": "pnpm build && node dist/scripts/downstream-local-package-gate.js",
233
233
  "package:smoke": "pnpm build && node dist/scripts/package-smoke.js",
234
234
  "prepublishOnly": "pnpm release:check",
235
- "release:check": "pnpm test && pnpm release:readiness && pnpm package:smoke && pnpm consumer:rehearse",
235
+ "release:check": "pnpm build && node dist/scripts/release-check.js",
236
236
  "release:readiness": "pnpm build && node dist/scripts/release-readiness.js",
237
237
  "typecheck": "tsc -p tsconfig.json --noEmit",
238
238
  "test": "pnpm clean && pnpm typecheck && pnpm exec tsc -p tsconfig.json && node --test dist/core/__tests__/*.test.js dist/runner/__tests__/*.test.js",