@sensigo/realm-cli 0.41.0 → 0.42.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 (54) hide show
  1. package/dist/agent/run-attach.d.ts.map +1 -1
  2. package/dist/agent/run-attach.js +8 -21
  3. package/dist/agent/run-attach.js.map +1 -1
  4. package/dist/commands/drain.d.ts.map +1 -1
  5. package/dist/commands/drain.js +23 -6
  6. package/dist/commands/drain.js.map +1 -1
  7. package/dist/commands/inspect.d.ts.map +1 -1
  8. package/dist/commands/inspect.js +6 -5
  9. package/dist/commands/inspect.js.map +1 -1
  10. package/dist/commands/list.d.ts.map +1 -1
  11. package/dist/commands/list.js +4 -2
  12. package/dist/commands/list.js.map +1 -1
  13. package/dist/commands/register.d.ts +1 -39
  14. package/dist/commands/register.d.ts.map +1 -1
  15. package/dist/commands/register.js +16 -88
  16. package/dist/commands/register.js.map +1 -1
  17. package/dist/commands/replay.d.ts.map +1 -1
  18. package/dist/commands/replay.js +4 -2
  19. package/dist/commands/replay.js.map +1 -1
  20. package/dist/commands/respond.d.ts.map +1 -1
  21. package/dist/commands/respond.js +8 -3
  22. package/dist/commands/respond.js.map +1 -1
  23. package/dist/commands/resume.d.ts.map +1 -1
  24. package/dist/commands/resume.js +3 -2
  25. package/dist/commands/resume.js.map +1 -1
  26. package/dist/commands/run.d.ts +4 -3
  27. package/dist/commands/run.d.ts.map +1 -1
  28. package/dist/commands/run.js +30 -10
  29. package/dist/commands/run.js.map +1 -1
  30. package/dist/commands/test.d.ts.map +1 -1
  31. package/dist/commands/test.js +8 -5
  32. package/dist/commands/test.js.map +1 -1
  33. package/dist/commands/validate.d.ts +12 -0
  34. package/dist/commands/validate.d.ts.map +1 -1
  35. package/dist/commands/validate.js +422 -168
  36. package/dist/commands/validate.js.map +1 -1
  37. package/dist/commands/watch.d.ts +14 -2
  38. package/dist/commands/watch.d.ts.map +1 -1
  39. package/dist/commands/watch.js +244 -54
  40. package/dist/commands/watch.js.map +1 -1
  41. package/dist/index.js +1 -1
  42. package/dist/lib/admission-context.d.ts +58 -0
  43. package/dist/lib/admission-context.d.ts.map +1 -0
  44. package/dist/lib/admission-context.js +57 -0
  45. package/dist/lib/admission-context.js.map +1 -0
  46. package/dist/lib/load-workflow-for-admission.d.ts +69 -0
  47. package/dist/lib/load-workflow-for-admission.d.ts.map +1 -0
  48. package/dist/lib/load-workflow-for-admission.js +130 -0
  49. package/dist/lib/load-workflow-for-admission.js.map +1 -0
  50. package/dist/lib/loader-warnings.d.ts +5 -8
  51. package/dist/lib/loader-warnings.d.ts.map +1 -1
  52. package/dist/lib/loader-warnings.js +23 -30
  53. package/dist/lib/loader-warnings.js.map +1 -1
  54. package/package.json +4 -4
@@ -1,17 +1,19 @@
1
1
  // realm validate <path> — validates a workflow YAML file without registering it.
2
2
  //
3
- // Strictness asymmetry (documented): extension-free workflows validate through the EXACT
4
- // from-string path used before project extensions existed (byte-identical behavior).
5
- // Workflows declaring `extensions:` (or validated with --extensions-module) go through
6
- // file-based loading so extension modules can be resolved, then a SECOND pass validates
7
- // step `config` against each resolved adapter's `config_schema` (two-pass).
3
+ // ONE admission path (issue #553): `validate <file>` calls the same `loadWorkflowForAdmission`
4
+ // register and watch call file loader (agent-profile resolution), the unconditional
5
+ // project-extensions pass (modules, manifest, config_schema two-pass), real-then-sentinel
6
+ // secret resolution so what validate blesses register accepts, and what register refuses
7
+ // validate refuses, by construction. The pre-#553 "strictness asymmetry" (extension-free
8
+ // workflows parsed from string, skipping every file-context check) is gone; `--registered`
9
+ // runs the same rules on the stored copy and supplies-or-declares the context-dependent ones.
8
10
  import { Command } from 'commander';
9
- import { dirname, join, resolve } from 'node:path';
10
- import { readFileSync } from 'node:fs';
11
- import { load } from 'js-yaml';
12
- import { loadWorkflowFromStringWithDiagnostics, loadWorkflowFromFileWithDiagnostics, findTrustRoot, WorkflowError, shouldEnforceTimeout, DEFAULT_EXECUTION_TIMEOUT_SECONDS, resolveSeverity, renderLoaderWarning, assessStructuredOutputEligibility, renderIneligibleMessage, JsonWorkflowStore, RUNTIME_ONLY_WORKFLOW_KEYS, VERSION, } from '@sensigo/realm';
13
- import { loadProjectExtensions, checkForOrphanedManifests, } from '../extensions/load-project-extensions.js';
11
+ import { join } from 'node:path';
12
+ import { existsSync } from 'node:fs';
13
+ import { loadWorkflowFromStringWithDiagnostics, resolveAgentProfiles, WorkflowError, shouldEnforceTimeout, DEFAULT_EXECUTION_TIMEOUT_SECONDS, resolveSeverity, renderLoaderWarning, assessStructuredOutputEligibility, renderIneligibleMessage, JsonWorkflowStore, RUNTIME_ONLY_WORKFLOW_KEYS, VERSION, } from '@sensigo/realm';
14
14
  import { renderLoadFailure, renderEscalationLine, printLoaderWarnings, rejectOnErrorSeverity, failsStrict, wrapSentinelWarnings, } from '../lib/loader-warnings.js';
15
+ import { loadWorkflowForAdmission, admitProjectExtensions, ExtensionLoadError, admittedDefinitionOf, } from '../lib/load-workflow-for-admission.js';
16
+ import { CONTEXT_DEPENDENT_CHECKS, notRunReason, renderChecksNotRunLine, } from '../lib/admission-context.js';
15
17
  /**
16
18
  * Advisory (issue A3, never rejects): an auto step declaring `retry:` but no `timeout_seconds`
17
19
  * has EVERY attempt bounded by the generous DEFAULT_EXECUTION_TIMEOUT_SECONDS default — a hung
@@ -56,20 +58,33 @@ export function findRetryWithoutExplicitTimeout(definition) {
56
58
  * accumulator turns the summary line into a failing one and returns true (the caller exits 1);
57
59
  * otherwise the summary line — and, when present, the description line existing tests assert on
58
60
  * — print exactly as before and this returns false.
61
+ *
62
+ * `checksNotRun` (issue #553 correction C9, default 0 — the file-mode call site never passes it,
63
+ * since every check runs there) adds the FIRST tail clause when non-zero: `N check(s) not run`.
64
+ * There is no non-strict `— N warning(s)` tail today and this does not invent one — a
65
+ * warnings-bearing run without `--strict` and without a not-run count still prints the bare line,
66
+ * warnings above it. The failing-`--strict` clause, when both fire, comes SECOND, `; `-joined
67
+ * with the not-run clause — `— N check(s) not run; M warning(s); failing due to --strict` — and
68
+ * the description-suppression rule is UNCHANGED: only a failing `--strict` suppresses it, not a
69
+ * bare not-run disclosure (a moved tree is not a reason to hide the workflow's own description).
59
70
  */
60
- function printValidationOutcome(definition, warnings, strict) {
71
+ function printValidationOutcome(definition, warnings, strict, checksNotRun = 0) {
61
72
  printLoaderWarnings(warnings);
62
73
  const stepCount = Object.keys(definition.steps).length;
63
74
  const base = `Valid: ${definition.id} v${definition.version} (${stepCount} ${stepCount === 1 ? 'step' : 'steps'})`;
64
- if (strict && failsStrict(warnings)) {
65
- console.log(`${base} ${warnings.length} ${warnings.length === 1 ? 'warning' : 'warnings'}; failing due to --strict`);
66
- return true;
75
+ const strictFailing = strict && failsStrict(warnings);
76
+ const clauses = [];
77
+ if (checksNotRun > 0) {
78
+ clauses.push(`${checksNotRun} ${checksNotRun === 1 ? 'check' : 'checks'} not run`);
79
+ }
80
+ if (strictFailing) {
81
+ clauses.push(`${warnings.length} ${warnings.length === 1 ? 'warning' : 'warnings'}; failing due to --strict`);
67
82
  }
68
- console.log(base);
69
- if (definition.description !== undefined) {
83
+ console.log(clauses.length > 0 ? `${base} — ${clauses.join('; ')}` : base);
84
+ if (!strictFailing && definition.description !== undefined) {
70
85
  console.log(` ${definition.description}`);
71
86
  }
72
- return false;
87
+ return strictFailing;
73
88
  }
74
89
  /** issue #236: the reasoning-position heuristic (design record §7, ratified via fixture C6) —
75
90
  * top-level property NAME match only, never a value/content inspection. */
@@ -105,6 +120,9 @@ function findReasoningLikeTopLevelProperty(schema) {
105
120
  *
106
121
  * Never printed for an ineligible-and-opted-in step: the LOADER already rejected that combination
107
122
  * at load time, so this function structurally never observes it.
123
+ *
124
+ * issue #454: this whole channel is suppressed under `--json` — a caller checking
125
+ * `opts.json` never calls this at all; there is no machinery for it here.
108
126
  */
109
127
  function printStructuredOutputNudge(definition, opts) {
110
128
  // Not-opted-in steps whose detail either renders (--explain) or feeds the summary counts.
@@ -214,6 +232,49 @@ function renderNudgeSummary(ready, withCaveats, oneAway) {
214
232
  line += `, ${oneAway} ${steps(oneAway)} one change away`;
215
233
  return line + tail;
216
234
  }
235
+ /**
236
+ * issue #454 — the severity `--json` reports for every diagnostic. Every mint site in the tree
237
+ * ALREADY resolves severity at construction (`severity: resolveSeverity(code)`,
238
+ * diagnostics.ts:239 and its siblings, under DEFAULT_POLICY — never the `--strict` all-error
239
+ * policy, which is a run mode reported separately in the `strict` block) — so minted ≡ effective
240
+ * for every constructible diagnostic today, and this re-resolution is a GUARD against a future
241
+ * mint or a policy change landing without updating this file, not a live divergence. No real
242
+ * fixture can distinguish the two; only a hand-constructed lying warning can (validate-json.test.ts's
243
+ * U1 cell).
244
+ * @internal Exported for testing only.
245
+ */
246
+ export function normalizeDiagnosticSeverity(w) {
247
+ return { ...w, severity: resolveSeverity(w.code) };
248
+ }
249
+ /**
250
+ * The ONE `--json` emission point (issue #454): every contract arm builds a `ValidateJsonEmit`
251
+ * and calls this, so the machine channel cannot drift arm-to-arm the way independently-written
252
+ * `JSON.stringify` call sites could. One `console.log` carrying the whole object is itself the
253
+ * purity guarantee this surface's cells assert on (nothing else may write to stdout on a
254
+ * contract arm) — `JSON.stringify(obj, null, 2)`, the `workflow list --json` sibling's own idiom.
255
+ */
256
+ function emitValidateJson(result) {
257
+ console.log(JSON.stringify({
258
+ valid: result.valid,
259
+ mode: result.mode,
260
+ path: result.path,
261
+ workflow_id: result.workflowId,
262
+ loader_version: VERSION,
263
+ schema_version: result.schemaVersion,
264
+ error_count: result.errors.length,
265
+ warning_count: result.diagnostics.length,
266
+ strict: { requested: result.strictRequested, failed: result.strictFailed },
267
+ diagnostics: result.diagnostics.map(normalizeDiagnosticSeverity),
268
+ errors: result.errors,
269
+ checks_not_run: result.checksNotRun,
270
+ }, null, 2));
271
+ }
272
+ /** issue #454 — the shared `err.warnings ?? []` shape four `--json` load-failure sites need
273
+ * (:527, :580, :621, :423 in the pre-#454 line numbering) — everywhere EXCEPT the orphan-guard
274
+ * site (:548), whose diagnostics are the human loop's own accumulated set, not `err.warnings`. */
275
+ function warningsOf(err) {
276
+ return err instanceof WorkflowError ? (err.warnings ?? []) : [];
277
+ }
217
278
  /**
218
279
  * The ONE place a load failure is rendered on this command (issue #445).
219
280
  *
@@ -230,9 +291,29 @@ function renderNudgeSummary(ready, withCaveats, oneAway) {
230
291
  * SCOPE, deliberate: watch/register/test/agent keep their own catches (#425's recorded
231
292
  * exclusion). If a second surface ever adopts this, move it to lib/loader-warnings.ts — one
232
293
  * caller does not earn a shared home.
294
+ *
295
+ * issue #454 — `jsonCtx`, when present, means `--json` was requested: the `errors[]` convention
296
+ * there is the RAW `err.errors ?? [err.message]` (channel prefixes like `Error: `/`Invalid: `
297
+ * are print-time decoration this never applied in the first place — nothing to strip), never the
298
+ * human-rendered `renderLoadFailure(err)` string.
233
299
  */
234
- function exitOnLoadFailure(err) {
300
+ function exitOnLoadFailure(err, jsonCtx) {
235
301
  if (err instanceof WorkflowError) {
302
+ if (jsonCtx !== undefined) {
303
+ emitValidateJson({
304
+ valid: false,
305
+ mode: jsonCtx.mode,
306
+ path: jsonCtx.path,
307
+ workflowId: jsonCtx.workflowId,
308
+ schemaVersion: jsonCtx.schemaVersion,
309
+ strictRequested: jsonCtx.strictRequested,
310
+ strictFailed: false,
311
+ diagnostics: jsonCtx.diagnostics,
312
+ errors: err.errors ?? [err.message],
313
+ checksNotRun: jsonCtx.checksNotRun,
314
+ });
315
+ process.exit(1);
316
+ }
236
317
  if (err.warnings !== undefined)
237
318
  printLoaderWarnings(err.warnings);
238
319
  console.error(renderLoadFailure(err));
@@ -256,25 +337,12 @@ function rejectIfPolicyEscalates(warnings) {
256
337
  //
257
338
  // register and watch print the same line since issue #451 (watch adds its timestamp and a
258
339
  // `— refusing to register.` tail, because it does not exit); it lives in lib/loader-warnings.ts
259
- // for that reason. On all three surfaces printLoaderWarnings' `— REFUSED below` substitution
260
- // already marks each refused warning one line above.
340
+ // for that reason. On all three surfaces the warnings printed one line above still read
341
+ // `— ignored` (issue #540 deleted the CLI's print-time rewrite to `— REFUSED below`) — this
342
+ // line is where the refusal, and WHICH warning triggered it, actually gets said.
261
343
  console.error(renderEscalationLine(warnings));
262
344
  return true;
263
345
  }
264
- /** Pre-scan: does the YAML carry a top-level `extensions` key? (Parse errors → false; the
265
- * real loader below reports them with its existing error surface.) */
266
- function hasTopLevelExtensions(content) {
267
- try {
268
- const raw = load(content);
269
- return (typeof raw === 'object' &&
270
- raw !== null &&
271
- !Array.isArray(raw) &&
272
- 'extensions' in raw);
273
- }
274
- catch {
275
- return false;
276
- }
277
- }
278
346
  /**
279
347
  * `validate --registered <id>` — audit the STORED copy of a workflow (issue #427).
280
348
  *
@@ -289,7 +357,7 @@ function hasTopLevelExtensions(content) {
289
357
  * change what your runs do. A legacy (schema_version-less or older) copy is a different case
290
358
  * entirely — see the legacy arm below: it is not grandfathered, it is already unreachable.
291
359
  */
292
- async function validateRegistered(id, strict) {
360
+ async function validateRegistered(id, strict, json, overrideModule) {
293
361
  const store = new JsonWorkflowStore();
294
362
  let stored;
295
363
  try {
@@ -297,6 +365,21 @@ async function validateRegistered(id, strict) {
297
365
  }
298
366
  catch (err) {
299
367
  if (err instanceof WorkflowError && err.code === 'STATE_WORKFLOW_NOT_FOUND') {
368
+ if (json) {
369
+ emitValidateJson({
370
+ valid: false,
371
+ mode: 'registered',
372
+ path: null,
373
+ workflowId: id,
374
+ schemaVersion: null,
375
+ strictRequested: strict,
376
+ strictFailed: false,
377
+ diagnostics: [],
378
+ errors: [err.message],
379
+ checksNotRun: [],
380
+ });
381
+ process.exit(1);
382
+ }
300
383
  console.error(`Error: ${err.message}`);
301
384
  console.error('Registered workflows: realm workflow list');
302
385
  process.exit(1);
@@ -307,6 +390,21 @@ async function validateRegistered(id, strict) {
307
390
  // consumer resolves through this same get() gate (start_run, execute_step, append_trace,
308
391
  // get_workflow_protocol, submit_human_response, replay), so a legacy entry cannot run at
309
392
  // all. It is not grandfathered; it is unreachable.
393
+ if (json) {
394
+ emitValidateJson({
395
+ valid: false,
396
+ mode: 'registered',
397
+ path: null,
398
+ workflowId: id,
399
+ schemaVersion: null,
400
+ strictRequested: strict,
401
+ strictFailed: false,
402
+ diagnostics: [],
403
+ errors: [err.message],
404
+ checksNotRun: [],
405
+ });
406
+ process.exit(1);
407
+ }
310
408
  console.log(`Auditing the registered copy of '${id}' with realm ${VERSION}'s loader.`);
311
409
  // The store's own message carries the remedy; the loader would say `Missing required
312
410
  // field: 'steps'` here, which is true of the shape and useless about the cause.
@@ -316,44 +414,212 @@ async function validateRegistered(id, strict) {
316
414
  if (!(err instanceof WorkflowError)) {
317
415
  // get()'s try wraps ONLY the read — JSON.parse sits outside it, so a corrupt stored file
318
416
  // arrives here as a bare SyntaxError with no code (executed).
319
- console.error(`Error: the registered copy of '${id}' is not parseable JSON: ${err instanceof Error ? err.message : String(err)}`);
417
+ const notParseableMsg = `the registered copy of '${id}' is not parseable JSON: ${err instanceof Error ? err.message : String(err)}`;
418
+ if (json) {
419
+ emitValidateJson({
420
+ valid: false,
421
+ mode: 'registered',
422
+ path: null,
423
+ workflowId: id,
424
+ schemaVersion: null,
425
+ strictRequested: strict,
426
+ strictFailed: false,
427
+ diagnostics: [],
428
+ errors: [notParseableMsg],
429
+ checksNotRun: [],
430
+ });
431
+ process.exit(1);
432
+ }
433
+ console.error(`Error: ${notParseableMsg}`);
320
434
  console.error('Registered workflows: realm workflow list');
321
435
  process.exit(1);
322
436
  }
323
437
  throw err; // #123: an unexpected WorkflowError is a bug, and bugs stay loud.
324
438
  }
325
- console.log(`Auditing the registered copy of '${id}' (schema_version ${String(stored.schema_version)}) ` +
326
- `with realm ${VERSION}'s loader.`);
327
- console.log('Registered copies stay grandfathered at runtime — this reports what re-registration today would say.');
439
+ if (!json) {
440
+ console.log(`Auditing the registered copy of '${id}' (schema_version ${String(stored.schema_version)}) ` +
441
+ `with realm ${VERSION}'s loader.`);
442
+ console.log('Registered copies stay grandfathered at runtime against LOADER changes — this reports ' +
443
+ 'what re-registration today would say. A NEW engine-side dispatch check (issue #508, ' +
444
+ 'realm 0.42.0) is NOT grandfathered: it applies immediately, whatever schema_version ' +
445
+ 'is on file.');
446
+ }
447
+ // issue #553 — read the recorded context BEFORE the strip: these are the paths the
448
+ // context-dependent checks are supplied with (the file loader stamps both since v0.14;
449
+ // older copies carry neither, and say so below).
450
+ const recorded = {
451
+ source_dir: typeof stored.source_dir === 'string' ? stored.source_dir : undefined,
452
+ trust_root: typeof stored.trust_root === 'string' ? stored.trust_root : undefined,
453
+ };
454
+ const storedExtensions = stored.extensions;
328
455
  const clone = { ...stored };
329
456
  for (const key of RUNTIME_ONLY_WORKFLOW_KEYS)
330
457
  delete clone[key];
331
- const declaresProfile = Object.values(stored.steps ?? {}).some((step) => step.agent_profile !== undefined);
332
- if (clone['extensions'] !== undefined || declaresProfile) {
333
- console.log('Extensions/profiles declared — module resolution, config_schema checks, and agent-profile ' +
334
- 'file resolution need the source tree and are not audited here; structural rules only.');
335
- }
336
458
  // MUST delete: the from-string loader hard-throws on an `extensions` key (allowExtensions:
337
459
  // false) with "Register this workflow from its YAML file" — maximally misleading here, where
338
- // the workflow IS registered and the operator asked about the stored copy. The honesty line
339
- // above is what carries the real limitation.
460
+ // the workflow IS registered and the operator asked about the stored copy. The declared
461
+ // modules are re-attached to the parsed definition below, for the extensions pass that
462
+ // actually consumes them.
340
463
  delete clone['extensions'];
464
+ const stripped = JSON.stringify(clone);
465
+ // Supply or declare (issue #553): per member of CONTEXT_DEPENDENT_CHECKS, either the recorded
466
+ // path still exists and the check RUNS with it, or the check is declared not run — here, on
467
+ // the human line, and in `checks_not_run` on every `--json` arm from this point.
468
+ const notRun = [];
469
+ const registeredCtx = (workflowId, diagnostics) => ({
470
+ mode: 'registered',
471
+ path: null,
472
+ workflowId,
473
+ schemaVersion: stored.schema_version ?? null,
474
+ diagnostics,
475
+ strictRequested: strict,
476
+ checksNotRun: notRun,
477
+ });
341
478
  let definition;
342
479
  let loaderWarnings;
343
480
  try {
344
- ({ definition, warnings: loaderWarnings } = loadWorkflowFromStringWithDiagnostics(JSON.stringify(clone)));
481
+ // After #553 the four context-free rules (context_wrapper, the workflow_context names,
482
+ // source.path) run here too — the string loader carries them. No `(line N)`: the body is
483
+ // JSON, not the author's file.
484
+ ({ definition, warnings: loaderWarnings } = loadWorkflowFromStringWithDiagnostics(stripped));
345
485
  }
346
486
  catch (err) {
347
- exitOnLoadFailure(err);
487
+ exitOnLoadFailure(err, json ? registeredCtx(id, warningsOf(err)) : undefined);
488
+ }
489
+ const applicable = CONTEXT_DEPENDENT_CHECKS.filter((check) => check.applies(definition));
490
+ for (const check of applicable) {
491
+ const path = recorded[check.needs];
492
+ // `existsSync` is load-bearing for BOTH members: `resolveAgentProfiles` against a dead
493
+ // tree would name a path under the missing tree (the WRONG error), and
494
+ // `admitProjectExtensions` against a nonexistent trust root returns defaults SILENTLY
495
+ // (executed) — a copy whose tree moved would audit as if it had no manifest at all.
496
+ if (path === undefined || !existsSync(path)) {
497
+ const reason = notRunReason(check.needs, path, stored.origin);
498
+ // issue #553 correction C5 — the extensions member cannot apply an override it never
499
+ // reaches: say so beside the reason, never silently, on the human line AND
500
+ // `checks_not_run[].reason` alike, so 7c's label+reason parity holds by construction.
501
+ notRun.push({
502
+ id: check.id,
503
+ reason: check.id === 'project_extensions' && overrideModule !== undefined
504
+ ? `${reason}; --extensions-module not applied`
505
+ : reason,
506
+ });
507
+ }
508
+ }
509
+ // The disclosure line — always on, never verbose-gated, the old honesty line's slot: after
510
+ // the header, before any verdict. Derived from the constant; nothing hand-typed. A non-empty
511
+ // set does NOT flip `--strict`: a disclosure, not a warning — a moved tree must not fail CI
512
+ // for a reason the operator cannot act on.
513
+ if (!json && notRun.length > 0)
514
+ console.log(renderChecksNotRunLine(notRun));
515
+ let sentinelWarnings;
516
+ for (const check of applicable) {
517
+ if (notRun.some((n) => n.id === check.id))
518
+ continue;
519
+ if (check.id === 'agent_profile_resolution') {
520
+ try {
521
+ resolveAgentProfiles(definition, recorded.source_dir);
522
+ }
523
+ catch (err) {
524
+ exitOnLoadFailure(err, json ? registeredCtx(definition.id, loaderWarnings) : undefined);
525
+ }
526
+ }
527
+ else {
528
+ // The extensions pass exactly as loadWorkflowForAdmission runs it — ONE shared call
529
+ // (issue #553 correction C2: `admitProjectExtensions` is now the single mint site for the
530
+ // sentinel-credentials advisory pair, replacing this arm's own hand-typed copy) — against
531
+ // the RECORDED paths, re-stamped on the parsed copy (the strip removed them).
532
+ if (recorded.source_dir !== undefined)
533
+ definition.source_dir = recorded.source_dir;
534
+ definition.trust_root = recorded.trust_root; // the member ran ⇒ recorded and present
535
+ if (storedExtensions !== undefined)
536
+ definition.extensions = storedExtensions;
537
+ let loaded;
538
+ try {
539
+ loaded = await admitProjectExtensions(definition, {
540
+ surface: 'validate',
541
+ ...(overrideModule !== undefined ? { overrideModule } : {}),
542
+ });
543
+ }
544
+ catch (err) {
545
+ // issue #445's sentence, issue #454's whole-message convention, issue #463's
546
+ // warnings-first — the file arm's shape, on the stored copy.
547
+ const accumulated = [...loaderWarnings, ...findRetryWithoutExplicitTimeout(definition)];
548
+ const msg = `Error loading extensions: ${err instanceof Error ? err.message : String(err)}`;
549
+ if (json) {
550
+ emitValidateJson({
551
+ valid: false,
552
+ mode: 'registered',
553
+ path: null,
554
+ workflowId: definition.id,
555
+ schemaVersion: stored.schema_version ?? null,
556
+ strictRequested: strict,
557
+ strictFailed: false,
558
+ diagnostics: accumulated,
559
+ errors: [msg],
560
+ checksNotRun: notRun,
561
+ });
562
+ process.exit(1);
563
+ }
564
+ for (const w of accumulated)
565
+ console.warn(renderLoaderWarning(w));
566
+ console.error(msg);
567
+ process.exit(1);
568
+ }
569
+ sentinelWarnings = loaded.sentinelWarnings;
570
+ try {
571
+ loadWorkflowFromStringWithDiagnostics(stripped, loaded.registry);
572
+ }
573
+ catch (err) {
574
+ exitOnLoadFailure(err, json ? registeredCtx(definition.id, warningsOf(err)) : undefined);
575
+ }
576
+ }
577
+ }
578
+ // The file arm's tail, minus the adoption nudge (a stored copy is not where you edit;
579
+ // `--explain` is therefore inert in this mode, deliberately — no machinery for it).
580
+ const accumulated = [
581
+ ...loaderWarnings,
582
+ ...findRetryWithoutExplicitTimeout(definition),
583
+ ...wrapSentinelWarnings(sentinelWarnings),
584
+ ];
585
+ if (json) {
586
+ if (rejectOnErrorSeverity(accumulated)) {
587
+ emitValidateJson({
588
+ valid: false,
589
+ mode: 'registered',
590
+ path: null,
591
+ workflowId: definition.id,
592
+ schemaVersion: stored.schema_version ?? null,
593
+ strictRequested: strict,
594
+ strictFailed: false,
595
+ diagnostics: accumulated,
596
+ errors: [renderEscalationLine(accumulated)],
597
+ checksNotRun: notRun,
598
+ });
599
+ process.exit(1);
600
+ }
601
+ const strictFailed = strict && failsStrict(accumulated);
602
+ emitValidateJson({
603
+ valid: true,
604
+ mode: 'registered',
605
+ path: null,
606
+ workflowId: definition.id,
607
+ schemaVersion: stored.schema_version ?? null,
608
+ strictRequested: strict,
609
+ strictFailed,
610
+ diagnostics: accumulated,
611
+ errors: [],
612
+ checksNotRun: notRun,
613
+ });
614
+ if (strictFailed) {
615
+ process.exit(1);
616
+ }
617
+ return;
348
618
  }
349
- // The extension-free arm's tail, minus the orphan-manifest check (there is no source tree to
350
- // have one) and minus the adoption nudge (a stored copy is not where you edit; `--explain` is
351
- // therefore inert in this mode, deliberately — no machinery for it).
352
- const accumulated = [...loaderWarnings, ...findRetryWithoutExplicitTimeout(definition)];
353
619
  if (rejectIfPolicyEscalates(accumulated)) {
354
620
  process.exit(1);
355
621
  }
356
- const strictFailed = printValidationOutcome(definition, accumulated, strict);
622
+ const strictFailed = printValidationOutcome(definition, accumulated, strict, notRun.length);
357
623
  if (strictFailed) {
358
624
  process.exit(1);
359
625
  }
@@ -364,15 +630,20 @@ export const validateCommand = new Command('validate')
364
630
  .option('--extensions-module <path>', "Extensions module that REPLACES the workflow's declared 'extensions' modules (repair/override)")
365
631
  .option('--strict', 'Exit non-zero if any loader warning is present (unknown keys, retry-without-timeout, sentinel credentials — issue #169)')
366
632
  .option('--explain', 'Print the full per-step structured_output adoption detail instead of the one-line summary the default run prints (issue #422)')
633
+ .option('--json', 'Emit the result as JSON on stdout, and nothing else')
367
634
  .description('Validate a workflow YAML file')
368
635
  .action(async (inputPath, opts) => {
369
636
  const strict = opts.strict === true;
370
637
  const explain = opts.explain === true;
638
+ const json = opts.json === true;
371
639
  // Exactly-one, checked FIRST and load-bearing: commander parses a `[path]` positional and
372
640
  // a `--registered <id>` option happily together and enforces nothing between them
373
641
  // (executed — both arrive). The flag is `--registered <id>` rather than an auto-detecting
374
642
  // positional deliberately: nothing can reliably tell an id from a path, and guessing wrong
375
643
  // means auditing something the operator did not name.
644
+ //
645
+ // issue #454: NOT under the contract — these two usage errors precede validation entirely
646
+ // (terraform-consistent) and stay human + exit 1 regardless of `--json`.
376
647
  if (inputPath === undefined && opts.registered === undefined) {
377
648
  console.error('Error: provide a workflow path, or --registered <id> to audit a stored definition.');
378
649
  process.exit(1);
@@ -384,138 +655,121 @@ export const validateCommand = new Command('validate')
384
655
  return;
385
656
  }
386
657
  if (opts.registered !== undefined) {
387
- await validateRegistered(opts.registered, strict);
658
+ await validateRegistered(opts.registered, strict, json, opts.extensionsModule);
388
659
  return;
389
660
  }
390
661
  const filePath = inputPath.endsWith('.yaml') || inputPath.endsWith('.yml')
391
662
  ? inputPath
392
663
  : join(inputPath, 'workflow.yaml');
393
- let content;
664
+ // ONE call (issue #553) — the path register and watch take, byte for byte: the file
665
+ // loader (read failure → the loader's `Failed to read workflow file:` sentence; profile
666
+ // resolution; the four context rules), the unconditional extensions pass with real-then-
667
+ // sentinel secret resolution, the config_schema pass 2. Two failure populations leave it,
668
+ // and each keeps its own sentence (issue #445): an ExtensionLoadError is extension or
669
+ // deployment territory (an unresolvable module, a malformed `realm.yaml`, the #123
670
+ // orphaned-manifest refusal) and says `Error loading extensions:`; everything else is the
671
+ // workflow's own invalidity, rendered by exitOnLoadFailure (a non-WorkflowError rethrows
672
+ // loud — the #123 doctrine).
673
+ let definition;
674
+ let warnings;
675
+ let manifest;
394
676
  try {
395
- content = readFileSync(filePath, 'utf8');
677
+ ({ definition, warnings, manifest } = await loadWorkflowForAdmission(filePath, {
678
+ ...(opts.extensionsModule !== undefined ? { overrideModule: opts.extensionsModule } : {}),
679
+ surface: 'validate',
680
+ }));
396
681
  }
397
682
  catch (err) {
398
- const message = err instanceof Error ? err.message : String(err);
399
- console.error(`Error: ${message}`);
400
- process.exit(1);
401
- return;
402
- }
403
- if (!hasTopLevelExtensions(content) && opts.extensionsModule === undefined) {
404
- // Extension-free: the exact current from-string path byte-identical behavior,
405
- // plus the orphaned-manifest guard (#123). The from-string loader stamps no
406
- // source_dir/trust_root, so resolve the same trust root the file-based path would
407
- // (findTrustRoot walks package.json/.git from the workflow dir) and run the guard
408
- // structural-first after the workflow parses, before the `Valid:` print. It throws
409
- // WorkflowError, which exitOnLoadFailure renders as `Invalid:` + exit(1) this arm's
410
- // guard call is DIRECT, which is why it keeps that sentence while an extensions-declaring
411
- // load surfaces the same guard as `Error loading extensions:` (issue #445). resolve()
412
- // before dirname so a relative `workflow.yaml` doesn't collapse the walk to '.'.
413
- let definition;
414
- let loaderWarnings;
415
- try {
416
- ({ definition, warnings: loaderWarnings } =
417
- loadWorkflowFromStringWithDiagnostics(content));
418
- }
419
- catch (err) {
420
- exitOnLoadFailure(err);
421
- }
422
- // Its own try (issue #463): each try owns one failure population — the #445 doctrine. A
423
- // load failure above has nothing to print but itself; the guard below fails AFTER the
424
- // workflow parsed, with its warnings in hand.
425
- try {
426
- const workflowDir = dirname(resolve(filePath));
427
- checkForOrphanedManifests(workflowDir, findTrustRoot(workflowDir));
428
- }
429
- catch (err) {
430
- // The workflow's own warnings before the refusal — the same set the success path counts,
431
- // so nothing the author would otherwise see only on the NEXT run is withheld. Plain
432
- // render: the escalation gate has not run, so printLoaderWarnings' `— REFUSED below`
433
- // would name the wrong cause (test.ts's render comment, #450's reasoning). Unconditional:
434
- // on the #123 non-WorkflowError rethrow population the warnings print before the loud
435
- // crash — true statements either way. exitOnLoadFailure cannot print them twice: the
436
- // orphan WorkflowError is minted bare in the CLI (load-project-extensions.ts), and the
437
- // core `warnings` slot is set only by attachLoaderWarnings inside the core loader, which
438
- // this throw never transits — which is exactly why this arm swallowed them until now.
439
- for (const w of [...loaderWarnings, ...findRetryWithoutExplicitTimeout(definition)]) {
440
- console.warn(renderLoaderWarning(w));
683
+ if (err instanceof ExtensionLoadError) {
684
+ // issue #463 — the workflow's own warnings first: pass-1's plus the retry advisory,
685
+ // the same set the success path counts minus the sentinel wraps, which come from the
686
+ // load that just failed — there is nothing to wrap. Plain render, not
687
+ // printLoaderWarnings — the escalation gate never ran on this arm (#540/#542).
688
+ //
689
+ // issue #454 — the errors[] convention's exception: this sentence ships WHOLE, with
690
+ // its `Error loading extensions: ` head — the #445 classification IS the composed
691
+ // message, not a channel prefix a caller prepends at print. Under `--json` the
692
+ // helper's two sentinel lines may already have reached stderr (`console.warn`;
693
+ // register has no `--json`): stdout purity holds #454's contract is stdout — and
694
+ // stderr may carry advisories on a contract arm (J7b pins it).
695
+ const failed = err.definition;
696
+ const accumulated = [
697
+ ...(err.warnings ?? []),
698
+ ...(failed !== undefined ? findRetryWithoutExplicitTimeout(failed) : []),
699
+ ];
700
+ const msg = `Error loading extensions: ${err.message}`;
701
+ if (json) {
702
+ emitValidateJson({
703
+ valid: false,
704
+ mode: 'file',
705
+ path: inputPath,
706
+ workflowId: failed?.id ?? null,
707
+ schemaVersion: null,
708
+ strictRequested: strict,
709
+ strictFailed: false,
710
+ diagnostics: accumulated,
711
+ errors: [msg],
712
+ checksNotRun: [],
713
+ });
714
+ process.exit(1);
441
715
  }
442
- exitOnLoadFailure(err);
716
+ for (const w of accumulated)
717
+ console.warn(renderLoaderWarning(w));
718
+ console.error(msg);
719
+ process.exit(1);
443
720
  }
444
- // Reporting, deliberately OUTSIDE the try (issue #445): these lines only run once the
445
- // load succeeded, and their own `process.exit` calls have no business passing through a
446
- // catch that exists to classify LOAD failures. Production-neutral — the exits still exit.
447
- const accumulated = [...loaderWarnings, ...findRetryWithoutExplicitTimeout(definition)];
448
- if (rejectIfPolicyEscalates(accumulated)) {
721
+ // A pass-2 (config_schema) refusal carries the pass-1 definition beside it, so
722
+ // `workflow_id` is named exactly as before the collapse (round-2 Q9); a pass-1 refusal
723
+ // has no definition to name.
724
+ exitOnLoadFailure(err, json
725
+ ? {
726
+ mode: 'file',
727
+ path: inputPath,
728
+ workflowId: admittedDefinitionOf(err)?.id ?? null,
729
+ schemaVersion: null,
730
+ diagnostics: warningsOf(err),
731
+ strictRequested: strict,
732
+ checksNotRun: [],
733
+ }
734
+ : undefined);
735
+ }
736
+ const accumulated = [...warnings, ...findRetryWithoutExplicitTimeout(definition)];
737
+ if (json) {
738
+ if (rejectOnErrorSeverity(accumulated)) {
739
+ emitValidateJson({
740
+ valid: false,
741
+ mode: 'file',
742
+ path: inputPath,
743
+ workflowId: definition.id,
744
+ schemaVersion: null,
745
+ strictRequested: strict,
746
+ strictFailed: false,
747
+ diagnostics: accumulated,
748
+ errors: [renderEscalationLine(accumulated)],
749
+ checksNotRun: [],
750
+ });
449
751
  process.exit(1);
450
752
  }
451
- const strictFailed = printValidationOutcome(definition, accumulated, strict);
452
- // issue #236: the nudge's own INFO channel — never affects the exit code below.
453
- printStructuredOutputNudge(definition, { explain });
753
+ const strictFailed = strict && failsStrict(accumulated);
754
+ emitValidateJson({
755
+ valid: true,
756
+ mode: 'file',
757
+ path: inputPath,
758
+ workflowId: definition.id,
759
+ schemaVersion: null,
760
+ strictRequested: strict,
761
+ strictFailed,
762
+ diagnostics: accumulated,
763
+ errors: [],
764
+ checksNotRun: [],
765
+ });
766
+ // issue #422/#236: the Extensions manifest line and the nudge are both suppressed under
767
+ // --json — human-informational, not represented (additive later if ever wanted).
454
768
  if (strictFailed) {
455
769
  process.exit(1);
456
770
  }
457
771
  return;
458
772
  }
459
- // Extensions declared (or an override supplied): file-based two-pass validation.
460
- //
461
- // THREE tries, not one (issue #445). The old single catch spanned five concerns and
462
- // rendered every one of them `Invalid: …` — including a user's unresolvable extension
463
- // module, and including internal bugs. Each try now owns one failure population, and the
464
- // reporting tail sits outside all of them.
465
- let definition;
466
- let pass1Warnings;
467
- try {
468
- // Pass 1: structural validation + extension resolution metadata (source_dir/trust_root).
469
- // The universal, registry-independent structural load — its warnings are what we count.
470
- ({ definition, warnings: pass1Warnings } = loadWorkflowFromFileWithDiagnostics(filePath));
471
- }
472
- catch (err) {
473
- exitOnLoadFailure(err);
474
- }
475
- let registry;
476
- let manifest;
477
- let sentinelWarnings;
478
- try {
479
- ({ registry, manifest, sentinelWarnings } = await loadProjectExtensions(definition, {
480
- ...(opts.extensionsModule !== undefined ? { overrideModule: opts.extensionsModule } : {}),
481
- secretMode: 'sentinel',
482
- }));
483
- }
484
- catch (err) {
485
- // issue #445 — a DIFFERENT population, and it gets its own sentence. Everything
486
- // loadProjectExtensions throws is extension or deployment territory: an unresolvable
487
- // module path, a failed import, a module whose default export is the wrong shape, a
488
- // malformed `realm.yaml`, and the #123 orphaned-manifest refusal. None of those makes
489
- // the WORKFLOW invalid, and calling them `Invalid:` sent an author to the wrong file.
490
- // The sentence is `realm run`'s, verbatim (run.ts) — the sibling surface renders this
491
- // identical failure class exactly so.
492
- //
493
- // issue #463 — the workflow's own warnings first: pass-1's plus the retry advisory, the
494
- // same set the success path counts minus the sentinel wraps, which come from the load that
495
- // just failed — there is nothing to wrap. Plain render (test.ts's render comment, #450's
496
- // reasoning): the escalation gate has not run, so printLoaderWarnings' `— REFUSED below`
497
- // would name the wrong cause.
498
- for (const w of [...pass1Warnings, ...findRetryWithoutExplicitTimeout(definition)]) {
499
- console.warn(renderLoaderWarning(w));
500
- }
501
- console.error(`Error loading extensions: ${err instanceof Error ? err.message : String(err)}`);
502
- process.exit(1);
503
- }
504
- try {
505
- // Pass 2: step config validated against each resolved adapter's config_schema. Same
506
- // content as pass 1, registry only adds config_schema checks — its warnings are proven
507
- // identical to pass 1's, so they are deliberately discarded here (not collected) to avoid
508
- // double-counting the same unknown key twice.
509
- loadWorkflowFromFileWithDiagnostics(filePath, registry);
510
- }
511
- catch (err) {
512
- exitOnLoadFailure(err);
513
- }
514
- const accumulated = [
515
- ...pass1Warnings,
516
- ...findRetryWithoutExplicitTimeout(definition),
517
- ...wrapSentinelWarnings(sentinelWarnings),
518
- ];
519
773
  if (rejectIfPolicyEscalates(accumulated)) {
520
774
  process.exit(1);
521
775
  }