apphx 1.1.0 → 2.0.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 (120) hide show
  1. package/README.md +50 -182
  2. package/benchmarks/peer-context-worker.mjs +186 -0
  3. package/benchmarks/peer-context.mjs +150 -0
  4. package/dist/apple-types.js.map +1 -1
  5. package/dist/cli.js +370 -118
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.js +42 -4
  8. package/dist/config.js.map +1 -1
  9. package/dist/dependencies.js +455 -16
  10. package/dist/dependencies.js.map +1 -1
  11. package/dist/export-options.js +44 -2
  12. package/dist/export-options.js.map +1 -1
  13. package/dist/flutter.js +13 -25
  14. package/dist/flutter.js.map +1 -1
  15. package/dist/go-dependencies.js +389 -0
  16. package/dist/go-dependencies.js.map +1 -0
  17. package/dist/inbox.js +7 -8
  18. package/dist/inbox.js.map +1 -1
  19. package/dist/integrations.js +3 -3
  20. package/dist/isolation.js +190 -0
  21. package/dist/isolation.js.map +1 -0
  22. package/dist/macos.js +57 -0
  23. package/dist/macos.js.map +1 -0
  24. package/dist/mygo-build.js +55 -0
  25. package/dist/mygo-build.js.map +1 -0
  26. package/dist/policy.js +35 -0
  27. package/dist/policy.js.map +1 -0
  28. package/dist/preflight.js +1 -1
  29. package/dist/process.js +3 -3
  30. package/dist/project-profiles.js +163 -0
  31. package/dist/project-profiles.js.map +1 -0
  32. package/dist/proof.js +33 -0
  33. package/dist/proof.js.map +1 -0
  34. package/dist/remote.js +1 -1
  35. package/dist/runner.js +269 -287
  36. package/dist/runner.js.map +1 -1
  37. package/dist/scheduler.js +0 -3
  38. package/dist/scheduler.js.map +1 -1
  39. package/dist/schemas.js +156 -44
  40. package/dist/schemas.js.map +1 -1
  41. package/dist/store.js +166 -44
  42. package/dist/store.js.map +1 -1
  43. package/dist/vendor/obfuscator/dist/cli.js +72 -0
  44. package/dist/vendor/obfuscator/dist/cli.js.map +1 -0
  45. package/dist/vendor/obfuscator/dist/code.js +585 -0
  46. package/dist/vendor/obfuscator/dist/code.js.map +1 -0
  47. package/dist/vendor/obfuscator/dist/common.js +262 -0
  48. package/dist/vendor/obfuscator/dist/common.js.map +1 -0
  49. package/dist/vendor/obfuscator/dist/config.js +123 -0
  50. package/dist/vendor/obfuscator/dist/config.js.map +1 -0
  51. package/dist/vendor/obfuscator/dist/content.js +89 -0
  52. package/dist/vendor/obfuscator/dist/content.js.map +1 -0
  53. package/dist/vendor/obfuscator/dist/obfuscate.js +93 -0
  54. package/dist/vendor/obfuscator/dist/obfuscate.js.map +1 -0
  55. package/dist/vendor/obfuscator/dist/png.js +283 -0
  56. package/dist/vendor/obfuscator/dist/png.js.map +1 -0
  57. package/dist/vendor/obfuscator/dist/project.js +159 -0
  58. package/dist/vendor/obfuscator/dist/project.js.map +1 -0
  59. package/dist/vendor/obfuscator/dist/resources.js +335 -0
  60. package/dist/vendor/obfuscator/dist/resources.js.map +1 -0
  61. package/dist/vendor/obfuscator/package.json +22 -0
  62. package/dist/workspace.js +113 -0
  63. package/dist/workspace.js.map +1 -0
  64. package/evals/control.mjs +73 -0
  65. package/evals/expectations.mjs +37 -0
  66. package/evals/host.mjs +51 -0
  67. package/evals/oracle.mjs +80 -0
  68. package/evals/policy.mjs +268 -0
  69. package/examples/android-java.factory.json +18 -0
  70. package/examples/android-java.json +16 -0
  71. package/examples/existing-project.factory.json +18 -0
  72. package/examples/existing-project.json +17 -0
  73. package/examples/expo.eas.json +17 -0
  74. package/examples/expo.factory.json +18 -0
  75. package/examples/expo.json +16 -0
  76. package/examples/factory.json +0 -2
  77. package/examples/flutter.factory.json +13 -0
  78. package/examples/flutter.json +16 -0
  79. package/examples/luggage.json +5 -3
  80. package/examples/mygo-web.factory.json +18 -0
  81. package/examples/mygo-web.json +16 -0
  82. package/examples/mygo.factory.json +18 -0
  83. package/examples/mygo.json +16 -0
  84. package/examples/react-native.factory.json +18 -0
  85. package/examples/react-native.json +16 -0
  86. package/examples/runtime/android-java/app/build.gradle +7 -0
  87. package/examples/runtime/android-java/app/src/main/AndroidManifest.xml +8 -0
  88. package/examples/runtime/android-java/app/src/main/java/dev/apphx/runtime/MainActivity.java +44 -0
  89. package/examples/runtime/android-java/sdk-build.mjs +29 -0
  90. package/examples/runtime/android-java/settings.gradle +2 -0
  91. package/examples/runtime/android-ui.mjs +70 -0
  92. package/examples/runtime/mygo-native/go.mod +13 -0
  93. package/examples/runtime/mygo-native/go.sum +10 -0
  94. package/examples/runtime/mygo-native/main.go +173 -0
  95. package/examples/runtime/mygo-native/main_test.go +14 -0
  96. package/examples/runtime/mygo-native/mygo.json +1 -0
  97. package/examples/runtime/mygo-web/bun.lock +15 -0
  98. package/examples/runtime/mygo-web/go.mod +9 -0
  99. package/examples/runtime/mygo-web/go.sum +8 -0
  100. package/examples/runtime/mygo-web/index.html +1 -0
  101. package/examples/runtime/mygo-web/main.go +60 -0
  102. package/examples/runtime/mygo-web/mygo.config.ts +10 -0
  103. package/examples/runtime/mygo-web/package.json +1 -0
  104. package/examples/runtime/mygo-web/src/main.ts +23 -0
  105. package/examples/runtime/mygo-web/src/mygo.ts +14 -0
  106. package/examples/swift.factory.json +18 -0
  107. package/examples/swift.json +16 -0
  108. package/examples/uni-app.factory.json +18 -0
  109. package/examples/uni-app.json +16 -0
  110. package/package.json +12 -5
  111. package/scripts/bundle-obfuscator.mjs +19 -0
  112. package/scripts/runtime-smoke.mjs +209 -0
  113. package/skills/apphx/SKILL.md +94 -17
  114. package/skills/apphx/resources/google-play-research-result.json +6 -0
  115. package/skills/apphx/resources/local-research-result.json +6 -0
  116. package/skills/apphx/resources/requirement.json +5 -8
  117. package/skills/apphx/resources/research-result.json +5 -14
  118. package/skills/apphx/resources/review-result.json +1 -1
  119. package/dist/apple.js +0 -900
  120. package/dist/apple.js.map +0 -1
package/README.md CHANGED
@@ -1,227 +1,95 @@
1
1
  # apphx
2
2
 
3
- 给现有 AI agent 接入 Flutter / iOS 开发、验收与交付流程。复用宿主的模型、会话和工具,不启动另一套 coding agent。
3
+ 给现有 AI agent 接入应用开发、验收与交付流程。支持 **Java Android、Swift、Flutter、React Native、Expo、uni-app、MyGo macOS**,可新建项目或接管已有工程;复用宿主模型和工具,不启动另一套 coding agent。
4
4
 
5
- ## 1. 安装 CLI
5
+ ## 快速开始
6
6
 
7
- 需要 Node.js **22.18+**。从 **1.1.0** 起,npm 包、命令和 Skill 统一为 **`apphx`**。已有 1.0 安装时,先按第 4 节迁移,**不要先升级 CLI**。
7
+ 需要 Node.js **22.18+**。先安装 CLI,再在控制项目中安装宿主工作流:
8
8
 
9
- | 包管理器 | 安装 CLI | 运行命令 | 卸载 CLI |
10
- | --- | --- | --- | --- |
11
- | npm | `npm install -g apphx` | `apphx` | `npm uninstall -g apphx` |
12
- | pnpm | `pnpm add -g apphx` | `apphx` | `pnpm remove -g apphx` |
13
- | Yarn Classic 1.x | `yarn global add apphx` | `apphx` | `yarn global remove apphx` |
14
- | Yarn 2+ | 在目标项目中 `yarn add -D apphx` | `yarn exec apphx` | `yarn remove apphx` |
15
-
16
- 先确认目标版本已在 npm 发布;本地试用或发布前,将表格中的包名换成安装包的绝对路径,例如 `$PWD/apphx-1.1.0.tgz`。Yarn Classic 全局安装不能使用相对 tarball 路径。
17
-
18
- pnpm 提示缺少全局 bin 目录时,先运行 `pnpm setup` 并重新打开终端。Yarn 2+ 没有 `yarn global`:后文的 `apphx ...` 全部改用 `yarn exec apphx ...`,并从安装了依赖的控制项目目录执行。
19
-
20
- npm / pnpm 也可先在控制项目中执行 `npm install -D apphx` / `pnpm add -D apphx`,之后分别用 `npm exec --offline --package=apphx -- apphx ...` / `pnpm exec apphx ...`。Skill 只使用已安装的依赖,不会代为下载 CLI。
21
-
22
- ## 2. 接入你的 agent
9
+ ```sh
10
+ npm install -g apphx
11
+ apphx install codex
12
+ apphx init --framework android-java --platform android --package-target apk
13
+ ```
23
14
 
24
- 在要开发应用的项目目录执行对应命令,再重新打开该目录的 agent 会话。
15
+ `init` 只创建 `factory.json`,不覆盖已有文件,也不生成应用代码。按项目校准构建、设备验收和签名配置后,重新打开 agent 会话,交给它继续:
25
16
 
26
- | Agent | 安装工作流 | 在 agent 中启动 |
27
- | --- | --- | --- |
28
- | Codex App / CLI | `apphx install codex` | `$apphx <需求>` |
29
- | Claude Code | `apphx install claude` | `/apphx <需求>` |
30
- | Cursor | `apphx install cursor` | `/apphx <需求>` |
31
- | Pi | `apphx install pi` | `/skill:apphx <需求>` |
32
- | Gemini CLI | `apphx install gemini` | `请启用 apphx skill,<需求>` |
33
- | OpenCode | `apphx install opencode` | `请加载 apphx skill,<需求>` |
34
- | DeepSeek Harness | `apphx install deepseek` | `请加载 apphx skill,<需求>` |
35
- | Hermes | `apphx install hermes --global` | `/apphx <需求>` |
17
+ ```text
18
+ $apphx 接管我的 Java Android 项目,完成真实验收,只做本地构建,不上传。
19
+ ```
36
20
 
37
- 其他项目加 `--project /path/to/project`;用户级安装加 `--global`。Hermes 的全局目录跟随 `HERMES_HOME`,默认 `~/.hermes/skills`。也可在 Git 项目内使用 `apphx install hermes`,然后由你执行 `hermes skills trust` 授予项目信任;安装器不会代为授权。见 [Hermes Skills](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills)。
21
+ 其他宿主先用 `apphx hosts` 查看安装方式;通常为 `apphx install <host>`:
38
22
 
39
- 例如在 Codex 中输入:
23
+ | 宿主 | 启动工作流 |
24
+ | --- | --- |
25
+ | Codex | `$apphx <需求>` |
26
+ | Claude Code / Cursor | `/apphx <需求>` |
27
+ | Pi | `/skill:apphx <需求>` |
28
+ | Gemini / OpenCode / DeepSeek | 请加载 apphx skill,随后描述需求 |
29
+ | Hermes | `apphx install hermes --global` 后使用 `/apphx <需求>` |
40
30
 
41
- ```text
42
- $apphx 开发行李配重应用:离线记录物品重量、检查超重、支持备份恢复;完成模拟器验收,只做本地构建,不上传。
43
- ```
31
+ 项目本地安装 CLI 时,使用原包管理器的 `pnpm exec apphx`、`yarn exec apphx` 或 `npm exec --offline --package=apphx -- apphx`;Skill 不会替你下载 CLI。
44
32
 
45
- ## 3. 准备环境并确认接入
33
+ ## 选择项目与交付方式
46
34
 
47
- 实际 iOS 验收需要 **macOS、完整 Xcode、Flutter 和模拟器**。仅在允许本机执行构建与验收命令时运行:
35
+ - `--framework`:`android-java`、`swift`、`flutter`、`react-native`、`expo`、`uni-app`、`mygo`。
36
+ - `--platform`:`android`、`ios` 或 `macos`;Java Android 仅 Android,Swift 仅 iOS,MyGo 仅 macOS。
37
+ - `--package-target`:Android 用 `apk` / `aab`,iOS 用 `simulator` / `ipa`,MyGo macOS 用 `app` / `dmg`。
38
+ - 已有工程在需求中设置 `project.sourceDir`,嵌套应用再设置 `project.directory`。只修改固定快照,不重写原工程或强制迁移技术栈。
48
39
 
49
- ```sh
50
- apphx init --allow-local-execution
51
- ```
40
+ 各技术栈及已有工程的成对需求/配置见 [examples](https://github.com/daguanren21/apple-app-factory/tree/main/packages/apple-app-factory/examples)(含 MyGo 原生 UI / Web)。复制到控制目录后按真实项目编辑,不要修改安装包资产或添加空验收脚本。
52
41
 
53
- 用 `flutter devices` 查询目标并设置真实的 `IOS_DEVICE_ID`;按 `factory.json` 配置验收命令,指定 SDK 时设置 `toolchain.flutterSdk`。然后检查:
42
+ 需求文件就绪后,可直接检查环境和任务状态:
54
43
 
55
44
  ```sh
56
- apphx doctor --build-only
45
+ apphx doctor requirement.json --build-only
57
46
  apphx doctor --host codex
58
47
  apphx status
59
48
  ```
60
49
 
61
- 宿主 ID、`--project` / `--global` 与安装时保持一致。宿主检查只证明安装文件完整,实际加载看宿主的技能列表;Hermes 可用 `hermes skills list`。
50
+ 后续由 agent 按 `next` 返回的 action 研究、开发、审查,再通过 `step` 执行验收和打包。中断后继续原 `JOB_ID`,不要重复入队。全部命令见 `apphx --help`。
62
51
 
63
- 无需配置另一份模型 API key 或 builder/reviewer。agent 负责研究、开发和独立审查,CLI 执行验收与交付门禁。`status` 返回任务 ID、阶段、错误和产物路径;中断后继续同一任务 ID,不重复入队。
52
+ ### 商店提交由人工完成
64
53
 
65
- ## 4. 更新与卸载
54
+ apphx 只负责在本地产出并验证 iOS/Android/macOS 包;App Store / Google Play 的上传、提交与审核都是工具之外的人工工作。内部关卡是真实验证证明 + 新鲜独立复核,附带诚实差异与质量清单:真实价值、视觉身份、开发者与应用元数据、已运行功能与代码身份——绝不伪造身份/IP、篡改哈希、隐藏敏感功能或脚本化回复,也不承诺过审。`apple` 元数据块是可选的所有者文档,不是提交要求。详见安装的 apphx Skill。
66
55
 
67
- 以下适用于已经使用 `apphx` 命令和 Skill 的安装;1.0 用户先完成下方迁移。先升级 CLI(本地试用则安装新的 tarball),再显式更新宿主工作流:
56
+ ## 注意事项
68
57
 
69
- | 包管理器 | 升级 CLI |
70
- | --- | --- |
71
- | npm | `npm install -g apphx@latest` |
72
- | pnpm | `pnpm add -g apphx@latest` |
73
- | Yarn Classic 1.x | `yarn global add apphx@latest` |
74
- | Yarn 2+ | 在控制项目中 `yarn add -D apphx@latest` |
58
+ - **执行权限**:默认不开启本地执行或云构建。确认命令可信后,才加 `--allow-local-execution` 或开启 `allowLocalExecution`。工作区不是安全沙箱;检查与真实运行验收都不能省略。
59
+ - **子进程隔离**:可显式配置 `isolation: { "mode": "macos-sandbox", "readOnlyPaths": [], "network": "deny" }`。macOS 检查/打包子进程默认只能访问自己的工作区、临时目录和批准的工具;后端不可用会阻塞,不回退。它不隔离宿主 agent,不提供 VM/资源配额,也不保证 GUI/设备工具兼容;未配置时仍是可信执行。详见 Skill。
60
+ - **工具链**:iOS 本地构建需要 macOS、完整 Xcode 和设备/模拟器;Android 需要匹配的 JDK、Android SDK 和设备/模拟器。配置外部 Flutter SDK 隔离克隆时,文件系统须支持写时复制。模块名、scheme、产物路径与签名按实际工程校准;`doctor` 通过不代表应用已运行。
61
+ - **Expo / uni-app**:Expo 使用 EAS local 时仍需认证和签名配置,且不保证完全离线;不要求 prebuild/eject。经典 HBuilderX uni-app 不要求添加 package.json 或 npm 工程,保留原有构建与设备验收命令;WGT/H5 产物不能冒充 APK/IPA。
62
+ - **MyGo macOS**:`apphx init --framework mygo` 默认 macOS/app;固定 MyGo v0.3.0 需 Go 1.27.1。仅本地 ad-hoc 签名、不公证/上传;原生 UI 不要求 Bun。`doctor` 在导入源的应用目录检查项目工具;新项目尚无源码时明确报告无法检查,不借用控制目录模块。真实桌面验收、名称/版本/架构和输出路径须按 Skill 与示例校准。
63
+ - **源码与产物**:控制配置、状态目录和导入源必须分离。应用目录不能放在被排除的 build/cache 路径下;产物必须新生成,拒绝符号链接别名和旧产物。Gradle `files` / `dirs` 集合只支持可证明归属的字面量路径,不接受未计入快照的外部可变依赖。
64
+ - **渠道**:默认 `channel: "local"`。`app-store` / `google-play` 只标记交付去向,产出仍是本地包;商店上传、提交与审核都是工具之外的人工工作。`built` 不等于上架或过审。
65
+ - **IPA 导出**:内置 Flutter 导出及配置中可识别的 `xcodebuild -exportArchive` 使用检查后的本地导出配置快照,拒绝 `destination=upload`。任意自定义脚本仍须信任审查;这不是脚本沙箱。
66
+ - **默认值与云构建**:不传技术栈时,`init` 默认 Flutter/iOS/simulator;`android-java` 默认 Android/APK,Expo 默认 iOS/IPA。远程云编译另需 `allowCloudBuild`;不确定的远程结果先对账,不自动重放。
67
+ - **升级旧数据**:先结束活跃任务,保留 `.factory`、配置和迁移备份。新需求显式带 `platform`,配置带 `packageTarget`;`version`、`screenshotsDir` 放在需求顶层。队列 v3 保留历史与回执、清除旧证明;重新验收后再继续。1.0 用户须在升级 CLI 前用原 `app-factory uninstall` 安全卸载未修改的旧 Skill,再安装新 `apphx` Skill;不要覆盖修改过的安装。
68
+ - **宿主与调度**:更新、卸载时保持原 `--project` / `--global` 作用域。Hermes 项目级安装还需你手动授予信任;定时任务应唤醒宿主、续跑同一任务并避免重叠,不能只定时执行 `next`。详见 [Hermes 调度文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron)。
75
69
 
76
- npm / pnpm 项目本地依赖分别用 `npm install -D apphx@latest` / `pnpm add -D apphx@latest`。升级后执行:
70
+ 已安装的工作流需显式更新或卸载;这些操作不会删除应用代码、配置或任务数据:
77
71
 
78
72
  ```sh
79
73
  apphx update codex
80
- apphx update hermes --global
81
- ```
82
-
83
- `install` 只安装;重复安装相同内容不做改动,已有不同版本会提示使用 `update`。`update` 不创建缺失的安装,也不会覆盖你修改过的文件。更新使用原来的 `--project` / `--global` 作用域。
84
-
85
- 卸载时先移除工作流,再按安装表卸载 CLI:
86
-
87
- ```sh
88
74
  apphx uninstall codex
89
- apphx uninstall hermes --global
90
- ```
91
-
92
- 不会删除应用代码、`factory.json`、`remote.json` 或 `.factory` 任务数据。
93
-
94
- <details>
95
- <summary>已有 1.0 安装:迁移到统一的 apphx 入口</summary>
96
-
97
- 1.0 的 npm 包已经叫 `apphx`,但可执行文件是 `app-factory`,Skill 是 `apple-app-factory`。1.1 只支持新的 `apphx` 入口;新版不会识别、接管或自动改名旧 Skill。
98
-
99
- 1. **暂停所有定时工作**,例如 `hermes cron pause CRON_ID`。保存原 `JOB_ID`、配置绝对路径、每个宿主的安装作用域与 profile;确认先前执行已结束,状态不明时停止,不抢占任务。
100
- 2. **升级 npm 包之前**,用仍已安装的 **1.0 CLI** 卸载每一个未修改的旧 Skill。下面是项目级和全局示例;将宿主、路径换成原安装值,对所有安装逐一执行,保持原 `--project` / `--global` 及 `HERMES_HOME` 等环境:
101
-
102
- ```sh
103
- app-factory uninstall codex --project /absolute/project
104
- app-factory uninstall hermes --global
105
- ```
106
-
107
- 若旧 CLI 是控制项目的本地依赖,使用原项目的 `pnpm exec app-factory`、`yarn exec app-factory` 或 `npm exec --offline --package=apphx -- app-factory` 前缀执行同样的卸载。
108
-
109
- **如果 CLI 已升级**,由你明确手动调用精确的旧版本作为卸载工具(可能需要下载并确认),而不是让新版兼容或让 Skill 自动获取旧 CLI:
110
-
111
- ```sh
112
- npm exec --package=apphx@1.0.0 -- app-factory uninstall codex --project /absolute/project
113
- npm exec --package=apphx@1.0.0 -- app-factory uninstall hermes --global
114
- ```
115
-
116
- 旧卸载器只移除归其所有且未修改的安装。若发现编辑过、添加或缺失的文件而拒绝,**停止自动迁移**:由你备份、审阅并决定如何保留;如需退出旧 Skill,由你明确将完整目录归档到宿主发现范围之外。不要覆盖、自动删除或篡改所有权标记来绕过拒绝。更早的本地预览包 `apple-app-factory` 也应先用其原 CLI 安全卸载 Skill,再用原包管理器卸载旧包。
117
- 3. 确认 1.1.0 可获取后,按上表升级 `apphx`(或安装已验证的 1.1.0 tarball),运行 `apphx --version` 确认版本,再用 **`install`,不是 `update`** 安装新 Skill:
118
-
119
- ```sh
120
- apphx install codex --project /absolute/project
121
- apphx install hermes --global
122
- apphx status JOB_ID --config /absolute/project/factory.json
123
- ```
124
-
125
- 保留原 `factory.json`、`remote.json`、`.factory` 及其任务数据,不重新 `init` 或 `enqueue`。使用 SSH 时,也在 Mac 主机升级 CLI,并将 `remote.json` 的 `executable` 改为该主机 `apphx` 的绝对路径,其他字段保持不变。重新打开宿主,确认加载的是 `apphx`;按第 2 节的新名称调用,继续同一个 `JOB_ID`。
126
- 4. 在恢复调度前,编辑**原定时任务**的命令、Skill 和名称为 `apphx` / `apphx` / `apphx-continue`,保留原任务 ID、配置、工作目录和防重叠设置,不另建并行调度。Hermes 可先替换所附 Skill(保留其他 Skill):
127
-
128
- ```sh
129
- hermes cron edit CRON_ID --remove-skill apple-app-factory --add-skill apphx
130
- hermes cron list
131
- ```
132
-
133
- 同时按第 5 节修改原任务提示词与名称;核对配置和任务状态后,才执行 `hermes cron resume CRON_ID` 或恢复原调度器。不要用恢复调度替代人工授权。
134
-
135
- </details>
136
-
137
-
138
- ## 5. Hermes 与定时任务
139
-
140
- 先在 Hermes 中完成一次正常接入,取得 `apphx status` 中的任务 ID。定时任务**唤醒 Hermes 并续跑这个任务**,不是重复 `enqueue`,也不是只运行 `apphx next`。
141
-
142
- 将 `JOB_ID`、`/absolute/project` 换成真实值,使用相同的 Hermes profile:
143
-
144
- ```sh
145
- hermes cron create "every 30m" \
146
- "继续 apphx 任务 JOB_ID,配置 /absolute/project/factory.json。先检查 status;若 running 且原执行是否结束不明,则报告并停止。否则读取 next,只处理一个返回 action;wait/done 或需要人工授权时停止。所有 apphx 操作使用 --build-only,不重复 enqueue,不上传。" \
147
- --skill apphx \
148
- --workdir /absolute/project \
149
- --name apphx-continue \
150
- --paused
151
- hermes cron list
152
75
  ```
153
76
 
154
- 核对后,将 `CRON_ID` 换成 Hermes 返回的定时任务 ID,再启用:
77
+ ## 开发与发布
155
78
 
156
- ```sh
157
- hermes cron resume CRON_ID
158
- hermes cron status
159
- ```
160
-
161
- - **Hermes gateway 必须运行**:可前台运行 `hermes gateway`,或按官方方式安装服务。`hermes cron tick` 不会替你启动 gateway。
162
- - gateway 的执行环境必须能访问 CLI、同一配置/工作目录和 Mac 工具链;使用本地 Yarn 依赖时还需在控制项目中使用 `yarn exec apphx`。
163
- - 同一 apphx 任务只交给一个调度入口;不要同时手工编辑。Hermes 的定时任务去重不是 apphx 跨宿主的文件编辑锁。需要人工处理时先 `hermes cron pause CRON_ID`。
164
- - 系统 cron / launchd / CI 也遵循相同边界:只查询状态可直接运行 CLI;需要模型工作时必须唤醒已有 agent 宿主,保留任务 ID、工作目录、环境和防重叠设置。
165
-
166
- 创建为暂停状态不会自动执行。具体调度、恢复和服务安装见 [Hermes 定时任务文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron)。
167
-
168
- 默认只构建、不上传;`built` 不代表 App Store 批准。Skill 不接管宿主所有工具权限,CLI 只对本项目的任务推进和交付执行门禁。
169
-
170
- <details>
171
- <summary>开发、打包与版本发布(Turborepo + tsdown + repoctl)</summary>
172
-
173
- 维护者使用 `.nvmrc` 指定的 **Node.js 24.22.1** 和 **pnpm 12.15.0**。根目录是私有工具 workspace,唯一发布包为 `apphx`;内部源码目录保留 `packages/apple-app-factory`,以兼容 repoctl 对已发布版本的历史路径校验。Turbo 编排构建、类型检查和测试,tsdown 生成发布产物,repoctl 管理版本和发布。
79
+ 维护者使用 `.nvmrc` 指定的 Node.js 和仓库声明的 pnpm 版本:
174
80
 
175
81
  ```sh
176
82
  pnpm install
177
- pnpm exec repoctl config validate --json
178
83
  pnpm test
179
84
  pnpm run package:check
180
85
  pnpm run package:pack
181
86
  ```
182
87
 
183
- 构建缓存包含 `dist`、README 与两份 MIT 许可证,并将根目录 README/LICENSE 纳入缓存输入;测试不缓存结果。只使用本地 Turbo 缓存,没有启用远程缓存。安装包输出到仓库根目录。
184
-
185
- ### Trusted Publisher 与原始源码恢复
186
-
187
- 1.0.0 的手动首发及原生元数据补齐已完成;后续使用 repoctl,不重复手动 `npm publish`。npm Trusted Publisher 保持以下身份:
188
-
189
- | npm 字段 | 值 |
190
- | --- | --- |
191
- | Organization or user | `daguanren21` |
192
- | Repository | `apple-app-factory` |
193
- | Workflow filename | `release.yml`(只填文件名) |
194
- | Environment name | `npm` |
195
- | Allowed actions | 必须允许 `npm publish`;只有默认的 `npm stage publish` 不够 |
196
-
197
- GitHub 的 `npm` environment 应受保护,发布 job 保留 `id-token: write`;源码仓库必须公开以生成 provenance。CI 使用 OIDC,不保存长期 npm token。`npm dist-tag` 是独立权限,不能替代 `npm publish`。可在 **Release** 工作流运行 `oidc-audit` 核对身份。
198
-
199
- 新建 Trusted Publisher 后,须在 **2 天内**通过它完成首次成功发布,否则配置过期,必须删除重建;审计或已发布版本的元数据恢复不算首次发布。首次成功发布后,该初始 2 天限制不再适用。见 [npm Trusted Publisher 配置规则](https://docs.npmjs.com/trusted-publishers/)。
200
-
201
- 发布中断时,从 `main` 手动分发 **Release**,选择 `publish`,`source-sha` 填引入该版本 changelog/ledger 的完整版本提交 SHA,先勾选 `dry-run`。它不能是任意后续文档提交。核对目标版本、源码与已发布包后,以同一输入取消 dry-run;repoctl 补齐缺失步骤,已存在的 npm 版本不重复上传。
202
-
203
- 不要拿后续 `main` 冒充原始源码、重写历史 manifest/changelog/ledger,或覆盖 `GITHUB_SHA`。repoctl 会隔离所选版本提交;来源不明、内容冲突或完整性不符时停止调查,不移动已有 tag,也不绕过校验。保留 `repoctl-release-state` 和发布诊断产物。
204
-
205
- ### 原生版本发布
206
-
207
- 按实际变更选择 `patch`、`minor` 或 `major`;每项变更只记录一次意图,再检查原生计划:
208
-
209
- ```sh
210
- pnpm change apphx --bump patch --summary "描述本次用户可见改动"
211
- pnpm run release:plan
212
- ```
213
-
214
- 确认目标版本,审查并提交实现及意图,推送到 `main`;不能在脏工作树上跳过门禁。随后在 GitHub Actions 的 **Release** 中依次运行 `plan`、`prepare`,审查原生版本 PR 和 changelog/ledger,检查通过后合并,再运行 `publish`(先勾选 `dry-run`)。`prepare` 会创建并推送版本 PR;repoctl 负责质量门禁、版本、npm 上传、tag、GitHub Release 与检查点。
215
-
216
- 使用默认 `GITHUB_TOKEN` 准备 PR 时,需要允许 Actions 创建 PR,并在生成的版本分支手动运行 **Check**;该 token 创建的 PR 不会自动触发普通 PR 工作流。已有 GitHub App 时,可配置 `REPOCTL_APP_CLIENT_ID` 变量和 `REPOCTL_APP_PRIVATE_KEY` secret。
217
-
218
- Release 仅手动触发,不会因普通 push 自动上传。保留 `repoctl-release-state` 检查点;恢复细节见 [repoctl 发布文档](https://repo.icebreaker.top/tasks/release)。不要对私有根包直接执行 `npm publish`。
219
-
220
- 本项目不安装本地提交钩子,Release 是自有的手动工作流;`repo doctor` 对这两项的提示会保留,不应为清除提示而自动覆盖发布流程。
88
+ 未发布的源码改动请使用本地打包产物验证,不要假定 npm 上的旧版本已经包含它们。Yarn 2+ 使用项目依赖;pnpm 全局安装缺少 bin 目录时先运行 `pnpm setup`。
221
89
 
222
- 配置与需求示例见 [factory.json](https://github.com/daguanren21/apple-app-factory/blob/main/packages/apple-app-factory/examples/factory.json) 和 [luggage.json](https://github.com/daguanren21/apple-app-factory/blob/main/packages/apple-app-factory/examples/luggage.json)。全部命令见 `apphx --help`;不要删除 `.factory`。
90
+ 维护入口:`pnpm smoke:apps --help` 驱动真实 Android Java / MyGo 应用与重启持久化;`pnpm eval:prepare /absolute/fresh-run` 生成原生 agent 审核案例,再用 `pnpm eval:score /absolute/fresh-run --manifest-sha256 PREPARE_HASH` 检查实际结果;未运行不能算通过。`pnpm bench:peers` 提供固定十应用比较基准。工具链须已安装;评估案例不是 iOS 真机或商店过审证明,详情见各命令帮助。
223
91
 
224
- </details>
92
+ 版本变更通过 `pnpm change apphx --bump patch --summary "变更说明"` 记录,再运行 `pnpm run release:plan`。发布只走已有的手动 Release 工作流;恢复时绑定原版本提交,不改写历史、tag 或 ledger。详见 [repoctl 发布文档](https://repo.icebreaker.top/tasks/release)。
225
93
 
226
94
  ## License
227
95
 
@@ -0,0 +1,186 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { syncBuiltinESMExports } from 'node:module';
6
+ import { Session } from 'node:inspector';
7
+ import { join, relative } from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+
10
+ const [root, label, output, mode] = process.argv.slice(2);
11
+ const manifest = JSON.parse(fs.readFileSync(join(root, 'manifest.json'), 'utf8'));
12
+ const dist = manifest[label].dist;
13
+ const sha = value => createHash('sha256').update(value).digest('hex');
14
+ const report = { label, mode: mode ?? 'timing', wallMs: null, operations: 0, errors: [], claims: 0, resumes: 0, outputBytes: 0, expectedRejections: 0, work: {} };
15
+ const { Store } = await import(pathToFileURL(join(dist, 'store.js')));
16
+ const { nextTurn, completeTurn } = await import(pathToFileURL(join(dist, 'runner.js')));
17
+ const store = new Store(join(root, 'state'));
18
+ const jobs = store.list();
19
+ assert.equal(jobs.length, manifest.apps);
20
+ // Independent oracle over the deliberately simple fixture: no production fingerprint helper.
21
+ function sourceHash(directory) {
22
+ const hash = createHash('sha256');
23
+ function visit(path) {
24
+ for (const name of fs.readdirSync(path).sort()) {
25
+ if (path === directory && name === '.dart_tool') continue;
26
+ const file = join(path, name), stat = fs.lstatSync(file);
27
+ assert(!stat.isSymbolicLink());
28
+ if (stat.isDirectory()) visit(file);
29
+ else hash.update(relative(directory, file)).update('\0').update(`${stat.size}:${stat.mode & 0o777}\0`).update(fs.readFileSync(file));
30
+ }
31
+ }
32
+ visit(directory);
33
+ return hash.digest('hex');
34
+ }
35
+ const expected = new Map(jobs.map(job => {
36
+ const peers = jobs.filter(peer => peer.spec.id !== job.spec.id).sort((a, b) => a.id.localeCompare(b.id)).map(peer => ({ jobId: peer.id, spec: peer.spec, source: peer.workspace, importedSource: peer.sourceDirectory, directory: '.', revision: { source: sourceHash(peer.workspace), dependencies: sha('factory-dependency-resolution-v1\0missing') } }));
37
+ return [job.id, { peers: JSON.parse(JSON.stringify(peers)), digest: sha(JSON.stringify(peers)) }];
38
+ }));
39
+ const prompts = {};
40
+ const goldenPath = join(root, 'expected-prompts.json');
41
+ const golden = fs.existsSync(goldenPath) ? JSON.parse(fs.readFileSync(goldenPath, 'utf8')) : null;
42
+ let instrumentation = false;
43
+ let session;
44
+ const post = (method, parameters = {}) => new Promise((resolve, reject) => session.post(method, parameters, (error, result) => error ? reject(error) : resolve(result)));
45
+ if (mode === '--instrument') {
46
+ for (const name of ['readFileSync', 'readdirSync', 'lstatSync', 'realpathSync', 'existsSync']) {
47
+ const original = fs[name];
48
+ fs[name] = function (...args) {
49
+ if (!instrumentation) return Reflect.apply(original, this, args);
50
+ const entry = report.work[name] ??= { calls: 0, bytes: 0, microseconds: 0 };
51
+ const start = process.hrtime.bigint();
52
+ try {
53
+ const value = Reflect.apply(original, this, args);
54
+ entry.calls++;
55
+ if (name === 'readFileSync') {
56
+ entry.bytes += Buffer.byteLength(value);
57
+ const path = String(args[0]);
58
+ if (path.includes('/workspaces/') && /\/(?:source-\d+\.dart|pubspec\.yaml)$/.test(path)) {
59
+ const key = path.endsWith('pubspec.yaml') ? 'manifestReads' : 'payloadReads';
60
+ const count = report.work[key] ??= { calls: 0, bytes: 0 };
61
+ count.calls++;
62
+ count.bytes += Buffer.byteLength(value);
63
+ }
64
+ }
65
+ return value;
66
+ } finally { entry.microseconds += Number(process.hrtime.bigint() - start) / 1000; }
67
+ };
68
+ }
69
+ syncBuiltinESMExports();
70
+ const list = Store.prototype.list;
71
+ Store.prototype.list = function (...args) {
72
+ if (instrumentation) report.work.storeListCalls = (report.work.storeListCalls ?? 0) + 1;
73
+ return Reflect.apply(list, this, args);
74
+ };
75
+ session = new Session();
76
+ session.connect();
77
+ await post('Profiler.enable');
78
+ await post('Profiler.start');
79
+ }
80
+ function consume(action, job, resumed) {
81
+ const serialized = JSON.stringify(action);
82
+ report.outputBytes += Buffer.byteLength(serialized);
83
+ const result = JSON.parse(serialized);
84
+ assert.equal(result.action, 'host');
85
+ assert.equal(result.job.id, job.id);
86
+ assert.equal(result.job.stage, 'research');
87
+ assert.equal(result.job.status, 'running');
88
+ assert.equal(result.job.hostTurns, 1);
89
+ assert.deepEqual(result.job.attempts, { research: 1 });
90
+ assert.equal(result.turn.id, result.job.turn.id);
91
+ assert.match(result.turn.id, /^[a-f0-9-]{36}$/);
92
+ assert.equal(result.turn.cwd, job.workspace);
93
+ assert.equal(result.turn.stage, 'research');
94
+ assert.deepEqual(result.turn.environment, { CI: '1' });
95
+ assert.equal(result.job.turn.policyContextDigest, expected.get(job.id).digest);
96
+ const marker = 'Other same-channel products, including pending batch packages (read-only comparison): ';
97
+ const start = result.turn.prompt.indexOf(marker);
98
+ assert(start >= 0);
99
+ const peerLine = result.turn.prompt.slice(start + marker.length).split('\n')[0];
100
+ assert.deepEqual(JSON.parse(peerLine), expected.get(job.id).peers);
101
+ if (resumed) assert.deepEqual(result.turn, prompts[job.id].turn);
102
+ else prompts[job.id] = { hash: sha(result.turn.prompt), turn: result.turn };
103
+ if (golden) assert.equal(sha(result.turn.prompt), golden[job.id], 'Full prompt differs from frozen baseline');
104
+ return result;
105
+ }
106
+ const cpuStart = process.cpuUsage();
107
+ const started = process.hrtime.bigint();
108
+ instrumentation = mode === '--instrument';
109
+ try {
110
+ for (const resumed of [false, true]) {
111
+ for (const job of jobs) {
112
+ report.operations++;
113
+ try {
114
+ consume(await nextTurn(store, job.id), job, resumed);
115
+ report[resumed ? 'resumes' : 'claims']++;
116
+ } catch (error) { report.errors.push({ operation: resumed ? 'resume' : 'claim', job: job.id, message: error.stack }); }
117
+ }
118
+ }
119
+ } finally {
120
+ report.wallMs = Number(process.hrtime.bigint() - started) / 1e6;
121
+ report.cpu = process.cpuUsage(cpuStart);
122
+ instrumentation = false;
123
+ }
124
+ if (session) {
125
+ const { profile } = await post('Profiler.stop');
126
+ fs.writeFileSync(output.replace(/\.json$/, '.cpuprofile'), JSON.stringify(profile));
127
+ session.disconnect();
128
+ const hits = new Map();
129
+ const nodes = new Map(profile.nodes.map(node => [node.id, node]));
130
+ for (const id of profile.samples ?? []) {
131
+ const frame = nodes.get(id).callFrame;
132
+ const key = `${frame.functionName || '(anonymous)'} ${frame.url}:${frame.lineNumber + 1}`;
133
+ hits.set(key, (hits.get(key) ?? 0) + 1);
134
+ }
135
+ report.profile = { samples: profile.samples?.length ?? 0, top: [...hits].sort((a, b) => b[1] - a[1]).slice(0, 25) };
136
+ }
137
+ if (!golden && !report.errors.length) {
138
+ assert.equal(label, 'baseline');
139
+ fs.writeFileSync(goldenPath, JSON.stringify(Object.fromEntries(Object.entries(prompts).map(([id, value]) => [id, value.hash])), null, 2), { flag: 'wx', mode: 0o600 });
140
+ }
141
+ if (mode === '--correctness' && !report.errors.length) {
142
+ async function rejectsPeerChange(name, mutate, restore) {
143
+ const target = jobs[0], before = store.get(target.id);
144
+ try {
145
+ mutate();
146
+ await assert.rejects(nextTurn(store, target.id), /peer comparison context changed/);
147
+ report.expectedRejections++;
148
+ assert.throws(() => completeTurn(store, target.id, before.turn.id, {}), /peer comparison context changed/);
149
+ report.expectedRejections++;
150
+ assert.deepEqual(store.get(target.id), before, `${name} stale rejection mutated the claimed task`);
151
+ } catch (error) { report.errors.push({ operation: name, message: error.stack }); }
152
+ finally { restore(); }
153
+ consume(await nextTurn(store, target.id), target, true);
154
+ }
155
+ try {
156
+ // Exercise the native CLI boundary, consume all output, and preserve the exact claim.
157
+ for (const job of jobs) {
158
+ const child = spawnSync(process.execPath, [join(dist, 'cli.js'), 'next', job.id, '--config', join(root, 'factory.json')], { encoding: 'utf8', timeout: 30000, maxBuffer: 4 * 1024 * 1024 });
159
+ assert.equal(child.status, 0, child.stderr);
160
+ consume(JSON.parse(child.stdout), job, true);
161
+ report.cliResumes = (report.cliResumes ?? 0) + 1;
162
+ }
163
+ const peer = jobs[1], file = join(peer.workspace, 'lib', 'source-00000.dart');
164
+ const bytes = fs.readFileSync(file), stat = fs.statSync(file);
165
+ await rejectsPeerChange('same-size peer source with restored mtime', () => {
166
+ const changed = Buffer.from(bytes); changed[changed.length - 1] ^= 1;
167
+ fs.writeFileSync(file, changed); fs.utimesSync(file, stat.atime, stat.mtime);
168
+ }, () => { fs.writeFileSync(file, bytes); fs.utimesSync(file, stat.atime, stat.mtime); });
169
+ const dependencyDirectory = join(peer.workspace, '.dart_tool');
170
+ await rejectsPeerChange('generated dependency resolution', () => {
171
+ fs.mkdirSync(dependencyDirectory);
172
+ fs.writeFileSync(join(dependencyDirectory, 'package_config.json'), JSON.stringify({ configVersion: 2, packages: [] }));
173
+ }, () => fs.rmSync(dependencyDirectory, { recursive: true, force: true }));
174
+ const originalSpec = store.get(peer.id).spec;
175
+ await rejectsPeerChange('peer metadata', () => store.update(peer.id, { spec: { ...originalSpec, purpose: `${originalSpec.purpose} changed` } }), () => store.update(peer.id, { spec: originalSpec }));
176
+ // Scheduler bookkeeping must not invalidate peer comparison inputs.
177
+ store.update(peer.id, { feedback: 'Scheduling-only marker', nextAt: peer.nextAt + 1 });
178
+ consume(await nextTurn(store, jobs[0].id), jobs[0], true);
179
+ report.schedulingOnlyStable = true;
180
+ } catch (error) { report.errors.push({ operation: 'correctness', message: error.stack }); }
181
+ }
182
+ report.promptHashes = Object.fromEntries(Object.entries(prompts).map(([id, value]) => [id, value.hash]));
183
+ report.memory = process.memoryUsage();
184
+ store.close();
185
+ fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
186
+ if (report.errors.length) process.exitCode = 1;
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // Run `node benchmarks/peer-context.mjs prepare /absolute/fresh-directory`, then
3
+ // `... baseline /absolute/directory` and, after rebuilding externally,
4
+ // `... compare /absolute/directory /absolute/candidate/dist`.
5
+ // No build, test runner, publisher, SDK, or external service is invoked.
6
+ import assert from 'node:assert/strict';
7
+ import fs from 'node:fs';
8
+ import { createHash } from 'node:crypto';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { dirname, join, resolve } from 'node:path';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+ import { cpus, loadavg, platform, release, arch } from 'node:os';
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const packageRoot = dirname(here);
16
+ const worker = join(here, 'peer-context-worker.mjs');
17
+ const sha = value => createHash('sha256').update(value).digest('hex');
18
+ const json = path => JSON.parse(fs.readFileSync(path, 'utf8'));
19
+ const save = (path, value) => fs.writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
20
+ function treeHash(root) {
21
+ const hash = createHash('sha256');
22
+ function walk(directory, prefix = '') {
23
+ for (const name of fs.readdirSync(directory).sort()) {
24
+ const path = join(directory, name), relative = `${prefix}${name}`;
25
+ if (fs.statSync(path).isDirectory()) walk(path, `${relative}/`);
26
+ else hash.update(relative).update('\0').update(fs.readFileSync(path));
27
+ }
28
+ }
29
+ walk(root);
30
+ return hash.digest('hex');
31
+ }
32
+ function snapshot(root, name, dist) {
33
+ const target = join(root, name);
34
+ fs.mkdirSync(target, { mode: 0o700 });
35
+ fs.cpSync(dist, join(target, 'dist'), { recursive: true });
36
+ save(join(target, 'package.json'), { type: 'module' });
37
+ for (const dependency of ['yaml', 'zod']) {
38
+ fs.cpSync(fs.realpathSync(join(packageRoot, 'node_modules', dependency)), join(target, 'node_modules', dependency), { recursive: true });
39
+ }
40
+ return { dist: join(target, 'dist'), digest: treeHash(target), dependencies: Object.fromEntries(['yaml', 'zod'].map(name => [name, json(join(target, 'node_modules', name, 'package.json')).version])) };
41
+ }
42
+ function number(value, fallback, minimum) {
43
+ const result = value === undefined ? fallback : Number(value);
44
+ assert(Number.isSafeInteger(result) && result >= minimum, `Expected integer >= ${minimum}`);
45
+ return result;
46
+ }
47
+ function reset(root) {
48
+ const manifest = json(join(root, 'manifest.json'));
49
+ assert.equal(manifest.owner, 'apphx-peer-context-benchmark-v1');
50
+ const state = join(root, 'state');
51
+ fs.rmSync(state, { recursive: true, force: true });
52
+ fs.cpSync(join(root, 'seed-state'), state, { recursive: true });
53
+ }
54
+ export function runWorker(command, args, path, stderrPath, timeout = 300000) {
55
+ fs.rmSync(path, { force: true });
56
+ const result = spawnSync(command, args, { encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024 });
57
+ fs.writeFileSync(stderrPath, result.stderr ?? '');
58
+ let data;
59
+ try {
60
+ data = json(path);
61
+ assert(data && typeof data === 'object' && !Array.isArray(data), 'Expected a report object');
62
+ assert(Array.isArray(data.errors) && data.errors.every(error => error && typeof error.message === 'string'), 'Expected structured errors');
63
+ assert(Number.isFinite(data.wallMs) && data.wallMs >= 0, 'Expected a finite nonnegative wallMs');
64
+ assert(Number.isSafeInteger(data.operations) && data.operations >= 0, 'Expected a nonnegative operation count');
65
+ } catch (error) {
66
+ data = { errors: [{ operation: 'worker-report', message: `Missing or malformed fresh worker report: ${error.message}` }], wallMs: null, operations: 0 };
67
+ }
68
+ if (result.error) data.errors.push({ operation: 'worker-process', message: `Worker spawn failed: ${result.error.message}` });
69
+ if (result.signal) data.errors.push({ operation: 'worker-process', message: `Worker terminated by signal ${result.signal}` });
70
+ if (result.status !== 0) data.errors.push({ operation: 'worker-process', message: `Worker exited with status ${result.status}` });
71
+ data.exitStatus = result.status;
72
+ data.signal = result.signal;
73
+ save(path, data);
74
+ return data;
75
+ }
76
+ function sample(root, label, name, options = []) {
77
+ reset(root);
78
+ const path = join(root, `${name}.json`);
79
+ return runWorker(process.execPath, [worker, root, label, path, ...options], path, join(root, `${name}.stderr`));
80
+ }
81
+ function summarize(samples) {
82
+ const times = samples.map(value => value.wallMs).filter(value => value !== null).sort((a, b) => a - b);
83
+ return { samples: samples.length, measuredSamples: times.length, medianMs: times.length ? (times[Math.floor((times.length - 1) / 2)] + times[Math.ceil((times.length - 1) / 2)]) / 2 : null,
84
+ rangeMs: times.length ? [times[0], times.at(-1)] : null,
85
+ operations: samples.reduce((sum, value) => sum + (value.operations ?? 0), 0), errors: samples.reduce((sum, value) => sum + value.errors.length, 0),
86
+ rawMs: samples.map(value => value.wallMs) };
87
+ }
88
+ async function prepare(root, args) {
89
+ const apps = number(args[0], 10, 2), files = number(args[1], 128, 1), bytes = number(args[2], 4096, 128), samples = number(args[3], 5, 5);
90
+ fs.mkdirSync(root, { mode: 0o700 }); // Refuse an existing directory, including somebody else's fixture.
91
+ const baseline = snapshot(root, 'baseline', join(packageRoot, 'dist'));
92
+ const { ConfigSchema } = await import(pathToFileURL(join(baseline.dist, 'schemas.js')));
93
+ const { Store } = await import(pathToFileURL(join(baseline.dist, 'store.js')));
94
+ const config = ConfigSchema.parse({ stateDir: join(root, 'state'), workspaceRoot: join(root, 'workspaces'), packageTarget: 'simulator', maxJobHours: 87600, verification: { checks: [[process.execPath, '--version']], runtime: [[process.execPath, '--version']] } });
95
+ save(join(root, 'factory.json'), config);
96
+ const store = new Store(config.stateDir);
97
+ const jobs = [];
98
+ try {
99
+ for (let app = 0; app < apps; app++) {
100
+ const spec = { id: `peer-benchmark-${app}`, name: `Peer workload ${app}`, framework: 'flutter', platform: 'ios', channel: 'app-store', version: '1.0.0', purpose: `Record collection ${app}`, audience: `Collection keepers ${app}`, differentiators: [`Collection workflow ${app}`], acceptance: [`Preserve collection ${app}`], apple: { appId: `${100000 + app}`, bundleId: `invalid.benchmark.peer${app}`, buildNumber: '1', locale: 'en-US', description: `Deterministic workload ${app}; not a submitted app`, keywords: 'records', supportUrl: 'https://example.invalid/support', privacyPolicyUrl: 'https://example.invalid/privacy', reviewNotes: 'Performance fixture, not App Review evidence', copyright: 'Fixture', manualSetupConfirmed: true, contact: { firstName: 'Test', lastName: 'Only', email: 'test@example.invalid', phone: '+12025550123' } } };
101
+ const job = store.enqueue(spec, config);
102
+ fs.mkdirSync(join(job.workspace, 'lib'), { recursive: true, mode: 0o700 });
103
+ fs.writeFileSync(join(job.workspace, 'pubspec.yaml'), `name: peer_benchmark_${app}\ndependencies:\n flutter: {sdk: flutter}\n`, { mode: 0o600 });
104
+ for (let file = 0; file < files; file++) {
105
+ const seed = sha(`peer-context-fixed-seed-v1:${app}:${file}`);
106
+ fs.writeFileSync(join(job.workspace, 'lib', `source-${String(file).padStart(5, '0')}.dart`), `// ${seed.repeat(Math.ceil(bytes / seed.length))}`.slice(0, bytes), { mode: 0o600 });
107
+ }
108
+ jobs.push(job);
109
+ }
110
+ } finally { store.close(); }
111
+ fs.cpSync(config.stateDir, join(root, 'seed-state'), { recursive: true });
112
+ const manifest = { owner: 'apphx-peer-context-benchmark-v1', baseline, node: process.version, executable: process.execPath, platform: platform(), release: release(), arch: arch(), cpu: cpus()[0]?.model, seed: 'peer-context-fixed-seed-v1', apps, files, bytes, samples, jobs, workspaceDigest: treeHash(config.workspaceRoot), seedDigest: treeHash(join(root, 'seed-state')), timing: 'Fresh process; warm OS file cache; imports, fixture reset and oracle creation excluded. Timed 10 claims + 10 resumes include JSON serialization, consumption and exact output assertions. Separate CLI correctness and CPU/instrumented runs.' };
113
+ save(join(root, 'manifest.json'), manifest);
114
+ console.log(JSON.stringify({ root, apps, sourceFiles: apps * (files + 1), payloadBytes: apps * files * bytes, baseline: baseline.digest }));
115
+ }
116
+ async function main() {
117
+ const [command, input, ...args] = process.argv.slice(2);
118
+ assert(input && ['prepare', 'baseline', 'compare'].includes(command), 'Usage: peer-context.mjs prepare ROOT [apps=10 files=128 bytes=4096 samples=5] | baseline ROOT | compare ROOT CANDIDATE_DIST');
119
+ const root = resolve(input);
120
+ if (command === 'prepare') return prepare(root, args);
121
+ const manifest = json(join(root, 'manifest.json'));
122
+ assert.equal(manifest.node, process.version, 'Node version changed');
123
+ assert.equal(treeHash(join(root, 'baseline')), manifest.baseline.digest, 'Frozen baseline changed');
124
+ assert.equal(treeHash(join(root, 'workspaces')), manifest.workspaceDigest, 'Fixture source changed');
125
+ assert.equal(treeHash(join(root, 'seed-state')), manifest.seedDigest, 'Fixture initial state changed');
126
+ const report = { command, loadBefore: loadavg(), machine: { node: process.version, cpu: cpus()[0]?.model, platform: platform(), release: release(), arch: arch() }, samples: {} };
127
+ if (command === 'compare') {
128
+ assert(args[0], 'Candidate dist directory required');
129
+ manifest.candidate = snapshot(root, 'candidate', resolve(args[0]));
130
+ assert.deepEqual(manifest.candidate.dependencies, manifest.baseline.dependencies, 'Dependency versions differ');
131
+ save(join(root, 'manifest.json'), manifest);
132
+ }
133
+ const labels = command === 'compare' ? ['baseline', 'candidate'] : ['baseline'];
134
+ report.warmups = Object.fromEntries(labels.map(label => [label, sample(root, label, `${command}-${label}-warmup`)]));
135
+ for (let index = 0; index < manifest.samples; index++) {
136
+ for (const label of index % 2 ? [...labels].reverse() : labels) {
137
+ (report.samples[label] ??= []).push(sample(root, label, `${command}-${label}-${index}`));
138
+ }
139
+ }
140
+ report.summary = Object.fromEntries(labels.map(label => [label, summarize(report.samples[label])]));
141
+ report.correctness = Object.fromEntries(labels.map(label => [label, sample(root, label, `${command}-${label}-correctness`, ['--correctness'])]));
142
+ report.profiles = Object.fromEntries(labels.map(label => [label, sample(root, label, `${command}-${label}-profile`, ['--instrument'])]));
143
+ report.loadAfter = loadavg();
144
+ save(join(root, `${command}-report.json`), report);
145
+ console.log(JSON.stringify({ summary: report.summary, correctness: Object.fromEntries(labels.map(label => [label, report.correctness[label].errors])), profiles: Object.fromEntries(labels.map(label => [label, report.profiles[label].work])) }, null, 2));
146
+ if (Object.values(report.summary).some(value => value.errors || value.measuredSamples !== manifest.samples) || [...Object.values(report.warmups), ...Object.values(report.correctness), ...Object.values(report.profiles)].some(value => value.errors.length)) process.exitCode = 1;
147
+ }
148
+ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
149
+ main().catch(error => { console.error(error); process.exitCode = 1; });
150
+ }