@msn-control/liftoff 0.11.2 → 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 (339) hide show
  1. package/DEVELOPER.md +26 -24
  2. package/README.md +18 -18
  3. package/assets/governance/single-maintainer-gitflow/activation-v2-graph.json +1583 -0
  4. package/dist/adapters/credentials/github-enrollment.d.ts +51 -0
  5. package/dist/adapters/credentials/github-enrollment.js +226 -0
  6. package/dist/adapters/credentials/github-enrollment.js.map +1 -0
  7. package/dist/adapters/credentials/protected-input.d.ts +7 -0
  8. package/dist/adapters/credentials/protected-input.js +68 -0
  9. package/dist/adapters/credentials/protected-input.js.map +1 -0
  10. package/dist/adapters/filesystem/executables.d.ts +2 -0
  11. package/dist/adapters/filesystem/executables.js +70 -0
  12. package/dist/adapters/filesystem/executables.js.map +1 -0
  13. package/dist/adapters/filesystem/update-previews.d.ts +9 -0
  14. package/dist/adapters/filesystem/update-previews.js +45 -2
  15. package/dist/adapters/filesystem/update-previews.js.map +1 -1
  16. package/dist/adapters/filesystem/workstation-attempts.d.ts +13 -0
  17. package/dist/adapters/filesystem/workstation-attempts.js +78 -0
  18. package/dist/adapters/filesystem/workstation-attempts.js.map +1 -0
  19. package/dist/adapters/github/activation-rest.d.ts +40 -0
  20. package/dist/adapters/github/activation-rest.js +272 -0
  21. package/dist/adapters/github/activation-rest.js.map +1 -0
  22. package/dist/adapters/process/shell-command.d.ts +1 -0
  23. package/dist/adapters/process/shell-command.js +5 -2
  24. package/dist/adapters/process/shell-command.js.map +1 -1
  25. package/dist/adapters/state/azure-blob.d.ts +43 -0
  26. package/dist/adapters/state/azure-blob.js +335 -0
  27. package/dist/adapters/state/azure-blob.js.map +1 -0
  28. package/dist/adapters/state/darwin-capabilities.d.ts +33 -0
  29. package/dist/adapters/state/darwin-capabilities.js +146 -0
  30. package/dist/adapters/state/darwin-capabilities.js.map +1 -0
  31. package/dist/adapters/state/darwin-native-host.d.ts +32 -0
  32. package/dist/adapters/state/darwin-native-host.js +294 -0
  33. package/dist/adapters/state/darwin-native-host.js.map +1 -0
  34. package/dist/adapters/state/darwin-profile.d.ts +6 -0
  35. package/dist/adapters/state/darwin-profile.js +56 -0
  36. package/dist/adapters/state/darwin-profile.js.map +1 -0
  37. package/dist/adapters/state/darwin-system-program.d.ts +1 -0
  38. package/dist/adapters/state/darwin-system-program.js +94 -0
  39. package/dist/adapters/state/darwin-system-program.js.map +1 -0
  40. package/dist/adapters/state/local-qualification.d.ts +16 -0
  41. package/dist/adapters/state/local-qualification.js +299 -0
  42. package/dist/adapters/state/local-qualification.js.map +1 -0
  43. package/dist/adapters/state/local.d.ts +25 -0
  44. package/dist/adapters/state/local.js +145 -0
  45. package/dist/adapters/state/local.js.map +1 -0
  46. package/dist/adapters/state/native-command.d.ts +21 -0
  47. package/dist/adapters/state/native-command.js +82 -0
  48. package/dist/adapters/state/native-command.js.map +1 -0
  49. package/dist/adapters/state/native-files.d.ts +2 -0
  50. package/dist/adapters/state/native-files.js +47 -0
  51. package/dist/adapters/state/native-files.js.map +1 -0
  52. package/dist/adapters/state/native-system.d.ts +38 -0
  53. package/dist/adapters/state/native-system.js +86 -0
  54. package/dist/adapters/state/native-system.js.map +1 -0
  55. package/dist/adapters/state/opentofu.d.ts +35 -0
  56. package/dist/adapters/state/opentofu.js +350 -0
  57. package/dist/adapters/state/opentofu.js.map +1 -0
  58. package/dist/adapters/state/owned-process.d.ts +43 -0
  59. package/dist/adapters/state/owned-process.js +252 -0
  60. package/dist/adapters/state/owned-process.js.map +1 -0
  61. package/dist/adapters/state/posix-lock-program.d.ts +1 -0
  62. package/dist/adapters/state/posix-lock-program.js +185 -0
  63. package/dist/adapters/state/posix-lock-program.js.map +1 -0
  64. package/dist/adapters/state/posix-native-lock.d.ts +15 -0
  65. package/dist/adapters/state/posix-native-lock.js +145 -0
  66. package/dist/adapters/state/posix-native-lock.js.map +1 -0
  67. package/dist/adapters/state/protected-workspace.d.ts +57 -0
  68. package/dist/adapters/state/protected-workspace.js +412 -0
  69. package/dist/adapters/state/protected-workspace.js.map +1 -0
  70. package/dist/application/context.d.ts +3 -0
  71. package/dist/application/diagnose/doctor.d.ts +5 -0
  72. package/dist/application/diagnose/doctor.js +21 -5
  73. package/dist/application/diagnose/doctor.js.map +1 -1
  74. package/dist/application/diagnose/generated-project.js +2 -7
  75. package/dist/application/diagnose/generated-project.js.map +1 -1
  76. package/dist/application/initialize/fixture.js +8 -1
  77. package/dist/application/initialize/fixture.js.map +1 -1
  78. package/dist/application/initialize/use-case.js +70 -36
  79. package/dist/application/initialize/use-case.js.map +1 -1
  80. package/dist/application/project/manifest.js +5 -4
  81. package/dist/application/project/manifest.js.map +1 -1
  82. package/dist/application/state-migration/execution.d.ts +32 -0
  83. package/dist/application/state-migration/execution.js +440 -0
  84. package/dist/application/state-migration/execution.js.map +1 -0
  85. package/dist/application/state-migration/index.d.ts +22 -0
  86. package/dist/application/state-migration/index.js +17 -0
  87. package/dist/application/state-migration/index.js.map +1 -0
  88. package/dist/application/state-migration/lifecycle.d.ts +3 -0
  89. package/dist/application/state-migration/lifecycle.js +149 -0
  90. package/dist/application/state-migration/lifecycle.js.map +1 -0
  91. package/dist/application/state-migration/planning.d.ts +7 -0
  92. package/dist/application/state-migration/planning.js +150 -0
  93. package/dist/application/state-migration/planning.js.map +1 -0
  94. package/dist/application/state-migration/recovery.d.ts +5 -0
  95. package/dist/application/state-migration/recovery.js +145 -0
  96. package/dist/application/state-migration/recovery.js.map +1 -0
  97. package/dist/application/state-migration/runtime.d.ts +32 -0
  98. package/dist/application/state-migration/runtime.js +188 -0
  99. package/dist/application/state-migration/runtime.js.map +1 -0
  100. package/dist/application/state-migration/service.d.ts +2 -0
  101. package/dist/application/state-migration/service.js +20 -0
  102. package/dist/application/state-migration/service.js.map +1 -0
  103. package/dist/application/update/command-guidance.d.ts +19 -2
  104. package/dist/application/update/command-guidance.js +20 -6
  105. package/dist/application/update/command-guidance.js.map +1 -1
  106. package/dist/application/update/guidance-context.d.ts +2 -0
  107. package/dist/application/update/guidance-context.js +35 -0
  108. package/dist/application/update/guidance-context.js.map +1 -0
  109. package/dist/application/update/inspection.d.ts +21 -4
  110. package/dist/application/update/inspection.js +88 -29
  111. package/dist/application/update/inspection.js.map +1 -1
  112. package/dist/application/update/migration-runtime.js +25 -13
  113. package/dist/application/update/migration-runtime.js.map +1 -1
  114. package/dist/application/update/output.d.ts +4 -1
  115. package/dist/application/update/output.js +14 -4
  116. package/dist/application/update/output.js.map +1 -1
  117. package/dist/application/update/preview.d.ts +10 -1
  118. package/dist/application/update/preview.js +26 -4
  119. package/dist/application/update/preview.js.map +1 -1
  120. package/dist/application/update/revalidation-plan.d.ts +1 -0
  121. package/dist/application/update/revalidation-plan.js +27 -15
  122. package/dist/application/update/revalidation-plan.js.map +1 -1
  123. package/dist/application/update/revalidation.d.ts +4 -1
  124. package/dist/application/update/revalidation.js +64 -23
  125. package/dist/application/update/revalidation.js.map +1 -1
  126. package/dist/application/update/use-case.js +50 -29
  127. package/dist/application/update/use-case.js.map +1 -1
  128. package/dist/cli/args/definitions.js +11 -3
  129. package/dist/cli/args/definitions.js.map +1 -1
  130. package/dist/cli/args/parser.js +29 -3
  131. package/dist/cli/args/parser.js.map +1 -1
  132. package/dist/domain/governance/activation/approvals.d.ts +264 -7
  133. package/dist/domain/governance/activation/approvals.js +166 -23
  134. package/dist/domain/governance/activation/approvals.js.map +1 -1
  135. package/dist/domain/governance/activation/capabilities.js +5 -2
  136. package/dist/domain/governance/activation/capabilities.js.map +1 -1
  137. package/dist/domain/governance/activation/evidence.d.ts +2 -1
  138. package/dist/domain/governance/activation/evidence.js +64 -11
  139. package/dist/domain/governance/activation/evidence.js.map +1 -1
  140. package/dist/domain/governance/activation/graph.d.ts +11 -384
  141. package/dist/domain/governance/activation/graph.js +424 -379
  142. package/dist/domain/governance/activation/graph.js.map +1 -1
  143. package/dist/domain/governance/activation/inputs.d.ts +3 -1
  144. package/dist/domain/governance/activation/inputs.js +47 -6
  145. package/dist/domain/governance/activation/inputs.js.map +1 -1
  146. package/dist/domain/governance/activation/operations.d.ts +4 -1
  147. package/dist/domain/governance/activation/operations.js +110 -22
  148. package/dist/domain/governance/activation/operations.js.map +1 -1
  149. package/dist/domain/governance/activation/readiness.d.ts +10 -0
  150. package/dist/domain/governance/activation/readiness.js +77 -38
  151. package/dist/domain/governance/activation/readiness.js.map +1 -1
  152. package/dist/domain/governance/activation/types.d.ts +151 -9
  153. package/dist/domain/governance/activation/types.js +23 -1
  154. package/dist/domain/governance/activation/types.js.map +1 -1
  155. package/dist/domain/governance/activation/validators.d.ts +4 -1
  156. package/dist/domain/governance/activation/validators.js +419 -26
  157. package/dist/domain/governance/activation/validators.js.map +1 -1
  158. package/dist/domain/governance/policy/identity.d.ts +55 -13
  159. package/dist/domain/governance/policy/identity.js +28 -8
  160. package/dist/domain/governance/policy/identity.js.map +1 -1
  161. package/dist/domain/project/artifact-lifecycle.d.ts +2 -1
  162. package/dist/domain/project/artifact-lifecycle.js +7 -1
  163. package/dist/domain/project/artifact-lifecycle.js.map +1 -1
  164. package/dist/domain/project/catalog.d.ts +50 -1
  165. package/dist/domain/project/catalog.js +104 -4
  166. package/dist/domain/project/catalog.js.map +1 -1
  167. package/dist/domain/project/contracts.d.ts +9 -1
  168. package/dist/domain/project/infrastructure-layout.d.ts +1 -1
  169. package/dist/domain/project/infrastructure-layout.js +3 -1
  170. package/dist/domain/project/infrastructure-layout.js.map +1 -1
  171. package/dist/domain/project/manifest/governance.d.ts +1 -1
  172. package/dist/domain/project/manifest/governance.js +9 -10
  173. package/dist/domain/project/manifest/governance.js.map +1 -1
  174. package/dist/domain/project/planning.js +4 -5
  175. package/dist/domain/project/planning.js.map +1 -1
  176. package/dist/domain/repair/stateful-bounded.d.ts +1 -0
  177. package/dist/domain/repair/stateful-bounded.js +37 -0
  178. package/dist/domain/repair/stateful-bounded.js.map +1 -0
  179. package/dist/domain/repair/stateful-invariants.d.ts +28 -0
  180. package/dist/domain/repair/stateful-invariants.js +278 -0
  181. package/dist/domain/repair/stateful-invariants.js.map +1 -0
  182. package/dist/domain/repair/stateful.d.ts +690 -0
  183. package/dist/domain/repair/stateful.js +26 -0
  184. package/dist/domain/repair/stateful.js.map +1 -0
  185. package/dist/domain/workstation/constraints.d.ts +1 -0
  186. package/dist/domain/workstation/constraints.js.map +1 -1
  187. package/dist/domain/workstation/contracts.d.ts +42 -0
  188. package/dist/domain/workstation/contracts.js +2 -0
  189. package/dist/domain/workstation/contracts.js.map +1 -0
  190. package/dist/domain/workstation/executables.d.ts +19 -0
  191. package/dist/domain/workstation/executables.js +68 -0
  192. package/dist/domain/workstation/executables.js.map +1 -0
  193. package/dist/domain/workstation/versions.d.ts +8 -0
  194. package/dist/domain/workstation/versions.js +145 -0
  195. package/dist/domain/workstation/versions.js.map +1 -0
  196. package/dist/framework-adapters.d.ts +2 -0
  197. package/dist/framework-adapters.js +148 -22
  198. package/dist/framework-adapters.js.map +1 -1
  199. package/dist/framework-validation.d.ts +6 -0
  200. package/dist/framework-validation.js +89 -24
  201. package/dist/framework-validation.js.map +1 -1
  202. package/dist/generators/common/base.js +57 -17
  203. package/dist/generators/common/base.js.map +1 -1
  204. package/dist/governance-activation/activation-state.js +39 -7
  205. package/dist/governance-activation/activation-state.js.map +1 -1
  206. package/dist/governance-activation/authority-records.d.ts +4 -0
  207. package/dist/governance-activation/authority-records.js +24 -0
  208. package/dist/governance-activation/authority-records.js.map +1 -0
  209. package/dist/governance-activation/commands.d.ts +9 -1
  210. package/dist/governance-activation/commands.js +392 -97
  211. package/dist/governance-activation/commands.js.map +1 -1
  212. package/dist/governance-activation/compatibility.d.ts +11 -8
  213. package/dist/governance-activation/compatibility.js +37 -23
  214. package/dist/governance-activation/compatibility.js.map +1 -1
  215. package/dist/governance-activation/doctor.js +40 -16
  216. package/dist/governance-activation/doctor.js.map +1 -1
  217. package/dist/governance-activation/github-config.d.ts +17 -0
  218. package/dist/governance-activation/github-config.js +88 -0
  219. package/dist/governance-activation/github-config.js.map +1 -0
  220. package/dist/governance-activation/github-discovery.d.ts +18 -0
  221. package/dist/governance-activation/github-discovery.js +229 -0
  222. package/dist/governance-activation/github-discovery.js.map +1 -0
  223. package/dist/governance-activation/github-ports.d.ts +12 -0
  224. package/dist/governance-activation/github-ports.js +4 -0
  225. package/dist/governance-activation/github-ports.js.map +1 -0
  226. package/dist/governance-activation/github-publication.d.ts +3 -0
  227. package/dist/governance-activation/github-publication.js +190 -0
  228. package/dist/governance-activation/github-publication.js.map +1 -0
  229. package/dist/governance-activation/historical-common.d.ts +98 -0
  230. package/dist/governance-activation/historical-common.js +269 -0
  231. package/dist/governance-activation/historical-common.js.map +1 -0
  232. package/dist/governance-activation/historical-credential-policy.d.ts +3 -0
  233. package/dist/governance-activation/historical-credential-policy.js +91 -0
  234. package/dist/governance-activation/historical-credential-policy.js.map +1 -0
  235. package/dist/governance-activation/historical-inputs.d.ts +6 -0
  236. package/dist/governance-activation/historical-inputs.js +57 -0
  237. package/dist/governance-activation/historical-inputs.js.map +1 -0
  238. package/dist/governance-activation/historical-safety.d.ts +9 -0
  239. package/dist/governance-activation/historical-safety.js +102 -0
  240. package/dist/governance-activation/historical-safety.js.map +1 -0
  241. package/dist/governance-activation/historical-source-metadata.d.ts +31 -0
  242. package/dist/governance-activation/historical-source-metadata.js +55 -0
  243. package/dist/governance-activation/historical-source-metadata.js.map +1 -0
  244. package/dist/governance-activation/historical-state.d.ts +12 -50
  245. package/dist/governance-activation/historical-state.js +195 -308
  246. package/dist/governance-activation/historical-state.js.map +1 -1
  247. package/dist/governance-activation/historical-v1-auxiliary.d.ts +1 -0
  248. package/dist/governance-activation/historical-v1-auxiliary.js +43 -0
  249. package/dist/governance-activation/historical-v1-auxiliary.js.map +1 -0
  250. package/dist/governance-activation/historical-v1-phase-contracts.d.ts +31 -0
  251. package/dist/governance-activation/historical-v1-phase-contracts.js +46 -0
  252. package/dist/governance-activation/historical-v1-phase-contracts.js.map +1 -0
  253. package/dist/governance-activation/historical-v2.d.ts +53 -0
  254. package/dist/governance-activation/historical-v2.js +313 -0
  255. package/dist/governance-activation/historical-v2.js.map +1 -0
  256. package/dist/governance-activation/history-contracts.d.ts +21 -12
  257. package/dist/governance-activation/history-contracts.js +99 -27
  258. package/dist/governance-activation/history-contracts.js.map +1 -1
  259. package/dist/governance-activation/inputs.d.ts +8 -2
  260. package/dist/governance-activation/inputs.js +41 -6
  261. package/dist/governance-activation/inputs.js.map +1 -1
  262. package/dist/governance-activation/migration-history.d.ts +43 -5
  263. package/dist/governance-activation/migration-history.js +231 -50
  264. package/dist/governance-activation/migration-history.js.map +1 -1
  265. package/dist/governance-activation/migration.d.ts +7 -4
  266. package/dist/governance-activation/migration.js +53 -252
  267. package/dist/governance-activation/migration.js.map +1 -1
  268. package/dist/governance-activation/phase-azure.d.ts +3 -0
  269. package/dist/governance-activation/phase-azure.js +205 -0
  270. package/dist/governance-activation/phase-azure.js.map +1 -0
  271. package/dist/governance-activation/phase-bootstrap-state.js +3 -0
  272. package/dist/governance-activation/phase-bootstrap-state.js.map +1 -1
  273. package/dist/governance-activation/phase-github.d.ts +3 -0
  274. package/dist/governance-activation/phase-github.js +192 -0
  275. package/dist/governance-activation/phase-github.js.map +1 -0
  276. package/dist/governance-activation/phase-governance.js +8 -2
  277. package/dist/governance-activation/phase-governance.js.map +1 -1
  278. package/dist/governance-activation/public-plans.d.ts +36 -0
  279. package/dist/governance-activation/public-plans.js +100 -0
  280. package/dist/governance-activation/public-plans.js.map +1 -0
  281. package/dist/governance-activation/read-only.d.ts +3 -1
  282. package/dist/governance-activation/read-only.js +7 -3
  283. package/dist/governance-activation/read-only.js.map +1 -1
  284. package/dist/governance-activation/reconciliation.js +10 -0
  285. package/dist/governance-activation/reconciliation.js.map +1 -1
  286. package/dist/governance-activation/seed-lifecycle.js +14 -25
  287. package/dist/governance-activation/seed-lifecycle.js.map +1 -1
  288. package/dist/governance-activation/source-of-truth.d.ts +21 -5
  289. package/dist/governance-activation/source-of-truth.js +102 -13
  290. package/dist/governance-activation/source-of-truth.js.map +1 -1
  291. package/dist/governance-activation/task-projection.d.ts +4 -1
  292. package/dist/governance-activation/task-projection.js +32 -3
  293. package/dist/governance-activation/task-projection.js.map +1 -1
  294. package/dist/governance-activation/task-writes.d.ts +42 -0
  295. package/dist/governance-activation/task-writes.js +250 -0
  296. package/dist/governance-activation/task-writes.js.map +1 -0
  297. package/dist/governance-activation/transition-files.d.ts +12 -0
  298. package/dist/governance-activation/transition-files.js +109 -0
  299. package/dist/governance-activation/transition-files.js.map +1 -0
  300. package/dist/governance-activation/transition-planning.d.ts +3 -1
  301. package/dist/governance-activation/transition-planning.js +158 -20
  302. package/dist/governance-activation/transition-planning.js.map +1 -1
  303. package/dist/governance-activation/transition-ports.d.ts +31 -3
  304. package/dist/governance-activation/transition-records.d.ts +11 -1
  305. package/dist/governance-activation/transition-records.js +52 -6
  306. package/dist/governance-activation/transition-records.js.map +1 -1
  307. package/dist/governance-activation/transitions.d.ts +5 -0
  308. package/dist/governance-activation/transitions.js +271 -37
  309. package/dist/governance-activation/transitions.js.map +1 -1
  310. package/dist/governance-assessment/engine.js +22 -15
  311. package/dist/governance-assessment/engine.js.map +1 -1
  312. package/dist/governance-assessment/project.d.ts +2 -0
  313. package/dist/governance-assessment/project.js +2 -1
  314. package/dist/governance-assessment/project.js.map +1 -1
  315. package/dist/interactive.d.ts +0 -1
  316. package/dist/interactive.js +3 -22
  317. package/dist/interactive.js.map +1 -1
  318. package/dist/openspec-profile.d.ts +2 -1
  319. package/dist/openspec-profile.js +11 -41
  320. package/dist/openspec-profile.js.map +1 -1
  321. package/dist/repository-governance.d.ts +10 -5
  322. package/dist/repository-governance.js +178 -92
  323. package/dist/repository-governance.js.map +1 -1
  324. package/dist/telemetry/contract.d.ts +1 -1
  325. package/dist/telemetry/contract.js +3 -0
  326. package/dist/telemetry/contract.js.map +1 -1
  327. package/dist/workstation-catalog.d.ts +13 -1
  328. package/dist/workstation-catalog.js +122 -0
  329. package/dist/workstation-catalog.js.map +1 -1
  330. package/dist/workstation.d.ts +69 -14
  331. package/dist/workstation.js +600 -215
  332. package/dist/workstation.js.map +1 -1
  333. package/docs/cli-reference.md +94 -23
  334. package/docs/configuration-and-manifests.md +11 -10
  335. package/docs/prerequisites.md +93 -18
  336. package/docs/repository-governance.md +68 -40
  337. package/docs/spec-workflows-and-agents.md +132 -13
  338. package/docs/troubleshooting.md +28 -0
  339. package/package.json +1 -1
@@ -1,7 +1,14 @@
1
- import { access, readFile } from 'node:fs/promises';
2
- import path from 'node:path';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { stripVTControlCharacters } from 'node:util';
3
3
  import { packagedSupportedStack as supportedStack } from './adapters/packaged-assets/supported-stack.js';
4
4
  import { workstationRequirementCatalog } from './workstation-catalog.js';
5
+ import { formatCommand, NodeCommandRunner } from './process-runner.js';
6
+ import { compareVersionCores, compareVersions, extractVersion, isPrereleaseVersion, matchesReleaseLine } from './domain/workstation/versions.js';
7
+ import { environmentValue, hostPath, unavailableExecutableObserver } from './domain/workstation/executables.js';
8
+ import { nativeExecutableObserver } from './adapters/filesystem/executables.js';
9
+ import { canonicalSha256 } from './domain/governance/activation/canonical-json.js';
10
+ export { extractVersion } from './domain/workstation/versions.js';
11
+ export { nativeExecutableObserver } from './adapters/filesystem/executables.js';
5
12
  const REQUIREMENT_ORDER = [
6
13
  'node',
7
14
  'npm',
@@ -14,37 +21,11 @@ const REQUIREMENT_ORDER = [
14
21
  'openspec',
15
22
  'spec-kit',
16
23
  'github-copilot',
17
- 'claude'
24
+ 'claude',
25
+ 'codex',
26
+ 'github-cli'
18
27
  ];
19
- const MISSING_ERROR_CODES = new Set(['ENOENT', 'UNKNOWN']);
20
- function compareVersions(left, right) {
21
- const leftParts = versionCore(left).split('.').map(Number);
22
- const rightParts = versionCore(right).split('.').map(Number);
23
- for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
24
- const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
25
- if (difference !== 0) {
26
- return difference;
27
- }
28
- }
29
- return 0;
30
- }
31
- export function extractVersion(output) {
32
- return output.match(/(?:^|[^0-9A-Za-z])(?:v|go)?([0-9]+\.[0-9]+(?:\.[0-9]+)?(?:(?:-[0-9A-Za-z]|[A-Za-z])[0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z.-]+)?)(?=$|[^0-9A-Za-z.+-])/i)?.[1];
33
- }
34
- function versionCore(value) {
35
- return value
36
- .split('+', 1)[0]
37
- .match(/^\d+\.\d+(?:\.\d+)?/)?.[0] ?? value;
38
- }
39
- function isPrereleaseVersion(value) {
40
- const withoutBuild = value.split('+', 1)[0];
41
- return withoutBuild.slice(versionCore(withoutBuild).length).length > 0;
42
- }
43
- function matchesReleaseLine(value, releaseLine) {
44
- const versionParts = versionCore(value).split('.');
45
- const releaseParts = releaseLine.split('.');
46
- return releaseParts.every((part, index) => versionParts[index] === part);
47
- }
28
+ const MISSING_ERROR_CODES = new Set(['ENOENT']);
48
29
  export function selectLiftoffRuntimeRequirements() {
49
30
  const definition = workstationRequirementCatalog.node;
50
31
  return [{
@@ -58,6 +39,7 @@ export function selectLiftoffRuntimeRequirements() {
58
39
  }];
59
40
  }
60
41
  export function selectWorkstationRequirements(plan, options = {}) {
42
+ const scope = options.scope ?? 'initialization';
61
43
  const selected = new Map();
62
44
  const add = (id, reason, overrides = {}) => {
63
45
  const definition = workstationRequirementCatalog[id];
@@ -66,6 +48,8 @@ export function selectWorkstationRequirements(plan, options = {}) {
66
48
  const exactVersion = overrides.exactVersion ?? definition.exactVersion;
67
49
  if (existing) {
68
50
  existing.reasons.push(reason);
51
+ if (overrides.severity === 'blocking')
52
+ existing.severity = 'blocking';
69
53
  if (minimumVersion && (!existing.minimumVersion || compareVersions(minimumVersion, existing.minimumVersion) > 0)) {
70
54
  existing.minimumVersion = minimumVersion;
71
55
  }
@@ -79,7 +63,8 @@ export function selectWorkstationRequirements(plan, options = {}) {
79
63
  ...(minimumVersion ? { minimumVersion } : {}),
80
64
  ...(exactVersion ? { exactVersion } : {}),
81
65
  ...(definition.releaseLine ? { releaseLine: definition.releaseLine } : {}),
82
- allowPrerelease: definition.allowPrerelease ?? false
66
+ allowPrerelease: definition.allowPrerelease ?? false,
67
+ scope
83
68
  });
84
69
  };
85
70
  const workload = typeof plan.workload === 'string'
@@ -95,6 +80,18 @@ export function selectWorkstationRequirements(plan, options = {}) {
95
80
  add('node', 'Liftoff runtime', {
96
81
  minimumVersion: supportedStack.runtimes.node.minimumVersion
97
82
  });
83
+ if (scope === 'activation' || scope === 'migration' || scope === 'lifecycle') {
84
+ if (scope !== 'lifecycle')
85
+ add('opentofu', `${scope} infrastructure operations`, { severity: 'blocking' });
86
+ if (scope === 'activation') {
87
+ add('github-cli', 'approved GitHub activation operations', { severity: 'blocking' });
88
+ if (workload.provider.id === 'azure')
89
+ add('azure-cli', 'approved Azure activation operations', { severity: 'blocking' });
90
+ }
91
+ for (const id of options.requiredTools ?? [])
92
+ add(id, `required ${scope} operation`, { severity: 'blocking' });
93
+ return REQUIREMENT_ORDER.flatMap((id) => selected.has(id) ? [selected.get(id)] : []);
94
+ }
98
95
  if (workload.apiStack.id === 'node-fastify') {
99
96
  add('npm', 'selected Node.js API dependency manager');
100
97
  }
@@ -130,169 +127,259 @@ export function selectWorkstationRequirements(plan, options = {}) {
130
127
  add('spec-kit', 'selected spec-driven framework', { exactVersion: plan.framework.version });
131
128
  }
132
129
  }
133
- add('docker', 'generated local development stack');
134
- add('opentofu', 'generated infrastructure');
135
- if (workload.provider.id === 'azure') {
130
+ add('docker', scope === 'local' ? 'local Docker Compose configuration check' : 'generated local development stack', { severity: scope === 'local' ? 'blocking' : 'advisory' });
131
+ add('opentofu', scope === 'local' ? 'local backend-disabled infrastructure checks' : 'generated infrastructure', { severity: scope === 'local' ? 'blocking' : 'advisory' });
132
+ if (scope === 'initialization' && workload.provider.id === 'azure') {
136
133
  add('azure-cli', 'selected Azure cloud');
137
134
  }
138
135
  for (const agent of plan.agents) {
139
136
  add(agent.id, `selected ${agent.label} coding agent`);
140
137
  }
138
+ for (const id of options.requiredTools ?? [])
139
+ add(id, `required ${scope} operation`, { severity: 'blocking' });
141
140
  return REQUIREMENT_ORDER.flatMap((id) => {
142
141
  const requirement = selected.get(id);
143
142
  return requirement ? [requirement] : [];
144
143
  });
145
144
  }
146
- function missingResult(requirement, detail = 'command not found') {
145
+ function commandMissing(result) {
146
+ return result.errorCode !== undefined && MISSING_ERROR_CODES.has(result.errorCode);
147
+ }
148
+ function requiredConstraint(requirement) {
149
+ return {
150
+ ...(requirement.minimumVersion ? { minimumVersion: requirement.minimumVersion } : {}),
151
+ ...(requirement.exactVersion ? { exactVersion: requirement.exactVersion } : {}),
152
+ ...(requirement.releaseLine ? { releaseLine: requirement.releaseLine } : {}),
153
+ allowPrerelease: requirement.allowPrerelease ?? false
154
+ };
155
+ }
156
+ function registeredRequirement(requirement) {
157
+ const definition = workstationRequirementCatalog[requirement.id];
158
+ const requestedMinimum = requirement.minimumVersion ?? definition.minimumVersion;
159
+ const minimumVersion = requestedMinimum && definition.minimumVersion &&
160
+ extractVersion(requestedMinimum) === requestedMinimum && compareVersionCores(requestedMinimum, definition.minimumVersion) < 0
161
+ ? definition.minimumVersion : requestedMinimum;
162
+ return {
163
+ ...requirement, definition, minimumVersion,
164
+ exactVersion: requirement.exactVersion ?? definition.exactVersion,
165
+ releaseLine: requirement.releaseLine ?? definition.releaseLine,
166
+ allowPrerelease: definition.allowPrerelease === true && requirement.allowPrerelease !== false
167
+ };
168
+ }
169
+ function unsupportedConstraint(requirement) {
170
+ for (const field of ['minimumVersion', 'exactVersion']) {
171
+ if (requirement[field] !== undefined && extractVersion(requirement[field]) !== requirement[field]) {
172
+ return `The requested ${field} is not a supported version constraint.`;
173
+ }
174
+ }
175
+ if (requirement.definition.exactVersion && requirement.exactVersion !== requirement.definition.exactVersion) {
176
+ return `The registered ${requirement.definition.label} pin is ${requirement.definition.exactVersion}; another requested pin is unsupported.`;
177
+ }
178
+ if (requirement.releaseLine !== undefined && (!/^\d+(?:\.\d+)*$/.test(requirement.releaseLine) ||
179
+ (requirement.definition.releaseLine && requirement.releaseLine !== requirement.definition.releaseLine))) {
180
+ return `The requested release line does not match the registered ${requirement.definition.label} constraint.`;
181
+ }
182
+ return undefined;
183
+ }
184
+ function observationContext(requirement, options) {
185
+ return {
186
+ platform: options.host?.platform ?? (process.platform === 'win32' || process.platform === 'darwin' ? process.platform : 'linux'),
187
+ cwd: options.cwd ?? process.cwd(),
188
+ env: options.env ? { ...process.env, ...options.env } : process.env,
189
+ definition: requirement.definition
190
+ };
191
+ }
192
+ function executableObserver(runner, options) {
193
+ return options.executableObserver ??
194
+ (runner instanceof NodeCommandRunner ? nativeExecutableObserver : unavailableExecutableObserver);
195
+ }
196
+ function safeDetail(value) {
197
+ return stripVTControlCharacters(value)
198
+ .replace(/\b(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9_-]+)\b/g, '<redacted>')
199
+ .replace(/\b(?:authorization|password|secret|token)\s*[:=]\s*\S+/gi, '<redacted>')
200
+ .split(/\r?\n/, 1)[0].replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 600);
201
+ }
202
+ async function runToolCommand(runner, command, options) {
203
+ try {
204
+ return await runner.run(command, options);
205
+ }
206
+ catch (error) {
207
+ const errorCode = error && typeof error === 'object' && 'code' in error &&
208
+ typeof error.code === 'string' && /^[A-Z0-9_]{1,64}$/.test(error.code)
209
+ ? error.code : 'COMMAND_RUNNER_ERROR';
210
+ return {
211
+ command, displayCommand: formatCommand(command), status: null, signal: null,
212
+ stdout: '', stderr: '', timedOut: false, errorCode,
213
+ errorMessage: error instanceof Error ? safeDetail(error.message) : 'The command runner could not complete the observation.'
214
+ };
215
+ }
216
+ }
217
+ function resultWithCause(requirement, identity, reasonCode, detail, fields = {}) {
218
+ const states = {
219
+ compatible: 'ready',
220
+ 'missing-executable': 'missing',
221
+ 'observation-unavailable': 'not-observable',
222
+ 'probe-failed': 'unhealthy',
223
+ 'version-unparseable': 'unhealthy',
224
+ 'unsupported-constraint': 'not-observable',
225
+ 'below-minimum': 'outdated',
226
+ 'release-line-mismatch': 'outdated',
227
+ 'exact-version-mismatch': 'outdated',
228
+ 'incompatible-channel': 'outdated'
229
+ };
147
230
  return {
148
231
  requirement,
149
- state: 'missing',
232
+ state: states[reasonCode],
233
+ reasonCode,
150
234
  detail,
151
- remedy: requirement.definition.missingRemedy ??
152
- `Install ${requirement.definition.label}.`,
153
- notices: []
235
+ identity,
236
+ required: requiredConstraint(requirement),
237
+ observations: [],
238
+ notices: [],
239
+ ...fields
154
240
  };
155
241
  }
156
- function commandMissing(result) {
157
- return result.errorCode !== undefined && MISSING_ERROR_CODES.has(result.errorCode);
242
+ function missingResult(requirement, identity, detail = 'command not found') {
243
+ return resultWithCause(requirement, identity, 'missing-executable', detail, {
244
+ remedy: requirement.definition.missingRemedy ?? `Install ${requirement.definition.label}.`
245
+ });
246
+ }
247
+ async function runObservedProbe(requirement, command, runner, options) {
248
+ let identity;
249
+ try {
250
+ identity = await executableObserver(runner, options)
251
+ .resolve(command.executable, observationContext(requirement, options));
252
+ }
253
+ catch {
254
+ identity = {
255
+ executable: command.executable, resolution: 'not-observable',
256
+ origin: 'unknown', evidence: 'unavailable'
257
+ };
258
+ }
259
+ const result = await runToolCommand(runner, command, {
260
+ timeoutMs: 15_000, maxOutputBytes: 16_384, cwd: options.cwd, env: options.env
261
+ });
262
+ if (!commandMissing(result) && result.status === 0 && !result.timedOut &&
263
+ !result.outputLimitExceeded && !result.aborted && !result.errorCode && !result.signal) {
264
+ identity = {
265
+ ...identity,
266
+ resolution: 'resolved',
267
+ evidence: identity.resolvedPath ? identity.evidence : 'version-probe'
268
+ };
269
+ }
270
+ return { result, identity };
271
+ }
272
+ function observed(classified, command, result) {
273
+ classified.observations = [{
274
+ command,
275
+ identity: classified.identity,
276
+ status: result.status,
277
+ timedOut: result.timedOut,
278
+ ...(result.errorCode ? { errorCode: result.errorCode } : {}),
279
+ reasonCode: classified.reasonCode,
280
+ ...(classified.detectedVersion ? { detectedVersion: classified.detectedVersion } : {})
281
+ }];
282
+ return classified;
158
283
  }
159
284
  async function probeCommandCandidates(requirement, runner, options) {
160
285
  const results = [];
161
286
  for (const command of requirement.definition.probes) {
162
- const result = await runner.run(command, { timeoutMs: 15_000, ...options });
163
- if (!commandMissing(result)) {
164
- results.push(result);
165
- }
287
+ const { result, identity } = await runObservedProbe(requirement, command, runner, options);
288
+ results.push(observed(classifyVersion(requirement, command, result, identity), command, result));
166
289
  }
167
290
  return results;
168
291
  }
169
292
  async function copilotFallback(requirement, runner, options) {
170
- const result = await runner.run({ executable: 'code', args: ['--list-extensions'] }, { timeoutMs: 15_000, ...options });
171
- if (commandMissing(result)) {
172
- return {
173
- requirement,
174
- state: 'not-observable',
175
- detail: 'Copilot CLI and the VS Code CLI are unavailable, so agent presence cannot be observed.',
176
- remedy: 'Install the Copilot CLI, or verify that GitHub.copilot or GitHub.copilot-chat is enabled in VS Code.',
177
- notices: []
178
- };
179
- }
180
- if (result.status !== 0) {
181
- return {
182
- requirement,
183
- state: 'unhealthy',
184
- detail: result.stderr.trim() || 'VS Code extension discovery failed.',
185
- remedy: 'Run `code --list-extensions` and repair the VS Code CLI before retrying.',
186
- notices: []
187
- };
188
- }
189
- const extensions = result.stdout.toLowerCase().split(/\r?\n/);
293
+ const command = { executable: 'code', args: ['--list-extensions'] };
294
+ const { result, identity } = await runObservedProbe(requirement, command, runner, options);
295
+ if (commandMissing(result) && identity.resolution !== 'resolved') {
296
+ return observed(resultWithCause(requirement, identity, 'observation-unavailable', 'Copilot CLI and the VS Code CLI are unavailable, so agent presence cannot be observed.', {
297
+ remedy: 'Install the Copilot CLI, or verify that GitHub.copilot or GitHub.copilot-chat is enabled in VS Code.'
298
+ }), command, result);
299
+ }
300
+ if (result.status !== 0 || result.timedOut || result.outputLimitExceeded || result.aborted || result.errorCode || result.signal) {
301
+ return observed(resultWithCause(requirement, identity, 'probe-failed', safeDetail(result.stderr) || 'VS Code extension discovery failed.', {
302
+ remedy: 'Run `code --list-extensions` and repair the VS Code CLI before retrying.'
303
+ }), command, result);
304
+ }
305
+ const extensions = result.stdout.toLowerCase().split(/\r?\n/).map((line) => line.trim());
190
306
  const installed = extensions.some((id) => id === 'github.copilot' || id === 'github.copilot-chat');
191
- return installed
192
- ? {
193
- requirement,
194
- state: 'ready',
195
- detail: 'GitHub Copilot is installed as a VS Code extension.',
307
+ return observed(installed
308
+ ? resultWithCause(requirement, identity, 'compatible', 'GitHub Copilot is installed as a VS Code extension.', {
196
309
  detectedBy: 'code --list-extensions',
197
310
  notices: [{
198
311
  label: 'GitHub Copilot authentication',
312
+ code: 'authentication',
199
313
  state: 'not-observable',
200
314
  detail: 'Authentication is managed by the selected editor or agent.',
201
315
  remedy: 'Open GitHub Copilot and sign in if prompted.'
202
316
  }]
203
- }
204
- : missingResult(requirement, 'GitHub Copilot CLI and supported VS Code extensions were not found.');
317
+ })
318
+ : missingResult(requirement, identity, 'GitHub Copilot CLI and supported VS Code extensions were not found.'), command, result);
205
319
  }
206
- function classifyVersion(requirement, command, result) {
320
+ function classifyVersion(requirement, command, result, identity) {
321
+ if (commandMissing(result) && identity.resolution !== 'resolved') {
322
+ return missingResult(requirement, { ...identity, resolution: 'missing' }, safeDetail(result.stderr || result.errorMessage || '') || 'command not found');
323
+ }
207
324
  if (result.timedOut) {
208
- return {
209
- requirement,
210
- state: 'unhealthy',
211
- detail: `${command.executable} version probe timed out.`,
212
- remedy: `Repair ${requirement.definition.label} and retry.`,
213
- notices: []
214
- };
325
+ return resultWithCause(requirement, identity, 'probe-failed', `${command.executable} version probe timed out.`, {
326
+ remedy: `Repair ${requirement.definition.label} and retry.`
327
+ });
215
328
  }
216
- if (result.status !== 0) {
217
- return {
218
- requirement,
219
- state: 'unhealthy',
220
- detail: result.stderr.trim() || result.errorMessage || `${command.executable} exited with status ${result.status}.`,
221
- remedy: `Repair ${requirement.definition.label} and retry.`,
222
- notices: []
223
- };
329
+ if (result.status !== 0 || result.outputLimitExceeded || result.aborted || result.errorCode || result.signal) {
330
+ return resultWithCause(requirement, identity, 'probe-failed', result.outputLimitExceeded ? `${command.executable} version probe exceeded the output limit.` :
331
+ safeDetail(result.stderr || result.errorMessage || '') || `${command.executable} exited with status ${result.status}.`, {
332
+ remedy: `Repair ${requirement.definition.label} and retry.`
333
+ });
224
334
  }
225
- const output = `${result.stdout}\n${result.stderr}`.trim();
226
- const version = extractVersion(output);
227
- if ((requirement.minimumVersion || requirement.exactVersion) && !version) {
228
- return {
229
- requirement,
230
- state: 'unhealthy',
231
- detail: `Unable to parse a version from: ${output || '(empty output)'}`,
232
- remedy: `Verify ${requirement.definition.label} manually and reinstall it if necessary.`,
233
- notices: []
234
- };
335
+ const output = requirement.id === 'azure-cli' ? result.stdout.trim() : `${result.stdout}\n${result.stderr}`.trim();
336
+ const version = extractVersion(output, requirement.id);
337
+ if (!version) {
338
+ return resultWithCause(requirement, identity, 'version-unparseable', `Unable to parse a supported ${requirement.definition.label} version from the successful probe output.`, {
339
+ detectedBy: command.executable,
340
+ remedy: `Verify the ${requirement.definition.label} executable and its version output before selecting a remedy.`
341
+ });
235
342
  }
343
+ const detected = { detectedVersion: version, detectedBy: command.executable };
236
344
  if (version &&
237
345
  !requirement.allowPrerelease &&
238
346
  isPrereleaseVersion(version)) {
239
- return {
240
- requirement,
241
- state: 'outdated',
242
- detail: `Found prerelease ${version}; a stable release is required.`,
243
- detectedVersion: version,
244
- detectedBy: command.executable,
347
+ return resultWithCause(requirement, identity, 'incompatible-channel', `Found prerelease ${version}; a stable release is required.`, {
348
+ ...detected,
245
349
  remedy: requirement.exactVersion
246
350
  ? `Install ${requirement.definition.label} ${requirement.exactVersion}.`
247
- : `Install a stable ${requirement.definition.label} release${requirement.releaseLine ? ` in the supported ${requirement.releaseLine} line` : ''}.`,
248
- notices: []
249
- };
351
+ : `Install a stable ${requirement.definition.label} release${requirement.releaseLine ? ` in the supported ${requirement.releaseLine} line` : ''}.`
352
+ });
250
353
  }
251
354
  if (version &&
252
355
  requirement.releaseLine &&
253
356
  !matchesReleaseLine(version, requirement.releaseLine)) {
254
- return {
255
- requirement,
256
- state: 'outdated',
257
- detail: `Found ${version}; the supported release line is ${requirement.releaseLine}.`,
258
- detectedVersion: version,
259
- detectedBy: command.executable,
357
+ return resultWithCause(requirement, identity, 'release-line-mismatch', `Found ${version}; the supported release line is ${requirement.releaseLine}.`, {
358
+ ...detected,
260
359
  remedy: requirement.exactVersion
261
360
  ? `Install ${requirement.definition.label} ${requirement.exactVersion}.`
262
- : `Install ${requirement.definition.label} ${requirement.releaseLine}.x at or above ${requirement.minimumVersion}.`,
263
- notices: []
264
- };
361
+ : `Install ${requirement.definition.label} ${requirement.releaseLine}.x at or above ${requirement.minimumVersion}.`
362
+ });
265
363
  }
266
- if (version && requirement.exactVersion && compareVersions(version, requirement.exactVersion) !== 0) {
267
- return {
268
- requirement,
269
- state: 'outdated',
270
- detail: `Found ${version}; Liftoff tested this integration with exactly ${requirement.exactVersion}.`,
271
- detectedVersion: version,
272
- detectedBy: command.executable,
273
- remedy: `Install ${requirement.definition.label} ${requirement.exactVersion}.`,
274
- notices: []
275
- };
364
+ if (requirement.exactVersion && compareVersionCores(version, requirement.exactVersion) !== 0) {
365
+ return resultWithCause(requirement, identity, 'exact-version-mismatch', `Found ${version}; Liftoff tested this integration with exactly ${requirement.exactVersion}.`, {
366
+ ...detected, remedy: `Install ${requirement.definition.label} ${requirement.exactVersion}.`
367
+ });
276
368
  }
277
- if (version && requirement.minimumVersion && compareVersions(version, requirement.minimumVersion) < 0) {
278
- return {
279
- requirement,
280
- state: 'outdated',
281
- detail: `Found ${version}; version ${requirement.minimumVersion} or newer is required.`,
282
- detectedVersion: version,
283
- detectedBy: command.executable,
284
- remedy: `Upgrade ${requirement.definition.label} to ${requirement.minimumVersion} or newer.`,
285
- notices: []
286
- };
369
+ if (requirement.minimumVersion && compareVersionCores(version, requirement.minimumVersion) < 0) {
370
+ return resultWithCause(requirement, identity, 'below-minimum', `Found ${version}; version ${requirement.minimumVersion} or newer is required.`, {
371
+ ...detected, remedy: `Upgrade ${requirement.definition.label} to ${requirement.minimumVersion} or newer.`
372
+ });
287
373
  }
288
- return {
289
- requirement,
290
- state: 'ready',
291
- detail: version ? `Version ${version}` : 'Available',
292
- ...(version ? { detectedVersion: version } : {}),
293
- detectedBy: command.executable,
294
- notices: []
295
- };
374
+ return resultWithCause(requirement, identity, 'compatible', `Version ${version}`, {
375
+ ...detected,
376
+ notices: isPrereleaseVersion(version) ? [{
377
+ code: 'preview-channel',
378
+ label: `${requirement.definition.label} release channel`,
379
+ state: 'notice',
380
+ detail: `Compatible preview ${version}; a stable-channel downgrade is not required.`
381
+ }] : []
382
+ });
296
383
  }
297
384
  async function healthNotices(requirement, runner, options) {
298
385
  let command;
@@ -317,44 +404,90 @@ async function healthNotices(requirement, runner, options) {
317
404
  case 'github-copilot':
318
405
  return [{
319
406
  label: 'GitHub Copilot authentication',
407
+ code: 'authentication',
320
408
  state: 'not-observable',
321
409
  detail: 'Liftoff does not automate or persist Copilot credentials.',
322
410
  remedy: 'Run `copilot` and sign in if prompted.'
323
411
  }];
412
+ case 'codex':
413
+ return [{
414
+ label: 'OpenAI Codex authentication',
415
+ code: 'authentication',
416
+ state: 'not-observable',
417
+ detail: 'Authentication remains owned by Codex; Liftoff does not collect or persist credentials.',
418
+ remedy: 'Run `codex` and sign in if prompted.'
419
+ }];
420
+ case 'github-cli':
421
+ command = { executable: 'gh', args: ['auth', 'status'] };
422
+ label = 'GitHub authentication';
423
+ remedy = 'Run `gh auth login` with the required account and scope.';
424
+ break;
324
425
  default:
325
426
  return [];
326
427
  }
327
- const result = await runner.run(command, { timeoutMs: 20_000, ...options });
328
- if (result.status === 0) {
329
- return [{ label, state: 'ready', detail: result.stdout.trim().split(/\r?\n/)[0] || 'Healthy' }];
428
+ const result = await runToolCommand(runner, command, {
429
+ timeoutMs: 20_000, maxOutputBytes: 16_384, cwd: options.cwd, env: options.env
430
+ });
431
+ const code = requirement.id === 'azure-cli' || requirement.id === 'github-cli' ? 'authentication' : 'health';
432
+ if (result.status === 0 && !result.timedOut && !result.outputLimitExceeded && !result.aborted && !result.errorCode && !result.signal) {
433
+ return [{ label, code, state: 'ready', detail: 'Health probe succeeded.' }];
330
434
  }
331
435
  return [{
332
436
  label,
437
+ code,
333
438
  state: 'unhealthy',
334
- detail: result.timedOut ? 'Health probe timed out.' : result.stderr.trim().split(/\r?\n/)[0] || 'Health probe failed.',
439
+ detail: result.timedOut ? 'Health probe timed out.' : safeDetail(result.stderr) || 'Health probe failed.',
335
440
  remedy
336
441
  }];
337
442
  }
338
443
  export async function probeRequirement(requirement, runner, options = {}) {
339
- const candidates = await probeCommandCandidates(requirement, runner, options);
340
- if (candidates.length === 0) {
341
- return requirement.id === 'github-copilot'
342
- ? copilotFallback(requirement, runner, options)
343
- : missingResult(requirement);
444
+ requirement = registeredRequirement(requirement);
445
+ const unsupported = unsupportedConstraint(requirement);
446
+ if (unsupported) {
447
+ return resultWithCause(requirement, {
448
+ executable: requirement.definition.probes[0]?.executable ?? requirement.id,
449
+ resolution: 'not-observable', origin: 'unknown', evidence: 'unavailable'
450
+ }, 'unsupported-constraint', unsupported, {
451
+ remedy: 'Use the registered tested tool constraints; no installation or PATH change can repair an unsupported requirement.'
452
+ });
344
453
  }
345
- const classified = candidates.map((candidate) => classifyVersion(requirement, candidate.command, candidate));
346
- const ready = classified.find((result) => result.state === 'ready');
454
+ const candidates = await probeCommandCandidates(requirement, runner, options);
455
+ const ready = candidates.find((result) => result.state === 'ready');
347
456
  if (ready) {
348
- ready.notices = await healthNotices(requirement, runner, options);
457
+ if (options.includeHealthNotices !== false) {
458
+ ready.notices.push(...await healthNotices(requirement, runner, options));
459
+ }
460
+ const update = options.availableUpdates?.[requirement.id];
461
+ const updateVersion = update ? extractVersion(update.version) : undefined;
462
+ if (update && updateVersion && ready.detectedVersion &&
463
+ compareVersions(updateVersion, ready.detectedVersion) > 0) {
464
+ ready.notices.push({
465
+ code: 'update-available',
466
+ label: `${requirement.definition.label} update`,
467
+ state: 'notice',
468
+ detail: `Version ${updateVersion} is available (${safeDetail(update.source)}); installed ${ready.detectedVersion} remains compatible.`
469
+ });
470
+ }
471
+ ready.observations = candidates.flatMap((candidate) => candidate.observations);
349
472
  return ready;
350
473
  }
474
+ let fallback;
351
475
  if (requirement.id === 'github-copilot') {
352
- const fallback = await copilotFallback(requirement, runner, options);
353
- if (fallback.state === 'ready') {
476
+ fallback = await copilotFallback(requirement, runner, options);
477
+ if (fallback.state === 'ready' || candidates.every((candidate) => candidate.reasonCode === 'missing-executable')) {
478
+ fallback.observations = [...candidates.flatMap((candidate) => candidate.observations), ...fallback.observations];
354
479
  return fallback;
355
480
  }
356
481
  }
357
- return classified.find((result) => result.state === 'outdated') ?? classified[0];
482
+ const selected = candidates.find((candidate) => candidate.state === 'outdated') ??
483
+ candidates.find((candidate) => candidate.reasonCode !== 'missing-executable') ?? candidates[0];
484
+ if (!selected) {
485
+ return resultWithCause(requirement, {
486
+ executable: requirement.id, resolution: 'not-observable', origin: 'unknown', evidence: 'unavailable'
487
+ }, 'observation-unavailable', 'No registered version probe is available.');
488
+ }
489
+ selected.observations = [...candidates.flatMap((candidate) => candidate.observations), ...(fallback?.observations ?? [])];
490
+ return selected;
358
491
  }
359
492
  export async function probeWorkstation(requirements, runner, options = {}) {
360
493
  return Promise.all(requirements.map((requirement) => probeRequirement(requirement, runner, options)));
@@ -389,22 +522,61 @@ export async function detectHostEnvironment(platform = process.platform, osRelea
389
522
  }
390
523
  return { platform: 'linux', linuxFamily: parseLinuxFamily(osRelease) };
391
524
  }
392
- function installRecipe(requirement, host) {
393
- const recipe = requirement.definition.install[host.platform];
394
- if (host.platform !== 'linux') {
395
- return recipe;
525
+ export function selectRemediation(requirement, probe, host, requestedRecipeId) {
526
+ requirement = registeredRequirement(requirement);
527
+ const definition = workstationRequirementCatalog[requirement.id];
528
+ const matchingRequirement = probe.requirement.id === requirement.id &&
529
+ JSON.stringify(requiredConstraint(probe.requirement)) === JSON.stringify(requiredConstraint(requirement)) &&
530
+ !unsupportedConstraint(requirement);
531
+ if (matchingRequirement && probe.state === 'ready' && probe.reasonCode === 'compatible') {
532
+ return { state: 'not-needed', reasonCode: 'compatible', detail: `${requirement.definition.label} is already compatible.` };
533
+ }
534
+ const recipes = matchingRequirement && requirement.exactVersion === definition.exactVersion
535
+ ? definition.remedies ?? []
536
+ : [];
537
+ const recipe = recipes.find((candidate) => (!requestedRecipeId || candidate.id === requestedRecipeId) &&
538
+ candidate.platforms.includes(host.platform) &&
539
+ candidate.causes.includes(probe.reasonCode) &&
540
+ candidate.origins.includes(probe.identity?.origin ?? 'unknown') &&
541
+ (host.platform !== 'linux' || candidate.manager === 'npm' || candidate.manager === 'uv') &&
542
+ !(candidate.operation === 'upgrade' && requirement.exactVersion &&
543
+ (!probe.detectedVersion || compareVersions(probe.detectedVersion, requirement.exactVersion) >= 0)));
544
+ if (recipe) {
545
+ return {
546
+ state: 'available',
547
+ reasonCode: probe.reasonCode,
548
+ detail: `${recipe.operation} ${definition.label} using its registered ${recipe.manager} recipe.`,
549
+ recipe
550
+ };
396
551
  }
397
- return recipe && (recipe.manager === 'npm' || recipe.manager === 'uv') ? recipe : undefined;
552
+ return {
553
+ state: 'manual',
554
+ reasonCode: probe.reasonCode,
555
+ detail: `No safe automatic ${definition.label} remedy is registered for ${probe.reasonCode ?? 'an unknown cause'} ` +
556
+ `from ${probe.identity?.origin ?? 'an unobserved installation origin'} on ${host.platform}.`,
557
+ remedy: `${probe.remedy ? `${probe.remedy} ` : ''}${manualRemedy(requirement, host)} ` +
558
+ 'Review the installation origin and required version/channel; Liftoff will not uninstall, downgrade, or repeat a missing-tool installer to guess a repair.'
559
+ };
398
560
  }
399
561
  async function managerAvailable(recipe, runner, options) {
400
- const result = await runner.run({ executable: recipe.command.executable, args: ['--version'] }, { timeoutMs: 10_000, ...options });
401
- return !commandMissing(result) && result.status === 0;
562
+ if (recipe.manager === 'npm' || recipe.manager === 'uv') {
563
+ const definition = workstationRequirementCatalog[recipe.manager];
564
+ const probe = await probeRequirement({
565
+ id: definition.id, definition, severity: 'blocking', reasons: ['registered remedy prerequisite'],
566
+ minimumVersion: definition.minimumVersion, releaseLine: definition.releaseLine,
567
+ allowPrerelease: definition.allowPrerelease ?? false
568
+ }, runner, { ...options, includeHealthNotices: false });
569
+ return probe.state === 'ready';
570
+ }
571
+ const result = await runToolCommand(runner, { executable: recipe.command.executable, args: ['--version'] }, { timeoutMs: 10_000, maxOutputBytes: 16_384, cwd: options.cwd, env: options.env });
572
+ return !result.errorCode && result.status === 0 && !result.timedOut &&
573
+ !result.outputLimitExceeded && !result.aborted && !result.signal;
402
574
  }
403
575
  function pathRemedy(recipe, requirement) {
404
576
  const executable = requirement.definition.probes[0]?.executable ?? requirement.id;
405
577
  switch (recipe.manager) {
406
578
  case 'brew':
407
- return `Run \`brew --prefix\`, ensure its bin directory is on PATH, open a new terminal, then retry ${executable}.`;
579
+ return `Run \`brew --prefix${requirement.definition.packageIdentities?.brew?.includes('@') ? ` ${requirement.definition.packageIdentities.brew}` : ''}\`, ensure its bin directory is on PATH, open a new terminal, then retry ${executable}.`;
408
580
  case 'winget':
409
581
  return `Open a new terminal and retry ${executable}; if it is still missing, inspect the WinGet package installation and PATH aliases.`;
410
582
  case 'npm':
@@ -414,10 +586,17 @@ function pathRemedy(recipe, requirement) {
414
586
  }
415
587
  }
416
588
  async function documentedInstallLocations(recipe, requirement, context) {
589
+ const nativePath = hostPath(context.host.platform);
590
+ const observerContext = observationContext(requirement, context);
591
+ const observer = executableObserver(context.runner, context);
417
592
  let binDirectories = [];
418
593
  if (recipe.manager === 'winget') {
419
- if (process.env.LOCALAPPDATA) {
420
- binDirectories = [path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WindowsApps')];
594
+ const localAppData = environmentValue(observerContext.env, 'LOCALAPPDATA', context.host.platform);
595
+ if (localAppData && !/[\u0000-\u001f\u007f]/.test(localAppData) && nativePath.isAbsolute(localAppData)) {
596
+ binDirectories = [
597
+ nativePath.join(localAppData, 'Microsoft', 'WindowsApps'),
598
+ nativePath.join(localAppData, 'Microsoft', 'WinGet', 'Links')
599
+ ];
421
600
  }
422
601
  }
423
602
  else {
@@ -425,116 +604,322 @@ async function documentedInstallLocations(recipe, requirement, context) {
425
604
  ? { executable: recipe.command.executable, args: ['prefix', '-g'] }
426
605
  : recipe.manager === 'uv'
427
606
  ? { executable: recipe.command.executable, args: ['tool', 'dir', '--bin'] }
428
- : { executable: recipe.command.executable, args: ['--prefix'] };
429
- const location = await context.runner.run(locationCommand, {
607
+ : {
608
+ executable: recipe.command.executable,
609
+ args: [
610
+ '--prefix',
611
+ ...(requirement.definition.packageIdentities?.brew?.includes('@') ? [requirement.definition.packageIdentities.brew] : [])
612
+ ]
613
+ };
614
+ const location = await runToolCommand(context.runner, locationCommand, {
430
615
  cwd: context.cwd,
431
- timeoutMs: 10_000
616
+ env: context.env,
617
+ timeoutMs: 10_000,
618
+ maxOutputBytes: 16_384
432
619
  });
433
- if (location.status === 0 && location.stdout.trim()) {
434
- const root = location.stdout.trim().split(/\r?\n/)[0];
435
- binDirectories = recipe.manager === 'npm' && context.host.platform !== 'win32'
436
- ? [path.join(root, 'bin')]
437
- : recipe.manager === 'brew'
438
- ? [path.join(root, 'bin')]
439
- : [root];
620
+ const root = location.stdout.trim();
621
+ if (location.status === 0 && !location.timedOut && !location.outputLimitExceeded &&
622
+ !location.aborted && !location.errorCode && !location.signal &&
623
+ !/[\u0000-\u001f\u007f]/.test(root) && nativePath.isAbsolute(root)) {
624
+ binDirectories = recipe.manager === 'brew' || (recipe.manager === 'npm' && context.host.platform !== 'win32')
625
+ ? [nativePath.join(root, 'bin')]
626
+ : [root];
440
627
  }
441
628
  }
442
- const extensions = context.host.platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : [''];
629
+ const extensions = context.host.platform === 'win32' ? ['.exe', '.cmd', '.bat', '.com'] : [''];
443
630
  const candidates = new Set();
444
631
  for (const directory of binDirectories) {
445
632
  for (const probe of requirement.definition.probes) {
446
633
  for (const extension of extensions) {
447
- candidates.add(path.join(directory, `${probe.executable}${extension}`));
634
+ candidates.add(nativePath.join(directory, `${probe.executable}${extension}`));
448
635
  }
449
636
  }
450
637
  }
451
638
  const found = [];
639
+ let complete = candidates.size > 0;
452
640
  for (const candidate of candidates) {
453
641
  try {
454
- await access(candidate);
455
- found.push(candidate);
642
+ const identity = await observer.inspect(candidate, observerContext);
643
+ if (identity.resolution === 'resolved')
644
+ found.push(identity);
645
+ else if (identity.resolution === 'not-observable')
646
+ complete = false;
456
647
  }
457
648
  catch {
458
- // Candidate locations are best-effort diagnostics after a successful installer.
649
+ complete = false;
459
650
  }
460
651
  }
461
- return found;
652
+ return { checkedLocations: [...candidates], found, complete };
462
653
  }
463
654
  function manualRemedy(requirement, host) {
464
655
  return host.platform === 'linux'
465
656
  ? requirement.definition.linuxRemedies[host.linuxFamily]
466
657
  : `Install ${requirement.definition.label} manually and retry.`;
467
658
  }
659
+ const attemptsByRunner = new WeakMap();
660
+ function fingerprintIdentity(identity) {
661
+ return {
662
+ executable: identity.executable, resolution: identity.resolution,
663
+ resolvedPath: identity.resolvedPath ?? null, realPath: identity.realPath ?? null,
664
+ kind: identity.kind ?? null, origin: identity.origin, evidence: identity.evidence
665
+ };
666
+ }
667
+ function observationFingerprint(probe) {
668
+ return canonicalSha256({
669
+ id: probe.requirement.id,
670
+ required: requiredConstraint(probe.requirement),
671
+ state: probe.state,
672
+ reasonCode: probe.reasonCode,
673
+ identity: fingerprintIdentity(probe.identity),
674
+ detectedVersion: probe.detectedVersion ?? null,
675
+ observations: probe.observations.map((observation) => ({
676
+ command: { executable: observation.command.executable, args: observation.command.args },
677
+ identity: fingerprintIdentity(observation.identity),
678
+ status: observation.status,
679
+ timedOut: observation.timedOut,
680
+ errorCode: observation.errorCode ?? null,
681
+ reasonCode: observation.reasonCode,
682
+ detectedVersion: observation.detectedVersion ?? null
683
+ }))
684
+ });
685
+ }
686
+ function attemptFingerprint(probe, recipe, context) {
687
+ const observation = observationContext(probe.requirement, context);
688
+ const managerVariables = {
689
+ brew: ['HOMEBREW_PREFIX', 'HOMEBREW_CELLAR', 'HOMEBREW_CASKROOM'],
690
+ winget: [],
691
+ npm: ['npm_config_prefix', 'NPM_CONFIG_PREFIX', 'NPM_CONFIG_USERCONFIG', 'npm_config_userconfig'],
692
+ uv: ['UV_TOOL_DIR', 'UV_TOOL_BIN_DIR', 'UV_PYTHON_INSTALL_DIR']
693
+ };
694
+ const locationVariables = [
695
+ 'PATH', 'PATHEXT', 'HOME', 'USERPROFILE', 'LOCALAPPDATA', 'APPDATA', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME',
696
+ ...managerVariables[recipe.manager]
697
+ ];
698
+ // Cwd, PWD, temporary-directory names, and credentials are not progress. Actual resolved launchers retain path identity.
699
+ return canonicalSha256({
700
+ schemaVersion: 1,
701
+ observation: observationFingerprint(probe),
702
+ recipeId: recipe.id,
703
+ command: { executable: recipe.command.executable, args: recipe.command.args },
704
+ host: { platform: context.host.platform, linuxFamily: context.host.linuxFamily },
705
+ environment: Object.fromEntries(locationVariables.map((name) => [name, environmentValue(observation.env, name, observation.platform) ?? null]))
706
+ });
707
+ }
708
+ function storedNoProgressAttempt(attempt, recipeId, inputFingerprint) {
709
+ if (attempt.recipeId !== recipeId || attempt.inputFingerprint !== inputFingerprint ||
710
+ attempt.outputFingerprint !== inputFingerprint || attempt.outcome !== 'unchanged') {
711
+ throw new Error('Stored no-progress history does not match the current recipe and unchanged observation binding.');
712
+ }
713
+ return { recipeId, inputFingerprint, outputFingerprint: inputFingerprint, outcome: 'unchanged' };
714
+ }
715
+ function historyFailure(requirement, probe, recipe, operation, error, attempt, command) {
716
+ const detail = error instanceof Error ? safeDetail(error.message) : 'The private no-progress store is unavailable.';
717
+ return {
718
+ requirement, probe, recipe, state: 'failed', reasonCode: 'history-storage-failed', historyError: operation,
719
+ progress: operation === 'record' ? 'unchanged' : 'indeterminate',
720
+ ...(attempt ? { attempt } : {}),
721
+ ...(command ? { command } : {}),
722
+ detail: operation === 'lookup'
723
+ ? `Workstation no-progress history could not be read. No remedy command was run. ${detail}`
724
+ : `The authorized remedy exited zero and the independent probe is unchanged, but its no-progress receipt could not be preserved. ${detail}`,
725
+ remedy: 'Resolve the private workstation remediation history error before retrying; history is not silently ignored or replaced.'
726
+ };
727
+ }
728
+ export function compareRequirementObservations(before, after) {
729
+ if (after.state === 'ready' && after.reasonCode === 'compatible')
730
+ return 'ready';
731
+ if (observationFingerprint(before) === observationFingerprint(after))
732
+ return 'unchanged';
733
+ const discovery = new Set(['missing-executable', 'observation-unavailable']);
734
+ if ((discovery.has(before.reasonCode) && !discovery.has(after.reasonCode)) ||
735
+ (before.reasonCode === 'version-unparseable' && Boolean(after.detectedVersion)) ||
736
+ (before.reasonCode === 'incompatible-channel' && after.reasonCode !== 'incompatible-channel' && Boolean(after.detectedVersion)) ||
737
+ (before.reasonCode === 'below-minimum' && after.reasonCode === 'below-minimum' &&
738
+ before.detectedVersion && after.detectedVersion && compareVersions(after.detectedVersion, before.detectedVersion) > 0)) {
739
+ return 'improved';
740
+ }
741
+ return 'changed';
742
+ }
468
743
  export async function installRequirement(requirement, currentProbe, context) {
744
+ requirement = registeredRequirement(requirement);
745
+ const selection = selectRemediation(requirement, currentProbe, context.host, context.approvedRemediationId);
746
+ if (selection.state === 'not-needed') {
747
+ return { requirement, state: 'not-needed', reasonCode: 'verified', detail: selection.detail, probe: currentProbe, progress: 'ready' };
748
+ }
469
749
  if (!context.authorized) {
470
750
  return {
471
751
  requirement,
472
752
  state: 'declined',
473
- detail: 'Installation was not authorized.',
753
+ reasonCode: 'not-authorized',
754
+ detail: 'Tool remediation was not authorized.',
474
755
  probe: currentProbe,
475
756
  remedy: currentProbe.remedy
476
757
  };
477
758
  }
478
- const recipe = installRecipe(requirement, context.host);
759
+ const recipe = selection.recipe;
479
760
  if (!recipe) {
480
761
  return {
481
762
  requirement,
482
763
  state: 'manual',
483
- detail: `Liftoff does not automate ${requirement.definition.label} installation on this host.`,
764
+ reasonCode: 'recipe-unavailable',
765
+ detail: selection.detail,
484
766
  probe: currentProbe,
485
- remedy: manualRemedy(requirement, context.host)
767
+ remedy: selection.remedy
768
+ };
769
+ }
770
+ if (recipe.requiresExplicitReview && context.approvedRemediationId !== recipe.id) {
771
+ return {
772
+ requirement, state: 'manual', reasonCode: 'review-required', recipe, probe: currentProbe,
773
+ detail: `The ${recipe.operation} recipe requires separate review because it can replace the installed version or channel.`,
774
+ remedy: `Review ${formatCommand(recipe.command)} and explicitly approve remedy ${recipe.id}; generic tool-install consent does not authorize a downgrade or channel change.`
775
+ };
776
+ }
777
+ const inputFingerprint = attemptFingerprint(currentProbe, recipe, context);
778
+ let persisted;
779
+ if (context.noProgressStore) {
780
+ try {
781
+ const stored = await context.noProgressStore.find(recipe.id, inputFingerprint);
782
+ if (stored)
783
+ persisted = storedNoProgressAttempt(stored, recipe.id, inputFingerprint);
784
+ }
785
+ catch (error) {
786
+ return historyFailure(requirement, currentProbe, recipe, 'lookup', error);
787
+ }
788
+ }
789
+ const history = [
790
+ ...(context.previousAttempts ?? []),
791
+ ...(currentProbe.remediationAttempts ?? []),
792
+ ...(attemptsByRunner.get(context.runner) ?? []),
793
+ ...(persisted ? [persisted] : [])
794
+ ];
795
+ if (history.some((attempt) => attempt.recipeId === recipe.id &&
796
+ attempt.inputFingerprint === inputFingerprint && attempt.outcome === 'unchanged')) {
797
+ return {
798
+ requirement, state: 'unchanged', reasonCode: 'no-progress', progress: 'unchanged', recipe, probe: currentProbe,
799
+ ...(persisted ? { attempt: persisted } : {}),
800
+ detail: `The same ${recipe.id} remedy already made no progress for this executable and constraint; it was not run again.`,
801
+ remedy: 'Reinspect after the installation or environment changes, or review a different registered remedy. Do not repeat the unchanged command.'
486
802
  };
487
803
  }
488
- if (!await managerAvailable(recipe, context.runner, { cwd: context.cwd })) {
804
+ if (!await managerAvailable(recipe, context.runner, context)) {
489
805
  return {
490
806
  requirement,
491
807
  state: 'manual',
492
- detail: `${recipe.manager} is unavailable and Liftoff does not bootstrap package managers.`,
808
+ reasonCode: 'manager-unavailable',
809
+ recipe,
810
+ detail: `${recipe.manager} is unavailable, incompatible, or unhealthy; Liftoff does not bootstrap package managers.`,
493
811
  probe: currentProbe,
494
- remedy: `Install ${recipe.manager} through its official instructions, then retry.`
812
+ remedy: `Prepare a compatible ${recipe.manager} through its official instructions, then retry.`
495
813
  };
496
814
  }
497
- const result = await context.runner.run(recipe.command, {
815
+ const result = await runToolCommand(context.runner, recipe.command, {
498
816
  cwd: context.cwd,
817
+ env: context.env,
499
818
  timeoutMs: 10 * 60_000,
819
+ maxOutputBytes: 1_048_576,
500
820
  stream: true,
501
821
  ...context.streamOptions
502
822
  });
503
- if (result.status !== 0 || result.timedOut) {
823
+ if (result.status !== 0 || result.timedOut || result.outputLimitExceeded || result.aborted || result.errorCode || result.signal) {
824
+ const attempt = { recipeId: recipe.id, inputFingerprint, outcome: 'failed' };
504
825
  return {
505
826
  requirement,
506
827
  state: 'failed',
828
+ reasonCode: 'execution-failed',
829
+ recipe,
830
+ attempt,
831
+ progress: 'indeterminate',
507
832
  detail: result.timedOut
508
833
  ? `${requirement.definition.label} installation timed out.`
509
- : result.stderr.trim().split(/\r?\n/)[0] || `Installer exited with status ${result.status}.`,
834
+ : safeDetail(result.stderr || result.errorMessage || '') || `Remedy exited with status ${result.status}.`,
510
835
  command: result.displayCommand,
511
836
  probe: currentProbe,
512
- remedy: manualRemedy(requirement, context.host)
837
+ remedy: `The ${recipe.operation} failed; no successful installation or rollback is inferred. ${manualRemedy(requirement, context.host)}`
838
+ };
839
+ }
840
+ const nextProbe = await probeRequirement(requirement, context.runner, context);
841
+ const observedProgress = compareRequirementObservations(currentProbe, nextProbe);
842
+ const outputFingerprint = attemptFingerprint(nextProbe, recipe, context);
843
+ const progress = observedProgress === 'unchanged' && inputFingerprint !== outputFingerprint ? 'changed' : observedProgress;
844
+ const attempt = {
845
+ recipeId: recipe.id,
846
+ inputFingerprint,
847
+ outputFingerprint,
848
+ outcome: progress
849
+ };
850
+ const ownHistory = [...(currentProbe.remediationAttempts ?? []), attempt];
851
+ nextProbe.remediationAttempts = ownHistory;
852
+ attemptsByRunner.set(context.runner, [...(attemptsByRunner.get(context.runner) ?? []), attempt].slice(-100));
853
+ if (context.noProgressStore && progress === 'unchanged') {
854
+ try {
855
+ await context.noProgressStore.record({ ...attempt, outcome: 'unchanged', outputFingerprint });
856
+ }
857
+ catch (error) {
858
+ return historyFailure(requirement, nextProbe, recipe, 'record', error, attempt, result.displayCommand);
859
+ }
860
+ }
861
+ if (nextProbe.state === 'ready' && nextProbe.reasonCode === 'compatible') {
862
+ return {
863
+ requirement, state: 'installed', reasonCode: 'verified', recipe, progress, attempt,
864
+ detail: `${requirement.definition.label} is ready after verification; installer file changes were not observed.`,
865
+ command: result.displayCommand, probe: nextProbe
513
866
  };
514
867
  }
515
- const nextProbe = await probeRequirement(requirement, context.runner, { cwd: context.cwd });
516
- if (nextProbe.state !== 'ready') {
517
- const foundLocations = await documentedInstallLocations(recipe, requirement, context);
868
+ const discoveryFailed = nextProbe.reasonCode === 'missing-executable' ||
869
+ (nextProbe.reasonCode === 'observation-unavailable' &&
870
+ nextProbe.observations.some((observation) => observation.command.executable === requirement.definition.probes[0]?.executable &&
871
+ observation.reasonCode === 'missing-executable'));
872
+ if (discoveryFailed) {
873
+ const discovery = await documentedInstallLocations(recipe, requirement, context);
874
+ if (discovery.complete && discovery.found.length > 0) {
875
+ return {
876
+ requirement, state: 'restart-required', reasonCode: 'executable-discovery', recipe, progress, attempt, discovery,
877
+ detail: `The remedy exited zero, but ${nextProbe.detail} Existing executable candidates were observed at ${discovery.found.map((item) => safeDetail(item.resolvedPath ?? item.executable)).join(', ')}; this does not prove the installer wrote them.`,
878
+ command: result.displayCommand, probe: nextProbe, remedy: pathRemedy(recipe, requirement)
879
+ };
880
+ }
881
+ const discoveryDetail = discovery.complete
882
+ ? 'No executable candidate was observed in the documented install locations.'
883
+ : 'Documented install locations could not be fully observed.';
518
884
  return {
519
- requirement,
520
- state: 'restart-required',
521
- detail: foundLocations.length > 0
522
- ? `The installer succeeded and wrote ${foundLocations.join(', ')}, but the command is not ready on PATH in this process.`
523
- : 'The installer succeeded, but the required command is not ready in this process after checking its documented install location.',
524
- command: result.displayCommand,
525
- probe: nextProbe,
526
- remedy: pathRemedy(recipe, requirement)
885
+ requirement, state: progress === 'unchanged' ? 'unchanged' : 'unresolved',
886
+ reasonCode: progress === 'unchanged' ? 'no-progress' : 'verification-unresolved',
887
+ recipe, progress, attempt, discovery, command: result.displayCommand, probe: nextProbe,
888
+ detail: `The remedy exited zero, but ${nextProbe.detail} ${discoveryDetail} No file changes were verified.`,
889
+ remedy: 'Inspect the installation and its documented executable location before selecting another remedy.'
527
890
  };
528
891
  }
529
892
  return {
530
- requirement,
531
- state: 'installed',
532
- detail: `${requirement.definition.label} is ready.`,
533
- command: result.displayCommand,
534
- probe: nextProbe
893
+ requirement, state: progress === 'unchanged' ? 'unchanged' : 'unresolved',
894
+ reasonCode: progress === 'unchanged' ? 'no-progress' : 'verification-unresolved',
895
+ recipe, progress, attempt, command: result.displayCommand, probe: nextProbe,
896
+ detail: `The remedy exited zero${progress === 'unchanged' ? ' with no progress' : ''}; ${nextProbe.detail} No installer file changes were verified.`,
897
+ remedy: `${nextProbe.remedy ?? 'Inspect the failed version or health probe.'} ` +
898
+ 'Review the installation origin and supported version/channel operation instead of repeating the same installer.'
535
899
  };
536
900
  }
537
901
  export function blockingReadinessFailures(results) {
538
- return results.filter((result) => result.requirement.severity === 'blocking' && result.state !== 'ready');
902
+ return results.filter((result) => result.requirement.severity === 'blocking' && (result.state !== 'ready' || result.reasonCode !== 'compatible'));
903
+ }
904
+ /** Workstation prerequisites only; provider permissions, private access, and protected state remain separate gates. */
905
+ export function workstationScopeReadiness(results, scope) {
906
+ const toolFailures = blockingReadinessFailures(results);
907
+ const authenticationFailures = [];
908
+ if (scope === 'activation' || scope === 'migration' || scope === 'lifecycle') {
909
+ for (const result of results) {
910
+ if (result.requirement.severity !== 'blocking' || result.state !== 'ready' ||
911
+ (result.requirement.id !== 'azure-cli' && result.requirement.id !== 'github-cli'))
912
+ continue;
913
+ const notice = result.notices.find((item) => item.code === 'authentication') ?? {
914
+ code: 'authentication',
915
+ label: `${result.requirement.definition.label} authentication`,
916
+ state: 'not-observable',
917
+ detail: 'The required authentication capability has not been observed.'
918
+ };
919
+ if (notice.state !== 'ready')
920
+ authenticationFailures.push({ requirementId: result.requirement.id, notice });
921
+ }
922
+ }
923
+ return { scope, ready: toolFailures.length === 0 && authenticationFailures.length === 0, toolFailures, authenticationFailures };
539
924
  }
540
925
  //# sourceMappingURL=workstation.js.map