@clossys/launcher 0.3.1 → 0.4.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 (285) hide show
  1. package/README.md +1324 -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 +453 -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 +799 -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 +157 -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 +381 -0
  46. package/dist/change-set-contract.d.ts.map +1 -0
  47. package/dist/change-set-contract.js +738 -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/generated/contract-schema.generated.d.ts +97 -0
  69. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  70. package/dist/generated/contract-schema.generated.js +496 -0
  71. package/dist/generated/contract-schema.generated.js.map +1 -0
  72. package/dist/generated/package-scope.generated.d.ts +6 -0
  73. package/dist/generated/package-scope.generated.d.ts.map +1 -0
  74. package/dist/generated/package-scope.generated.js +10 -0
  75. package/dist/generated/package-scope.generated.js.map +1 -0
  76. package/dist/generated/plan-contracts.generated.d.ts +3 -0
  77. package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
  78. package/dist/generated/plan-contracts.generated.js +2840 -0
  79. package/dist/generated/plan-contracts.generated.js.map +1 -0
  80. package/dist/host.d.ts.map +1 -1
  81. package/dist/host.js +11 -0
  82. package/dist/host.js.map +1 -1
  83. package/dist/identity.d.ts +15 -0
  84. package/dist/identity.d.ts.map +1 -0
  85. package/dist/identity.js +48 -0
  86. package/dist/identity.js.map +1 -0
  87. package/dist/index.d.ts +34 -5
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +19 -2
  90. package/dist/index.js.map +1 -1
  91. package/dist/inventory-adoption.d.ts +24 -5
  92. package/dist/inventory-adoption.d.ts.map +1 -1
  93. package/dist/inventory-adoption.js +70 -25
  94. package/dist/inventory-adoption.js.map +1 -1
  95. package/dist/inventory-choice.d.ts +40 -0
  96. package/dist/inventory-choice.d.ts.map +1 -0
  97. package/dist/inventory-choice.js +156 -0
  98. package/dist/inventory-choice.js.map +1 -0
  99. package/dist/inventory-contract.d.ts +89 -0
  100. package/dist/inventory-contract.d.ts.map +1 -0
  101. package/dist/inventory-contract.js +121 -0
  102. package/dist/inventory-contract.js.map +1 -0
  103. package/dist/key-editor.d.ts +30 -0
  104. package/dist/key-editor.d.ts.map +1 -0
  105. package/dist/key-editor.js +445 -0
  106. package/dist/key-editor.js.map +1 -0
  107. package/dist/ledger-contract.d.ts +187 -0
  108. package/dist/ledger-contract.d.ts.map +1 -0
  109. package/dist/ledger-contract.js +532 -0
  110. package/dist/ledger-contract.js.map +1 -0
  111. package/dist/ledger-trust.d.ts +90 -0
  112. package/dist/ledger-trust.d.ts.map +1 -0
  113. package/dist/ledger-trust.js +198 -0
  114. package/dist/ledger-trust.js.map +1 -0
  115. package/dist/lockfile-invariants.d.ts +48 -0
  116. package/dist/lockfile-invariants.d.ts.map +1 -0
  117. package/dist/lockfile-invariants.js +375 -0
  118. package/dist/lockfile-invariants.js.map +1 -0
  119. package/dist/lockfile-readers.d.ts +72 -0
  120. package/dist/lockfile-readers.d.ts.map +1 -0
  121. package/dist/lockfile-readers.js +713 -0
  122. package/dist/lockfile-readers.js.map +1 -0
  123. package/dist/lockfile-regen.d.ts +106 -0
  124. package/dist/lockfile-regen.d.ts.map +1 -0
  125. package/dist/lockfile-regen.js +760 -0
  126. package/dist/lockfile-regen.js.map +1 -0
  127. package/dist/lockfile-tool-env.d.ts +29 -0
  128. package/dist/lockfile-tool-env.d.ts.map +1 -0
  129. package/dist/lockfile-tool-env.js +111 -0
  130. package/dist/lockfile-tool-env.js.map +1 -0
  131. package/dist/materialize.d.ts +113 -0
  132. package/dist/materialize.d.ts.map +1 -0
  133. package/dist/materialize.js +840 -0
  134. package/dist/materialize.js.map +1 -0
  135. package/dist/observe-repository.d.ts +90 -0
  136. package/dist/observe-repository.d.ts.map +1 -0
  137. package/dist/observe-repository.js +1367 -0
  138. package/dist/observe-repository.js.map +1 -0
  139. package/dist/plan-bundle-setup-fixture.d.ts +68 -0
  140. package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
  141. package/dist/plan-bundle-setup-fixture.js +167 -0
  142. package/dist/plan-bundle-setup-fixture.js.map +1 -0
  143. package/dist/plan-bundle.d.ts +250 -0
  144. package/dist/plan-bundle.d.ts.map +1 -0
  145. package/dist/plan-bundle.js +827 -0
  146. package/dist/plan-bundle.js.map +1 -0
  147. package/dist/plan-command.d.ts +29 -0
  148. package/dist/plan-command.d.ts.map +1 -0
  149. package/dist/plan-command.js +493 -0
  150. package/dist/plan-command.js.map +1 -0
  151. package/dist/plan-contract.d.ts +153 -0
  152. package/dist/plan-contract.d.ts.map +1 -0
  153. package/dist/plan-contract.js +61 -0
  154. package/dist/plan-contract.js.map +1 -0
  155. package/dist/plan-digest.d.ts +25 -0
  156. package/dist/plan-digest.d.ts.map +1 -0
  157. package/dist/plan-digest.js +106 -0
  158. package/dist/plan-digest.js.map +1 -0
  159. package/dist/plan-rules.d.ts +23 -0
  160. package/dist/plan-rules.d.ts.map +1 -0
  161. package/dist/plan-rules.js +177 -0
  162. package/dist/plan-rules.js.map +1 -0
  163. package/dist/planned-bundle.d.ts +20 -0
  164. package/dist/planned-bundle.d.ts.map +1 -0
  165. package/dist/planned-bundle.js +191 -0
  166. package/dist/planned-bundle.js.map +1 -0
  167. package/dist/product-repository.d.ts +4 -0
  168. package/dist/product-repository.d.ts.map +1 -1
  169. package/dist/product-repository.js +9 -1
  170. package/dist/product-repository.js.map +1 -1
  171. package/dist/provenance-gate.d.ts +48 -0
  172. package/dist/provenance-gate.d.ts.map +1 -0
  173. package/dist/provenance-gate.js +324 -0
  174. package/dist/provenance-gate.js.map +1 -0
  175. package/dist/pull-request-body.d.ts +45 -0
  176. package/dist/pull-request-body.d.ts.map +1 -0
  177. package/dist/pull-request-body.js +232 -0
  178. package/dist/pull-request-body.js.map +1 -0
  179. package/dist/registry-snapshot.d.ts +141 -0
  180. package/dist/registry-snapshot.d.ts.map +1 -0
  181. package/dist/registry-snapshot.js +483 -0
  182. package/dist/registry-snapshot.js.map +1 -0
  183. package/dist/release-age-edit.d.ts +52 -0
  184. package/dist/release-age-edit.d.ts.map +1 -0
  185. package/dist/release-age-edit.js +413 -0
  186. package/dist/release-age-edit.js.map +1 -0
  187. package/dist/root-entries.d.ts +36 -0
  188. package/dist/root-entries.d.ts.map +1 -0
  189. package/dist/root-entries.js +80 -0
  190. package/dist/root-entries.js.map +1 -0
  191. package/dist/setup-template-scripts.d.ts +34 -0
  192. package/dist/setup-template-scripts.d.ts.map +1 -0
  193. package/dist/setup-template-scripts.js +557 -0
  194. package/dist/setup-template-scripts.js.map +1 -0
  195. package/dist/setup-templates.d.ts +54 -0
  196. package/dist/setup-templates.d.ts.map +1 -0
  197. package/dist/setup-templates.js +427 -0
  198. package/dist/setup-templates.js.map +1 -0
  199. package/dist/skills.d.ts +34 -1
  200. package/dist/skills.d.ts.map +1 -1
  201. package/dist/skills.js +129 -17
  202. package/dist/skills.js.map +1 -1
  203. package/dist/status.d.ts +63 -0
  204. package/dist/status.d.ts.map +1 -0
  205. package/dist/status.js +539 -0
  206. package/dist/status.js.map +1 -0
  207. package/dist/types.d.ts +151 -13
  208. package/dist/types.d.ts.map +1 -1
  209. package/package.json +4 -4
  210. package/skeleton/README.md +14 -9
  211. package/skeleton/package.json +2 -1
  212. package/skill/SKILL.md +23 -7
  213. package/skill-catalogue/advisor/SKILL.md +59 -6
  214. package/skill-catalogue/architect/SKILL.md +2 -2
  215. package/skill-catalogue/bouncer/SKILL.md +2 -2
  216. package/skill-catalogue/builder/SKILL.md +2 -2
  217. package/skill-catalogue/butler/SKILL.md +2 -2
  218. package/skill-catalogue/controller/SKILL.md +2 -2
  219. package/skill-catalogue/customer/SKILL.md +2 -2
  220. package/skill-catalogue/designer/SKILL.md +4 -2
  221. package/skill-catalogue/giver/SKILL.md +2 -2
  222. package/skill-catalogue/influencer/SKILL.md +2 -2
  223. package/skill-catalogue/inspector/SKILL.md +2 -2
  224. package/skill-catalogue/integrator/SKILL.md +2 -2
  225. package/skill-catalogue/keeper/SKILL.md +2 -2
  226. package/skill-catalogue/launcher/SKILL.md +23 -7
  227. package/skill-catalogue/locksmith/SKILL.md +2 -2
  228. package/skill-catalogue/messenger/SKILL.md +2 -2
  229. package/skill-catalogue/observer/SKILL.md +2 -2
  230. package/skill-catalogue/publisher/SKILL.md +2 -2
  231. package/skill-catalogue/starter/SKILL.md +3 -2
  232. package/skill-catalogue/strategist/SKILL.md +12 -4
  233. package/skill-catalogue/writer/SKILL.md +2 -2
  234. package/src/admission-fixture.ts +572 -0
  235. package/src/admission.ts +816 -0
  236. package/src/agents-guide.ts +29 -0
  237. package/src/apply-command-options.check.ts +27 -0
  238. package/src/apply-plan-cli.ts +454 -14
  239. package/src/apply-plan.ts +112 -124
  240. package/src/apply-step-fixture.ts +236 -0
  241. package/src/apply-store.ts +584 -0
  242. package/src/approval-sheet.ts +164 -0
  243. package/src/body-command.ts +162 -0
  244. package/src/change-set-contract.ts +937 -0
  245. package/src/change-set-digest.ts +70 -0
  246. package/src/check-cli.ts +14 -3
  247. package/src/cli.ts +90 -22
  248. package/src/core.ts +973 -275
  249. package/src/dry-materialize.ts +353 -0
  250. package/src/generated/contract-schema.generated.ts +520 -0
  251. package/src/generated/package-scope.generated.ts +10 -0
  252. package/src/generated/plan-contracts.generated.ts +2840 -0
  253. package/src/host.ts +10 -0
  254. package/src/identity.ts +51 -0
  255. package/src/index.ts +72 -3
  256. package/src/inventory-adoption.ts +107 -29
  257. package/src/inventory-choice.ts +172 -0
  258. package/src/inventory-contract.ts +166 -0
  259. package/src/key-editor.ts +446 -0
  260. package/src/ledger-contract.ts +637 -0
  261. package/src/ledger-trust.ts +267 -0
  262. package/src/lockfile-invariants.ts +421 -0
  263. package/src/lockfile-readers.ts +749 -0
  264. package/src/lockfile-regen.ts +851 -0
  265. package/src/lockfile-tool-env.ts +131 -0
  266. package/src/materialize.ts +886 -0
  267. package/src/observe-repository.ts +1365 -0
  268. package/src/plan-bundle-setup-fixture.ts +200 -0
  269. package/src/plan-bundle.ts +964 -0
  270. package/src/plan-command.ts +509 -0
  271. package/src/plan-contract.ts +179 -0
  272. package/src/plan-digest.ts +102 -0
  273. package/src/plan-rules.ts +188 -0
  274. package/src/planned-bundle.ts +211 -0
  275. package/src/product-repository.ts +10 -1
  276. package/src/provenance-gate.ts +352 -0
  277. package/src/pull-request-body.ts +261 -0
  278. package/src/registry-snapshot.ts +534 -0
  279. package/src/release-age-edit.ts +430 -0
  280. package/src/root-entries.ts +81 -0
  281. package/src/setup-template-scripts.ts +571 -0
  282. package/src/setup-templates.ts +471 -0
  283. package/src/skills.ts +161 -18
  284. package/src/status.ts +557 -0
  285. package/src/types.ts +148 -13
@@ -0,0 +1,1367 @@
1
+ // Observing one local clone as a RepositoryObservation (RFC apply-approved-plan
2
+ // §7 V4 and V7, issue #1178): the read side of the apply planner. It reports
3
+ // the committed head of a clean clone, as the clone's object database stores
4
+ // it, whose origin is the repository the inventory id names, and nothing else. The clone's working tree is never
5
+ // read: every byte comes from a git object at the head commit, so an edit that
6
+ // is not committed, or a file that is ignored, is not observed.
7
+ //
8
+ // The clone is untrusted content, and the rule is: this repository's own object
9
+ // database and refs are read through git plumbing, never the working tree and
10
+ // never an alternate object store, `commondir` or submodule repository;
11
+ // anything unusual is refused, not interpreted. Its `.git/config` is read as
12
+ // data, never executed, and refused unless every key is one a plain clone
13
+ // carries; a clone that names an alternate object store, or holds a split
14
+ // index's shared file (git rewrites its mtime on every index read), is refused;
15
+ // only then does a git command run inside it, with every hook, filesystem
16
+ // monitor and pager switched off. git inside the clone reads no configuration but the
17
+ // vetted `.git/config` and fixed `-c` overrides: the system and global
18
+ // configuration and the environment's configuration variables are pinned or
19
+ // removed. What the object database holds is trusted to match its ids, as the
20
+ // clone's object database stores it. The committed
21
+ // tree is listed, and a submodule refused, before any command that reads the
22
+ // working tree runs, and none of those considers a submodule. The origin's tip
23
+ // is asked with `git ls-remote`, which writes no ref and no object, from a
24
+ // directory outside the clone. It performs no write to the clone, and none
25
+ // anywhere except one empty scratch directory under the system temporary
26
+ // directory, removed before it returns. The clone is not locked, and the checks
27
+ // are not repeated after the reads: a process that changes the clone in
28
+ // between goes unnoticed, which changes nothing reported, since every byte is
29
+ // an object at the remote tip's commit (see the README's declared limits).
30
+ //
31
+ // Anything that cannot be established is a skip with a reason id, never a
32
+ // guess. A skip is `violated` when the clone is what it must not be (dirty,
33
+ // on another branch, behind or ahead of the origin, hostile), and
34
+ // `indeterminate` when this module could not tell. It never throws for what a
35
+ // repository contains; a port that throws is a skip too.
36
+ import { spawnSync } from "node:child_process";
37
+ import { accessSync, constants, lstatSync, mkdtempSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
38
+ import { tmpdir } from "node:os";
39
+ import { delimiter, dirname, isAbsolute, join } from "node:path";
40
+ import { PACKAGE_SCOPE } from "./generated/package-scope.generated.js";
41
+ import { LEDGER_PATH, SKILLS_MANIFEST_PATH, TEMPLATE_PATHS, compareCodeUnits, compareTuples, contentDigest, matchesPathPattern } from "./change-set-contract.js";
42
+ import { parseGitHubRemote } from "./core.js";
43
+ import { sameRepository } from "./identity.js";
44
+ import { readInstalledLedger } from "./ledger-contract.js";
45
+ import { isUnreadable, readLockfile } from "./lockfile-readers.js";
46
+ import { loadContract } from "./plan-contract.js";
47
+ import { wouldViolateRootEntries } from "./root-entries.js";
48
+ import { editReleaseAgeExemption } from "./release-age-edit.js";
49
+ import { isSupportedStarterVersion } from "./setup-templates.js";
50
+ /** Every reason an observation is skipped, with the verdict it carries. */
51
+ const SKIPS = {
52
+ "clone-missing": "indeterminate",
53
+ "clone-unreadable": "indeterminate",
54
+ "id-owner-unknown": "indeterminate",
55
+ "remote-tip-unreadable": "indeterminate",
56
+ "node-id-unavailable": "indeterminate",
57
+ "visibility-unavailable": "indeterminate",
58
+ "tree-too-large": "indeterminate",
59
+ "submodule-present": "indeterminate",
60
+ "manifest-unreadable": "indeterminate",
61
+ "lockfile-ambiguous": "indeterminate",
62
+ "package-manager-unknown": "indeterminate",
63
+ "lockfile-unreadable": "indeterminate",
64
+ "release-age-surface-invalid": "indeterminate",
65
+ "agents-link-unreportable": "indeterminate",
66
+ "observation-too-large": "indeterminate",
67
+ "case-variant-owned-path": "indeterminate",
68
+ "ledger-unreadable": "indeterminate",
69
+ "profile-ambiguous": "indeterminate",
70
+ "invalid-id": "violated",
71
+ "clone-config-unsafe": "violated",
72
+ "origin-mismatch": "violated",
73
+ "not-on-default-branch": "violated",
74
+ "remote-tip-mismatch": "violated",
75
+ "working-tree-dirty": "violated",
76
+ "package-manager-conflict": "violated",
77
+ };
78
+ /** Ends the observation with a skip; caught in observeRepository() and nowhere else. */
79
+ class Refusal extends Error {
80
+ reason;
81
+ constructor(reason) {
82
+ super(reason);
83
+ this.reason = reason;
84
+ }
85
+ }
86
+ function refuse(reason) {
87
+ throw new Refusal(reason);
88
+ }
89
+ const KIB = 1024;
90
+ const MIB = 1024 * KIB;
91
+ const GIT_TIMEOUT_MS = 60_000;
92
+ const SMALL_OUTPUT = MIB;
93
+ const MAX_TREE_BYTES = 64 * MIB;
94
+ const MAX_TREE_ENTRIES = 200_000;
95
+ const MAX_BLOB_BYTES = 4 * MIB;
96
+ const MAX_LOCKFILE_BYTES = 32 * MIB;
97
+ const MAX_READ_BYTES = 64 * MIB;
98
+ const MAX_OWNED_FILES = 5000;
99
+ const MAX_JSON_DEPTH = 256;
100
+ const ID_PATTERN = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/)?(?!\.\.?$)[A-Za-z0-9._-]+$/u;
101
+ const OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u;
102
+ const NODE_ID_PATTERN = /^[A-Za-z0-9_=+/-]+$/u;
103
+ const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u;
104
+ const BRANCH_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u;
105
+ const INTEGRITY = /^sha512-[A-Za-z0-9+/]{86}==$/u;
106
+ const EXACT_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/u;
107
+ const PACKAGE_MANAGER_FIELD = /^(npm|pnpm|yarn)@\S+$/u;
108
+ const AGENTS_LINK = /^\.agents(?:\/skills(?:\/clossys-[^/\\\u0000-\u001f]+)?)?$/u;
109
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
110
+ const SHA256_HEX = /^[0-9a-f]{64}$/u;
111
+ const CONTROL = /[\u0000-\u001f\u007f]/u;
112
+ const LOCKFILES = [
113
+ { name: "package-lock.json", manager: "npm" },
114
+ { name: "pnpm-lock.yaml", manager: "pnpm" },
115
+ { name: "yarn.lock", manager: "yarn" },
116
+ ];
117
+ /** Lockfiles of managers this module does not read: their presence makes the package manager ambiguous. */
118
+ const OTHER_LOCKFILES = ["npm-shrinkwrap.json", "bun.lock", "bun.lockb"];
119
+ const RELEASE_AGE_SURFACES = [
120
+ { surface: "pnpm-workspace", path: "pnpm-workspace.yaml" },
121
+ { surface: "yarnrc", path: ".yarnrc.yml" },
122
+ { surface: "npmrc", path: ".npmrc" },
123
+ ];
124
+ const DISCOVERY = [".claude/skills", ".cursor/skills"];
125
+ const PROFILE_ROOTS = ["clossys", ".agents", ".claude", ".cursor"];
126
+ /** The roots a setup set adds beside them: its workflows and scripts, and its Starter request. */
127
+ const SETUP_PROFILE_ROOTS = [".github", ".starter"];
128
+ const NPMRC_PATH = ".npmrc";
129
+ const PNPM_WORKSPACE_PATH = "pnpm-workspace.yaml";
130
+ const PROFILE_NAMES = new Set(["repository-profile.json", "repository-declaration.json"]);
131
+ const PROFILE_FIRST = "governance/repository-profile.json";
132
+ const PROFILE_SKIPPED_DIRECTORIES = new Set([".git", "node_modules", "dist", "build", ".next", "coverage", ".turbo", ".cache"]);
133
+ const STARTER = `${PACKAGE_SCOPE.scope}/starter`;
134
+ // ---- git ------------------------------------------------------------------
135
+ const GIT_OPTIONS = [
136
+ "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "core.pager=cat", "-c", "core.untrackedCache=false", "-c", "protocol.ext.allow=never", "--no-pager",
137
+ ];
138
+ /** Variables that would point git at another repository, object store or configuration than the clone's own. */
139
+ const GIT_REMOVED_ENV = [
140
+ "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_REPLACE_REF_BASE", "GIT_GRAFT_FILE",
141
+ "GIT_SHALLOW_FILE", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_SYSTEM", "GIT_ATTR_SOURCE", "GIT_EXTERNAL_DIFF",
142
+ ];
143
+ /**
144
+ * The configuration variables `git ls-remote` alone receives: `GIT_CONFIG_COUNT`
145
+ * and the numbered key and value variables it reads. They are the operator's own
146
+ * environment, the same trust as the global git config file `ls-remote` keeps,
147
+ * and they cannot loosen a fixed `-c` override or `GIT_ALLOW_PROTOCOL`, which
148
+ * both win over them.
149
+ */
150
+ const GIT_COUNTED_CONFIG_ENV = /^GIT_CONFIG_(?:COUNT|(?:KEY|VALUE)_.*)$/iu;
151
+ /** git's own test switches (`GIT_TEST_SPLIT_INDEX` forces a split index on an index write, for one): never passed on to any git command. */
152
+ const GIT_TEST_ENV = /^GIT_TEST_/iu;
153
+ /** The transports a fetch may use when the default origin parser named the origin: exactly the two GitHub serves. */
154
+ const REMOTE_PROTOCOLS = "https:ssh";
155
+ /**
156
+ * The git executable as an absolute path, found once per PATH value among the
157
+ * entries that are absolute and not empty. An empty entry, or a relative one,
158
+ * would search the directory git runs in, and for a command run inside the
159
+ * clone that directory is the repository's own content.
160
+ */
161
+ let resolvedGit = null;
162
+ function gitBinary() {
163
+ const searched = process.env.PATH ?? process.env.Path ?? "";
164
+ if (resolvedGit !== null && resolvedGit.searched === searched)
165
+ return resolvedGit.binary;
166
+ const name = process.platform === "win32" ? "git.exe" : "git";
167
+ let binary = null;
168
+ for (const directory of searched.split(delimiter)) {
169
+ if (directory === "" || !isAbsolute(directory))
170
+ continue;
171
+ const candidate = join(directory, name);
172
+ try {
173
+ if (!statSync(candidate).isFile())
174
+ continue;
175
+ accessSync(candidate, constants.X_OK);
176
+ binary = candidate;
177
+ break;
178
+ }
179
+ catch {
180
+ continue;
181
+ }
182
+ }
183
+ resolvedGit = { searched, binary };
184
+ return binary;
185
+ }
186
+ /**
187
+ * git inside the clone reads no configuration but the vetted `.git/config` and
188
+ * fixed `-c` overrides: the system and global configuration are switched off,
189
+ * and every other source the environment names is removed
190
+ * (`GIT_CONFIG_COUNT` and its numbered key and value variables,
191
+ * `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_SYSTEM`). `ls-remote`, which runs outside
192
+ * the clone, keeps the operator's global and system git config files
193
+ * (credential helpers, proxy) and is given `GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_n`
194
+ * and `GIT_CONFIG_VALUE_n`, so an origin that needs credentials supplied through
195
+ * them is observed; `GIT_CONFIG_PARAMETERS` (git's own encoding of `-c` for its
196
+ * children, not an interface) and `GIT_CONFIG_SYSTEM` are not forwarded to it,
197
+ * and an origin that needs either is skipped as `remote-tip-unreadable`. git's
198
+ * `GIT_TEST_*` switches reach no command.
199
+ */
200
+ function gitEnvironment(cwd, protocols, remote) {
201
+ const env = { ...process.env };
202
+ for (const name of GIT_REMOVED_ENV)
203
+ delete env[name];
204
+ for (const name of Object.keys(env)) {
205
+ if (GIT_TEST_ENV.test(name) || (!remote && GIT_COUNTED_CONFIG_ENV.test(name)))
206
+ delete env[name];
207
+ }
208
+ return {
209
+ ...env,
210
+ ...(remote ? {} : { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }),
211
+ GIT_TERMINAL_PROMPT: "0",
212
+ GIT_OPTIONAL_LOCKS: "0",
213
+ GIT_NO_REPLACE_OBJECTS: "1",
214
+ GIT_NO_LAZY_FETCH: "1",
215
+ GIT_PAGER: "cat",
216
+ GIT_ALLOW_PROTOCOL: protocols,
217
+ // A repository above `cwd` is never discovered: a directory beside the clone cannot stand in for it.
218
+ GIT_CEILING_DIRECTORIES: dirname(cwd),
219
+ LC_ALL: "C",
220
+ };
221
+ }
222
+ function git(cwd, args, maxBuffer = SMALL_OUTPUT, protocols = REMOTE_PROTOCOLS, remote = false) {
223
+ const binary = gitBinary();
224
+ if (binary === null)
225
+ return { status: -1, stdout: Buffer.alloc(0), overflow: false };
226
+ const run = spawnSync(binary, [...GIT_OPTIONS, ...args], {
227
+ cwd,
228
+ env: gitEnvironment(cwd, protocols, remote),
229
+ shell: false,
230
+ stdio: ["ignore", "pipe", "ignore"],
231
+ timeout: GIT_TIMEOUT_MS,
232
+ maxBuffer,
233
+ encoding: "buffer",
234
+ windowsHide: true,
235
+ });
236
+ const overflow = run.error?.code === "ENOBUFS";
237
+ return { status: run.error === undefined ? (run.status ?? -1) : -1, stdout: run.stdout ?? Buffer.alloc(0), overflow };
238
+ }
239
+ function gitText(cwd, args) {
240
+ const run = git(cwd, args);
241
+ return run.status === 0 && !run.overflow ? run.stdout.toString("utf8") : null;
242
+ }
243
+ // ---- the id and the clone ---------------------------------------------------
244
+ function checkId(id, hubOwner) {
245
+ if (!ID_PATTERN.test(id))
246
+ refuse("invalid-id");
247
+ if (!id.includes("/") && (hubOwner === undefined || !OWNER_PATTERN.test(hubOwner)))
248
+ refuse("id-owner-unknown");
249
+ }
250
+ /** The clone's real path, once it is a real directory holding a real `.git` directory of its own, with an object database of its own. */
251
+ function locateClone(clone) {
252
+ const stat = statOf(clone, "clone-missing");
253
+ if (!stat.isDirectory())
254
+ refuse("clone-unreadable");
255
+ const gitDir = join(clone, ".git");
256
+ if (!statOf(gitDir, "clone-unreadable").isDirectory())
257
+ refuse("clone-unreadable");
258
+ // A regular config file, and no `commondir` redirecting git to another directory's configuration.
259
+ if (!statOf(join(gitDir, "config"), "clone-unreadable").isFile())
260
+ refuse("clone-unreadable");
261
+ if (isPresent(join(gitDir, "commondir")))
262
+ refuse("clone-unreadable");
263
+ // Objects that are the clone's own: a real directory, and no list of other stores to read from.
264
+ if (!statOf(join(gitDir, "objects"), "clone-unreadable").isDirectory())
265
+ refuse("clone-unreadable");
266
+ for (const name of ["alternates", "http-alternates"])
267
+ if (isPresent(join(gitDir, "objects", "info", name)))
268
+ refuse("clone-config-unsafe");
269
+ // A split index: git freshens the shared file's mtime on every index read, which would be a write to the clone.
270
+ if (holdsSharedIndex(gitDir))
271
+ refuse("clone-config-unsafe");
272
+ try {
273
+ return realpathSync(clone);
274
+ }
275
+ catch {
276
+ return refuse("clone-unreadable");
277
+ }
278
+ }
279
+ /** Whether the git directory holds a `sharedindex.*` entry of any type, by listing it: no shell glob, and no git command. */
280
+ function holdsSharedIndex(gitDir) {
281
+ try {
282
+ return readdirSync(gitDir).some((name) => name.toLowerCase().startsWith("sharedindex."));
283
+ }
284
+ catch {
285
+ return refuse("clone-unreadable");
286
+ }
287
+ }
288
+ /** Whether anything, of any type, is at `path`; an error other than absence is a refusal. */
289
+ function isPresent(path) {
290
+ try {
291
+ lstatSync(path);
292
+ return true;
293
+ }
294
+ catch (cause) {
295
+ const code = cause.code;
296
+ if (code === "ENOENT" || code === "ENOTDIR")
297
+ return false;
298
+ return refuse("clone-unreadable");
299
+ }
300
+ }
301
+ function statOf(path, absent) {
302
+ try {
303
+ const stat = lstatSync(path);
304
+ if (stat.isSymbolicLink())
305
+ refuse("clone-unreadable");
306
+ return stat;
307
+ }
308
+ catch (cause) {
309
+ if (cause instanceof Refusal)
310
+ throw cause;
311
+ const code = cause.code;
312
+ return refuse(code === "ENOENT" || code === "ENOTDIR" ? absent : "clone-unreadable");
313
+ }
314
+ }
315
+ const CONFIG_CORE = new Set(["repositoryformatversion", "filemode", "bare", "logallrefupdates", "ignorecase", "precomposeunicode", "symlinks"]);
316
+ const CONFIG_REMOTE = new Set(["url", "fetch", "gh-resolved"]);
317
+ const CONFIG_BRANCH = new Set(["remote", "merge", "rebase", "vscode-merge-base", "github-pr-owner-number"]);
318
+ const CONFIG_USER = new Set(["name", "email"]);
319
+ function readConfig(clone, neutral) {
320
+ const run = git(neutral, ["config", "--file", join(clone, ".git", "config"), "--no-includes", "--list", "-z"]);
321
+ if (run.status !== 0 || run.overflow)
322
+ return refuse("clone-unreadable");
323
+ let text;
324
+ try {
325
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(run.stdout);
326
+ }
327
+ catch {
328
+ return refuse("clone-config-unsafe");
329
+ }
330
+ const records = text.split("\0");
331
+ if (records.pop() !== "")
332
+ return refuse("clone-unreadable");
333
+ return records.map((record) => {
334
+ const newline = record.indexOf("\n");
335
+ return newline === -1 ? { key: record, value: null } : { key: record.slice(0, newline), value: record.slice(newline + 1) };
336
+ });
337
+ }
338
+ /** Whether a key is one a plain clone carries. What is not recognised is refused: the set of keys that run something is unbounded. */
339
+ function isPlainCloneKey({ key, value }) {
340
+ const first = key.indexOf(".");
341
+ const last = key.lastIndexOf(".");
342
+ if (first < 1 || last === key.length - 1)
343
+ return false;
344
+ const section = key.slice(0, first).toLowerCase();
345
+ const variable = key.slice(last + 1).toLowerCase();
346
+ const subsection = first === last ? null : key.slice(first + 1, last);
347
+ if (section === "core") {
348
+ if (subsection !== null || !CONFIG_CORE.has(variable))
349
+ return false;
350
+ if (variable === "repositoryformatversion")
351
+ return value === "0";
352
+ if (variable === "bare")
353
+ return value === "false";
354
+ return true;
355
+ }
356
+ if (section === "user")
357
+ return subsection === null && CONFIG_USER.has(variable);
358
+ if (subsection === null || subsection === "")
359
+ return false;
360
+ if (section === "remote")
361
+ return CONFIG_REMOTE.has(variable);
362
+ if (section === "branch")
363
+ return CONFIG_BRANCH.has(variable);
364
+ return false;
365
+ }
366
+ function checkConfig(entries) {
367
+ if (!entries.every(isPlainCloneKey))
368
+ refuse("clone-config-unsafe");
369
+ }
370
+ // ---- origin and the remote tip -------------------------------------------------
371
+ function originUrl(entries) {
372
+ const urls = entries.filter((entry) => entry.key === "remote.origin.url");
373
+ const only = urls.length === 1 ? urls[0].value : null;
374
+ // git would read a value that is padded or holds a control character as a different URL than the mapper judged.
375
+ if (only === null || only === "" || only !== only.trim() || CONTROL.test(only))
376
+ return refuse("origin-mismatch");
377
+ return only;
378
+ }
379
+ /** The exact forms of a GitHub origin: https, scp-style ssh and `ssh://`, with an owner and a name and nothing more. */
380
+ const GITHUB_HTTPS = /^https:\/\/github\.com\/([^/?#@:\s]+)\/([^/?#@:\s]+)$/u;
381
+ const GITHUB_SSH = /^(?:git@github\.com:|ssh:\/\/git@github\.com\/)([^/?#@:\s]+)\/([^/?#@:\s]+)$/u;
382
+ /** An origin URL as `owner/name` when it is exactly an https or ssh GitHub URL; any other scheme, host, port, user, extra path, query or fragment is null. */
383
+ export function defaultOriginId(url) {
384
+ const match = GITHUB_HTTPS.exec(url) ?? GITHUB_SSH.exec(url);
385
+ if (match === null)
386
+ return null;
387
+ const parsed = parseGitHubRemote(`https://github.com/${match[1]}/${match[2]}`);
388
+ return parsed === null ? null : `${parsed.owner}/${parsed.repository}`;
389
+ }
390
+ function checkOrigin(url, id, hubOwner, originId) {
391
+ let named;
392
+ try {
393
+ named = originId(url);
394
+ }
395
+ catch {
396
+ return refuse("origin-mismatch");
397
+ }
398
+ if (typeof named !== "string" || !ID_PATTERN.test(named) || !named.includes("/") || !sameRepository(named, id, hubOwner))
399
+ refuse("origin-mismatch");
400
+ }
401
+ function safeBranch(branch) {
402
+ if (!BRANCH_SHAPE.test(branch) || /[:+*]/u.test(branch))
403
+ return false;
404
+ return !branch.includes("..") && !branch.includes("//") && !branch.endsWith("/") && !branch.includes(".lock");
405
+ }
406
+ /** The origin's default branch and its tip, from `git ls-remote`: it writes no ref and no object anywhere. */
407
+ function remoteTip(url, neutral, protocols) {
408
+ const run = git(neutral, ["ls-remote", "--symref", "--", url, "HEAD"], SMALL_OUTPUT, protocols, true);
409
+ if (run.status !== 0 || run.overflow)
410
+ return refuse("remote-tip-unreadable");
411
+ let text;
412
+ try {
413
+ text = new TextDecoder("utf-8", { fatal: true }).decode(run.stdout);
414
+ }
415
+ catch {
416
+ return refuse("remote-tip-unreadable");
417
+ }
418
+ const symrefs = [];
419
+ const oids = [];
420
+ for (const line of text.split("\n")) {
421
+ if (line === "")
422
+ continue;
423
+ const symref = /^ref: (\S+)\t(\S+)$/u.exec(line);
424
+ const oid = /^([0-9a-f]+)\t(\S+)$/u.exec(line);
425
+ if (symref !== null) {
426
+ if (symref[2] === "HEAD")
427
+ symrefs.push(symref[1]);
428
+ }
429
+ else if (oid !== null) {
430
+ if (oid[2] === "HEAD")
431
+ oids.push(oid[1]);
432
+ }
433
+ else
434
+ return refuse("remote-tip-unreadable");
435
+ }
436
+ const target = symrefs.length === 1 ? symrefs[0] : "";
437
+ const branch = target.startsWith("refs/heads/") ? target.slice("refs/heads/".length) : "";
438
+ const tip = oids.length === 1 ? oids[0] : "";
439
+ if (!safeBranch(branch) || !OBJECT_ID.test(tip))
440
+ return refuse("remote-tip-unreadable");
441
+ return { branch, oid: tip };
442
+ }
443
+ // ---- the local clone against the remote tip ----------------------------------------
444
+ /** git's own view of the clone: its work tree is `clone`, its git directory is `clone/.git`, and it is not bare. Runs only after the configuration passed. */
445
+ function checkRepositoryShape(clone) {
446
+ const text = gitText(clone, ["rev-parse", "--show-toplevel", "--is-bare-repository", "--absolute-git-dir"]);
447
+ const lines = text === null ? [] : text.split("\n");
448
+ if (lines.length !== 4 || lines[3] !== "")
449
+ return refuse("clone-unreadable");
450
+ try {
451
+ if (realpathSync(lines[0]) !== clone || lines[1] !== "false" || realpathSync(lines[2]) !== join(clone, ".git"))
452
+ refuse("clone-unreadable");
453
+ }
454
+ catch (cause) {
455
+ if (cause instanceof Refusal)
456
+ throw cause;
457
+ refuse("clone-unreadable");
458
+ }
459
+ }
460
+ function checkHead(clone, tip) {
461
+ const symbolic = git(clone, ["symbolic-ref", "-q", "HEAD"]);
462
+ if (symbolic.status === 1)
463
+ refuse("not-on-default-branch");
464
+ if (symbolic.status !== 0 || symbolic.overflow)
465
+ refuse("clone-unreadable");
466
+ if (symbolic.stdout.toString("utf8").trim() !== `refs/heads/${tip.branch}`)
467
+ refuse("not-on-default-branch");
468
+ const head = git(clone, ["rev-parse", "--verify", "-q", `refs/heads/${tip.branch}^{commit}`]);
469
+ const local = head.status === 0 && !head.overflow ? head.stdout.toString("utf8").trim() : "";
470
+ if (!OBJECT_ID.test(local))
471
+ refuse("clone-unreadable");
472
+ if (local !== tip.oid)
473
+ refuse("remote-tip-mismatch");
474
+ }
475
+ /**
476
+ * The index, read as data: no entry is a submodule, and none carries a flag
477
+ * that hides an edit from `git status` (skip-worktree, assume-unchanged) or is
478
+ * unmerged. Reads the index file only, and neither the working tree nor a
479
+ * submodule.
480
+ */
481
+ function checkIndex(clone) {
482
+ const run = git(clone, ["ls-files", "-v", "-s", "-z"], MAX_TREE_BYTES);
483
+ if (run.overflow)
484
+ refuse("tree-too-large");
485
+ if (run.status !== 0)
486
+ refuse("clone-unreadable");
487
+ let submodule = false;
488
+ let hidden = false;
489
+ let position = 0;
490
+ while (position < run.stdout.length) {
491
+ let end = run.stdout.indexOf(0, position);
492
+ if (end === -1)
493
+ end = run.stdout.length;
494
+ const record = run.stdout.subarray(position, end);
495
+ position = end + 1;
496
+ const tab = record.indexOf(9);
497
+ const header = tab === -1 ? null : INDEX_HEADER.exec(record.subarray(0, tab).toString("latin1"));
498
+ if (header === null)
499
+ return refuse("clone-unreadable");
500
+ const [, tag, mode, stage] = header;
501
+ if (mode === "160000")
502
+ submodule = true;
503
+ // `H` is a cached entry with nothing hidden; anything else is a flag that keeps status from looking.
504
+ if (tag !== "H" || stage !== "0")
505
+ hidden = true;
506
+ }
507
+ if (submodule)
508
+ refuse("submodule-present");
509
+ if (hidden)
510
+ refuse("working-tree-dirty");
511
+ }
512
+ const INDEX_HEADER = /^(\S) (\d{6}) (?:[0-9a-f]{40}|[0-9a-f]{64}) (\d)$/u;
513
+ /** Runs after the tree was listed and checkIndex refused a submodule: it never considers one, since git would open its own repository and read its configuration. */
514
+ function checkClean(clone) {
515
+ const run = git(clone, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=all"], 16 * MIB);
516
+ // Output past the bound is output: a status that long is not clean.
517
+ if (run.overflow || (run.status === 0 && run.stdout.length > 0))
518
+ refuse("working-tree-dirty");
519
+ if (run.status !== 0)
520
+ refuse("clone-unreadable");
521
+ }
522
+ // ---- ports ---------------------------------------------------------------------------
523
+ async function readPorts(id, ports) {
524
+ let nodeId;
525
+ try {
526
+ nodeId = await ports.nodeId(id);
527
+ }
528
+ catch {
529
+ return refuse("node-id-unavailable");
530
+ }
531
+ if (typeof nodeId !== "string" || !NODE_ID_PATTERN.test(nodeId))
532
+ return refuse("node-id-unavailable");
533
+ let visibility;
534
+ try {
535
+ visibility = await ports.visibility(id);
536
+ }
537
+ catch {
538
+ return refuse("visibility-unavailable");
539
+ }
540
+ if (visibility !== "private" && visibility !== "internal" && visibility !== "public")
541
+ return refuse("visibility-unavailable");
542
+ return { nodeId, visibility };
543
+ }
544
+ const LS_TREE_HEADER = /^(\d{6}) (blob|commit) ([0-9a-f]{40}|[0-9a-f]{64}) +(\d+|-)$/u;
545
+ const REGULAR_MODES = new Set(["100644", "100755"]);
546
+ function isRegular(entry) {
547
+ return entry !== null && REGULAR_MODES.has(entry.mode);
548
+ }
549
+ function listTree(clone, commit) {
550
+ const run = git(clone, ["ls-tree", "-r", "-z", "-l", "--full-tree", commit], MAX_TREE_BYTES);
551
+ if (run.overflow)
552
+ return refuse("tree-too-large");
553
+ if (run.status !== 0)
554
+ return refuse("clone-unreadable");
555
+ const entries = [];
556
+ const root = { children: new Map(), entry: null };
557
+ const out = run.stdout;
558
+ const decoder = new TextDecoder("utf-8", { fatal: true });
559
+ let submodule = false;
560
+ let position = 0;
561
+ while (position < out.length) {
562
+ let end = out.indexOf(0, position);
563
+ if (end === -1)
564
+ end = out.length;
565
+ if (entries.length >= MAX_TREE_ENTRIES)
566
+ return refuse("tree-too-large");
567
+ const record = out.subarray(position, end);
568
+ position = end + 1;
569
+ const tab = record.indexOf(9);
570
+ const header = tab === -1 ? null : LS_TREE_HEADER.exec(record.subarray(0, tab).toString("latin1"));
571
+ if (header === null)
572
+ return refuse("clone-unreadable");
573
+ const [, mode, type, oid, size] = header;
574
+ let path;
575
+ try {
576
+ path = decoder.decode(record.subarray(tab + 1));
577
+ }
578
+ catch {
579
+ return refuse("clone-unreadable");
580
+ }
581
+ if (mode === "160000" && type === "commit") {
582
+ submodule = true;
583
+ entries.push({ path, mode, oid, size: 0 });
584
+ continue;
585
+ }
586
+ if (type !== "blob" || size === "-" || (mode !== "100644" && mode !== "100755" && mode !== "120000"))
587
+ return refuse("clone-unreadable");
588
+ const entry = { path, mode, oid, size: Number(size) };
589
+ insertEntry(root, entry);
590
+ entries.push(entry);
591
+ }
592
+ if (submodule)
593
+ return refuse("submodule-present");
594
+ return { entries, root };
595
+ }
596
+ function insertEntry(root, entry) {
597
+ let node = root;
598
+ for (const segment of entry.path.split("/")) {
599
+ // A file and a directory at one path, or one path twice, is a tree git would not have written.
600
+ if (node.entry !== null)
601
+ refuse("clone-unreadable");
602
+ let child = node.children.get(segment);
603
+ if (child === undefined) {
604
+ child = { children: new Map(), entry: null };
605
+ node.children.set(segment, child);
606
+ }
607
+ node = child;
608
+ }
609
+ if (node.entry !== null || node.children.size > 0)
610
+ refuse("clone-unreadable");
611
+ node.entry = entry;
612
+ }
613
+ /** The node at exactly `path`, or null. */
614
+ function nodeAt(tree, path) {
615
+ let node = tree.root;
616
+ for (const segment of path.split("/")) {
617
+ node = node.children.get(segment);
618
+ if (node === undefined)
619
+ return null;
620
+ }
621
+ return node;
622
+ }
623
+ function lookup(tree, path) {
624
+ const node = nodeAt(tree, path);
625
+ if (node === null)
626
+ return { kind: "absent" };
627
+ return isRegular(node.entry) ? { kind: "regular", entry: node.entry } : { kind: "other" };
628
+ }
629
+ // ---- letter case ------------------------------------------------------------------------------
630
+ /**
631
+ * How a case-insensitive or normalising file system may read a name: each
632
+ * segment decomposed, case-folded through both cases, decomposed again, and
633
+ * without trailing dots and spaces.
634
+ */
635
+ export function fold(path) {
636
+ return path
637
+ .split("/")
638
+ .map((segment) => {
639
+ const folded = segment.normalize("NFD").toLowerCase().toUpperCase().toLowerCase().normalize("NFD");
640
+ // A loop, not a pattern: `/[. ]+$/` retries from every dot or space of a long run.
641
+ let end = folded.length;
642
+ while (end > 0 && (folded[end - 1] === "." || folded[end - 1] === " "))
643
+ end -= 1;
644
+ return folded.slice(0, end);
645
+ })
646
+ .join("/");
647
+ }
648
+ /** The read of the packed contract that INTRODUCIBLE_ROOTS makes, kept whole: every path the apply flow may own. */
649
+ const OWNED_PATTERNS = (() => {
650
+ const definitions = loadContract("repository-change-set.json").definitions;
651
+ const patterns = definitions?.ownedPattern?.allOf?.[1]?.enum;
652
+ if (!Array.isArray(patterns) || !patterns.every((entry) => typeof entry === "string"))
653
+ throw new Error("the packed change-set contract has no ownedPattern list");
654
+ return patterns;
655
+ })();
656
+ function patternSet(patterns) {
657
+ return {
658
+ patterns: patterns.map((pattern) => {
659
+ const segments = pattern.split("/");
660
+ return { pattern, first: segments[0], last: segments[segments.length - 1] };
661
+ }),
662
+ };
663
+ }
664
+ const OWNED = patternSet(OWNED_PATTERNS);
665
+ const OWNED_FOLDED = patternSet(OWNED_PATTERNS.map(fold));
666
+ /** Whether `path` matches any pattern; a literal first or last segment of a pattern is checked first, only to avoid matching every path against every pattern. */
667
+ function matchesAny(path, set) {
668
+ const segments = path.split("/");
669
+ const last = segments[segments.length - 1];
670
+ return set.patterns.some(({ pattern, first, last: tail }) => {
671
+ if (first !== "**" && !first.includes("*") && first !== segments[0])
672
+ return false;
673
+ if (tail !== "**" && !tail.includes("*") && tail !== last)
674
+ return false;
675
+ return matchesPathPattern(path, pattern);
676
+ });
677
+ }
678
+ /** Whether `entry` is a link `linkedAgentsPaths` reports under its folded name, so it is reported there rather than refused here. */
679
+ function isReportedAgentsLink(entry) {
680
+ if (entry.mode !== "120000")
681
+ return false;
682
+ const parts = fold(entry.path).split("/");
683
+ return parts.length === 3 && parts[0] === ".agents" && parts[1] === "skills" && parts[2].startsWith("clossys-");
684
+ }
685
+ /** Every file, link and (through its files) directory beneath `node`, and the node's own entry. */
686
+ function entriesUnder(node) {
687
+ const found = [];
688
+ const stack = [node];
689
+ for (let next = stack.pop(); next !== undefined; next = stack.pop()) {
690
+ if (next.entry !== null)
691
+ found.push(next.entry);
692
+ stack.push(...next.children.values());
693
+ }
694
+ return found;
695
+ }
696
+ /**
697
+ * What the head holds at the flow's own paths, by exact spelling, whatever its
698
+ * type: a file or a link is listed, and a directory is listed by every file
699
+ * under it, so that the path reads as occupied and is never planned over. A path
700
+ * that is not the flow's own, but reads as one where letter case or a trailing
701
+ * dot is not told apart, is refused: a write to one could land on the other.
702
+ */
703
+ function ownedEntries(tree) {
704
+ const owned = new Map();
705
+ const folded = new Set();
706
+ const stack = [];
707
+ for (const [name, node] of tree.root.children)
708
+ stack.push({ path: name, node, covered: false });
709
+ for (let item = stack.pop(); item !== undefined; item = stack.pop()) {
710
+ const { path, node } = item;
711
+ let covered = item.covered;
712
+ if (matchesAny(path, OWNED)) {
713
+ const key = fold(path);
714
+ if (folded.has(key))
715
+ refuse("case-variant-owned-path");
716
+ folded.add(key);
717
+ if (!covered)
718
+ for (const entry of entriesUnder(node))
719
+ owned.set(entry.path, entry);
720
+ covered = true;
721
+ }
722
+ else if (matchesAny(fold(path), OWNED_FOLDED) && !(node.entry !== null && isReportedAgentsLink(node.entry)))
723
+ refuse("case-variant-owned-path");
724
+ for (const [name, child] of node.children)
725
+ stack.push({ path: `${path}/${name}`, node: child, covered });
726
+ }
727
+ return [...owned.values()].sort((left, right) => compareCodeUnits(left.path, right.path));
728
+ }
729
+ /** Root names whose case variants are refused: the roots the flow adds, and the two it writes into. */
730
+ const CASE_GUARDED_ROOTS = [...PROFILE_ROOTS, ".github", ".starter"];
731
+ /**
732
+ * Two root entries that differ only by letter case (or by normalisation form or a
733
+ * trailing dot) are one name on a file system that does not tell them apart, and
734
+ * a variant of a root the flow adds stands in for it in every check that looks
735
+ * for the root by name. The roots the flow adds are refused for any spelling but
736
+ * their own; `.github` and `.starter` only when two spellings are present.
737
+ */
738
+ function checkRootNames(tree) {
739
+ const spellings = new Map();
740
+ for (const name of tree.root.children.keys()) {
741
+ const key = fold(name);
742
+ const held = spellings.get(key);
743
+ if (held === undefined)
744
+ spellings.set(key, [name]);
745
+ else
746
+ held.push(name);
747
+ }
748
+ for (const root of CASE_GUARDED_ROOTS) {
749
+ const names = spellings.get(fold(root)) ?? [];
750
+ if (names.length > 1 || (PROFILE_ROOTS.includes(root) && names.length === 1 && names[0] !== root))
751
+ refuse("case-variant-owned-path");
752
+ }
753
+ }
754
+ /** Every node whose path is `segments` when each name is folded, with the spelling it has: a directory may have several spellings at once. */
755
+ function variantsAt(tree, segments) {
756
+ let level = [{ name: "", node: tree.root }];
757
+ for (const segment of segments) {
758
+ const wanted = fold(segment);
759
+ const next = [];
760
+ for (const { node } of level)
761
+ for (const [name, child] of node.children)
762
+ if (fold(name) === wanted)
763
+ next.push({ name, node: child });
764
+ level = next;
765
+ }
766
+ return level;
767
+ }
768
+ /** Whether a node, or a node above it along `segments` under any spelling, is a symbolic link. */
769
+ function anySymlinkOnPath(tree, segments) {
770
+ for (let length = 1; length <= segments.length; length += 1) {
771
+ if (variantsAt(tree, segments.slice(0, length)).some(({ node }) => node.entry?.mode === "120000"))
772
+ return true;
773
+ }
774
+ return false;
775
+ }
776
+ // ---- blobs ------------------------------------------------------------------------------------
777
+ /** Reads blobs of the committed tree by object id, each path once, within the total the observation may read. */
778
+ class BlobReader {
779
+ clone;
780
+ cache = new Map();
781
+ total = 0;
782
+ constructor(clone) {
783
+ this.clone = clone;
784
+ }
785
+ /** The blob's exact bytes; `over` is the skip when it is larger than `cap`. */
786
+ read(entry, cap, over) {
787
+ const cached = this.cache.get(entry.path);
788
+ if (cached !== undefined)
789
+ return cached;
790
+ if (entry.size > cap)
791
+ refuse(over);
792
+ if (this.total + entry.size > MAX_READ_BYTES)
793
+ refuse("observation-too-large");
794
+ const run = git(this.clone, ["cat-file", "blob", entry.oid], entry.size + 1);
795
+ // The listed size and the bytes must agree, or the object is not the one the tree named.
796
+ if (run.status !== 0 || run.overflow || run.stdout.length !== entry.size)
797
+ refuse("clone-unreadable");
798
+ this.total += entry.size;
799
+ this.cache.set(entry.path, run.stdout);
800
+ return run.stdout;
801
+ }
802
+ }
803
+ /**
804
+ * The digest of a file's bytes exactly as materialize computes one: the bytes
805
+ * are read as UTF-8 text, a sequence that is not UTF-8 becoming U+FFFD, and that
806
+ * text is hashed (`readFileSync(path, "utf8")` on disk, `git show` decoded as
807
+ * UTF-8 on a ref, and the same for a link's target). Hashing the raw bytes would
808
+ * give a different digest for a file that is not UTF-8.
809
+ */
810
+ function digestOf(bytes) {
811
+ return contentDigest(bytes.toString("utf8"));
812
+ }
813
+ // ---- strict JSON ------------------------------------------------------------------------------
814
+ /** UTF-8 text, or null. A byte order mark is kept as U+FEFF, which strict JSON refuses. */
815
+ function decodeUtf8(bytes) {
816
+ try {
817
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
818
+ }
819
+ catch {
820
+ return null;
821
+ }
822
+ }
823
+ /**
824
+ * Whether `text` is JSON with no object that repeats a key, at any depth
825
+ * (keys compared after decoding their escapes). JSON.parse takes the last of
826
+ * two equal keys, so it alone would read a document another reader reads
827
+ * differently.
828
+ */
829
+ function isStrictJson(text) {
830
+ let at = 0;
831
+ const number = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/uy;
832
+ const space = () => {
833
+ while (at < text.length && (text[at] === " " || text[at] === "\t" || text[at] === "\n" || text[at] === "\r"))
834
+ at += 1;
835
+ };
836
+ const string = () => {
837
+ if (text[at] !== '"')
838
+ return null;
839
+ const start = at;
840
+ at += 1;
841
+ while (at < text.length) {
842
+ const code = text.charCodeAt(at);
843
+ if (code === 0x22) {
844
+ at += 1;
845
+ return JSON.parse(text.slice(start, at));
846
+ }
847
+ if (code < 0x20)
848
+ return null;
849
+ if (code !== 0x5c) {
850
+ at += 1;
851
+ continue;
852
+ }
853
+ const escape = text[at + 1];
854
+ if (escape === "u") {
855
+ if (!/^[0-9a-fA-F]{4}$/u.test(text.slice(at + 2, at + 6)))
856
+ return null;
857
+ at += 6;
858
+ }
859
+ else if (escape !== undefined && '"\\/bfnrt'.includes(escape))
860
+ at += 2;
861
+ else
862
+ return null;
863
+ }
864
+ return null;
865
+ };
866
+ const value = (depth) => {
867
+ if (depth > MAX_JSON_DEPTH)
868
+ return false;
869
+ space();
870
+ const start = text[at];
871
+ if (start === "{") {
872
+ at += 1;
873
+ space();
874
+ if (text[at] === "}") {
875
+ at += 1;
876
+ return true;
877
+ }
878
+ const seen = new Set();
879
+ for (;;) {
880
+ space();
881
+ const key = string();
882
+ if (key === null || seen.has(key))
883
+ return false;
884
+ seen.add(key);
885
+ space();
886
+ if (text[at] !== ":")
887
+ return false;
888
+ at += 1;
889
+ if (!value(depth + 1))
890
+ return false;
891
+ space();
892
+ if (text[at] === ",")
893
+ at += 1;
894
+ else if (text[at] === "}") {
895
+ at += 1;
896
+ return true;
897
+ }
898
+ else
899
+ return false;
900
+ }
901
+ }
902
+ if (start === "[") {
903
+ at += 1;
904
+ space();
905
+ if (text[at] === "]") {
906
+ at += 1;
907
+ return true;
908
+ }
909
+ for (;;) {
910
+ if (!value(depth + 1))
911
+ return false;
912
+ space();
913
+ if (text[at] === ",")
914
+ at += 1;
915
+ else if (text[at] === "]") {
916
+ at += 1;
917
+ return true;
918
+ }
919
+ else
920
+ return false;
921
+ }
922
+ }
923
+ if (start === '"')
924
+ return string() !== null;
925
+ for (const literal of ["true", "false", "null"]) {
926
+ if (text.startsWith(literal, at)) {
927
+ at += literal.length;
928
+ return true;
929
+ }
930
+ }
931
+ number.lastIndex = at;
932
+ const match = number.exec(text);
933
+ if (match === null)
934
+ return false;
935
+ at += match[0].length;
936
+ return true;
937
+ };
938
+ if (!value(0))
939
+ return false;
940
+ space();
941
+ return at === text.length;
942
+ }
943
+ /** The JSON a file holds, or null when it is not UTF-8 text without a byte order mark, holds a repeated key, or is not JSON. */
944
+ function parseStrictJson(bytes) {
945
+ const text = decodeUtf8(bytes);
946
+ if (text === null || !isStrictJson(text))
947
+ return null;
948
+ try {
949
+ return { text, value: JSON.parse(text) };
950
+ }
951
+ catch {
952
+ return null;
953
+ }
954
+ }
955
+ function isRecord(value) {
956
+ return typeof value === "object" && value !== null && !Array.isArray(value);
957
+ }
958
+ const PLACEMENTS = ["dependencies", "devDependencies"];
959
+ function readManifest(entry, reader) {
960
+ const parsed = parseStrictJson(reader.read(entry, MAX_BLOB_BYTES, "manifest-unreadable"));
961
+ if (parsed === null || !isRecord(parsed.value))
962
+ return refuse("manifest-unreadable");
963
+ const manifest = parsed.value;
964
+ const entries = [];
965
+ for (const placement of PLACEMENTS) {
966
+ if (!Object.hasOwn(manifest, placement))
967
+ continue;
968
+ const map = manifest[placement];
969
+ if (!isRecord(map))
970
+ return refuse("manifest-unreadable");
971
+ const rows = [];
972
+ for (const [name, value] of Object.entries(map)) {
973
+ if (typeof value !== "string")
974
+ return refuse("manifest-unreadable");
975
+ rows.push({ placement, name, value });
976
+ }
977
+ entries.push(...rows.sort((left, right) => compareCodeUnits(left.name, right.name)));
978
+ }
979
+ let declared;
980
+ if (Object.hasOwn(manifest, "packageManager")) {
981
+ const field = manifest.packageManager;
982
+ const match = typeof field === "string" ? PACKAGE_MANAGER_FIELD.exec(field) : null;
983
+ declared = match === null ? null : match[1];
984
+ }
985
+ return { entries, declared };
986
+ }
987
+ /** The lockfile files present at the root, in any spelling, and whether a lockfile of a manager this module does not read is. */
988
+ function rootLockfiles(tree) {
989
+ return {
990
+ known: LOCKFILES.filter((lockfile) => variantsAt(tree, [lockfile.name]).length > 0),
991
+ other: OTHER_LOCKFILES.some((name) => variantsAt(tree, [name]).length > 0),
992
+ };
993
+ }
994
+ /**
995
+ * The Starter the lockfile locks must have a lockfile row of that name and
996
+ * version: not an `npm:` alias, not another package installed under its name,
997
+ * and not a tarball of another package. Anything else is a lockfile this module
998
+ * does not interpret. The host and the integrity are the planner's to check.
999
+ */
1000
+ function checkStarterIdentity(view, row, manifest) {
1001
+ const locked = view.entries.find((candidate) => candidate.key === (view.format === "npm" ? `node_modules/${STARTER}` : `${STARTER}@${row.version}`));
1002
+ const written = manifest?.entries.find((candidate) => candidate.placement === row.placement && candidate.name === row.name);
1003
+ const named = locked !== undefined && !locked.link && locked.name === STARTER && locked.installedName === STARTER && locked.version === row.version;
1004
+ const fetched = locked?.tarball == null || locked.tarball.endsWith(`/${STARTER}/-/starter-${row.version}.tgz`);
1005
+ const aliased = row.specifier.startsWith("npm:") || (written !== undefined && written.value !== row.specifier);
1006
+ if (!named || !fetched || aliased)
1007
+ refuse("lockfile-unreadable");
1008
+ }
1009
+ function lockedPackagesOf(entry, format, reader, manifest) {
1010
+ const text = decodeUtf8(reader.read(entry, MAX_LOCKFILE_BYTES, "lockfile-unreadable"));
1011
+ if (text === null)
1012
+ return refuse("lockfile-unreadable");
1013
+ let view;
1014
+ try {
1015
+ view = readLockfile(format, text);
1016
+ }
1017
+ catch {
1018
+ return refuse("lockfile-unreadable");
1019
+ }
1020
+ if (isUnreadable(view))
1021
+ return refuse("lockfile-unreadable");
1022
+ const seen = new Map();
1023
+ for (const row of view.root) {
1024
+ if ((row.placement !== "dependencies" && row.placement !== "devDependencies") || row.version === null || row.link)
1025
+ continue;
1026
+ if (row.integrity === null || !INTEGRITY.test(row.integrity))
1027
+ continue;
1028
+ if (row.name === STARTER)
1029
+ checkStarterIdentity(view, row, manifest);
1030
+ const key = `${row.name}\0${row.version}`;
1031
+ const held = seen.get(key);
1032
+ // One name and version with two integrity values is a lockfile that says two things.
1033
+ if (held !== undefined && held.integrity !== row.integrity)
1034
+ return refuse("lockfile-unreadable");
1035
+ seen.set(key, { name: row.name, version: row.version, integrity: row.integrity });
1036
+ }
1037
+ return [...seen.values()].sort((left, right) => compareTuples([left.name, left.version], [right.name, right.version]));
1038
+ }
1039
+ function readPackageState(tree, manifest, reader) {
1040
+ const { known, other } = rootLockfiles(tree);
1041
+ if (manifest === null) {
1042
+ if (known.length > 0 || other)
1043
+ return refuse("package-manager-conflict");
1044
+ return { packageManager: "none", lockfile: "none", lockedPackages: [] };
1045
+ }
1046
+ if (known.length > 1 || other)
1047
+ return refuse("lockfile-ambiguous");
1048
+ const lockfile = known[0];
1049
+ if (lockfile === undefined) {
1050
+ // Nothing but the package.json field decides, and it is not guessed.
1051
+ if (manifest.declared === undefined || manifest.declared === null)
1052
+ return refuse("package-manager-unknown");
1053
+ return { packageManager: manifest.declared, lockfile: "none", lockedPackages: [] };
1054
+ }
1055
+ if (manifest.declared === null)
1056
+ return refuse("package-manager-unknown");
1057
+ if (manifest.declared !== undefined && manifest.declared !== lockfile.manager)
1058
+ return refuse("package-manager-conflict");
1059
+ const found = lookup(tree, lockfile.name);
1060
+ if (found.kind !== "regular" || lockfile.manager === "yarn")
1061
+ return refuse("lockfile-unreadable");
1062
+ return { packageManager: lockfile.manager, lockfile: lockfile.name, lockedPackages: lockedPackagesOf(found.entry, lockfile.manager, reader, manifest) };
1063
+ }
1064
+ // ---- the other fields ----------------------------------------------------------------------------
1065
+ function readReleaseAgeSurfaces(tree) {
1066
+ const found = [];
1067
+ for (const { surface, path } of RELEASE_AGE_SURFACES) {
1068
+ const variants = variantsAt(tree, [path]);
1069
+ if (variants.length === 0)
1070
+ continue;
1071
+ const only = variants[0];
1072
+ if (variants.length > 1 || only.name !== path || !isRegular(only.node.entry))
1073
+ refuse("release-age-surface-invalid");
1074
+ found.push({ surface, path });
1075
+ }
1076
+ return found.sort((left, right) => compareTuples([left.surface, left.path], [right.surface, right.path]));
1077
+ }
1078
+ /**
1079
+ * Whether a workflow of the repository's own is at the head: a regular file
1080
+ * directly in `.github/workflows`, spelled exactly so (GitHub reads no other
1081
+ * spelling), that is not a `clossys-` file.
1082
+ */
1083
+ function hasConsumerCi(tree) {
1084
+ const workflows = nodeAt(tree, ".github/workflows");
1085
+ if (workflows === null)
1086
+ return false;
1087
+ for (const [name, child] of workflows.children) {
1088
+ if (isRegular(child.entry) && /\.ya?ml$/iu.test(name) && !fold(name).startsWith("clossys-"))
1089
+ return true;
1090
+ }
1091
+ return false;
1092
+ }
1093
+ function readSymlinkedSkillRoots(tree) {
1094
+ return DISCOVERY.filter((root) => anySymlinkOnPath(tree, root.split("/")));
1095
+ }
1096
+ function readLinkedAgentsPaths(tree) {
1097
+ const linked = new Set();
1098
+ if (anySymlinkOnPath(tree, [".agents"]))
1099
+ linked.add(".agents");
1100
+ if (variantsAt(tree, [".agents", "skills"]).some(({ node }) => node.entry?.mode === "120000"))
1101
+ linked.add(".agents/skills");
1102
+ for (const { node } of variantsAt(tree, [".agents", "skills"])) {
1103
+ for (const [name, child] of node.children) {
1104
+ const folded = fold(name);
1105
+ if (child.entry?.mode === "120000" && folded.startsWith("clossys-"))
1106
+ linked.add(`.agents/skills/${folded}`);
1107
+ }
1108
+ }
1109
+ const paths = [...linked].sort(compareCodeUnits);
1110
+ if (!paths.every((path) => AGENTS_LINK.test(path)))
1111
+ refuse("agents-link-unreportable");
1112
+ return paths;
1113
+ }
1114
+ function readOwnedFiles(owned, reader) {
1115
+ if (owned.length > MAX_OWNED_FILES)
1116
+ refuse("observation-too-large");
1117
+ const lockfileNames = new Set(LOCKFILES.map((lockfile) => lockfile.name));
1118
+ return owned.map((entry) => ({ path: entry.path, sha256: digestOf(reader.read(entry, lockfileNames.has(entry.path) ? MAX_LOCKFILE_BYTES : MAX_BLOB_BYTES, "observation-too-large")) }));
1119
+ }
1120
+ function readLedger(tree, reader) {
1121
+ const found = lookup(tree, LEDGER_PATH);
1122
+ if (found.kind === "absent")
1123
+ return null;
1124
+ if (found.kind === "other")
1125
+ return refuse("ledger-unreadable");
1126
+ return new Uint8Array(reader.read(found.entry, MAX_BLOB_BYTES, "observation-too-large"));
1127
+ }
1128
+ /** The composed-skill manifest's entries, or null when it is absent or is not exactly the shape the flow writes. */
1129
+ function readSkillsManifest(tree, reader) {
1130
+ const found = lookup(tree, SKILLS_MANIFEST_PATH);
1131
+ if (found.kind !== "regular")
1132
+ return null;
1133
+ const parsed = parseStrictJson(reader.read(found.entry, MAX_BLOB_BYTES, "observation-too-large"));
1134
+ const document = parsed?.value;
1135
+ if (!isRecord(document) || Object.keys(document).length !== 2 || document.schemaVersion !== 1 || !Array.isArray(document.skills))
1136
+ return null;
1137
+ const skills = [];
1138
+ for (const skill of document.skills) {
1139
+ if (!isRecord(skill) || !hasExactKeys(skill, ["name", "source", "sha256", "version"]))
1140
+ return null;
1141
+ const { name, source, sha256, version } = skill;
1142
+ if (typeof name !== "string" || !SKILL_NAME.test(name) || typeof source !== "string" || typeof version !== "string")
1143
+ return null;
1144
+ if (typeof sha256 !== "string" || !SHA256_HEX.test(sha256) || skills.some((held) => held.name === name))
1145
+ return null;
1146
+ skills.push({ name, sha256 });
1147
+ }
1148
+ return skills.sort((left, right) => compareCodeUnits(left.name, right.name));
1149
+ }
1150
+ function hasExactKeys(record, keys) {
1151
+ const own = Object.keys(record);
1152
+ return own.length === keys.length && keys.every((key) => Object.hasOwn(record, key));
1153
+ }
1154
+ /**
1155
+ * The Controller repository profile: the one Controller would locate, its path, and what a set that adds the four roots would break in
1156
+ * it. `setupRoots` are the roots a setup set adds beside them, when the repository is in its setup phase.
1157
+ */
1158
+ function readProfile(tree, reader, setupRoots) {
1159
+ const path = locateProfile(tree);
1160
+ if (path === null)
1161
+ return { profile: null, text: null };
1162
+ const unparseable = { profile: { path, rootVocabulary: "unparseable", undeclaredRoots: [], prohibitedRoots: [] }, text: null };
1163
+ const entry = nodeAt(tree, path).entry;
1164
+ if (entry.size > MAX_BLOB_BYTES)
1165
+ return unparseable;
1166
+ const parsed = parseStrictJson(reader.read(entry, MAX_BLOB_BYTES, "observation-too-large"));
1167
+ if (parsed === null)
1168
+ return unparseable;
1169
+ // checkRootNames() refused every spelling but the exact one, so exact presence is presence.
1170
+ const introduced = [...PROFILE_ROOTS, ...setupRoots].filter((root) => !tree.root.children.has(root));
1171
+ const verdict = wouldViolateRootEntries(parsed.value, introduced);
1172
+ if (verdict.verdict === "indeterminate")
1173
+ return unparseable;
1174
+ if (verdict.verdict === "satisfied")
1175
+ return { profile: { path, rootVocabulary: verdict.vocabulary, undeclaredRoots: [], prohibitedRoots: [] }, text: null };
1176
+ return {
1177
+ profile: { path, rootVocabulary: "checked", undeclaredRoots: [...verdict.undeclared], prohibitedRoots: [...verdict.prohibited] },
1178
+ text: verdict.undeclared.length > 0 ? parsed.text : null,
1179
+ };
1180
+ }
1181
+ /** The path of the profile Controller would locate: `governance/` first, otherwise the one candidate in the tree; null for none. */
1182
+ function locateProfile(tree) {
1183
+ const first = lookup(tree, PROFILE_FIRST);
1184
+ if (first.kind === "regular")
1185
+ return PROFILE_FIRST;
1186
+ if (first.kind === "other")
1187
+ return refuse("profile-ambiguous");
1188
+ const candidates = tree.entries.filter((entry) => {
1189
+ const segments = entry.path.split("/");
1190
+ if (!PROFILE_NAMES.has(segments[segments.length - 1]))
1191
+ return false;
1192
+ if (segments.slice(0, -1).some((directory) => PROFILE_SKIPPED_DIRECTORIES.has(directory)))
1193
+ return false;
1194
+ return !entry.path.startsWith(".github/workflows/");
1195
+ });
1196
+ if (candidates.length > 1 || (candidates.length === 1 && !isRegular(candidates[0])))
1197
+ return refuse("profile-ambiguous");
1198
+ return candidates[0]?.path ?? null;
1199
+ }
1200
+ /** `apply` only when the base carries every proof of an earlier set; any doubt is `setup`. */
1201
+ function readPhase(tree, ledger, manifest, locked) {
1202
+ if (ledger === null)
1203
+ return "setup";
1204
+ let readable;
1205
+ try {
1206
+ readable = readInstalledLedger(ledger) !== null;
1207
+ }
1208
+ catch {
1209
+ readable = false;
1210
+ }
1211
+ if (!readable)
1212
+ return "setup";
1213
+ if (!Object.values(TEMPLATE_PATHS).every((paths) => paths.every((path) => lookup(tree, path).kind === "regular")))
1214
+ return "setup";
1215
+ const starters = manifest.filter((entry) => entry.name === STARTER);
1216
+ const version = starters.length === 1 ? starters[0].value : "";
1217
+ // The pin must also be one the setup templates support, or the request they wrote could not have named it.
1218
+ if (!EXACT_VERSION.test(version) || !isSupportedStarterVersion(version))
1219
+ return "setup";
1220
+ return locked.some((pkg) => pkg.name === STARTER && pkg.version === version) ? "apply" : "setup";
1221
+ }
1222
+ /** The exact text of a regular file at the root, or null when it is absent, not a regular file, or not UTF-8. */
1223
+ function readSurfaceText(tree, reader, path) {
1224
+ const found = lookup(tree, path);
1225
+ return found.kind === "regular" ? decodeUtf8(reader.read(found.entry, MAX_BLOB_BYTES, "observation-too-large")) : null;
1226
+ }
1227
+ /** `files` with the .npmrc's digest added when it is there: a pnpm setup reads it, though the flow never writes it. */
1228
+ function withNpmrc(files, tree, reader) {
1229
+ const found = lookup(tree, NPMRC_PATH);
1230
+ if (found.kind !== "regular")
1231
+ return files;
1232
+ return [...files, { path: NPMRC_PATH, sha256: digestOf(reader.read(found.entry, MAX_BLOB_BYTES, "observation-too-large")) }].sort((left, right) => compareCodeUnits(left.path, right.path));
1233
+ }
1234
+ /**
1235
+ * The root names a setup set adds beyond the four every set adds: the two directories its templates go in, and, for a pnpm
1236
+ * repository with no workspace file, that file when the exemption edit will create it (a conflicting .npmrc refuses the edit, and
1237
+ * then nothing creates it).
1238
+ */
1239
+ function setupRoots(tree, packageManager, npmrcText) {
1240
+ const roots = [...SETUP_PROFILE_ROOTS];
1241
+ if (packageManager === "pnpm" && !tree.root.children.has(PNPM_WORKSPACE_PATH) && editReleaseAgeExemption({ surface: "pnpm-workspace", text: null, npmrc: npmrcText }).kind === "edited") {
1242
+ roots.push(PNPM_WORKSPACE_PATH);
1243
+ }
1244
+ return roots;
1245
+ }
1246
+ /** Everything read from the committed tree, which is the tree at the head commit. */
1247
+ function readCommittedHead(clone, tree) {
1248
+ checkRootNames(tree);
1249
+ const owned = ownedEntries(tree);
1250
+ const reader = new BlobReader(clone);
1251
+ const packageJson = lookup(tree, "package.json");
1252
+ if (packageJson.kind === "other")
1253
+ refuse("manifest-unreadable");
1254
+ const manifest = packageJson.kind === "regular" ? readManifest(packageJson.entry, reader) : null;
1255
+ const packages = readPackageState(tree, manifest, reader);
1256
+ const releaseAgeSurfaces = readReleaseAgeSurfaces(tree);
1257
+ const linkedAgentsPaths = readLinkedAgentsPaths(tree);
1258
+ const files = withNpmrc(readOwnedFiles(owned, reader), tree, reader);
1259
+ const ledger = readLedger(tree, reader);
1260
+ const skillsManifest = readSkillsManifest(tree, reader);
1261
+ const manifestEntries = manifest?.entries ?? [];
1262
+ const phase = readPhase(tree, ledger, manifestEntries, packages.lockedPackages);
1263
+ const pnpmWorkspaceText = readSurfaceText(tree, reader, PNPM_WORKSPACE_PATH);
1264
+ const npmrcText = readSurfaceText(tree, reader, NPMRC_PATH);
1265
+ const { profile, text } = readProfile(tree, reader, phase === "setup" ? setupRoots(tree, packages.packageManager, npmrcText) : []);
1266
+ return {
1267
+ phase,
1268
+ packageManager: packages.packageManager,
1269
+ lockfile: packages.lockfile,
1270
+ releaseAgeSurfaces,
1271
+ consumerCi: hasConsumerCi(tree),
1272
+ symlinkedSkillRoots: readSymlinkedSkillRoots(tree),
1273
+ repositoryProfile: profile,
1274
+ linkedAgentsPaths,
1275
+ files,
1276
+ manifestEntries,
1277
+ lockedPackages: packages.lockedPackages,
1278
+ ledger,
1279
+ skillsManifest,
1280
+ repositoryProfileText: text,
1281
+ pnpmWorkspaceText,
1282
+ npmrcText,
1283
+ };
1284
+ }
1285
+ /**
1286
+ * Observes one clone: the committed head of a clean clone whose origin is the
1287
+ * repository `id` names, or the reason it cannot be observed. Nothing in the
1288
+ * clone is written or executed, and its working tree is not read.
1289
+ */
1290
+ export async function observeRepository(input) {
1291
+ const { id, hubOwner, ports } = input;
1292
+ let neutral = null;
1293
+ try {
1294
+ checkId(id, hubOwner);
1295
+ // Objects from a store the clone does not own are refused, as the alternates file that names one is.
1296
+ if ((process.env.GIT_ALTERNATE_OBJECT_DIRECTORIES ?? "") !== "")
1297
+ refuse("clone-unreadable");
1298
+ const clone = locateClone(input.clone);
1299
+ // A directory of its own, outside the clone, for the commands that must not see any repository's configuration.
1300
+ neutral = mkdtempSync(join(tmpdir(), "observe-repository-"));
1301
+ const config = readConfig(clone, neutral);
1302
+ checkConfig(config);
1303
+ checkRepositoryShape(clone);
1304
+ const url = originUrl(config);
1305
+ checkOrigin(url, id, hubOwner, ports.originId ?? defaultOriginId);
1306
+ // The file transport is only for an origin the caller's own mapper named: a local path is never what the default parser names.
1307
+ const tip = remoteTip(url, neutral, ports.originId === undefined ? REMOTE_PROTOCOLS : `${REMOTE_PROTOCOLS}:file`);
1308
+ checkHead(clone, tip);
1309
+ // The object database first: the head tree is listed, and a submodule refused, before any command reads the working tree.
1310
+ const tree = listTree(clone, tip.oid);
1311
+ checkIndex(clone);
1312
+ checkClean(clone);
1313
+ const { nodeId, visibility } = await readPorts(id, ports);
1314
+ const head = readCommittedHead(clone, tree);
1315
+ return { id, nodeId, visibility, defaultBranch: tip.branch, baseCommit: tip.oid, ...head };
1316
+ }
1317
+ catch (cause) {
1318
+ // Anything that is not a named refusal is still not an observation.
1319
+ const reason = cause instanceof Refusal ? cause.reason : "clone-unreadable";
1320
+ return { id, skipped: reason, verdict: SKIPS[reason] };
1321
+ }
1322
+ finally {
1323
+ if (neutral !== null)
1324
+ rmSync(neutral, { recursive: true, force: true });
1325
+ }
1326
+ }
1327
+ // ---- the committed files, for a caller that writes them elsewhere -------------------------------
1328
+ /** The most files a caller may be handed; a tree with more is refused as `tree-too-large`. */
1329
+ const MAX_COMMITTED_FILES = 20_000;
1330
+ /**
1331
+ * Every file of the tree at `commit`, read as observeRepository() reads a tree: the clone is vetted the same way (a real
1332
+ * `.git` of its own, a `.git/config` of plain keys only, no alternate object store), and then only `ls-tree` and `cat-file` run,
1333
+ * at that commit. Nothing is checked out and no index, ref or worktree is written; the working tree is never read. A submodule,
1334
+ * more than a fixed number of files, or more than the observation's read total is refused, and the reason is one fixed id.
1335
+ */
1336
+ export function readCommittedFiles(cloneInput, commit) {
1337
+ let neutral = null;
1338
+ try {
1339
+ if (!OBJECT_ID.test(commit))
1340
+ refuse("clone-unreadable");
1341
+ if ((process.env.GIT_ALTERNATE_OBJECT_DIRECTORIES ?? "") !== "")
1342
+ refuse("clone-unreadable");
1343
+ const clone = locateClone(cloneInput);
1344
+ neutral = mkdtempSync(join(tmpdir(), "observe-repository-"));
1345
+ checkConfig(readConfig(clone, neutral));
1346
+ checkRepositoryShape(clone);
1347
+ const named = git(clone, ["rev-parse", "--verify", "-q", `${commit}^{commit}`]);
1348
+ if (named.status !== 0 || named.overflow || named.stdout.toString("utf8").trim() !== commit)
1349
+ refuse("clone-unreadable");
1350
+ const tree = listTree(clone, commit);
1351
+ if (tree.entries.length > MAX_COMMITTED_FILES)
1352
+ refuse("tree-too-large");
1353
+ if (tree.entries.reduce((sum, entry) => sum + entry.size, 0) > MAX_READ_BYTES)
1354
+ refuse("tree-too-large");
1355
+ const reader = new BlobReader(clone);
1356
+ const files = tree.entries.map((entry) => ({ path: entry.path, mode: entry.mode, bytes: reader.read(entry, MAX_READ_BYTES, "tree-too-large") }));
1357
+ return { ok: true, files };
1358
+ }
1359
+ catch (cause) {
1360
+ return { ok: false, reason: cause instanceof Refusal ? cause.reason : "clone-unreadable" };
1361
+ }
1362
+ finally {
1363
+ if (neutral !== null)
1364
+ rmSync(neutral, { recursive: true, force: true });
1365
+ }
1366
+ }
1367
+ //# sourceMappingURL=observe-repository.js.map