@clossys/launcher 0.3.1 → 0.5.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 (290) hide show
  1. package/README.md +1373 -60
  2. package/contracts/conversation-contract.md +2 -2
  3. package/contracts/product-ci-workflow.yml +74 -0
  4. package/contracts/repository-inventory.json +53 -0
  5. package/dist/admission-fixture.d.ts +168 -0
  6. package/dist/admission-fixture.d.ts.map +1 -0
  7. package/dist/admission-fixture.js +467 -0
  8. package/dist/admission-fixture.js.map +1 -0
  9. package/dist/admission.d.ts +124 -0
  10. package/dist/admission.d.ts.map +1 -0
  11. package/dist/admission.js +804 -0
  12. package/dist/admission.js.map +1 -0
  13. package/dist/agents-guide.d.ts +9 -0
  14. package/dist/agents-guide.d.ts.map +1 -0
  15. package/dist/agents-guide.js +26 -0
  16. package/dist/agents-guide.js.map +1 -0
  17. package/dist/apply-command-options.check.d.ts +12 -0
  18. package/dist/apply-command-options.check.d.ts.map +1 -0
  19. package/dist/apply-command-options.check.js +20 -0
  20. package/dist/apply-command-options.check.js.map +1 -0
  21. package/dist/apply-plan-cli.d.ts +39 -1
  22. package/dist/apply-plan-cli.d.ts.map +1 -1
  23. package/dist/apply-plan-cli.js +432 -15
  24. package/dist/apply-plan-cli.js.map +1 -1
  25. package/dist/apply-plan.d.ts +46 -59
  26. package/dist/apply-plan.d.ts.map +1 -1
  27. package/dist/apply-plan.js +112 -97
  28. package/dist/apply-plan.js.map +1 -1
  29. package/dist/apply-step-fixture.d.ts +87 -0
  30. package/dist/apply-step-fixture.d.ts.map +1 -0
  31. package/dist/apply-step-fixture.js +199 -0
  32. package/dist/apply-step-fixture.js.map +1 -0
  33. package/dist/apply-store.d.ts +93 -0
  34. package/dist/apply-store.d.ts.map +1 -0
  35. package/dist/apply-store.js +625 -0
  36. package/dist/apply-store.js.map +1 -0
  37. package/dist/approval-sheet.d.ts +21 -0
  38. package/dist/approval-sheet.d.ts.map +1 -0
  39. package/dist/approval-sheet.js +163 -0
  40. package/dist/approval-sheet.js.map +1 -0
  41. package/dist/body-command.d.ts +42 -0
  42. package/dist/body-command.d.ts.map +1 -0
  43. package/dist/body-command.js +143 -0
  44. package/dist/body-command.js.map +1 -0
  45. package/dist/change-set-contract.d.ts +403 -0
  46. package/dist/change-set-contract.d.ts.map +1 -0
  47. package/dist/change-set-contract.js +781 -0
  48. package/dist/change-set-contract.js.map +1 -0
  49. package/dist/change-set-digest.d.ts +28 -0
  50. package/dist/change-set-digest.d.ts.map +1 -0
  51. package/dist/change-set-digest.js +65 -0
  52. package/dist/change-set-digest.js.map +1 -0
  53. package/dist/check-cli.d.ts.map +1 -1
  54. package/dist/check-cli.js +14 -3
  55. package/dist/check-cli.js.map +1 -1
  56. package/dist/cli.d.ts +17 -6
  57. package/dist/cli.d.ts.map +1 -1
  58. package/dist/cli.js +84 -23
  59. package/dist/cli.js.map +1 -1
  60. package/dist/core.d.ts +79 -22
  61. package/dist/core.d.ts.map +1 -1
  62. package/dist/core.js +843 -268
  63. package/dist/core.js.map +1 -1
  64. package/dist/dry-materialize.d.ts +63 -0
  65. package/dist/dry-materialize.d.ts.map +1 -0
  66. package/dist/dry-materialize.js +330 -0
  67. package/dist/dry-materialize.js.map +1 -0
  68. package/dist/existing-declaration-adoption.check.d.ts +2 -0
  69. package/dist/existing-declaration-adoption.check.d.ts.map +1 -0
  70. package/dist/existing-declaration-adoption.check.js +10 -0
  71. package/dist/existing-declaration-adoption.check.js.map +1 -0
  72. package/dist/generated/contract-schema.generated.d.ts +97 -0
  73. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  74. package/dist/generated/contract-schema.generated.js +496 -0
  75. package/dist/generated/contract-schema.generated.js.map +1 -0
  76. package/dist/generated/package-scope.generated.d.ts +6 -0
  77. package/dist/generated/package-scope.generated.d.ts.map +1 -0
  78. package/dist/generated/package-scope.generated.js +10 -0
  79. package/dist/generated/package-scope.generated.js.map +1 -0
  80. package/dist/generated/plan-contracts.generated.d.ts +3 -0
  81. package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
  82. package/dist/generated/plan-contracts.generated.js +3101 -0
  83. package/dist/generated/plan-contracts.generated.js.map +1 -0
  84. package/dist/host.d.ts.map +1 -1
  85. package/dist/host.js +11 -0
  86. package/dist/host.js.map +1 -1
  87. package/dist/identity.d.ts +15 -0
  88. package/dist/identity.d.ts.map +1 -0
  89. package/dist/identity.js +48 -0
  90. package/dist/identity.js.map +1 -0
  91. package/dist/index.d.ts +35 -5
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +19 -2
  94. package/dist/index.js.map +1 -1
  95. package/dist/inventory-adoption.d.ts +24 -5
  96. package/dist/inventory-adoption.d.ts.map +1 -1
  97. package/dist/inventory-adoption.js +70 -25
  98. package/dist/inventory-adoption.js.map +1 -1
  99. package/dist/inventory-choice.d.ts +40 -0
  100. package/dist/inventory-choice.d.ts.map +1 -0
  101. package/dist/inventory-choice.js +156 -0
  102. package/dist/inventory-choice.js.map +1 -0
  103. package/dist/inventory-contract.d.ts +89 -0
  104. package/dist/inventory-contract.d.ts.map +1 -0
  105. package/dist/inventory-contract.js +121 -0
  106. package/dist/inventory-contract.js.map +1 -0
  107. package/dist/key-editor.d.ts +30 -0
  108. package/dist/key-editor.d.ts.map +1 -0
  109. package/dist/key-editor.js +445 -0
  110. package/dist/key-editor.js.map +1 -0
  111. package/dist/ledger-contract.d.ts +190 -0
  112. package/dist/ledger-contract.d.ts.map +1 -0
  113. package/dist/ledger-contract.js +555 -0
  114. package/dist/ledger-contract.js.map +1 -0
  115. package/dist/ledger-trust.d.ts +90 -0
  116. package/dist/ledger-trust.d.ts.map +1 -0
  117. package/dist/ledger-trust.js +203 -0
  118. package/dist/ledger-trust.js.map +1 -0
  119. package/dist/lockfile-invariants.d.ts +48 -0
  120. package/dist/lockfile-invariants.d.ts.map +1 -0
  121. package/dist/lockfile-invariants.js +375 -0
  122. package/dist/lockfile-invariants.js.map +1 -0
  123. package/dist/lockfile-readers.d.ts +72 -0
  124. package/dist/lockfile-readers.d.ts.map +1 -0
  125. package/dist/lockfile-readers.js +713 -0
  126. package/dist/lockfile-readers.js.map +1 -0
  127. package/dist/lockfile-regen.d.ts +106 -0
  128. package/dist/lockfile-regen.d.ts.map +1 -0
  129. package/dist/lockfile-regen.js +760 -0
  130. package/dist/lockfile-regen.js.map +1 -0
  131. package/dist/lockfile-tool-env.d.ts +29 -0
  132. package/dist/lockfile-tool-env.d.ts.map +1 -0
  133. package/dist/lockfile-tool-env.js +111 -0
  134. package/dist/lockfile-tool-env.js.map +1 -0
  135. package/dist/materialize.d.ts +113 -0
  136. package/dist/materialize.d.ts.map +1 -0
  137. package/dist/materialize.js +881 -0
  138. package/dist/materialize.js.map +1 -0
  139. package/dist/observe-repository.d.ts +90 -0
  140. package/dist/observe-repository.d.ts.map +1 -0
  141. package/dist/observe-repository.js +1367 -0
  142. package/dist/observe-repository.js.map +1 -0
  143. package/dist/plan-bundle-setup-fixture.d.ts +68 -0
  144. package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
  145. package/dist/plan-bundle-setup-fixture.js +167 -0
  146. package/dist/plan-bundle-setup-fixture.js.map +1 -0
  147. package/dist/plan-bundle.d.ts +256 -0
  148. package/dist/plan-bundle.d.ts.map +1 -0
  149. package/dist/plan-bundle.js +882 -0
  150. package/dist/plan-bundle.js.map +1 -0
  151. package/dist/plan-command.d.ts +29 -0
  152. package/dist/plan-command.d.ts.map +1 -0
  153. package/dist/plan-command.js +523 -0
  154. package/dist/plan-command.js.map +1 -0
  155. package/dist/plan-contract.d.ts +153 -0
  156. package/dist/plan-contract.d.ts.map +1 -0
  157. package/dist/plan-contract.js +61 -0
  158. package/dist/plan-contract.js.map +1 -0
  159. package/dist/plan-digest.d.ts +25 -0
  160. package/dist/plan-digest.d.ts.map +1 -0
  161. package/dist/plan-digest.js +106 -0
  162. package/dist/plan-digest.js.map +1 -0
  163. package/dist/plan-rules.d.ts +23 -0
  164. package/dist/plan-rules.d.ts.map +1 -0
  165. package/dist/plan-rules.js +177 -0
  166. package/dist/plan-rules.js.map +1 -0
  167. package/dist/planned-bundle.d.ts +20 -0
  168. package/dist/planned-bundle.d.ts.map +1 -0
  169. package/dist/planned-bundle.js +191 -0
  170. package/dist/planned-bundle.js.map +1 -0
  171. package/dist/product-repository.d.ts +4 -0
  172. package/dist/product-repository.d.ts.map +1 -1
  173. package/dist/product-repository.js +9 -1
  174. package/dist/product-repository.js.map +1 -1
  175. package/dist/provenance-gate.d.ts +48 -0
  176. package/dist/provenance-gate.d.ts.map +1 -0
  177. package/dist/provenance-gate.js +324 -0
  178. package/dist/provenance-gate.js.map +1 -0
  179. package/dist/pull-request-body.d.ts +45 -0
  180. package/dist/pull-request-body.d.ts.map +1 -0
  181. package/dist/pull-request-body.js +232 -0
  182. package/dist/pull-request-body.js.map +1 -0
  183. package/dist/registry-snapshot.d.ts +141 -0
  184. package/dist/registry-snapshot.d.ts.map +1 -0
  185. package/dist/registry-snapshot.js +483 -0
  186. package/dist/registry-snapshot.js.map +1 -0
  187. package/dist/release-age-edit.d.ts +52 -0
  188. package/dist/release-age-edit.d.ts.map +1 -0
  189. package/dist/release-age-edit.js +413 -0
  190. package/dist/release-age-edit.js.map +1 -0
  191. package/dist/root-entries.d.ts +36 -0
  192. package/dist/root-entries.d.ts.map +1 -0
  193. package/dist/root-entries.js +80 -0
  194. package/dist/root-entries.js.map +1 -0
  195. package/dist/setup-template-scripts.d.ts +36 -0
  196. package/dist/setup-template-scripts.d.ts.map +1 -0
  197. package/dist/setup-template-scripts.js +568 -0
  198. package/dist/setup-template-scripts.js.map +1 -0
  199. package/dist/setup-templates.d.ts +55 -0
  200. package/dist/setup-templates.d.ts.map +1 -0
  201. package/dist/setup-templates.js +438 -0
  202. package/dist/setup-templates.js.map +1 -0
  203. package/dist/skills.d.ts +34 -1
  204. package/dist/skills.d.ts.map +1 -1
  205. package/dist/skills.js +129 -17
  206. package/dist/skills.js.map +1 -1
  207. package/dist/status.d.ts +63 -0
  208. package/dist/status.d.ts.map +1 -0
  209. package/dist/status.js +539 -0
  210. package/dist/status.js.map +1 -0
  211. package/dist/types.d.ts +151 -13
  212. package/dist/types.d.ts.map +1 -1
  213. package/package.json +4 -4
  214. package/skeleton/README.md +14 -9
  215. package/skeleton/package.json +2 -1
  216. package/skill/SKILL.md +23 -7
  217. package/skill-catalogue/advisor/SKILL.md +59 -6
  218. package/skill-catalogue/architect/SKILL.md +2 -2
  219. package/skill-catalogue/bouncer/SKILL.md +2 -2
  220. package/skill-catalogue/builder/SKILL.md +2 -2
  221. package/skill-catalogue/butler/SKILL.md +2 -2
  222. package/skill-catalogue/controller/SKILL.md +2 -2
  223. package/skill-catalogue/customer/SKILL.md +2 -2
  224. package/skill-catalogue/designer/SKILL.md +4 -2
  225. package/skill-catalogue/giver/SKILL.md +2 -2
  226. package/skill-catalogue/influencer/SKILL.md +2 -2
  227. package/skill-catalogue/inspector/SKILL.md +2 -2
  228. package/skill-catalogue/integrator/SKILL.md +2 -2
  229. package/skill-catalogue/keeper/SKILL.md +2 -2
  230. package/skill-catalogue/launcher/SKILL.md +23 -7
  231. package/skill-catalogue/locksmith/SKILL.md +2 -2
  232. package/skill-catalogue/messenger/SKILL.md +2 -2
  233. package/skill-catalogue/observer/SKILL.md +2 -2
  234. package/skill-catalogue/publisher/SKILL.md +2 -2
  235. package/skill-catalogue/starter/SKILL.md +3 -2
  236. package/skill-catalogue/strategist/SKILL.md +12 -4
  237. package/skill-catalogue/writer/SKILL.md +2 -2
  238. package/src/admission-fixture.ts +585 -0
  239. package/src/admission.ts +819 -0
  240. package/src/agents-guide.ts +29 -0
  241. package/src/apply-command-options.check.ts +27 -0
  242. package/src/apply-plan-cli.ts +454 -14
  243. package/src/apply-plan.ts +112 -124
  244. package/src/apply-step-fixture.ts +236 -0
  245. package/src/apply-store.ts +584 -0
  246. package/src/approval-sheet.ts +170 -0
  247. package/src/body-command.ts +162 -0
  248. package/src/change-set-contract.ts +987 -0
  249. package/src/change-set-digest.ts +70 -0
  250. package/src/check-cli.ts +14 -3
  251. package/src/cli.ts +90 -22
  252. package/src/core.ts +973 -275
  253. package/src/dry-materialize.ts +353 -0
  254. package/src/existing-declaration-adoption.check.ts +12 -0
  255. package/src/generated/contract-schema.generated.ts +520 -0
  256. package/src/generated/package-scope.generated.ts +10 -0
  257. package/src/generated/plan-contracts.generated.ts +3101 -0
  258. package/src/host.ts +10 -0
  259. package/src/identity.ts +51 -0
  260. package/src/index.ts +74 -3
  261. package/src/inventory-adoption.ts +107 -29
  262. package/src/inventory-choice.ts +172 -0
  263. package/src/inventory-contract.ts +166 -0
  264. package/src/key-editor.ts +446 -0
  265. package/src/ledger-contract.ts +660 -0
  266. package/src/ledger-trust.ts +272 -0
  267. package/src/lockfile-invariants.ts +421 -0
  268. package/src/lockfile-readers.ts +749 -0
  269. package/src/lockfile-regen.ts +851 -0
  270. package/src/lockfile-tool-env.ts +131 -0
  271. package/src/materialize.ts +915 -0
  272. package/src/observe-repository.ts +1365 -0
  273. package/src/plan-bundle-setup-fixture.ts +200 -0
  274. package/src/plan-bundle.ts +1014 -0
  275. package/src/plan-command.ts +532 -0
  276. package/src/plan-contract.ts +179 -0
  277. package/src/plan-digest.ts +102 -0
  278. package/src/plan-rules.ts +188 -0
  279. package/src/planned-bundle.ts +211 -0
  280. package/src/product-repository.ts +10 -1
  281. package/src/provenance-gate.ts +352 -0
  282. package/src/pull-request-body.ts +261 -0
  283. package/src/registry-snapshot.ts +534 -0
  284. package/src/release-age-edit.ts +430 -0
  285. package/src/root-entries.ts +81 -0
  286. package/src/setup-template-scripts.ts +580 -0
  287. package/src/setup-templates.ts +479 -0
  288. package/src/skills.ts +161 -18
  289. package/src/status.ts +557 -0
  290. package/src/types.ts +148 -13
package/src/apply-plan.ts CHANGED
@@ -1,155 +1,128 @@
1
1
  // Apply an approved plan (#1178): writes clossys/brief.json into a staffed
2
- // repository from the exact EngagementBrief shape and plan.json contract
3
- // recorded on issue #1175 ("Plan file contract", posted 2026-09-22).
2
+ // repository once clossys/advisor/plan.json is approved, both validated
3
+ // against the shared plan and brief contracts (#1175, #1475).
4
4
  //
5
5
  // SCOPE OF THIS MODULE (see the wave-2 PR body for the full explanation):
6
6
  // this lands the mechanical, auditable core the landed contract fully
7
7
  // specifies -- reading clossys/advisor/plan.json, confirming it is
8
8
  // approved, validating an EngagementBrief, and writing clossys/brief.json
9
- // byte-identically. Multi-repository orchestration (branch creation, exact
10
- // package installs, Starter's caller workflow, opening one pull request
11
- // per repository) is deferred: the landed contract does not yet specify
12
- // how a plan's approved roles map to inventory repository ids or to
13
- // install/remove/relocate work items, and building that mapping now would
14
- // mean inventing an interface Advisor's still-open PR (#1193) might define
15
- // differently.
9
+ // byte-identically. The plan contract now says which roles work in which
10
+ // inventory repository (`staffing`) and which exact package acts are
11
+ // authorized (`packages`), and approvedSubject() below says what an
12
+ // approval binds (#1178). Multi-repository orchestration (computing each
13
+ // repository's change, branch creation, exact package installs, Starter's
14
+ // caller workflow, opening one pull request per repository) is not built
15
+ // yet, and this module does not act on those fields.
16
16
  //
17
17
  // clossys/brief.json's shape is NOT re-derived here -- @clossys/advisor's
18
- // EngagementBrief export (landing in #1193) is the one owner of that
19
- // computation. This module receives an already-computed brief (as a file
20
- // path today; a direct call once #1193 lands and a caller can import the
21
- // package) and only validates its shape and writes it, exactly the split
22
- // #1187's governing principle draws between package-owned definition and
23
- // judgment versus Launcher's deterministic mechanics.
18
+ // toEngagementBrief() is the one owner of that computation. This module
19
+ // receives an already-computed brief and only validates it and writes it,
20
+ // exactly the split #1187's governing principle draws between
21
+ // package-owned definition and judgment versus Launcher's deterministic
22
+ // mechanics.
23
+ //
24
+ // VALIDATION (issue #1475): see plan-contract.ts. There is no second,
25
+ // hand-written shape check here to drift from Advisor's: a plan or brief
26
+ // Advisor accepts, Launcher accepts, and every object in them is closed, so
27
+ // an unknown field is refused. This package still has no runtime
28
+ // dependency on @clossys/advisor.
24
29
 
30
+ import { validateAdvisorPlan, validateEngagementBrief } from "./plan-contract.js";
31
+ import type { AdvisorPlan, EngagementBrief, PlanDecision } from "./plan-contract.js";
32
+ import { planDigest } from "./plan-digest.js";
25
33
  import type { WorkspaceHost } from "./types.js";
26
34
 
27
- export interface EngagementBriefRole {
28
- readonly role: string;
29
- readonly why: string;
30
- readonly goal: { readonly metric: string; readonly direction: "increase" | "decrease" };
31
- readonly inputsFrom: readonly string[];
32
- readonly outputsTo: readonly string[];
33
- }
34
-
35
- export interface EngagementBrief {
36
- readonly schemaVersion: 1;
37
- readonly problem: string;
38
- readonly roles: readonly EngagementBriefRole[];
39
- readonly sequence: readonly string[];
40
- readonly deliverables: readonly string[];
41
- }
42
-
43
- export type BlockerKind = "missing-input" | "missing-authority" | "failing-evidence" | "unavailable-environment" | "contradiction";
44
-
45
- export interface PlanBlocker {
46
- readonly kind: BlockerKind;
47
- readonly description: string;
48
- readonly owner: string;
49
- readonly dueDate?: string;
50
- }
51
-
52
- export interface PlanDecision {
53
- readonly at: string;
54
- readonly recommended: string;
55
- readonly chosen: string;
56
- readonly by: string;
57
- }
58
-
59
- export interface AdvisorPlan {
60
- readonly schemaVersion: 1;
61
- readonly asOf: string;
62
- readonly mandate: { readonly problem: string; readonly primaryProblemId: string; readonly roles: readonly string[] };
63
- readonly whereWeAre: readonly string[];
64
- readonly recommendedNext: { readonly action: string; readonly owner: string; readonly due: string } | null;
65
- readonly decisions: readonly PlanDecision[];
66
- readonly blockers: readonly PlanBlocker[];
67
- }
35
+ export { validateAdvisorPlan, validateEngagementBrief } from "./plan-contract.js";
36
+ export type {
37
+ AdvisorPlan, BlockerKind, EngagementBrief, EngagementBriefRole, EngagementContext, EngagementContextField, EngagementContextFieldId, GoalDirection, PlanBlocker, PlanDecision,
38
+ PlanKit, PlanPackageAct, PlanStaffing, ValidationResult,
39
+ } from "./plan-contract.js";
68
40
 
69
- export type ValidationResult = { readonly valid: true } | { readonly valid: false; readonly reason: string };
70
-
71
- function isRecord(value: unknown): value is Record<string, unknown> {
72
- return typeof value === "object" && value !== null && !Array.isArray(value);
73
- }
74
- function nonEmptyString(value: unknown): value is string {
75
- return typeof value === "string" && value.trim() !== "";
76
- }
77
- function stringArray(value: unknown): value is string[] {
78
- return Array.isArray(value) && value.every((item) => typeof item === "string");
79
- }
80
-
81
- /** Validates an EngagementBrief's shape exactly against the #1175 contract. Never mutates, never re-derives content. */
82
- export function validateEngagementBrief(value: unknown): ValidationResult {
83
- if (!isRecord(value)) return { valid: false, reason: "brief must be an object" };
84
- if (value.schemaVersion !== 1) return { valid: false, reason: "brief.schemaVersion must be 1" };
85
- if (!nonEmptyString(value.problem)) return { valid: false, reason: "brief.problem must be a non-empty string" };
86
- if (!Array.isArray(value.roles) || value.roles.length === 0) return { valid: false, reason: "brief.roles must be a non-empty array" };
87
- for (const [index, role] of value.roles.entries()) {
88
- if (!isRecord(role)) return { valid: false, reason: `brief.roles[${index}] must be an object` };
89
- if (!nonEmptyString(role.role)) return { valid: false, reason: `brief.roles[${index}].role must be a non-empty string` };
90
- if (!nonEmptyString(role.why)) return { valid: false, reason: `brief.roles[${index}].why must be a non-empty string` };
91
- if (!isRecord(role.goal) || !nonEmptyString(role.goal.metric) || (role.goal.direction !== "increase" && role.goal.direction !== "decrease")) {
92
- return { valid: false, reason: `brief.roles[${index}].goal must have a metric and a direction of increase or decrease` };
93
- }
94
- if (!stringArray(role.inputsFrom)) return { valid: false, reason: `brief.roles[${index}].inputsFrom must be a string array` };
95
- if (!stringArray(role.outputsTo)) return { valid: false, reason: `brief.roles[${index}].outputsTo must be a string array` };
96
- }
97
- if (!stringArray(value.sequence) || value.sequence.length === 0) return { valid: false, reason: "brief.sequence must be a non-empty string array" };
98
- if (!stringArray(value.deliverables)) return { valid: false, reason: "brief.deliverables must be a string array" };
99
- return { valid: true };
41
+ /**
42
+ * The decisions made at the latest instant, by `at` -- never by array
43
+ * position -- or null when there are none, or when any decision's `at` does
44
+ * not parse to a finite time (the plan contract already refuses one; this
45
+ * does not rely on that), because then time cannot say which is latest.
46
+ */
47
+ function latestDecisions(plan: AdvisorPlan): readonly PlanDecision[] | null {
48
+ if (plan.decisions.length === 0) return null;
49
+ const times = plan.decisions.map((decision) => Date.parse(decision.at));
50
+ if (!times.every(Number.isFinite)) return null;
51
+ // A reduce, not Math.max(...times): spreading a very long list into
52
+ // arguments throws a RangeError.
53
+ const latest = times.reduce((highest, time) => (time > highest ? time : highest), Number.NEGATIVE_INFINITY);
54
+ return plan.decisions.filter((_, index) => times[index] === latest);
100
55
  }
101
56
 
102
- const BLOCKER_KINDS = new Set<BlockerKind>(["missing-input", "missing-authority", "failing-evidence", "unavailable-environment", "contradiction"]);
57
+ const SUBJECT_DIGEST = /^sha256:[0-9a-f]{64}$/;
103
58
 
104
- /** Validates an AdvisorPlan's shape exactly against the #1175 contract. */
105
- export function validateAdvisorPlan(value: unknown): ValidationResult {
106
- if (!isRecord(value)) return { valid: false, reason: "plan must be an object" };
107
- if (value.schemaVersion !== 1) return { valid: false, reason: "plan.schemaVersion must be 1" };
108
- if (!nonEmptyString(value.asOf)) return { valid: false, reason: "plan.asOf must be a non-empty string" };
109
- if (!isRecord(value.mandate) || !nonEmptyString(value.mandate.problem) || !nonEmptyString(value.mandate.primaryProblemId) || !stringArray(value.mandate.roles)) {
110
- return { valid: false, reason: "plan.mandate must have problem, primaryProblemId, and a roles string array" };
111
- }
112
- if (!stringArray(value.whereWeAre)) return { valid: false, reason: "plan.whereWeAre must be a string array" };
113
- if (value.recommendedNext !== null) {
114
- if (!isRecord(value.recommendedNext) || !nonEmptyString(value.recommendedNext.action) || !nonEmptyString(value.recommendedNext.owner) || !nonEmptyString(value.recommendedNext.due)) {
115
- return { valid: false, reason: "plan.recommendedNext must be null or have action, owner, and due" };
116
- }
117
- }
118
- if (!Array.isArray(value.decisions)) return { valid: false, reason: "plan.decisions must be an array" };
119
- for (const [index, decision] of value.decisions.entries()) {
120
- if (!isRecord(decision) || !nonEmptyString(decision.at) || !nonEmptyString(decision.recommended) || !nonEmptyString(decision.chosen) || !nonEmptyString(decision.by)) {
121
- return { valid: false, reason: `plan.decisions[${index}] must have at, recommended, chosen, and by` };
122
- }
123
- }
124
- if (!Array.isArray(value.blockers)) return { valid: false, reason: "plan.blockers must be an array" };
125
- for (const [index, blocker] of value.blockers.entries()) {
126
- if (!isRecord(blocker) || !BLOCKER_KINDS.has(blocker.kind as BlockerKind) || !nonEmptyString(blocker.description) || !nonEmptyString(blocker.owner)) {
127
- return { valid: false, reason: `plan.blockers[${index}] must have a valid kind, description, and owner` };
128
- }
129
- }
130
- return { valid: true };
59
+ /**
60
+ * What an approval binds (#1178): the `subjectDigest` of the plan's latest
61
+ * decision, by `at`, when that decision has chosen "approved" -- the digest of
62
+ * the exact change the approver was shown. Otherwise null, and null binds
63
+ * nothing. Fails closed:
64
+ *
65
+ * - a plan that does not validate against the plan contract, rules
66
+ * included: null. It checks this itself rather than trusting its caller,
67
+ * because on an unchecked plan a time with no offset parses as local time,
68
+ * so the machine's time zone could decide which decision is latest;
69
+ * - no decisions, or any decision time that does not parse: null;
70
+ * - a latest decision that is not "approved": null;
71
+ * - an approval with no `subjectDigest`, or one that is not a sha256 digest:
72
+ * null -- an approval that names no bytes approves no bytes;
73
+ * - several decisions at the latest instant: their subject only when every
74
+ * one of them is "approved" with the same `subjectDigest`, else null.
75
+ *
76
+ * It says what was approved, not whether that is what is about to be applied:
77
+ * the caller recomputes the digest of the change it holds and refuses unless
78
+ * the two are equal.
79
+ */
80
+ export function approvedSubject(plan: AdvisorPlan): string | null {
81
+ if (!validateAdvisorPlan(plan).valid) return null;
82
+ const latest = latestDecisions(plan);
83
+ if (latest === null || !latest.every((decision) => decision.chosen === "approved")) return null;
84
+ const subject = latest[0]!.subjectDigest;
85
+ if (typeof subject !== "string" || !SUBJECT_DIGEST.test(subject)) return null;
86
+ return latest.every((decision) => decision.subjectDigest === subject) ? subject : null;
131
87
  }
132
88
 
133
89
  /**
134
90
  * The plan is approved when its most recent decision (by `at`) records
135
91
  * chosen === "approved". No decisions, or a most-recent decision that
136
92
  * isn't "approved", is not approved -- this never assumes approval from
137
- * absence.
93
+ * absence. Fails closed on anything that would let array order decide
94
+ * instead of time: a decision whose `at` does not parse to a finite time
95
+ * (the plan contract already refuses one; this does not rely on that), or
96
+ * two decisions at the same latest instant that do not all say "approved".
97
+ *
98
+ * It binds no bytes (#1178): it does not look at `subjectDigest`, so it is
99
+ * true for an approval that names no change at all. It says only that the
100
+ * latest decision is an approval. Anything that applies a plan must use
101
+ * `approvedSubject()` instead, and compare the subject it returns with the
102
+ * digest of the change it holds. The one stated exception is the legacy
103
+ * brief-only path, `applyEngagementBrief()`, which predates the binding.
138
104
  */
139
105
  export function isPlanApproved(plan: AdvisorPlan): boolean {
140
- if (plan.decisions.length === 0) return false;
141
- const mostRecent = [...plan.decisions].sort((left, right) => Date.parse(left.at) - Date.parse(right.at)).at(-1);
142
- return mostRecent?.chosen === "approved";
106
+ const latest = latestDecisions(plan);
107
+ return latest !== null && latest.every((decision) => decision.chosen === "approved");
143
108
  }
144
109
 
145
110
  export type ApplyBriefResult =
146
- | { readonly state: "applied"; readonly path: string }
111
+ | { readonly state: "applied"; readonly path: string; readonly planDigest: string }
147
112
  | { readonly state: "refused"; readonly reason: string };
148
113
 
149
114
  /**
150
115
  * Writes clossys/brief.json into `repositoryDirectory`, byte-identically
151
116
  * from the validated brief -- never re-authors its prose. Refuses (does
152
- * not write) unless both the plan is approved and the brief validates.
117
+ * not write) unless the plan validates and is approved and the brief,
118
+ * including its context snapshot, validates. Reports the canonical digest
119
+ * of the plan it applied.
120
+ *
121
+ * LEGACY (#1178): this path predates the approval binding. It accepts an
122
+ * approval whether or not it carries a `subjectDigest` and checks no
123
+ * binding (it uses `isPlanApproved()`), exactly as before; it is kept
124
+ * working, not extended. Anything that must know what an approval binds uses
125
+ * `approvedSubject()`.
153
126
  */
154
127
  export function applyEngagementBrief(
155
128
  host: WorkspaceHost,
@@ -158,15 +131,30 @@ export function applyEngagementBrief(
158
131
  brief: EngagementBrief,
159
132
  briefRelPath: string,
160
133
  ): ApplyBriefResult {
134
+ const planValidation = validateAdvisorPlan(plan);
135
+ if (!planValidation.valid) {
136
+ return { state: "refused", reason: `plan does not validate: ${planValidation.reason}` };
137
+ }
161
138
  if (!isPlanApproved(plan)) {
162
- return { state: "refused", reason: "the plan's most recent decision is not \"approved\"" };
139
+ return { state: "refused", reason: "the plan's most recent decision is not \"approved\", or decisions made at that same time disagree" };
163
140
  }
164
141
  const validation = validateEngagementBrief(brief);
165
142
  if (!validation.valid) {
166
143
  return { state: "refused", reason: `brief does not validate: ${validation.reason}` };
167
144
  }
145
+ // Everything that can refuse runs before the first write, so a refusal
146
+ // never leaves a brief behind. A plan that validates always has a digest
147
+ // (the contract refuses what canonical JSON cannot carry); this catch is
148
+ // the backstop that keeps that a refusal rather than a throw after writing.
149
+ let digest: string;
150
+ try {
151
+ digest = planDigest(plan);
152
+ } catch (cause) {
153
+ return { state: "refused", reason: `plan has no canonical digest: ${cause instanceof Error ? cause.message : String(cause)}` };
154
+ }
155
+ const contents = `${JSON.stringify(brief, null, 2)}\n`;
168
156
  const path = `${repositoryDirectory}/${briefRelPath}`;
169
157
  host.mkdirp(path.slice(0, path.lastIndexOf("/")));
170
- host.writeText(path, `${JSON.stringify(brief, null, 2)}\n`);
171
- return { state: "applied", path };
158
+ host.writeText(path, contents);
159
+ return { state: "applied", path, planDigest: digest };
172
160
  }
@@ -0,0 +1,236 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import {
7
+ SITE_ID,
8
+ approvedPlan,
9
+ basePlan,
10
+ buildWorld,
11
+ bundleOf,
12
+ committedPlanPackages,
13
+ hubRepo,
14
+ lockfileText,
15
+ reseal,
16
+ siteRepo,
17
+ } from "./admission-fixture.js";
18
+ import type { HubOptions, World, WorldOptions } from "./admission-fixture.js";
19
+ import { contentDigest, discoveryLinkRole, discoveryLinkTarget } from "./change-set-contract.js";
20
+ import type { ApplyBundle, RepositoryChangeSet } from "./change-set-contract.js";
21
+ import type { LockfileSpawn } from "./lockfile-regen.js";
22
+ import type { AdvisorPlan } from "./plan-contract.js";
23
+ import { planDigest } from "./plan-digest.js";
24
+
25
+ export { reseal };
26
+
27
+ const REPO = new URL("../../../", import.meta.url);
28
+ const read = (path: string): string => readFileSync(new URL(path, REPO), "utf8");
29
+
30
+ type Loose = Record<string, any>;
31
+
32
+ const gitEnv = {
33
+ ...process.env,
34
+ PATH: process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
35
+ GIT_CONFIG_GLOBAL: "/dev/null",
36
+ GIT_CONFIG_NOSYSTEM: "1",
37
+ GIT_AUTHOR_NAME: "Example Author",
38
+ GIT_AUTHOR_EMAIL: "author@example.com",
39
+ GIT_COMMITTER_NAME: "Example Author",
40
+ GIT_COMMITTER_EMAIL: "author@example.com",
41
+ };
42
+
43
+ function git(cwd: string, ...args: string[]): string {
44
+ return execFileSync("git", ["-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", ...args], {
45
+ cwd,
46
+ env: gitEnv,
47
+ encoding: "utf8",
48
+ stdio: ["ignore", "pipe", "pipe"],
49
+ });
50
+ }
51
+
52
+ export interface MaterializedFixtureOptions {
53
+ /** Overrides for the hub: options, or a function of what the fixture built (its plan, its approved bundle and its set). */
54
+ readonly hub?: Partial<HubOptions> | ((built: { plan: AdvisorPlan; bundle: ApplyBundle; set: RepositoryChangeSet }) => Partial<HubOptions>);
55
+ /** Store the set, with its whole-file texts, in the hub's change-set store, where the command line finds it. Default false. */
56
+ readonly storeSet?: boolean;
57
+ }
58
+
59
+ /**
60
+ * A setup change set whose whole-file bytes are known, with the pin already
61
+ * satisfied and no lockfile to regenerate, checked out from a local clone, and
62
+ * a hub that approved it: a git repository whose HEAD holds a plan (its latest
63
+ * decision approves the bundle that holds the set) and an execution
64
+ * authorization, with the bundle stored and the hub's readiness executable
65
+ * installed. `binding` is the binding admission is expected to compute; it is
66
+ * never an input to materialize or verify.
67
+ */
68
+ export function buildMaterializedFixture(roots: string[], options: MaterializedFixtureOptions = {}) {
69
+ const parent = mkdtempSync(join(tmpdir(), "launcher-apply-step-"));
70
+ roots.push(parent);
71
+ const origin = join(parent, "origin.git");
72
+ const clone = join(parent, "site");
73
+ mkdirSync(clone, { recursive: true });
74
+ git(clone, "init", "-b", "main");
75
+ git(clone, "config", "core.autocrlf", "false");
76
+ writeFileSync(join(clone, "README.md"), "# Site\n");
77
+ git(clone, "add", "README.md");
78
+ git(clone, "commit", "-m", "init");
79
+ execFileSync("git", ["init", "--bare", origin], { env: gitEnv, stdio: "ignore" });
80
+ git(clone, "remote", "add", "origin", origin);
81
+ git(clone, "push", "-u", "origin", "HEAD");
82
+ const baseCommit = git(clone, "rev-parse", "HEAD").trim();
83
+
84
+ const corpus = JSON.parse(read("docs/contracts/apply-change-set-digest.fixture.json")) as {
85
+ changeSets: { name: string; changeSet: RepositoryChangeSet }[];
86
+ };
87
+ const set = structuredClone(corpus.changeSets.find((entry) => entry.name === "setup-site")!.changeSet) as unknown as Loose;
88
+ set.repository.baseCommit = baseCommit;
89
+ set.keys = [];
90
+ set.deferred = [];
91
+ set.files = (set.files as Loose[]).filter((file) => file.path !== "package-lock.json");
92
+ for (const item of set.items as Loose[]) {
93
+ if (item.act === "pin-starter" || item.act === "install") item.satisfiedInBase = true;
94
+ }
95
+ const plan0 = basePlan() as unknown as AdvisorPlan;
96
+ set.planDigest = planDigest(plan0);
97
+ const texts: Record<string, string> = {};
98
+ for (const file of set.files as Loose[]) {
99
+ if (file.derived === true) continue;
100
+ if (file.mode === "120000") {
101
+ const role = discoveryLinkRole(file.path as string);
102
+ const target = discoveryLinkTarget(role!);
103
+ texts[file.path as string] = target;
104
+ file.before = null;
105
+ file.after = contentDigest(target);
106
+ } else {
107
+ const text = `fixture ${file.path}\n`;
108
+ texts[file.path as string] = text;
109
+ file.before = null;
110
+ file.after = contentDigest(text);
111
+ }
112
+ }
113
+ const sealed = reseal(set);
114
+ const bundle = bundleOf(plan0, [{ id: SITE_ID, set: sealed }]);
115
+ const built = { ...sealed, bundle: bundle.bundleDigest } as RepositoryChangeSet;
116
+ const plan = approvedPlan(bundle.bundleDigest, plan0);
117
+ const override = typeof options.hub === "function" ? options.hub({ plan, bundle, set: built }) : (options.hub ?? {});
118
+ // What the planner stores carries the whole-file texts, so the command line can write from the store alone.
119
+ const stored = { ...built, texts: Object.entries(texts).map(([path, text]) => ({ path, text })).sort((left, right) => left.path.localeCompare(right.path)) };
120
+ const hub = hubRepo(roots, { plans: [plan], sets: options.storeSet === true ? [stored] : [], bundles: [bundle], ...override });
121
+ const binding = { kind: "approved" as const, subjectDigest: bundle.bundleDigest };
122
+ return { clone: realpathSync(clone), hub: hub.hub, set: built, texts, binding, planPackages: committedPlanPackages(plan, SITE_ID), plan, bundle, origin };
123
+ }
124
+
125
+ export interface AdmittedFixtureOptions {
126
+ readonly world?: WorldOptions;
127
+ readonly hub?: Partial<HubOptions> | ((world: World) => Partial<HubOptions>);
128
+ /** How the setup set reached the default branch. Default `squash`: no ancestor of the setup branch. */
129
+ readonly mergeStyle?: "direct" | "squash";
130
+ /** Edits the base tree before it is committed. */
131
+ readonly tree?: (tree: World["tree"]) => void;
132
+ /** Store the apply set in the hub too. Default false. */
133
+ readonly storeApplySet?: boolean;
134
+ }
135
+
136
+ /**
137
+ * The two phases of one plan. The setup set was approved (the plan's latest
138
+ * decision approves the bundle that holds it), materialized and merged into
139
+ * the clone's default branch (by squash by default), and the apply set was
140
+ * then computed against the merged base and is held by a later run's bundle,
141
+ * which the hub stores. `spawn` stands in for the package manager: it
142
+ * regenerates the lockfile the apply set's installs write.
143
+ */
144
+ export function buildAdmittedFixture(roots: string[], options: AdmittedFixtureOptions = {}) {
145
+ let site: ReturnType<typeof siteRepo> | undefined;
146
+ const world = buildWorld({
147
+ ...options.world,
148
+ commitBase: (tree) => {
149
+ options.tree?.(tree);
150
+ site = siteRepo(roots, tree, { mergeStyle: options.mergeStyle ?? "squash" });
151
+ const origin = join(dirname(site.clone), "origin.git");
152
+ execFileSync("git", ["init", "--bare", origin], { env: gitEnv, stdio: "ignore" });
153
+ git(site.clone, "remote", "add", "origin", origin);
154
+ git(site.clone, "push", "-u", "origin", "main");
155
+ return site.baseCommit;
156
+ },
157
+ });
158
+ const override = typeof options.hub === "function" ? options.hub(world) : (options.hub ?? {});
159
+ const hub = hubRepo(roots, {
160
+ plans: [world.plan],
161
+ sets: options.storeApplySet === true ? [world.setup, world.apply] : [world.setup],
162
+ bundles: [world.approvedBundle, world.applyBundle],
163
+ ...override,
164
+ });
165
+ const packages = committedPlanPackages(world.plan, SITE_ID);
166
+ const spawn: LockfileSpawn = async (request) => {
167
+ if (request.args.includes("--version")) return { status: 0, stdout: "10.9.0\n", stderr: "" };
168
+ writeFileSync(join(request.cwd, "package-lock.json"), lockfileText(packages));
169
+ return { status: 0, stdout: "", stderr: "" };
170
+ };
171
+ return {
172
+ world,
173
+ hub: hub.hub,
174
+ clone: site!.clone,
175
+ baseCommit: site!.baseCommit,
176
+ sideTip: site!.sideTip,
177
+ set: world.apply,
178
+ setup: world.setup,
179
+ texts: world.texts,
180
+ spawn,
181
+ };
182
+ }
183
+
184
+ function walk(root: string, skip: (relative: string) => boolean, into: string[], relative = ""): void {
185
+ let names: string[];
186
+ try {
187
+ names = readdirSync(join(root, relative)).sort();
188
+ } catch {
189
+ return;
190
+ }
191
+ for (const name of names) {
192
+ const path = relative === "" ? name : `${relative}/${name}`;
193
+ if (skip(path)) continue;
194
+ const stat = lstatSync(join(root, path));
195
+ if (stat.isDirectory()) walk(root, skip, into, path);
196
+ else if (stat.isSymbolicLink()) into.push(`${path} -> ${readlinkSync(join(root, path))}`);
197
+ else into.push(`${path} ${createHash("sha256").update(readFileSync(join(root, path))).digest("hex")} ${stat.mode & 0o777}`);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Everything a refused materialize must leave alone: the clone's refs, HEAD,
203
+ * index, status and every file outside .git, and every file the hub keeps
204
+ * under clossys/.state. Two snapshots are equal only when nothing moved.
205
+ */
206
+ export function writeSnapshot(clone: string, hub: string): string {
207
+ const files: string[] = [];
208
+ walk(clone, (path) => path === ".git", files);
209
+ const hubFiles: string[] = [];
210
+ walk(join(hub, "clossys", ".state"), () => false, hubFiles);
211
+ return JSON.stringify({
212
+ refs: git(clone, "for-each-ref").trim(),
213
+ head: git(clone, "rev-parse", "HEAD").trim(),
214
+ branch: git(clone, "rev-parse", "--abbrev-ref", "HEAD").trim(),
215
+ index: git(clone, "ls-files", "-s").trim(),
216
+ status: git(clone, "status", "--porcelain", "--untracked-files=all").trim(),
217
+ files,
218
+ hub: hubFiles,
219
+ });
220
+ }
221
+
222
+ /** Whether the clone holds a local branch of this name. */
223
+ export function branchExists(clone: string, branch: string): boolean {
224
+ try {
225
+ git(clone, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`);
226
+ return true;
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ /** The ledger a clone holds at clossys/.state/installed.json, parsed, or null when it holds none. */
233
+ export function readCloneLedger(clone: string): Loose | null {
234
+ const path = join(clone, "clossys/.state/installed.json");
235
+ return existsSync(path) ? (JSON.parse(readFileSync(path, "utf8")) as Loose) : null;
236
+ }