@nextcommerce/campaigns-os 1.33.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 (310) hide show
  1. package/AGENTS.md +204 -0
  2. package/CHANGELOG.md +5002 -0
  3. package/CONTEXT.md +685 -0
  4. package/LICENSE +202 -0
  5. package/NOTICE +4 -0
  6. package/README.md +368 -0
  7. package/agents/claude/CLAUDE.md +32 -0
  8. package/agents/codex/AGENTS.md +27 -0
  9. package/agents/copilot/copilot-instructions.md +14 -0
  10. package/agents/cursor/campaigns-os.mdc +13 -0
  11. package/bin/campaigns-os.mjs +38 -0
  12. package/campaign-spec/README.md +138 -0
  13. package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
  14. package/campaign-spec/dist/analytics-vocabulary.js +74 -0
  15. package/campaign-spec/dist/index.d.ts +40 -0
  16. package/campaign-spec/dist/index.js +77 -0
  17. package/campaign-spec/dist/normalize.d.ts +22 -0
  18. package/campaign-spec/dist/normalize.js +41 -0
  19. package/campaign-spec/dist/routing.d.ts +190 -0
  20. package/campaign-spec/dist/routing.js +263 -0
  21. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
  22. package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
  23. package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
  24. package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
  25. package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
  26. package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
  27. package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
  28. package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
  29. package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
  30. package/campaign-spec/dist/rules/cycle-detection.js +141 -0
  31. package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
  32. package/campaign-spec/dist/rules/design-source-shape.js +142 -0
  33. package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
  34. package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
  35. package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
  36. package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
  37. package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
  38. package/campaign-spec/dist/rules/funnel-count.js +37 -0
  39. package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
  40. package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
  41. package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
  42. package/campaign-spec/dist/rules/funnel-identity.js +57 -0
  43. package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
  44. package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
  45. package/campaign-spec/dist/rules/index.d.ts +69 -0
  46. package/campaign-spec/dist/rules/index.js +131 -0
  47. package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
  48. package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
  49. package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
  50. package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
  51. package/campaign-spec/dist/rules/page-count.d.ts +11 -0
  52. package/campaign-spec/dist/rules/page-count.js +31 -0
  53. package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
  54. package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
  55. package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
  56. package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
  57. package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
  58. package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
  59. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
  60. package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
  61. package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
  62. package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
  63. package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
  64. package/campaign-spec/dist/rules/schema-version.js +53 -0
  65. package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
  66. package/campaign-spec/dist/rules/sdk-version.js +96 -0
  67. package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
  68. package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
  69. package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
  70. package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
  71. package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
  72. package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
  73. package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
  74. package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
  75. package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
  76. package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
  77. package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
  78. package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
  79. package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
  80. package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
  81. package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
  82. package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
  83. package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
  84. package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
  85. package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
  86. package/campaign-spec/dist/sdk-version-parse.js +62 -0
  87. package/campaign-spec/dist/types.d.ts +674 -0
  88. package/campaign-spec/dist/types.js +40 -0
  89. package/campaign-spec/package.json +12 -0
  90. package/compatibility.json +25 -0
  91. package/contracts/agent-relevant-change-policy.v1.json +111 -0
  92. package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
  93. package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
  94. package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
  95. package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
  96. package/contracts/commerce-surface-catalog.json +2452 -0
  97. package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
  98. package/contracts/fixtures/orientation/envelope/current.json +95 -0
  99. package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
  100. package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
  101. package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
  102. package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
  103. package/contracts/fixtures/orientation/envelope/refused.json +100 -0
  104. package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
  105. package/contracts/fixtures/orientation/envelope/updated.json +135 -0
  106. package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
  107. package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
  108. package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
  109. package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
  110. package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
  111. package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
  112. package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
  113. package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
  114. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
  115. package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
  116. package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
  117. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
  118. package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
  119. package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
  120. package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
  121. package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
  122. package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
  123. package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
  124. package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
  125. package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
  126. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
  127. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
  128. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
  129. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
  130. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
  131. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
  132. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
  133. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
  134. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
  135. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
  136. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
  137. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
  138. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
  139. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
  140. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
  141. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
  142. package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
  143. package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
  144. package/contracts/migration-sidecar-bundle.v0.json +149 -0
  145. package/contracts/orientation-limits.v1.json +41 -0
  146. package/contracts/orientation-reason-codes.v1.json +196 -0
  147. package/contracts/private-template-sources.json +8 -0
  148. package/contracts/release-ledger.json +4598 -0
  149. package/contracts/reserved-skill-names.json +13 -0
  150. package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
  151. package/contracts/supported-surface.json +180 -0
  152. package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
  153. package/contracts/template-brand-contract.apollo.v0.json +27 -0
  154. package/contracts/template-brand-contract.demeter.v0.json +27 -0
  155. package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
  156. package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
  157. package/contracts/template-brand-contract.olympus.v0.json +27 -0
  158. package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
  159. package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
  160. package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
  161. package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
  162. package/contracts/template-slot-manifest.apollo.v0.json +12 -0
  163. package/contracts/template-slot-manifest.demeter.v0.json +30 -0
  164. package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
  165. package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
  166. package/contracts/template-slot-manifest.olympus.v0.json +12 -0
  167. package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
  168. package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
  169. package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
  170. package/docs/brand-theme-bridge.md +159 -0
  171. package/docs/build-packet.md +1300 -0
  172. package/docs/campaign-build-brief.md +145 -0
  173. package/docs/campaign-standardization-report.md +329 -0
  174. package/docs/campaigns-os-build-flow.md +117 -0
  175. package/docs/design-source-package.md +784 -0
  176. package/docs/legacy-migration.md +58 -0
  177. package/docs/migration-sidecar-bundle.md +139 -0
  178. package/docs/orientation-contract-reference.md +1220 -0
  179. package/docs/polish-evidence.md +502 -0
  180. package/docs/qa-and-test-orders.md +1691 -0
  181. package/docs/release-ledger-authoring-guide.md +274 -0
  182. package/docs/runtime-readiness.md +211 -0
  183. package/docs/supported-surface.md +83 -0
  184. package/docs/versioning.md +55 -0
  185. package/docs/workflow-findings-sidecar.md +588 -0
  186. package/package.json +135 -0
  187. package/prompts/first-build.md +27 -0
  188. package/prompts/friction-log.md +30 -0
  189. package/schemas/campaign-build-brief.v1.schema.json +149 -0
  190. package/schemas/campaign-design-source-package.v0.schema.json +697 -0
  191. package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
  192. package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
  193. package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
  194. package/schemas/campaign-spec.v4.schema.json +582 -0
  195. package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
  196. package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
  197. package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
  198. package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
  199. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
  200. package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
  201. package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
  202. package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
  203. package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
  204. package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
  205. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
  206. package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
  207. package/schemas/source-html-manifest.v0.schema.json +252 -0
  208. package/skills/next-campaigns-build/SKILL.md +73 -0
  209. package/skills/next-campaigns-os/SKILL.md +101 -0
  210. package/skills/next-campaigns-os/references/session-intake.md +160 -0
  211. package/skills/next-campaigns-os-setup/SKILL.md +22 -0
  212. package/skills/next-campaigns-polish/SKILL.md +145 -0
  213. package/skills/next-campaigns-qa/SKILL.md +92 -0
  214. package/skills.json +56 -0
  215. package/skills.sh +64 -0
  216. package/src/adapter-decision-contract.mjs +333 -0
  217. package/src/brand-theme.mjs +1151 -0
  218. package/src/browser-launch.mjs +79 -0
  219. package/src/build-brief.mjs +781 -0
  220. package/src/built-site-scope.mjs +312 -0
  221. package/src/campaign-ecosystem.mjs +734 -0
  222. package/src/campaign-identity.mjs +405 -0
  223. package/src/campaign-workspace.mjs +127 -0
  224. package/src/checkpoint-waiver.mjs +302 -0
  225. package/src/cli.mjs +13442 -0
  226. package/src/commercial-journey.mjs +1119 -0
  227. package/src/commercial-parity.mjs +965 -0
  228. package/src/consent.mjs +347 -0
  229. package/src/content-residue.mjs +322 -0
  230. package/src/deadline.mjs +81 -0
  231. package/src/design-source-package.mjs +2604 -0
  232. package/src/deviation.mjs +107 -0
  233. package/src/doctor-check-registry.mjs +49 -0
  234. package/src/doctor-sidecar.mjs +106 -0
  235. package/src/finding-cause.mjs +557 -0
  236. package/src/findings.mjs +326 -0
  237. package/src/fs-identity.mjs +68 -0
  238. package/src/gate-actions.mjs +105 -0
  239. package/src/html-scan.mjs +53 -0
  240. package/src/install-mode.mjs +272 -0
  241. package/src/legacy-migration.d.ts +128 -0
  242. package/src/legacy-migration.mjs +510 -0
  243. package/src/lifecycle.mjs +338 -0
  244. package/src/local-proof.mjs +401 -0
  245. package/src/map-pin-writeback.mjs +210 -0
  246. package/src/orchestration-stage-contract.mjs +81 -0
  247. package/src/package-install-fixture.mjs +33 -0
  248. package/src/page-kit-build-summary.mjs +175 -0
  249. package/src/page-kit-campaign-config.mjs +57 -0
  250. package/src/page-kit-sdk-version.mjs +392 -0
  251. package/src/page-kit-store-profile.mjs +369 -0
  252. package/src/page-kit-sync.mjs +162 -0
  253. package/src/polish-browser.mjs +867 -0
  254. package/src/polish-capture.mjs +1094 -0
  255. package/src/polish-deadline.mjs +51 -0
  256. package/src/polish-gate.mjs +739 -0
  257. package/src/polish-node.mjs +639 -0
  258. package/src/polish-page-load.mjs +1100 -0
  259. package/src/private-template-source.mjs +237 -0
  260. package/src/proof-policy.mjs +82 -0
  261. package/src/qa-analytics-correctness.mjs +307 -0
  262. package/src/qa-analytics-errors.mjs +38 -0
  263. package/src/qa-analytics-parity.mjs +699 -0
  264. package/src/qa-binding-evidence.mjs +140 -0
  265. package/src/qa-browser.mjs +6608 -0
  266. package/src/qa-cart-entry.mjs +406 -0
  267. package/src/qa-commercial-parity.mjs +641 -0
  268. package/src/qa-node.mjs +3620 -0
  269. package/src/qa-order-bump.mjs +381 -0
  270. package/src/qa-parity-capture.mjs +428 -0
  271. package/src/qa-parity-fixture.mjs +359 -0
  272. package/src/qa-publish.mjs +362 -0
  273. package/src/qa-purchase-data-layer.mjs +263 -0
  274. package/src/qa-route-probe.mjs +272 -0
  275. package/src/qa-sidecar.mjs +188 -0
  276. package/src/qa-test-order-topology.mjs +207 -0
  277. package/src/qa-url-privacy.mjs +13 -0
  278. package/src/qa-verdict-discovery.mjs +192 -0
  279. package/src/qa-verdict-publish.mjs +105 -0
  280. package/src/qa-verdict.mjs +287 -0
  281. package/src/remit.mjs +388 -0
  282. package/src/repo-scan.mjs +83 -0
  283. package/src/route-identity.mjs +133 -0
  284. package/src/run-record-closeout.mjs +229 -0
  285. package/src/run-record.mjs +839 -0
  286. package/src/run-session.mjs +226 -0
  287. package/src/runtime-state-ignore.mjs +113 -0
  288. package/src/sdk-attribute-index.mjs +212 -0
  289. package/src/sdk-markup.mjs +358 -0
  290. package/src/sdk-meta-tags.mjs +52 -0
  291. package/src/shell-token.mjs +7 -0
  292. package/src/sidecar-bundle.mjs +399 -0
  293. package/src/source-asset-crawl.mjs +469 -0
  294. package/src/source-html-intake.mjs +627 -0
  295. package/src/source-html-manifest.mjs +276 -0
  296. package/src/source-prep.mjs +284 -0
  297. package/src/spec-derive-store.mjs +431 -0
  298. package/src/spec-derive.mjs +514 -0
  299. package/src/spec-fetch.mjs +66 -0
  300. package/src/spec-hash.mjs +48 -0
  301. package/src/spec-identity.mjs +27 -0
  302. package/src/stage-ledger.mjs +509 -0
  303. package/src/standardization-report.mjs +1297 -0
  304. package/src/template-brand-contract.mjs +473 -0
  305. package/src/template-freshness.mjs +196 -0
  306. package/src/template-reference.mjs +81 -0
  307. package/src/template-slot-manifest.mjs +150 -0
  308. package/src/text-safety.mjs +62 -0
  309. package/src/theme-gate.mjs +185 -0
  310. package/src/upsell-selector-scope.mjs +299 -0
@@ -0,0 +1,338 @@
1
+ // Run Telemetry — command-lifecycle instrumentation for Campaigns OS.
2
+ // See docs/workflow-findings-sidecar.md (Deferred / scope cut).
3
+ //
4
+ // A thin wrapper that times one command and captures its lifecycle: the
5
+ // command name, the argv SHAPE (flag names, never values), the exit status,
6
+ // and wall-clock start/end + monotonic duration. This is the instrumentation
7
+ // the v0 scope cut said the deferred fields needed first: stage timings and
8
+ // repair-loop count have recorder hooks here (stages[]/repair_loop_count) so
9
+ // they can be populated as command boundaries are instrumented — v0 captures
10
+ // command/argv-shape/exit-status/duration and leaves those hooks empty.
11
+ //
12
+ // The wrapper never changes a command's behavior: it re-throws after recording
13
+ // so the CLI exit code is unchanged, and lifecycle persistence is opt-in and
14
+ // non-fatal. No network, no credentials.
15
+
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
17
+ import { dirname, resolve } from "node:path";
18
+
19
+ export const LIFECYCLE_SCHEMA = "campaigns-os-command-lifecycle/v0";
20
+ export const LIFECYCLE_JOURNAL_REL_PATH = ".campaign-runtime/command-lifecycle.jsonl";
21
+
22
+ function isNonEmptyString(value) {
23
+ return typeof value === "string" && value.trim().length > 0;
24
+ }
25
+
26
+ function isStringArray(value) {
27
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
28
+ }
29
+
30
+ // Injectable so tests are deterministic. `now` is wall-clock (ISO source);
31
+ // `monotonic` is a steadily-increasing millisecond counter for durations.
32
+ const defaultClock = {
33
+ now: () => new Date(),
34
+ monotonic: () => performance.now(),
35
+ };
36
+
37
+ // Raw process.exitCode: an integer when a command set it (INCLUDING 0), or
38
+ // undefined when unset. The wrapper coerces per path so an explicit 0 is
39
+ // distinguishable from "unset" (the bug a plain `|| 0` / `|| 1` would hide).
40
+ function defaultReadExitStatus() {
41
+ return process.exitCode;
42
+ }
43
+
44
+ /**
45
+ * A small recorder passed into the wrapped command. `stage(name)` returns a
46
+ * stop() that records that stage's duration; `recordRepairLoop()` bumps the
47
+ * repair-loop counter. v0 commands don't mark stages yet — the hooks exist so
48
+ * the deferred fields can be filled without reshaping the Run Record.
49
+ */
50
+ export function createLifecycleRecorder(clock = defaultClock) {
51
+ const stages = [];
52
+ let repairLoopCount = 0;
53
+ function stage(name) {
54
+ const t0 = clock.monotonic();
55
+ let stopped = false;
56
+ return function stop() {
57
+ if (stopped) return;
58
+ stopped = true;
59
+ stages.push({ name: String(name), duration_ms: Math.max(0, Math.round(clock.monotonic() - t0)) });
60
+ };
61
+ }
62
+ // Convenience: time `fn` as a named sub-phase. Records the stage even if fn
63
+ // throws (the phase still ran), then re-throws. Async-aware.
64
+ async function time(name, fn) {
65
+ const stop = stage(name);
66
+ try {
67
+ return await fn();
68
+ } finally {
69
+ stop();
70
+ }
71
+ }
72
+ return {
73
+ stage,
74
+ time,
75
+ recordRepairLoop() {
76
+ repairLoopCount += 1;
77
+ },
78
+ snapshot() {
79
+ return { stages: stages.slice(), repair_loop_count: repairLoopCount };
80
+ },
81
+ };
82
+ }
83
+
84
+ // A no-op recorder for callers that run a command without lifecycle capture.
85
+ // Same surface as createLifecycleRecorder(); records nothing.
86
+ export const NOOP_RECORDER = {
87
+ stage: () => () => {},
88
+ time: async (_name, fn) => fn(),
89
+ recordRepairLoop: () => {},
90
+ snapshot: () => ({ stages: [], repair_loop_count: 0 }),
91
+ };
92
+
93
+ function buildLifecycle({ command, argvShape, runId, exitStatus, startedAt, completedAt, durationMs, recorder }) {
94
+ const recorded = recorder ? recorder.snapshot() : { stages: [], repair_loop_count: 0 };
95
+ return {
96
+ schema_version: LIFECYCLE_SCHEMA,
97
+ run_id: isNonEmptyString(runId) ? runId : null,
98
+ command: String(command || ""),
99
+ argv_shape: isStringArray(argvShape) ? argvShape : [],
100
+ exit_status: Number.isInteger(exitStatus) ? exitStatus : null,
101
+ started_at: startedAt,
102
+ completed_at: completedAt,
103
+ duration_ms: Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null,
104
+ stages: recorded.stages,
105
+ repair_loop_count: recorded.repair_loop_count,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Run `fn(recorder)` while capturing its command lifecycle. Returns
111
+ * `{ result, lifecycle }`. `onFinish(lifecycle, error)` runs on BOTH the
112
+ * success and error paths (before re-throw) so persistence happens even when
113
+ * the command fails. Re-throws any error so the CLI's exit behavior is
114
+ * unchanged — the lifecycle just records the resulting exit status.
115
+ */
116
+ export async function withCommandLifecycle({
117
+ command,
118
+ argvShape = [],
119
+ runId = null,
120
+ clock = defaultClock,
121
+ readExitStatus = defaultReadExitStatus,
122
+ onFinish = null,
123
+ } = {}, fn) {
124
+ const startedAtDate = clock.now();
125
+ const t0 = clock.monotonic();
126
+ const recorder = createLifecycleRecorder(clock);
127
+
128
+ let result;
129
+ let thrown = null;
130
+ let exitStatus = 0;
131
+ try {
132
+ result = await fn(recorder);
133
+ // A clean return is exit 0 unless the command set process.exitCode.
134
+ const raw = readExitStatus();
135
+ exitStatus = Number.isInteger(raw) ? raw : 0;
136
+ } catch (error) {
137
+ thrown = error;
138
+ // Symmetric with the success path, but using an integer test (not `||`) so an
139
+ // explicit process.exitCode = 0 set before throwing is preserved rather than
140
+ // treated as falsy. An integer (incl. 0) wins; else the error's own exitCode;
141
+ // else 1.
142
+ const raw = readExitStatus();
143
+ exitStatus = Number.isInteger(raw) ? raw : (Number.isInteger(error?.exitCode) ? error.exitCode : 1);
144
+ }
145
+
146
+ const lifecycle = buildLifecycle({
147
+ command,
148
+ argvShape,
149
+ runId,
150
+ exitStatus,
151
+ startedAt: startedAtDate.toISOString(),
152
+ completedAt: clock.now().toISOString(),
153
+ durationMs: clock.monotonic() - t0,
154
+ recorder,
155
+ });
156
+
157
+ if (typeof onFinish === "function") {
158
+ try {
159
+ await onFinish(lifecycle, thrown);
160
+ } catch {
161
+ // Persistence is non-fatal — never let a lifecycle write mask the command.
162
+ }
163
+ }
164
+
165
+ if (thrown) throw thrown;
166
+ return { result, lifecycle };
167
+ }
168
+
169
+ /**
170
+ * Light validator (no AJV), matching the repo convention. Checks the lifecycle
171
+ * envelope shape. Returns `{ ok, errors }`.
172
+ */
173
+ export function validateLifecycle(entry) {
174
+ const errors = [];
175
+ const add = (code, message) => errors.push({ code, message });
176
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
177
+ add("lifecycle.type", "Lifecycle must be a JSON object.");
178
+ return { ok: false, errors };
179
+ }
180
+ if (!isNonEmptyString(entry.command)) add("lifecycle.command", "command is required and must be a non-empty string.");
181
+ if (!isStringArray(entry.argv_shape)) add("lifecycle.argv_shape", "argv_shape must be an array of strings.");
182
+ if (entry.exit_status != null && !Number.isInteger(entry.exit_status)) add("lifecycle.exit_status", "exit_status must be an integer or null.");
183
+ if (entry.run_id != null && typeof entry.run_id !== "string") add("lifecycle.run_id", "run_id must be a string or null.");
184
+ if (entry.duration_ms != null && typeof entry.duration_ms !== "number") add("lifecycle.duration_ms", "duration_ms must be a number or null.");
185
+ if (entry.repair_loop_count != null && !Number.isInteger(entry.repair_loop_count)) add("lifecycle.repair_loop_count", "repair_loop_count must be an integer.");
186
+ if (entry.stages != null) {
187
+ if (!Array.isArray(entry.stages)) {
188
+ add("lifecycle.stages", "stages must be an array.");
189
+ } else {
190
+ entry.stages.forEach((stage, index) => {
191
+ if (!stage || typeof stage !== "object" || Array.isArray(stage)) add(`lifecycle.stages[${index}]`, "each stage must be an object.");
192
+ else if (!isNonEmptyString(stage.name)) add(`lifecycle.stages[${index}].name`, "stage name is required.");
193
+ });
194
+ }
195
+ }
196
+ return { ok: errors.length === 0, errors };
197
+ }
198
+
199
+ /**
200
+ * Append one validated lifecycle entry as one JSONL line. Throws on an invalid
201
+ * entry so a bug is caught; callers that want non-fatal behavior (the CLI) wrap
202
+ * this.
203
+ *
204
+ * Single-writer-per-run assumption: each entry is one append, but appendFileSync
205
+ * is only atomic for writes under PIPE_BUF, so two processes appending to the
206
+ * SAME journal concurrently can interleave. A journal is scoped to one run
207
+ * (one run-id, cleared by `run end`), so the normal path is a single writer.
208
+ * readLifecycleJournal tolerates the rare malformed line either way.
209
+ */
210
+ export function appendLifecycleEntry(journalPath, entry) {
211
+ const validation = validateLifecycle(entry);
212
+ if (!validation.ok) {
213
+ const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
214
+ throw new Error(`Command lifecycle failed validation: ${detail}`);
215
+ }
216
+ mkdirSync(dirname(resolve(journalPath)), { recursive: true });
217
+ appendFileSync(resolve(journalPath), `${JSON.stringify(entry)}\n`);
218
+ return entry;
219
+ }
220
+
221
+ /**
222
+ * Read the lifecycle journal. Returns `{ entries, malformed }`; malformed
223
+ * lines are preserved rather than thrown, so one bad line never blocks the rest.
224
+ * Reads the whole file: journals are per-run and short-lived (a run session
225
+ * clears on `run end`), and run-record aggregation needs every entry for the
226
+ * run_id, so there is no last-N shortcut. Callers treat read failures as
227
+ * "no journal" (best-effort) — see the run-record embed path.
228
+ */
229
+ export function readLifecycleJournal(journalPath) {
230
+ const resolved = resolve(journalPath);
231
+ if (!existsSync(resolved)) return { entries: [], malformed: [] };
232
+ const entries = [];
233
+ const malformed = [];
234
+ const lines = readFileSync(resolved, "utf8").split("\n");
235
+ for (let index = 0; index < lines.length; index += 1) {
236
+ const raw = lines[index];
237
+ if (!raw.trim()) continue;
238
+ try {
239
+ entries.push(JSON.parse(raw));
240
+ } catch (error) {
241
+ malformed.push({ line: index + 1, raw, error: error.message });
242
+ }
243
+ }
244
+ return { entries, malformed };
245
+ }
246
+
247
+ function entriesForRun(journal, runId, excludeCommands) {
248
+ const entries = Array.isArray(journal?.entries) ? journal.entries : Array.isArray(journal) ? journal : [];
249
+ return entries.filter((entry) => entry && entry.run_id === runId && !excludeCommands.includes(entry.command));
250
+ }
251
+
252
+ /**
253
+ * Aggregate ALL lifecycle journal entries for `runId` into one run-level
254
+ * lifecycle block — the populated form of the deferred stage-timings /
255
+ * repair-loop fields. Each command invocation becomes a stage; when a command
256
+ * marked its own sub-phases (Tier 2), those become `command:phase` stages
257
+ * instead. `repair_loop_count` = re-runs of any command (a re-run is a repair
258
+ * loop: doctor -> fix -> doctor). Run-level duration is summed active command
259
+ * time, while started_at/completed_at preserve the outer observed bounds.
260
+ * Returns null when no entry matches, so embedding stays best-effort and
261
+ * backward-compatible.
262
+ */
263
+ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = [] } = {}) {
264
+ const matching = entriesForRun(journal, runId, excludeCommands);
265
+ if (!matching.length) return null;
266
+
267
+ const stages = [];
268
+ const commandCounts = new Map();
269
+ let earliest = null;
270
+ let latest = null;
271
+ let durationSum = 0;
272
+ let explicitRepairLoops = 0;
273
+
274
+ // Only finite, parseable ISO timestamps participate in span timing; a junk
275
+ // string (e.g. a hand-edited journal) is ignored rather than emitted as a
276
+ // bogus started_at/completed_at.
277
+ const isParseableTimestamp = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
278
+
279
+ for (const entry of matching) {
280
+ const command = typeof entry.command === "string" ? entry.command : "(unknown)";
281
+ commandCounts.set(command, (commandCounts.get(command) || 0) + 1);
282
+ const exitStatus = Number.isInteger(entry.exit_status) ? entry.exit_status : null;
283
+ // A command may have recorded its own repair loops via recordRepairLoop().
284
+ if (Number.isInteger(entry.repair_loop_count)) explicitRepairLoops += entry.repair_loop_count;
285
+
286
+ const subStages = Array.isArray(entry.stages) && entry.stages.length
287
+ ? entry.stages.map((stage) => ({
288
+ name: `${command}:${typeof stage?.name === "string" ? stage.name : "stage"}`,
289
+ duration_ms: typeof stage?.duration_ms === "number" ? stage.duration_ms : null,
290
+ exit_status: exitStatus,
291
+ }))
292
+ : [{
293
+ name: command,
294
+ duration_ms: typeof entry.duration_ms === "number" ? entry.duration_ms : null,
295
+ exit_status: exitStatus,
296
+ }];
297
+ stages.push(...subStages);
298
+
299
+ if (typeof entry.duration_ms === "number") durationSum += entry.duration_ms;
300
+ if (isParseableTimestamp(entry.started_at) && (!earliest || entry.started_at < earliest)) earliest = entry.started_at;
301
+ if (isParseableTimestamp(entry.completed_at) && (!latest || entry.completed_at > latest)) latest = entry.completed_at;
302
+ }
303
+
304
+ // repair_loop_count = command re-runs (doctor -> fix -> doctor) PLUS any loops
305
+ // a command recorded explicitly. Re-runs are the v0 heuristic; explicit loops
306
+ // refine it once commands call recordRepairLoop().
307
+ let repairLoopCount = explicitRepairLoops;
308
+ for (const count of commandCounts.values()) if (count > 1) repairLoopCount += count - 1;
309
+
310
+ // Duration is active work, not the idle wall-clock gap between separate
311
+ // invocations. Report the full run span separately so operator/review/idle
312
+ // time remains visible without inflating command execution time.
313
+ const durationMs = durationSum;
314
+ const wallClockDurationMs = earliest && latest
315
+ ? Math.max(0, Date.parse(latest) - Date.parse(earliest))
316
+ : null;
317
+
318
+ // Top-level command/argv_shape describe the RUN, not its earliest invocation.
319
+ // They are meaningful only when the run is a single distinct command; for a
320
+ // multi-command run (doctor -> start -> qa) they would mislead, so null them
321
+ // and let stages[] carry the per-command detail. exit_status is the LAST
322
+ // command's (the run's final outcome).
323
+ const first = matching[0];
324
+ const last = matching[matching.length - 1];
325
+ const singleCommand = commandCounts.size === 1 && typeof first.command === "string";
326
+ return {
327
+ run_id: runId,
328
+ command: singleCommand ? first.command : null,
329
+ argv_shape: singleCommand && isStringArray(first.argv_shape) ? first.argv_shape : [],
330
+ exit_status: Number.isInteger(last.exit_status) ? last.exit_status : null,
331
+ started_at: earliest,
332
+ completed_at: latest,
333
+ duration_ms: Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null,
334
+ wall_clock_duration_ms: wallClockDurationMs == null ? null : Math.round(wallClockDurationMs),
335
+ stages,
336
+ repair_loop_count: repairLoopCount,
337
+ };
338
+ }