@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
@@ -30,7 +30,14 @@ export async function prepareFixture({ declaredPath, root, groupDirectory }) {
30
30
  const files = await fingerprintDirectory(sourcePath);
31
31
  const snapshotPath = path.join(groupDirectory, "resources", "fixture");
32
32
  await mkdir(path.dirname(snapshotPath), { recursive: true });
33
- await cp(sourcePath, snapshotPath, { recursive: true, dereference: false, errorOnExist: true, force: false });
33
+ // Links are copied as they are: Node otherwise rewrites a relative link into an absolute one into the source.
34
+ await cp(sourcePath, snapshotPath, {
35
+ recursive: true,
36
+ dereference: false,
37
+ verbatimSymlinks: true,
38
+ errorOnExist: true,
39
+ force: false,
40
+ });
34
41
  const snapshotFiles = await fingerprintDirectory(await realpath(snapshotPath));
35
42
  const sourceFilesAfter = await fingerprintDirectory(sourcePath);
36
43
  if (
@@ -57,11 +64,14 @@ export async function materializeFixture({ fixture, workspace }) {
57
64
  await cp(fixture.snapshotPath, workspace, {
58
65
  recursive: true,
59
66
  dereference: false,
67
+ verbatimSymlinks: true,
60
68
  errorOnExist: true,
61
69
  force: false,
62
70
  });
63
71
  const materializedPath = await realpath(workspace);
64
- const materializedFiles = await fingerprintDirectory(materializedPath);
72
+ // A workspace may hold links a subject made (an earlier stage's workspace), recorded rather than followed; a
73
+ // declared fixture's links were already checked when it was snapshotted.
74
+ const materializedFiles = await fingerprintDirectory(materializedPath, { links: "record" });
65
75
  if (JSON.stringify(materializedFiles) !== JSON.stringify(fixture.files)) {
66
76
  throw new Error(`declared fixture changed while it was being copied: ${fixture.declaredPath}`);
67
77
  }
@@ -126,6 +136,7 @@ export async function materializeRuntime({ runtime, variantDirectory }) {
126
136
  await cp(bundle.root, destination, {
127
137
  recursive: true,
128
138
  dereference: false,
139
+ verbatimSymlinks: true,
129
140
  errorOnExist: true,
130
141
  force: false,
131
142
  });
@@ -228,13 +239,84 @@ export async function materializeEnvironment({ environment, root, variantDirecto
228
239
  };
229
240
  }
230
241
 
242
+ /**
243
+ * Snapshots each of the group's two arms' target skill into `destinationRoot/<arm>` and checks that the two differ
244
+ * only in the frontmatter description: every other file, and SKILL.md outside its description, must be
245
+ * byte-identical. Throws with the first difference found.
246
+ */
247
+ export async function verifyDescriptionOnlyRevision({ variants, root, destinationRoot }) {
248
+ const canonicalRoot = await realpath(root);
249
+ const snapshots = {};
250
+ const [reference, revised] = Object.keys(variants);
251
+ for (const variant of [reference, revised]) {
252
+ const environment = variants[variant];
253
+ const declaredPath = environment.skills[environment.target];
254
+ const destination = path.join(destinationRoot, variant);
255
+ if (environment.source.kind === "working-tree") {
256
+ snapshots[variant] = await snapshotWorkingTreeResource({
257
+ canonicalRoot,
258
+ declaredPath,
259
+ destination,
260
+ requireDirectory: true,
261
+ label: "skill",
262
+ });
263
+ } else {
264
+ const git = await prepareGitSource(canonicalRoot, environment.source.ref);
265
+ snapshots[variant] = await snapshotGitResource({
266
+ ...git,
267
+ declaredPath,
268
+ destination,
269
+ requireDirectory: true,
270
+ label: "skill",
271
+ });
272
+ }
273
+ }
274
+ const baselineFiles = new Map(snapshots[reference].files.map((file) => [file.path, file]));
275
+ const candidateFiles = new Map(snapshots[revised].files.map((file) => [file.path, file]));
276
+ for (const filePath of new Set([...baselineFiles.keys(), ...candidateFiles.keys()])) {
277
+ if (filePath === "SKILL.md") continue;
278
+ const baselineFile = baselineFiles.get(filePath);
279
+ const candidateFile = candidateFiles.get(filePath);
280
+ if (!baselineFile || !candidateFile || baselineFile.sha256 !== candidateFile.sha256) {
281
+ throw new Error(`description-only revision changes ${filePath} outside SKILL.md`);
282
+ }
283
+ }
284
+ const [baselineSkill, candidateSkill] = await Promise.all(
285
+ [snapshots[reference], snapshots[revised]].map((snapshot) =>
286
+ readFile(path.join(snapshot.path, "SKILL.md"), "utf8"),
287
+ ),
288
+ );
289
+ if (withoutDescription(baselineSkill) !== withoutDescription(candidateSkill)) {
290
+ throw new Error("description-only revision changes SKILL.md outside its frontmatter description");
291
+ }
292
+ }
293
+
294
+ // Removes the frontmatter `description` entry (one line, or a block scalar with its indented continuation).
295
+ function withoutDescription(text) {
296
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(text);
297
+ if (!match) return text;
298
+ const kept = [];
299
+ let skipping = false;
300
+ for (const line of match[1].split(/\r?\n/)) {
301
+ if (/^description\s*:/.test(line)) {
302
+ skipping = true;
303
+ continue;
304
+ }
305
+ if (skipping && /^\s+\S/.test(line)) continue;
306
+ skipping = false;
307
+ kept.push(line);
308
+ }
309
+ return `---\n${kept.join("\n")}\n---${match[2]}${text.slice(match[0].length)}`;
310
+ }
311
+
231
312
  export async function verifyEnvironment(environment) {
232
313
  for (const [label, resources] of [
233
314
  ["skill", environment.skills],
234
315
  ["context", environment.context],
235
316
  ]) {
236
317
  for (const resource of resources) {
237
- const current = await fingerprintDirectory(resource.path);
318
+ // An earlier stage's delivered work may hold links its subject made; it is checked the way it was recorded.
319
+ const current = await fingerprintDirectory(resource.path, { links: resource.links ?? "strict" });
238
320
  if (JSON.stringify(current) !== JSON.stringify(resource.files)) {
239
321
  throw new Error(`declared ${label} changed during subject execution: ${resource.declaredPath}`);
240
322
  }
@@ -253,9 +335,12 @@ export async function snapshotWorkspace({ workspace, variantDirectory, turn, bef
253
335
  await rm(snapshotPath, { recursive: true, force: true });
254
336
  await mkdir(path.dirname(snapshotPath), { recursive: true });
255
337
 
256
- const sourceBefore = await fingerprintDirectory(workspace);
338
+ const sourceBefore = await fingerprintDirectory(workspace, WORKSPACE);
257
339
  await cp(workspace, snapshotPath, { recursive: true, force: false, errorOnExist: true, verbatimSymlinks: true });
258
- const [sourceAfter, files] = await Promise.all([fingerprintDirectory(workspace), fingerprintDirectory(snapshotPath)]);
340
+ const [sourceAfter, files] = await Promise.all([
341
+ fingerprintDirectory(workspace, WORKSPACE),
342
+ fingerprintDirectory(snapshotPath, WORKSPACE),
343
+ ]);
259
344
  if (
260
345
  JSON.stringify(sourceBefore) !== JSON.stringify(sourceAfter) ||
261
346
  JSON.stringify(sourceAfter) !== JSON.stringify(files)
@@ -265,10 +350,22 @@ export async function snapshotWorkspace({ workspace, variantDirectory, turn, bef
265
350
  return { path: snapshotPath, files, changes: workspaceChanges(before, files) };
266
351
  }
267
352
 
268
- export async function fingerprintDirectory(root) {
353
+ /** How a subject's workspace is fingerprinted: its links are the subject's own work, recorded and never followed. */
354
+ export const WORKSPACE = Object.freeze({ links: "record" });
355
+
356
+ /**
357
+ * Every entry under `root`, sorted: files and links with a SHA-256, directories, and each link's target. A declared
358
+ * resource (`links: "strict"`, the default) may hold only relative links that stay inside it. A subject's workspace
359
+ * (`links: "record"`) keeps any link the subject made, such as one an installer pointed at the container's
360
+ * workdir: it is recorded with its target, as a link, and nothing outside is ever read through it.
361
+ *
362
+ * @param {string} root
363
+ * @param {{ links?: "strict" | "record" }} [options]
364
+ */
365
+ export async function fingerprintDirectory(root, { links = "strict" } = {}) {
269
366
  const canonicalRoot = await realpath(root);
270
367
  const files = [];
271
- await walk(canonicalRoot, canonicalRoot, files);
368
+ await walk(canonicalRoot, canonicalRoot, files, links);
272
369
  return files.sort((left, right) => comparePaths(left.path, right.path));
273
370
  }
274
371
 
@@ -304,7 +401,13 @@ async function snapshotWorkingTreeResource({ canonicalRoot, declaredPath, destin
304
401
  : [{ path: path.basename(sourcePath), type: "file", sha256: sha256(await readFile(sourcePath)) }];
305
402
  if (sourceStat.isDirectory()) {
306
403
  await mkdir(path.dirname(destination), { recursive: true });
307
- await cp(sourcePath, destination, { recursive: true, dereference: false, errorOnExist: true, force: false });
404
+ await cp(sourcePath, destination, {
405
+ recursive: true,
406
+ dereference: false,
407
+ verbatimSymlinks: true,
408
+ errorOnExist: true,
409
+ force: false,
410
+ });
308
411
  } else {
309
412
  await mkdir(destination, { recursive: true });
310
413
  await copyFile(sourcePath, path.join(destination, path.basename(sourcePath)));
@@ -462,7 +565,7 @@ async function contextFiles(root, fingerprints) {
462
565
  return files;
463
566
  }
464
567
 
465
- async function walk(root, directory, files) {
568
+ async function walk(root, directory, files, links) {
466
569
  const entries = await readdir(directory, { withFileTypes: true });
467
570
  entries.sort((left, right) => comparePaths(left.name, right.name));
468
571
  for (const entry of entries) {
@@ -471,14 +574,16 @@ async function walk(root, directory, files) {
471
574
  const entryStat = await lstat(entryPath);
472
575
  if (entryStat.isSymbolicLink()) {
473
576
  const target = await readlink(entryPath);
474
- const canonicalTarget = await realpath(entryPath).catch(() => null);
475
- if (path.isAbsolute(target) || canonicalTarget === null || !isContained(root, canonicalTarget)) {
476
- throw new Error(`resource symlink escapes its declared root: ${relativePath}`);
577
+ if (links !== "record") {
578
+ const canonicalTarget = await realpath(entryPath).catch(() => null);
579
+ if (path.isAbsolute(target) || canonicalTarget === null || !isContained(root, canonicalTarget)) {
580
+ throw new Error(`resource symlink escapes its declared root: ${relativePath}`);
581
+ }
477
582
  }
478
- files.push({ path: relativePath, type: "symlink", sha256: sha256(`symlink\0${target}`) });
583
+ files.push({ path: relativePath, type: "symlink", sha256: sha256(`symlink\0${target}`), target });
479
584
  } else if (entryStat.isDirectory()) {
480
585
  files.push({ path: relativePath, type: "directory", sha256: sha256("directory") });
481
- await walk(root, entryPath, files);
586
+ await walk(root, entryPath, files, links);
482
587
  } else if (entryStat.isFile()) {
483
588
  files.push({ path: relativePath, type: "file", sha256: sha256(await readFile(entryPath)) });
484
589
  } else {
@@ -0,0 +1,457 @@
1
+ // The transcript scan's reading of a bash command: its simple commands, each as its words, and the network targets
2
+ // they reach for. It is a heuristic over shell text, not a shell: it follows quotes, `bash -c` strings, command
3
+ // substitutions and heredocs fed to a shell, and expands variables assigned a literal earlier in the same command.
4
+ // A target it cannot resolve (a variable read from a file) is not shown to be external, so it is not reported.
5
+
6
+ const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
7
+ // Programs that only run the program after them.
8
+ const WRAPPERS = new Set(["sudo", "nohup", "command", "exec", "time", "nice", "stdbuf", "caffeinate"]);
9
+ const MAX_DEPTH = 4;
10
+
11
+ /**
12
+ * The simple commands of a shell command line, each `{ words, bodies }`: its words with quotes removed, and the
13
+ * text of each heredoc fed to it. A string run by a shell (`bash -c`), a command substitution and a heredoc fed to a
14
+ * shell are parsed as commands too; a heredoc fed to anything else (a script for `node`, a JSON payload) is data.
15
+ *
16
+ * @param {string} text
17
+ * @returns {{ words: string[], bodies: string[] }[]}
18
+ */
19
+ export function simpleCommands(text, depth = 0) {
20
+ const commands = [];
21
+ const nested = [];
22
+ let current = { words: [], bodies: [] };
23
+ let word = null;
24
+ let heredocs = [];
25
+ const endWord = () => {
26
+ if (word !== null) current.words.push(word);
27
+ word = null;
28
+ };
29
+ const endCommand = () => {
30
+ endWord();
31
+ if (current.words.length > 0) commands.push(current);
32
+ current = { words: [], bodies: [] };
33
+ };
34
+ let index = 0;
35
+ while (index < text.length) {
36
+ const char = text[index];
37
+ if (char === "\\") {
38
+ if (text[index + 1] !== "\n") word = (word ?? "") + (text[index + 1] ?? "");
39
+ index += 2;
40
+ } else if (char === "'") {
41
+ const end = text.indexOf("'", index + 1);
42
+ const stop = end === -1 ? text.length : end;
43
+ word = (word ?? "") + text.slice(index + 1, stop);
44
+ index = stop + 1;
45
+ } else if (char === '"') {
46
+ let cursor = index + 1;
47
+ let value = "";
48
+ while (cursor < text.length && text[cursor] !== '"') {
49
+ if (text[cursor] === "\\" && cursor + 1 < text.length) {
50
+ value += text[cursor + 1];
51
+ cursor += 2;
52
+ } else {
53
+ value += text[cursor];
54
+ cursor += 1;
55
+ }
56
+ }
57
+ word = (word ?? "") + value;
58
+ index = cursor + 1;
59
+ } else if (char === "$" && text[index + 1] === "(" && text[index + 2] !== "(") {
60
+ const end = closing(text, index + 1);
61
+ nested.push(text.slice(index + 2, end));
62
+ word = (word ?? "") + text.slice(index, end + 1);
63
+ index = end + 1;
64
+ } else if (char === "`") {
65
+ const end = text.indexOf("`", index + 1);
66
+ const stop = end === -1 ? text.length : end;
67
+ nested.push(text.slice(index + 1, stop));
68
+ word = (word ?? "") + text.slice(index, stop + 1);
69
+ index = stop + 1;
70
+ } else if (char === "#" && word === null) {
71
+ const end = text.indexOf("\n", index);
72
+ index = end === -1 ? text.length : end;
73
+ } else if (char === "<" && text[index + 1] === "<" && text[index + 2] !== "<") {
74
+ endWord();
75
+ let cursor = index + 2;
76
+ const strip = text[cursor] === "-";
77
+ if (strip) cursor += 1;
78
+ while (text[cursor] === " " || text[cursor] === "\t") cursor += 1;
79
+ let delimiter = "";
80
+ while (cursor < text.length && !/[\s;&|<>()]/.test(text[cursor])) {
81
+ if (text[cursor] !== "'" && text[cursor] !== '"' && text[cursor] !== "\\") delimiter += text[cursor];
82
+ cursor += 1;
83
+ }
84
+ heredocs.push({ delimiter, strip, owner: current });
85
+ index = cursor;
86
+ } else if (char === "<" || char === ">") {
87
+ endWord();
88
+ index += 1;
89
+ // `2>&1`, `>&2`: a descriptor, not a command separator.
90
+ if (text[index] === "&") index += 1;
91
+ while (text[index] === ">" || text[index] === "|") index += 1;
92
+ } else if (char === "\n") {
93
+ endCommand();
94
+ index += 1;
95
+ for (const heredoc of heredocs) {
96
+ const body = [];
97
+ while (index < text.length) {
98
+ const end = text.indexOf("\n", index);
99
+ const line = text.slice(index, end === -1 ? text.length : end);
100
+ index = end === -1 ? text.length : end + 1;
101
+ if ((heredoc.strip ? line.replace(/^\t+/, "") : line) === heredoc.delimiter) break;
102
+ body.push(line);
103
+ }
104
+ heredoc.owner.bodies.push(body.join("\n"));
105
+ }
106
+ heredocs = [];
107
+ } else if (";&|()".includes(char)) {
108
+ endCommand();
109
+ index += 1;
110
+ } else if (char === " " || char === "\t" || char === "\r") {
111
+ endWord();
112
+ index += 1;
113
+ } else {
114
+ word = (word ?? "") + char;
115
+ index += 1;
116
+ }
117
+ }
118
+ endCommand();
119
+ if (depth >= MAX_DEPTH) return commands;
120
+ const all = [];
121
+ for (const command of commands) {
122
+ all.push(command);
123
+ const { name, args } = program(command.words);
124
+ if (SHELLS.has(name)) {
125
+ const flag = args.findIndex((arg) => /^-[a-z]*c[a-z]*$/.test(arg));
126
+ if (flag !== -1 && typeof args[flag + 1] === "string") all.push(...simpleCommands(args[flag + 1], depth + 1));
127
+ for (const body of command.bodies) all.push(...simpleCommands(body, depth + 1));
128
+ }
129
+ }
130
+ for (const inner of nested) all.push(...simpleCommands(inner, depth + 1));
131
+ return all;
132
+ }
133
+
134
+ /** The index of the parenthesis that closes the one at `open`, or the text's end. */
135
+ function closing(text, open) {
136
+ let depth = 0;
137
+ for (let index = open; index < text.length; index += 1) {
138
+ if (text[index] === "(") depth += 1;
139
+ else if (text[index] === ")") {
140
+ depth -= 1;
141
+ if (depth === 0) return index;
142
+ }
143
+ }
144
+ return text.length;
145
+ }
146
+
147
+ const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s;
148
+
149
+ /** The program a simple command runs and its arguments: leading assignments and wrappers (`sudo`, `env`) skipped. */
150
+ export function program(words) {
151
+ let index = 0;
152
+ while (index < words.length) {
153
+ const word = words[index];
154
+ if (ASSIGNMENT.test(word)) {
155
+ index += 1;
156
+ } else if (WRAPPERS.has(word)) {
157
+ index += 1;
158
+ while (index < words.length && words[index].startsWith("-")) index += 1;
159
+ } else if (word === "env") {
160
+ index += 1;
161
+ while (index < words.length && (words[index].startsWith("-") || ASSIGNMENT.test(words[index]))) index += 1;
162
+ } else if (word === "timeout") {
163
+ index += 1;
164
+ while (index < words.length && words[index].startsWith("-")) index += 1;
165
+ index += 1;
166
+ } else {
167
+ break;
168
+ }
169
+ }
170
+ const name = (words[index] ?? "").split("/").at(-1);
171
+ return { name, args: words.slice(index + 1) };
172
+ }
173
+
174
+ /** Variables a command line assigns a literal, in order: `NAME=value` and `export NAME=value`. */
175
+ export function assignments(commands) {
176
+ const values = new Map();
177
+ for (const { words } of commands) {
178
+ for (const word of words[0] === "export" || words[0] === "local" ? words.slice(1) : words) {
179
+ const match = ASSIGNMENT.exec(word);
180
+ if (match === null) break;
181
+ if (!match[2].includes("$(") && !match[2].includes("`")) values.set(match[1], expand(match[2], values));
182
+ }
183
+ }
184
+ return values;
185
+ }
186
+
187
+ /** `$NAME` and `${NAME}` replaced by their literal values; unknown variables are left as they are. */
188
+ export function expand(word, values) {
189
+ return word.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, braced, bare) => {
190
+ const value = values.get(braced ?? bare);
191
+ return value === undefined ? whole : value;
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Whether a host is this machine or the container's own network: loopback, `0.0.0.0`, `*.localhost`, or a name with
197
+ * no dot (a container's service name, which a sealed run cannot resolve beyond its own network anyway).
198
+ */
199
+ export function isInternalHost(host) {
200
+ const name = host.toLowerCase().replace(/^\[|\]$/g, "");
201
+ if (name === "localhost" || name.endsWith(".localhost") || name === "0.0.0.0" || name === "::1") return true;
202
+ if (/^127(?:\.\d{1,3}){3}$/.test(name)) return true;
203
+ return !name.includes(".") && !name.includes(":");
204
+ }
205
+
206
+ const SCHEME_URL = /^([a-z][a-z0-9+.-]*):\/\/(?:[^@/?#\s]*@)?(\[[^\]]+\]|[^:/?#\s]+)/i;
207
+ const BARE_HOST = /^(?:[^@/\s]+@)?([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)(?::\d+)?(?:[/?#].*)?$/s;
208
+
209
+ // A bare word ending like a file name is a file, not a host (`out.json`, `notes.md`).
210
+ const FILE_ENDING = /\.(?:json|md|txt|log|js|mjs|cjs|ts|html|css|csv|xml|ya?ml|sh|py|out|body|tmp|lock|tgz|gz|zip)$/i;
211
+
212
+ /** The host a URL (or, with `bare`, a `host[:port]/path`) names; null when it is neither or still has a variable. */
213
+ function hostOf(word, { bare = true } = {}) {
214
+ if (word.includes("$")) return null;
215
+ const url = SCHEME_URL.exec(word);
216
+ if (url !== null) return url[1].toLowerCase() === "file" ? null : url[2];
217
+ if (!bare) return null;
218
+ const host = BARE_HOST.exec(word)?.[1] ?? null;
219
+ if (host === null || FILE_ENDING.test(host)) return null;
220
+ // A version or other number (1.2.3) is no host; a dotted IPv4 address is.
221
+ if (/^\d+(?:\.\d+)*$/.test(host) && !/^\d+\.\d+\.\d+\.\d+$/.test(host)) return null;
222
+ return host;
223
+ }
224
+
225
+ // Options of an HTTP client that take a value: the value is a header, payload, file or number, never the target.
226
+ const HTTP_VALUE_OPTIONS = new Set([
227
+ "-A",
228
+ "-b",
229
+ "-c",
230
+ "-C",
231
+ "-d",
232
+ "-D",
233
+ "-e",
234
+ "-E",
235
+ "-F",
236
+ "-H",
237
+ "-K",
238
+ "-m",
239
+ "-o",
240
+ "-O",
241
+ "-P",
242
+ "-r",
243
+ "-T",
244
+ "-t",
245
+ "-u",
246
+ "-U",
247
+ "-w",
248
+ "-X",
249
+ "-a",
250
+ "-i",
251
+ "--cacert",
252
+ "--cert",
253
+ "--config",
254
+ "--connect-timeout",
255
+ "--cookie",
256
+ "--cookie-jar",
257
+ "--data",
258
+ "--data-ascii",
259
+ "--data-binary",
260
+ "--data-raw",
261
+ "--data-urlencode",
262
+ "--dump-header",
263
+ "--form",
264
+ "--header",
265
+ "--key",
266
+ "--max-time",
267
+ "--output",
268
+ "--output-document",
269
+ "--post-data",
270
+ "--post-file",
271
+ "--referer",
272
+ "--request",
273
+ "--retry",
274
+ "--timeout",
275
+ "--tries",
276
+ "--upload-file",
277
+ "--user",
278
+ "--user-agent",
279
+ "--write-out",
280
+ ]);
281
+ const SOCKET_VALUE_OPTIONS = new Set(["-w", "-p", "-s", "-x", "-X", "-i", "-q", "-O", "-I", "-T", "-V"]);
282
+ const SSH_VALUE_OPTIONS = new Set([
283
+ "-p",
284
+ "-P",
285
+ "-i",
286
+ "-l",
287
+ "-o",
288
+ "-F",
289
+ "-J",
290
+ "-b",
291
+ "-c",
292
+ "-D",
293
+ "-E",
294
+ "-e",
295
+ "-L",
296
+ "-m",
297
+ "-O",
298
+ "-Q",
299
+ "-R",
300
+ "-S",
301
+ "-W",
302
+ "-w",
303
+ "-B",
304
+ ]);
305
+
306
+ /** Positional arguments, with the values of the given options skipped. */
307
+ function positionals(args, valueOptions) {
308
+ const found = [];
309
+ for (let index = 0; index < args.length; index += 1) {
310
+ const arg = args[index];
311
+ if (arg === "--url") {
312
+ if (args[index + 1] !== undefined) found.push(args[index + 1]);
313
+ index += 1;
314
+ } else if (valueOptions.has(arg)) {
315
+ index += 1;
316
+ } else if (!arg.startsWith("-") || arg === "-") {
317
+ if (arg !== "-") found.push(arg);
318
+ }
319
+ }
320
+ return found;
321
+ }
322
+
323
+ const PACKAGE_MANAGERS = {
324
+ npm: ["install", "i", "add", "update", "up", "upgrade"],
325
+ pnpm: ["install", "i", "add", "update", "up"],
326
+ yarn: ["add", "upgrade", "up"],
327
+ bun: ["add", "install", "i"],
328
+ pip: ["install", "download"],
329
+ pip3: ["install", "download"],
330
+ gem: ["install"],
331
+ cargo: ["install", "add", "fetch"],
332
+ go: ["get", "install"],
333
+ };
334
+ const SYSTEM_PACKAGES = {
335
+ apt: ["install", "update", "upgrade", "dist-upgrade"],
336
+ "apt-get": ["install", "update", "upgrade", "dist-upgrade"],
337
+ apk: ["add", "update", "upgrade"],
338
+ brew: ["install", "update", "upgrade", "tap"],
339
+ yum: ["install", "update", "upgrade"],
340
+ dnf: ["install", "update", "upgrade"],
341
+ };
342
+ // Options of a package manager that take a value that is not a package.
343
+ const PACKAGE_VALUE_OPTIONS = new Set([
344
+ "--prefix",
345
+ "-C",
346
+ "--cache",
347
+ "--registry",
348
+ "--workspace",
349
+ "-w",
350
+ "--dir",
351
+ "--cwd",
352
+ "-t",
353
+ "--target",
354
+ "-r",
355
+ "--requirement",
356
+ "-c",
357
+ "--constraint",
358
+ "-i",
359
+ "--index-url",
360
+ "--extra-index-url",
361
+ "--root",
362
+ ]);
363
+
364
+ /** Whether a package spec names something local (a path, a file: or link: spec, a tarball beside it). */
365
+ function localSpec(spec) {
366
+ return /^(?:\.|\/|~|file:|link:|workspace:|portal:)/.test(spec) || /\.(?:tgz|tar\.gz|whl|gem)$/.test(spec);
367
+ }
368
+
369
+ /**
370
+ * The network attempts in one bash command: each `{ program, target }`. An HTTP client, socket tool, remote shell or
371
+ * Git remote counts only with an external target; a package install counts only when `sealed` (the run's network
372
+ * was "none") and it names a package from a registry, since installing the project's own dependencies or a local
373
+ * path is ordinary work. Code in the command (a `node -e` script, a heredoc) counts when it fetches an external URL.
374
+ *
375
+ * @param {string} command
376
+ * @param {{ sealed?: boolean }} [options]
377
+ */
378
+ export function networkAttempts(command, { sealed = false } = {}) {
379
+ const commands = simpleCommands(command);
380
+ const values = assignments(commands);
381
+ const attempts = [];
382
+ const external = (name, words, options) => {
383
+ for (const word of words) {
384
+ const host = hostOf(expand(word, values), options);
385
+ if (host !== null && !isInternalHost(host)) attempts.push({ program: name, target: expand(word, values) });
386
+ }
387
+ };
388
+ for (const { words } of commands) {
389
+ const { name, args } = program(words);
390
+ if (["curl", "wget", "http", "https", "xh"].includes(name)) {
391
+ external(name, positionals(args, HTTP_VALUE_OPTIONS));
392
+ } else if (["nc", "ncat", "netcat", "telnet"].includes(name)) {
393
+ external(name, positionals(args, SOCKET_VALUE_OPTIONS).slice(0, 1), { bare: true });
394
+ } else if (name === "ssh") {
395
+ // A remote shell's host may be an alias with no dot, so only a loopback name is this machine.
396
+ const [host] = positionals(args, SSH_VALUE_OPTIONS);
397
+ const target = host === undefined ? null : expand(host, values).replace(/^[^@]*@/, "");
398
+ if (target !== null && !target.includes("$") && !isLoopbackName(target)) attempts.push({ program: name, target });
399
+ } else if (["scp", "sftp", "rsync"].includes(name)) {
400
+ for (const arg of positionals(args, SSH_VALUE_OPTIONS)) {
401
+ const value = expand(arg, values);
402
+ const remote = /^(?:[^@/\s]+@)?([^:/\s]+):/.exec(value);
403
+ const host =
404
+ SCHEME_URL.exec(value)?.[2] ?? (value.startsWith("/") || value.startsWith(".") ? null : remote?.[1]);
405
+ if (host && !host.includes("$") && !isLoopbackName(host)) attempts.push({ program: name, target: value });
406
+ }
407
+ } else if (name === "git") {
408
+ const rest = args.filter((arg, index) => !(args[index - 1] === "-C" || args[index - 1] === "-c"));
409
+ const subcommand = rest.find((arg) => !arg.startsWith("-") && !ASSIGNMENT.test(arg));
410
+ if (["clone", "fetch", "pull", "push", "ls-remote"].includes(subcommand)) {
411
+ const after = rest.slice(rest.indexOf(subcommand) + 1).filter((arg) => !arg.startsWith("-"));
412
+ const remotes = after
413
+ .map((arg) => expand(arg, values))
414
+ .map((arg) => SCHEME_URL.exec(arg)?.[2] ?? /^[^@/\s]+@([^:/\s]+):/.exec(arg)?.[1] ?? null);
415
+ const named = remotes.filter((host) => host !== null);
416
+ if (named.some((host) => !isLoopbackName(host))) {
417
+ attempts.push({ program: `git ${subcommand}`, target: after.join(" ") });
418
+ } else if (named.length === 0 && subcommand !== "clone" && after.every((arg) => !/^[./~]/.test(arg))) {
419
+ // A remote by name (origin): whatever it points at is reached.
420
+ attempts.push({ program: `git ${subcommand}`, target: after[0] ?? "the default remote" });
421
+ }
422
+ }
423
+ } else if (sealed && (name in PACKAGE_MANAGERS || name in SYSTEM_PACKAGES || isPythonPip(name, args))) {
424
+ const pip = isPythonPip(name, args);
425
+ const effective = pip ? args.slice(args.indexOf("pip") + 1) : args;
426
+ const manager = pip ? "pip" : name;
427
+ const subcommand = effective.find((arg) => !arg.startsWith("-"));
428
+ if (effective.includes("--offline")) continue;
429
+ if (name in SYSTEM_PACKAGES) {
430
+ if (SYSTEM_PACKAGES[name].includes(subcommand)) attempts.push({ program: `${name} ${subcommand}`, target: "" });
431
+ continue;
432
+ }
433
+ if (!PACKAGE_MANAGERS[manager].includes(subcommand)) continue;
434
+ const specs = positionals(effective.slice(effective.indexOf(subcommand) + 1), PACKAGE_VALUE_OPTIONS)
435
+ .map((spec) => expand(spec, values))
436
+ .filter((spec) => !spec.includes("$") && !localSpec(spec));
437
+ if (specs.length > 0) attempts.push({ program: `${manager} ${subcommand}`, target: specs.join(" ") });
438
+ }
439
+ }
440
+ // Code the command runs that fetches a literal URL: fetch(), http.get(), requests.get(), urlopen(), WebSocket.
441
+ const code =
442
+ /\b(?:fetch|https?\.(?:get|request)|axios(?:\.(?:get|post|put|request))?|got|requests\.(?:get|post|put|head)|urlopen|WebSocket)\(\s*(?:new URL\(\s*)?(["'`])([^"'`]*)\1/g;
443
+ for (const match of command.matchAll(code)) {
444
+ const host = hostOf(match[2].replace(/\$\{[^}]*\}/g, "x"), { bare: false });
445
+ if (host !== null && !isInternalHost(host)) attempts.push({ program: "code", target: match[2] });
446
+ }
447
+ return attempts;
448
+ }
449
+
450
+ function isLoopbackName(host) {
451
+ const name = host.toLowerCase().replace(/^\[|\]$/g, "");
452
+ return name === "localhost" || name.endsWith(".localhost") || name === "::1" || /^127(?:\.\d{1,3}){3}$/.test(name);
453
+ }
454
+
455
+ function isPythonPip(name, args) {
456
+ return /^python(?:3(?:\.\d+)?)?$/.test(name) && args[0] === "-m" && args[1] === "pip";
457
+ }