@namewta/speculo 2.0.5 → 2.1.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 (283) hide show
  1. package/README.md +3 -0
  2. package/THIRD-PARTY-NOTICES.md +4 -0
  3. package/dist/src/agent-skills.d.ts +5 -0
  4. package/dist/src/agent-skills.js +10 -7
  5. package/dist/src/agent-skills.js.map +1 -1
  6. package/dist/src/cli.js +17 -4
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/coordination.d.ts +3 -3
  9. package/dist/src/coordination.js +21 -4
  10. package/dist/src/coordination.js.map +1 -1
  11. package/dist/src/doctor.js +8 -1
  12. package/dist/src/doctor.js.map +1 -1
  13. package/dist/src/external-files.d.ts +4 -2
  14. package/dist/src/external-files.js +4 -4
  15. package/dist/src/external-files.js.map +1 -1
  16. package/dist/src/hub-catalog.d.ts +11 -0
  17. package/dist/src/hub-catalog.js +91 -0
  18. package/dist/src/hub-catalog.js.map +1 -0
  19. package/dist/src/hub-resources.json +1 -0
  20. package/dist/src/hub-skills.d.ts +53 -0
  21. package/dist/src/hub-skills.js +157 -0
  22. package/dist/src/hub-skills.js.map +1 -0
  23. package/dist/src/index.d.ts +6 -0
  24. package/dist/src/index.js +48 -47
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/install-ui.d.ts +16 -0
  27. package/dist/src/install-ui.js +67 -11
  28. package/dist/src/install-ui.js.map +1 -1
  29. package/dist/src/manifest.d.ts +2 -0
  30. package/dist/src/manifest.js +2 -0
  31. package/dist/src/manifest.js.map +1 -1
  32. package/dist/src/refresh.d.ts +1 -0
  33. package/dist/src/refresh.js +13 -10
  34. package/dist/src/refresh.js.map +1 -1
  35. package/dist/src/retained-workflows.d.ts +10 -0
  36. package/dist/src/retained-workflows.js +25 -0
  37. package/dist/src/retained-workflows.js.map +1 -0
  38. package/dist/src/runtime-entry.d.ts +1 -0
  39. package/dist/src/runtime-entry.js +1 -0
  40. package/dist/src/runtime-entry.js.map +1 -1
  41. package/dist/src/skill-trees.d.ts +27 -0
  42. package/dist/src/skill-trees.js +118 -0
  43. package/dist/src/skill-trees.js.map +1 -0
  44. package/dist/src/transaction.d.ts +3 -14
  45. package/dist/src/transaction.js +20 -36
  46. package/dist/src/transaction.js.map +1 -1
  47. package/dist/src/workflow-install.d.ts +15 -0
  48. package/dist/src/workflow-install.js +104 -0
  49. package/dist/src/workflow-install.js.map +1 -0
  50. package/package.json +4 -2
  51. package/skills-hub/automation/grok-bot-team-steward/CHEATSHEET.md +17 -0
  52. package/skills-hub/automation/grok-bot-team-steward/INSTALL.md +23 -0
  53. package/skills-hub/automation/grok-bot-team-steward/LICENSE +21 -0
  54. package/skills-hub/automation/grok-bot-team-steward/README.md +9 -0
  55. package/skills-hub/automation/grok-bot-team-steward/SKILL.md +52 -0
  56. package/skills-hub/automation/grok-bot-team-steward/agents/openai.yaml +4 -0
  57. package/skills-hub/automation/grok-bot-team-steward/references/00-hard-rules.md +50 -0
  58. package/skills-hub/automation/grok-bot-team-steward/references/01-snapshot-schema.md +84 -0
  59. package/skills-hub/automation/grok-bot-team-steward/references/02-export-protocol.md +57 -0
  60. package/skills-hub/automation/grok-bot-team-steward/references/03-restore-protocol.md +64 -0
  61. package/skills-hub/automation/grok-bot-team-steward/references/04-inventory.md +37 -0
  62. package/skills-hub/automation/grok-bot-team-steward/references/05-conversations.md +71 -0
  63. package/skills-hub/automation/grok-bot-team-steward/references/06-sanitizer.md +39 -0
  64. package/skills-hub/automation/grok-bot-team-steward/references/07-prompts.md +87 -0
  65. package/skills-hub/automation/grok-bot-team-steward/references/08-verification.md +30 -0
  66. package/skills-hub/automation/grok-bot-team-steward/references/09-sources.md +31 -0
  67. package/skills-hub/automation/grok-bot-team-steward/scripts/scrub.sh +17 -0
  68. package/skills-hub/automation/grok-bot-team-steward/scripts/snapshot-name.sh +18 -0
  69. package/skills-hub/automation/grok-bot-team-steward/scripts/validate-snapshot.sh +62 -0
  70. package/skills-hub/automation/grok-bot-team-steward/steward/ACTIVATE.md +58 -0
  71. package/skills-hub/automation/grok-bot-team-steward/steward/SETUP-INSTRUCTIONS.md +33 -0
  72. package/skills-hub/automation/grok-bot-team-steward/steward/STEWARD-PROFILE.md +28 -0
  73. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/CHANGELOG.md +3 -0
  74. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/CHARTER.md +27 -0
  75. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/DROP-LIST.md +10 -0
  76. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/GAPS.md +6 -0
  77. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/INIT.md +14 -0
  78. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/MANIFEST.md +30 -0
  79. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/MANIFEST.yaml +20 -0
  80. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/ORG.md +15 -0
  81. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/README.md +8 -0
  82. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/RESTORE.md +13 -0
  83. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/ROSTER.md +11 -0
  84. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/GAPS.md +7 -0
  85. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/MEMORY.md +9 -0
  86. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/PROFILE.md +27 -0
  87. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/agent-spec.md +9 -0
  88. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/conversations/INDEX.md +5 -0
  89. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/conversations/main.md +9 -0
  90. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/routines.md +5 -0
  91. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/bots/_SLUG/skills.md +5 -0
  92. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/connectors.md +7 -0
  93. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/COMPLETED.md +6 -0
  94. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/CREATED.md +6 -0
  95. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/RESTORE-NOTES.md +13 -0
  96. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/SANITIZER.md +7 -0
  97. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/meta/SOURCE.md +8 -0
  98. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/rooms/_ROOM.md +8 -0
  99. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/routines/_ROUTINE.md +12 -0
  100. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/shared/shared-memory.md +5 -0
  101. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/shared/workspace-map.md +5 -0
  102. package/skills-hub/automation/grok-bot-team-steward/templates/snapshot/skills/_SKILL/SKILL.md +18 -0
  103. package/skills-hub/automation/herdr/SKILL.md +246 -0
  104. package/skills-hub/automation/orca/SKILL.md +328 -0
  105. package/skills-hub/coding/gitea-repo/LICENSE +21 -0
  106. package/skills-hub/coding/gitea-repo/SKILL.md +50 -0
  107. package/skills-hub/coding/gitea-repo/agents/openai.yaml +4 -0
  108. package/skills-hub/coding/gitea-repo/references/auth-and-scopes.md +58 -0
  109. package/skills-hub/coding/gitea-repo/references/endpoint-map.md +80 -0
  110. package/skills-hub/coding/gitea-repo/references/pagination-and-errors.md +34 -0
  111. package/skills-hub/coding/gitea-repo/scripts/gitea_api.py +814 -0
  112. package/skills-hub/coding/github-repo-steward/LICENSE +21 -0
  113. package/skills-hub/coding/github-repo-steward/SKILL.md +48 -0
  114. package/skills-hub/coding/github-repo-steward/agents/openai.yaml +4 -0
  115. package/skills-hub/coding/github-repo-steward/references/00-auth-and-tokens.md +73 -0
  116. package/skills-hub/coding/github-repo-steward/references/01-inventory.md +55 -0
  117. package/skills-hub/coding/github-repo-steward/references/02-lifecycle.md +57 -0
  118. package/skills-hub/coding/github-repo-steward/references/03-stars.md +42 -0
  119. package/skills-hub/coding/github-repo-steward/references/04-hygiene.md +40 -0
  120. package/skills-hub/coding/github-repo-steward/references/05-sources.md +42 -0
  121. package/skills-hub/coding/github-repo-steward/scripts/inventory.sh +64 -0
  122. package/skills-hub/coding/vscode-fullstack/LICENSE +21 -0
  123. package/skills-hub/coding/vscode-fullstack/SKILL.md +47 -0
  124. package/skills-hub/coding/vscode-fullstack/agents/openai.yaml +4 -0
  125. package/skills-hub/coding/vscode-fullstack/references/extensions-and-settings.md +181 -0
  126. package/skills-hub/coding/vscode-fullstack/references/profiles-and-validation.md +62 -0
  127. package/skills-hub/coding/vscode-fullstack/references/runtime-reuse.md +52 -0
  128. package/skills-hub/design/photo-retouch/LICENSE +21 -0
  129. package/skills-hub/design/photo-retouch/SKILL.md +50 -0
  130. package/skills-hub/design/photo-retouch/agents/openai.yaml +4 -0
  131. package/skills-hub/design/photo-retouch/references/basic-retouch.md +58 -0
  132. package/skills-hub/design/photo-retouch/references/formal-portrait.md +57 -0
  133. package/skills-hub/design/photo-retouch/references/object-and-canvas.md +82 -0
  134. package/skills-hub/design/photo-retouch/references/product-photo.md +41 -0
  135. package/skills-hub/design/photo-retouch/references/repair-and-look.md +61 -0
  136. package/skills-hub/design/photo-retouch/references/social-effects.md +55 -0
  137. package/skills-hub/design/photo-retouch/references/travel-creative.md +79 -0
  138. package/skills-hub/system/clash-client-profile/LICENSE +21 -0
  139. package/skills-hub/system/clash-client-profile/SKILL.md +79 -0
  140. package/skills-hub/system/clash-client-profile/agents/openai.yaml +4 -0
  141. package/skills-hub/system/clash-client-profile/references/acceptance.md +81 -0
  142. package/skills-hub/system/clash-client-profile/references/browser-privacy.md +35 -0
  143. package/skills-hub/system/clash-client-profile/references/clash-verge.md +40 -0
  144. package/skills-hub/system/clash-client-profile/references/dns-workflow.md +147 -0
  145. package/skills-hub/system/clash-client-profile/references/flclash.md +24 -0
  146. package/skills-hub/system/clash-client-profile/references/network-performance.md +25 -0
  147. package/skills-hub/system/clash-client-profile/references/platform-dns.md +109 -0
  148. package/skills-hub/system/clash-client-profile/references/sources.md +46 -0
  149. package/skills-hub/system/clash-client-profile/references/troubleshooting.md +82 -0
  150. package/skills-hub/system/clash-client-profile/scripts/dns_guard.py +650 -0
  151. package/skills-hub/system/clash-client-profile/scripts/proxy_doctor.py +213 -0
  152. package/skills-hub/system/clash-client-profile/scripts/render_rules.py +34 -0
  153. package/skills-hub/system/clash-client-profile/templates/ai-rules.yaml +18 -0
  154. package/skills-hub/system/clash-client-profile/templates/dns-request.example.json +9 -0
  155. package/skills-hub/system/clash-client-profile/templates/dns.yaml +8 -0
  156. package/skills-hub/system/linux-dev-disk-cleanup/LICENSE +21 -0
  157. package/skills-hub/system/linux-dev-disk-cleanup/SKILL.md +42 -0
  158. package/skills-hub/system/linux-dev-disk-cleanup/agents/openai.yaml +4 -0
  159. package/skills-hub/system/linux-dev-disk-cleanup/references/linux-cleanup-workflow.md +145 -0
  160. package/skills-hub/system/optimize-codex-config/LICENSE +21 -0
  161. package/skills-hub/system/optimize-codex-config/SKILL.md +101 -0
  162. package/skills-hub/system/optimize-codex-config/agents/openai.yaml +4 -0
  163. package/skills-hub/system/optimize-codex-config/assets/capabilities.example.json +22 -0
  164. package/skills-hub/system/optimize-codex-config/references/cli.md +35 -0
  165. package/skills-hub/system/optimize-codex-config/references/configuration-contract.md +33 -0
  166. package/skills-hub/system/optimize-codex-config/references/optimization-method.md +38 -0
  167. package/skills-hub/system/optimize-codex-config/references/privacy.md +41 -0
  168. package/skills-hub/system/optimize-codex-config/references/sources.md +24 -0
  169. package/skills-hub/system/optimize-codex-config/references/statusline.md +31 -0
  170. package/skills-hub/system/optimize-codex-config/references/transactions.md +32 -0
  171. package/skills-hub/system/optimize-codex-config/references/troubleshooting.md +95 -0
  172. package/skills-hub/system/optimize-codex-config/scripts/adapters/claude.mjs +24 -0
  173. package/skills-hub/system/optimize-codex-config/scripts/adapters/codex.mjs +10 -0
  174. package/skills-hub/system/optimize-codex-config/scripts/ai-cli-config.mjs +67 -0
  175. package/skills-hub/system/optimize-codex-config/scripts/audit-codex-config.mjs +762 -0
  176. package/skills-hub/system/optimize-codex-config/scripts/lib/document.mjs +166 -0
  177. package/skills-hub/system/optimize-codex-config/scripts/lib/inspect.mjs +211 -0
  178. package/skills-hub/system/optimize-codex-config/scripts/lib/io.mjs +104 -0
  179. package/skills-hub/system/optimize-codex-config/scripts/lib/plan.mjs +79 -0
  180. package/skills-hub/system/optimize-codex-config/scripts/lib/transaction.mjs +205 -0
  181. package/skills-hub/system/optimize-codex-config/scripts/lib/writers.mjs +46 -0
  182. package/skills-hub/system/optimize-codex-config/scripts/statusline/claude-statusline.mjs +94 -0
  183. package/skills-hub/system/optimize-codex-config/scripts/vendor/THIRD-PARTY-NOTICES.txt +250 -0
  184. package/skills-hub/system/optimize-codex-config/scripts/vendor/build-package-lock.json +488 -0
  185. package/skills-hub/system/optimize-codex-config/scripts/vendor/manifest.json +14 -0
  186. package/skills-hub/system/optimize-codex-config/scripts/vendor/parsers.mjs +16 -0
  187. package/skills-hub/system/proxy-region-locale/LICENSE +21 -0
  188. package/skills-hub/system/proxy-region-locale/SKILL.md +42 -0
  189. package/skills-hub/system/proxy-region-locale/agents/openai.yaml +4 -0
  190. package/skills-hub/system/proxy-region-locale/references/linux.md +35 -0
  191. package/skills-hub/system/proxy-region-locale/references/macos.md +31 -0
  192. package/skills-hub/system/proxy-region-locale/references/windows.md +45 -0
  193. package/skills-hub/system/system-health-audit/LICENSE +21 -0
  194. package/skills-hub/system/system-health-audit/SKILL.md +41 -0
  195. package/skills-hub/system/system-health-audit/agents/openai.yaml +4 -0
  196. package/skills-hub/system/system-health-audit/references/platforms.md +37 -0
  197. package/skills-hub/system/system-health-audit/scripts/system_audit.py +41 -0
  198. package/skills-hub/system/windows-dev-disk-cleanup/LICENSE +21 -0
  199. package/skills-hub/system/windows-dev-disk-cleanup/SKILL.md +40 -0
  200. package/skills-hub/system/windows-dev-disk-cleanup/agents/openai.yaml +4 -0
  201. package/skills-hub/system/windows-dev-disk-cleanup/references/windows-cleanup-workflow.md +121 -0
  202. package/skills-hub/writing/.gitkeep +0 -0
  203. package/skills-hub/writing/job-application/LICENSE +21 -0
  204. package/skills-hub/writing/job-application/SKILL.md +160 -0
  205. package/skills-hub/writing/job-application/agents/openai.yaml +4 -0
  206. package/skills-hub/writing/job-application/assets/resume.css +10 -0
  207. package/skills-hub/writing/job-application/config.example.json +9 -0
  208. package/skills-hub/writing/job-application/references/company-research.md +7 -0
  209. package/skills-hub/writing/job-application/references/corpus-schema.md +33 -0
  210. package/skills-hub/writing/job-application/references/honesty-gate.md +29 -0
  211. package/skills-hub/writing/job-application/references/improvement-plan.md +15 -0
  212. package/skills-hub/writing/job-application/references/ingest-protocol.md +18 -0
  213. package/skills-hub/writing/job-application/references/interview-speech.md +16 -0
  214. package/skills-hub/writing/job-application/references/jd-parse.md +18 -0
  215. package/skills-hub/writing/job-application/references/persist-paths.md +34 -0
  216. package/skills-hub/writing/job-application/references/question-bank.md +19 -0
  217. package/skills-hub/writing/job-application/references/rendering.md +9 -0
  218. package/skills-hub/writing/job-application/references/resume-playbook.md +30 -0
  219. package/skills-hub/writing/job-application/scripts/README.md +12 -0
  220. package/skills-hub/writing/job-application/scripts/ingest_kb.py +188 -0
  221. package/skills-hub/writing/job-application/scripts/render_resume.py +144 -0
  222. package/skills-hub/writing/job-application/scripts/validate_claims.py +52 -0
  223. package/skills-hub/writing/job-application/scripts/write_config.py +72 -0
  224. package/skills-hub/writing/job-application/templates/application-index.md +10 -0
  225. package/skills-hub/writing/job-application/templates/brief.md +16 -0
  226. package/skills-hub/writing/job-application/templates/changelog.md +6 -0
  227. package/skills-hub/writing/job-application/templates/company-research.md +15 -0
  228. package/skills-hub/writing/job-application/templates/corpus.md +11 -0
  229. package/skills-hub/writing/job-application/templates/evidence-map.md +5 -0
  230. package/skills-hub/writing/job-application/templates/framings.md +6 -0
  231. package/skills-hub/writing/job-application/templates/improvement-plan.md +16 -0
  232. package/skills-hub/writing/job-application/templates/ingest-log.md +4 -0
  233. package/skills-hub/writing/job-application/templates/known-gaps.md +4 -0
  234. package/skills-hub/writing/job-application/templates/match-report.md +21 -0
  235. package/skills-hub/writing/job-application/templates/methodology-cache.md +10 -0
  236. package/skills-hub/writing/job-application/templates/persist-index.md +11 -0
  237. package/skills-hub/writing/job-application/templates/profile.md +21 -0
  238. package/skills-hub/writing/job-application/templates/questions.md +22 -0
  239. package/skills-hub/writing/job-application/templates/resume-original.md +10 -0
  240. package/skills-hub/writing/job-application/templates/resume-tailored.md +21 -0
  241. package/skills-hub/writing/job-application/templates/sources-index.md +6 -0
  242. package/skills-hub/writing/job-application/templates/speech.md +19 -0
  243. package/skills-hub/writing/job-application/templates/stories.md +9 -0
  244. package/skills-hub/writing/job-application/templates/story-bank.md +11 -0
  245. package/skills-hub/writing/job-application/templates/strengths.md +4 -0
  246. package/skills-hub/writing/job-application/templates/style-memory.md +7 -0
  247. package/skills-hub/writing/ste-zh/LICENSE +21 -0
  248. package/skills-hub/writing/ste-zh/NOTICE.md +9 -0
  249. package/skills-hub/writing/ste-zh/SKILL.md +130 -0
  250. package/skills-hub/writing/ste-zh/agents/openai.yaml +6 -0
  251. package/skills-hub/writing/ste-zh/examples/zh-report.md +23 -0
  252. package/skills-hub/writing/ste-zh/references/english-modes.md +13 -0
  253. package/skills-hub/writing/ste-zh/references/terminology.md +13 -0
  254. package/skills-hub/writing/ste-zh/references/zh-rules.md +29 -0
  255. package/template/.speculo/refresh-contract.json +2 -0
  256. package/template/canonical/canonical-specdev-goal-plan.md +170 -101
  257. package/template/canonical/canonical-specdev-grill-with-docs.md +346 -277
  258. package/template/canonical/canonical-specdev-spec.md +346 -277
  259. package/template/canonical/canonical-specdev-tickets.md +346 -277
  260. package/template/canonical/canonical-specdev-wayfinder.md +346 -277
  261. package/template/lib/speculo-runtime.mjs +183 -8
  262. package/template/references/coordination/coordination-contract.md +12 -4
  263. package/template/references/coordination/modes/cli.md +3 -3
  264. package/template/references/coordination/modes/herdr.md +11 -11
  265. package/template/references/coordination/modes/orca.md +14 -14
  266. package/template/references/coordination/project-facts.schema.json +1 -0
  267. package/template/references/coordination/report-template.md +18 -0
  268. package/template/references/coordination/result-and-recovery.md +3 -3
  269. package/template/references/coordination/session-files.md +49 -56
  270. package/template/references/coordination/task-brief.md +3 -3
  271. package/template/references/coordination/task-template.md +25 -0
  272. package/template/workflows/specdev/common/rules/path-reference-contract.md +1 -1
  273. package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +1 -1
  274. package/template/workflows/specdev/common/schemas/session-locator.schema.json +15 -0
  275. package/template/workflows/specdev/common/skills/code-review/references/review-evidence.md +1 -1
  276. package/template/workflows/specdev/common/tools/README.md +6 -0
  277. package/template/workflows/specdev/common/tools/delivery-artifacts.mjs +1 -1
  278. package/template/workflows/specdev/common/tools/validate-handoff.mjs +108 -0
  279. package/template/workflows/specdev/specdev-i-implement/assets/evidence-template.md +2 -2
  280. package/template/workflows/specdev/specdev-i-implement/references/implementation-procedure.md +1 -1
  281. package/template/workflows/specdev/specdev-i-init-setup/SKILL.md +7 -6
  282. package/template/workflows/specdev/specdev-i-init-setup/references/first-setup.md +30 -1
  283. package/template/workflows/specdev/specdev-p-goal-plan/references/lead-orchestration.md +1 -1
@@ -0,0 +1,166 @@
1
+ // Full grammar parsing; range edits, not a handwritten TOML/JSON parser.
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { parseTOML, getStaticTOMLValue, parseTree, modify, applyEdits } from '../vendor/parsers.mjs';
4
+
5
+ const forbidden = new Set(['__proto__', 'prototype', 'constructor']);
6
+ export class ConfigError extends Error {
7
+ constructor(code) { super(code); this.code = code; }
8
+ }
9
+ const keys = node => node.keys.map(key => key.type === 'TOMLBare' ? key.name : key.value);
10
+ const prefix = (a, b) => a.length <= b.length && a.every((v, i) => v === b[i]);
11
+ const same = (a, b) => a.length === b.length && prefix(a, b);
12
+ const keyText = path => path.map(key => JSON.stringify(key)).join('.');
13
+ export function at(value, path) {
14
+ for (const key of path) {
15
+ if (value === null || typeof value !== 'object' || !Object.hasOwn(value, key)) return undefined;
16
+ value = value[key];
17
+ }
18
+ return value;
19
+ }
20
+ function checkedPath(path) {
21
+ if (!Array.isArray(path) || !path.length || path.some(k => typeof k !== 'string' || forbidden.has(k))) {
22
+ throw new ConfigError('unsafe_key');
23
+ }
24
+ }
25
+ function set(value, path, replacement) {
26
+ let cursor = value;
27
+ for (const key of path.slice(0, -1)) {
28
+ if (!Object.hasOwn(cursor, key)) cursor[key] = {};
29
+ if (!cursor[key] || typeof cursor[key] !== 'object' || Array.isArray(cursor[key]) || cursor[key] instanceof Date) {
30
+ throw new ConfigError('parent_type_conflict');
31
+ }
32
+ cursor = cursor[key];
33
+ }
34
+ cursor[path.at(-1)] = replacement;
35
+ }
36
+ function tomlNodes(ast) {
37
+ const entries = [], tables = [], inline = [];
38
+ function walk(nodes, base = []) {
39
+ for (const node of nodes) {
40
+ if (node.type === 'TOMLTable') {
41
+ const path = node.resolvedKey;
42
+ if (path.some(k => forbidden.has(k))) throw new ConfigError('unsafe_key');
43
+ tables.push({ node, path }); walk(node.body, path);
44
+ } else if (node.type === 'TOMLKeyValue') {
45
+ const path = [...base, ...keys(node.key)];
46
+ if (path.some(k => forbidden.has(k))) throw new ConfigError('unsafe_key');
47
+ entries.push({ node, path });
48
+ walkValue(node.value, path);
49
+ }
50
+ }
51
+ }
52
+ function walkValue(node, path) {
53
+ if (node.type === 'TOMLInlineTable') { inline.push({ node, path }); walk(node.body, path); }
54
+ if (node.type === 'TOMLArray') node.elements.forEach((child, i) => walkValue(child, [...path, i]));
55
+ }
56
+ walk(ast.body[0].body);
57
+ return { entries, tables, inline };
58
+ }
59
+ function checkJSONTree(node, depth = 0) {
60
+ if (depth > 64) throw new ConfigError('nesting_limit');
61
+ if (node.type === 'object') {
62
+ const seen = new Set();
63
+ for (const property of node.children ?? []) {
64
+ const name = property.children[0].value;
65
+ if (forbidden.has(name)) throw new ConfigError('unsafe_key');
66
+ if (seen.has(name)) throw new ConfigError('duplicate_json_key');
67
+ seen.add(name);
68
+ }
69
+ }
70
+ for (const child of node.children ?? []) checkJSONTree(child, depth + 1);
71
+ }
72
+ export function parseDocument(text, format) {
73
+ if (typeof text !== 'string' || Buffer.byteLength(text) > 1024 * 1024) throw new ConfigError('document_size_limit');
74
+ try {
75
+ if (format === 'toml') {
76
+ const ast = parseTOML(text, { tomlVersion: '1.0.0' });
77
+ const nodes = tomlNodes(ast); // Reject dangerous object keys before static evaluation.
78
+ return { text, format, ast, ...nodes, value: getStaticTOMLValue(ast) };
79
+ }
80
+ if (format !== 'json') throw new ConfigError('unsupported_format');
81
+ const value = JSON.parse(text); // Deliberately reject JSONC/trailing commas.
82
+ const errors = [];
83
+ const ast = parseTree(text, errors, { allowTrailingComma: false, disallowComments: true });
84
+ if (errors.length || !ast || ast.type !== 'object') throw new ConfigError('invalid_json_object');
85
+ checkJSONTree(ast);
86
+ return { text, format, ast, value };
87
+ } catch (error) {
88
+ // Parser errors can quote secrets: expose a code, never the original message.
89
+ if (error instanceof ConfigError) throw error;
90
+ throw new ConfigError(format === 'toml' ? 'invalid_toml' : 'invalid_json');
91
+ }
92
+ }
93
+ function literal(value) {
94
+ if (typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value);
95
+ if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value);
96
+ if (Array.isArray(value)) return '[' + value.map(literal).join(', ') + ']';
97
+ throw new ConfigError('unsupported_replacement_type');
98
+ }
99
+ function lineEnd(text, offset) {
100
+ const end = text.indexOf('\n', offset);
101
+ return end < 0 ? text.length : end + 1;
102
+ }
103
+ function replaceRanges(text, changes) {
104
+ const sorted = changes.sort((a, b) => b.start - a.start);
105
+ let last = text.length + 1;
106
+ for (const edit of sorted) {
107
+ if (edit.end > last) throw new ConfigError('overlapping_edits');
108
+ text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
109
+ last = edit.start;
110
+ }
111
+ return text;
112
+ }
113
+ function tomlPatch(doc, path, value) {
114
+ const { text, entries, tables, inline } = doc;
115
+ const serialized = literal(value);
116
+ const exact = entries.find(entry => same(entry.path, path));
117
+ if (exact) return text.slice(0, exact.node.value.range[0]) + serialized + text.slice(exact.node.value.range[1]);
118
+
119
+ // A table-valued exporter may be replaced only within its own subtree. The
120
+ // semantic postcondition below proves that unrelated tables were preserved.
121
+ const subtree = tables.filter(table => prefix(path, table.path));
122
+ if (subtree.length) {
123
+ if (subtree.some(table => table.node.kind === 'array')) throw new ConfigError('array_table_replacement_blocked');
124
+ const edits = [];
125
+ for (const table of subtree) {
126
+ edits.push({ start: table.node.range[0], end: table.node.key.range[1] + 1, text: '' });
127
+ for (const child of table.node.body) edits.push({ start: child.range[0], end: child.range[1], text: '' });
128
+ }
129
+ const without = parseDocument(replaceRanges(text, edits), 'toml');
130
+ return tomlPatch(without, path, value);
131
+ }
132
+ // Object represented by dotted keys without an explicit table header.
133
+ const dotted = entries.filter(entry => prefix(path, entry.path));
134
+ if (dotted.length) {
135
+ const outer = dotted.filter(entry => !dotted.some(other => other !== entry && prefix(other.path, entry.path)));
136
+ if (outer.some(entry => entry.node.parent.type === 'TOMLInlineTable')) throw new ConfigError('inline_subtree_replacement_blocked');
137
+ return tomlPatch(parseDocument(replaceRanges(text, outer.map(entry => ({ start: entry.node.range[0], end: entry.node.range[1], text: '' }))), 'toml'), path, value);
138
+ }
139
+ const inlined = inline.filter(entry => prefix(entry.path, path)).sort((a, b) => b.path.length - a.path.length)[0];
140
+ if (inlined) {
141
+ const pos = inlined.node.range[1] - 1;
142
+ const insertion = (inlined.node.body.length ? ', ' : ' ') + keyText(path.slice(inlined.path.length)) + ' = ' + serialized + ' ';
143
+ return text.slice(0, pos) + insertion + text.slice(pos);
144
+ }
145
+ const parent = tables.filter(entry => entry.node.kind === 'standard' && prefix(entry.path, path)).sort((a, b) => b.path.length - a.path.length)[0];
146
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
147
+ const pos = parent ? lineEnd(text, parent.node.key.range[1] + 1) : 0;
148
+ const relative = parent ? path.slice(parent.path.length) : path;
149
+ return text.slice(0, pos) + (pos && !text.slice(0, pos).endsWith('\n') ? eol : '') + keyText(relative) + ' = ' + serialized + eol + text.slice(pos);
150
+ }
151
+ export function patchDocument(text, format, operations) {
152
+ let doc = parseDocument(text, format);
153
+ for (const { path, value } of operations) {
154
+ checkedPath(path);
155
+ if (isDeepStrictEqual(at(doc.value, path), value)) continue;
156
+ const expected = structuredClone(doc.value);
157
+ set(expected, path, structuredClone(value));
158
+ const changed = format === 'toml' ? tomlPatch(doc, path, value) : applyEdits(doc.text, modify(doc.text, path, value, {
159
+ formattingOptions: { insertSpaces: true, tabSize: 2, eol: doc.text.includes('\r\n') ? '\r\n' : '\n' },
160
+ }));
161
+ const next = parseDocument(changed, format);
162
+ if (!isDeepStrictEqual(next.value, expected)) throw new ConfigError('semantic_postcondition_failed');
163
+ doc = next;
164
+ }
165
+ return doc.text;
166
+ }
@@ -0,0 +1,211 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, dirname } from 'node:path';
3
+ import { readdir } from 'node:fs/promises';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+ import { definition as codex } from '../adapters/codex.mjs';
7
+ import { definition as claude, disabledEnvironment } from '../adapters/claude.mjs';
8
+ import { ConfigError, parseDocument, at } from './document.mjs';
9
+ import { absolute, safePath, metadata, readBounded, fingerprint, sha256, canonical } from './io.mjs';
10
+
11
+ export const adapters = { codex, claude };
12
+ // Only built-in read-only system paths use macOS's canonical /private/etc.
13
+ // User homes and project paths retain safePath's strict no-symlink contract.
14
+ export function codexSystemPaths(platform = process.platform) {
15
+ if (platform === 'win32') return { defaults: [], managed: [] };
16
+ const root = platform === 'darwin' ? '/private/etc/codex' : '/etc/codex';
17
+ return { defaults: [root + '/config.toml'],
18
+ managed: [root + '/managed_config.toml', root + '/requirements.toml'] };
19
+ }
20
+ export const rendererSource = fileURLToPath(new URL('../statusline/claude-statusline.mjs', import.meta.url));
21
+ const specKeys = ['client', 'scope', 'project', 'codex_home', 'claude_home', 'mode', 'replace_statusline', 'evidence'];
22
+ export function normalizeSpec(input = {}, env = process.env) {
23
+ if (Object.keys(input).some(key => !specKeys.includes(key))) throw new ConfigError('unknown_spec_key');
24
+ const result = {
25
+ client: input.client ?? 'all', scope: input.scope ?? 'user',
26
+ project: input.project ? absolute(input.project) : null,
27
+ codex_home: absolute(input.codex_home ?? env.CODEX_HOME ?? join(homedir(), '.codex')),
28
+ claude_home: absolute(input.claude_home ?? env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')),
29
+ mode: input.mode ?? 'local-private', replace_statusline: input.replace_statusline ?? false,
30
+ evidence: input.evidence ? absolute(input.evidence) : null,
31
+ };
32
+ if (!['all', 'codex', 'claude'].includes(result.client) || !['user', 'project'].includes(result.scope)
33
+ || !['local-private', 'privacy-only', 'statusline-only'].includes(result.mode)
34
+ || typeof result.replace_statusline !== 'boolean') throw new ConfigError('invalid_selection');
35
+ if (result.scope === 'project' && !result.project) throw new ConfigError('project_required');
36
+ return result;
37
+ }
38
+ export const selectedClients = spec => spec.client === 'all' ? ['codex', 'claude'] : [spec.client];
39
+ export function targetFor(spec, client) {
40
+ const root = spec.scope === 'user' ? spec[client + '_home'] : join(spec.project, '.' + client);
41
+ const filename = client === 'claude' && spec.scope === 'project' ? 'settings.local.json' : adapters[client].filename;
42
+ return { client, root, path: join(root, filename), format: adapters[client].format };
43
+ }
44
+ export async function rulesFor(spec, client) {
45
+ const target = targetFor(spec, client);
46
+ const source = await readBounded(rendererSource);
47
+ const runtime = join(target.root, '.wta-ai-cli-config', 'runtime', 'claude-statusline-' + source.sha256.slice(0, 16) + '.mjs');
48
+ const rules = [
49
+ ...(spec.mode !== 'statusline-only' ? adapters[client].privacy : []),
50
+ ...(spec.mode !== 'privacy-only' ? adapters[client].statusline(process.execPath, runtime) : []),
51
+ ];
52
+ return { rules, runtime, source, target };
53
+ }
54
+ const nonemptyOff = new Set(['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY']);
55
+ function envMatches(key, value) {
56
+ if (nonemptyOff.has(key)) return typeof value === 'string' && value.length > 0;
57
+ return value === disabledEnvironment[key];
58
+ }
59
+ export function environmentState(env) {
60
+ const observed = Object.keys(disabledEnvironment).map(key => ({
61
+ key, present: Object.hasOwn(env, key), matches_policy: Object.hasOwn(env, key) ? envMatches(key, env[key]) : null,
62
+ }));
63
+ // Hash only explicitly named control values. Never enumerate process secrets.
64
+ const digest = sha256(canonical(Object.fromEntries(Object.keys(disabledEnvironment).filter(k => Object.hasOwn(env, k)).map(k => [k, env[k]]))));
65
+ return { digest: sha256(digest + (env.NODE_OPTIONS ? ':node-options-present' : ':clean-node-options')), observed };
66
+ }
67
+ function safeValue(value, desired) {
68
+ if (value === undefined) return '<absent>';
69
+ if (isDeepStrictEqual(value, desired)) return desired;
70
+ if (typeof value === 'boolean' || ['none', 'otlp', 'statsig', '0', '1', 'true', 'false'].includes(value)) return value;
71
+ // Config strings and objects can contain arbitrary secrets, even at innocent keys.
72
+ return '<redacted-existing-value>';
73
+ }
74
+ export const previewValue = safeValue;
75
+ async function layer(path, label, format, files) {
76
+ try {
77
+ const file = await readBounded(path);
78
+ const parsed = parseDocument(file.exists ? file.text : format === 'toml' ? '' : '{}', format);
79
+ const entry = { path, label, ...fingerprint(file), value: parsed.value, text: file.text, format };
80
+ files.push(entry); return entry;
81
+ } catch (error) {
82
+ const entry = { path, label, error: error.code ?? 'read_failed', format };
83
+ files.push(entry); return entry;
84
+ }
85
+ }
86
+ async function managedLayers(client, files) {
87
+ const paths = client === 'codex'
88
+ ? codexSystemPaths().managed
89
+ : [join(process.platform === 'darwin' ? '/Library/Application Support/ClaudeCode'
90
+ : process.platform === 'win32' ? 'C:\\Program Files\\ClaudeCode' : '/etc/claude-code', 'managed-settings.json')];
91
+ for (const path of paths) await layer(path, client + '-managed', client === 'codex' ? 'toml' : 'json', files);
92
+ if (client === 'claude') {
93
+ const directory = join(dirname(paths[0]), 'managed-settings.d');
94
+ try {
95
+ await safePath(directory);
96
+ const names = (await readdir(directory)).filter(n => !n.startsWith('.') && n.endsWith('.json')).sort();
97
+ if (names.length > 32) throw new ConfigError('managed_fragment_limit');
98
+ for (const name of names) await layer(join(directory, name), 'claude-managed-fragment', 'json', files);
99
+ } catch (error) {
100
+ if (error.code !== 'ENOENT') files.push({ path: directory, label: 'claude-managed-fragments', error: error.code ?? 'read_failed' });
101
+ }
102
+ }
103
+ }
104
+ export async function inspect(input = {}, { env = process.env } = {}) {
105
+ const spec = normalizeSpec(input, env);
106
+ const files = [], clients = [], findings = [];
107
+ const add = (client, code, severity = 'warning') => findings.push({ client, code, severity });
108
+ if (spec.project) {
109
+ await safePath(spec.project);
110
+ if ((await metadata(spec.project)).type !== 'directory') throw new ConfigError('project_directory_required');
111
+ }
112
+ const environment = environmentState(env);
113
+ if (env.NODE_OPTIONS) add('runtime', 'node_startup_options_require_clean_environment', 'blocker');
114
+ let evidence = null, evidenceFingerprint = null;
115
+ if (spec.evidence) {
116
+ const file = await readBounded(spec.evidence);
117
+ if (file.exists) { evidence = parseDocument(file.text, 'json').value; evidenceFingerprint = fingerprint(file); }
118
+ }
119
+ for (const client of selectedClients(spec)) {
120
+ const { target, rules, runtime, source } = await rulesFor(spec, client);
121
+ const ownFiles = [];
122
+ if (client === 'codex') for (const path of codexSystemPaths().defaults) await layer(path, 'codex-system', 'toml', ownFiles);
123
+ const user = await layer(join(spec[client + '_home'], adapters[client].filename), client + '-user', target.format, ownFiles);
124
+ if (spec.project) {
125
+ const dirs = [spec.project];
126
+ if (client === 'codex') {
127
+ let directory = spec.project;
128
+ for (let n = 0; n < 20 && !(await metadata(join(directory, '.git'))).exists; n++) {
129
+ const parent = dirname(directory); if (parent === directory) break;
130
+ dirs.unshift(parent); directory = parent;
131
+ }
132
+ }
133
+ for (const directory of dirs) await layer(join(directory, '.' + client, adapters[client].filename), client + '-project', target.format, ownFiles);
134
+ if (client === 'claude') await layer(join(spec.project, '.claude', 'settings.local.json'), 'claude-local', 'json', ownFiles);
135
+ }
136
+ await managedLayers(client, ownFiles);
137
+ files.push(...ownFiles);
138
+ for (const f of ownFiles) if (f.error) add(client, f.label + ':' + f.error, 'blocker');
139
+ // Do not guess which server/OS-managed source is authoritative, execute a
140
+ // policy helper, or merge its restrictions with a writable user document.
141
+ if (ownFiles.some(f => f.label.includes('managed') && f.exists)) add(client, 'managed_policy_requires_operator_review', 'blocker');
142
+ if (client === 'codex' && (at(user.value, ['profile']) !== undefined || at(user.value, ['profiles']) !== undefined)) {
143
+ add(client, 'profile_selection_requires_operator_review', 'blocker');
144
+ }
145
+ const targetFile = ownFiles.find(f => f.path === target.path);
146
+ if (!targetFile) throw new ConfigError('target_not_discovered');
147
+ const credentials = [];
148
+ const names = client === 'codex' ? ['auth.json'] : ['.credentials.json'];
149
+ for (const name of names) {
150
+ try { credentials.push({ name, ...await metadata(join(spec[client + '_home'], name)), contents_read: false }); }
151
+ catch { credentials.push({ name, state: 'unknown', contents_read: false }); }
152
+ }
153
+ const integrations = [];
154
+ for (const field of ['hooks', 'mcp_servers', 'mcpServers', 'plugins', 'enabledPlugins', 'extraKnownMarketplaces', 'apiKeyHelper', 'policyHelper', 'notify']) {
155
+ if (ownFiles.some(f => at(f.value, [field]) !== undefined)) integrations.push(field);
156
+ }
157
+ if (integrations.length) add(client, 'integrations_preserved_not_executed_or_privacy_verified');
158
+ if (client === 'claude' && environment.observed.some(v => v.present && !v.matches_policy) && spec.mode !== 'statusline-only') {
159
+ add(client, 'inherited_environment_conflict', 'blocker');
160
+ }
161
+ const cells = rules.map(rule => {
162
+ let found;
163
+ for (const f of ownFiles) {
164
+ if (f.error || !f.exists) continue;
165
+ // Machine-local Codex telemetry cannot be supplied by project config.
166
+ if (client === 'codex' && f.label.endsWith('project') && rule.scope === 'user') continue;
167
+ const value = at(f.value, rule.path);
168
+ if (value !== undefined) found = { file: f, value };
169
+ }
170
+ return { key: rule.path.join('.'), source: found?.file.label ?? 'client-default-unknown',
171
+ observed: safeValue(found?.value, rule.value), matches_policy: found ? isDeepStrictEqual(found.value, rule.value) : null,
172
+ higher_override: !!found && found.file.path !== target.path && ownFiles.indexOf(found.file) > ownFiles.indexOf(targetFile),
173
+ };
174
+ });
175
+ for (const cell of cells) if (cell.higher_override && !cell.matches_policy) add(client, 'higher_precedence_override:' + cell.key, 'blocker');
176
+ if (spec.mode !== 'privacy-only' && client === 'claude') {
177
+ if (ownFiles.some(f => at(f.value, ['disableAllHooks']) === true)) add(client, 'statusline_disabled_by_disableAllHooks', 'blocker');
178
+ const command = rules.find(r => r.path.join('.') === 'statusLine.command').value;
179
+ const custom = ownFiles.some(f => {
180
+ const prior = at(f.value, ['statusLine', 'command']);
181
+ return prior !== undefined && prior !== command;
182
+ });
183
+ if (custom && !spec.replace_statusline) add(client, 'existing_statusline_requires_explicit_replacement', 'blocker');
184
+ }
185
+ for (const rule of rules) if (spec.scope === 'project' && rule.scope === 'user') add(client, 'requires_user_scope:' + rule.path.join('.'), 'blocker');
186
+ const attestation = evidence?.schema_version === 1 ? evidence.clients?.[client] : null;
187
+ const reviewedAt = Date.parse(evidence?.reviewed_at ?? '');
188
+ const fresh = Number.isFinite(reviewedAt) && reviewedAt <= Date.now() && Date.now() - reviewedAt <= 30 * 86400_000;
189
+ const version = typeof attestation?.version === 'string' && /^[A-Za-z0-9.+ -]{1,80}$/.test(attestation.version) ? attestation.version : null;
190
+ const supported = version && fresh && attestation?.verification === 'manual-local'
191
+ && attestation?.higher_precedence_reviewed === true
192
+ && Array.isArray(attestation?.scopes) && attestation.scopes.includes(spec.scope)
193
+ && Array.isArray(attestation?.keys) && rules.every(r => attestation.keys.includes(r.path.join('.')))
194
+ && (client !== 'codex' || spec.mode === 'privacy-only' || adapters.codex.statusline()[0].value.every(item => attestation?.status_items?.includes(item)));
195
+ if (!supported) add(client, 'installed_capabilities_not_attested', 'blocker');
196
+ clients.push({ client, target, targetFile, rules, runtime, source,
197
+ credentials, integrations, cells, version, capabilities: supported ? 'operator-attested-not-auto-probed' : 'unknown' });
198
+ }
199
+ return { spec, files, clients, findings, environment, evidenceFingerprint };
200
+ }
201
+ export function publicReport(snapshot) {
202
+ return { schema_version: 2, scope: snapshot.spec.scope, mode: snapshot.spec.mode,
203
+ network: 'not-used', commands: 'not-run', credential_contents: 'not-read',
204
+ effective_configuration: 'observed-files-only; CLI flags, OS/remote policies and helpers require local review',
205
+ clients: snapshot.clients.map(c => ({ client: c.client, version: c.version, capabilities: c.capabilities,
206
+ configuration: fingerprint(c.targetFile), credentials: c.credentials, integrations: c.integrations, settings: c.cells,
207
+ })),
208
+ // Do not output targetFile.value/text or a provider's identifiers/URLs.
209
+ findings: snapshot.findings, environment: snapshot.environment.observed,
210
+ };
211
+ }
@@ -0,0 +1,104 @@
1
+ // Bounded, no-follow local I/O. Does not read credentials, run a CLI, or use a network.
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open, mkdir, rmdir } from 'node:fs/promises';
4
+ import { createHash } from 'node:crypto';
5
+ import { resolve, isAbsolute, parse, join, relative, sep, dirname } from 'node:path';
6
+ import { ConfigError } from './document.mjs';
7
+
8
+ export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
9
+ export const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
10
+ export function canonical(value) {
11
+ if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
12
+ if (value && typeof value === 'object') return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';
13
+ return JSON.stringify(value);
14
+ }
15
+ export function absolute(path) {
16
+ if (typeof path !== 'string' || !isAbsolute(path) || /[\x00-\x1f\x7f]/.test(path)) throw new ConfigError('absolute_path_required');
17
+ return resolve(path);
18
+ }
19
+ export function within(root, path) {
20
+ const part = relative(root, path);
21
+ if (part === '..' || part.startsWith('..' + sep) || isAbsolute(part)) throw new ConfigError('path_escape');
22
+ return path;
23
+ }
24
+ export async function metadata(path) {
25
+ try {
26
+ const s = await lstat(path);
27
+ return { exists: true, type: s.isSymbolicLink() ? 'symlink' : s.isFile() ? 'file' : s.isDirectory() ? 'directory' : 'other',
28
+ bytes: s.size, mode: s.mode & 0o777, mtime: s.mtimeMs, dev: s.dev, ino: s.ino };
29
+ } catch (error) {
30
+ if (error.code === 'ENOENT') return { exists: false };
31
+ throw new ConfigError('metadata_unavailable');
32
+ }
33
+ }
34
+ export async function safePath(path, { privateWrite = false } = {}) {
35
+ path = absolute(path);
36
+ let current = parse(path).root;
37
+ for (const component of path.slice(current.length).split(sep).filter(Boolean)) {
38
+ current = join(current, component);
39
+ const meta = await metadata(current);
40
+ if (!meta.exists) continue;
41
+ if (meta.type === 'symlink' || (current !== path && meta.type !== 'directory')) throw new ConfigError('unsafe_path');
42
+ if (privateWrite && process.platform !== 'win32') {
43
+ const stat = await lstat(current);
44
+ // Sticky system temp parents are acceptable; private state itself is checked separately.
45
+ if (stat.isDirectory() && (stat.mode & 0o022) && !(stat.mode & 0o1000)) throw new ConfigError('shared_writable_parent');
46
+ if (current === path && process.getuid && stat.uid !== process.getuid()) throw new ConfigError('owner_mismatch');
47
+ }
48
+ }
49
+ return path;
50
+ }
51
+ export async function readBounded(path, limit = 1024 * 1024) {
52
+ await safePath(path);
53
+ const before = await metadata(path);
54
+ if (!before.exists) return { ...before, text: '' };
55
+ if (before.type !== 'file' || before.bytes > limit) throw new ConfigError('unsafe_or_oversize_file');
56
+ let handle;
57
+ try {
58
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
59
+ const stat = await handle.stat();
60
+ if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino || stat.size > limit) throw new ConfigError('read_race');
61
+ const buffer = Buffer.alloc(Math.min(limit + 1, stat.size + 1));
62
+ let total = 0;
63
+ while (total < buffer.length) {
64
+ const { bytesRead } = await handle.read(buffer, total, buffer.length - total, null);
65
+ if (!bytesRead) break;
66
+ total += bytesRead;
67
+ }
68
+ if (total !== stat.size || total > limit) throw new ConfigError('read_race');
69
+ const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(buffer.subarray(0, total));
70
+ if (!same(before, await metadata(path))) throw new ConfigError('read_race');
71
+ return { ...before, sha256: sha256(buffer.subarray(0, total)), text };
72
+ } catch (error) {
73
+ if (error instanceof ConfigError) throw error;
74
+ throw new ConfigError('read_failed');
75
+ } finally { await handle?.close(); }
76
+ }
77
+ export function fingerprint(value) {
78
+ return Object.fromEntries(['exists', 'type', 'bytes', 'mode', 'mtime', 'dev', 'ino', 'sha256'].filter(k => Object.hasOwn(value, k)).map(k => [k, value[k]]));
79
+ }
80
+ export async function makePrivateDirs(path, made = []) {
81
+ await safePath(path, { privateWrite: true });
82
+ const meta = await metadata(path);
83
+ if (meta.exists) {
84
+ if (meta.type !== 'directory') throw new ConfigError('directory_required');
85
+ return made;
86
+ }
87
+ await makePrivateDirs(dirname(path), made);
88
+ await mkdir(path, { mode: 0o700 });
89
+ made.push(path);
90
+ return made;
91
+ }
92
+ export async function newPrivateFile(path, content) {
93
+ await safePath(path, { privateWrite: true });
94
+ const h = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
95
+ try { await h.writeFile(content, 'utf8'); await h.sync(); }
96
+ finally { await h.close(); }
97
+ }
98
+ export async function cleanCreatedDirs(dirs) {
99
+ for (const directory of [...dirs].reverse()) {
100
+ try { await rmdir(directory); } catch (error) {
101
+ if (!['ENOTEMPTY', 'ENOENT', 'EEXIST'].includes(error.code)) throw new ConfigError('directory_cleanup_failed');
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,79 @@
1
+ import { at, patchDocument, ConfigError, parseDocument } from './document.mjs';
2
+ import { inspect, previewValue, rulesFor, normalizeSpec, selectedClients } from './inspect.mjs';
3
+ import { readBounded, fingerprint, sha256, canonical, newPrivateFile } from './io.mjs';
4
+
5
+ const digest = value => sha256(canonical(value));
6
+ export function planId(plan) { const { id, ...body } = plan; return digest(body); }
7
+ export async function createPlan(input, options) {
8
+ const snapshot = await inspect(input, options);
9
+ const entries = [], findings = [...snapshot.findings];
10
+ for (const client of snapshot.clients) {
11
+ const { target, targetFile, rules } = client;
12
+ if (targetFile.error) continue;
13
+ try {
14
+ const text = targetFile.exists ? targetFile.text : target.format === 'toml' ? '' : '{}\n';
15
+ const changed = patchDocument(text, target.format, rules);
16
+ if (target.client === 'claude' && snapshot.spec.mode !== 'privacy-only') {
17
+ const existing = await readBounded(client.runtime);
18
+ // Content-addressed renderer names are immutable: do not overwrite a custom script.
19
+ if (existing.exists && existing.sha256 !== client.source.sha256) throw new ConfigError('renderer_content_conflict');
20
+ entries.push({ client: target.client, kind: 'renderer', root: target.root, path: client.runtime,
21
+ before: fingerprint(existing), after_hash: client.source.sha256, noop: existing.exists,
22
+ });
23
+ }
24
+ entries.push({ client: target.client, kind: 'config', root: target.root, path: target.path, format: target.format,
25
+ before: fingerprint(targetFile), after_hash: sha256(changed), noop: targetFile.exists && targetFile.sha256 === sha256(changed),
26
+ operations: rules,
27
+ diff: rules.filter(rule => canonical(at(targetFile.value, rule.path)) !== canonical(rule.value)).map(rule => ({
28
+ key: rule.path.join('.'), before: previewValue(at(targetFile.value, rule.path), rule.value), after: rule.value, reason: rule.reason,
29
+ })),
30
+ });
31
+ } catch (error) { findings.push({ client: target.client, code: error.code ?? 'plan_failed', severity: 'blocker' }); }
32
+ }
33
+ const plan = {
34
+ schema_version: 1, kind: 'wta-ai-cli-config-plan', policy_revision: '2026-10-10', spec: snapshot.spec,
35
+ platform: process.platform, node: process.execPath,
36
+ observations: {
37
+ files: snapshot.files.map(f => ({ path: f.path, fingerprint: fingerprint(f), ...(f.error ? { error: f.error } : {}) })),
38
+ evidence: snapshot.evidenceFingerprint, environment: snapshot.environment.digest,
39
+ },
40
+ entries, findings, ready: !findings.some(f => f.severity === 'blocker'),
41
+ verification: 'offline parse/hash/policy only; host load, account limits and OS/remote policy not automatically verified',
42
+ privacy: 'no runtime network; existing integrations preserved and not certified',
43
+ backup: '<each-target-root>/.wta-ai-cli-config/transactions/<plan-id>/N.pre-optimize.bak',
44
+ };
45
+ return { ...plan, id: planId(plan) };
46
+ }
47
+ export async function savePlan(plan, path) {
48
+ if (!path.endsWith('.plan.json') || plan.entries.some(entry => entry.path === path) || plan.spec.evidence === path) throw new ConfigError('plan_output_requires_new_plan_json');
49
+ await newPrivateFile(path, JSON.stringify(plan, null, 2) + '\n');
50
+ }
51
+ export async function loadPlan(path) {
52
+ const file = await readBounded(path);
53
+ if (!file.exists) throw new ConfigError('plan_missing');
54
+ const plan = parseDocument(file.text, 'json').value;
55
+ if (plan.kind !== 'wta-ai-cli-config-plan' || plan.schema_version !== 1 || plan.id !== planId(plan)
56
+ || !/^[0-9a-f]{64}$/.test(plan.id) || plan.platform !== process.platform || plan.node !== process.execPath) {
57
+ throw new ConfigError('plan_integrity_or_platform_mismatch');
58
+ }
59
+ if (canonical(normalizeSpec(plan.spec)) !== canonical(plan.spec)) throw new ConfigError('noncanonical_plan_spec');
60
+ const expected = [];
61
+ for (const client of selectedClients(plan.spec)) {
62
+ const r = await rulesFor(plan.spec, client);
63
+ if (client === 'claude' && plan.spec.mode !== 'privacy-only') expected.push({ path: r.runtime, kind: 'renderer', client, root: r.target.root });
64
+ expected.push({ ...r.target, kind: 'config', operations: r.rules });
65
+ }
66
+ if (plan.entries.length !== expected.length) throw new ConfigError('incomplete_plan');
67
+ for (let i = 0; i < expected.length; i++) {
68
+ for (const [key, value] of Object.entries(expected[i])) {
69
+ if (canonical(plan.entries[i][key]) !== canonical(value)) throw new ConfigError('plan_target_or_operation_mismatch');
70
+ }
71
+ }
72
+ return plan;
73
+ }
74
+ export async function desiredText(plan, entry) {
75
+ const rules = await rulesFor(plan.spec, entry.client);
76
+ if (entry.kind === 'renderer') return rules.source.text;
77
+ const file = await readBounded(entry.path);
78
+ return patchDocument(file.exists ? file.text : entry.format === 'toml' ? '' : '{}\n', entry.format, rules.rules);
79
+ }