@made-by-moonlight/athene-core 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +241 -0
  3. package/dist/activity-events.d.ts +42 -0
  4. package/dist/activity-events.d.ts.map +1 -0
  5. package/dist/activity-events.js +192 -0
  6. package/dist/activity-events.js.map +1 -0
  7. package/dist/activity-log.d.ts +71 -0
  8. package/dist/activity-log.d.ts.map +1 -0
  9. package/dist/activity-log.js +203 -0
  10. package/dist/activity-log.js.map +1 -0
  11. package/dist/activity-signal.d.ts +20 -0
  12. package/dist/activity-signal.d.ts.map +1 -0
  13. package/dist/activity-signal.js +91 -0
  14. package/dist/activity-signal.js.map +1 -0
  15. package/dist/agent-report.d.ts +148 -0
  16. package/dist/agent-report.d.ts.map +1 -0
  17. package/dist/agent-report.js +516 -0
  18. package/dist/agent-report.js.map +1 -0
  19. package/dist/agent-selection.d.ts +31 -0
  20. package/dist/agent-selection.d.ts.map +1 -0
  21. package/dist/agent-selection.js +69 -0
  22. package/dist/agent-selection.js.map +1 -0
  23. package/dist/agent-workspace-hooks.d.ts +74 -0
  24. package/dist/agent-workspace-hooks.d.ts.map +1 -0
  25. package/dist/agent-workspace-hooks.js +988 -0
  26. package/dist/agent-workspace-hooks.js.map +1 -0
  27. package/dist/atomic-write.d.ts +6 -0
  28. package/dist/atomic-write.d.ts.map +1 -0
  29. package/dist/atomic-write.js +49 -0
  30. package/dist/atomic-write.js.map +1 -0
  31. package/dist/cleanup-stack.d.ts +37 -0
  32. package/dist/cleanup-stack.d.ts.map +1 -0
  33. package/dist/cleanup-stack.js +45 -0
  34. package/dist/cleanup-stack.js.map +1 -0
  35. package/dist/code-review-manager.d.ts +118 -0
  36. package/dist/code-review-manager.d.ts.map +1 -0
  37. package/dist/code-review-manager.js +719 -0
  38. package/dist/code-review-manager.js.map +1 -0
  39. package/dist/code-review-store.d.ts +114 -0
  40. package/dist/code-review-store.d.ts.map +1 -0
  41. package/dist/code-review-store.js +346 -0
  42. package/dist/code-review-store.js.map +1 -0
  43. package/dist/config-generator.d.ts +84 -0
  44. package/dist/config-generator.d.ts.map +1 -0
  45. package/dist/config-generator.js +295 -0
  46. package/dist/config-generator.js.map +1 -0
  47. package/dist/config.d.ts +55 -0
  48. package/dist/config.d.ts.map +1 -0
  49. package/dist/config.js +852 -0
  50. package/dist/config.js.map +1 -0
  51. package/dist/daemon-children.d.ts +55 -0
  52. package/dist/daemon-children.d.ts.map +1 -0
  53. package/dist/daemon-children.js +435 -0
  54. package/dist/daemon-children.js.map +1 -0
  55. package/dist/dashboard-notifications.d.ts +42 -0
  56. package/dist/dashboard-notifications.d.ts.map +1 -0
  57. package/dist/dashboard-notifications.js +123 -0
  58. package/dist/dashboard-notifications.js.map +1 -0
  59. package/dist/events-db.d.ts +39 -0
  60. package/dist/events-db.d.ts.map +1 -0
  61. package/dist/events-db.js +185 -0
  62. package/dist/events-db.js.map +1 -0
  63. package/dist/feature-flags.d.ts +2 -0
  64. package/dist/feature-flags.d.ts.map +1 -0
  65. package/dist/feature-flags.js +9 -0
  66. package/dist/feature-flags.js.map +1 -0
  67. package/dist/feedback-tools.d.ts +97 -0
  68. package/dist/feedback-tools.d.ts.map +1 -0
  69. package/dist/feedback-tools.js +161 -0
  70. package/dist/feedback-tools.js.map +1 -0
  71. package/dist/file-lock.d.ts +5 -0
  72. package/dist/file-lock.d.ts.map +1 -0
  73. package/dist/file-lock.js +59 -0
  74. package/dist/file-lock.js.map +1 -0
  75. package/dist/format-automated-comments.d.ts +18 -0
  76. package/dist/format-automated-comments.d.ts.map +1 -0
  77. package/dist/gh-trace.d.ts +57 -0
  78. package/dist/gh-trace.d.ts.map +1 -0
  79. package/dist/gh-trace.js +320 -0
  80. package/dist/gh-trace.js.map +1 -0
  81. package/dist/git-activity.d.ts +10 -0
  82. package/dist/git-activity.d.ts.map +1 -0
  83. package/dist/git-activity.js +30 -0
  84. package/dist/git-activity.js.map +1 -0
  85. package/dist/global-config.d.ts +1085 -0
  86. package/dist/global-config.d.ts.map +1 -0
  87. package/dist/global-config.js +1067 -0
  88. package/dist/global-config.js.map +1 -0
  89. package/dist/index.d.ts +91 -0
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +59 -0
  92. package/dist/index.js.map +1 -0
  93. package/dist/key-value.d.ts +7 -0
  94. package/dist/key-value.d.ts.map +1 -0
  95. package/dist/key-value.js +24 -0
  96. package/dist/key-value.js.map +1 -0
  97. package/dist/lifecycle-manager.d.ts +22 -0
  98. package/dist/lifecycle-manager.d.ts.map +1 -0
  99. package/dist/lifecycle-manager.js +2813 -0
  100. package/dist/lifecycle-manager.js.map +1 -0
  101. package/dist/lifecycle-state.d.ts +28 -0
  102. package/dist/lifecycle-state.d.ts.map +1 -0
  103. package/dist/lifecycle-state.js +446 -0
  104. package/dist/lifecycle-state.js.map +1 -0
  105. package/dist/lifecycle-status-decisions.d.ts +85 -0
  106. package/dist/lifecycle-status-decisions.d.ts.map +1 -0
  107. package/dist/lifecycle-status-decisions.js +262 -0
  108. package/dist/lifecycle-status-decisions.js.map +1 -0
  109. package/dist/lifecycle-transition.d.ts +81 -0
  110. package/dist/lifecycle-transition.d.ts.map +1 -0
  111. package/dist/lifecycle-transition.js +207 -0
  112. package/dist/lifecycle-transition.js.map +1 -0
  113. package/dist/metadata.d.ts +54 -0
  114. package/dist/metadata.d.ts.map +1 -0
  115. package/dist/metadata.js +484 -0
  116. package/dist/metadata.js.map +1 -0
  117. package/dist/migration/storage-v2.d.ts +76 -0
  118. package/dist/migration/storage-v2.d.ts.map +1 -0
  119. package/dist/migration/storage-v2.js +1614 -0
  120. package/dist/migration/storage-v2.js.map +1 -0
  121. package/dist/notification-data.d.ts +135 -0
  122. package/dist/notification-data.d.ts.map +1 -0
  123. package/dist/notification-data.js +204 -0
  124. package/dist/notification-data.js.map +1 -0
  125. package/dist/notification-observability.d.ts +21 -0
  126. package/dist/notification-observability.d.ts.map +1 -0
  127. package/dist/notification-observability.js +154 -0
  128. package/dist/notification-observability.js.map +1 -0
  129. package/dist/notifier-resolution.d.ts +14 -0
  130. package/dist/notifier-resolution.d.ts.map +1 -0
  131. package/dist/notifier-resolution.js +23 -0
  132. package/dist/notifier-resolution.js.map +1 -0
  133. package/dist/observability.d.ts +100 -0
  134. package/dist/observability.d.ts.map +1 -0
  135. package/dist/observability.js +535 -0
  136. package/dist/observability.js.map +1 -0
  137. package/dist/opencode-agents-md.d.ts +3 -0
  138. package/dist/opencode-agents-md.d.ts.map +1 -0
  139. package/dist/opencode-agents-md.js +40 -0
  140. package/dist/opencode-agents-md.js.map +1 -0
  141. package/dist/opencode-config.d.ts +2 -0
  142. package/dist/opencode-config.d.ts.map +1 -0
  143. package/dist/opencode-config.js +17 -0
  144. package/dist/opencode-config.js.map +1 -0
  145. package/dist/opencode-session-id.d.ts +2 -0
  146. package/dist/opencode-session-id.d.ts.map +1 -0
  147. package/dist/opencode-session-id.js +12 -0
  148. package/dist/opencode-session-id.js.map +1 -0
  149. package/dist/opencode-shared.d.ts +80 -0
  150. package/dist/opencode-shared.d.ts.map +1 -0
  151. package/dist/opencode-shared.js +202 -0
  152. package/dist/opencode-shared.js.map +1 -0
  153. package/dist/orchestrator-prompt.d.ts +19 -0
  154. package/dist/orchestrator-prompt.d.ts.map +1 -0
  155. package/dist/orchestrator-prompt.js +130 -0
  156. package/dist/orchestrator-prompt.js.map +1 -0
  157. package/dist/orchestrator-session-strategy.d.ts +5 -0
  158. package/dist/orchestrator-session-strategy.d.ts.map +1 -0
  159. package/dist/orchestrator-session-strategy.js +13 -0
  160. package/dist/orchestrator-session-strategy.js.map +1 -0
  161. package/dist/paths.d.ts +145 -0
  162. package/dist/paths.d.ts.map +1 -0
  163. package/dist/paths.js +288 -0
  164. package/dist/paths.js.map +1 -0
  165. package/dist/platform.d.ts +32 -0
  166. package/dist/platform.d.ts.map +1 -0
  167. package/dist/platform.js +211 -0
  168. package/dist/platform.js.map +1 -0
  169. package/dist/plugin-registry.d.ts +15 -0
  170. package/dist/plugin-registry.d.ts.map +1 -0
  171. package/dist/plugin-registry.js +499 -0
  172. package/dist/plugin-registry.js.map +1 -0
  173. package/dist/portfolio-projects.d.ts +7 -0
  174. package/dist/portfolio-projects.d.ts.map +1 -0
  175. package/dist/portfolio-projects.js +65 -0
  176. package/dist/portfolio-projects.js.map +1 -0
  177. package/dist/portfolio-registry.d.ts +42 -0
  178. package/dist/portfolio-registry.d.ts.map +1 -0
  179. package/dist/portfolio-registry.js +311 -0
  180. package/dist/portfolio-registry.js.map +1 -0
  181. package/dist/portfolio-routing.d.ts +5 -0
  182. package/dist/portfolio-routing.d.ts.map +1 -0
  183. package/dist/portfolio-routing.js +24 -0
  184. package/dist/portfolio-routing.js.map +1 -0
  185. package/dist/portfolio-session-service.d.ts +15 -0
  186. package/dist/portfolio-session-service.d.ts.map +1 -0
  187. package/dist/portfolio-session-service.js +206 -0
  188. package/dist/portfolio-session-service.js.map +1 -0
  189. package/dist/process-cache.d.ts +32 -0
  190. package/dist/process-cache.d.ts.map +1 -0
  191. package/dist/process-cache.js +44 -0
  192. package/dist/process-cache.js.map +1 -0
  193. package/dist/project-resolver.d.ts +5 -0
  194. package/dist/project-resolver.d.ts.map +1 -0
  195. package/dist/project-resolver.js +20 -0
  196. package/dist/project-resolver.js.map +1 -0
  197. package/dist/prompt-builder.d.ts +42 -0
  198. package/dist/prompt-builder.d.ts.map +1 -0
  199. package/dist/prompt-builder.js +182 -0
  200. package/dist/prompt-builder.js.map +1 -0
  201. package/dist/prompts/orchestrator.md.js +4 -0
  202. package/dist/prompts/orchestrator.md.js.map +1 -0
  203. package/dist/query-activity-events.d.ts +42 -0
  204. package/dist/query-activity-events.d.ts.map +1 -0
  205. package/dist/query-activity-events.js +170 -0
  206. package/dist/query-activity-events.js.map +1 -0
  207. package/dist/recovery/actions.d.ts +7 -0
  208. package/dist/recovery/actions.d.ts.map +1 -0
  209. package/dist/recovery/index.d.ts +8 -0
  210. package/dist/recovery/index.d.ts.map +1 -0
  211. package/dist/recovery/logger.d.ts +12 -0
  212. package/dist/recovery/logger.d.ts.map +1 -0
  213. package/dist/recovery/manager.d.ts +24 -0
  214. package/dist/recovery/manager.d.ts.map +1 -0
  215. package/dist/recovery/scanner.d.ts +11 -0
  216. package/dist/recovery/scanner.d.ts.map +1 -0
  217. package/dist/recovery/types.d.ts +170 -0
  218. package/dist/recovery/types.d.ts.map +1 -0
  219. package/dist/recovery/validator.d.ts +8 -0
  220. package/dist/recovery/validator.d.ts.map +1 -0
  221. package/dist/report-watcher.d.ts +93 -0
  222. package/dist/report-watcher.d.ts.map +1 -0
  223. package/dist/report-watcher.js +182 -0
  224. package/dist/report-watcher.js.map +1 -0
  225. package/dist/scm-webhook-utils.d.ts +6 -0
  226. package/dist/scm-webhook-utils.d.ts.map +1 -0
  227. package/dist/scm-webhook-utils.js +36 -0
  228. package/dist/scm-webhook-utils.js.map +1 -0
  229. package/dist/session-manager.d.ts +22 -0
  230. package/dist/session-manager.d.ts.map +1 -0
  231. package/dist/session-manager.js +3077 -0
  232. package/dist/session-manager.js.map +1 -0
  233. package/dist/spawn-target.d.ts +23 -0
  234. package/dist/spawn-target.d.ts.map +1 -0
  235. package/dist/spawn-target.js +39 -0
  236. package/dist/spawn-target.js.map +1 -0
  237. package/dist/storage-key.d.ts +9 -0
  238. package/dist/storage-key.d.ts.map +1 -0
  239. package/dist/storage-key.js +59 -0
  240. package/dist/storage-key.js.map +1 -0
  241. package/dist/tmux.d.ts +39 -0
  242. package/dist/tmux.d.ts.map +1 -0
  243. package/dist/tmux.js +141 -0
  244. package/dist/tmux.js.map +1 -0
  245. package/dist/types.d.ts +1496 -0
  246. package/dist/types.d.ts.map +1 -0
  247. package/dist/types.js +215 -0
  248. package/dist/types.js.map +1 -0
  249. package/dist/update-cache.d.ts +59 -0
  250. package/dist/update-cache.d.ts.map +1 -0
  251. package/dist/update-cache.js +77 -0
  252. package/dist/update-cache.js.map +1 -0
  253. package/dist/utils/metadata-flatten.d.ts +3 -0
  254. package/dist/utils/metadata-flatten.d.ts.map +1 -0
  255. package/dist/utils/metadata-flatten.js +18 -0
  256. package/dist/utils/metadata-flatten.js.map +1 -0
  257. package/dist/utils/pr.d.ts +7 -0
  258. package/dist/utils/pr.d.ts.map +1 -0
  259. package/dist/utils/pr.js +97 -0
  260. package/dist/utils/pr.js.map +1 -0
  261. package/dist/utils/session-from-metadata.d.ts +16 -0
  262. package/dist/utils/session-from-metadata.d.ts.map +1 -0
  263. package/dist/utils/session-from-metadata.js +87 -0
  264. package/dist/utils/session-from-metadata.js.map +1 -0
  265. package/dist/utils/session-id.d.ts +4 -0
  266. package/dist/utils/session-id.d.ts.map +1 -0
  267. package/dist/utils/session-id.js +9 -0
  268. package/dist/utils/session-id.js.map +1 -0
  269. package/dist/utils/validation.d.ts +9 -0
  270. package/dist/utils/validation.d.ts.map +1 -0
  271. package/dist/utils/validation.js +45 -0
  272. package/dist/utils/validation.js.map +1 -0
  273. package/dist/utils.d.ts +65 -0
  274. package/dist/utils.d.ts.map +1 -0
  275. package/dist/utils.js +189 -0
  276. package/dist/utils.js.map +1 -0
  277. package/dist/version-compare.d.ts +27 -0
  278. package/dist/version-compare.d.ts.map +1 -0
  279. package/dist/version-compare.js +121 -0
  280. package/dist/version-compare.js.map +1 -0
  281. package/dist/windows-pty-registry.d.ts +27 -0
  282. package/dist/windows-pty-registry.d.ts.map +1 -0
  283. package/dist/windows-pty-registry.js +109 -0
  284. package/dist/windows-pty-registry.js.map +1 -0
  285. package/package.json +110 -0
@@ -0,0 +1,988 @@
1
+ import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { isWindows } from './platform.js';
6
+
7
+ /**
8
+ * Shared PATH-based workspace hooks for all agent plugins.
9
+ *
10
+ * Installs ~/.ao/bin/gh and ~/.ao/bin/git wrappers that:
11
+ * - Intercept PR creation and branch operations to auto-update session metadata
12
+ * - Cache repeated read-only gh commands (PR discovery, issue context) to reduce
13
+ * GitHub API traffic — see D4-wrapper-cache-plan.md for design
14
+ *
15
+ * The session manager injects these wrappers into every agent's PATH,
16
+ * including Claude Code (which also has its own PostToolUse hooks for writes).
17
+ */
18
+ // =============================================================================
19
+ // Constants
20
+ // =============================================================================
21
+ const DEFAULT_PATH = "/usr/bin:/bin";
22
+ const PREFERRED_GH_BIN_DIR = "/usr/local/bin";
23
+ /** Preferred gh binary path for wrapper scripts */
24
+ const PREFERRED_GH_PATH = `${PREFERRED_GH_BIN_DIR}/gh`;
25
+ /**
26
+ * Get the shared bin directory for ao shell wrappers (prepended to PATH).
27
+ * Computed lazily to avoid calling homedir() at module load time,
28
+ * which breaks test mocks that replace homedir after import.
29
+ */
30
+ function getAoBinDir() {
31
+ return join(homedir(), ".ao", "bin");
32
+ }
33
+ /** Current version of wrapper scripts — bump when scripts change */
34
+ const WRAPPER_VERSION = "0.8.0";
35
+ // =============================================================================
36
+ // PATH Builder
37
+ // =============================================================================
38
+ /**
39
+ * Build a PATH string with ~/.ao/bin prepended for wrapper interception.
40
+ * Deduplicates entries and ensures /usr/local/bin is early for gh resolution.
41
+ */
42
+ function buildAgentPath(basePath) {
43
+ const delimiter = isWindows() ? ";" : ":";
44
+ const inherited = (basePath ?? (isWindows() ? "" : DEFAULT_PATH))
45
+ .split(delimiter)
46
+ .filter(Boolean);
47
+ const ordered = [];
48
+ const seen = new Set();
49
+ const add = (entry) => {
50
+ if (!entry || seen.has(entry))
51
+ return;
52
+ ordered.push(entry);
53
+ seen.add(entry);
54
+ };
55
+ add(getAoBinDir());
56
+ if (!isWindows()) {
57
+ add(PREFERRED_GH_BIN_DIR);
58
+ }
59
+ for (const entry of inherited)
60
+ add(entry);
61
+ return ordered.join(delimiter);
62
+ }
63
+ // =============================================================================
64
+ // Shell Wrapper Scripts
65
+ // =============================================================================
66
+ /* eslint-disable no-useless-escape -- \$ escapes are intentional: bash scripts in JS template literals */
67
+ /**
68
+ * Helper script sourced by both gh and git wrappers.
69
+ * Provides:
70
+ * update_ao_metadata <key> <value> — write key=value to session metadata
71
+ * read_ao_metadata <key> — read a value from session metadata
72
+ * ao_cache_dir — print the per-session gh cache directory
73
+ * ao_cache_fresh <key> <max_age> — test if a cache entry is fresh (0 = infinite)
74
+ * ao_cache_read <key> — print cached stdout
75
+ * ao_cache_write <key> — write stdin to cache atomically
76
+ */
77
+ const AO_METADATA_HELPER = `#!/usr/bin/env bash
78
+ # ao-metadata-helper — shared by gh/git wrappers
79
+ # Provides: update_ao_metadata, read_ao_metadata, ao_cache_*
80
+
81
+ # ── Shared validation ────────────────────────────────────────────────────────
82
+
83
+ _ao_validate_env() {
84
+ local ao_dir="\${AO_DATA_DIR:-}"
85
+ local ao_session="\${AO_SESSION:-}"
86
+ [[ -z "\$ao_dir" || -z "\$ao_session" ]] && return 1
87
+ case "\$ao_session" in */* | *..*) return 1 ;; esac
88
+ case "\$ao_dir" in
89
+ "\$HOME"/.ao/* | "\$HOME"/.agent-orchestrator/* | /tmp/*) ;;
90
+ *) return 1 ;;
91
+ esac
92
+ return 0
93
+ }
94
+
95
+ # ── Metadata write ───────────────────────────────────────────────────────────
96
+
97
+ update_ao_metadata() {
98
+ local key="\$1" value="\$2"
99
+ local ao_dir="\${AO_DATA_DIR:-}"
100
+ local ao_session="\${AO_SESSION:-}"
101
+
102
+ [[ -z "\$ao_dir" || -z "\$ao_session" ]] && return 0
103
+
104
+ # Validate: session name must not contain path separators or traversal
105
+ case "\$ao_session" in
106
+ */* | *..*) return 0 ;;
107
+ esac
108
+
109
+ # Validate: ao_dir must be an absolute path under known ao directories or /tmp
110
+ case "\$ao_dir" in
111
+ "\$HOME"/.ao/* | "\$HOME"/.agent-orchestrator/* | /tmp/*) ;;
112
+ *) return 0 ;;
113
+ esac
114
+
115
+ # V2 storage uses .json extension; fallback to bare filename for pre-migration layouts
116
+ local metadata_file="\$ao_dir/\${ao_session}.json"
117
+ if [[ ! -f "\$metadata_file" ]]; then
118
+ metadata_file="\$ao_dir/\$ao_session"
119
+ fi
120
+
121
+ # Resolve symlinks and verify canonicalized paths are still within trusted roots
122
+ local real_dir real_ao_dir
123
+ real_ao_dir="\$(cd "\$ao_dir" 2>/dev/null && pwd -P)" || return 0
124
+ real_dir="\$(cd "\$(dirname "\$metadata_file")" 2>/dev/null && pwd -P)" || return 0
125
+
126
+ # Re-validate real_ao_dir against trusted roots after canonicalization
127
+ # (prevents /tmp/../../home/user from escaping the allowlist)
128
+ case "\$real_ao_dir" in
129
+ "\$HOME"/.ao/* | "\$HOME"/.ao | "\$HOME"/.agent-orchestrator/* | "\$HOME"/.agent-orchestrator | /tmp/*) ;;
130
+ *) return 0 ;;
131
+ esac
132
+
133
+ [[ "\$real_dir" == "\$real_ao_dir"* ]] || return 0
134
+
135
+ [[ -f "\$metadata_file" ]] || return 0
136
+
137
+ # Validate key — only allow alphanumeric, underscore, hyphen (prevents sed/jq injection)
138
+ [[ "\$key" =~ ^[a-zA-Z0-9_-]+$ ]] || return 0
139
+
140
+ local temp_file="\${metadata_file}.tmp.\$\$"
141
+
142
+ # Strip newlines from value to prevent injection
143
+ local clean_value="\$(printf '%s' "\$value" | tr -d '\\n')"
144
+
145
+ # Detect JSON vs key=value format
146
+ local first_char
147
+ first_char="\$(head -c1 "\$metadata_file" 2>/dev/null)"
148
+
149
+ if [[ "\$first_char" == "{" ]]; then
150
+ # JSON format
151
+ if command -v jq &>/dev/null; then
152
+ jq --arg k "\$key" --arg v "\$clean_value" '.[\$k] = \$v' "\$metadata_file" > "\$temp_file"
153
+ mv "\$temp_file" "\$metadata_file"
154
+ else
155
+ # jq unavailable — use node (hard dep) for safe nested JSON update
156
+ node -e "
157
+ const fs = require('fs');
158
+ const d = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
159
+ d[process.argv[2]] = process.argv[3];
160
+ fs.writeFileSync(process.argv[4], JSON.stringify(d, null, 2));
161
+ " "\$metadata_file" "\$key" "\$clean_value" "\$temp_file"
162
+ mv "\$temp_file" "\$metadata_file"
163
+ fi
164
+ else
165
+ # Key=value format (legacy)
166
+ local escaped_value="\$(printf '%s' "\$clean_value" | sed 's/[&|\\\\]/\\\\&/g')"
167
+ if grep -q "^\${key}=" "\$metadata_file" 2>/dev/null; then
168
+ sed "s|^\${key}=.*|\${key}=\${escaped_value}|" "\$metadata_file" > "\$temp_file"
169
+ else
170
+ cp "\$metadata_file" "\$temp_file"
171
+ printf '%s=%s\\n' "\$key" "\$clean_value" >> "\$temp_file"
172
+ fi
173
+ mv "\$temp_file" "\$metadata_file"
174
+ fi
175
+ }
176
+
177
+ # ── Metadata read ────────────────────────────────────────────────────────────
178
+
179
+ read_ao_metadata() {
180
+ local key="\$1"
181
+ _ao_validate_env || return 1
182
+ local metadata_file="\${AO_DATA_DIR}/\${AO_SESSION}"
183
+ [[ -f "\$metadata_file" ]] || return 1
184
+ [[ "\$key" =~ ^[a-zA-Z0-9_-]+$ ]] || return 1
185
+ local line
186
+ line=\$(grep "^\${key}=" "\$metadata_file" 2>/dev/null | head -1) || return 1
187
+ printf '%s' "\${line#*=}"
188
+ }
189
+
190
+ # ── Cache helpers ────────────────────────────────────────────────────────────
191
+
192
+ ao_cache_dir() {
193
+ _ao_validate_env || return 1
194
+ local d="\${AO_DATA_DIR}/.ghcache/\${AO_SESSION}"
195
+ mkdir -p "\$d" 2>/dev/null || return 1
196
+ printf '%s' "\$d"
197
+ }
198
+
199
+ ao_cache_fresh() {
200
+ local cache_key="\$1" max_age="\$2"
201
+ [[ "\$cache_key" =~ ^[a-zA-Z0-9_.-]+$ ]] || return 1
202
+ local cache_dir
203
+ cache_dir="\$(ao_cache_dir)" || return 1
204
+ local ts_file="\$cache_dir/\${cache_key}.ts"
205
+ local stdout_file="\$cache_dir/\${cache_key}.stdout"
206
+ [[ -f "\$stdout_file" && -f "\$ts_file" ]] || return 1
207
+ local cached_ts now
208
+ cached_ts=\$(cat "\$ts_file" 2>/dev/null) || return 1
209
+ # Sanity check: cached_ts must be a positive integer (epoch seconds)
210
+ [[ "\$cached_ts" =~ ^[0-9]+$ && "\$cached_ts" -gt 0 ]] || return 1
211
+ # max_age=0 means infinite TTL
212
+ [[ "\$max_age" -eq 0 ]] 2>/dev/null && return 0
213
+ now=\$(date +%s)
214
+ (( now - cached_ts < max_age ))
215
+ }
216
+
217
+ ao_cache_read() {
218
+ local cache_key="\$1"
219
+ [[ "\$cache_key" =~ ^[a-zA-Z0-9_.-]+$ ]] || return 1
220
+ local cache_dir
221
+ cache_dir="\$(ao_cache_dir)" || return 1
222
+ cat "\$cache_dir/\${cache_key}.stdout"
223
+ }
224
+
225
+ ao_cache_write() {
226
+ local cache_key="\$1"
227
+ [[ "\$cache_key" =~ ^[a-zA-Z0-9_.-]+$ ]] || return 1
228
+ local cache_dir
229
+ cache_dir="\$(ao_cache_dir)" || return 1
230
+ local tmp="\$cache_dir/\${cache_key}.stdout.tmp.\$\$"
231
+ cat > "\$tmp" && mv "\$tmp" "\$cache_dir/\${cache_key}.stdout"
232
+ date +%s > "\$cache_dir/\${cache_key}.ts"
233
+ }
234
+ `;
235
+ /**
236
+ * gh wrapper — intercepts agent-side gh calls for:
237
+ * 1. Caching repeated read-only commands (PR discovery, issue context)
238
+ * 2. Auto-updating session metadata on PR creation
239
+ *
240
+ * Cache storage: $AO_DATA_DIR/.ghcache/$AO_SESSION/{key}.stdout + {key}.ts
241
+ * See D4-wrapper-cache-plan.md for full design rationale.
242
+ */
243
+ const GH_WRAPPER = `#!/usr/bin/env bash
244
+ # ao gh wrapper — caches reads + auto-updates metadata on writes
245
+
246
+ # Find real gh by removing our wrapper directory from PATH
247
+ ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)"
248
+ clean_path="\$(echo "\$PATH" | tr ':' '\\n' | grep -Fxv "\$ao_bin_dir" | grep . | tr '\\n' ':')"
249
+ clean_path="\${clean_path%:}"
250
+ real_gh=""
251
+
252
+ # Prefer explicit gh path when provided by AO environment.
253
+ # Guard against recursive self-reference to the wrapper in ~/.ao/bin.
254
+ if [[ -n "\${GH_PATH:-}" && -x "\$GH_PATH" ]]; then
255
+ gh_dir="\$(cd "\$(dirname "\$GH_PATH")" 2>/dev/null && pwd)"
256
+ if [[ "\$gh_dir" != "\$ao_bin_dir" ]]; then
257
+ real_gh="\$GH_PATH"
258
+ fi
259
+ fi
260
+
261
+ if [[ -z "\$real_gh" ]]; then
262
+ real_gh="\$(PATH="\$clean_path" command -v gh 2>/dev/null)"
263
+ fi
264
+
265
+ if [[ -z "\$real_gh" ]]; then
266
+ echo "ao-wrapper: gh not found in PATH" >&2
267
+ exit 127
268
+ fi
269
+
270
+ # Source the metadata helper (provides update/read_ao_metadata, ao_cache_*)
271
+ source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true
272
+
273
+ # Redact sensitive values from args before tracing.
274
+ # Handles: -H "Authorization: ...", token=..., password=..., secret=...
275
+ _ao_redact_args() {
276
+ local prev=""
277
+ local out=()
278
+ for arg in "\$@"; do
279
+ if [[ "\$prev" == "-H" || "\$prev" == "--header" ]] && [[ "\$arg" =~ ^[Aa]uthorization: ]]; then
280
+ out+=("Authorization: [REDACTED]")
281
+ elif [[ "\$arg" =~ ^-H[Aa]uthorization: ]]; then
282
+ out+=("-HAuthorization: [REDACTED]")
283
+ elif [[ "\$arg" =~ ^[Tt]oken= ]]; then
284
+ out+=("token=[REDACTED]")
285
+ elif [[ "\$arg" =~ ^[Pp]assword= ]]; then
286
+ out+=("password=[REDACTED]")
287
+ elif [[ "\$arg" =~ ^[Ss]ecret= ]]; then
288
+ out+=("secret=[REDACTED]")
289
+ else
290
+ out+=("\$arg")
291
+ fi
292
+ prev="\$arg"
293
+ done
294
+ printf '%s\n' "\${out[@]}"
295
+ }
296
+
297
+ # Best-effort JSONL tracing for agent-side gh invocations.
298
+ log_gh_invocation() {
299
+ local trace_file="\${AO_AGENT_GH_TRACE:-}"
300
+ [[ -z "\$trace_file" ]] && return 0
301
+ command -v jq >/dev/null 2>&1 || return 0
302
+
303
+ mkdir -p "\$(dirname "\$trace_file")" 2>/dev/null || return 0
304
+
305
+ local args_json
306
+ args_json="\$(_ao_redact_args "\$@" | jq -Rsc 'split("\n")[:-1]')" || return 0
307
+
308
+ # Compute operation: gh.{arg1}.{arg2} (mirrors AO-side extractOperation)
309
+ local _ao_op="gh"
310
+ [[ \$# -ge 1 ]] && _ao_op="gh.\$1"
311
+ [[ \$# -ge 2 && "\$2" != -* ]] && _ao_op="gh.\$1.\$2"
312
+
313
+ jq -nc \
314
+ --arg timestamp "\$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
315
+ --arg cwd "\$PWD" \
316
+ --arg operation "\$_ao_op" \
317
+ --arg aoSession "\${AO_SESSION:-}" \
318
+ --arg aoSessionName "\${AO_SESSION_NAME:-}" \
319
+ --arg aoProjectId "\${AO_PROJECT_ID:-}" \
320
+ --arg aoIssueId "\${AO_ISSUE_ID:-}" \
321
+ --arg aoCallerType "\${AO_CALLER_TYPE:-}" \
322
+ --arg pid "\$\$" \
323
+ --arg wrapperVersion "${WRAPPER_VERSION}" \
324
+ --argjson args "\$args_json" \
325
+ '{
326
+ timestamp: $timestamp,
327
+ cwd: $cwd,
328
+ args: $args,
329
+ operation: $operation,
330
+ aoSession: (if $aoSession == "" then null else $aoSession end),
331
+ aoSessionName: (if $aoSessionName == "" then null else $aoSessionName end),
332
+ aoProjectId: (if $aoProjectId == "" then null else $aoProjectId end),
333
+ aoIssueId: (if $aoIssueId == "" then null else $aoIssueId end),
334
+ aoCallerType: (if $aoCallerType == "" then null else $aoCallerType end),
335
+ pid: ($pid | tonumber),
336
+ wrapperVersion: $wrapperVersion
337
+ }' >> "\$trace_file" 2>/dev/null || true
338
+ }
339
+
340
+ log_gh_invocation "\$@"
341
+
342
+ # Best-effort cache-outcome tracing (appends to same JSONL trace file).
343
+ # result: hit | miss-stored | miss-write-failed | miss-negative | miss-error | passthrough
344
+ log_ao_cache() {
345
+ local result="\$1" cache_key="\$2" duration_ms="\${3:-0}" exit_code="\${4:-0}" ok="\${5:-true}"
346
+ local trace_file="\${AO_AGENT_GH_TRACE:-}"
347
+ [[ -z "\$trace_file" ]] && return 0
348
+ printf '{"timestamp":"%s","cacheResult":"%s","cacheKey":"%s","pid":%s,"durationMs":%s,"exitCode":%s,"ok":%s}\\n' \
349
+ "\$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "\$result" "\$cache_key" "\$\$" \
350
+ "\$duration_ms" "\$exit_code" "\$ok" \
351
+ >> "\$trace_file" 2>/dev/null || true
352
+ }
353
+
354
+ # =============================================================================
355
+ # Cacheable reads
356
+ # =============================================================================
357
+
358
+ # ── 1. PR discovery: gh pr list --head <B> --limit 1 ────────────────────────
359
+ # 120s TTL for positive results (non-empty array). Never caches [].
360
+ if [[ "\$1" == "pr" && "\$2" == "list" ]]; then
361
+ _ao_head="" _ao_limit="" _ao_json="" _ao_repo="" _ao_cacheable=true
362
+ _ao_saved_args=("\$@")
363
+ shift 2
364
+ while [[ \$# -gt 0 ]]; do
365
+ case "\$1" in
366
+ --head) _ao_head="\$2"; shift 2 ;;
367
+ --head=*) _ao_head="\${1#--head=}"; shift ;;
368
+ --limit) _ao_limit="\$2"; shift 2 ;;
369
+ --limit=*) _ao_limit="\${1#--limit=}"; shift ;;
370
+ --json) _ao_json="\$2"; shift 2 ;;
371
+ --json=*) _ao_json="\${1#--json=}"; shift ;;
372
+ --repo) _ao_repo="\$2"; shift 2 ;;
373
+ --repo=*) _ao_repo="\${1#--repo=}"; shift ;;
374
+ --search|--state|--assignee|--label|--jq|--template)
375
+ _ao_cacheable=false; break ;;
376
+ --search=*|--state=*|--assignee=*|--label=*|--jq=*|--template=*)
377
+ _ao_cacheable=false; break ;;
378
+ -*) shift ;; # skip unknown flags
379
+ *) shift ;; # skip positional
380
+ esac
381
+ done
382
+ set -- "\${_ao_saved_args[@]}"
383
+
384
+ if [[ "\$_ao_cacheable" == true && "\$_ao_limit" == "1" && -n "\$_ao_head" ]]; then
385
+ # Use sha256 hash suffix to avoid collisions from tr-based sanitization
386
+ # (e.g. feat/foo, feat-foo, feat_foo would otherwise map to the same key)
387
+ _ao_raw_key="pr-discovery-\${_ao_repo}-\${_ao_head}"
388
+ if [[ -n "\$_ao_json" ]]; then
389
+ _ao_raw_key="\${_ao_raw_key}-j-\${_ao_json}"
390
+ fi
391
+ _ao_cache_key=\$(printf '%s' "\$_ao_raw_key" | shasum -a 256 | cut -c1-16)
392
+ _ao_cache_key="pr-disc-\${_ao_cache_key}"
393
+
394
+ if ao_cache_fresh "\$_ao_cache_key" 120 2>/dev/null; then
395
+ log_ao_cache "hit" "\$_ao_cache_key" 0 0 true
396
+ ao_cache_read "\$_ao_cache_key"
397
+ exit 0
398
+ fi
399
+
400
+ # Cache miss — call real gh, cache positive results (stderr passes through)
401
+ _ao_tmpout="\$(mktemp)"
402
+ trap 'rm -f "\$_ao_tmpout"' EXIT
403
+ _ao_start_s=\$(date +%s)
404
+ "\$real_gh" "\$@" > "\$_ao_tmpout"
405
+ _ao_exit=\$?
406
+ _ao_duration_ms=\$(( (\$(date +%s) - _ao_start_s) * 1000 ))
407
+ _ao_ok=true; [[ \$_ao_exit -ne 0 ]] && _ao_ok=false
408
+ cat "\$_ao_tmpout"
409
+ if [[ \$_ao_exit -eq 0 ]]; then
410
+ _ao_trimmed=\$(tr -d '[:space:]' < "\$_ao_tmpout")
411
+ # Only cache non-empty positive results
412
+ if [[ -n "\$_ao_trimmed" && "\$_ao_trimmed" != "[]" ]]; then
413
+ if ao_cache_write "\$_ao_cache_key" < "\$_ao_tmpout" 2>/dev/null; then
414
+ log_ao_cache "miss-stored" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
415
+ else
416
+ log_ao_cache "miss-write-failed" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
417
+ fi
418
+ else
419
+ log_ao_cache "miss-negative" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
420
+ fi
421
+ else
422
+ log_ao_cache "miss-error" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
423
+ fi
424
+ exit \$_ao_exit
425
+ fi
426
+ fi
427
+
428
+ # ── 2. Issue context: gh issue view <N> ─────────────────────────────────────
429
+ # 300-second TTL. Caches any successful response.
430
+ if [[ "\$1" == "issue" && "\$2" == "view" ]]; then
431
+ _ao_issue_id="" _ao_json="" _ao_repo="" _ao_cacheable=true
432
+ _ao_saved_args=("\$@")
433
+ shift 2
434
+ # First non-flag arg is the issue identifier
435
+ while [[ \$# -gt 0 ]]; do
436
+ case "\$1" in
437
+ --web|--comments|--jq|--template)
438
+ _ao_cacheable=false; break ;;
439
+ --jq=*|--template=*)
440
+ _ao_cacheable=false; break ;;
441
+ --json) _ao_json="\$2"; shift 2 ;;
442
+ --json=*) _ao_json="\${1#--json=}"; shift ;;
443
+ --repo) _ao_repo="\$2"; shift 2 ;;
444
+ --repo=*) _ao_repo="\${1#--repo=}"; shift ;;
445
+ -*) shift ;;
446
+ *)
447
+ if [[ -z "\$_ao_issue_id" && "\$1" =~ ^[0-9]+$ ]]; then
448
+ _ao_issue_id="\$1"
449
+ fi
450
+ shift ;;
451
+ esac
452
+ done
453
+ set -- "\${_ao_saved_args[@]}"
454
+
455
+ if [[ "\$_ao_cacheable" == true && -n "\$_ao_issue_id" ]]; then
456
+ _ao_raw_key="issue-ctx-\${_ao_repo}-\${_ao_issue_id}"
457
+ if [[ -n "\$_ao_json" ]]; then
458
+ _ao_raw_key="\${_ao_raw_key}-j-\${_ao_json}"
459
+ fi
460
+ _ao_cache_key=\$(printf '%s' "\$_ao_raw_key" | shasum -a 256 | cut -c1-16)
461
+ _ao_cache_key="issue-\${_ao_cache_key}"
462
+
463
+ if ao_cache_fresh "\$_ao_cache_key" 300 2>/dev/null; then
464
+ log_ao_cache "hit" "\$_ao_cache_key" 0 0 true
465
+ ao_cache_read "\$_ao_cache_key"
466
+ exit 0
467
+ fi
468
+
469
+ _ao_tmpout="\$(mktemp)"
470
+ trap 'rm -f "\$_ao_tmpout"' EXIT
471
+ _ao_start_s=\$(date +%s)
472
+ "\$real_gh" "\$@" > "\$_ao_tmpout"
473
+ _ao_exit=\$?
474
+ _ao_duration_ms=\$(( (\$(date +%s) - _ao_start_s) * 1000 ))
475
+ _ao_ok=true; [[ \$_ao_exit -ne 0 ]] && _ao_ok=false
476
+ cat "\$_ao_tmpout"
477
+ if [[ \$_ao_exit -eq 0 ]]; then
478
+ if ao_cache_write "\$_ao_cache_key" < "\$_ao_tmpout" 2>/dev/null; then
479
+ log_ao_cache "miss-stored" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
480
+ else
481
+ log_ao_cache "miss-write-failed" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
482
+ fi
483
+ else
484
+ log_ao_cache "miss-error" "\$_ao_cache_key" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
485
+ fi
486
+ exit \$_ao_exit
487
+ fi
488
+ fi
489
+
490
+ # =============================================================================
491
+ # Write intercepts
492
+ # =============================================================================
493
+
494
+ case "\$1/\$2" in
495
+ pr/create)
496
+ tmpout="\$(mktemp)"
497
+ trap 'rm -f "\$tmpout"' EXIT
498
+
499
+ _ao_start_s=\$(date +%s)
500
+ "\$real_gh" "\$@" 2>&1 | tee "\$tmpout"
501
+ exit_code=\${PIPESTATUS[0]}
502
+ _ao_duration_ms=\$(( (\$(date +%s) - _ao_start_s) * 1000 ))
503
+ _ao_ok=true; [[ \$exit_code -ne 0 ]] && _ao_ok=false
504
+
505
+ if [[ \$exit_code -eq 0 ]]; then
506
+ output="\$(cat "\$tmpout")"
507
+ pr_url="\$(echo "\$output" | grep -Eo 'https?://[^/]+/[^/]+/[^/]+/pull/[0-9]+' | head -1)"
508
+ report_state="pr_created"
509
+ report_draft="false"
510
+ for arg in "\$@"; do
511
+ if [[ "\$arg" == "--draft" || "\$arg" == "-d" ]]; then
512
+ report_state="draft_pr_created"
513
+ report_draft="true"
514
+ break
515
+ fi
516
+ done
517
+ if [[ -n "\$pr_url" ]]; then
518
+ update_ao_metadata pr "\$pr_url"
519
+ update_ao_metadata agentReportedPrUrl "\$pr_url"
520
+ # Append to prs field (comma-separated list of all PR URLs for this session).
521
+ # Supports multiple PRs per session — same repo or different repos.
522
+ _ao_meta_f="\${AO_DATA_DIR}/\${AO_SESSION}.json"
523
+ [[ -f "\$_ao_meta_f" ]] || _ao_meta_f="\${AO_DATA_DIR}/\${AO_SESSION}"
524
+ if head -c1 "\$_ao_meta_f" 2>/dev/null | grep -q '{'; then
525
+ existing_prs="\$(jq -r '.prs // empty' "\$_ao_meta_f" 2>/dev/null || echo "")"
526
+ else
527
+ existing_prs="\$(grep '^prs=' "\$_ao_meta_f" 2>/dev/null | cut -d'=' -f2- || echo "")"
528
+ fi
529
+ if [[ -z "\$existing_prs" ]]; then
530
+ new_prs="\$pr_url"
531
+ else
532
+ if ! echo ",\$existing_prs," | grep -qF ",\$pr_url,"; then
533
+ new_prs="\$existing_prs,\$pr_url"
534
+ else
535
+ new_prs="\$existing_prs"
536
+ fi
537
+ fi
538
+ update_ao_metadata prs "\$new_prs"
539
+ fi
540
+ pr_number="\$(printf '%s' "\$pr_url" | grep -Eo '[0-9]+$' | head -1)"
541
+ if [[ -n "\$pr_number" ]]; then
542
+ update_ao_metadata agentReportedPrNumber "\$pr_number"
543
+ fi
544
+ update_ao_metadata agentReportedState "\$report_state"
545
+ update_ao_metadata agentReportedAt "\$(date -u +%Y-%m-%dT%H:%M:%SZ)"
546
+ update_ao_metadata agentReportedPrIsDraft "\$report_draft"
547
+ fi
548
+
549
+ log_ao_cache "passthrough" "" "\$_ao_duration_ms" "\$exit_code" "\$_ao_ok"
550
+ exit \$exit_code
551
+ ;;
552
+ *)
553
+ _ao_start_s=\$(date +%s)
554
+ "\$real_gh" "\$@"
555
+ _ao_exit=\$?
556
+ _ao_duration_ms=\$(( (\$(date +%s) - _ao_start_s) * 1000 ))
557
+ _ao_ok=true; [[ \$_ao_exit -ne 0 ]] && _ao_ok=false
558
+ log_ao_cache "passthrough" "" "\$_ao_duration_ms" "\$_ao_exit" "\$_ao_ok"
559
+ exit \$_ao_exit
560
+ ;;
561
+ esac
562
+ `;
563
+ /**
564
+ * git wrapper — intercepts branch operations to auto-update metadata.
565
+ * All other commands pass through transparently.
566
+ *
567
+ * Detects:
568
+ * - git checkout -b <branch> / git switch -c <branch> (new branch)
569
+ * - git checkout <branch> / git switch <branch> (existing feature branch)
570
+ *
571
+ * For existing branch switches, only updates if the branch name looks like a
572
+ * feature branch (contains / or -) to avoid noise from checkout of commits/tags.
573
+ * Matches the same heuristic as Claude Code's PostToolUse hook.
574
+ */
575
+ const GIT_WRAPPER = `#!/usr/bin/env bash
576
+ # ao git wrapper — auto-updates session metadata on branch operations
577
+
578
+ # Find real git by removing our wrapper directory from PATH
579
+ ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)"
580
+ clean_path="\$(echo "\$PATH" | tr ':' '\\n' | grep -Fxv "\$ao_bin_dir" | grep . | tr '\\n' ':')"
581
+ clean_path="\${clean_path%:}"
582
+ real_git="\$(PATH="\$clean_path" command -v git 2>/dev/null)"
583
+
584
+ if [[ -z "\$real_git" ]]; then
585
+ echo "ao-wrapper: git not found in PATH" >&2
586
+ exit 127
587
+ fi
588
+
589
+ # Source the metadata helper
590
+ source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true
591
+
592
+ # Run real git
593
+ "\$real_git" "\$@"
594
+ exit_code=\$?
595
+
596
+ # Only update metadata on success
597
+ if [[ \$exit_code -eq 0 ]]; then
598
+ case "\$1/\$2" in
599
+ checkout/-b)
600
+ update_ao_metadata branch "\$3"
601
+ ;;
602
+ switch/-c)
603
+ update_ao_metadata branch "\$3"
604
+ ;;
605
+ checkout/*|switch/*)
606
+ # Existing branch switch — only track feature-looking branches (contain / or -)
607
+ # Skip flags (e.g. -B), HEAD, tags, commit hashes, and simple names like "main"
608
+ branch="\$2"
609
+ # If $2 is a flag, the actual branch name is in $3
610
+ if [[ "\$branch" == -* ]]; then branch="\$3"; fi
611
+ if [[ -n "\$branch" && "\$branch" != "HEAD" && "\$branch" != -* && "\$branch" == *[/-]* ]]; then
612
+ update_ao_metadata branch "\$branch"
613
+ fi
614
+ ;;
615
+ esac
616
+ fi
617
+
618
+ exit \$exit_code
619
+ `;
620
+ // =============================================================================
621
+ // Node.js Wrapper Scripts (Windows)
622
+ // =============================================================================
623
+ /**
624
+ * Build a Node.js wrapper script for a given binary (gh or git).
625
+ *
626
+ * On Windows, bash scripts cannot be executed directly, so we generate:
627
+ * - <name>.cjs — the actual interception logic (Node.js, forced CJS mode)
628
+ * - <name>.cmd — a tiny CMD shim: @node "%~dp0<name>.cjs" %*
629
+ *
630
+ * The .js script replicates what the bash wrapper does:
631
+ * - gh: intercepts `gh pr create` and `gh pr merge`
632
+ * - git: intercepts `git checkout -b` and `git switch -c`
633
+ *
634
+ * @param name - "gh" or "git"
635
+ * @param realBinaryPath - Absolute path to the real binary, or empty string to
636
+ * resolve at runtime via PATH (excluding the wrapper dir).
637
+ */
638
+ function buildNodeWrapper(name, realBinaryPath) {
639
+ if (name === "gh") {
640
+ return buildGhNodeWrapper();
641
+ }
642
+ return buildGitNodeWrapper();
643
+ }
644
+ /**
645
+ * Shared Node.js snippet: updateAoMetadata function used by both gh and git wrappers.
646
+ * Validates session, key, and AO_DATA_DIR before writing metadata.
647
+ */
648
+ const NODE_UPDATE_AO_METADATA = `\
649
+ // ---------------------------------------------------------------------------
650
+ // Metadata update (shared by gh/git wrappers)
651
+ // ---------------------------------------------------------------------------
652
+ function updateAoMetadata(key, value) {
653
+ const aoDir = process.env["AO_DATA_DIR"] || "";
654
+ const aoSession = process.env["AO_SESSION"] || "";
655
+ if (!aoDir || !aoSession) return;
656
+
657
+ // Validate session — no path separators or traversal
658
+ if (aoSession.includes("/") || aoSession.includes("\\\\") || aoSession.includes("..")) return;
659
+
660
+ // Validate key
661
+ if (!/^[a-zA-Z0-9_-]+$/.test(key)) return;
662
+
663
+ // Validate aoDir is under expected locations (mirrors bash ao-metadata-helper.sh)
664
+ const os = require("os");
665
+ const home = os.homedir();
666
+ const sep = path.sep;
667
+ let resolvedDir;
668
+ try { resolvedDir = fs.realpathSync(aoDir); } catch { resolvedDir = path.resolve(aoDir); }
669
+ const allowed = [path.join(home, ".ao"), path.join(home, ".agent-orchestrator"), os.tmpdir()];
670
+ if (!allowed.some(a => resolvedDir === a || resolvedDir.startsWith(a + sep))) return;
671
+
672
+ // Try V2 (.json) first, then fall back to V1 (bare) — mirrors bash ao-metadata-helper.sh
673
+ let metadataFile = path.join(resolvedDir, aoSession + ".json");
674
+ if (!fs.existsSync(metadataFile)) {
675
+ metadataFile = path.join(resolvedDir, aoSession);
676
+ }
677
+ if (!fs.existsSync(metadataFile)) return;
678
+
679
+ // Strip newlines from value
680
+ const cleanValue = String(value).replace(/[\\r\\n]/g, "");
681
+
682
+ let content;
683
+ try { content = fs.readFileSync(metadataFile, "utf8"); } catch { return; }
684
+
685
+ const tmpFile = metadataFile + ".tmp." + process.pid;
686
+ try {
687
+ if (metadataFile.endsWith(".json")) {
688
+ // V2 JSON format
689
+ let d;
690
+ try { d = JSON.parse(content); } catch { return; }
691
+ d[key] = cleanValue;
692
+ fs.writeFileSync(tmpFile, JSON.stringify(d, null, 2), "utf8");
693
+ } else {
694
+ // V1 key=value format
695
+ const lines = content.split("\\n");
696
+ const keyPrefix = key + "=";
697
+ const idx = lines.findIndex(l => l.startsWith(keyPrefix));
698
+ if (idx >= 0) {
699
+ lines[idx] = key + "=" + cleanValue;
700
+ } else {
701
+ lines.push(key + "=" + cleanValue);
702
+ }
703
+ fs.writeFileSync(tmpFile, lines.join("\\n"), "utf8");
704
+ }
705
+ fs.renameSync(tmpFile, metadataFile);
706
+ } catch {
707
+ try { fs.unlinkSync(tmpFile); } catch {}
708
+ }
709
+ }`;
710
+ function buildGhNodeWrapper(realBinaryPath) {
711
+ return `#!/usr/bin/env node
712
+ // ao gh wrapper (Windows Node.js) — auto-updates session metadata on PR operations
713
+ "use strict";
714
+ const { spawnSync } = require("child_process");
715
+ const fs = require("fs");
716
+ const path = require("path");
717
+
718
+ // ---------------------------------------------------------------------------
719
+ // Real binary resolution
720
+ // ---------------------------------------------------------------------------
721
+ const AO_BIN_DIR = path.dirname(__filename);
722
+
723
+ function findRealGh() {
724
+ const explicit = process.env["GH_PATH"] || "";
725
+ if (explicit) {
726
+ try {
727
+ const resolved = path.resolve(explicit);
728
+ const dir = path.dirname(resolved);
729
+ if (dir !== AO_BIN_DIR && fs.existsSync(resolved)) return resolved;
730
+ } catch {}
731
+ }
732
+
733
+ // Walk PATH, skip wrapper directory
734
+ const pathDirs = (process.env["PATH"] || "").split(path.delimiter);
735
+ for (const dir of pathDirs) {
736
+ if (!dir || path.resolve(dir) === AO_BIN_DIR) continue;
737
+ // Windows executables always have an extension (.exe/.cmd). Skip the bare
738
+ // no-extension case — on Windows X_OK is identical to F_OK (execute bit
739
+ // doesn't exist), so a bare text file named "gh" would otherwise be
740
+ // selected before gh.exe.
741
+ for (const ext of [".exe", ".cmd"]) {
742
+ const candidate = path.join(dir, "gh" + ext);
743
+ try {
744
+ fs.accessSync(candidate, fs.constants.F_OK);
745
+ return candidate;
746
+ } catch {}
747
+ }
748
+ }
749
+ return null;
750
+ }
751
+
752
+ ${NODE_UPDATE_AO_METADATA}
753
+
754
+ // ---------------------------------------------------------------------------
755
+ // Main
756
+ // ---------------------------------------------------------------------------
757
+ const realGh = ${"findRealGh()"};
758
+ if (!realGh) {
759
+ process.stderr.write("ao-wrapper: gh not found in PATH\\n");
760
+ process.exit(127);
761
+ }
762
+
763
+ const args = process.argv.slice(2);
764
+ const sub1 = args[0] || "";
765
+ const sub2 = args[1] || "";
766
+ const key = sub1 + "/" + sub2;
767
+
768
+ if (key === "pr/create" || key === "pr/merge") {
769
+ const result = spawnSync(realGh, args, {
770
+ stdio: ["inherit", "pipe", "pipe"],
771
+ encoding: "utf8",
772
+ });
773
+
774
+ if (result.stdout) process.stdout.write(result.stdout);
775
+ if (result.stderr) process.stderr.write(result.stderr);
776
+
777
+ if (result.status === 0) {
778
+ const output = (result.stdout || "") + (result.stderr || "");
779
+ if (key === "pr/create") {
780
+ const match = output.match(/https:\\/\\/github\\.com\\/[^/]+\\/[^/]+\\/pull\\/[0-9]+/);
781
+ if (match) {
782
+ const prUrl = match[0];
783
+ updateAoMetadata("pr", prUrl);
784
+ updateAoMetadata("status", "pr_open");
785
+ // Append to prs field — supports multiple PRs per session
786
+ let existingPrs = "";
787
+ try {
788
+ const aoDir = process.env["AO_DATA_DIR"] || "";
789
+ const aoSession = process.env["AO_SESSION"] || "";
790
+ if (aoDir && aoSession && /^[a-zA-Z0-9_-]+$/.test(aoSession)) {
791
+ let metaFile = path.join(aoDir, aoSession + ".json");
792
+ if (!fs.existsSync(metaFile)) metaFile = path.join(aoDir, aoSession);
793
+ if (fs.existsSync(metaFile)) {
794
+ const raw = fs.readFileSync(metaFile, "utf8");
795
+ if (metaFile.endsWith(".json")) {
796
+ existingPrs = JSON.parse(raw).prs || "";
797
+ } else {
798
+ const line = raw.split("\\n").find(l => l.startsWith("prs="));
799
+ existingPrs = line ? line.slice(4) : "";
800
+ }
801
+ }
802
+ }
803
+ } catch {}
804
+ const newPrs = existingPrs
805
+ ? existingPrs.split(",").map((u) => u.trim()).includes(prUrl)
806
+ ? existingPrs
807
+ : existingPrs + "," + prUrl
808
+ : prUrl;
809
+ updateAoMetadata("prs", newPrs);
810
+ }
811
+ } else if (key === "pr/merge") {
812
+ updateAoMetadata("status", "merged");
813
+ }
814
+ }
815
+
816
+ process.exit(result.status ?? 1);
817
+ } else {
818
+ const result = spawnSync(realGh, args, { stdio: "inherit" });
819
+ process.exit(result.status ?? 1);
820
+ }
821
+ `;
822
+ }
823
+ function buildGitNodeWrapper(realBinaryPath) {
824
+ return `#!/usr/bin/env node
825
+ // ao git wrapper (Windows Node.js) — auto-updates session metadata on branch operations
826
+ "use strict";
827
+ const { spawnSync } = require("child_process");
828
+ const fs = require("fs");
829
+ const path = require("path");
830
+
831
+ // ---------------------------------------------------------------------------
832
+ // Real binary resolution
833
+ // ---------------------------------------------------------------------------
834
+ const AO_BIN_DIR = path.dirname(__filename);
835
+
836
+ function findRealGit() {
837
+ const pathDirs = (process.env["PATH"] || "").split(path.delimiter);
838
+ for (const dir of pathDirs) {
839
+ if (!dir || path.resolve(dir) === AO_BIN_DIR) continue;
840
+ // Windows executables always have an extension (.exe/.cmd). Skip the bare
841
+ // no-extension case — on Windows X_OK is identical to F_OK (execute bit
842
+ // doesn't exist), so a bare text file named "git" would otherwise be
843
+ // selected before git.exe.
844
+ for (const ext of [".exe", ".cmd"]) {
845
+ const candidate = path.join(dir, "git" + ext);
846
+ try {
847
+ fs.accessSync(candidate, fs.constants.F_OK);
848
+ return candidate;
849
+ } catch {}
850
+ }
851
+ }
852
+ return null;
853
+ }
854
+
855
+ ${NODE_UPDATE_AO_METADATA}
856
+
857
+ // ---------------------------------------------------------------------------
858
+ // Main
859
+ // ---------------------------------------------------------------------------
860
+ const realGit = ${"findRealGit()"};
861
+ if (!realGit) {
862
+ process.stderr.write("ao-wrapper: git not found in PATH\\n");
863
+ process.exit(127);
864
+ }
865
+
866
+ const args = process.argv.slice(2);
867
+ const result = spawnSync(realGit, args, { stdio: "inherit" });
868
+ const exitCode = result.status ?? 1;
869
+
870
+ if (exitCode === 0) {
871
+ const sub1 = args[0] || "";
872
+ const sub2 = args[1] || "";
873
+ const key = sub1 + "/" + sub2;
874
+
875
+ if (key === "checkout/-b" || key === "switch/-c") {
876
+ const branch = args[2];
877
+ if (branch) updateAoMetadata("branch", branch);
878
+ } else if (sub1 === "checkout" || sub1 === "switch") {
879
+ // Existing branch switch — only track feature-looking branches (contain / or -)
880
+ let branch = sub2;
881
+ // If sub2 is a flag, the actual branch name is in args[2]
882
+ if (branch && branch.startsWith("-")) branch = args[2] || "";
883
+ if (
884
+ branch &&
885
+ branch !== "HEAD" &&
886
+ !branch.startsWith("-") &&
887
+ (branch.includes("/") || branch.includes("-"))
888
+ ) {
889
+ updateAoMetadata("branch", branch);
890
+ }
891
+ }
892
+ }
893
+
894
+ process.exit(exitCode);
895
+ `;
896
+ }
897
+ /**
898
+ * Section appended to AGENTS.md as a secondary signal. The PATH-based wrappers
899
+ * handle metadata updates automatically, but AGENTS.md reinforces the intent
900
+ * and helps if the wrappers are bypassed.
901
+ */
902
+ const AO_AGENTS_MD_SECTION = `
903
+ ## Athene (ao) Session
904
+
905
+ You are running inside an Athene managed workspace.
906
+ Session metadata is updated automatically via shell wrappers.
907
+
908
+ If automatic updates fail, you can manually update metadata:
909
+ \`\`\`bash
910
+ ~/.ao/bin/ao-metadata-helper.sh # sourced automatically
911
+ # Then call: update_ao_metadata <key> <value>
912
+ \`\`\`
913
+ `;
914
+ /* eslint-enable no-useless-escape */
915
+ // =============================================================================
916
+ // Workspace Setup
917
+ // =============================================================================
918
+ /**
919
+ * Atomically write a file by writing to a temp file in the same directory,
920
+ * then renaming. Prevents concurrent sessions from reading partially written scripts.
921
+ */
922
+ async function atomicWriteFile(filePath, content, mode) {
923
+ const suffix = randomBytes(6).toString("hex");
924
+ const tmpPath = `${filePath}.tmp.${suffix}`;
925
+ await writeFile(tmpPath, content, { encoding: "utf-8", mode });
926
+ await rename(tmpPath, filePath);
927
+ }
928
+ /**
929
+ * Install PATH-based shell wrappers and append an AO section to AGENTS.md.
930
+ *
931
+ * This is the standard workspace setup for agents that don't have native hook
932
+ * systems (Codex, Aider, OpenCode). Call this from both `setupWorkspaceHooks`
933
+ * and `postLaunchSetup`.
934
+ *
935
+ * 1. Creates ~/.ao/bin/ with gh/git wrappers and metadata helper script
936
+ * 2. Appends an "Athene" section to the workspace AGENTS.md
937
+ */
938
+ async function setupPathWrapperWorkspace(workspacePath) {
939
+ // 1. Write shared wrappers to ~/.ao/bin/ (skip if version marker matches)
940
+ await mkdir(getAoBinDir(), { recursive: true });
941
+ const markerPath = join(getAoBinDir(), ".ao-version");
942
+ let needsUpdate = true;
943
+ try {
944
+ const existing = await readFile(markerPath, "utf-8");
945
+ if (existing.trim() === WRAPPER_VERSION)
946
+ needsUpdate = false;
947
+ }
948
+ catch {
949
+ // File doesn't exist — needs update
950
+ }
951
+ if (needsUpdate) {
952
+ if (isWindows()) {
953
+ // On Windows: generate Node.js .js wrappers + .cmd shims.
954
+ // Bash scripts can't be executed directly on Windows.
955
+ // Write wrappers atomically, then write the version marker last.
956
+ for (const name of ["gh", "git"]) {
957
+ const wrapperBase = join(getAoBinDir(), name);
958
+ const nodeScript = buildNodeWrapper(name);
959
+ // Use .cjs extension to force CJS mode regardless of any parent package.json "type" field
960
+ await atomicWriteFile(wrapperBase + ".cjs", nodeScript, 0o644);
961
+ // .cmd shim: delegates to node <wrapper>.cjs forwarding all args
962
+ await atomicWriteFile(wrapperBase + ".cmd", `@node "%~dp0${name}.cjs" %*\r\n`, 0o644);
963
+ }
964
+ }
965
+ else {
966
+ await atomicWriteFile(join(getAoBinDir(), "ao-metadata-helper.sh"), AO_METADATA_HELPER, 0o755);
967
+ // Write wrappers atomically, then write the version marker last.
968
+ // If we crash between wrapper writes and marker write, the next
969
+ // invocation will redo the writes (safe: wrappers are idempotent).
970
+ await atomicWriteFile(join(getAoBinDir(), "gh"), GH_WRAPPER, 0o755);
971
+ await atomicWriteFile(join(getAoBinDir(), "git"), GIT_WRAPPER, 0o755);
972
+ }
973
+ await atomicWriteFile(markerPath, WRAPPER_VERSION, 0o644);
974
+ }
975
+ // 2. Write AO session context to .ao/AGENTS.md (gitignored) so agents
976
+ // can discover they're in a managed session. We don't modify the
977
+ // repo-tracked AGENTS.md to avoid polluting worktrees with dirty state.
978
+ const aoAgentsMdPath = join(workspacePath, ".ao", "AGENTS.md");
979
+ await mkdir(join(workspacePath, ".ao"), { recursive: true });
980
+ // On Windows, ao-metadata-helper.sh is never created — use a platform-appropriate section
981
+ const agentsMdContent = isWindows()
982
+ ? `## Athene (ao) Session\n\nYou are running inside an Athene managed workspace.\nSession metadata is updated automatically via shell wrappers.\n`
983
+ : AO_AGENTS_MD_SECTION.trimStart();
984
+ await writeFile(aoAgentsMdPath, agentsMdContent, "utf-8");
985
+ }
986
+
987
+ export { AO_AGENTS_MD_SECTION, AO_METADATA_HELPER, GH_WRAPPER, GIT_WRAPPER, PREFERRED_GH_PATH, buildAgentPath, buildNodeWrapper, setupPathWrapperWorkspace };
988
+ //# sourceMappingURL=agent-workspace-hooks.js.map