@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
@@ -0,0 +1,179 @@
1
+ // The plan record and the engagement brief (issue #1475), validated against
2
+ // the shared contracts docs/contracts/advisor-plan.json, engagement-brief.json
3
+ // and engagement-context.json -- in the public repository, not shipped in this package.
4
+ // This package's build packs their content into
5
+ // src/generated/ as plain data, with a generated copy of the one contract
6
+ // checker @clossys/advisor also uses, so Launcher and Advisor validate
7
+ // against the same definition with no runtime dependency between them.
8
+ // The TypeScript types below describe the same shapes for callers; they
9
+ // validate nothing.
10
+
11
+ import { formatContractViolation, validateAgainstContract } from "./generated/contract-schema.generated.js";
12
+ import type { ContractSchema } from "./generated/contract-schema.generated.js";
13
+ import { PLAN_CONTRACTS } from "./generated/plan-contracts.generated.js";
14
+ import { briefRuleViolations, planRuleViolations } from "./plan-rules.js";
15
+ import type { ContractRuleId } from "./plan-rules.js";
16
+
17
+ /** An engagement-context field id (docs/contracts/engagement-context.json, in the public repository, not shipped in this package). */
18
+ export type EngagementContextFieldId = "business" | "product" | "audience" | "stage" | "intent" | "constraints";
19
+
20
+ /** An unknown context field, or a known one whose value is one of that field's fixed choice ids -- never founder text. */
21
+ export type EngagementContextField =
22
+ | { readonly id: EngagementContextFieldId; readonly state: "unknown" }
23
+ | { readonly id: EngagementContextFieldId; readonly state: "known"; readonly value: string };
24
+
25
+ /** The engagement-context snapshot a brief may carry (docs/contracts/engagement-context.json, in the public repository, not shipped in this package). */
26
+ export interface EngagementContext {
27
+ readonly schemaVersion: 1;
28
+ readonly fields: readonly EngagementContextField[];
29
+ }
30
+
31
+ export type GoalDirection = "increase" | "decrease" | "maintain" | "target-range";
32
+
33
+ export interface EngagementBriefRole {
34
+ readonly role: string;
35
+ readonly why: string;
36
+ readonly goal: { readonly metric: string; readonly direction: GoalDirection };
37
+ readonly inputsFrom: readonly string[];
38
+ readonly outputsTo: readonly string[];
39
+ }
40
+
41
+ /** clossys/brief.json (docs/contracts/engagement-brief.json, in the public repository, not shipped in this package). */
42
+ export interface EngagementBrief {
43
+ readonly schemaVersion: 1;
44
+ readonly problem: string;
45
+ readonly roles: readonly EngagementBriefRole[];
46
+ readonly sequence: readonly string[];
47
+ readonly deliverables: readonly string[];
48
+ /** The roles staffed in the repository this brief is written to, in plan order; absent in the hub brief (#1178). */
49
+ readonly staffedHere?: readonly string[];
50
+ /** Optional snapshot of the hub's engagement context; absent means every field is unknown. */
51
+ readonly context?: EngagementContext;
52
+ }
53
+
54
+ export type BlockerKind = "missing-input" | "missing-authority" | "failing-evidence" | "unavailable-environment" | "contradiction";
55
+
56
+ /** One capability at rest with exactly one next action: who does it, how, and by when. */
57
+ export interface PlanBlocker {
58
+ readonly capabilityId: string;
59
+ readonly kind: BlockerKind;
60
+ readonly owner: string;
61
+ readonly nextAction: { readonly who: string; readonly how: string; readonly byWhen: string };
62
+ /** ISO 8601 date-time the blocker was recorded. */
63
+ readonly since: string;
64
+ }
65
+
66
+ export interface PlanDecision {
67
+ readonly at: string;
68
+ readonly recommended: string;
69
+ readonly chosen: string;
70
+ readonly by: string;
71
+ /** On an approving decision, the digest of the exact change the approver was shown; an approval without it binds nothing (#1178). */
72
+ readonly subjectDigest?: string;
73
+ }
74
+
75
+ /** A kit recommended for the plan (#1178). */
76
+ export interface PlanKit {
77
+ readonly id: string;
78
+ readonly source: "preset" | "composed";
79
+ readonly verdict: "recommended";
80
+ }
81
+
82
+ /** Which roles work in one repository, named by its repository inventory id (#1178). */
83
+ export interface PlanStaffing {
84
+ readonly repository: string;
85
+ readonly roles: readonly string[];
86
+ }
87
+
88
+ /** One exact package act: one version and one sha512 integrity value (#1178). */
89
+ export interface PlanPackageAct {
90
+ readonly planItem: string;
91
+ readonly repository: string;
92
+ readonly act: "install" | "pin-starter";
93
+ readonly name: string;
94
+ readonly version: string;
95
+ readonly integrity: string;
96
+ readonly placement: "dependencies" | "devDependencies";
97
+ }
98
+
99
+ /** clossys/advisor/plan.json (docs/contracts/advisor-plan.json, in the public repository, not shipped in this package). */
100
+ export interface AdvisorPlan {
101
+ readonly schemaVersion: 1;
102
+ readonly asOf: string;
103
+ readonly mandate: { readonly problem: string; readonly primaryProblemId: string; readonly roles: readonly string[] };
104
+ readonly whereWeAre: readonly string[];
105
+ readonly recommendedNext: { readonly action: string; readonly owner: string; readonly due?: string } | null;
106
+ readonly decisions: readonly PlanDecision[];
107
+ readonly blockers: readonly PlanBlocker[];
108
+ readonly kits?: readonly PlanKit[];
109
+ readonly staffing?: readonly PlanStaffing[];
110
+ /** Present exactly when `resolution` is. */
111
+ readonly packages?: readonly PlanPackageAct[];
112
+ readonly resolution?: { readonly snapshotDigest: string };
113
+ /** Declares that copy a delegate approved is accepted on production (issue #1586); read by @clossys/writer, not by this package. */
114
+ readonly delegatedCopyApproval?: { readonly target: "production"; readonly scopes?: readonly string[] };
115
+ }
116
+
117
+ /** `reason` lists every violation, separated by `; `, each naming the field at fault (for example `plan.blockers[0].capabilityId is required`). */
118
+ export type ValidationResult = { readonly valid: true } | { readonly valid: false; readonly reason: string };
119
+
120
+ /** Resolves a packed shared contract by its docs/contracts/ file name. Throws for a name that was not packed. */
121
+ export function loadContract(name: string): ContractSchema {
122
+ const contract = Object.hasOwn(PLAN_CONTRACTS, name) ? PLAN_CONTRACTS[name] : undefined;
123
+ if (contract === undefined) throw new Error(`no packed contract named ${JSON.stringify(name)}`);
124
+ return contract;
125
+ }
126
+
127
+ /** One reason a plan or brief is refused: `rule` is "schema" for the contract's keywords, else the code rule's id. */
128
+ export interface DocumentViolation {
129
+ readonly rule: "schema" | ContractRuleId;
130
+ /** The field at fault, like `staffing[1].repository`, or "" for the document itself. */
131
+ readonly path: string;
132
+ /** The whole message, starting with the label and path; it never quotes a value. */
133
+ readonly message: string;
134
+ }
135
+
136
+ function violationsOf<T>(contractName: string, label: string, value: unknown, rules: (document: T) => readonly { rule: ContractRuleId; path: string; message: string }[]): DocumentViolation[] {
137
+ const schema = validateAgainstContract(loadContract(contractName), value, loadContract);
138
+ if (schema.length > 0) return schema.map((violation) => ({ rule: "schema", path: violation.path, message: formatContractViolation(label, violation) }));
139
+ // The code rules relate fields to one another, so they run only on a document whose shape is known good.
140
+ return rules(value as T).map((violation) => ({ rule: violation.rule, path: violation.path, message: `${label}.${violation.path} ${violation.message} (rule ${violation.rule})` }));
141
+ }
142
+
143
+ function result(violations: readonly DocumentViolation[]): ValidationResult {
144
+ if (violations.length === 0) return { valid: true };
145
+ return { valid: false, reason: violations.map((violation) => violation.message).join("; ") };
146
+ }
147
+
148
+ /** Every reason a plan is refused: the plan contract's schema, then, once that passes, its code rules R1-R12. */
149
+ export function advisorPlanViolations(value: unknown): DocumentViolation[] {
150
+ return violationsOf<AdvisorPlan>("advisor-plan.json", "plan", value, planRuleViolations);
151
+ }
152
+
153
+ /** Every reason a brief is refused: the brief contract's schema, then, once that passes, its code rules B1-B2. */
154
+ export function engagementBriefViolations(value: unknown): DocumentViolation[] {
155
+ return violationsOf<EngagementBrief>("engagement-brief.json", "brief", value, briefRuleViolations);
156
+ }
157
+
158
+ /**
159
+ * Validates a brief against docs/contracts/engagement-brief.json, including
160
+ * its optional `context` snapshot against engagement-context.json: a known
161
+ * context value must be one of that field's fixed choice ids, because the
162
+ * brief is committed in every staffed repository. Both contracts are in the public repository, not shipped in this package.
163
+ * Once the schema passes, the contract's code rules run: every `staffedHere`
164
+ * entry is one of `roles[].role` (B1), and none repeats (B2). Never mutates,
165
+ * never re-derives content, and no reason echoes a value from the brief.
166
+ */
167
+ export function validateEngagementBrief(value: unknown): ValidationResult {
168
+ return result(engagementBriefViolations(value));
169
+ }
170
+
171
+ /**
172
+ * Validates a plan against docs/contracts/advisor-plan.json (in the public repository, not shipped in this package):
173
+ * its schema, then, once that passes, its code rules R1-R12 -- staffing and
174
+ * package entries that repeat, the joins between staffing, the mandate
175
+ * and packages, and each planItem derived from its act (#1178). No reason echoes a value from the plan.
176
+ */
177
+ export function validateAdvisorPlan(value: unknown): ValidationResult {
178
+ return result(advisorPlanViolations(value));
179
+ }
@@ -0,0 +1,102 @@
1
+ // The canonical plan digest (issue #1475), implemented from its one
2
+ // definition in this package's source repository,
3
+ // docs/contracts/advisor-plan-digest.md. An approval binds this value, so it
4
+ // names exactly which plan was approved. @clossys/advisor implements the same
5
+ // definition separately; both packages are tested against the same fixture
6
+ // corpus (docs/contracts/advisor-plan-digest.fixture.json), so they compute
7
+ // identical digests. Both files are in the public repository, not shipped in this package.
8
+ // This implementation writes the RFC 8785 escaping out
9
+ // character by character rather than leaning on JSON.stringify, so the
10
+ // corpus checks two genuinely separate readings of the definition.
11
+
12
+ import { createHash } from "node:crypto";
13
+ import { validateAdvisorPlan } from "./plan-contract.js";
14
+ import type { AdvisorPlan } from "./plan-contract.js";
15
+
16
+ /** The top-level plan members the digest leaves out: when the file was written, and the decisions an approval is recorded in. */
17
+ export const PLAN_DIGEST_EXCLUDED_FIELDS: readonly string[] = ["asOf", "decisions"];
18
+
19
+ const SHORT_ESCAPES: Readonly<Record<number, string>> = { 0x08: "\\b", 0x09: "\\t", 0x0a: "\\n", 0x0c: "\\f", 0x0d: "\\r", 0x22: '\\"', 0x5c: "\\\\" };
20
+
21
+ function quote(text: string): string {
22
+ let out = '"';
23
+ for (let index = 0; index < text.length; index += 1) {
24
+ const unit = text.charCodeAt(index);
25
+ if (unit >= 0xd800 && unit <= 0xdbff) {
26
+ const next = index + 1 < text.length ? text.charCodeAt(index + 1) : -1;
27
+ if (next < 0xdc00 || next > 0xdfff) throw new TypeError("canonical JSON refuses a string that is not well-formed Unicode (a lone surrogate)");
28
+ out += text[index]! + text[index + 1]!;
29
+ index += 1;
30
+ continue;
31
+ }
32
+ if (unit >= 0xdc00 && unit <= 0xdfff) throw new TypeError("canonical JSON refuses a string that is not well-formed Unicode (a lone surrogate)");
33
+ const short = SHORT_ESCAPES[unit];
34
+ if (short !== undefined) out += short;
35
+ else if (unit < 0x20) out += `\\u${unit.toString(16).padStart(4, "0")}`;
36
+ else out += text[index]!;
37
+ }
38
+ return `${out}"`;
39
+ }
40
+
41
+ /** Orders two keys by their UTF-16 code units, as RFC 8785 requires. */
42
+ function compareCodeUnits(left: string, right: string): number {
43
+ const length = Math.min(left.length, right.length);
44
+ for (let index = 0; index < length; index += 1) {
45
+ const difference = left.charCodeAt(index) - right.charCodeAt(index);
46
+ if (difference !== 0) return difference;
47
+ }
48
+ return left.length - right.length;
49
+ }
50
+
51
+ /**
52
+ * RFC 8785 (JSON Canonicalization Scheme) serialization: no whitespace,
53
+ * object members sorted by UTF-16 code units, strings escaped only where
54
+ * JSON requires, numbers as ECMAScript writes them. Throws on anything JSON
55
+ * cannot carry (undefined, a function, a non-finite number) and on a lone
56
+ * surrogate, rather than silently dropping or repairing it.
57
+ */
58
+ export function canonicalJson(value: unknown): string {
59
+ switch (typeof value) {
60
+ case "boolean":
61
+ return value ? "true" : "false";
62
+ case "string":
63
+ return quote(value);
64
+ case "number":
65
+ if (!Number.isFinite(value)) throw new TypeError("canonical JSON refuses a non-finite number");
66
+ return value === 0 ? "0" : String(value);
67
+ case "object": {
68
+ if (value === null) return "null";
69
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
70
+ const record = value as Record<string, unknown>;
71
+ const members = Object.keys(record)
72
+ .sort(compareCodeUnits)
73
+ .map((key) => `${quote(key)}:${canonicalJson(record[key])}`);
74
+ return `{${members.join(",")}}`;
75
+ }
76
+ default:
77
+ throw new TypeError(`canonical JSON refuses a value of type ${typeof value}`);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * `sha256:` and the lowercase hex SHA-256 of the UTF-8 bytes of
83
+ * `canonicalJson(value)` (issue #1178). The one digest step the plan digest,
84
+ * the change-set digest and the bundle digest share; each decides only what
85
+ * `value` is. Throws whatever `canonicalJson()` throws.
86
+ */
87
+ export function canonicalDigest(value: unknown): string {
88
+ return `sha256:${createHash("sha256").update(Buffer.from(canonicalJson(value), "utf8")).digest("hex")}`;
89
+ }
90
+
91
+ /**
92
+ * `sha256:` and the lowercase hex SHA-256 of the canonical JSON of the plan
93
+ * without `asOf` and `decisions`. Throws when the plan does not validate
94
+ * against the plan contract: an invalid plan has no digest.
95
+ */
96
+ export function planDigest(plan: AdvisorPlan): string {
97
+ const validation = validateAdvisorPlan(plan);
98
+ if (!validation.valid) throw new TypeError(`an invalid plan has no digest: ${validation.reason}`);
99
+ const subject: Record<string, unknown> = {};
100
+ for (const [key, member] of Object.entries(plan)) if (!PLAN_DIGEST_EXCLUDED_FIELDS.includes(key)) subject[key] = member;
101
+ return canonicalDigest(subject);
102
+ }
@@ -0,0 +1,188 @@
1
+ // The plan and brief contracts' code rules (issue #1178): the checks the
2
+ // contracts' JSON Schema keywords cannot express, because each one relates one
3
+ // field to another. They are defined once, as prose in the descriptions of
4
+ // the shared contracts docs/contracts/advisor-plan.json (R1-R12) and
5
+ // engagement-brief.json (B1-B2) -- in the public repository, not shipped in
6
+ // this package. @clossys/advisor implements the same rules separately; both
7
+ // packages are tested against one corpus,
8
+ // docs/contracts/advisor-plan-rules.fixture.json (likewise not shipped), so a
9
+ // plan or brief Advisor accepts, Launcher accepts, and the reverse.
10
+ //
11
+ // These functions assume the value already passed the schema: the validators
12
+ // in plan-contract.ts run them only then. A violation names its rule and the
13
+ // position of the field at fault, never a value, because a plan or brief can
14
+ // carry founder text.
15
+
16
+ import { PLAN_CONTRACTS } from "./generated/plan-contracts.generated.js";
17
+ import type { AdvisorPlan, EngagementBrief } from "./plan-contract.js";
18
+
19
+ /** A code rule of the plan contract (R1-R12) or the brief contract (B1-B2). */
20
+ export type ContractRuleId = "R1" | "R2" | "R3" | "R4" | "R5" | "R6" | "R7" | "R8" | "R9" | "R10" | "R11" | "R12" | "B1" | "B2";
21
+
22
+ /**
23
+ * The roles whose package lives in the engagement hub only, read from the
24
+ * packed plan contract's `definitions.hubOnlyRoles`, the one list every
25
+ * package that validates a plan reads (issue #1178). Code rules R2 and R11
26
+ * use it. A packed contract without a well-formed list is a build defect, so
27
+ * loading this module throws rather than judge plans against a guess.
28
+ */
29
+ export const HUB_ONLY_ROLES: readonly string[] = (() => {
30
+ const definitions = PLAN_CONTRACTS["advisor-plan.json"]?.definitions as Record<string, { const?: unknown }> | undefined;
31
+ const roles = definitions?.hubOnlyRoles?.const;
32
+ if (!Array.isArray(roles) || roles.some((role) => typeof role !== "string")) throw new Error("the packed plan contract has no well-formed definitions.hubOnlyRoles");
33
+ return Object.freeze([...(roles as string[])]);
34
+ })();
35
+
36
+ export interface ContractRuleViolation {
37
+ readonly rule: ContractRuleId;
38
+ /** The field at fault, such as `staffing[1].repository`. */
39
+ readonly path: string;
40
+ /** What is wrong, by position only. */
41
+ readonly message: string;
42
+ }
43
+
44
+ /** Calls `onRepeat(index, firstIndex)` for every item whose key an earlier item already had. One pass, with a Map. */
45
+ function eachRepeat<T>(items: readonly T[], key: (item: T) => string, onRepeat: (index: number, firstIndex: number) => void): void {
46
+ const first = new Map<string, number>();
47
+ for (let index = 0; index < items.length; index += 1) {
48
+ const value = key(items[index]!);
49
+ const earlier = first.get(value);
50
+ if (earlier === undefined) first.set(value, index);
51
+ else onRepeat(index, earlier);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * A document's own member, or undefined. The schema checker sees only own
57
+ * members, so these rules read only those too: a member inherited from a
58
+ * prototype is ignored, never judged -- exactly as @clossys/advisor does.
59
+ */
60
+ function own<T extends object, K extends keyof T>(document: T, name: K): T[K] | undefined {
61
+ return Object.hasOwn(document, name) ? document[name] : undefined;
62
+ }
63
+
64
+ /** Every violation of R1-R12, for a plan that already passed the plan contract's schema. */
65
+ export function planRuleViolations(plan: AdvisorPlan): ContractRuleViolation[] {
66
+ const out: ContractRuleViolation[] = [];
67
+ const add = (rule: ContractRuleId, path: string, message: string): void => {
68
+ out.push({ rule, path, message });
69
+ };
70
+ const staffing = own(plan, "staffing");
71
+ const packages = own(plan, "packages");
72
+ const kits = own(plan, "kits");
73
+ const mandateRoles = plan.mandate.roles;
74
+
75
+ // R1: one staffing entry per repository, ids compared case-insensitively.
76
+ if (staffing !== undefined) {
77
+ eachRepeat(staffing, (entry) => entry.repository.toLowerCase(), (index, first) => {
78
+ add("R1", `staffing[${index}].repository`, `names the same repository as staffing[${first}].repository (repository ids compare case-insensitively)`);
79
+ });
80
+ }
81
+
82
+ // R2: staffed roles and mandate roles agree, in both directions; a hub-only role is never required to be staffed.
83
+ if (staffing !== undefined) {
84
+ const inMandate = new Set<string>(mandateRoles);
85
+ const staffedRoles = new Set<string>();
86
+ for (let index = 0; index < staffing.length; index += 1) {
87
+ const roles = staffing[index]!.roles;
88
+ for (let position = 0; position < roles.length; position += 1) {
89
+ staffedRoles.add(roles[position]!);
90
+ if (!inMandate.has(roles[position]!)) add("R2", `staffing[${index}].roles[${position}]`, "is not one of mandate.roles");
91
+ }
92
+ }
93
+ for (let position = 0; position < mandateRoles.length; position += 1) {
94
+ if (!staffedRoles.has(mandateRoles[position]!) && !HUB_ONLY_ROLES.includes(mandateRoles[position]!)) add("R2", `mandate.roles[${position}]`, "is not staffed in any staffing entry");
95
+ }
96
+ }
97
+
98
+ if (packages !== undefined) {
99
+ // R3: every act names a staffed repository, spelled exactly the same.
100
+ const staffed = new Set<string>((staffing ?? []).map((entry) => entry.repository));
101
+ for (let index = 0; index < packages.length; index += 1) {
102
+ if (!staffed.has(packages[index]!.repository)) add("R3", `packages[${index}].repository`, "is not spelled exactly as any staffing[].repository");
103
+ }
104
+ // R4: planItem ids are unique.
105
+ eachRepeat(packages, (act) => act.planItem, (index, first) => {
106
+ add("R4", `packages[${index}].planItem`, `repeats packages[${first}].planItem`);
107
+ });
108
+ // R5: one act per (repository, name), repositories compared case-insensitively.
109
+ eachRepeat(packages, (act) => `${act.repository.toLowerCase()}\u0000${act.name}`, (index, first) => {
110
+ add("R5", `packages[${index}].name`, `repeats the repository and name of packages[${first}] (repositories compare case-insensitively)`);
111
+ });
112
+ // R12: every planItem is exactly its act's repository, a colon and its name, in the same letter case.
113
+ for (let index = 0; index < packages.length; index += 1) {
114
+ const act = packages[index]!;
115
+ if (act.planItem !== `${act.repository}:${act.name}`) add("R12", `packages[${index}].planItem`, "is not this act's repository, a colon and its name, in the same letter case");
116
+ }
117
+ }
118
+
119
+ // R6: resolution exactly when packages.
120
+ const hasPackages = Object.hasOwn(plan, "packages");
121
+ const hasResolution = Object.hasOwn(plan, "resolution");
122
+ if (hasPackages && !hasResolution) add("R6", "resolution", "is required when packages is present");
123
+ if (!hasPackages && hasResolution) add("R6", "resolution", "must be absent when there are no packages");
124
+
125
+ // R7: kit ids are unique.
126
+ if (kits !== undefined) {
127
+ eachRepeat(kits, (kit) => kit.id, (index, first) => {
128
+ add("R7", `kits[${index}].id`, `repeats kits[${first}].id`);
129
+ });
130
+ }
131
+
132
+ // R8: no role twice within one staffing entry.
133
+ if (staffing !== undefined) {
134
+ for (let index = 0; index < staffing.length; index += 1) {
135
+ eachRepeat(staffing[index]!.roles, (role) => role, (position, first) => {
136
+ add("R8", `staffing[${index}].roles[${position}]`, `repeats staffing[${index}].roles[${first}]`);
137
+ });
138
+ }
139
+ }
140
+
141
+ // R9: no role twice in mandate.roles.
142
+ eachRepeat(mandateRoles, (role) => role, (position, first) => {
143
+ add("R9", `mandate.roles[${position}]`, `repeats mandate.roles[${first}]`);
144
+ });
145
+
146
+ // R10: at most one pin-starter act per repository (compared case-insensitively), and it is a devDependency.
147
+ if (packages !== undefined) {
148
+ const pins: number[] = [];
149
+ for (let index = 0; index < packages.length; index += 1) if (packages[index]!.act === "pin-starter") pins.push(index);
150
+ eachRepeat(pins, (index) => packages[index]!.repository.toLowerCase(), (position, first) => {
151
+ add("R10", `packages[${pins[position]}].act`, `is a second act of this kind for the repository of packages[${pins[first]}] (repositories compare case-insensitively)`);
152
+ });
153
+ for (const index of pins) {
154
+ if (packages[index]!.placement !== "devDependencies") add("R10", `packages[${index}].placement`, "must be devDependencies for a pin-starter act");
155
+ }
156
+ }
157
+
158
+ // R11: a hub-only role is never staffed in a repository.
159
+ if (staffing !== undefined) {
160
+ for (let index = 0; index < staffing.length; index += 1) {
161
+ const roles = staffing[index]!.roles;
162
+ for (let position = 0; position < roles.length; position += 1) {
163
+ if (HUB_ONLY_ROLES.includes(roles[position]!)) add("R11", `staffing[${index}].roles[${position}]`, "is a hub-only role, which works from the hub and is never staffed in a repository");
164
+ }
165
+ }
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /** Every violation of B1-B2, for a brief that already passed the brief contract's schema. */
171
+ export function briefRuleViolations(brief: EngagementBrief): ContractRuleViolation[] {
172
+ const out: ContractRuleViolation[] = [];
173
+ const add = (rule: ContractRuleId, path: string, message: string): void => {
174
+ out.push({ rule, path, message });
175
+ };
176
+ const staffedHere = own(brief, "staffedHere");
177
+ if (staffedHere === undefined) return out;
178
+ // B1: every entry is one of the brief's roles.
179
+ const roles = new Set<string>(brief.roles.map((entry) => entry.role));
180
+ for (let index = 0; index < staffedHere.length; index += 1) {
181
+ if (!roles.has(staffedHere[index]!)) add("B1", `staffedHere[${index}]`, "is not one of roles[].role");
182
+ }
183
+ // B2: no entry repeats.
184
+ eachRepeat(staffedHere, (role) => role, (index, first) => {
185
+ add("B2", `staffedHere[${index}]`, `repeats staffedHere[${first}]`);
186
+ });
187
+ return out;
188
+ }