@jevitate/cli 0.11.0 → 0.12.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 (39) hide show
  1. package/dist/approval-provenance.d.ts +6 -0
  2. package/dist/approval-provenance.js +9 -1
  3. package/dist/approval-provenance.js.map +1 -1
  4. package/dist/author-journey-cli.js +2 -1
  5. package/dist/author-journey-cli.js.map +1 -1
  6. package/dist/bin.js +10188 -9336
  7. package/dist/build-info.generated.js +2 -2
  8. package/dist/demo-aspect-cli.js +2 -1
  9. package/dist/demo-aspect-cli.js.map +1 -1
  10. package/dist/explore-cli.js +3 -2
  11. package/dist/explore-cli.js.map +1 -1
  12. package/dist/explore-goal.js +4 -3
  13. package/dist/explore-goal.js.map +1 -1
  14. package/dist/journey-api.d.ts +12 -3
  15. package/dist/journey-api.js +34 -12
  16. package/dist/journey-api.js.map +1 -1
  17. package/dist/journey-cli.d.ts +0 -1
  18. package/dist/journey-cli.js +88 -17
  19. package/dist/journey-cli.js.map +1 -1
  20. package/dist/journey-labels-migrate-api.d.ts +100 -0
  21. package/dist/journey-labels-migrate-api.js +194 -0
  22. package/dist/journey-labels-migrate-api.js.map +1 -0
  23. package/dist/journey-lint-sarif.js +4 -0
  24. package/dist/journey-lint-sarif.js.map +1 -1
  25. package/dist/journey-review.d.ts +12 -2
  26. package/dist/journey-review.js +522 -108
  27. package/dist/journey-review.js.map +1 -1
  28. package/dist/mcp-cli-tools.js +2 -1
  29. package/dist/mcp-cli-tools.js.map +1 -1
  30. package/dist/regression-api.js +6 -4
  31. package/dist/regression-api.js.map +1 -1
  32. package/dist/review-render-options.d.ts +17 -0
  33. package/dist/review-render-options.js +17 -0
  34. package/dist/review-render-options.js.map +1 -0
  35. package/dist/ux-usability.js +5 -2
  36. package/dist/ux-usability.js.map +1 -1
  37. package/dist/verify-fix-api.js +2 -0
  38. package/dist/verify-fix-api.js.map +1 -1
  39. package/package.json +9 -9
@@ -2,7 +2,7 @@ import { contentHash } from "@jevitate/domain";
2
2
  import { describeProvenance } from "./approval-provenance.js";
3
3
  import { SECRET_PARAM_NAME_RE, describeStep, flatJourneySteps, isNoClaimExpect, isOwnTargetVisible, journeyAssertions, journeyEndState, journeyRunParams, journeyWriteSteps, lintJourney, secretParamNames, } from "@jevitate/journey";
4
4
  import { renderFindings } from "./catalog-review.js";
5
- import { SafetyPolicy, describeCheck } from "@jevitate/explore";
5
+ import { SafetyPolicy, describeCheck, explainCheck } from "@jevitate/explore";
6
6
  import { navigateUrlParams, writeClassifier } from "@jevitate/recording";
7
7
  /**
8
8
  * #432 — `jevitate journey review <id>`: one human-readable review sheet for promotion sign-off.
@@ -45,7 +45,7 @@ function reviewTarget(t) {
45
45
  }
46
46
  /** Does the field read as a credential (its name, label or test id)? Its literal value is then never shown. */
47
47
  function secretLooking(t) {
48
- return [t.name, t.label, t.testId, t.text].some((s) => s !== undefined && SECRET_PARAM_NAME_RE.test(s));
48
+ return [t.name, t.label, t.testId, t.text, t.display?.name].some((s) => s !== undefined && SECRET_PARAM_NAME_RE.test(s));
49
49
  }
50
50
  function hideValue(v) {
51
51
  return "var" in v || v.redacted ? v : { redacted: true, length: 0 };
@@ -91,6 +91,28 @@ function evidenceLine(e) {
91
91
  const where = e.file === undefined ? "" : ` (${e.file}${e.line === undefined ? "" : `:${e.line}`})`;
92
92
  return `${e.kind}${change}${where}`;
93
93
  }
94
+ /** A target's exact locator, e.g. `data-testid=publish-now` or `role=button name="Save"`. */
95
+ function locatorSpec(t) {
96
+ if (t.testId !== undefined)
97
+ return `${t.testIdAttr ?? "data-testid"}=${t.testId}`;
98
+ const parts = [
99
+ t.role === undefined ? null : `role=${t.role}`,
100
+ t.name === undefined ? null : `name="${t.name}"`,
101
+ t.label === undefined ? null : `label="${t.label}"`,
102
+ t.text === undefined ? null : `text="${t.text}"`,
103
+ t.css === undefined ? null : `css=${t.css}`,
104
+ ].filter((x) => x !== null);
105
+ return parts.join(" ");
106
+ }
107
+ /**
108
+ * #497: a self-heal proposal changes a step's LOCATOR, which a recorded display label hides
109
+ * (`click "Import"` before and after) — so a proposal's step shows its exact locator too.
110
+ */
111
+ function withLocator(step) {
112
+ const target = targetOf(step);
113
+ const spec = target === undefined ? "" : locatorSpec(target);
114
+ return spec === "" ? describeStep(sanitized(step)) : `${describeStep(sanitized(step))} · locator ${spec}`;
115
+ }
94
116
  function buildProposal(ctx, journeyId) {
95
117
  const p = ctx.proposal;
96
118
  return {
@@ -103,8 +125,8 @@ function buildProposal(ctx, journeyId) {
103
125
  changes: { ...(p.changes.range === undefined ? {} : { range: p.changes.range }), ...(p.changes.baseSha === undefined ? {} : { baseSha: p.changes.baseSha }), ...(p.changes.headSha === undefined ? {} : { headSha: p.changes.headSha }), notes: [...p.changes.notes] },
104
126
  steps: p.steps.map((s) => ({
105
127
  number: s.index + 1,
106
- before: describeStep(s.before),
107
- after: describeStep(s.after),
128
+ before: withLocator(s.before),
129
+ after: withLocator(s.after),
108
130
  hypothesis: s.justification.hypothesis + (s.justification.anchorNotInChange === true ? " (the new anchor is not named by any change evidence)" : ""),
109
131
  evidence: s.justification.evidence.map(evidenceLine),
110
132
  screenshots: [...(s.screenshots ?? [])],
@@ -113,15 +135,34 @@ function buildProposal(ctx, journeyId) {
113
135
  acceptCommand: `jevitate journey promote ${journeyId} --proposal ${p.proposalId} --reviewed-hash ${p.proposedHash}`,
114
136
  };
115
137
  }
138
+ /**
139
+ * 0.12.0 review M4: the short exact locator shown beside a step's recorded display label — the
140
+ * test id when there is one, else role and name (else the full `locatorSpec`). A display label is
141
+ * free text a shared Journey can set (`{testId: "delete-account", display: {name: "Cancel"}}`), so
142
+ * the approver sees what the step really acts on. Undefined when the target has no display label.
143
+ */
144
+ function displayedLocator(t) {
145
+ if (t === undefined || t.display === undefined)
146
+ return undefined;
147
+ const short = t.testId !== undefined
148
+ ? { testId: t.testId, ...(t.testIdAttr === undefined ? {} : { testIdAttr: t.testIdAttr }) }
149
+ : t.role !== undefined || t.name !== undefined
150
+ ? { ...(t.role === undefined ? {} : { role: t.role }), ...(t.name === undefined ? {} : { name: t.name }) }
151
+ : t;
152
+ const spec = locatorSpec(short);
153
+ return spec === "" ? undefined : spec;
154
+ }
116
155
  function buildSteps(journey) {
117
156
  return flatJourneySteps(journey).map(({ index, recorded }) => {
118
157
  const step = recorded.step;
119
158
  const target = reviewTarget(targetOf(step));
120
159
  const assertion = ownAssertion(step);
160
+ const locator = displayedLocator(targetOf(step));
121
161
  return {
122
162
  number: index + 1,
123
163
  kind: step.kind,
124
164
  action: describeStep(sanitized(step)),
165
+ ...(locator === undefined ? {} : { locator }),
125
166
  ...(target === undefined ? {} : { target }),
126
167
  ...(recorded.objective === undefined || recorded.objective.trim() === "" ? {} : { objective: recorded.objective }),
127
168
  ...(recorded.expectedResult === undefined || recorded.expectedResult.trim() === "" ? {} : { expectedResult: recorded.expectedResult }),
@@ -328,13 +369,25 @@ export function buildJourneyReview(journey, ctx = {}) {
328
369
  const stepAssertions = journeyAssertions(journey).flatMap((site) => {
329
370
  if (site.where === "end-state")
330
371
  return [];
372
+ const explained = explainCheck(site.check);
331
373
  if (site.where === "step-request")
332
- return [{ step: site.step, check: describeCheck(site.check), weak: false }];
374
+ return [{ step: site.step, check: describeCheck(site.check), weak: explained.weak, text: explained.text }];
333
375
  const step = flat[site.step - 1]?.recorded.step;
334
- const weak = site.check.assertion.kind === "visible" || (site.field === "expect" && step !== undefined && (isOwnTargetVisible(step) || isNoClaimExpect(step)));
335
- return [{ step: site.step, check: describeCheck(site.check), weak }];
376
+ const weak = explained.weak || site.check.assertion.kind === "visible" || (site.field === "expect" && step !== undefined && (isOwnTargetVisible(step) || isNoClaimExpect(step)));
377
+ return [{ step: site.step, check: describeCheck(site.check), weak, text: explained.text }];
336
378
  });
337
- return {
379
+ const endStateChecks = journeyEndState(journey);
380
+ const endStateExplained = endStateChecks.map(explainCheck);
381
+ const anchors = (m.anchors ?? []).map((a) => ({ name: a.name, step: a.step }));
382
+ const writeRequests = buildWriteRequests(journey);
383
+ const enriched = steps.map((st) => {
384
+ const checks = stepAssertions.filter((a) => a.step === st.number).map((a) => ({ text: a.text, weak: a.weak }));
385
+ const mine = anchors.filter((a) => a.step === st.number).map((a) => a.name);
386
+ const rec = flat[st.number - 1]?.recorded.step;
387
+ const file = rec !== undefined && rec.kind === "upload" ? describeFile(sanitized(rec)) : undefined;
388
+ return { ...st, ...(checks.length === 0 ? {} : { checks }), ...(mine.length === 0 ? {} : { anchors: mine }), ...(file === undefined ? {} : { file }) };
389
+ });
390
+ const review = {
338
391
  id: m.id,
339
392
  name: m.name,
340
393
  promoted: m.promoted,
@@ -343,6 +396,7 @@ export function buildJourneyReview(journey, ctx = {}) {
343
396
  ...(m.goal === undefined ? {} : { goal: m.goal }),
344
397
  ...(m.persona === undefined ? {} : { persona: m.persona }),
345
398
  ...(m.role === undefined ? {} : { role: m.role }),
399
+ ...((journey.recording.intent ?? "").trim() === "" ? {} : { intent: journey.recording.intent.trim() }),
346
400
  requiresAuth: m.requiresAuth === true,
347
401
  preconditions: (m.preconditions ?? []).map((p) => p.description),
348
402
  successCriteria: (m.successCriteria ?? []).map((c) => ({
@@ -351,8 +405,8 @@ export function buildJourneyReview(journey, ctx = {}) {
351
405
  })),
352
406
  missingIntent,
353
407
  },
354
- steps,
355
- sideEffects: { writeRequests: buildWriteRequests(journey), riskyControls: buildRiskyControls(journey, ctx.safety), origins: buildOrigins(journey) },
408
+ steps: enriched,
409
+ sideEffects: { writeRequests, riskyControls: buildRiskyControls(journey, ctx.safety), origins: buildOrigins(journey) },
356
410
  inputs: {
357
411
  params: paramNames.map((name) => {
358
412
  const description = described.get(name);
@@ -361,7 +415,8 @@ export function buildJourneyReview(journey, ctx = {}) {
361
415
  secrets: (m.secretRefs ?? []).map((r) => ({ field: r.field, manager: r.manager, origin: r.origin })),
362
416
  },
363
417
  proof: {
364
- endState: journeyEndState(journey).map(describeCheck),
418
+ endState: endStateChecks.map(describeCheck),
419
+ endStateText: endStateExplained.map((e) => e.text),
365
420
  stepAssertions,
366
421
  lint: {
367
422
  errors: findings.filter((f) => f.level === "error").length,
@@ -392,8 +447,118 @@ export function buildJourneyReview(journey, ctx = {}) {
392
447
  ...(ctx.anchorIssues === undefined ? {} : { anchorWarnings: anchorWarningsOf(ctx.anchorIssues) }),
393
448
  ...(ctx.locatorHealth === undefined ? {} : { locatorHealth: locatorHealthOf(ctx.locatorHealth) }),
394
449
  ...(ctx.proposal === undefined ? {} : { proposal: buildProposal(ctx.proposal, m.id) }),
450
+ ...(anchors.length === 0 ? {} : { anchors }),
395
451
  contentHash: hash,
396
452
  };
453
+ return { ...review, attention: buildAttention(review, endStateExplained.filter((e) => e.weak).map((e) => e.text), m.goal) };
454
+ }
455
+ /** An upload step's file in words: the recorded path, `{var}` or «redacted». */
456
+ function describeFile(step) {
457
+ const f = step.file;
458
+ if (f === undefined)
459
+ return undefined;
460
+ if ("var" in f)
461
+ return `{${f.var}}`;
462
+ return f.redacted ? "«redacted»" : f.value;
463
+ }
464
+ /** Step numbers as ranges: 1, 2, 3, 5 becomes `1–3, 5`. */
465
+ export function stepRanges(numbers) {
466
+ const sorted = [...new Set(numbers)].sort((a, b) => a - b);
467
+ const parts = [];
468
+ for (let i = 0; i < sorted.length;) {
469
+ let j = i;
470
+ while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1)
471
+ j++;
472
+ parts.push(j === i ? `${sorted[i]}` : `${sorted[i]}–${sorted[j]}`);
473
+ i = j + 1;
474
+ }
475
+ return parts.join(", ");
476
+ }
477
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
478
+ /** Everything that needs the reviewer's decision, warnings first (stable within a severity). */
479
+ function buildAttention(r, weakEndState, goal) {
480
+ const out = [];
481
+ const warn = (text, hint) => void out.push({ severity: "warn", text, ...(hint === undefined ? {} : { hint }) });
482
+ const info = (text, hint) => void out.push({ severity: "info", text, ...(hint === undefined ? {} : { hint }) });
483
+ const weakSteps = [...new Set(r.proof.stepAssertions.filter((a) => a.weak).map((a) => a.step))];
484
+ if (weakSteps.length === 1)
485
+ warn(`Step ${stepRanges(weakSteps)} proves nothing: its check passes even if the step fails`);
486
+ else if (weakSteps.length > 1)
487
+ warn(`Steps ${stepRanges(weakSteps)} prove nothing: their checks pass even if the step fails`);
488
+ for (const t of weakEndState)
489
+ warn(`An end-state check proves nothing: ${t}`);
490
+ const annotate = `jevitate journey annotate ${r.id}`;
491
+ const noGoal = (goal ?? "").trim() === "";
492
+ const noCriteria = r.summary.successCriteria.length === 0;
493
+ if (noGoal && noCriteria)
494
+ warn("No goal or success criteria written", annotate);
495
+ else if (noGoal)
496
+ warn("No goal written", annotate);
497
+ else if (noCriteria)
498
+ warn("No success criteria written", annotate);
499
+ for (const f of r.proof.lint.findings) {
500
+ warn(`Lint ${f.level} ${f.rule}${f.step === undefined ? "" : ` (step ${f.step})`}: ${f.message}`);
501
+ }
502
+ for (const c of r.sideEffects.riskyControls) {
503
+ const text = `Risky control at step ${c.step}: ${c.role === undefined ? "" : `${c.role} `}"${c.control}" (${c.risk})`;
504
+ if (c.waivedBy === undefined)
505
+ warn(text);
506
+ else
507
+ info(`${text}, exempted by allowControl`);
508
+ }
509
+ if (r.catalog !== undefined) {
510
+ if (r.catalog.unvetted.length > 0)
511
+ warn(`Unvetted catalog links: ${r.catalog.unvetted.join(", ")}`, `approve them, or promote with --accept-unvetted "<reason>"`);
512
+ if (r.catalog.needsReReview.length > 0)
513
+ warn(`Catalog links need re-review: ${r.catalog.needsReReview.join("; ")}`);
514
+ }
515
+ for (const f of r.findings ?? []) {
516
+ if (f.analyzer === "catalog-analysis")
517
+ continue;
518
+ if (f.severity !== "info")
519
+ warn(f.message, f.fix);
520
+ else if (f.analyzer === "readiness" || f.analyzer === "readiness-jev")
521
+ info(f.message, f.fix);
522
+ }
523
+ const c = r.changeSinceApproval;
524
+ if ((c.kind === "diff" || c.kind === "snapshot-missing") && c.changed) {
525
+ if (c.kind === "diff") {
526
+ const bit = (sign, n, word, many) => (n === 0 ? [] : [`${sign}${plural(n, word, many)}`]);
527
+ const parts = [
528
+ ...bit("+", c.steps.added.length, "step"),
529
+ ...bit("−", c.steps.removed.length, "step"),
530
+ ...bit("+", c.assertions.added.length, "check"),
531
+ ...bit("−", c.assertions.removed.length, "check"),
532
+ ...bit("+", c.sideEffects.added.length, "side effect"),
533
+ ...bit("−", c.sideEffects.removed.length, "side effect"),
534
+ ];
535
+ warn(`Changed since approval${parts.length === 0 ? "" : `: ${parts.join(", ")}`}${c.stepIdOnly === true ? " (step ids only)" : ""}`, "--full lists every change");
536
+ }
537
+ else {
538
+ warn(`Changed since approval (its approved snapshot is missing, so only the hash differs)${c.stepIdOnly === true ? " (step ids only)" : ""}`);
539
+ }
540
+ }
541
+ for (const w of r.anchorWarnings ?? [])
542
+ warn(`Anchor ${w.severity} ${w.path}${w.step === undefined ? "" : ` (step ${w.step})`}: ${w.message}`, w.fix);
543
+ if (r.proposal !== undefined) {
544
+ const p = r.proposal;
545
+ if (p.stale || p.problem !== undefined)
546
+ warn(`A self-heal proposal is pending but cannot be accepted: ${p.stale ? "the Journey changed since" : p.problem}`);
547
+ else
548
+ warn(`A self-heal proposal changes ${plural(p.steps.length, "step")}`, p.acceptCommand);
549
+ }
550
+ const v = r.proof.verify;
551
+ if (v.status === "not-verified")
552
+ info("Not mutation-tested", `jevitate journey verify ${r.id} --mutate`);
553
+ else if (v.stale)
554
+ warn("The mutation proof is out of date: the Journey changed after it ran", `jevitate journey verify ${r.id} --mutate`);
555
+ else if (v.verdict === "insensitive")
556
+ warn("Mutation proof: some checks cannot fail", `jevitate journey verify ${r.id} --mutate`);
557
+ if (r.proof.acceptedWeak !== undefined)
558
+ info(`Weak checks were accepted: "${r.proof.acceptedWeak.reason}"`);
559
+ if (r.locatorHealth !== undefined && r.locatorHealth.brittle > 0)
560
+ info(`${plural(r.locatorHealth.brittle, "step")} on brittle locators`, "--full lists the fixes");
561
+ return [...out.filter((a) => a.severity === "warn"), ...out.filter((a) => a.severity === "info")];
397
562
  }
398
563
  /** #437: a copy of an approval's provenance (the sheet never shares the Journey's objects). */
399
564
  function copyProvenance(p) {
@@ -454,125 +619,374 @@ function prLines(p) {
454
619
  ];
455
620
  return [`Pull-request approval: ${parts.join(" · ")}`];
456
621
  }
457
- function renderSheet(r, style) {
622
+ const DEFAULT_WIDTH = 100;
623
+ const NB = " ";
624
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
625
+ /** `2026-10-11T08:33:26.979Z` as `11 Oct 2026 08:33 UTC` — parsed, not locale-formatted, so it is deterministic. */
626
+ export function humanTime(iso) {
627
+ const m = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)/.exec(iso);
628
+ if (m === null)
629
+ return iso;
630
+ return `${Number(m[3])} ${MONTHS[Number(m[2]) - 1] ?? m[2]} ${m[1]} ${m[4]}:${m[5]} UTC`;
631
+ }
632
+ /** Spaces inside a command or hash must not become line breaks. */
633
+ const nb = (s) => s.replace(/ /g, NB);
634
+ /** Wraps `text` after `prefix`; continuation lines are indented `hang` columns. Words (and nb-joined commands) are never cut. */
635
+ function wrapText(prefix, text, width, hang = prefix.length) {
636
+ const words = text.split(" ").filter((w) => w !== "");
637
+ const lines = [];
638
+ let cur = prefix;
639
+ let empty = true;
640
+ for (const w of words) {
641
+ if (!empty && cur.length + 1 + w.length > width) {
642
+ lines.push(cur);
643
+ cur = " ".repeat(hang);
644
+ empty = true;
645
+ }
646
+ cur += `${empty ? "" : " "}${w}`;
647
+ empty = false;
648
+ }
649
+ lines.push(cur);
650
+ return lines.map((l) => l.replaceAll(NB, " ").trimEnd());
651
+ }
652
+ const HEADERS = ["NEEDS YOUR ATTENTION", "WHAT IT DOES", "STEPS", "PROVES AT THE END", "WRITES", "CLEAR", "APPROVE", "INPUTS", "PROPOSED REVISION (SELF-HEAL)", "DETAIL", "Review"];
653
+ function paint(line, color) {
654
+ if (!color)
655
+ return line;
656
+ const header = HEADERS.find((h) => line.startsWith(h) && (line.length === h.length || line[h.length] === " "));
657
+ const body = line.replaceAll("⚠", "\u001b[33m⚠\u001b[0m").replaceAll("✓", "\u001b[32m✓\u001b[0m");
658
+ return header === undefined ? body : `\u001b[1m${header}\u001b[0m${body.slice(header.length)}`;
659
+ }
660
+ function renderSheet(r, style, opts = {}) {
458
661
  const md = style === "markdown";
662
+ const width = Math.max(40, opts.width ?? DEFAULT_WIDTH);
663
+ const full = opts.full === true;
459
664
  const code = (s) => (md ? `\`${s.replace(/`/g, "'")}\`` : s);
460
- const h1 = (s) => (md ? `# ${s}` : `${s}\n${"=".repeat(Math.min(s.length, 80))}`);
461
- const h2 = (s) => (md ? `## ${s}` : `${s.toUpperCase()}`);
462
- const h3 = (s) => (md ? `### ${s}` : `${s}:`);
463
665
  const em = (s) => (md ? `_${s}_` : s);
464
- const li = (s, depth = 0) => `${" ".repeat(md ? depth : depth + 1)}- ${s}`;
465
666
  const out = [];
466
- const section = (...lines) => {
467
- out.push(...lines, "");
667
+ /** One item: wrapped and hanging in text, a single line in Markdown. */
668
+ const put = (prefix, text, hang) => {
669
+ if (md)
670
+ out.push(`${prefix}${text}`.replaceAll(NB, " "));
671
+ else
672
+ out.push(...wrapText(prefix, text, width, hang));
468
673
  };
469
- const list = (items, none) => (items.length === 0 ? [li(em(none))] : items.map((i) => li(i)));
470
- section(h1(`Journey review: ${r.name} (${r.id})`), "", `Content hash: ${code(r.contentHash)}`, `Status: ${r.promoted ? "promoted" : "not promoted"}${r.approval === undefined ? "" : ` · last approved ${r.approval.at} — ${describeProvenance(r.approval.provenance)}`}`, ...prLines(r.approval?.provenance));
471
- const s = r.summary;
472
- section(h2("Summary"), "", ...(s.description === undefined ? [] : [li(`Description: ${s.description}`)]), li(`Goal: ${s.goal ?? em("none")}`), ...(s.persona === undefined ? [] : [li(`Persona: ${s.persona}`)]), ...(s.role === undefined ? [] : [li(`Runs as: ${s.role}`)]), ...(s.requiresAuth ? [li("Needs an authenticated session")] : []), ...(s.preconditions.length === 0 ? [] : [li("Preconditions:"), ...s.preconditions.map((p) => li(p, 1))]), li("Success criteria:"), ...(s.successCriteria.length === 0
473
- ? [li(em("none"), 1)]
474
- : s.successCriteria.map((c) => li(`${c.description}${c.check === undefined ? "" : ` (checked by ${code(c.check)})`}`, 1))), ...(s.missingIntent.length === 0 ? [] : [li("Missing intent:"), ...s.missingIntent.map((w) => li(`⚠ ${w}`, 1))]));
475
- const stepLinesOut = [];
476
- for (const st of r.steps) {
477
- const target = st.target === undefined ? "" : ` — target: ${[st.target.role, st.target.name === undefined ? undefined : `"${st.target.name}"`].filter((x) => x !== undefined).join(" ")}`;
478
- stepLinesOut.push(`${md ? "" : " "}${st.number}. ${md ? `**${st.action}**` : st.action}${target}`);
479
- const sub = (t) => {
480
- stepLinesOut.push(`${md ? " " : " "}- ${t}`);
481
- };
482
- sub(`Objective: ${st.objective ?? em("none")}`);
483
- if (st.expectedResult !== undefined)
484
- sub(`Expected result: ${st.expectedResult}`);
485
- if (st.assertion !== undefined)
486
- sub(`Checks: ${code(st.assertion)}`);
487
- if (st.params.length > 0)
488
- sub(`Params: ${st.params.map(code).join(", ")}`);
489
- }
490
- section(h2("Steps"), "", ...(stepLinesOut.length === 0 ? [li(em("no steps"))] : stepLinesOut));
491
- const fx = r.sideEffects;
492
- section(h2("Side effects"), "", h3("Write requests"), ...list(fx.writeRequests.map((w) => `${code(`${w.method} ${w.endpoint}`)} — ${w.source === "recorded" ? `recorded at step ${w.step}` : w.source === "expect-request" ? `expected at step ${w.step}` : "end-state check"}`), "none expected"), "", h3("Controls matching safety rules"), ...list(fx.riskyControls.map((c) => `step ${c.step}: ${c.role === undefined ? "" : `${c.role} `}"${c.control}" — ${c.risk} [${code(c.ruleId)}]${c.waivedBy === undefined ? "" : ` (exempted by allowControl ${code(c.waivedBy)})`}`), "none"), "", h3("Origins"), ...list(fx.origins.map(code), "none"));
493
- section(h2("Inputs"), "", h3("Parameters"), ...list(r.inputs.params.map((p) => `${code(p.name)}${p.secret ? " (secret)" : ""}${p.description === undefined ? "" : ` — ${p.description}`}`), "none"), "", h3("Secret references"), ...list(r.inputs.secrets.map((x) => `${code(x.field)} from ${x.manager} on ${x.origin}`), "none"));
494
- const p = r.proof;
495
- const verify = p.verify.status === "not-verified"
496
- ? `not verified — ${p.verify.hint}`
497
- : `${p.verify.verdict} (${p.verify.at})${p.verify.stale ? " — STALE: the Journey changed after this proof ran" : ""}${p.verify.reason === undefined ? "" : ` — ${p.verify.reason}`}: ${Object.entries(p.verify.summary)
498
- .map(([k, v]) => `${v} ${k}`)
499
- .join(", ")}`;
500
- section(h2("Proof"), "", h3("End-state checks"), ...list(p.endState.map(code), "none"), "", h3("Per-step assertions"), ...list(p.stepAssertions.map((a) => `step ${a.step}: ${code(a.check)}${a.weak ? " (weak: proves nothing about the step's effect)" : ""}`), "none"), "", h3(`Lint: ${p.lint.errors} error(s), ${p.lint.warnings} warning(s)`), ...list(p.lint.findings.map((f) => `${f.level} ${f.rule}${f.step === undefined ? "" : ` (step ${f.step})`}: ${f.message}`), "no findings"), ...(p.acceptedWeak === undefined ? [] : ["", `Accepted weak: "${p.acceptedWeak.reason}" (waived: ${p.acceptedWeak.rules.join(", ")})`]), "", `Mutation proof: ${verify}`);
674
+ const gap = () => void out.push("");
675
+ /** A section header: caps in text, a heading in Markdown. */
676
+ const head = (label, title = label.charAt(0) + label.slice(1).toLowerCase()) => {
677
+ if (!md)
678
+ out.push(label);
679
+ else
680
+ out.push(`## ${title}`, "");
681
+ };
682
+ const bullet = md ? "- " : " ";
683
+ const cmd = (c) => (md ? code(c) : nb(c));
684
+ // ── Header
685
+ const prov = r.approval?.provenance;
501
686
  const c = r.changeSinceApproval;
502
- const change = [];
503
- if (c.kind === "first-approval")
504
- change.push(em("first approval — nothing approved before"));
505
- else if (c.kind === "no-record")
506
- change.push(em("promoted before approvals were recorded — no approved version to compare with"));
507
- else if (c.kind === "snapshot-missing") {
508
- change.push(`Approved${c.approvedAt === undefined ? "" : ` ${c.approvedAt}`} as ${code(c.approvedHash)}; its snapshot is missing — ${c.changed ? "the Journey CHANGED since" : "unchanged since"}`);
509
- if (c.stepIdOnly === true)
510
- change.push(li(STEP_ID_ONLY_LABEL));
687
+ const changed = (c.kind === "diff" || c.kind === "snapshot-missing") && c.changed;
688
+ const plainApproval = prov !== undefined && prov.channel === "tty" && prov.agentSignals.filter((x) => !/not.?tty/i.test(x)).length === 0;
689
+ const approvedBy = prov?.user === undefined ? "" : ` by ${prov.user}`;
690
+ const status = !r.promoted
691
+ ? "not promoted"
692
+ : r.approval === undefined
693
+ ? "promoted (no approval recorded)"
694
+ : `approved ${humanTime(r.approval.at)}${plainApproval ? `${approvedBy} (terminal)` : ` — ${describeProvenance(prov)}`} · ${nb(changed ? "CHANGED since approval" : "unchanged since approval")}`;
695
+ if (md)
696
+ out.push(`# Review: ${r.name}`, "", `**${r.id}** · ${status}`);
697
+ else {
698
+ put("Review ", r.name);
699
+ put(" ", `${r.id} · ${status}`);
700
+ }
701
+ for (const l of prLines(prov))
702
+ put(md ? "" : " ", l);
703
+ gap();
704
+ // ── Needs your attention
705
+ const attention = r.attention ?? [];
706
+ if (attention.length === 0) {
707
+ if (md)
708
+ out.push("## Needs your attention", "", "Nothing — every gate is clear.");
709
+ else
710
+ out.push("NEEDS YOUR ATTENTION nothing — every gate is clear");
511
711
  }
512
712
  else {
513
- change.push(`Approved${c.approvedAt === undefined ? "" : ` ${c.approvedAt}`} as ${code(c.approvedHash)} — ${c.changed ? "CHANGED since" : "unchanged since"}`);
514
- if (c.stepIdOnly === true)
515
- change.push(li(STEP_ID_ONLY_LABEL));
516
- for (const [label, d] of [
517
- ["Steps", c.steps],
518
- ["Assertions", c.assertions],
519
- ["Side effects", c.sideEffects],
520
- ]) {
521
- if (d.added.length === 0 && d.removed.length === 0)
522
- continue;
523
- change.push("", h3(label), ...d.removed.map((l) => li(`− ${l}`)), ...d.added.map((l) => li(`+ ${l}`)));
713
+ head("NEEDS YOUR ATTENTION", "Needs your attention");
714
+ for (const a of attention) {
715
+ const icon = a.severity === "warn" ? "⚠" : "○";
716
+ const hint = a.hint === undefined ? "" : ` → ${/^(jevitate|--)/.test(a.hint) ? cmd(a.hint) : a.hint}`;
717
+ put(`${bullet}${icon} `, `${a.text}${hint}`, bullet.length + 2);
524
718
  }
525
719
  }
526
- section(h2("Change since last approval"), "", ...change);
527
- // #466 / #470: anchor-rule warnings and brittle steps, each with its fix.
528
- if (r.anchorWarnings !== undefined && r.anchorWarnings.length > 0) {
529
- section(h2("Anchor rules (#466)"), "", ...r.anchorWarnings.map((w) => li(`${w.severity} ${w.path}${w.step === undefined ? "" : ` (step ${w.step})`}: ${w.message}${w.fix === undefined ? "" : ` — fix: ${w.fix}`}`)));
720
+ gap();
721
+ // ── What it does
722
+ const s = r.summary;
723
+ const wid = [];
724
+ const primary = s.goal ?? s.intent ?? s.description;
725
+ head("WHAT IT DOES", "What it does");
726
+ if (primary !== undefined)
727
+ put(bullet, primary);
728
+ if (s.goal !== undefined && s.intent !== undefined && s.intent !== s.goal && full)
729
+ put(bullet, s.intent);
730
+ if (s.description !== undefined && s.description !== primary && full)
731
+ put(bullet, s.description);
732
+ for (const cr of s.successCriteria)
733
+ put(bullet, `Success: ${cr.description}${cr.check === undefined || !full ? "" : ` (checked by ${code(cr.check)})`}`);
734
+ for (const pc of s.preconditions)
735
+ put(bullet, `Needs: ${pc}`);
736
+ if (s.persona !== undefined)
737
+ wid.push(`persona ${s.persona}`);
738
+ if (s.role !== undefined)
739
+ wid.push(`runs as ${s.role}`);
740
+ if (s.requiresAuth)
741
+ wid.push("Signed in");
742
+ wid.push(...r.sideEffects.origins.slice(0, 1));
743
+ const files = r.steps.flatMap((st) => (st.file === undefined ? [] : [st.file]));
744
+ if (wid.length > 0)
745
+ put(bullet, wid.join(" · "));
746
+ if (files.length > 0)
747
+ put(bullet, `Uploads ${files.map(nb).join(", ")}`);
748
+ gap();
749
+ // ── Inputs (only when present)
750
+ if (r.inputs.params.length > 0 || r.inputs.secrets.length > 0) {
751
+ head("INPUTS", "Inputs");
752
+ if (r.inputs.params.length > 0)
753
+ put(bullet, `Parameters: ${r.inputs.params.map((p) => `${code(p.name)}${p.secret ? " (secret)" : ""}${p.description === undefined ? "" : ` — ${p.description}`}`).join("; ")}`);
754
+ for (const x of r.inputs.secrets)
755
+ put(bullet, `Secret ${code(x.field)} from ${x.manager} on ${x.origin}`);
756
+ gap();
530
757
  }
531
- if (r.locatorHealth !== undefined) {
532
- const lh = r.locatorHealth;
533
- section(h2("Locator health (advisory)"), "", lh.line, "", ...(lh.brittleSteps.length === 0
534
- ? [li(em("no brittle steps"))]
535
- : lh.brittleSteps.map((b) => li(`step ${b.index + 1}${b.stepId === undefined ? "" : ` [${b.stepId}]`} ${b.kind} ${code(b.locator)}${b.route === undefined ? "" : ` on ${b.route}`} — ${b.reasons.join("; ")} — fix: ${b.fix}`))));
758
+ // ── Steps
759
+ if (r.steps.length > 0) {
760
+ head("STEPS", "Steps");
761
+ const numW = String(r.steps.length).length;
762
+ for (const st of r.steps) {
763
+ const anchors = (st.anchors ?? []).map((a) => `⚓ ${a}`).join(" ");
764
+ const target = full && st.target !== undefined ? ` — target: ${[st.target.role, st.target.name === undefined ? undefined : `"${st.target.name}"`].filter((x) => x !== undefined).join(" ")}` : "";
765
+ const lead = md ? `${st.number}. ` : ` ${String(st.number).padStart(numW)} `;
766
+ const prefixLen = lead.length;
767
+ // M4: a labelled step's exact locator follows its label, so a spoofed label cannot hide it.
768
+ const locator = st.locator === undefined ? "" : `${md ? " " : " "}‹${code(st.locator)}›`;
769
+ put(lead, `${md ? `**${st.action}**` : st.action}${locator}${target}${anchors === "" ? "" : ` ${anchors}`}`, md ? 0 : prefixLen);
770
+ const sub = md ? " - " : " ".repeat(prefixLen);
771
+ const subHang = sub.length + 2;
772
+ if (st.checks !== undefined) {
773
+ const joined = st.checks.map((k) => (k.weak ? `⚠ ${k.text} (proves nothing)` : `✓ ${k.text}`));
774
+ if (md)
775
+ for (const j of joined)
776
+ put(sub, j);
777
+ else if (sub.length + joined.join(" · ").length <= width)
778
+ put(sub, joined.join(" · "), subHang);
779
+ else
780
+ for (const j of joined)
781
+ put(sub, j, subHang);
782
+ }
783
+ const writes = [...new Set(r.sideEffects.writeRequests.filter((w) => w.step === st.number && w.source !== "end-state").map((w) => `${w.method} ${w.endpoint}`))];
784
+ if (writes.length > 0)
785
+ put(sub, `writes ${writes.join(" · ")}`, subHang);
786
+ if (full) {
787
+ if (st.objective !== undefined)
788
+ put(sub, `Objective: ${st.objective}`, subHang);
789
+ if (st.expectedResult !== undefined)
790
+ put(sub, `Expected result: ${st.expectedResult}`, subHang);
791
+ if (st.file !== undefined)
792
+ put(sub, `File: ${code(st.file)}`, subHang);
793
+ if (st.params.length > 0)
794
+ put(sub, `Params: ${st.params.map(code).join(", ")}`, subHang);
795
+ }
796
+ }
797
+ gap();
536
798
  }
537
- // #433: the catalog links (job, persona), then the pre-approval findings — before the approval line.
538
- if (r.catalog !== undefined) {
539
- const k = r.catalog;
540
- const status = (st) => (st === "stale" ? "STALE — needs re-review" : st === "unknown" ? "UNKNOWN — not declared in the catalog" : st);
541
- section(h2("Catalog"), "", ...(k.linked
542
- ? [
543
- li(k.job === undefined ? `Job: ${em("none")}` : `Job: ${code(k.job.id)} (${status(k.job.status)})${k.job.story === undefined ? "" : ` — ${k.job.story}`}`),
544
- li(k.persona === undefined ? `Persona: ${em("none")}` : `Persona: ${code(k.persona.id)} (${status(k.persona.status)})`),
545
- ...(k.unvetted.length === 0 ? [] : [li(`Unvetted: ${k.unvetted.join(", ")} — promote needs them approved, or --accept-unvetted "<reason>"`)]),
546
- ...(k.needsReReview.length === 0 ? [] : [li(`NEEDS RE-REVIEW: ${k.needsReReview.join("; ")}`)]),
547
- ]
548
- : [li(em("not linked to a job/persona"))]));
799
+ // ── Proves at the end
800
+ const endState = r.proof.endStateText ?? r.proof.endState;
801
+ if (endState.length > 0) {
802
+ head("PROVES AT THE END", "Proves at the end");
803
+ for (const e of endState)
804
+ put(`${bullet}✓ `, e, bullet.length + 2);
805
+ gap();
549
806
  }
550
- if (r.findings !== undefined)
551
- section(...renderFindings(r.findings, style));
552
- if (r.approval?.waivers !== undefined || r.approval?.acceptedFindings !== undefined) {
553
- section(h3("Recorded with the last approval"), ...(r.approval.waivers ?? []).map((w) => li(`waived (${w.kind}): "${w.reason}" — ${w.items.join(", ")}`)), ...(r.approval.acceptedFindings === undefined ? [] : [li(`acknowledged findings: "${r.approval.acceptedFindings.reason}" — ${r.approval.acceptedFindings.findings.join(", ")}`)]));
807
+ // ── Writes
808
+ const fx = r.sideEffects;
809
+ const byEndpoint = new Map();
810
+ for (const w of fx.writeRequests) {
811
+ const key = `${w.method} ${w.endpoint}`;
812
+ const e = byEndpoint.get(key) ?? { steps: [], end: false };
813
+ if (w.step !== undefined)
814
+ e.steps.push(w.step);
815
+ if (w.source === "end-state")
816
+ e.end = true;
817
+ byEndpoint.set(key, e);
554
818
  }
819
+ if (byEndpoint.size > 0 || fx.riskyControls.length > 0 || fx.origins.length > 1) {
820
+ head("WRITES", "Writes");
821
+ for (const [key, e] of byEndpoint) {
822
+ const where = [...(e.steps.length === 0 ? [] : [`${e.steps.length > 1 ? "steps" : "step"} ${stepRanges(e.steps)}`]), ...(e.end ? ["end state"] : [])];
823
+ put(bullet, `${code(key)}${where.length === 0 ? "" : ` (${where.join(", ")})`}`);
824
+ }
825
+ for (const x of fx.riskyControls) {
826
+ put(bullet, `Risky control, step ${x.step}: ${x.role === undefined ? "" : `${x.role} `}"${x.control}" — ${x.risk} [${code(x.ruleId)}]${x.waivedBy === undefined ? "" : ` (exempted by allowControl ${code(x.waivedBy)})`}`);
827
+ }
828
+ if (fx.origins.length > 1)
829
+ put(bullet, `Origins: ${fx.origins.map(code).join(", ")}`);
830
+ gap();
831
+ }
832
+ // ── Clear
833
+ const clear = [];
834
+ const lint = r.proof.lint;
835
+ if (lint.errors === 0 && lint.warnings === 0)
836
+ clear.push("lint ✓");
837
+ if (r.locatorHealth !== undefined && r.locatorHealth.brittle === 0)
838
+ clear.push(`locators ${r.locatorHealth.stable}/${r.locatorHealth.stable} stable ✓`);
839
+ if (r.catalog !== undefined && !r.catalog.linked)
840
+ clear.push("not linked to a job/persona (info)");
841
+ else if (r.catalog !== undefined && r.catalog.unvetted.length === 0 && r.catalog.needsReReview.length === 0)
842
+ clear.push("catalog links ✓");
843
+ const related = new Set((r.findings ?? []).filter((f) => f.analyzer === "catalog-analysis" && f.code.startsWith("pair:")).flatMap((f) => (f.items ?? []).filter((i) => i !== `journey:${r.id}`)));
844
+ const relatedCount = related.size > 0 ? related.size : (r.findings ?? []).filter((f) => f.analyzer === "catalog-analysis" && f.code.startsWith("pair:")).length;
845
+ if (relatedCount > 0)
846
+ clear.push(`${plural(relatedCount, "related Journey", "related Journeys")} share writes${full ? "" : " (--full for detail)"}`);
847
+ const v = r.proof.verify;
848
+ if (v.status === "recorded" && !v.stale && v.verdict !== "insensitive")
849
+ clear.push(`mutation proof ${v.verdict} ✓`);
850
+ if (clear.length > 0) {
851
+ if (md)
852
+ out.push("## Clear", "", clear.join(" · "));
853
+ else
854
+ put("CLEAR ", clear.join(" · "), 9);
855
+ gap();
856
+ }
857
+ // ── Self-heal proposal
555
858
  if (r.proposal !== undefined) {
556
859
  const pr = r.proposal;
860
+ head("PROPOSED REVISION (SELF-HEAL)", "Proposed revision (self-heal)");
861
+ put(bullet, `${code(pr.proposalId)} from ${humanTime(pr.createdAt)}: accepting it makes the content hash ${nb(code(pr.proposedHash))} (made against ${nb(code(pr.baseHash))})`);
557
862
  const scope = pr.changes.range ?? (pr.changes.notes.length > 0 ? "change notes" : "no change context");
558
- section(h2("Proposed revision (self-heal)"), "", li(`Proposal ${code(pr.proposalId)} from ${pr.createdAt}, made against ${code(pr.baseHash)}; accepting it makes the content hash ${code(pr.proposedHash)}`), li(`Change context: ${scope}${pr.changes.baseSha === undefined ? "" : ` (${pr.changes.baseSha.slice(0, 8)}..${(pr.changes.headSha ?? "").slice(0, 8)})`}`), ...pr.changes.notes.map((n) => li(`note: ${n}`, 1)), li(`Heal attempts: ${pr.attempts.total} (${pr.attempts.rejected} rejected)`), ...(pr.stale ? [li(`STALE — the Journey changed after this proposal was made; it cannot be accepted. Re-run: jevitate journey run ${r.id} --self-heal`)] : []), ...(pr.problem !== undefined && !pr.stale ? [li(`REFUSED — ${pr.problem}`)] : []), "", ...pr.steps.flatMap((st) => [
559
- `${md ? "" : " "}${st.number}. ${md ? `**${st.hypothesis}**` : st.hypothesis}`,
560
- `${md ? " " : " "}- Before: ${code(st.before)}`,
561
- `${md ? " " : " "}- After: ${code(st.after)}`,
562
- ...st.evidence.map((e) => `${md ? " " : " "}- Evidence: ${e}`),
563
- ...st.screenshots.map((sh) => `${md ? " " : " "}- Screenshot: ${sh}`),
564
- ]), "", pr.stale || pr.problem !== undefined ? em("not acceptable as it stands") : `Accept exactly this revision: ${code(pr.acceptCommand)}`, `Reject it: ${code(`jevitate journey promote ${r.id} --reject-proposal ${pr.proposalId} --reason "<why>"`)}`);
863
+ put(bullet, `Change context: ${scope}${pr.changes.baseSha === undefined ? "" : ` (${pr.changes.baseSha.slice(0, 8)}..${(pr.changes.headSha ?? "").slice(0, 8)})`} · heal attempts ${pr.attempts.total} (${pr.attempts.rejected} rejected)`);
864
+ for (const n of pr.changes.notes)
865
+ put(bullet, `note: ${n}`);
866
+ if (pr.stale)
867
+ put(bullet, `STALE — the Journey changed after this proposal was made; it cannot be accepted. Re-run: ${cmd(`jevitate journey run ${r.id} --self-heal`)}`);
868
+ if (pr.problem !== undefined && !pr.stale)
869
+ put(bullet, `REFUSED — ${pr.problem}`);
870
+ for (const st of pr.steps) {
871
+ put(bullet, `Step ${st.number}: ${st.hypothesis}`);
872
+ put(`${bullet} `, `before: ${code(st.before)}`);
873
+ put(`${bullet} `, `after: ${code(st.after)}`);
874
+ for (const e of st.evidence)
875
+ put(`${bullet} `, `evidence: ${e}`);
876
+ for (const sh of st.screenshots)
877
+ put(`${bullet} `, `screenshot: ${sh}`);
878
+ }
879
+ if (pr.stale || pr.problem !== undefined)
880
+ put(bullet, em("not acceptable as it stands"));
881
+ else
882
+ put(bullet, `Accept exactly this revision: ${cmd(pr.acceptCommand)}`);
883
+ put(bullet, `Reject it: ${cmd(`jevitate journey promote ${r.id} --reject-proposal ${pr.proposalId} --reason "<why>"`)}`);
884
+ gap();
885
+ }
886
+ // ── Full detail
887
+ if (full) {
888
+ head("DETAIL", "Detail");
889
+ const h3 = (t) => {
890
+ out.push(md ? `### ${t}` : `${t}:`);
891
+ };
892
+ h3(`Lint: ${lint.errors} error(s), ${lint.warnings} warning(s)`);
893
+ if (lint.findings.length === 0)
894
+ put(bullet, em("no findings"));
895
+ for (const f of lint.findings)
896
+ put(bullet, `${f.level} ${f.rule}${f.step === undefined ? "" : ` (step ${f.step})`}: ${f.message}`);
897
+ if (r.proof.acceptedWeak !== undefined)
898
+ put(bullet, `Accepted weak: "${r.proof.acceptedWeak.reason}" (waived: ${r.proof.acceptedWeak.rules.join(", ")})`);
899
+ const verify = v.status === "not-verified" ? `not verified — ${v.hint}` : `${v.verdict} (${humanTime(v.at)})${v.stale ? " — STALE: the Journey changed after this proof ran" : ""}${v.reason === undefined ? "" : ` — ${v.reason}`}: ${Object.entries(v.summary).map(([k, n]) => `${n} ${k}`).join(", ")}`;
900
+ put("", `Mutation proof: ${verify}`);
901
+ gap();
902
+ h3("Change since last approval");
903
+ if (c.kind === "first-approval")
904
+ put(bullet, em("first approval — nothing approved before"));
905
+ else if (c.kind === "no-record")
906
+ put(bullet, em("promoted before approvals were recorded — no approved version to compare with"));
907
+ else {
908
+ put(bullet, `Approved${c.approvedAt === undefined ? "" : ` ${humanTime(c.approvedAt)}`} as ${nb(code(c.approvedHash))}${c.kind === "snapshot-missing" ? "; its snapshot is missing" : ""} — ${c.changed ? "CHANGED since" : "unchanged since"}`);
909
+ if (c.stepIdOnly === true)
910
+ put(bullet, STEP_ID_ONLY_LABEL);
911
+ if (c.kind === "diff") {
912
+ for (const [label, d] of [["Steps", c.steps], ["Assertions", c.assertions], ["Side effects", c.sideEffects]]) {
913
+ if (d.added.length === 0 && d.removed.length === 0)
914
+ continue;
915
+ put(bullet, `${label}:`);
916
+ for (const l of d.removed)
917
+ put(`${bullet} `, `− ${l}`, bullet.length + 4);
918
+ for (const l of d.added)
919
+ put(`${bullet} `, `+ ${l}`, bullet.length + 4);
920
+ }
921
+ }
922
+ }
923
+ gap();
924
+ if (r.anchorWarnings !== undefined && r.anchorWarnings.length > 0) {
925
+ h3("Anchor rules");
926
+ for (const w of r.anchorWarnings)
927
+ put(bullet, `${w.severity} ${w.path}${w.step === undefined ? "" : ` (step ${w.step})`}: ${w.message}${w.fix === undefined ? "" : ` — fix: ${w.fix}`}`);
928
+ gap();
929
+ }
930
+ if (r.locatorHealth !== undefined) {
931
+ const lh = r.locatorHealth;
932
+ h3("Locator health (advisory)");
933
+ put("", lh.line);
934
+ if (lh.brittleSteps.length === 0)
935
+ put(bullet, em("no brittle steps"));
936
+ for (const b of lh.brittleSteps)
937
+ put(bullet, `step ${b.index + 1}${b.stepId === undefined ? "" : ` [${b.stepId}]`} ${b.kind} ${code(b.locator)}${b.route === undefined ? "" : ` on ${b.route}`} — ${b.reasons.join("; ")} — fix: ${b.fix}`);
938
+ gap();
939
+ }
940
+ if (r.catalog !== undefined) {
941
+ const k = r.catalog;
942
+ const st = (x) => (x === "stale" ? "STALE — needs re-review" : x === "unknown" ? "UNKNOWN — not declared in the catalog" : x);
943
+ h3("Catalog");
944
+ if (!k.linked)
945
+ put(bullet, em("not linked to a job/persona"));
946
+ else {
947
+ put(bullet, k.job === undefined ? "Job: none" : `Job: ${code(k.job.id)} (${st(k.job.status)})${k.job.story === undefined ? "" : ` — ${k.job.story}`}`);
948
+ put(bullet, k.persona === undefined ? "Persona: none" : `Persona: ${code(k.persona.id)} (${st(k.persona.status)})`);
949
+ }
950
+ gap();
951
+ }
952
+ if (r.findings !== undefined) {
953
+ for (const l of renderFindings(r.findings, style)) {
954
+ if (md || l === "" || !/^\s*- /.test(l))
955
+ out.push(l);
956
+ else {
957
+ const lead = /^(\s*- )/.exec(l)[1];
958
+ put(lead, l.slice(lead.length), lead.length + 2);
959
+ }
960
+ }
961
+ }
962
+ if (r.approval !== undefined) {
963
+ h3("Approval record");
964
+ put(bullet, describeProvenance(prov));
965
+ for (const w of r.approval.waivers ?? [])
966
+ put(bullet, `waived (${w.kind}): "${w.reason}" — ${w.items.join(", ")}`);
967
+ if (r.approval.acceptedFindings !== undefined)
968
+ put(bullet, `acknowledged findings: "${r.approval.acceptedFindings.reason}" — ${r.approval.acceptedFindings.findings.join(", ")}`);
969
+ gap();
970
+ }
565
971
  }
566
- section(h2("Content hash"), "", `Content hash: ${code(r.contentHash)}`, "", `Approve exactly this version: ${code(`jevitate journey promote ${r.id} --reviewed-hash ${r.contentHash}`)}`);
567
- return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
972
+ // ── Approve (the one place the content hash appears)
973
+ const approved = r.promoted && !changed && (c.kind === "diff" || c.kind === "snapshot-missing" || c.kind === "no-record");
974
+ head("APPROVE", "Approve");
975
+ const approveCmd = cmd(`jevitate journey promote ${r.id} --reviewed-hash ${r.contentHash}`);
976
+ put(bullet, `${approved ? "(already approved) " : ""}${approveCmd}`);
977
+ if (md)
978
+ out.push("", `Content hash: ${code(r.contentHash)}`);
979
+ else
980
+ out.push(`Content hash: ${r.contentHash}`);
981
+ return `${out.map((l) => paint(l, opts.color === true && !md)).join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
568
982
  }
569
983
  /** #432: the review sheet as Markdown (`journey review --markdown`). */
570
- export function renderReviewMarkdown(review) {
571
- return renderSheet(review, "markdown");
984
+ export function renderReviewMarkdown(review, opts = {}) {
985
+ return renderSheet(review, "markdown", opts);
572
986
  }
573
987
  /** #432: the review sheet as plain text (`journey review`, and `journey promote` in human mode). */
574
- export function renderReviewText(review) {
575
- return renderSheet(review, "text");
988
+ export function renderReviewText(review, opts = {}) {
989
+ return renderSheet(review, "text", opts);
576
990
  }
577
991
  /** #432: a review sheet file's content hash refused (none, or two different ones). */
578
992
  export class ReviewSheetError extends Error {