@nextcommerce/campaigns-os 1.54.0 → 1.57.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 (111) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/CONTEXT.md +19 -5
  3. package/README.md +26 -12
  4. package/campaign-spec/README.md +15 -1
  5. package/campaign-spec/dist/checkout-flow.d.ts +90 -0
  6. package/campaign-spec/dist/checkout-flow.js +229 -0
  7. package/campaign-spec/dist/index.d.ts +1 -0
  8. package/campaign-spec/dist/index.js +6 -1
  9. package/campaign-spec/dist/normalize.d.ts +3 -2
  10. package/campaign-spec/dist/normalize.js +7 -5
  11. package/campaign-spec/dist/routing.d.ts +6 -3
  12. package/campaign-spec/dist/routing.js +6 -3
  13. package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
  14. package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
  15. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
  16. package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
  17. package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
  18. package/campaign-spec/dist/rules/index.d.ts +4 -0
  19. package/campaign-spec/dist/rules/index.js +12 -0
  20. package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
  21. package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
  22. package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
  23. package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
  24. package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
  25. package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
  26. package/campaign-spec/dist/rules/schema-version.js +10 -5
  27. package/campaign-spec/dist/types.d.ts +20 -3
  28. package/campaign-spec/dist/types.js +3 -2
  29. package/compatibility.json +2 -2
  30. package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
  31. package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
  32. package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
  33. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
  34. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
  35. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
  36. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
  37. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
  38. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
  39. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
  40. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
  41. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
  42. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
  43. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
  44. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
  45. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
  46. package/contracts/release-ledger.json +1146 -0
  47. package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
  48. package/contracts/supported-surface.json +9 -5
  49. package/docs/brand-theme-bridge.md +10 -0
  50. package/docs/build-packet.md +86 -26
  51. package/docs/campaigns-os-build-flow.md +1 -1
  52. package/docs/effects.md +2 -5
  53. package/docs/local-setup.md +1 -1
  54. package/docs/orientation-contract-reference.md +1 -1
  55. package/docs/qa-and-test-orders.md +133 -29
  56. package/docs/runtime-readiness.md +8 -3
  57. package/docs/skills-revision.md +10 -10
  58. package/docs/supported-surface.md +1 -1
  59. package/docs/versioning.md +4 -1
  60. package/docs/workflow-findings-sidecar.md +57 -2
  61. package/package.json +4 -1
  62. package/schemas/campaign-runtime-build-context.v0.schema.json +3 -1
  63. package/schemas/campaign-runtime-build-packet.v0.schema.json +3 -1
  64. package/schemas/campaign-spec.v5.schema.json +586 -0
  65. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  66. package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
  67. package/skills/campaign-readback-classification/SKILL.md +3 -3
  68. package/skills/campaign-run-evidence/SKILL.md +3 -3
  69. package/skills/contribution-intake/SKILL.md +3 -3
  70. package/skills/next-campaigns-build/SKILL.md +6 -4
  71. package/skills/next-campaigns-os/SKILL.md +3 -3
  72. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  73. package/skills/next-campaigns-polish/SKILL.md +21 -6
  74. package/skills/next-campaigns-qa/SKILL.md +5 -4
  75. package/skills.json +10 -10
  76. package/src/adapter-decision-contract.mjs +27 -13
  77. package/src/brand-theme.mjs +44 -8
  78. package/src/build-brief.mjs +14 -5
  79. package/src/campaign-identity.mjs +33 -4
  80. package/src/cli-helpers.mjs +129 -0
  81. package/src/cli.mjs +156 -111
  82. package/src/commercial-journey.mjs +25 -2
  83. package/src/consent.mjs +3 -2
  84. package/src/design-source-package.mjs +1 -1
  85. package/src/design-source-publication.mjs +8 -8
  86. package/src/deviation.mjs +2 -1
  87. package/src/doctor/checks.mjs +212 -19
  88. package/src/doctor/inspect.mjs +4 -2
  89. package/src/doctor/next-step.mjs +46 -16
  90. package/src/doctor-sidecar.mjs +5 -3
  91. package/src/gate-actions.mjs +21 -1
  92. package/src/input-refresh.mjs +6 -6
  93. package/src/invocation.mjs +4 -3
  94. package/src/lifecycle.mjs +88 -6
  95. package/src/local-preview-policy.mjs +116 -12
  96. package/src/login.mjs +2 -1
  97. package/src/polish-browser.mjs +8 -2
  98. package/src/polish-node.mjs +9 -4
  99. package/src/polish-readability.mjs +2 -2
  100. package/src/qa-browser.mjs +741 -120
  101. package/src/qa-cart-entry.mjs +107 -20
  102. package/src/qa-node.mjs +179 -72
  103. package/src/qa-test-order-topology.mjs +118 -1
  104. package/src/qa-verdict-discovery.mjs +21 -4
  105. package/src/qa-verdict-publish.mjs +2 -1
  106. package/src/run-record.mjs +46 -34
  107. package/src/runtime-state-ignore.mjs +7 -5
  108. package/src/sdk-markup.mjs +41 -1
  109. package/src/source-html-intake.mjs +46 -4
  110. package/src/stage-ledger.mjs +18 -4
  111. package/src/stage-record.mjs +53 -16
@@ -1,9 +1,9 @@
1
1
  // The next step doctor recommends, and the gate issues `next` reads from doctor.
2
2
  import { campaignIdentitiesMatch } from "../spec-source-identity.mjs";
3
- import { CARRIED_FORWARD } from "../local-preview-policy.mjs";
3
+ import { NO_CAPTURABLE_ROUTES_CODE, hostedTemplateNeedsUsablePreview, polishCarriedForwardForLadder, polishGateCanCarryPreview } from "../local-preview-policy.mjs";
4
4
  import { resolve } from "node:path";
5
5
  import { orderPathDepthDriftText } from "../proof-policy.mjs";
6
- import { anyAssemblyReportStageBlocked } from "../stage-ledger.mjs";
6
+ import { anyAssemblyReportStageBlocked, qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "../stage-ledger.mjs";
7
7
  import {
8
8
  SOURCE_PREP_DOCUMENT_WRAPPER,
9
9
  SOURCE_PREP_FRONTMATTER_RESIDUE,
@@ -16,9 +16,10 @@ import {
16
16
  stageIsBlocked,
17
17
  stageIsTerminal,
18
18
  } from "../orchestration-stage-contract.mjs";
19
- import { evaluatePolishGate } from "../polish-gate.mjs";
19
+ import { currentBuildFingerprint, evaluatePolishGate } from "../polish-gate.mjs";
20
20
  import { effectiveStageStatus, effectiveStatusIsTerminal } from "../input-currency.mjs";
21
21
  import { cmd } from "../install-invocation.mjs";
22
+ import { shellToken } from "../shell-token.mjs";
22
23
  import { isObject, isNonEmptyString, optionalString, resolveFromFile, addIssue, filesystemPathsMatch } from "../cli-helpers.mjs";
23
24
  import { orderPathDepthDrift } from "./checks.mjs";
24
25
 
@@ -412,7 +413,7 @@ export function assessPurchaseProofCoverage({ packet = null, report = null } = {
412
413
  };
413
414
  }
414
415
 
415
- function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null) {
416
+ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null, packet = null) {
416
417
  const polishGate = derived?.polish_gate || evaluatePolishGate({ report });
417
418
  const polishCheckpointGate = derived?.polish_checkpoint_gate || null;
418
419
  // prepare-build is the earliest lifecycle prerequisite. Surface its
@@ -473,9 +474,15 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
473
474
  }
474
475
 
475
476
  for (const cliStage of NEXT_STAGE_ORDER) {
476
- // On the local preview, missing polish is carried forward as a warning
477
- // (local-preview-policy.mjs): the ladder moves on to deploy and QA.
478
- if (cliStage === "polish" && polishGate.status === CARRIED_FORWARD) continue;
477
+ // A first local preview may carry missing Polish forward. A theme waiver
478
+ // or prior capture makes the recorded Polish stage owed again.
479
+ if (cliStage === "polish" && polishCarriedForwardForLadder(report, polishGate)) continue;
480
+ // A hosted all-template preview has no hosted deploy recorder. The
481
+ // recorded preview URL satisfies this one deploy handoff while the
482
+ // carried-forward gate identifies the exact preview-only shape.
483
+ if (cliStage === "deploy" && packet?.deploy?.preview_url
484
+ && polishGate?.carried_forward?.policy === "hosted_template_preview"
485
+ && polishCheckpointGate?.carried_forward?.policy === "hosted_template_preview") continue;
479
486
  const reportKey = reportKeyForCliStage(cliStage);
480
487
  const stage = report.stages[reportKey];
481
488
  if (!stage) {
@@ -500,6 +507,10 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
500
507
  reason: `Stage "${reportKey}" has status "${status || "(unset)"}"; run "${cliStage}" next.`,
501
508
  };
502
509
  }
510
+ if (cliStage === "qa" && qaRecordedBuildFingerprint(report) && currentBuildFingerprint(report)
511
+ && !qaRecordedForCurrentBuild(report, currentBuildFingerprint(report))) {
512
+ return { stage: "qa", reason: "QA was recorded for a different build; run QA against the current build." };
513
+ }
503
514
  // A terminal QA status is not the same claim as purchase proof. QA finalizes
504
515
  // a verdict and records a terminal status even when no order path ran, so a
505
516
  // `--test-order off` diagnostic used to carry the pipeline to "done" against
@@ -513,9 +524,13 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
513
524
  }
514
525
  }
515
526
 
527
+ const hostedPreview = Boolean(packet?.deploy?.preview_url && polishGate?.carried_forward?.policy === "hosted_template_preview");
516
528
  return {
517
529
  stage: "done",
518
- reason: "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
530
+ outcome: hostedPreview ? "hosted_preview_satisfies_deploy" : "standard_closeout",
531
+ reason: hostedPreview
532
+ ? "The recorded hosted preview satisfies deploy, and QA is terminal for the current build. Pipeline is complete."
533
+ : "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
519
534
  };
520
535
  }
521
536
 
@@ -573,7 +588,7 @@ export const DOCTOR_NEXT_STAGE_OWNERS = NEXT_STAGE_OWNERS;
573
588
  // The code -> action strings doctor prints under `Next:`. They describe the
574
589
  // repairs the findings ask for and are independent of which stage the picker
575
590
  // names, so they survive the picker consolidation unchanged.
576
- function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef = derived.packet_path || "<packet>" }) {
591
+ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packet = null, packetRef = derived.packet_path || "<packet>" }) {
577
592
  const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
578
593
  const onlyPolishErrors = doctorErrorsAreOnlyPolishGate(errors);
579
594
  const actions = [];
@@ -629,11 +644,17 @@ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGat
629
644
  actions.push(`Target campaign output directory is missing; run ${cmd("next")} setup --packet ${packetRef} before build.`);
630
645
  }
631
646
  if (polishBlocked) {
632
- if (polishGate.status === "blocked") {
647
+ const needsHostedPreview = hostedTemplateNeedsUsablePreview(packet, polishCheckpointGate);
648
+ if (needsHostedPreview) {
649
+ actions.push(`Every mapped page is template stock and needs a hosted preview URL that differs from the production URL. Run ${cmd("qa")} policy set --packet ${packetRef} --preview-url <url>, then run ${cmd("next")} --packet ${packetRef}.`);
650
+ }
651
+ if (polishGate.status === "blocked" && (!needsHostedPreview || !polishGateCanCarryPreview(polishGate))) {
633
652
  actions.push(`${polishGate.reason} Run next-campaigns-polish and record structured evidence before deploy/QA handoff.`);
634
653
  }
635
- if (polishCheckpointGate?.status === "blocked") {
636
- actions.push(`${polishCheckpointGate.reason} Run ${cmd("polish")} capture before marking Polish complete.`);
654
+ if (polishCheckpointGate?.status === "blocked" && !needsHostedPreview) {
655
+ actions.push(polishCheckpointGate.code === NO_CAPTURABLE_ROUTES_CODE
656
+ ? `${polishCheckpointGate.reason} ${polishCheckpointGate.required_actions?.[0]?.description || "Map a design route and rerun intake before QA."}`
657
+ : `${polishCheckpointGate.reason} Run ${cmd("polish")} capture before marking Polish complete.`);
637
658
  }
638
659
  }
639
660
  return actions;
@@ -652,7 +673,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
652
673
  && (polishGate.status === "blocked" || polishCheckpointGate?.status === "blocked");
653
674
  const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
654
675
  const purchaseProof = report ? assessPurchaseProofCoverage({ packet, report }) : null;
655
- const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof);
676
+ const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof, packet);
656
677
  // The picker's vocabulary and this table must not drift apart: a stage the
657
678
  // table does not know would otherwise be relabelled as an operator step and
658
679
  // sliced into the whole ladder. Fail loudly instead.
@@ -661,8 +682,8 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
661
682
  }
662
683
  // An explicit --context / --report is carried into every recommended
663
684
  // command, so a recovery reads the same artifacts the recommendation did.
664
- const packetRef = `${derived.packet_path || "<packet>"}${sidecarArgs}`;
665
- const actions = doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef });
685
+ const packetRef = `${derived.packet_path ? shellToken(derived.packet_path) : "<packet>"}${sidecarArgs}`;
686
+ const actions = doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packet, packetRef });
666
687
  const deployStatus = String(report?.stages?.deploy?.status || "");
667
688
  const deploySatisfied = ["completed", "completed_with_warnings", "ready_with_exceptions"].some((prefix) => deployStatus.startsWith(prefix))
668
689
  || Boolean(deployUrlFromReportOutputs(report));
@@ -709,8 +730,16 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
709
730
  ? `${cmd("next")} --packet ${packetRef}`
710
731
  : `${cmd("next")} ${picked.stage} --packet ${packetRef}`;
711
732
  const fallbackAction = picked.stage === "done"
712
- ? `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`
733
+ ? (picked.outcome === "hosted_preview_satisfies_deploy"
734
+ ? `${picked.reason} Run ${cmd("next")} to confirm the closeout actions.`
735
+ : `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`)
713
736
  : `Run ${command}.`;
737
+ if (picked.stage === "done") {
738
+ actions.unshift(fallbackAction);
739
+ if (report?.stages?.qa?.status === "completed_with_warnings") {
740
+ actions.push("QA passed with exceptions. Report them to the operator; do not clear or waive them, or change markup just to make them pass.");
741
+ }
742
+ }
714
743
  return {
715
744
  stage: picked.stage,
716
745
  status: blocked ? "blocked" : readinessStatus(warnings, derived),
@@ -723,6 +752,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
723
752
  default_skill: owners.default_skill,
724
753
  command,
725
754
  reason: picked.reason,
755
+ ...(picked.outcome ? { outcome: picked.outcome } : {}),
726
756
  actions: actions.length ? actions : [fallbackAction],
727
757
  // Gate-blocked stages stay listed even when the recommended stage is
728
758
  // runnable (a Design Source Package change after assembly names build,
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
+ import { portableArtifactPaths } from "./cli-helpers.mjs";
4
5
 
5
6
  export const DOCTOR_SIDECAR_REL_PATH = ".campaign-runtime/doctor-output.json";
6
7
  export const DOCTOR_SIDECAR_SCHEMA = "campaigns-os-doctor-output/v0";
@@ -61,8 +62,9 @@ export function stampDoctorProducer(doctor, command) {
61
62
  // through this (or through `stampDoctorProducer` when its own transactional
62
63
  // writer must do the rename), so `generated_by` cannot be skipped by one of
63
64
  // them the way #327's cause labels once were.
64
- export function writeDoctorSidecar(path, doctor, { command } = {}) {
65
- writeJsonAtomic(path, stampDoctorProducer(doctor, command));
65
+ export function writeDoctorSidecar(path, doctor, { command, targetRepo = null } = {}) {
66
+ const stamped = stampDoctorProducer(doctor, command);
67
+ writeJsonAtomic(path, targetRepo ? portableArtifactPaths(stamped, targetRepo) : stamped);
66
68
  return path;
67
69
  }
68
70
 
@@ -89,7 +91,7 @@ export function markDoctorSidecarStale(targetBaseDir, { command = null, reason =
89
91
  stale_reason: reason
90
92
  || "A later command changed doctor inputs after this snapshot was written. Re-run campaigns-os doctor (or campaigns-os next) for current state.",
91
93
  };
92
- writeJsonAtomic(path, stamped);
94
+ writeJsonAtomic(path, portableArtifactPaths(stamped, targetBaseDir));
93
95
  return path;
94
96
  }
95
97
 
@@ -68,10 +68,30 @@ export const HIDDEN_EAGER_MEDIA_ACTIONS = Object.freeze({
68
68
  id: "polish.hidden_eager_media.map_design_route",
69
69
  kind: "manual",
70
70
  command: null,
71
- description: "Every mapped page is template stock, so polish capture has nothing to measure. Map at least one page to its design source HTML (source_html.pages[].path) and rerun intake, or prove the campaign on the local preview (deploy.target local-serve served from localhost), where this missing evidence is carried forward as a warning.",
71
+ description: "Every mapped page is template stock, so there is no design route to measure. Map at least one page to its design source HTML (source_html.pages[].path) and rerun intake, or use a local or hosted preview for QA with the missing evidence carried forward as a warning.",
72
72
  }),
73
73
  });
74
74
 
75
+ export function qaRunCommand(packetPath, url, bumpCart = null) {
76
+ return `${invocationPrefixFor(PACKAGE_ROOT)} qa run --packet ${packetPath ? shellToken(packetPath) : "<packet>"} --base-url ${url === "<preview-url>" ? url : shellToken(url)} --browser --test-order common${bumpCart ? ` --cart ${shellToken(bumpCart.cart)}` : ""}`;
77
+ }
78
+
79
+ export function hostedTemplateQaAction(previewUrl) {
80
+ return {
81
+ id: "polish.hidden_eager_media.hosted_preview_qa",
82
+ kind: "command",
83
+ command: qaRunCommand(null, previewUrl),
84
+ description: "Run QA against the packet's hosted preview URL; missing Polish evidence remains a warning.",
85
+ };
86
+ }
87
+
88
+ export const HOSTED_TEMPLATE_PREVIEW_URL_ACTION = Object.freeze({
89
+ id: "polish.hidden_eager_media.set_hosted_preview_url",
90
+ kind: "command",
91
+ command: "campaigns-os qa policy set --packet <packet> --preview-url <url>",
92
+ description: "Record a hosted preview URL that differs from the production URL with qa policy set, then run next again; missing Polish evidence can be carried forward on that preview.",
93
+ });
94
+
75
95
  const PACKET_PLACEHOLDER = "--packet <packet>";
76
96
 
77
97
  // The placeholder becomes the packet this run read, shell-quoted, so the
@@ -26,7 +26,7 @@ import {
26
26
  loadCampaignBuildBriefFile,
27
27
  } from "./build-brief.mjs";
28
28
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
29
- import { isObject, optionalString, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
29
+ import { isObject, optionalString, portableArtifactPaths, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
30
30
  import { writeJsonAtomic } from "./doctor-sidecar.mjs";
31
31
  import { activeSpecPages, specIdentityMismatch } from "./doctor/checks.mjs";
32
32
  import {
@@ -475,9 +475,9 @@ function saveBriefUnderLock({ packetPath, explicitBrief, sidecars, lockedTarget,
475
475
  if (!dryRun) {
476
476
  // Intake's publication order, the report last: a failure between files
477
477
  // leaves the report bound to the old material, which reads as owed.
478
- writeJsonAtomic(packetPath, nextPacket);
479
- writeJsonAtomic(normalizedPath, buildBrief.artifact);
480
- writeJsonAtomic(contextPath, saved.nextContext);
478
+ writeJsonAtomic(packetPath, portableArtifactPaths(nextPacket, targetRepo, { artifactPath: packetPath }));
479
+ writeJsonAtomic(normalizedPath, portableArtifactPaths(buildBrief.artifact, targetRepo, { artifactPath: normalizedPath }));
480
+ writeJsonAtomic(contextPath, portableArtifactPaths(saved.nextContext, targetRepo, { artifactPath: contextPath }));
481
481
  commitAssemblyReport(workspace, () => nextReport, {
482
482
  command: "record brief",
483
483
  staleReason: "The Campaign Build Brief was saved after this doctor snapshot. Re-run doctor (or next) for current state.",
@@ -674,8 +674,8 @@ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, time
674
674
  if (!dryRun) {
675
675
  // Intake's publication order, the report last: a failure between files
676
676
  // leaves the report bound to the old spec material, which reads as owed.
677
- if (briefWritten) writeJsonAtomic(normalizedPath, brief.buildBrief.artifact);
678
- writeJsonAtomic(contextPath, nextContext);
677
+ if (briefWritten) writeJsonAtomic(normalizedPath, portableArtifactPaths(brief.buildBrief.artifact, targetRepo, { artifactPath: normalizedPath }));
678
+ writeJsonAtomic(contextPath, portableArtifactPaths(nextContext, targetRepo, { artifactPath: contextPath }));
679
679
  commitAssemblyReport(workspace, () => nextReport, {
680
680
  command: "record spec",
681
681
  staleReason: "The CampaignSpec was bound with record spec after this doctor snapshot. Re-run doctor (or next) for current state.",
@@ -132,14 +132,15 @@ export function resolveInvocationPolicy(command, args) {
132
132
  const steps = CLASS_STEPS[rule.class];
133
133
  const noWrite = args["no-write"] === true;
134
134
  const implementsDryRun = rule.dryRun === true;
135
- const sweepSuppressed = optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun);
135
+ const help = Boolean(args.help);
136
+ const sweepSuppressed = help || optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun);
136
137
  const inspection = Boolean(rule.journalExemptWhen && args[rule.journalExemptWhen.given] && args[rule.journalExemptWhen.unlessBare] !== true);
137
138
  return Object.freeze({
138
139
  class: rule.class,
139
140
  wrapper: steps.includes("wrapper"), ambient: steps.includes("ambient"),
140
141
  sweepRoot: steps.includes("sweep") && !sweepSuppressed ? rule.sweepRoot || null : null,
141
- journalExempt: !steps.includes("journal") || rule.journalExempt === true || noWrite || (args["dry-run"] === true && implementsDryRun) || inspection,
142
- implementsDryRun, autoEnd: rule.autoEnd === true,
142
+ journalExempt: help || !steps.includes("journal") || rule.journalExempt === true || noWrite || (args["dry-run"] === true && implementsDryRun) || inspection,
143
+ implementsDryRun, autoEnd: !help && rule.autoEnd === true,
143
144
  });
144
145
  }
145
146
 
package/src/lifecycle.mjs CHANGED
@@ -66,6 +66,7 @@ export const LIFECYCLE_JOURNAL_REL_PATH = ".campaign-runtime/command-lifecycle.j
66
66
  // from anywhere.
67
67
  export const REFUSED_INVOCATION = "refused_invocation";
68
68
  const refusalScope = new AsyncLocalStorage();
69
+ const invocationScope = new AsyncLocalStorage();
69
70
 
70
71
  /** Run `fn` with its own refusal verdict. Returns whatever `fn` returns. */
71
72
  export function runWithRefusalScope(fn) {
@@ -96,6 +97,14 @@ export function refusalSeen() {
96
97
  return refusalScope.getStore()?.seen === true;
97
98
  }
98
99
 
100
+ // Prompt sites share this hook. Nested prompts count only the outer blocked
101
+ // interval, so a prompt helper can call another without inflating wait time.
102
+ export async function timeOperatorWait(fn) {
103
+ const active = invocationScope.getStore();
104
+ if (!active) return fn();
105
+ return active.wait(fn);
106
+ }
107
+
99
108
  /**
100
109
  * Run `fn()` and re-throw anything it throws as a tagged refusal, message
101
110
  * byte-identical. The contract it encodes: THE TAG IS APPLIED AT THE UP-FRONT
@@ -123,6 +132,10 @@ function isStringArray(value) {
123
132
  return Array.isArray(value) && value.every((entry) => typeof entry === "string");
124
133
  }
125
134
 
135
+ function isFindingCodes(value) {
136
+ return isStringArray(value) && value.length <= 5 && new Set(value).size === value.length;
137
+ }
138
+
126
139
  // Injectable so tests are deterministic. `now` is wall-clock (ISO source);
127
140
  // `monotonic` is a steadily-increasing millisecond counter for durations.
128
141
  const defaultClock = {
@@ -146,13 +159,22 @@ function defaultReadExitStatus() {
146
159
  export function createLifecycleRecorder(clock = defaultClock) {
147
160
  const stages = [];
148
161
  let repairLoopCount = 0;
162
+ let waitMs = 0;
163
+ let waitDepth = 0;
164
+ const activeStages = [];
165
+ const findingCodes = [];
149
166
  function stage(name) {
150
167
  const t0 = clock.monotonic();
168
+ const phase = { name: String(name), wait_ms: 0 };
169
+ activeStages.push(phase);
151
170
  let stopped = false;
152
171
  return function stop() {
153
172
  if (stopped) return;
154
173
  stopped = true;
155
- stages.push({ name: String(name), duration_ms: Math.max(0, Math.round(clock.monotonic() - t0)) });
174
+ activeStages.splice(activeStages.indexOf(phase), 1);
175
+ // Wait accumulates unrounded and is rounded once here, so it never
176
+ // exceeds the stage's own rounded duration.
177
+ stages.push({ ...phase, wait_ms: Math.round(phase.wait_ms), duration_ms: Math.max(0, Math.round(clock.monotonic() - t0)) });
156
178
  };
157
179
  }
158
180
  // Convenience: time `fn` as a named sub-phase. Records the stage even if fn
@@ -168,11 +190,38 @@ export function createLifecycleRecorder(clock = defaultClock) {
168
190
  return {
169
191
  stage,
170
192
  time,
193
+ async wait(fn) {
194
+ if (waitDepth++) {
195
+ try { return await fn(); } finally { waitDepth -= 1; }
196
+ }
197
+ const t0 = clock.monotonic();
198
+ const phase = activeStages.at(-1);
199
+ try { return await fn(); }
200
+ finally {
201
+ const elapsed = Math.max(0, clock.monotonic() - t0);
202
+ waitMs += elapsed;
203
+ if (phase) phase.wait_ms += elapsed;
204
+ waitDepth -= 1;
205
+ }
206
+ },
207
+ recordFindingCodes(codes) {
208
+ // Codes attach to the phase active when they are recorded; outside any
209
+ // phase they stay invocation-level only.
210
+ const phase = activeStages.at(-1);
211
+ for (const code of Array.isArray(codes) ? codes : []) {
212
+ if (typeof code !== "string") continue;
213
+ if (!findingCodes.includes(code) && findingCodes.length < 5) findingCodes.push(code);
214
+ if (phase) {
215
+ phase.finding_codes ??= [];
216
+ if (!phase.finding_codes.includes(code) && phase.finding_codes.length < 5) phase.finding_codes.push(code);
217
+ }
218
+ }
219
+ },
171
220
  recordRepairLoop() {
172
221
  repairLoopCount += 1;
173
222
  },
174
223
  snapshot() {
175
- return { stages: stages.slice(), repair_loop_count: repairLoopCount };
224
+ return { stages: stages.slice(), repair_loop_count: repairLoopCount, wait_ms: Math.round(waitMs), finding_codes: findingCodes.slice() };
176
225
  },
177
226
  };
178
227
  }
@@ -183,7 +232,9 @@ export const NOOP_RECORDER = {
183
232
  stage: () => () => {},
184
233
  time: async (_name, fn) => fn(),
185
234
  recordRepairLoop: () => {},
186
- snapshot: () => ({ stages: [], repair_loop_count: 0 }),
235
+ wait: (fn) => fn(),
236
+ recordFindingCodes: () => {},
237
+ snapshot: () => ({ stages: [], repair_loop_count: 0, wait_ms: 0, finding_codes: [] }),
187
238
  };
188
239
 
189
240
  function buildLifecycle({ command, argvShape, runId, exitStatus, startedAt, completedAt, durationMs, recorder }) {
@@ -197,7 +248,10 @@ function buildLifecycle({ command, argvShape, runId, exitStatus, startedAt, comp
197
248
  started_at: startedAt,
198
249
  completed_at: completedAt,
199
250
  duration_ms: Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null,
200
- stages: recorded.stages,
251
+ wait_ms: recorded.wait_ms,
252
+ ...(Number.isInteger(exitStatus) && exitStatus !== 0 && recorded.finding_codes.length ? { finding_codes: recorded.finding_codes } : {}),
253
+ stages: recorded.stages.map(({ finding_codes, ...stage }) =>
254
+ Number.isInteger(exitStatus) && exitStatus !== 0 && finding_codes?.length ? { ...stage, finding_codes } : stage),
201
255
  repair_loop_count: recorded.repair_loop_count,
202
256
  };
203
257
  }
@@ -225,7 +279,7 @@ export async function withCommandLifecycle({
225
279
  let thrown = null;
226
280
  let exitStatus = 0;
227
281
  try {
228
- result = await fn(recorder);
282
+ result = await invocationScope.run(recorder, () => fn(recorder));
229
283
  // A clean return is exit 0 unless the command set process.exitCode.
230
284
  const raw = readExitStatus();
231
285
  exitStatus = Number.isInteger(raw) ? raw : 0;
@@ -278,6 +332,8 @@ export function validateLifecycle(entry) {
278
332
  if (entry.exit_status != null && !Number.isInteger(entry.exit_status)) add("lifecycle.exit_status", "exit_status must be an integer or null.");
279
333
  if (entry.run_id != null && typeof entry.run_id !== "string") add("lifecycle.run_id", "run_id must be a string or null.");
280
334
  if (entry.duration_ms != null && typeof entry.duration_ms !== "number") add("lifecycle.duration_ms", "duration_ms must be a number or null.");
335
+ if (entry.wait_ms != null && (!Number.isInteger(entry.wait_ms) || entry.wait_ms < 0)) add("lifecycle.wait_ms", "wait_ms must be a non-negative integer or null.");
336
+ if (entry.finding_codes !== undefined && (!isFindingCodes(entry.finding_codes) || !Number.isInteger(entry.exit_status) || entry.exit_status === 0)) add("lifecycle.finding_codes", "finding_codes must have at most five distinct strings and require a non-zero exit_status.");
281
337
  if (entry.repair_loop_count != null && !Number.isInteger(entry.repair_loop_count)) add("lifecycle.repair_loop_count", "repair_loop_count must be an integer.");
282
338
  if (entry.stages != null) {
283
339
  if (!Array.isArray(entry.stages)) {
@@ -285,7 +341,11 @@ export function validateLifecycle(entry) {
285
341
  } else {
286
342
  entry.stages.forEach((stage, index) => {
287
343
  if (!stage || typeof stage !== "object" || Array.isArray(stage)) add(`lifecycle.stages[${index}]`, "each stage must be an object.");
288
- else if (!isNonEmptyString(stage.name)) add(`lifecycle.stages[${index}].name`, "stage name is required.");
344
+ else {
345
+ if (!isNonEmptyString(stage.name)) add(`lifecycle.stages[${index}].name`, "stage name is required.");
346
+ if (stage.wait_ms != null && (!Number.isInteger(stage.wait_ms) || stage.wait_ms < 0)) add(`lifecycle.stages[${index}].wait_ms`, "wait_ms must be a non-negative integer or null.");
347
+ if (stage.finding_codes !== undefined && (!isFindingCodes(stage.finding_codes) || !Number.isInteger(entry.exit_status) || entry.exit_status === 0)) add(`lifecycle.stages[${index}].finding_codes`, "finding_codes must have at most five distinct strings and require a non-zero exit_status.");
348
+ }
289
349
  });
290
350
  }
291
351
  }
@@ -366,6 +426,8 @@ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = []
366
426
  let latest = null;
367
427
  let durationSum = 0;
368
428
  let explicitRepairLoops = 0;
429
+ let needsInputCount = 0;
430
+ let failureCount = 0;
369
431
 
370
432
  // Only finite, parseable ISO timestamps participate in span timing; a junk
371
433
  // string (e.g. a hand-edited journal) is ignored rather than emitted as a
@@ -376,6 +438,11 @@ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = []
376
438
  const command = typeof entry.command === "string" ? entry.command : "(unknown)";
377
439
  commandCounts.set(command, (commandCounts.get(command) || 0) + 1);
378
440
  const exitStatus = Number.isInteger(entry.exit_status) ? entry.exit_status : null;
441
+ if (exitStatus === 2) needsInputCount += 1;
442
+ else if (exitStatus !== null && exitStatus !== 0) failureCount += 1;
443
+ const findings = exitStatus !== null && exitStatus !== 0 && isStringArray(entry.finding_codes)
444
+ ? [...new Set(entry.finding_codes)].slice(0, 5) : [];
445
+ const findingFields = findings.length ? { finding_codes: findings } : {};
379
446
  // A command may have recorded its own repair loops via recordRepairLoop().
380
447
  if (Number.isInteger(entry.repair_loop_count)) explicitRepairLoops += entry.repair_loop_count;
381
448
 
@@ -384,12 +451,25 @@ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = []
384
451
  name: `${command}:${typeof stage?.name === "string" ? stage.name : "stage"}`,
385
452
  duration_ms: typeof stage?.duration_ms === "number" ? stage.duration_ms : null,
386
453
  exit_status: exitStatus,
454
+ ...(Number.isInteger(stage?.wait_ms) && stage.wait_ms >= 0 ? { wait_ms: stage.wait_ms } : {}),
455
+ ...(exitStatus !== null && exitStatus !== 0 && isFindingCodes(stage?.finding_codes) && stage.finding_codes.length
456
+ ? { finding_codes: stage.finding_codes } : {}),
387
457
  }))
388
458
  : [{
389
459
  name: command,
390
460
  duration_ms: typeof entry.duration_ms === "number" ? entry.duration_ms : null,
391
461
  exit_status: exitStatus,
462
+ ...(Number.isInteger(entry.wait_ms) && entry.wait_ms >= 0 ? { wait_ms: entry.wait_ms } : {}),
463
+ ...findingFields,
392
464
  }];
465
+ if (subStages.length > 1 || (Array.isArray(entry.stages) && entry.stages.length)) {
466
+ const attributed = new Set(subStages.flatMap((stage) => stage.finding_codes ?? []));
467
+ const unknown = findings.filter((code) => !attributed.has(code));
468
+ if (unknown.length) {
469
+ const lastStage = subStages.at(-1);
470
+ lastStage.finding_codes = [...new Set([...(lastStage.finding_codes ?? []), ...unknown])].slice(0, 5);
471
+ }
472
+ }
393
473
  stages.push(...subStages);
394
474
 
395
475
  if (typeof entry.duration_ms === "number") durationSum += entry.duration_ms;
@@ -430,5 +510,7 @@ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = []
430
510
  wall_clock_duration_ms: wallClockDurationMs == null ? null : Math.round(wallClockDurationMs),
431
511
  stages,
432
512
  repair_loop_count: repairLoopCount,
513
+ needs_input_count: needsInputCount,
514
+ failure_count: failureCount,
433
515
  };
434
516
  }