@hassangameryt/freeflow 0.8.0 → 0.9.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/.agents/plugins/marketplace.json +2 -2
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +2 -2
  4. package/.codex-plugin/plugin.json +3 -3
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/README.md +96 -149
  7. package/capabilities/cognitive-routing/SKILL.md +21 -129
  8. package/capabilities/cognitive-routing/references/coordinator.md +163 -0
  9. package/capabilities/cognitive-routing/references/worker.md +38 -0
  10. package/capabilities/compaction/SKILL.md +3 -3
  11. package/capabilities/compaction/references/summary-format.md +26 -15
  12. package/capabilities/messaging/SKILL.md +49 -0
  13. package/capabilities/task-memory/SKILL.md +87 -0
  14. package/command-surface.json +6 -54
  15. package/gemini-extension.json +1 -1
  16. package/hooks/claude/hooks.json +13 -3
  17. package/hooks/codex/hooks.json +2 -1
  18. package/hooks/shared/runtime-context.mjs +69 -77
  19. package/package.json +25 -8
  20. package/pi-extension/dist/ask-user/ask.js +108 -0
  21. package/pi-extension/dist/ask-user/form.js +250 -0
  22. package/pi-extension/dist/ask-user/questions.js +66 -0
  23. package/pi-extension/dist/ask-user/tool.js +124 -0
  24. package/pi-extension/dist/cognitive-routing/assembler.js +35 -11
  25. package/pi-extension/dist/cognitive-routing/bootstrap.js +90 -0
  26. package/pi-extension/dist/cognitive-routing/config.js +3 -4
  27. package/pi-extension/dist/cognitive-routing/event-store.js +4 -1
  28. package/pi-extension/dist/cognitive-routing/gate.js +20 -5
  29. package/pi-extension/dist/cognitive-routing/handlers.js +15 -13
  30. package/pi-extension/dist/cognitive-routing/model-control.js +17 -8
  31. package/pi-extension/dist/cognitive-routing/projection.js +23 -6
  32. package/pi-extension/dist/cognitive-routing/provenance.js +3 -1
  33. package/pi-extension/dist/cognitive-routing/render.js +15 -15
  34. package/pi-extension/dist/cognitive-routing/runtime.js +4 -2
  35. package/pi-extension/dist/cognitive-routing/schemas.js +4 -4
  36. package/pi-extension/dist/cognitive-routing/session.js +3 -1
  37. package/pi-extension/dist/cognitive-routing/sources.js +29 -4
  38. package/pi-extension/dist/cognitive-routing/state.js +18 -8
  39. package/pi-extension/dist/cognitive-routing/status.js +4 -3
  40. package/pi-extension/dist/cognitive-routing/tools.js +29 -15
  41. package/pi-extension/dist/cognitive-routing/types.js +10 -1
  42. package/pi-extension/dist/compaction/carry.js +36 -22
  43. package/pi-extension/dist/compaction/config.js +4 -2
  44. package/pi-extension/dist/compaction/controller.js +52 -29
  45. package/pi-extension/dist/compaction/harness.js +19 -3
  46. package/pi-extension/dist/compaction/results.js +14 -13
  47. package/pi-extension/dist/compaction/tool.js +6 -6
  48. package/pi-extension/dist/host/catalog.js +20 -35
  49. package/pi-extension/dist/host/config-validation.js +233 -0
  50. package/pi-extension/dist/host/config.js +78 -91
  51. package/pi-extension/dist/host/inbound.js +142 -0
  52. package/pi-extension/dist/host/projection-tags.js +1 -1
  53. package/pi-extension/dist/host/prompts.js +24 -13
  54. package/pi-extension/dist/host/read-only-session.js +6 -1
  55. package/pi-extension/dist/host/request-history.js +7 -3
  56. package/pi-extension/dist/host/rows.js +25 -0
  57. package/pi-extension/dist/host/runtime-state.js +17 -2
  58. package/pi-extension/dist/host/section-keeper.js +85 -0
  59. package/pi-extension/dist/host/settings/freeflow-command.js +846 -93
  60. package/pi-extension/dist/host/settings/settings-view.js +27 -8
  61. package/pi-extension/dist/host/status.js +19 -5
  62. package/pi-extension/dist/index.js +638 -72
  63. package/pi-extension/dist/messaging/config.js +27 -0
  64. package/pi-extension/dist/messaging/messaging.js +398 -0
  65. package/pi-extension/dist/messaging/registry.js +57 -0
  66. package/pi-extension/dist/messaging/render.js +90 -0
  67. package/pi-extension/dist/messaging/tools.js +109 -0
  68. package/pi-extension/dist/messaging/transport.js +101 -0
  69. package/pi-extension/dist/provider-support/cache/keepalive.js +16 -4
  70. package/pi-extension/dist/task-memory/config.js +49 -0
  71. package/pi-extension/dist/task-memory/registry.js +119 -0
  72. package/pi-extension/dist/task-memory/render.js +107 -0
  73. package/pi-extension/dist/task-memory/runtime.js +994 -0
  74. package/pi-extension/dist/task-memory/tool.js +233 -0
  75. package/pi-extension/dist/tool-execution/apply-patch/tool.js +1 -1
  76. package/pi-extension/dist/tool-execution/background.js +220 -28
  77. package/pi-extension/dist/tool-execution/config.js +14 -3
  78. package/pi-extension/dist/tool-execution/file-tracking.js +24 -4
  79. package/pi-extension/dist/tool-execution/guidance-notices.js +124 -0
  80. package/pi-extension/dist/tool-execution/skill-reads.js +143 -0
  81. package/pi-extension/dist/tool-execution/tools.js +2 -2
  82. package/runtime/prompts/cognitive-routing.md +4 -4
  83. package/runtime/prompts/core.md +24 -14
  84. package/runtime/prompts/interaction-contract.md +7 -4
  85. package/runtime/prompts/tool-execution.md +3 -16
  86. package/runtime/prompts/working-method.md +16 -99
  87. package/skills/deliver-work/SKILL.md +76 -0
  88. package/skills/deliver-work/references/branch-closeout.md +134 -0
  89. package/skills/deliver-work/references/commit.md +83 -0
  90. package/skills/deliver-work/references/launch.md +94 -0
  91. package/skills/deliver-work/references/release.md +85 -0
  92. package/skills/deliver-work/scripts/commit-state.mjs +139 -0
  93. package/skills/deliver-work/scripts/delivery-state.mjs +107 -0
  94. package/skills/deliver-work/scripts/lib/git-state.mjs +178 -0
  95. package/skills/deliver-work/scripts/push-route.mjs +122 -0
  96. package/skills/deliver-work/scripts/release-preflight.mjs +177 -0
  97. package/skills/discuss-work/SKILL.md +148 -0
  98. package/skills/{discuss → discuss-work}/references/discussion-continuity.md +10 -27
  99. package/skills/discuss-work/references/learning-action.md +36 -0
  100. package/skills/discuss-work/scripts/choices.mjs +101 -0
  101. package/skills/discuss-work/scripts/lib/choices.mjs +159 -0
  102. package/skills/evaluate-behavior/SKILL.md +107 -0
  103. package/skills/evaluate-behavior/references/authoring.md +381 -0
  104. package/skills/evaluate-behavior/references/coding-tasks.md +145 -0
  105. package/skills/evaluate-behavior/references/conversations-pipelines-judges.md +177 -0
  106. package/skills/evaluate-behavior/references/design-and-measurement.md +273 -0
  107. package/skills/evaluate-behavior/references/running-and-evidence.md +245 -0
  108. package/skills/evaluate-behavior/scripts/claude-guard.mjs +76 -0
  109. package/skills/evaluate-behavior/scripts/guard-rules.mjs +309 -0
  110. package/skills/evaluate-behavior/scripts/lib/agreement.mjs +90 -0
  111. package/skills/evaluate-behavior/scripts/lib/arms.mjs +55 -0
  112. package/skills/evaluate-behavior/scripts/lib/budget.mjs +87 -0
  113. package/skills/evaluate-behavior/scripts/lib/build-source.mjs +287 -0
  114. package/skills/evaluate-behavior/scripts/lib/clear.mjs +74 -0
  115. package/skills/evaluate-behavior/scripts/lib/container.mjs +395 -0
  116. package/skills/evaluate-behavior/scripts/lib/decision.mjs +418 -0
  117. package/skills/evaluate-behavior/scripts/lib/definitions.mjs +1072 -0
  118. package/skills/evaluate-behavior/scripts/lib/freeflow.mjs +103 -0
  119. package/skills/evaluate-behavior/scripts/lib/grade-command.mjs +591 -0
  120. package/skills/evaluate-behavior/scripts/lib/grade-comparison.mjs +90 -0
  121. package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/grade-interaction.mjs +19 -18
  122. package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/grade-support.mjs +32 -5
  123. package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/grade-workspace.mjs +28 -12
  124. package/skills/evaluate-behavior/scripts/lib/grade.mjs +101 -0
  125. package/skills/evaluate-behavior/scripts/lib/hooks.mjs +98 -0
  126. package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/confinement.mjs +196 -0
  127. package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/constraints.mjs +111 -0
  128. package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/index.mjs +48 -0
  129. package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/isolation.mjs +74 -0
  130. package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/launch.mjs +123 -0
  131. package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/model-call.mjs +73 -0
  132. package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/recorder.mjs +178 -0
  133. package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/session.mjs +494 -0
  134. package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/skills.mjs +81 -0
  135. package/skills/evaluate-behavior/scripts/lib/hosts/index.mjs +31 -0
  136. package/skills/evaluate-behavior/scripts/lib/hosts/names.mjs +3 -0
  137. package/skills/evaluate-behavior/scripts/lib/hosts/pi/confinement.mjs +173 -0
  138. package/skills/evaluate-behavior/scripts/lib/hosts/pi/host.mjs +148 -0
  139. package/skills/evaluate-behavior/scripts/lib/hosts/pi/index.mjs +70 -0
  140. package/skills/evaluate-behavior/scripts/lib/hosts/pi/isolation.mjs +81 -0
  141. package/skills/evaluate-behavior/scripts/lib/hosts/pi/model-call.mjs +79 -0
  142. package/skills/evaluate-behavior/scripts/lib/hosts/pi/process.mjs +241 -0
  143. package/skills/{evaluate-skill/scripts/lib → evaluate-behavior/scripts/lib/hosts/pi}/rpc.mjs +144 -26
  144. package/skills/evaluate-behavior/scripts/lib/identity.mjs +82 -0
  145. package/skills/evaluate-behavior/scripts/lib/judge.mjs +688 -0
  146. package/skills/evaluate-behavior/scripts/lib/metrics.mjs +332 -0
  147. package/skills/evaluate-behavior/scripts/lib/model-call.mjs +17 -0
  148. package/skills/evaluate-behavior/scripts/lib/participants.mjs +616 -0
  149. package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/process.mjs +22 -0
  150. package/skills/evaluate-behavior/scripts/lib/queue.mjs +54 -0
  151. package/skills/evaluate-behavior/scripts/lib/reference.mjs +343 -0
  152. package/skills/evaluate-behavior/scripts/lib/reuse.mjs +277 -0
  153. package/skills/evaluate-behavior/scripts/lib/review.mjs +933 -0
  154. package/skills/evaluate-behavior/scripts/lib/runner.mjs +732 -0
  155. package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/sandbox.mjs +119 -14
  156. package/skills/evaluate-behavior/scripts/lib/scan-shell.mjs +457 -0
  157. package/skills/evaluate-behavior/scripts/lib/scan.mjs +479 -0
  158. package/skills/evaluate-behavior/scripts/lib/seal.mjs +91 -0
  159. package/skills/evaluate-behavior/scripts/lib/shell.mjs +560 -0
  160. package/skills/evaluate-behavior/scripts/lib/source-override.mjs +64 -0
  161. package/skills/evaluate-behavior/scripts/lib/subject-file.mjs +88 -0
  162. package/skills/{evaluate-skill/scripts/lib/pi.mjs → evaluate-behavior/scripts/lib/subject.mjs} +235 -265
  163. package/skills/evaluate-behavior/scripts/lib/task-copy.mjs +124 -0
  164. package/skills/evaluate-behavior/scripts/lib/trials.mjs +524 -0
  165. package/skills/evaluate-behavior/scripts/lib/validity.mjs +68 -0
  166. package/skills/evaluate-behavior/scripts/lib/view-html.mjs +454 -0
  167. package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/view.mjs +159 -18
  168. package/skills/{evaluate-skill → evaluate-behavior}/scripts/pi-command-tool.mjs +21 -6
  169. package/skills/evaluate-behavior/scripts/pi-container-route.mjs +23 -0
  170. package/skills/evaluate-behavior/scripts/pi-guard.mjs +77 -0
  171. package/skills/evaluate-behavior/scripts/pi-login-refresh.mjs +31 -0
  172. package/skills/evaluate-behavior/scripts/skill-eval.mjs +586 -0
  173. package/skills/execute-work/SKILL.md +96 -104
  174. package/skills/execute-work/references/code-practices.md +27 -51
  175. package/skills/execute-work/references/design.md +271 -0
  176. package/skills/execute-work/references/diagnose.md +117 -0
  177. package/skills/execute-work/references/migration.md +108 -0
  178. package/skills/execute-work/references/simplify.md +65 -0
  179. package/skills/execute-work/scripts/self-review.mjs +422 -0
  180. package/skills/execute-work/scripts/touch-map.mjs +290 -0
  181. package/skills/review-work/SKILL.md +89 -65
  182. package/skills/review-work/references/adjudication.md +94 -0
  183. package/skills/review-work/references/artifact.md +198 -0
  184. package/skills/review-work/references/evals-and-judges.md +66 -0
  185. package/skills/review-work/references/work.md +177 -0
  186. package/skills/review-work/scripts/check-brief.mjs +128 -0
  187. package/skills/review-work/scripts/review-packet.mjs +233 -0
  188. package/skills/setup-freeflow/SKILL.md +68 -17
  189. package/skills/track-work/SKILL.md +52 -20
  190. package/skills/track-work/references/handoff.md +190 -0
  191. package/skills/track-work/references/projects.md +51 -0
  192. package/skills/track-work/references/working-record-format.md +36 -18
  193. package/skills/track-work/scripts/lib/document.mjs +17 -13
  194. package/skills/track-work/scripts/lib/format.mjs +2 -0
  195. package/skills/track-work/scripts/lib/recover.mjs +254 -0
  196. package/skills/track-work/scripts/lib/registry.mjs +656 -0
  197. package/skills/track-work/scripts/lib/search.mjs +40 -0
  198. package/skills/track-work/scripts/lib/store.mjs +46 -15
  199. package/skills/track-work/scripts/lib/views.mjs +50 -1
  200. package/skills/track-work/scripts/working-record.mjs +232 -13
  201. package/skills/verify-work/SKILL.md +45 -37
  202. package/skills/verify-work/references/feature-map.md +120 -0
  203. package/skills/verify-work/references/runtime-evidence.md +203 -0
  204. package/skills/verify-work/references/test-design.md +25 -6
  205. package/skills/verify-work/scripts/test-changes.mjs +246 -0
  206. package/skills/write-docs/SKILL.md +113 -25
  207. package/skills/write-docs/references/documentation-shapes.md +68 -3
  208. package/skills/write-docs/references/spec-and-plan-shapes.md +308 -0
  209. package/skills/write-docs/scripts/artifact-check.mjs +148 -0
  210. package/skills/write-docs/scripts/docs-check.mjs +96 -6
  211. package/skills/write-docs/scripts/plan-lint.mjs +123 -0
  212. package/skills/write-skill/SKILL.md +92 -52
  213. package/skills/write-skill/references/activation-and-wording.md +143 -0
  214. package/skills/write-skill/references/placement-and-dependencies.md +154 -0
  215. package/skills/write-skill/scripts/lib/compare.mjs +701 -0
  216. package/skills/write-skill/scripts/lib/skill-author.mjs +197 -3
  217. package/skills/write-skill/scripts/skill-author.mjs +51 -8
  218. package/capabilities/cognitive-routing/references/both-mode.md +0 -36
  219. package/capabilities/cognitive-routing/references/executor-mode.md +0 -48
  220. package/capabilities/cognitive-routing/references/helper-mode.md +0 -47
  221. package/plugin.json +0 -33
  222. package/skills/action-selection/SKILL.md +0 -92
  223. package/skills/action-selection/references/trajectory-stalls.md +0 -73
  224. package/skills/bypass/SKILL.md +0 -153
  225. package/skills/commit-work/SKILL.md +0 -93
  226. package/skills/commit-work/references/git-state-edges.md +0 -94
  227. package/skills/commit-work/references/simple-push.md +0 -94
  228. package/skills/commit-work/references/staging-decisions.md +0 -106
  229. package/skills/decision-gate/SKILL.md +0 -87
  230. package/skills/design-for-depth/SKILL.md +0 -108
  231. package/skills/design-for-depth/references/design-pressure-signals.md +0 -84
  232. package/skills/design-for-depth/references/implementation-decisions.md +0 -76
  233. package/skills/design-for-depth/references/interface-design-loop.md +0 -105
  234. package/skills/design-for-depth/references/module-and-dependency-design.md +0 -117
  235. package/skills/design-for-depth/references/state-and-failure-boundaries.md +0 -125
  236. package/skills/diagnose-failure/SKILL.md +0 -140
  237. package/skills/diagnose-failure/references/diagnostic-loop-catalog.md +0 -50
  238. package/skills/diagnose-failure/references/flaky-and-performance-diagnosis.md +0 -51
  239. package/skills/discuss/SKILL.md +0 -140
  240. package/skills/evaluate-skill/SKILL.md +0 -188
  241. package/skills/evaluate-skill/references/evaluation-definition-schema.md +0 -415
  242. package/skills/evaluate-skill/references/evaluation-design.md +0 -112
  243. package/skills/evaluate-skill/references/execution-and-evidence.md +0 -111
  244. package/skills/evaluate-skill/references/review-and-revision.md +0 -94
  245. package/skills/evaluate-skill/scripts/lib/definitions.mjs +0 -448
  246. package/skills/evaluate-skill/scripts/lib/grade-comparison.mjs +0 -60
  247. package/skills/evaluate-skill/scripts/lib/grade.mjs +0 -64
  248. package/skills/evaluate-skill/scripts/lib/review.mjs +0 -196
  249. package/skills/evaluate-skill/scripts/lib/runner.mjs +0 -405
  250. package/skills/evaluate-skill/scripts/lib/trials.mjs +0 -103
  251. package/skills/evaluate-skill/scripts/pi-guard.mjs +0 -181
  252. package/skills/evaluate-skill/scripts/skill-eval.mjs +0 -182
  253. package/skills/execute-work/references/execute-work-edges.md +0 -78
  254. package/skills/finish-branch/SKILL.md +0 -117
  255. package/skills/finish-branch/references/integration-options.md +0 -80
  256. package/skills/handoff/SKILL.md +0 -82
  257. package/skills/handoff/references/handoff-templates.md +0 -150
  258. package/skills/launch-work/SKILL.md +0 -116
  259. package/skills/launch-work/references/launch-readiness.md +0 -85
  260. package/skills/migration-work/SKILL.md +0 -115
  261. package/skills/migration-work/references/migration-lifecycle.md +0 -82
  262. package/skills/release-work/SKILL.md +0 -118
  263. package/skills/release-work/references/release-evidence.md +0 -78
  264. package/skills/review-artifact/SKILL.md +0 -143
  265. package/skills/review-artifact/references/adjudicate-artifact-review.md +0 -101
  266. package/skills/review-artifact/references/independent-artifact-reviewer-contract.md +0 -159
  267. package/skills/review-work/references/adjudicate-work-review.md +0 -97
  268. package/skills/review-work/references/independent-work-reviewer-contract.md +0 -139
  269. package/skills/review-work/references/security-risk-lens.md +0 -67
  270. package/skills/setup-freeflow/references/activation-contract.md +0 -103
  271. package/skills/setup-freeflow/references/host-setup.md +0 -67
  272. package/skills/simplify-code/SKILL.md +0 -85
  273. package/skills/simplify-code/references/simplification-patterns.md +0 -70
  274. package/skills/verify-work/references/browser-runtime-evidence.md +0 -51
  275. package/skills/verify-work/references/integration-evidence.md +0 -49
  276. package/skills/verify-work/references/performance-evidence.md +0 -59
  277. package/skills/workflow/SKILL.md +0 -171
  278. package/skills/workflow/references/domain-skill-composition.md +0 -45
  279. package/skills/write-plan/SKILL.md +0 -92
  280. package/skills/write-plan/references/plan-shapes.md +0 -156
  281. package/skills/write-skill/references/activation-boundaries.md +0 -106
  282. package/skills/write-skill/references/agent-first-instructions.md +0 -133
  283. package/skills/write-skill/references/progressive-disclosure.md +0 -129
  284. package/skills/write-skill/references/skill-dependency-graphs.md +0 -192
  285. package/skills/write-spec/SKILL.md +0 -85
  286. package/skills/write-spec/references/artifact-standards.md +0 -86
  287. package/skills/write-spec/references/decision-records.md +0 -80
  288. package/skills/write-spec/references/spec-shapes.md +0 -143
  289. /package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/evidence.mjs +0 -0
  290. /package/skills/{evaluate-skill → evaluate-behavior}/scripts/pi-observer.mjs +0 -0
@@ -0,0 +1,560 @@
1
+ // The shell tier: a body group may give its subject `bash` only through a declared isolation extension, and the
2
+ // evaluator never trusts the declaration. Before an arm's first subject it checks the isolation's prerequisites and
3
+ // runs the subject's own Pi, with the arm's extensions and a local scripted model, through probes that a confined
4
+ // shell must fail. Any probe that does not hold makes the group invalid. The evaluator picks no sandbox itself.
5
+
6
+ import { randomBytes } from "node:crypto";
7
+ import { access, constants, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
8
+ import http from "node:http";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { execFile } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+
14
+ import { SHELL_TOOLS } from "../guard-rules.mjs";
15
+ import { containerShell, containerTarget, missingContainerPrerequisites, startContainer } from "./container.mjs";
16
+ import { hostAdapter } from "./hosts/index.mjs";
17
+
18
+ const run = promisify(execFile);
19
+ const PROBE_TIMEOUT_MS = 10 * 60 * 1000;
20
+ const OUTPUT_TAIL = 2000;
21
+ // Planted in Pi's own environment; a confined shell must not see it.
22
+ export const PROBE_VARIABLE = "CONFINEMENT_PROBE";
23
+ const PUBLIC_URL_VARIABLE = "SKILL_EVAL_PROBE_PUBLIC_URL";
24
+ const DEFAULT_PUBLIC_URL = "https://example.com/";
25
+
26
+ /**
27
+ * Prerequisites an isolation may name in `shell.requires`. Each is a program on PATH (any of `programs`), or a
28
+ * minimum Node version for the `node` Pi runs on.
29
+ */
30
+ const PREREQUISITES = {
31
+ qemu: {
32
+ name: "QEMU",
33
+ programs: ["qemu-system-x86_64", "qemu-system-aarch64"],
34
+ install: {
35
+ macos: "brew install qemu",
36
+ apt: process.arch === "arm64" ? "sudo apt-get install qemu-system-arm" : "sudo apt-get install qemu-system-x86",
37
+ dnf: process.arch === "arm64" ? "sudo dnf install qemu-system-aarch64" : "sudo dnf install qemu-system-x86",
38
+ },
39
+ },
40
+ "node-23.6": {
41
+ name: "Node.js 23.6 or newer",
42
+ node: "23.6.0",
43
+ install: {
44
+ macos: "brew install node",
45
+ apt: "install Node.js 23.6 or newer from https://nodejs.org (distribution packages are usually older), or with nvm: nvm install 24",
46
+ dnf: "install Node.js 23.6 or newer from https://nodejs.org (distribution packages are usually older), or with nvm: nvm install 24",
47
+ },
48
+ },
49
+ };
50
+
51
+ /** Named sets of prerequisites, for isolations the evaluator knows. */
52
+ const PROFILES = {
53
+ gondolin: {
54
+ requires: ["qemu", "node-23.6"],
55
+ reason: "Gondolin runs each shell command in a local QEMU micro-VM, and its host library needs Node.js 23.6+",
56
+ download: "Gondolin downloads its guest image on first start (size not verified)",
57
+ },
58
+ };
59
+
60
+ function isPlainObject(value) {
61
+ return value !== null && typeof value === "object" && !Array.isArray(value);
62
+ }
63
+
64
+ /**
65
+ * Validates a configuration's `shell`: `{ isolation, network: "none", workspace?, requires? }`.
66
+ *
67
+ * @param {any} value
68
+ * @param {string} label
69
+ * @param {(code: string, message: string) => never} fail
70
+ */
71
+ export function validateShell(value, label, fail) {
72
+ if (!isPlainObject(value)) fail("invalid-definition", `${label} must be an object`);
73
+ for (const key of Object.keys(value)) {
74
+ if (!["isolation", "network", "workspace", "requires"].includes(key)) {
75
+ fail("invalid-definition", `${label} contains unsupported field ${key}`);
76
+ }
77
+ }
78
+ if (typeof value.isolation !== "string" || value.isolation.trim() === "") {
79
+ fail("invalid-definition", `${label}.isolation must name one of the arm's host.extensions`);
80
+ }
81
+ if (value.network !== "none") fail("invalid-definition", `${label}.network must be "none"`);
82
+ if (Object.hasOwn(value, "workspace")) {
83
+ const workspace = value.workspace;
84
+ if (
85
+ typeof workspace !== "string" ||
86
+ !workspace.startsWith("/") ||
87
+ workspace === "/" ||
88
+ workspace.endsWith("/") ||
89
+ workspace
90
+ .split("/")
91
+ .slice(1)
92
+ .some((part) => part === "" || part === "." || part === "..")
93
+ ) {
94
+ fail("invalid-definition", `${label}.workspace must be an absolute path such as /workspace`);
95
+ }
96
+ }
97
+ if (Object.hasOwn(value, "requires")) {
98
+ if (!Array.isArray(value.requires)) fail("invalid-definition", `${label}.requires must be an array`);
99
+ for (const [index, entry] of value.requires.entries()) {
100
+ if (typeof entry === "string") {
101
+ if (!Object.hasOwn(PROFILES, entry) && !Object.hasOwn(PREREQUISITES, entry)) {
102
+ fail(
103
+ "invalid-definition",
104
+ `${label}.requires[${index}] names an unknown prerequisite ${entry}; known: ${[...Object.keys(PROFILES), ...Object.keys(PREREQUISITES)].join(", ")}`,
105
+ );
106
+ }
107
+ continue;
108
+ }
109
+ validateCustomPrerequisite(entry, `${label}.requires[${index}]`, fail);
110
+ }
111
+ }
112
+ }
113
+
114
+ function validateCustomPrerequisite(entry, label, fail) {
115
+ if (!isPlainObject(entry)) fail("invalid-definition", `${label} must be a known name or an object`);
116
+ for (const key of Object.keys(entry)) {
117
+ if (!["name", "programs", "reason", "install", "download"].includes(key)) {
118
+ fail("invalid-definition", `${label} contains unsupported field ${key}`);
119
+ }
120
+ }
121
+ if (typeof entry.name !== "string" || entry.name === "") fail("invalid-definition", `${label}.name must be a string`);
122
+ if (
123
+ !Array.isArray(entry.programs) ||
124
+ entry.programs.length === 0 ||
125
+ !entry.programs.every((program) => typeof program === "string" && /^[A-Za-z0-9._+-]+$/.test(program))
126
+ ) {
127
+ fail("invalid-definition", `${label}.programs must list program names, any one of which satisfies it`);
128
+ }
129
+ for (const key of ["reason", "download"]) {
130
+ if (Object.hasOwn(entry, key) && typeof entry[key] !== "string") {
131
+ fail("invalid-definition", `${label}.${key} must be a string`);
132
+ }
133
+ }
134
+ if (Object.hasOwn(entry, "install")) {
135
+ if (!isPlainObject(entry.install)) fail("invalid-definition", `${label}.install must be an object`);
136
+ for (const [key, command] of Object.entries(entry.install)) {
137
+ if (!["macos", "apt", "dnf"].includes(key) || typeof command !== "string") {
138
+ fail("invalid-definition", `${label}.install may give macos, apt and dnf commands as strings`);
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ /** A host extension entry as `shell.isolation` names it: the package name, or the source or working-tree path. */
145
+ export function isolationLabel(entry) {
146
+ if (typeof entry?.package === "string") return entry.package;
147
+ if (isPlainObject(entry?.source)) return entry.source.path;
148
+ return entry?.path;
149
+ }
150
+
151
+ /**
152
+ * Group-level rules once any arm declares `shell`: `bash` in the tools, a body group, and every arm's shell isolated
153
+ * by one of that arm's host extensions. A group that declares no shell keeps the run-time refusal of `bash`.
154
+ *
155
+ * @param {{ group: any, names: string[], configurations: Record<string, any>, fail: (code: string, message: string) => never }} options
156
+ */
157
+ export function validateShellGroup({ group, names, configurations, fail }) {
158
+ for (const arm of names) {
159
+ if (configurations[arm].shell !== undefined && configurations[arm].environment !== undefined) {
160
+ fail(
161
+ "invalid-definition",
162
+ `arm ${arm} declares both shell and a container environment, which provides the shell`,
163
+ );
164
+ }
165
+ }
166
+ const containers = names.filter((arm) => configurations[arm].environment !== undefined);
167
+ if (containers.length > 0 && group.type !== "body") {
168
+ fail("invalid-definition", "a container environment is supported only in body groups");
169
+ }
170
+ const declared = names.filter((arm) => configurations[arm].shell !== undefined);
171
+ if (declared.length === 0 && !(group.tools.includes("bash") && containers.length > 0)) return;
172
+ if (!group.tools.includes("bash")) {
173
+ fail("invalid-definition", `arm ${declared[0]} declares shell, so group.tools must include bash`);
174
+ }
175
+ if (group.type !== "body") fail("invalid-definition", "bash is supported only in body groups");
176
+ for (const arm of names) {
177
+ const shell = configurations[arm].shell ?? containerShell(configurations[arm].environment);
178
+ if (shell === undefined) {
179
+ fail(
180
+ "invalid-definition",
181
+ `bash needs a confined shell: arm ${arm} must declare configuration.shell with an isolation extension, or a container environment`,
182
+ );
183
+ }
184
+ if (shell.container !== undefined) continue;
185
+ const labels = (configurations[arm].host.extensions ?? []).map(isolationLabel);
186
+ if (!labels.includes(shell.isolation)) {
187
+ fail(
188
+ "invalid-definition",
189
+ `arm ${arm}'s shell.isolation ${shell.isolation} is not among its host.extensions (${labels.join(", ") || "none"})`,
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ /** The prerequisites a shell declares, expanded from profiles, with the reasons and downloads they carry. */
196
+ function declaredPrerequisites(shell) {
197
+ const entries = [];
198
+ for (const entry of shell.requires ?? []) {
199
+ if (typeof entry !== "string") {
200
+ entries.push({ ...entry, reason: entry.reason ?? null, download: entry.download ?? null });
201
+ continue;
202
+ }
203
+ const profile = PROFILES[entry];
204
+ const names = profile === undefined ? [entry] : profile.requires;
205
+ for (const name of names) {
206
+ entries.push({ ...PREREQUISITES[name], reason: profile?.reason ?? null, download: profile?.download ?? null });
207
+ }
208
+ }
209
+ return entries;
210
+ }
211
+
212
+ /**
213
+ * The declared prerequisites that are missing on this machine, each with why it is needed and how to install it on
214
+ * this operating system.
215
+ *
216
+ * @returns {Promise<Array<{ name: string, need: string, found: string | null, reason: string | null, install: string | null, download: string | null }>>}
217
+ */
218
+ export async function missingPrerequisites(shell, { environment = process.env } = {}) {
219
+ const missing = [];
220
+ const manager = await packageManager(environment);
221
+ for (const prerequisite of declaredPrerequisites(shell)) {
222
+ let need;
223
+ let found = null;
224
+ let satisfied;
225
+ if (prerequisite.node !== undefined) {
226
+ need = `node ${prerequisite.node} or newer on PATH`;
227
+ found = await nodeVersion(environment);
228
+ satisfied = found !== null && compareVersions(found, prerequisite.node) >= 0;
229
+ if (found !== null) found = `node ${found}`;
230
+ } else {
231
+ need = `${prerequisite.programs.join(" or ")} on PATH`;
232
+ satisfied = (await Promise.all(prerequisite.programs.map((program) => onPath(program, environment)))).some(
233
+ Boolean,
234
+ );
235
+ }
236
+ if (satisfied) continue;
237
+ missing.push({
238
+ name: prerequisite.name,
239
+ need,
240
+ found,
241
+ reason: prerequisite.reason,
242
+ install: manager === null ? null : (prerequisite.install?.[manager] ?? null),
243
+ download: prerequisite.download,
244
+ });
245
+ }
246
+ return missing;
247
+ }
248
+
249
+ /** The refusal the agent relays to the user: what is missing, why, how to install it, and that it should ask first. */
250
+ export function prerequisiteRefusal(groupId, arm, shell, missing) {
251
+ const what =
252
+ shell.container === undefined
253
+ ? `declares a shell isolated by ${shell.isolation}`
254
+ : `runs in the container image ${shell.container.image}`;
255
+ const lines = [`group ${groupId} cannot run: arm ${arm} ${what}, and this machine is missing:`];
256
+ for (const entry of missing) {
257
+ lines.push(`- ${entry.name}: needs ${entry.need}${entry.found ? ` (found ${entry.found})` : " (not found)"}.`);
258
+ if (entry.reason) lines.push(` Why: ${entry.reason}.`);
259
+ lines.push(
260
+ ` Install: ${entry.install ?? "no install command is known for this system; see the isolation's documentation"}`,
261
+ );
262
+ if (entry.download) lines.push(` Download: ${entry.download}.`);
263
+ }
264
+ lines.push("Ask the user before installing anything, then run again.");
265
+ return lines.join("\n");
266
+ }
267
+
268
+ export async function packageManager(environment) {
269
+ if (process.platform === "darwin") return "macos";
270
+ if (process.platform !== "linux") return null;
271
+ if (await onPath("apt-get", environment)) return "apt";
272
+ if (await onPath("dnf", environment)) return "dnf";
273
+ return null;
274
+ }
275
+
276
+ async function onPath(program, environment) {
277
+ for (const directory of (environment.PATH ?? "").split(path.delimiter).filter(Boolean)) {
278
+ try {
279
+ await access(path.join(directory, program), constants.X_OK);
280
+ return true;
281
+ } catch {
282
+ // Not in this directory.
283
+ }
284
+ }
285
+ return false;
286
+ }
287
+
288
+ async function nodeVersion(environment) {
289
+ try {
290
+ const { stdout } = await run("node", ["--version"], { env: environment, timeout: 10000 });
291
+ return stdout.trim().replace(/^v/, "");
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
297
+ function compareVersions(left, right) {
298
+ const parse = (value) => value.split(".").map((part) => Number.parseInt(part, 10) || 0);
299
+ const [a, b] = [parse(left), parse(right)];
300
+ for (let index = 0; index < 3; index += 1) {
301
+ if ((a[index] ?? 0) !== (b[index] ?? 0)) return (a[index] ?? 0) - (b[index] ?? 0);
302
+ }
303
+ return 0;
304
+ }
305
+
306
+ /** A failure that makes a group invalid, with structured details for its runs' evidence. */
307
+ export class ShellError extends Error {
308
+ constructor(message, details) {
309
+ super(message);
310
+ this.name = "ShellError";
311
+ this.details = details;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Checks prerequisites and verifies confinement for every selected arm that declares a shell, before any subject
317
+ * starts. Returns each verified arm's confinement record; throws a ShellError when a prerequisite is missing or a
318
+ * probe does not hold.
319
+ *
320
+ * @param {{ group: any, variants: Record<string, string>, hostExtensions: Record<string, any[]>, groupDirectory: string, signal?: AbortSignal }} options
321
+ */
322
+ export async function prepareShell({ group, variants, hostExtensions, groupDirectory, signal }) {
323
+ const confinement = {};
324
+ for (const [arm, state] of Object.entries(variants)) {
325
+ const shell = group.variants[arm]?.shell;
326
+ if (state !== "selected" || shell === undefined) continue;
327
+ const missing =
328
+ shell.container === undefined
329
+ ? await missingPrerequisites(shell)
330
+ : await missingContainerPrerequisites(shell.container, await packageManager(process.env), groupDirectory);
331
+ if (missing.length > 0) {
332
+ throw new ShellError(prerequisiteRefusal(group.id, arm, shell, missing), {
333
+ kind: "shell-prerequisites",
334
+ arm,
335
+ isolation: shell.isolation,
336
+ missing,
337
+ });
338
+ }
339
+ // A container without a shell tool (bash, or Freeflow's background commands, which run in the same shell) gives the
340
+ // subject no shell to probe: it hosts only declared commands and graders.
341
+ if (shell.container !== undefined && !group.tools.some((tool) => SHELL_TOOLS.has(tool))) continue;
342
+ confinement[arm] = await verifyConfinement({
343
+ arm,
344
+ host: group.host ?? "pi",
345
+ shell,
346
+ extensions: hostExtensions[arm] ?? [],
347
+ directory: path.join(groupDirectory, "confinement", arm),
348
+ groupDirectory,
349
+ signal,
350
+ });
351
+ if (!confinement[arm].verified) {
352
+ const failed = confinement[arm].probes.filter((probe) => !probe.held).map((probe) => probe.id);
353
+ throw new ShellError(
354
+ `arm ${arm}'s shell isolated by ${shell.isolation} is not confined: ${failed.join(", ")} did not hold (see confinement in the run evidence)`,
355
+ { kind: "shell-confinement", arm, confinement },
356
+ );
357
+ }
358
+ }
359
+ return confinement;
360
+ }
361
+
362
+ /**
363
+ * Runs the probes for one arm through its own host and extensions, with a local scripted model, and judges each by
364
+ * what the host can observe: files on the host, connections to a host listener, and the probe output.
365
+ */
366
+ export async function verifyConfinement({ arm, host = "pi", shell, extensions, directory, groupDirectory, signal }) {
367
+ await rm(directory, { recursive: true, force: true });
368
+ const workspace = path.join(directory, "workspace");
369
+ const outside = path.join(directory, "outside");
370
+ await mkdir(workspace, { recursive: true });
371
+ await mkdir(outside, { recursive: true });
372
+ const nonce = randomBytes(12).toString("hex");
373
+ const adapter = hostAdapter(host);
374
+ const isolation = await adapter.prepareIsolation(directory, null, { settings: adapter.effectiveSettings({}) });
375
+ // The isolated HOME's parent holds a marker the shell must not read, and so does the directory where the host keeps
376
+ // its login: a shell that can read there could read a subject's copied login.
377
+ const marker = path.join(isolation.evidence.root, "probe-marker");
378
+ await writeFile(marker, `${nonce}\n`);
379
+ const loginMarker = path.join(isolation.loginDirectory ?? isolation.evidence.root, "probe-login-marker");
380
+ await writeFile(loginMarker, `${nonce}\n`);
381
+ const written = path.join(outside, `written-${nonce}`);
382
+
383
+ const listener = await startListener(nonce);
384
+ const publicUrl = Object.hasOwn(process.env, PUBLIC_URL_VARIABLE)
385
+ ? process.env[PUBLIC_URL_VARIABLE]
386
+ : DEFAULT_PUBLIC_URL;
387
+ const urls = [listener.url, ...(publicUrl ? [publicUrl] : [])];
388
+ const probes = [
389
+ {
390
+ id: "workspace-write",
391
+ purpose: "control: the shell runs and writes inside the workspace",
392
+ command: `printf '%s' ${nonce} > confinement-control && cat confinement-control`,
393
+ },
394
+ {
395
+ id: "write-outside",
396
+ purpose: "writing a host file outside the workspace must fail",
397
+ command: `printf '%s' ${nonce} > ${quote(written)}; echo "exit=$?"`,
398
+ },
399
+ {
400
+ id: "read-home-parent",
401
+ purpose: "reading a marker beside the isolated home must fail",
402
+ command: `cat ${quote(marker)}; echo "exit=$?"`,
403
+ },
404
+ {
405
+ id: "network",
406
+ purpose: "reaching the network must fail",
407
+ command: networkCommand(urls),
408
+ },
409
+ {
410
+ id: "environment",
411
+ purpose: `the ${PROBE_VARIABLE} variable planted in ${adapter.label}'s environment must be absent`,
412
+ command: `printenv ${PROBE_VARIABLE} || echo unset`,
413
+ },
414
+ {
415
+ id: "read-login",
416
+ purpose: `reading a marker in ${adapter.label}'s login directory must fail`,
417
+ command: `cat ${quote(loginMarker)}; echo "exit=$?"`,
418
+ },
419
+ ];
420
+
421
+ // A container shell is probed in its own container, started on the probe workspace like a subject's.
422
+ let container = null;
423
+ if (shell.container !== undefined) {
424
+ try {
425
+ container = await startContainer({ container: shell.container, workspace, purpose: "probe" });
426
+ } catch (error) {
427
+ await listener.close();
428
+ throw error;
429
+ }
430
+ }
431
+ // The guard's contract, as for a subject: only bash, confined to the probe workspace.
432
+ const guard = {
433
+ PWD: workspace,
434
+ SKILL_EVAL_ALLOWED_ROOTS: JSON.stringify([workspace]),
435
+ SKILL_EVAL_WRITABLE_ROOT: workspace,
436
+ SKILL_EVAL_ALLOWED_TOOLS: JSON.stringify(["bash"]),
437
+ SKILL_EVAL_CONTEXT_MANIFEST: "",
438
+ SKILL_EVAL_HOST: host,
439
+ SKILL_EVAL_PROMPT_MODE: "isolated",
440
+ SKILL_EVAL_SHELL: JSON.stringify(shell),
441
+ [PROBE_VARIABLE]: nonce,
442
+ ...(container === null ? {} : { SKILL_EVAL_CONTAINER: containerTarget(container) }),
443
+ };
444
+ const timeout = AbortSignal.timeout(PROBE_TIMEOUT_MS);
445
+ const eventsFile = path.join(directory, "events.jsonl");
446
+ const stderrFile = path.join(directory, "stderr.log");
447
+ let probed = { observation: null, launchError: null, results: new Map() };
448
+ let launchError = null;
449
+ try {
450
+ probed = await adapter.probeShell({
451
+ probes,
452
+ workspace,
453
+ isolation,
454
+ extensions,
455
+ guard,
456
+ eventsFile,
457
+ stderrFile,
458
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
459
+ });
460
+ } catch (error) {
461
+ launchError = error instanceof Error ? error.message : String(error);
462
+ } finally {
463
+ await Promise.all([listener.close(), container?.remove()]);
464
+ }
465
+
466
+ const { observation, results } = probed;
467
+ launchError ??= probed.launchError;
468
+ const judged = probes.map((probe) => ({ ...probe, ...results.get(probe.id) }));
469
+ const control = await readFile(path.join(workspace, "confinement-control"), "utf8").catch(() => null);
470
+ const leakedWrite = await stat(written).then(
471
+ () => true,
472
+ () => false,
473
+ );
474
+ const held = {
475
+ "workspace-write": control === nonce,
476
+ "write-outside": !leakedWrite,
477
+ "read-home-parent": !includes(judged[2], nonce),
478
+ network: listener.connections === 0 && !/REACHED:(?!000)\S+/.test(judged[3].output ?? ""),
479
+ environment: !includes(judged[4], nonce),
480
+ "read-login": !includes(judged[5], nonce),
481
+ };
482
+ const record = {
483
+ verified: false,
484
+ isolation: shell.isolation,
485
+ network: shell.network,
486
+ directory: path.relative(groupDirectory, directory).split(path.sep).join("/"),
487
+ probes: judged.map((probe) => ({
488
+ id: probe.id,
489
+ purpose: probe.purpose,
490
+ command: probe.command,
491
+ ran: probe.ran === true,
492
+ held: probe.ran === true && held[probe.id],
493
+ isError: probe.isError ?? null,
494
+ output: (probe.output ?? "").slice(-OUTPUT_TAIL),
495
+ })),
496
+ networkConnections: listener.connections,
497
+ // The host process that ran the probes, recorded under the host's name.
498
+ [host]: {
499
+ exitCode: observation?.exitCode ?? null,
500
+ terminationReason: observation?.terminationReason ?? (timeout.aborted ? "timeout" : null),
501
+ error: launchError,
502
+ stderr: (await readFile(stderrFile, "utf8").catch(() => "")).slice(-OUTPUT_TAIL),
503
+ },
504
+ };
505
+ record.verified = record.probes.every((probe) => probe.held);
506
+ await writeFile(path.join(directory, "confinement.json"), `${JSON.stringify(record, null, 2)}\n`);
507
+ return record;
508
+ }
509
+
510
+ function includes(probe, nonce) {
511
+ return (probe.output ?? "").includes(nonce);
512
+ }
513
+
514
+ function quote(value) {
515
+ return `'${String(value).replaceAll("'", `'\\''`)}'`;
516
+ }
517
+
518
+ /** Tries each URL with whichever HTTP client the shell has, printing REACHED:<status> only on a response. */
519
+ function networkCommand(urls) {
520
+ const lines = urls.map(
521
+ (url) =>
522
+ `if command -v curl >/dev/null 2>&1; then curl -sS -m 8 -o /dev/null -w 'REACHED:%{http_code}\\n' ${quote(url)}; ` +
523
+ `elif command -v wget >/dev/null 2>&1; then wget -q -T 8 -O /dev/null ${quote(url)} && echo REACHED:ok; ` +
524
+ `else echo NO-HTTP-CLIENT; fi`,
525
+ );
526
+ return lines.join("; ");
527
+ }
528
+
529
+ /** An HTTP listener on every interface, reached through the host's first external IPv4 address when it has one. */
530
+ function startListener(nonce) {
531
+ return new Promise((resolve, reject) => {
532
+ const state = { connections: 0 };
533
+ const server = http.createServer((request, response) => {
534
+ response.end(nonce);
535
+ });
536
+ server.on("connection", () => {
537
+ state.connections += 1;
538
+ });
539
+ server.once("error", reject);
540
+ server.listen(0, "0.0.0.0", () => {
541
+ const address = externalAddress() ?? "127.0.0.1";
542
+ resolve({
543
+ url: `http://${address}:${server.address().port}/${nonce}`,
544
+ get connections() {
545
+ return state.connections;
546
+ },
547
+ close: () => new Promise((done) => server.close(() => done())),
548
+ });
549
+ });
550
+ });
551
+ }
552
+
553
+ function externalAddress() {
554
+ for (const entries of Object.values(os.networkInterfaces())) {
555
+ for (const entry of entries ?? []) {
556
+ if (entry.family === "IPv4" && !entry.internal) return entry.address;
557
+ }
558
+ }
559
+ return null;
560
+ }
@@ -0,0 +1,64 @@
1
+ // `--source <arm>=<commit>`: for one run only, every Git source ref an arm declares (its subject source, its
2
+ // Freeflow source, and its source-built host extensions) is replaced by a commit. The definition files never
3
+ // change; the override is recorded in invocation.json and on each run, and it reaches the run's recorded source
4
+ // identity and its reuse key, because both read the effective configuration. An arm with no Git source refuses it.
5
+
6
+ import { ARM_NAME } from "./arms.mjs";
7
+
8
+ const COMMIT = /^[0-9a-f]{7,64}$/;
9
+
10
+ /**
11
+ * Parses `<arm>=<commit>` values into `{ <arm>: <commit> }`.
12
+ *
13
+ * @param {string[]} values
14
+ */
15
+ export function parseSourceOverrides(values) {
16
+ const sources = {};
17
+ for (const value of values) {
18
+ const match = /^([^=]+)=(.+)$/.exec(value);
19
+ if (match === null || !ARM_NAME.test(match[1]) || !COMMIT.test(match[2])) {
20
+ throw new Error(`--source must be <arm>=<commit SHA>, such as candidate=3415a8a2: ${value}`);
21
+ }
22
+ if (Object.hasOwn(sources, match[1])) throw new Error(`--source ${match[1]} may be supplied only once`);
23
+ sources[match[1]] = match[2];
24
+ }
25
+ return sources;
26
+ }
27
+
28
+ /**
29
+ * Applies source overrides to a selection's groups in place, before anything runs. Every overridden arm must be
30
+ * an arm of each selected group and declare at least one Git source there.
31
+ *
32
+ * @param {{groups: Array<{group: any}>}} selection
33
+ * @param {Record<string, string> | null | undefined} sources
34
+ */
35
+ export function applySourceOverrides(selection, sources) {
36
+ if (!sources || Object.keys(sources).length === 0) return;
37
+ for (const [arm, commit] of Object.entries(sources)) {
38
+ for (const { group } of selection.groups) {
39
+ const variant = group.variants?.[arm];
40
+ if (variant === undefined) throw new Error(`--source ${arm}: ${arm} is not an arm of group ${group.id}`);
41
+ const configuration = group.configurations?.[arm];
42
+ let replaced = 0;
43
+ if (variant.source?.kind === "git") {
44
+ variant.source = { ...variant.source, ref: commit };
45
+ if (configuration?.subject?.source?.kind === "git") configuration.subject.source.ref = commit;
46
+ replaced += 1;
47
+ }
48
+ if (variant.freeflow?.source?.kind === "git") {
49
+ variant.freeflow = { ...variant.freeflow, source: { ...variant.freeflow.source, ref: commit } };
50
+ if (configuration?.freeflow?.source) configuration.freeflow.source.ref = commit;
51
+ replaced += 1;
52
+ }
53
+ variant.hostExtensions = (variant.hostExtensions ?? []).map((extension) => {
54
+ if (extension?.source?.kind !== "git") return extension;
55
+ replaced += 1;
56
+ return { ...extension, source: { ...extension.source, ref: commit } };
57
+ });
58
+ if (configuration?.host?.extensions) configuration.host.extensions = variant.hostExtensions;
59
+ if (replaced === 0) {
60
+ throw new Error(`--source ${arm}: arm ${arm} of group ${group.id} declares no Git source to override`);
61
+ }
62
+ }
63
+ }
64
+ }