work-ally 0.2.0-alpha.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 (172) hide show
  1. package/AGENTS.md +110 -0
  2. package/DASHBOARD.md +160 -0
  3. package/PRODUCT.md +113 -0
  4. package/README.md +403 -0
  5. package/ally.sh +171 -0
  6. package/bridge/src/approval-rules.ts +360 -0
  7. package/bridge/src/channel-delivery.ts +207 -0
  8. package/bridge/src/channel-types.ts +22 -0
  9. package/bridge/src/channels/fake/adapter.ts +31 -0
  10. package/bridge/src/channels/feishu/adapter.ts +411 -0
  11. package/bridge/src/channels/feishu/approvals.ts +6 -0
  12. package/bridge/src/channels/feishu/formatter.ts +276 -0
  13. package/bridge/src/channels/feishu/normalize.ts +368 -0
  14. package/bridge/src/codex-config.ts +52 -0
  15. package/bridge/src/config.ts +240 -0
  16. package/bridge/src/fake-runtime-client.ts +505 -0
  17. package/bridge/src/handoff-service.ts +494 -0
  18. package/bridge/src/logger.ts +194 -0
  19. package/bridge/src/memory-digest.ts +186 -0
  20. package/bridge/src/receiver-approval-autonomy.ts +158 -0
  21. package/bridge/src/receiver-control-core.ts +140 -0
  22. package/bridge/src/receiver-control-work-session.ts +218 -0
  23. package/bridge/src/receiver-control.ts +83 -0
  24. package/bridge/src/receiver-delivery.ts +136 -0
  25. package/bridge/src/receiver-helpers.ts +96 -0
  26. package/bridge/src/receiver-human-gate.ts +333 -0
  27. package/bridge/src/receiver-inbound-preflight.ts +162 -0
  28. package/bridge/src/receiver-recovery.ts +236 -0
  29. package/bridge/src/receiver-runtime-callbacks.ts +367 -0
  30. package/bridge/src/receiver-runtime-policy.ts +132 -0
  31. package/bridge/src/receiver-runtime-state.ts +124 -0
  32. package/bridge/src/receiver-support-actions.ts +189 -0
  33. package/bridge/src/receiver-thread-start.ts +57 -0
  34. package/bridge/src/receiver-turn-coordination.ts +94 -0
  35. package/bridge/src/receiver-turn-execution.ts +257 -0
  36. package/bridge/src/receiver-turn-failure.ts +143 -0
  37. package/bridge/src/receiver-turn-result.ts +185 -0
  38. package/bridge/src/receiver-turn-steer.ts +70 -0
  39. package/bridge/src/receiver-work-session.ts +76 -0
  40. package/bridge/src/receiver.ts +329 -0
  41. package/bridge/src/router.ts +62 -0
  42. package/bridge/src/runtime-client-agent-messages.ts +150 -0
  43. package/bridge/src/runtime-client-message-dispatch.ts +176 -0
  44. package/bridge/src/runtime-client-protocol.ts +411 -0
  45. package/bridge/src/runtime-client-request-ops.ts +56 -0
  46. package/bridge/src/runtime-client-run-turn.ts +158 -0
  47. package/bridge/src/runtime-client-thread-ops.ts +270 -0
  48. package/bridge/src/runtime-client-transport.ts +309 -0
  49. package/bridge/src/runtime-client-turn-poll.ts +224 -0
  50. package/bridge/src/runtime-client-turn-read.ts +185 -0
  51. package/bridge/src/runtime-client-turn-state.ts +105 -0
  52. package/bridge/src/runtime-client.ts +344 -0
  53. package/bridge/src/runtime-user-input.ts +403 -0
  54. package/bridge/src/scheduler.ts +239 -0
  55. package/bridge/src/server-handoff-command.ts +364 -0
  56. package/bridge/src/server-main.ts +80 -0
  57. package/bridge/src/server-routine-command.ts +60 -0
  58. package/bridge/src/server-routine-execution.ts +222 -0
  59. package/bridge/src/server-runtime-app-support.ts +107 -0
  60. package/bridge/src/server-runtime-app.ts +238 -0
  61. package/bridge/src/server-thread-sync-command.ts +63 -0
  62. package/bridge/src/server.ts +17 -0
  63. package/bridge/src/session-store-delivery.ts +220 -0
  64. package/bridge/src/session-store-human-gate.ts +380 -0
  65. package/bridge/src/session-store-inbound-acceptance.ts +66 -0
  66. package/bridge/src/session-store-meta.ts +134 -0
  67. package/bridge/src/session-store-turn-ledger.ts +272 -0
  68. package/bridge/src/session-store.ts +380 -0
  69. package/bridge/src/system-notify.ts +220 -0
  70. package/bridge/src/thread-sync.ts +200 -0
  71. package/bridge/src/translator.ts +494 -0
  72. package/bridge/src/types.ts +289 -0
  73. package/bridge/src/utils.ts +104 -0
  74. package/bridge/src/work-session-store.ts +471 -0
  75. package/docs/.gitkeep +0 -0
  76. package/docs/architecture/codex-feishu-bridge-proposal.md +2742 -0
  77. package/docs/completed/FEATURE-feishu-markdown-and-reply-support.md +327 -0
  78. package/docs/completed/README.md +21 -0
  79. package/docs/completed/SPEC-approval-autonomy-and-safe-defaults.md +205 -0
  80. package/docs/completed/SPEC-approval-batch-and-strict-reply-shortcuts.md +153 -0
  81. package/docs/completed/SPEC-conversation-noise-reduction-and-busy-input-gate.md +538 -0
  82. package/docs/completed/SPEC-engineering-sop-skillization.md +190 -0
  83. package/docs/completed/SPEC-faithful-bridge-core-thinning-v2.md +376 -0
  84. package/docs/completed/SPEC-faithful-bridge-core-thinning.md +1071 -0
  85. package/docs/completed/SPEC-group-chat-sender-identity.md +301 -0
  86. package/docs/completed/SPEC-middleware-exception-visibility.md +227 -0
  87. package/docs/completed/SPEC-nightly-memory-digest-visibility.md +121 -0
  88. package/docs/completed/SPEC-project-group-chat-human-centered-conversation-mapping.md +326 -0
  89. package/docs/completed/SPEC-remove-cli-persona-bootstrap.md +201 -0
  90. package/docs/developer-workflow.md +49 -0
  91. package/docs/implementation/SPEC-codex-same-machine-session-handoff-implementation.md +239 -0
  92. package/docs/implementation/test-coverage-map.md +363 -0
  93. package/docs/implementation/work-ally-implementation-guide.md +790 -0
  94. package/docs/issues/README.md +10 -0
  95. package/docs/issues/pending/ANALYSIS-ally-premature-recovery-notice-and-task-state-semantics-2026-03-18.md +295 -0
  96. package/docs/issues/resolved/ANALYSIS-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +466 -0
  97. package/docs/issues/resolved/ANALYSIS-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +261 -0
  98. package/docs/issues/resolved/ANALYSIS-codex-app-server-transport-disconnect-semantics-2026-03-14.md +606 -0
  99. package/docs/issues/resolved/ANALYSIS-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +348 -0
  100. package/docs/issues/resolved/ANALYSIS-runtime-turn-delivery-and-recovery-2026-03-14.md +603 -0
  101. package/docs/issues/resolved/ANALYSIS-self-test-gap-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +166 -0
  102. package/docs/issues/resolved/ANALYSIS-self-test-gap-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +186 -0
  103. package/docs/issues/resolved/ANALYSIS-self-test-gap-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +166 -0
  104. package/docs/issues/resolved/REPORT-ally-runtime-turn-delivery-3b42fb8-2026-03-15.md +373 -0
  105. package/docs/manual-acceptance.md +127 -0
  106. package/docs/ops-runbook.md +44 -0
  107. package/docs/planning/FEATURE-memory-system.md +748 -0
  108. package/docs/planning/SPEC-active-turn-steer-and-context-compaction-visibility.md +269 -0
  109. package/docs/planning/SPEC-approval-rules-inheritance-and-local-validation-lane.md +450 -0
  110. package/docs/planning/SPEC-assistant-persona-bootstrap.md +199 -0
  111. package/docs/planning/SPEC-assistant-rename.md +610 -0
  112. package/docs/planning/SPEC-bridge-app-server-protocol-alignment.md +667 -0
  113. package/docs/planning/SPEC-claude-runtime-host-for-work-ally.md +434 -0
  114. package/docs/planning/SPEC-cli-feishu-codex-session-unification.md +236 -0
  115. package/docs/planning/SPEC-codex-same-machine-session-handoff.md +873 -0
  116. package/docs/planning/SPEC-feishu-reaction-shortcuts.md +282 -0
  117. package/docs/planning/SPEC-local-stable-release-boundary.md +166 -0
  118. package/docs/planning/SPEC-managed-thread-entry-and-surface-mobility.md +862 -0
  119. package/docs/planning/SPEC-minimal-bridge-semantics-and-user-visible-surface.md +362 -0
  120. package/docs/planning/SPEC-npm-alpha-distribution-and-install-first-release.md +222 -0
  121. package/docs/planning/SPEC-remove-websocket-runtime-transport.md +364 -0
  122. package/docs/planning/SPEC-runtime-abstraction-phase-1.md +424 -0
  123. package/docs/planning/SPEC-runtime-connection-and-turn-recovery-semantics.md +274 -0
  124. package/docs/planning/SPEC-session-presence-and-state-visibility.md +397 -0
  125. package/docs/planning/SPEC-skill-first-capability-packaging.md +338 -0
  126. package/docs/planning/SPEC-stable-archive-contract.md +456 -0
  127. package/docs/planning/SPEC-supervised-start-boundary.md +127 -0
  128. package/docs/planning/SPEC-user-barrier-reduction-and-activation.md +832 -0
  129. package/docs/planning/ally-next.md +1278 -0
  130. package/docs/planning/assistant-workbench-spec.md +725 -0
  131. package/docs/planning/product-workbench.md +283 -0
  132. package/docs/product-onboarding.md +227 -0
  133. package/docs/product-spec-standard.md +528 -0
  134. package/docs/troubleshooting.md +45 -0
  135. package/docs/user-quickstart.md +46 -0
  136. package/internal/dispatch.sh +95 -0
  137. package/internal/lib/common.sh +1450 -0
  138. package/internal/modules/assistant/manage.sh +1312 -0
  139. package/internal/modules/bootstrap/setup.sh +144 -0
  140. package/internal/modules/config/init-env.sh +10 -0
  141. package/internal/modules/global/manage.sh +154 -0
  142. package/internal/modules/handoff/manage.sh +54 -0
  143. package/internal/modules/mcp/manage.sh +83 -0
  144. package/internal/modules/ops/logs.sh +76 -0
  145. package/internal/modules/routines/manage.sh +55 -0
  146. package/internal/modules/runtime/assistant-autosave.sh +26 -0
  147. package/internal/modules/runtime/restart.sh +6 -0
  148. package/internal/modules/runtime/start.sh +283 -0
  149. package/internal/modules/runtime/status.sh +194 -0
  150. package/internal/modules/runtime/stop.sh +55 -0
  151. package/internal/modules/runtime/supervisor.sh +216 -0
  152. package/internal/modules/runtime/update.sh +26 -0
  153. package/package.json +41 -0
  154. package/runtime/config/.gitkeep +0 -0
  155. package/runtime/host/.gitkeep +0 -0
  156. package/runtime/host/healthcheck-codex-app-server.ts +22 -0
  157. package/runtime/host/ping-pong-codex-app-server.ts +66 -0
  158. package/runtime/host/probe-codex-app-server.ts +115 -0
  159. package/skills/archive-reader/SKILL.md +9 -0
  160. package/skills/feishu-production-debug/SKILL.md +37 -0
  161. package/skills/feishu-production-debug/references/feishu-debug-order.md +49 -0
  162. package/skills/feishu-production-debug/references/platform-permission-baseline.md +23 -0
  163. package/skills/issue-to-spec-triage/SKILL.md +44 -0
  164. package/skills/issue-to-spec-triage/references/triage-rules.md +66 -0
  165. package/skills/memory-digest/SKILL.md +9 -0
  166. package/skills/post-implementation-closure/SKILL.md +39 -0
  167. package/skills/post-implementation-closure/references/closure-checklist.md +45 -0
  168. package/skills/post-implementation-closure/references/doc-drift-map.md +49 -0
  169. package/skills/product-spec/SKILL.md +244 -0
  170. package/templates/env.example +5 -0
  171. package/templates/routines/nightly-memory-digest.yaml +10 -0
  172. package/templates/workspace/AGENTS.md +26 -0
@@ -0,0 +1,1450 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ work_ally_append_timeline_log() {
5
+ local level="$1"
6
+ shift || true
7
+ [ -n "${WORK_ALLY_LOG_DIR:-}" ] || return 0
8
+ mkdir -p "$WORK_ALLY_LOG_DIR" 2>/dev/null || return 0
9
+
10
+ local stamp target
11
+ if [ -n "${WORK_ALLY_TIMEZONE:-}" ]; then
12
+ stamp=$(TZ="$WORK_ALLY_TIMEZONE" date +%F 2>/dev/null || date +%F)
13
+ else
14
+ stamp=$(date +%F)
15
+ fi
16
+ target="$WORK_ALLY_LOG_DIR/timeline-$stamp.log"
17
+ printf '%s [%s] [shell] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$level" "$*" >> "$target" 2>/dev/null || true
18
+ }
19
+
20
+ work_ally_note() {
21
+ printf '→ %s\n' "$*"
22
+ work_ally_append_timeline_log INFO "$*"
23
+ }
24
+
25
+ work_ally_ok() {
26
+ printf '✓ %s\n' "$*"
27
+ work_ally_append_timeline_log INFO "$*"
28
+ }
29
+
30
+ work_ally_warn() {
31
+ printf '⚠ %s\n' "$*" >&2
32
+ work_ally_append_timeline_log WARN "$*"
33
+ }
34
+
35
+ work_ally_die() {
36
+ printf '✗ %s\n' "$*" >&2
37
+ work_ally_append_timeline_log ERROR "$*"
38
+ exit 1
39
+ }
40
+
41
+ work_ally_require_cmd() {
42
+ command -v "$1" >/dev/null 2>&1 || work_ally_die "$1 is required"
43
+ }
44
+
45
+ work_ally_is_managed_ai_shell() {
46
+ if [ "${WORK_ALLY_ALLOW_MANAGED_START:-0}" = "1" ]; then
47
+ return 1
48
+ fi
49
+ if [ -n "${CODEX_THREAD_ID:-}" ]; then
50
+ return 0
51
+ fi
52
+ if [ "${CODEX_CI:-0}" = "1" ]; then
53
+ return 0
54
+ fi
55
+ return 1
56
+ }
57
+
58
+ work_ally_cmd_name() {
59
+ printf '%s\n' "${WORK_ALLY_CMD_NAME:-ally.sh}"
60
+ }
61
+
62
+ work_ally_abs_path() {
63
+ local target="$1"
64
+ if [ -d "$target" ]; then
65
+ (cd "$target" && pwd)
66
+ else
67
+ local dir
68
+ dir=$(cd "$(dirname "$target")" && pwd)
69
+ printf '%s/%s\n' "$dir" "$(basename "$target")"
70
+ fi
71
+ }
72
+
73
+ work_ally_init_context() {
74
+ export WORK_ALLY_WORKSPACE_ROOT="${WORK_ALLY_WORKSPACE_ROOT:?WORK_ALLY_WORKSPACE_ROOT is required}"
75
+ export WORK_ALLY_INSTALL_ROOT="${WORK_ALLY_INSTALL_ROOT:-${WORK_ALLY_IMPLEMENTATION_DIR:?WORK_ALLY_IMPLEMENTATION_DIR is required}}"
76
+ export WORK_ALLY_IMPLEMENTATION_DIR="${WORK_ALLY_IMPLEMENTATION_DIR:?WORK_ALLY_IMPLEMENTATION_DIR is required}"
77
+ export WORK_ALLY_HOME="${WORK_ALLY_HOME:-$HOME/.work-ally}"
78
+ export WORK_ALLY_ASSISTANTS_DIR="${WORK_ALLY_ASSISTANTS_DIR:-$WORK_ALLY_HOME/assistants}"
79
+ export WORK_ALLY_ASSISTANT_REGISTRY_FILE="${WORK_ALLY_ASSISTANT_REGISTRY_FILE:-$WORK_ALLY_ASSISTANTS_DIR/registry.yaml}"
80
+ export WORK_ALLY_PROJECT_REGISTRY_FILE="${WORK_ALLY_PROJECT_REGISTRY_FILE:-$WORK_ALLY_HOME/projects/registry.yaml}"
81
+ export WORK_ALLY_IMPLEMENTATION_CACHE_DIR="${WORK_ALLY_IMPLEMENTATION_CACHE_DIR:-$WORK_ALLY_HOME/implementation}"
82
+
83
+ local resolved_workspace
84
+ resolved_workspace=$(work_ally_resolve_registered_workspace_for_path "$WORK_ALLY_WORKSPACE_ROOT" || true)
85
+ if [ -n "$resolved_workspace" ]; then
86
+ export WORK_ALLY_WORKSPACE_ROOT="$resolved_workspace"
87
+ fi
88
+
89
+ if [ -z "${WORK_ALLY_ASSISTANT_NAME:-}" ]; then
90
+ WORK_ALLY_ASSISTANT_NAME=$(work_ally_resolve_registered_assistant_for_workspace "$WORK_ALLY_WORKSPACE_ROOT" || true)
91
+ export WORK_ALLY_ASSISTANT_NAME
92
+ fi
93
+
94
+ if [ -n "${WORK_ALLY_ASSISTANT_NAME:-}" ]; then
95
+ work_ally_hydrate_assistant_context "$WORK_ALLY_ASSISTANT_NAME" || true
96
+ fi
97
+ }
98
+
99
+ work_ally_ensure_state_dirs() {
100
+ mkdir -p \
101
+ "$WORK_ALLY_HOME" \
102
+ "$WORK_ALLY_ASSISTANTS_DIR" \
103
+ "$(dirname "$WORK_ALLY_PROJECT_REGISTRY_FILE")"
104
+
105
+ if [ -n "${WORK_ALLY_STATE_DIR:-}" ]; then
106
+ mkdir -p \
107
+ "$WORK_ALLY_STATE_DIR" \
108
+ "$WORK_ALLY_RUNTIME_DIR" \
109
+ "$WORK_ALLY_LOG_DIR" \
110
+ "$WORK_ALLY_SESSION_DIR" \
111
+ "$WORK_ALLY_RUN_DIR" \
112
+ "$WORK_ALLY_CACHE_DIR"
113
+ fi
114
+ }
115
+
116
+ work_ally_validate_assistant_name() {
117
+ local name="$1"
118
+ case "$name" in
119
+ ''|*[!A-Za-z0-9._-]*)
120
+ work_ally_die "Invalid assistant name: $name"
121
+ ;;
122
+ esac
123
+ }
124
+
125
+ work_ally_assistant_home() {
126
+ local name="$1"
127
+ printf '%s/%s\n' "$WORK_ALLY_ASSISTANTS_DIR" "$name"
128
+ }
129
+
130
+ work_ally_assistant_system_dir() {
131
+ local name="$1"
132
+ printf '%s/.system\n' "$(work_ally_assistant_home "$name")"
133
+ }
134
+
135
+ work_ally_assistant_codex_home() {
136
+ local name="$1"
137
+ printf '%s/codex-home\n' "$(work_ally_assistant_system_dir "$name")"
138
+ }
139
+
140
+ work_ally_assistant_conversations_dir() {
141
+ local name="$1"
142
+ printf '%s/conversations\n' "$(work_ally_assistant_home "$name")"
143
+ }
144
+
145
+ work_ally_assistant_sessions_dir() {
146
+ local name="$1"
147
+ printf '%s/sessions\n' "$(work_ally_assistant_runtime_dir "$name")"
148
+ }
149
+
150
+ work_ally_assistant_archive_dir() {
151
+ local name="$1"
152
+ printf '%s/archive\n' "$(work_ally_assistant_system_dir "$name")"
153
+ }
154
+
155
+ work_ally_assistant_journal_dir() {
156
+ local name="$1"
157
+ printf '%s/journal\n' "$(work_ally_assistant_home "$name")"
158
+ }
159
+
160
+ work_ally_assistant_daily_memory_dir() {
161
+ local name="$1"
162
+ work_ally_assistant_journal_dir "$name"
163
+ }
164
+
165
+ work_ally_assistant_long_term_memory_dir() {
166
+ local name="$1"
167
+ work_ally_assistant_home "$name"
168
+ }
169
+
170
+ work_ally_assistant_routines_dir() {
171
+ local name="$1"
172
+ printf '%s/routines\n' "$(work_ally_assistant_system_dir "$name")"
173
+ }
174
+
175
+ work_ally_assistant_runtime_dir() {
176
+ local name="$1"
177
+ printf '%s/runtime\n' "$(work_ally_assistant_system_dir "$name")"
178
+ }
179
+
180
+ work_ally_assistant_logs_dir() {
181
+ local name="$1"
182
+ printf '%s/logs\n' "$(work_ally_assistant_system_dir "$name")"
183
+ }
184
+
185
+ work_ally_assistant_runs_dir() {
186
+ local name="$1"
187
+ printf '%s/runs\n' "$(work_ally_assistant_system_dir "$name")"
188
+ }
189
+
190
+ work_ally_assistant_cache_dir() {
191
+ local name="$1"
192
+ printf '%s/cache\n' "$(work_ally_assistant_system_dir "$name")"
193
+ }
194
+
195
+ work_ally_assistant_env_file() {
196
+ local name="$1"
197
+ printf '%s/config.env\n' "$(work_ally_assistant_system_dir "$name")"
198
+ }
199
+
200
+ work_ally_assistant_state_file() {
201
+ local name="$1"
202
+ printf '%s/assistant.env\n' "$(work_ally_assistant_runtime_dir "$name")"
203
+ }
204
+
205
+ work_ally_assistant_now_file() {
206
+ local name="$1"
207
+ printf '%s/NOW.md\n' "$(work_ally_assistant_home "$name")"
208
+ }
209
+
210
+ work_ally_assistant_soul_file() {
211
+ local name="$1"
212
+ printf '%s/SOUL.md\n' "$(work_ally_assistant_home "$name")"
213
+ }
214
+
215
+ work_ally_assistant_memory_file() {
216
+ local name="$1"
217
+ printf '%s/MEMORY.md\n' "$(work_ally_assistant_home "$name")"
218
+ }
219
+
220
+ work_ally_assistant_mistakes_file() {
221
+ local name="$1"
222
+ printf '%s/MISTAKES.md\n' "$(work_ally_assistant_home "$name")"
223
+ }
224
+
225
+ work_ally_hydrate_assistant_context() {
226
+ local name="$1"
227
+ [ -n "$name" ] || return 1
228
+ export WORK_ALLY_ASSISTANT_NAME="$name"
229
+ export WORK_ALLY_ASSISTANT_HOME="$(work_ally_assistant_home "$name")"
230
+ export WORK_ALLY_STATE_DIR="$(work_ally_assistant_system_dir "$name")"
231
+ export WORK_ALLY_RUNTIME_DIR="$(work_ally_assistant_runtime_dir "$name")"
232
+ export WORK_ALLY_LOG_DIR="$(work_ally_assistant_logs_dir "$name")"
233
+ export WORK_ALLY_SESSION_DIR="$(work_ally_assistant_sessions_dir "$name")"
234
+ export WORK_ALLY_RUN_DIR="$(work_ally_assistant_runs_dir "$name")"
235
+ export WORK_ALLY_CACHE_DIR="$(work_ally_assistant_cache_dir "$name")"
236
+ export WORK_ALLY_ENV_FILE="$(work_ally_assistant_env_file "$name")"
237
+ export WORK_ALLY_ASSISTANT_STATE_FILE="$(work_ally_assistant_state_file "$name")"
238
+ }
239
+
240
+ work_ally_project_registry_has_workspace() {
241
+ local workspace_root="$1"
242
+ [ -f "$WORK_ALLY_PROJECT_REGISTRY_FILE" ] || return 1
243
+ grep -Fq "workspace_root: $workspace_root" "$WORK_ALLY_PROJECT_REGISTRY_FILE"
244
+ }
245
+
246
+ work_ally_project_registry_assistants_for_workspace() {
247
+ local workspace_root="$1"
248
+ [ -f "$WORK_ALLY_PROJECT_REGISTRY_FILE" ] || return 1
249
+ awk -v workspace="$workspace_root" '
250
+ $0 ~ /^ - workspace_root: / {
251
+ current = substr($0, length(" - workspace_root: ") + 1)
252
+ next
253
+ }
254
+ current == workspace && $0 ~ /^ assistant: / {
255
+ value = substr($0, length(" assistant: ") + 1)
256
+ print value
257
+ }
258
+ ' "$WORK_ALLY_PROJECT_REGISTRY_FILE" | awk 'NF && !seen[$0]++ { print }'
259
+ }
260
+
261
+ work_ally_project_registry_assistant_for_workspace() {
262
+ local workspace_root="$1"
263
+ local assistants count
264
+ assistants=$(work_ally_project_registry_assistants_for_workspace "$workspace_root" || true)
265
+ count=$(printf '%s\n' "$assistants" | awk 'NF { count += 1 } END { print count + 0 }')
266
+ if [ "$count" -eq 1 ]; then
267
+ printf '%s\n' "$assistants" | awk 'NF { print; exit }'
268
+ return 0
269
+ fi
270
+ return 1
271
+ }
272
+
273
+ work_ally_project_registry_workspace_for_path() {
274
+ local target_path="$1"
275
+ [ -f "$WORK_ALLY_PROJECT_REGISTRY_FILE" ] || return 1
276
+ awk -v target="$target_path" '
277
+ function matches(workspace, candidate) {
278
+ return candidate == workspace || index(candidate, workspace "/") == 1
279
+ }
280
+ $0 ~ /^ - workspace_root: / {
281
+ workspace = substr($0, length(" - workspace_root: ") + 1)
282
+ if (matches(workspace, target) && length(workspace) > best_len) {
283
+ best = workspace
284
+ best_len = length(workspace)
285
+ }
286
+ }
287
+ END {
288
+ if (best_len > 0) {
289
+ print best
290
+ }
291
+ }
292
+ ' "$WORK_ALLY_PROJECT_REGISTRY_FILE"
293
+ }
294
+
295
+ work_ally_project_registry_workspace_for_assistant() {
296
+ local assistant_name="$1"
297
+ [ -f "$WORK_ALLY_PROJECT_REGISTRY_FILE" ] || return 1
298
+ awk -v assistant="$assistant_name" '
299
+ /^ - workspace_root: / {
300
+ current_workspace = substr($0, length(" - workspace_root: ") + 1)
301
+ next
302
+ }
303
+ $0 == " assistant: " assistant {
304
+ print current_workspace
305
+ exit
306
+ }
307
+ ' "$WORK_ALLY_PROJECT_REGISTRY_FILE"
308
+ }
309
+
310
+ work_ally_project_registry_set() {
311
+ local workspace_root="$1"
312
+ local assistant_name="$2"
313
+ local tmp
314
+ mkdir -p "$(dirname "$WORK_ALLY_PROJECT_REGISTRY_FILE")"
315
+ tmp=$(mktemp "${TMPDIR:-/tmp}/work-ally-project-registry.XXXXXX")
316
+
317
+ if [ -f "$WORK_ALLY_PROJECT_REGISTRY_FILE" ]; then
318
+ awk -v assistant="$assistant_name" '
319
+ BEGIN {
320
+ block = ""
321
+ in_block = 0
322
+ skip = 0
323
+ }
324
+ /^ - workspace_root: / {
325
+ if (in_block && !skip) {
326
+ printf "%s", block
327
+ }
328
+ block = $0 ORS
329
+ in_block = 1
330
+ skip = 0
331
+ next
332
+ }
333
+ in_block {
334
+ block = block $0 ORS
335
+ if ($0 ~ /^ assistant: /) {
336
+ current_assistant = substr($0, length(" assistant: ") + 1)
337
+ if (current_assistant == assistant) {
338
+ skip = 1
339
+ }
340
+ }
341
+ next
342
+ }
343
+ { print }
344
+ END {
345
+ if (in_block && !skip) {
346
+ printf "%s", block
347
+ }
348
+ }
349
+ ' "$WORK_ALLY_PROJECT_REGISTRY_FILE" > "$tmp"
350
+ else
351
+ printf 'projects:\n' > "$tmp"
352
+ fi
353
+
354
+ if ! grep -q '^projects:$' "$tmp"; then
355
+ printf 'projects:\n' | cat - "$tmp" > "$tmp.with-header"
356
+ mv "$tmp.with-header" "$tmp"
357
+ fi
358
+
359
+ if ! awk -v workspace="$workspace_root" -v assistant="$assistant_name" '
360
+ $0 ~ /^ - workspace_root: / {
361
+ current = substr($0, length(" - workspace_root: ") + 1)
362
+ next
363
+ }
364
+ current == workspace && $0 == " assistant: " assistant {
365
+ found = 1
366
+ }
367
+ END { exit(found ? 0 : 1) }
368
+ ' "$tmp"; then
369
+ {
370
+ printf ' - workspace_root: %s\n' "$workspace_root"
371
+ printf ' assistant: %s\n' "$assistant_name"
372
+ } >> "$tmp"
373
+ fi
374
+
375
+ mv "$tmp" "$WORK_ALLY_PROJECT_REGISTRY_FILE"
376
+ }
377
+
378
+ work_ally_project_registry_remove_assistant() {
379
+ local assistant_name="$1"
380
+ local tmp
381
+ [ -f "$WORK_ALLY_PROJECT_REGISTRY_FILE" ] || return 0
382
+ tmp=$(mktemp "${TMPDIR:-/tmp}/work-ally-project-registry.XXXXXX")
383
+
384
+ awk -v assistant="$assistant_name" '
385
+ BEGIN {
386
+ block = ""
387
+ in_block = 0
388
+ skip = 0
389
+ }
390
+ /^ - workspace_root: / {
391
+ if (in_block && !skip) {
392
+ printf "%s", block
393
+ }
394
+ block = $0 ORS
395
+ in_block = 1
396
+ skip = 0
397
+ next
398
+ }
399
+ in_block {
400
+ block = block $0 ORS
401
+ if ($0 ~ /^ assistant: /) {
402
+ current_assistant = substr($0, length(" assistant: ") + 1)
403
+ if (current_assistant == assistant) {
404
+ skip = 1
405
+ }
406
+ }
407
+ next
408
+ }
409
+ { print }
410
+ END {
411
+ if (in_block && !skip) {
412
+ printf "%s", block
413
+ }
414
+ }
415
+ ' "$WORK_ALLY_PROJECT_REGISTRY_FILE" > "$tmp"
416
+
417
+ if ! grep -q '^projects:$' "$tmp"; then
418
+ printf 'projects:\n' > "$tmp.with-header"
419
+ cat "$tmp" >> "$tmp.with-header"
420
+ mv "$tmp.with-header" "$tmp"
421
+ fi
422
+
423
+ mv "$tmp" "$WORK_ALLY_PROJECT_REGISTRY_FILE"
424
+ }
425
+
426
+ work_ally_resolve_registered_workspace_for_path() {
427
+ local workspace_root="${1:-$WORK_ALLY_WORKSPACE_ROOT}"
428
+ work_ally_project_registry_workspace_for_path "$workspace_root"
429
+ }
430
+
431
+ work_ally_resolve_registered_assistant_for_workspace() {
432
+ local workspace_root="${1:-$WORK_ALLY_WORKSPACE_ROOT}"
433
+ work_ally_project_registry_assistant_for_workspace "$workspace_root"
434
+ }
435
+
436
+ work_ally_assistant_git_origin_url() {
437
+ local assistant_home="$1"
438
+ command -v git >/dev/null 2>&1 || return 0
439
+ [ -d "$assistant_home/.git" ] || return 0
440
+ git -C "$assistant_home" remote get-url origin 2>/dev/null || true
441
+ }
442
+
443
+ work_ally_assistant_git_branch() {
444
+ local assistant_home="$1"
445
+ command -v git >/dev/null 2>&1 || {
446
+ printf 'main\n'
447
+ return 0
448
+ }
449
+
450
+ local branch
451
+ branch=$(git -C "$assistant_home" branch --show-current 2>/dev/null || true)
452
+ printf '%s\n' "${branch:-main}"
453
+ }
454
+
455
+ work_ally_assistant_git_setup() {
456
+ local assistant_home="$1"
457
+ local remote_url="${2:-}"
458
+
459
+ if ! command -v git >/dev/null 2>&1; then
460
+ work_ally_warn "git is unavailable; assistant desk autosave is disabled"
461
+ return 1
462
+ fi
463
+
464
+ if [ ! -d "$assistant_home/.git" ]; then
465
+ if ! git -C "$assistant_home" init -b main >/dev/null 2>&1; then
466
+ git -C "$assistant_home" init >/dev/null 2>&1 || {
467
+ work_ally_warn "Failed to initialize assistant Git repo at $assistant_home"
468
+ return 1
469
+ }
470
+ git -C "$assistant_home" branch -M main >/dev/null 2>&1 || true
471
+ fi
472
+ fi
473
+
474
+ git -C "$assistant_home" config user.name work-ally >/dev/null 2>&1 || true
475
+ git -C "$assistant_home" config user.email work-ally@local >/dev/null 2>&1 || true
476
+
477
+ if [ -n "$remote_url" ]; then
478
+ if git -C "$assistant_home" remote get-url origin >/dev/null 2>&1; then
479
+ git -C "$assistant_home" remote set-url origin "$remote_url" >/dev/null 2>&1 || {
480
+ work_ally_warn "Failed to update assistant Git remote for $assistant_home"
481
+ return 1
482
+ }
483
+ else
484
+ git -C "$assistant_home" remote add origin "$remote_url" >/dev/null 2>&1 || {
485
+ work_ally_warn "Failed to add assistant Git remote for $assistant_home"
486
+ return 1
487
+ }
488
+ fi
489
+ fi
490
+
491
+ return 0
492
+ }
493
+
494
+ work_ally_assistant_git_prepare_index() {
495
+ local assistant_home="$1"
496
+
497
+ command -v git >/dev/null 2>&1 || return 0
498
+ [ -d "$assistant_home/.git" ] || return 0
499
+
500
+ git -C "$assistant_home" rm --cached -r --ignore-unmatch .system/config.env .system/logs .system/cache .system/runtime .system/runs .system/codex-home/.personality_migration .system/codex-home/logs_1.sqlite .system/codex-home/logs_1.sqlite-shm .system/codex-home/logs_1.sqlite-wal .system/codex-home/state_5.sqlite .system/codex-home/sessions .system/codex-home/shell_snapshots .system/codex-home/skills .system/codex-home/tmp >/dev/null 2>&1 || true
501
+ }
502
+
503
+ work_ally_assistant_git_checkpoint() {
504
+ local assistant_home="$1"
505
+ local reason="${2:-checkpoint}"
506
+ local branch remote_url stamp
507
+
508
+ command -v git >/dev/null 2>&1 || return 0
509
+ [ -d "$assistant_home/.git" ] || return 0
510
+
511
+ work_ally_assistant_git_prepare_index "$assistant_home"
512
+
513
+ if ! git -C "$assistant_home" add -A >/dev/null 2>&1; then
514
+ work_ally_warn "Failed to stage assistant desk changes at $assistant_home"
515
+ return 1
516
+ fi
517
+
518
+ if git -C "$assistant_home" diff --cached --quiet --ignore-submodules --exit-code >/dev/null 2>&1; then
519
+ return 0
520
+ fi
521
+
522
+ stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
523
+ if ! git -C "$assistant_home" commit -m "work-ally: $reason ($stamp)" >/dev/null 2>&1; then
524
+ work_ally_warn "Failed to commit assistant desk changes at $assistant_home"
525
+ return 1
526
+ fi
527
+
528
+ remote_url=$(work_ally_assistant_git_origin_url "$assistant_home")
529
+ if [ -n "$remote_url" ]; then
530
+ branch=$(work_ally_assistant_git_branch "$assistant_home")
531
+ git -C "$assistant_home" push -u origin "$branch" >/dev/null 2>&1 || work_ally_warn "Assistant desk push failed for $assistant_home"
532
+ fi
533
+
534
+ return 0
535
+ }
536
+
537
+ work_ally_registry_has_assistant() {
538
+ local name="$1"
539
+ [ -f "$WORK_ALLY_ASSISTANT_REGISTRY_FILE" ] || return 1
540
+ grep -Eq "^[[:space:]]{2}${name}:$" "$WORK_ALLY_ASSISTANT_REGISTRY_FILE"
541
+ }
542
+
543
+ work_ally_registry_field() {
544
+ local name="$1"
545
+ local field="$2"
546
+ [ -f "$WORK_ALLY_ASSISTANT_REGISTRY_FILE" ] || return 1
547
+ awk -v assistant="$name" -v target="$field" '
548
+ $0 ~ "^ " assistant ":$" { in_block=1; next }
549
+ in_block && $0 ~ "^ [^[:space:]]+:$" { exit }
550
+ in_block {
551
+ prefix = " " target ": "
552
+ if (index($0, prefix) == 1) {
553
+ value = substr($0, length(prefix) + 1)
554
+ gsub(/^"|"$/, "", value)
555
+ print value
556
+ exit
557
+ }
558
+ }
559
+ ' "$WORK_ALLY_ASSISTANT_REGISTRY_FILE"
560
+ }
561
+
562
+ work_ally_require_registered_assistant() {
563
+ local name="$1"
564
+ work_ally_validate_assistant_name "$name"
565
+ if ! work_ally_registry_has_assistant "$name"; then
566
+ work_ally_die "Assistant not registered: $name. Run '$(work_ally_cmd_name) assistant list' or '$(work_ally_cmd_name) assistant add $name'."
567
+ fi
568
+ }
569
+
570
+ work_ally_assistant_description() {
571
+ local name="$1"
572
+ work_ally_registry_field "$name" description || true
573
+ }
574
+
575
+ work_ally_assistant_registered_home() {
576
+ local name="$1"
577
+ work_ally_registry_field "$name" assistant_home || true
578
+ }
579
+
580
+ work_ally_assistant_registered_codex_home() {
581
+ local name="$1"
582
+ work_ally_registry_field "$name" codex_home || true
583
+ }
584
+
585
+ work_ally_assistant_registered_workspace() {
586
+ local name="$1"
587
+ work_ally_project_registry_workspace_for_assistant "$name" || true
588
+ }
589
+
590
+ work_ally_registry_remove_assistant() {
591
+ local name="$1"
592
+ local tmp
593
+ [ -f "$WORK_ALLY_ASSISTANT_REGISTRY_FILE" ] || return 0
594
+ tmp=$(mktemp "${TMPDIR:-/tmp}/work-ally-assistant-registry.XXXXXX")
595
+
596
+ awk -v assistant="$name" '
597
+ BEGIN { skip = 0 }
598
+ $0 ~ "^ " assistant ":$" {
599
+ skip = 1
600
+ next
601
+ }
602
+ skip && /^ / { next }
603
+ skip { skip = 0 }
604
+ { print }
605
+ ' "$WORK_ALLY_ASSISTANT_REGISTRY_FILE" > "$tmp"
606
+
607
+ if ! grep -q '^assistants:$' "$tmp"; then
608
+ printf 'assistants:\n' | cat - "$tmp" > "$tmp.with-header"
609
+ mv "$tmp.with-header" "$tmp"
610
+ fi
611
+
612
+ mv "$tmp" "$WORK_ALLY_ASSISTANT_REGISTRY_FILE"
613
+ }
614
+
615
+ work_ally_assistant_is_busy() {
616
+ local name="$1"
617
+ local lock_file lock_pid
618
+ lock_file=$(work_ally_assistant_lock_file "$name")
619
+ [ -f "$lock_file" ] || return 1
620
+ lock_pid=$(work_ally_assistant_lock_field "$lock_file" pid || true)
621
+ if [ -n "$lock_pid" ] && work_ally_is_pid_alive "$lock_pid"; then
622
+ return 0
623
+ fi
624
+ return 1
625
+ }
626
+
627
+ work_ally_workspace_binding_count() {
628
+ local workspace_root="${1:-$WORK_ALLY_WORKSPACE_ROOT}"
629
+ local assistants
630
+ assistants=$(work_ally_project_registry_assistants_for_workspace "$workspace_root" || true)
631
+ printf '%s\n' "$assistants" | awk 'NF { count += 1 } END { print count + 0 }'
632
+ }
633
+
634
+ work_ally_assistant_workspace_root_or_die() {
635
+ local name="$1"
636
+ local workspace_root
637
+ workspace_root=$(work_ally_assistant_registered_workspace "$name" || true)
638
+ [ -n "$workspace_root" ] || work_ally_die "Assistant $name is not bound to a project yet. Re-run '$(work_ally_cmd_name) setup $name --workspace <path>'."
639
+ printf '%s\n' "$workspace_root"
640
+ }
641
+
642
+ work_ally_sync_workspace_root_from_assistant() {
643
+ local name="$1"
644
+ local workspace_root
645
+ workspace_root=$(work_ally_assistant_workspace_root_or_die "$name")
646
+ export WORK_ALLY_WORKSPACE_ROOT="$workspace_root"
647
+ }
648
+
649
+ work_ally_resolve_assistant_name_from_args() {
650
+ local default_name="${WORK_ALLY_ASSISTANT_NAME:-}"
651
+ local current_workspace binding_count
652
+ current_workspace="${WORK_ALLY_WORKSPACE_ROOT:-}"
653
+ while [ "$#" -gt 0 ]; do
654
+ case "$1" in
655
+ --assistant)
656
+ [ "$#" -ge 2 ] || work_ally_die "--assistant requires a value"
657
+ printf '%s\n' "$2"
658
+ return 0
659
+ ;;
660
+ --assistant=*)
661
+ printf '%s\n' "${1#--assistant=}"
662
+ return 0
663
+ ;;
664
+ esac
665
+ shift
666
+ done
667
+
668
+ if [ -z "$default_name" ] && [ -n "$current_workspace" ]; then
669
+ default_name=$(work_ally_resolve_registered_assistant_for_workspace "$current_workspace" || true)
670
+ fi
671
+
672
+ if [ -n "$default_name" ]; then
673
+ printf '%s\n' "$default_name"
674
+ return 0
675
+ fi
676
+
677
+ if [ -n "${WORK_ALLY_ASSISTANT_STATE_FILE:-}" ] && [ -f "$WORK_ALLY_ASSISTANT_STATE_FILE" ]; then
678
+ # shellcheck disable=SC1090
679
+ . "$WORK_ALLY_ASSISTANT_STATE_FILE"
680
+ if [ -n "${WORK_ALLY_ASSISTANT_NAME:-}" ]; then
681
+ printf '%s\n' "$WORK_ALLY_ASSISTANT_NAME"
682
+ return 0
683
+ fi
684
+ fi
685
+
686
+ if [ -n "$current_workspace" ]; then
687
+ binding_count=$(work_ally_workspace_binding_count "$current_workspace")
688
+ if [ "$binding_count" -gt 1 ]; then
689
+ work_ally_die "Multiple assistants are bound to $current_workspace. Use --assistant <name>."
690
+ fi
691
+ fi
692
+
693
+ work_ally_die "Assistant is required. Use --assistant <name> or run '$(work_ally_cmd_name) setup <name> --workspace <path>'."
694
+ }
695
+
696
+ work_ally_write_assistant_state() {
697
+ local name="$1"
698
+ local assistant_home="$2"
699
+ local codex_home="$3"
700
+ work_ally_hydrate_assistant_context "$name"
701
+ mkdir -p "$WORK_ALLY_RUNTIME_DIR"
702
+ {
703
+ printf 'WORK_ALLY_ASSISTANT_MODE=assistant\n'
704
+ printf 'WORK_ALLY_ASSISTANT_NAME=%s\n' "$name"
705
+ printf 'WORK_ALLY_ASSISTANT_HOME=%s\n' "$assistant_home"
706
+ printf 'WORK_ALLY_ASSISTANT_CODEX_HOME=%s\n' "$codex_home"
707
+ printf 'WORK_ALLY_WORKSPACE_ROOT=%s\n' "$WORK_ALLY_WORKSPACE_ROOT"
708
+ printf 'WORK_ALLY_INSTALL_ROOT=%s\n' "$WORK_ALLY_INSTALL_ROOT"
709
+ printf 'WORK_ALLY_IMPLEMENTATION_DIR=%s\n' "$WORK_ALLY_IMPLEMENTATION_DIR"
710
+ printf 'WORK_ALLY_INSTALL_CHANNEL=%s\n' "${WORK_ALLY_INSTALL_CHANNEL:-unknown}"
711
+ printf 'WORK_ALLY_STATE_DIR=%s\n' "$WORK_ALLY_STATE_DIR"
712
+ } > "$WORK_ALLY_ASSISTANT_STATE_FILE"
713
+ }
714
+
715
+ work_ally_load_assistant_state() {
716
+ [ -f "$WORK_ALLY_ASSISTANT_STATE_FILE" ] || return 1
717
+ set -a
718
+ # shellcheck disable=SC1090
719
+ . "$WORK_ALLY_ASSISTANT_STATE_FILE"
720
+ set +a
721
+ }
722
+
723
+ work_ally_require_assistant_profile_ready() {
724
+ local name="$1"
725
+ local assistant_home codex_home assistant_agents config_file
726
+
727
+ work_ally_require_registered_assistant "$name"
728
+ assistant_home=$(work_ally_assistant_registered_home "$name")
729
+ codex_home=$(work_ally_assistant_registered_codex_home "$name")
730
+ [ -n "$assistant_home" ] || work_ally_die "Assistant profile is invalid: missing assistant_home for $name"
731
+ [ -n "$codex_home" ] || work_ally_die "Assistant profile is invalid: missing codex_home for $name"
732
+ [ -d "$assistant_home" ] || work_ally_die "Assistant profile is incomplete: missing assistant_home at $assistant_home"
733
+ [ -d "$codex_home" ] || work_ally_die "Assistant profile is incomplete: missing codex_home at $codex_home"
734
+ assistant_agents="$codex_home/AGENTS.md"
735
+ config_file="$codex_home/config.toml"
736
+ [ -f "$assistant_agents" ] || work_ally_die "Assistant profile is incomplete: missing $assistant_agents"
737
+ [ -f "$config_file" ] || work_ally_die "Assistant profile is incomplete: missing $config_file"
738
+ }
739
+
740
+ work_ally_global_lock_dir() {
741
+ printf '%s/locks\n' "$WORK_ALLY_HOME"
742
+ }
743
+
744
+ work_ally_assistant_lock_file() {
745
+ local name="$1"
746
+ printf '%s/assistant-%s.lock\n' "$(work_ally_global_lock_dir)" "$name"
747
+ }
748
+
749
+ work_ally_emit_assistant_lock_content() {
750
+ local owner_pid="$1"
751
+ printf 'assistant=%s\n' "${WORK_ALLY_ASSISTANT_NAME:-}"
752
+ printf 'workspace_root=%s\n' "$WORK_ALLY_WORKSPACE_ROOT"
753
+ printf 'pid=%s\n' "$owner_pid"
754
+ printf 'updated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
755
+ }
756
+
757
+ work_ally_assistant_lock_field() {
758
+ local file="$1"
759
+ local key="$2"
760
+ [ -f "$file" ] || return 1
761
+ sed -n "s/^${key}=//p" "$file" | head -n 1
762
+ }
763
+
764
+ work_ally_assert_assistant_available() {
765
+ local name="$1"
766
+ local lock_file lock_pid lock_workspace
767
+ lock_file=$(work_ally_assistant_lock_file "$name")
768
+ mkdir -p "$(dirname "$lock_file")"
769
+ [ -f "$lock_file" ] || return 0
770
+
771
+ lock_pid=$(work_ally_assistant_lock_field "$lock_file" pid || true)
772
+ lock_workspace=$(work_ally_assistant_lock_field "$lock_file" workspace_root || true)
773
+
774
+ if [ -n "$lock_pid" ] && work_ally_is_pid_alive "$lock_pid"; then
775
+ if [ -z "$lock_workspace" ] || [ "$lock_workspace" = "$WORK_ALLY_WORKSPACE_ROOT" ]; then
776
+ return 0
777
+ fi
778
+ work_ally_die "Assistant $name is already active in project $lock_workspace (pid $lock_pid). Stop it first before using the same assistant elsewhere."
779
+ fi
780
+
781
+ rm -f "$lock_file"
782
+ }
783
+
784
+ work_ally_claim_assistant_lock() {
785
+ local name="$1"
786
+ local owner_pid="${2:-$$}"
787
+ local lock_file lock_pid lock_workspace
788
+ lock_file=$(work_ally_assistant_lock_file "$name")
789
+ mkdir -p "$(dirname "$lock_file")"
790
+
791
+ while :; do
792
+ if ( set -C; work_ally_emit_assistant_lock_content "$owner_pid" > "$lock_file" ) 2>/dev/null; then
793
+ return 0
794
+ fi
795
+
796
+ lock_pid=$(work_ally_assistant_lock_field "$lock_file" pid || true)
797
+ lock_workspace=$(work_ally_assistant_lock_field "$lock_file" workspace_root || true)
798
+
799
+ if [ -n "$lock_pid" ] && work_ally_is_pid_alive "$lock_pid"; then
800
+ if [ -z "$lock_workspace" ] || [ "$lock_workspace" = "$WORK_ALLY_WORKSPACE_ROOT" ]; then
801
+ work_ally_write_assistant_lock "$name" "$owner_pid"
802
+ return 0
803
+ fi
804
+ work_ally_die "Assistant $name is already active in project $lock_workspace (pid $lock_pid). Stop it first before using the same assistant elsewhere."
805
+ fi
806
+
807
+ rm -f "$lock_file"
808
+ done
809
+ }
810
+
811
+ work_ally_write_assistant_lock() {
812
+ local name="$1"
813
+ local owner_pid="$2"
814
+ local lock_file
815
+ lock_file=$(work_ally_assistant_lock_file "$name")
816
+ mkdir -p "$(dirname "$lock_file")"
817
+ work_ally_emit_assistant_lock_content "$owner_pid" > "$lock_file"
818
+ }
819
+
820
+ work_ally_assistant_lock_status() {
821
+ local name="$1"
822
+ local lock_file lock_pid lock_workspace
823
+ lock_file=$(work_ally_assistant_lock_file "$name")
824
+ [ -f "$lock_file" ] || {
825
+ printf 'none\n'
826
+ return 0
827
+ }
828
+ lock_pid=$(work_ally_assistant_lock_field "$lock_file" pid || true)
829
+ lock_workspace=$(work_ally_assistant_lock_field "$lock_file" workspace_root || true)
830
+ if [ -n "$lock_pid" ] && work_ally_is_pid_alive "$lock_pid"; then
831
+ if [ -n "$lock_workspace" ] && [ "$lock_workspace" = "$WORK_ALLY_WORKSPACE_ROOT" ]; then
832
+ printf 'owned\n'
833
+ else
834
+ printf 'busy\n'
835
+ fi
836
+ return 0
837
+ fi
838
+ printf 'stale\n'
839
+ }
840
+
841
+ work_ally_release_assistant_lock() {
842
+ local name="$1"
843
+ local expected_pid="${2:-}"
844
+ local lock_file lock_workspace lock_pid
845
+ lock_file=$(work_ally_assistant_lock_file "$name")
846
+ [ -f "$lock_file" ] || return 0
847
+ lock_workspace=$(work_ally_assistant_lock_field "$lock_file" workspace_root || true)
848
+ lock_pid=$(work_ally_assistant_lock_field "$lock_file" pid || true)
849
+ if [ -n "$lock_workspace" ] && [ "$lock_workspace" != "$WORK_ALLY_WORKSPACE_ROOT" ]; then
850
+ return 0
851
+ fi
852
+ if [ -n "$expected_pid" ] && [ -n "$lock_pid" ] && [ "$expected_pid" != "$lock_pid" ]; then
853
+ return 0
854
+ fi
855
+ rm -f "$lock_file"
856
+ }
857
+
858
+ work_ally_env_template_file() {
859
+ printf '%s/templates/env.example\n' "$WORK_ALLY_IMPLEMENTATION_DIR"
860
+ }
861
+
862
+ work_ally_env_set() {
863
+ local key="$1"
864
+ local value="$2"
865
+ local tmp
866
+
867
+ mkdir -p "$(dirname "$WORK_ALLY_ENV_FILE")"
868
+ tmp=$(mktemp "${TMPDIR:-/tmp}/work-ally-env-set.XXXXXX")
869
+
870
+ if [ -f "$WORK_ALLY_ENV_FILE" ]; then
871
+ awk -v key="$key" -v value="$value" '
872
+ BEGIN { updated = 0 }
873
+ index($0, key "=") == 1 {
874
+ if (!updated) {
875
+ printf "%s=%s\n", key, value
876
+ updated = 1
877
+ }
878
+ next
879
+ }
880
+ { print }
881
+ END {
882
+ if (!updated) {
883
+ printf "%s=%s\n", key, value
884
+ }
885
+ }
886
+ ' "$WORK_ALLY_ENV_FILE" > "$tmp"
887
+ else
888
+ printf '%s=%s\n' "$key" "$value" > "$tmp"
889
+ fi
890
+
891
+ mv "$tmp" "$WORK_ALLY_ENV_FILE"
892
+ }
893
+
894
+ work_ally_assistant_autosave_interval() {
895
+ local value="${WORK_ALLY_ASSISTANT_AUTOSAVE_INTERVAL_SECONDS:-1800}"
896
+ case "$value" in
897
+ ''|*[!0-9]*) printf '1800\n' ;;
898
+ *)
899
+ if [ "$value" -lt 1 ]; then
900
+ printf '1800\n'
901
+ else
902
+ printf '%s\n' "$value"
903
+ fi
904
+ ;;
905
+ esac
906
+ }
907
+
908
+ work_ally_ensure_env_defaults() {
909
+ local template tmp changed line key current_emoji
910
+ template=$(work_ally_env_template_file)
911
+ export WORK_ALLY_ENV_UPDATED=0
912
+
913
+ [ -f "$template" ] || work_ally_die "Missing env template: $template"
914
+
915
+ if [ ! -f "$WORK_ALLY_ENV_FILE" ]; then
916
+ cp "$template" "$WORK_ALLY_ENV_FILE"
917
+ export WORK_ALLY_ENV_UPDATED=1
918
+ work_ally_ok "Created assistant config: $WORK_ALLY_ENV_FILE"
919
+ return 0
920
+ fi
921
+
922
+ tmp=$(mktemp "${TMPDIR:-/tmp}/work-ally-env.XXXXXX")
923
+ cp "$WORK_ALLY_ENV_FILE" "$tmp"
924
+ changed=0
925
+
926
+ while IFS= read -r line || [ -n "$line" ]; do
927
+ case "$line" in
928
+ ''|'#'*)
929
+ continue
930
+ ;;
931
+ esac
932
+ key=${line%%=*}
933
+ if ! grep -Eq "^${key}=" "$tmp"; then
934
+ printf '\n%s\n' "$line" >> "$tmp"
935
+ changed=1
936
+ fi
937
+ done < "$template"
938
+
939
+ current_emoji=$(grep -E '^FEISHU_REACT_EMOJI=' "$tmp" | tail -n 1 | cut -d= -f2- || true)
940
+ if [ "$current_emoji" = "OnIt" ]; then
941
+ awk '{ if ($0 == "FEISHU_REACT_EMOJI=OnIt") print "FEISHU_REACT_EMOJI=Typing"; else print $0 }' "$tmp" > "$tmp.rewritten"
942
+ mv "$tmp.rewritten" "$tmp"
943
+ changed=1
944
+ fi
945
+
946
+ if [ "$changed" -eq 1 ]; then
947
+ mv "$tmp" "$WORK_ALLY_ENV_FILE"
948
+ export WORK_ALLY_ENV_UPDATED=1
949
+ work_ally_note "Updated assistant config defaults: $WORK_ALLY_ENV_FILE"
950
+ else
951
+ rm -f "$tmp"
952
+ fi
953
+ }
954
+
955
+ work_ally_load_env() {
956
+ local env_file="${WORK_ALLY_ENV_FILE:-}"
957
+ [ -n "$env_file" ] || return 0
958
+ if [ -f "$env_file" ]; then
959
+ set -a
960
+ # shellcheck disable=SC1090
961
+ . "$env_file"
962
+ set +a
963
+ fi
964
+ }
965
+
966
+ work_ally_codex_required_env_keys() {
967
+ local codex_home config_file keys
968
+ codex_home="${WORK_ALLY_ASSISTANT_CODEX_HOME:-${CODEX_HOME:-}}"
969
+ [ -n "$codex_home" ] || return 0
970
+ config_file="$codex_home/config.toml"
971
+ [ -f "$config_file" ] || return 0
972
+ keys=$(sed -nE 's/^[[:space:]]*env_key[[:space:]]*=[[:space:]]*"([^"]+)"[[:space:]]*$/\1/p' "$config_file" | awk '!seen[$0]++')
973
+ [ -n "$keys" ] || return 0
974
+ printf '%s\n' "$keys"
975
+ }
976
+
977
+ work_ally_assert_codex_env_keys_available() {
978
+ local key missing=0
979
+ while IFS= read -r key; do
980
+ [ -n "$key" ] || continue
981
+ if [ -z "${!key:-}" ]; then
982
+ work_ally_warn "Missing required environment variable for Codex provider: $key"
983
+ missing=1
984
+ fi
985
+ done <<EOF
986
+ $(work_ally_codex_required_env_keys)
987
+ EOF
988
+
989
+ if [ "$missing" -eq 1 ]; then
990
+ work_ally_die "Codex provider environment variables are missing. Export them in the shell before running $(work_ally_cmd_name) start."
991
+ fi
992
+ }
993
+
994
+ work_ally_pid_file() {
995
+ printf '%s/%s.pid\n' "$WORK_ALLY_RUNTIME_DIR" "$1"
996
+ }
997
+
998
+ work_ally_health_file() {
999
+ printf '%s/%s.health.json\n' "$WORK_ALLY_RUNTIME_DIR" "$1"
1000
+ }
1001
+
1002
+ work_ally_json_field() {
1003
+ local file="$1"
1004
+ local key="$2"
1005
+ [ -f "$file" ] || return 0
1006
+ sed -nE "s/.*\"${key}\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\\1/p" "$file" | head -n 1
1007
+ }
1008
+
1009
+ work_ally_json_number_field() {
1010
+ local file="$1"
1011
+ local key="$2"
1012
+ [ -f "$file" ] || return 0
1013
+ sed -nE "s/.*\"${key}\"[[:space:]]*:[[:space:]]*([0-9]+).*/\\1/p" "$file" | head -n 1
1014
+ }
1015
+
1016
+ work_ally_is_pid_alive() {
1017
+ local pid="$1"
1018
+ [ -n "$pid" ] && kill -0 "$pid" >/dev/null 2>&1
1019
+ }
1020
+
1021
+ work_ally_read_pid() {
1022
+ local file="$1"
1023
+ local pid
1024
+ [ -f "$file" ] || return 1
1025
+ pid=$(tr -d '[:space:]' < "$file")
1026
+ [ -n "$pid" ] || return 1
1027
+ printf '%s\n' "$pid"
1028
+ }
1029
+
1030
+ work_ally_terminate_pid() {
1031
+ local pid="$1"
1032
+ local attempt=0
1033
+
1034
+ [ -n "$pid" ] || return 0
1035
+ if ! work_ally_is_pid_alive "$pid"; then
1036
+ return 0
1037
+ fi
1038
+
1039
+ kill "$pid" >/dev/null 2>&1 || true
1040
+ while [ "$attempt" -lt 5 ]; do
1041
+ if ! work_ally_is_pid_alive "$pid"; then
1042
+ return 0
1043
+ fi
1044
+ attempt=$((attempt + 1))
1045
+ sleep 1
1046
+ done
1047
+
1048
+ if work_ally_is_pid_alive "$pid"; then
1049
+ kill -9 "$pid" >/dev/null 2>&1 || true
1050
+ fi
1051
+ }
1052
+
1053
+ work_ally_is_external_owner() {
1054
+ local health_file
1055
+ health_file=$(work_ally_health_file "$1")
1056
+ [ -f "$health_file" ] || return 1
1057
+ grep -Eq '"owner"[[:space:]]*:[[:space:]]*"external"' "$health_file"
1058
+ }
1059
+
1060
+ work_ally_health_pid() {
1061
+ local health_file
1062
+ health_file=$(work_ally_health_file "$1")
1063
+ [ -f "$health_file" ] || return 1
1064
+ sed -nE 's/.*"pid"[[:space:]]*:[[:space:]]*([0-9]+).*/\1/p' "$health_file" | head -n 1
1065
+ }
1066
+
1067
+ work_ally_bridge_entrypoint() {
1068
+ work_ally_abs_path "$WORK_ALLY_IMPLEMENTATION_DIR/bridge/src/server.ts"
1069
+ }
1070
+
1071
+ work_ally_list_bridge_pids() {
1072
+ local pid_file health_pid
1073
+ pid_file=$(work_ally_pid_file bridge)
1074
+
1075
+ {
1076
+ work_ally_read_pid "$pid_file" || true
1077
+ health_pid=$(work_ally_health_pid bridge || true)
1078
+ if [ -n "$health_pid" ]; then
1079
+ printf '%s\n' "$health_pid"
1080
+ fi
1081
+ } | awk 'NF && !seen[$0]++ { print $0 }' | while IFS= read -r pid; do
1082
+ [ -n "$pid" ] || continue
1083
+ if work_ally_is_pid_alive "$pid"; then
1084
+ printf '%s\n' "$pid"
1085
+ fi
1086
+ done
1087
+ }
1088
+ work_ally_primary_bridge_pid() {
1089
+ local pid_file pid live
1090
+ pid_file=$(work_ally_pid_file bridge)
1091
+ pid=$(work_ally_read_pid "$pid_file" || true)
1092
+ if [ -n "$pid" ] && work_ally_is_pid_alive "$pid"; then
1093
+ live=$(work_ally_list_bridge_pids || true)
1094
+ if [ -z "$live" ] || printf '%s\n' "$live" | grep -qx "$pid"; then
1095
+ printf '%s\n' "$pid"
1096
+ return 0
1097
+ fi
1098
+ fi
1099
+
1100
+ work_ally_list_bridge_pids | awk 'NF { print; exit }'
1101
+ }
1102
+
1103
+ work_ally_bridge_pid_count() {
1104
+ work_ally_list_bridge_pids | awk 'NF { count += 1 } END { print count + 0 }'
1105
+ }
1106
+
1107
+ work_ally_stop_bridge_processes() {
1108
+ local pid_file
1109
+ pid_file=$(work_ally_pid_file bridge)
1110
+
1111
+ {
1112
+ work_ally_read_pid "$pid_file" || true
1113
+ work_ally_list_bridge_pids || true
1114
+ } | awk 'NF && !seen[$0]++ { print $0 }' | while IFS= read -r pid; do
1115
+ [ -n "$pid" ] || continue
1116
+ if work_ally_is_pid_alive "$pid"; then
1117
+ work_ally_terminate_pid "$pid"
1118
+ fi
1119
+ done
1120
+
1121
+ rm -f "$pid_file"
1122
+ work_ally_write_health bridge stopped '{}'
1123
+ work_ally_release_channel_lock
1124
+ if [ -n "${WORK_ALLY_ASSISTANT_NAME:-}" ]; then
1125
+ work_ally_release_assistant_lock "$WORK_ALLY_ASSISTANT_NAME"
1126
+ fi
1127
+ }
1128
+
1129
+ work_ally_process_status() {
1130
+ local name="$1"
1131
+
1132
+ if [ "$name" = "bridge" ]; then
1133
+ local bridge_pids bridge_count bridge_joined
1134
+ bridge_pids=$(work_ally_list_bridge_pids || true)
1135
+ bridge_count=$(printf '%s\n' "$bridge_pids" | awk 'NF { count += 1 } END { print count + 0 }')
1136
+ if [ "$bridge_count" -gt 1 ]; then
1137
+ bridge_joined=$(printf '%s\n' "$bridge_pids" | awk 'NF' | paste -sd ',' -)
1138
+ printf 'running (multiple: %s)\n' "$bridge_joined"
1139
+ return 0
1140
+ fi
1141
+ if [ "$bridge_count" -eq 1 ]; then
1142
+ printf 'running (%s)\n' "$(printf '%s\n' "$bridge_pids" | awk 'NF { print; exit }')"
1143
+ return 0
1144
+ fi
1145
+ fi
1146
+
1147
+ local pid_file pid
1148
+ pid_file=$(work_ally_pid_file "$name")
1149
+ if [ ! -f "$pid_file" ]; then
1150
+ if work_ally_is_external_owner "$name"; then
1151
+ printf 'running (external)\n'
1152
+ return 0
1153
+ fi
1154
+ printf 'stopped\n'
1155
+ return 0
1156
+ fi
1157
+
1158
+ pid=$(work_ally_read_pid "$pid_file" || true)
1159
+ if work_ally_is_pid_alive "$pid"; then
1160
+ printf 'running (%s)\n' "$pid"
1161
+ return 0
1162
+ fi
1163
+
1164
+ if work_ally_is_external_owner "$name"; then
1165
+ printf 'running (external)\n'
1166
+ return 0
1167
+ fi
1168
+
1169
+ printf 'stale (%s)\n' "${pid:-unknown}"
1170
+ }
1171
+
1172
+ work_ally_log_retention_days() {
1173
+ local value="${WORK_ALLY_LOG_RETENTION_DAYS:-3}"
1174
+ case "$value" in
1175
+ ''|*[!0-9]*) printf '3\n' ;;
1176
+ *)
1177
+ if [ "$value" -lt 1 ]; then
1178
+ printf '3\n'
1179
+ else
1180
+ printf '%s\n' "$value"
1181
+ fi
1182
+ ;;
1183
+ esac
1184
+ }
1185
+
1186
+
1187
+ work_ally_global_runtime_dir() {
1188
+ printf '%s\n' "${WORK_ALLY_GLOBAL_RUNTIME_DIR:-${TMPDIR:-/tmp}/work-ally-runtime}"
1189
+ }
1190
+
1191
+ work_ally_channel_lock_key() {
1192
+ local channel_impl="${WORK_ALLY_CHANNEL_IMPL:-feishu}"
1193
+ case "$channel_impl" in
1194
+ feishu)
1195
+ [ -n "${FEISHU_APP_ID:-}" ] || return 1
1196
+ printf 'feishu-%s\n' "$FEISHU_APP_ID" | tr -c 'A-Za-z0-9._-' '_'
1197
+ ;;
1198
+ *)
1199
+ return 1
1200
+ ;;
1201
+ esac
1202
+ }
1203
+
1204
+ work_ally_channel_lock_file() {
1205
+ local key
1206
+ key=$(work_ally_channel_lock_key || true)
1207
+ [ -n "$key" ] || return 1
1208
+ printf '%s/channel-locks/%s.lock\n' "$(work_ally_global_runtime_dir)" "$key"
1209
+ }
1210
+
1211
+ work_ally_channel_lock_field() {
1212
+ local file="$1"
1213
+ local key="$2"
1214
+ [ -f "$file" ] || return 1
1215
+ sed -n "s/^${key}=//p" "$file" | head -n 1
1216
+ }
1217
+
1218
+ work_ally_emit_channel_lock_content() {
1219
+ local owner_pid="$1"
1220
+ printf 'channel=%s\n' "${WORK_ALLY_CHANNEL_IMPL:-}"
1221
+ printf 'app_id=%s\n' "${FEISHU_APP_ID:-}"
1222
+ printf 'workspace_root=%s\n' "$WORK_ALLY_WORKSPACE_ROOT"
1223
+ printf 'pid=%s\n' "$owner_pid"
1224
+ printf 'updated_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1225
+ }
1226
+
1227
+ work_ally_assert_channel_available() {
1228
+ local lock_file lock_pid lock_workspace
1229
+ lock_file=$(work_ally_channel_lock_file || true)
1230
+ [ -n "$lock_file" ] || return 0
1231
+ mkdir -p "$(dirname "$lock_file")"
1232
+ [ -f "$lock_file" ] || return 0
1233
+
1234
+ lock_pid=$(work_ally_channel_lock_field "$lock_file" pid || true)
1235
+ lock_workspace=$(work_ally_channel_lock_field "$lock_file" workspace_root || true)
1236
+
1237
+ if [ -n "$lock_pid" ] && work_ally_is_pid_alive "$lock_pid"; then
1238
+ if [ -z "$lock_workspace" ] || [ "$lock_workspace" = "$WORK_ALLY_WORKSPACE_ROOT" ]; then
1239
+ return 0
1240
+ fi
1241
+ work_ally_die "Feishu app ${FEISHU_APP_ID:-unknown} is already active in workspace $lock_workspace (bridge pid $lock_pid). Stop it first or use a dedicated bot/app."
1242
+ fi
1243
+
1244
+ rm -f "$lock_file"
1245
+ }
1246
+
1247
+ work_ally_claim_channel_lock() {
1248
+ local owner_pid lock_file lock_pid lock_workspace
1249
+ owner_pid="${1:-$$}"
1250
+ lock_file=$(work_ally_channel_lock_file || true)
1251
+ [ -n "$lock_file" ] || return 0
1252
+ mkdir -p "$(dirname "$lock_file")"
1253
+
1254
+ while :; do
1255
+ if ( set -C; work_ally_emit_channel_lock_content "$owner_pid" > "$lock_file" ) 2>/dev/null; then
1256
+ return 0
1257
+ fi
1258
+
1259
+ lock_pid=$(work_ally_channel_lock_field "$lock_file" pid || true)
1260
+ lock_workspace=$(work_ally_channel_lock_field "$lock_file" workspace_root || true)
1261
+
1262
+ if [ -n "$lock_pid" ] && work_ally_is_pid_alive "$lock_pid"; then
1263
+ if [ -z "$lock_workspace" ] || [ "$lock_workspace" = "$WORK_ALLY_WORKSPACE_ROOT" ]; then
1264
+ work_ally_write_channel_lock "$owner_pid"
1265
+ return 0
1266
+ fi
1267
+ work_ally_die "Feishu app ${FEISHU_APP_ID:-unknown} is already active in workspace $lock_workspace (bridge pid $lock_pid). Stop it first or use a dedicated bot/app."
1268
+ fi
1269
+
1270
+ rm -f "$lock_file"
1271
+ done
1272
+ }
1273
+
1274
+ work_ally_write_channel_lock() {
1275
+ local bridge_pid="$1"
1276
+ local lock_file
1277
+ lock_file=$(work_ally_channel_lock_file || true)
1278
+ [ -n "$lock_file" ] || return 0
1279
+ mkdir -p "$(dirname "$lock_file")"
1280
+ work_ally_emit_channel_lock_content "$bridge_pid" > "$lock_file"
1281
+ }
1282
+
1283
+ work_ally_channel_lock_status() {
1284
+ local lock_file lock_pid lock_workspace
1285
+ lock_file=$(work_ally_channel_lock_file || true)
1286
+ [ -n "$lock_file" ] || return 0
1287
+ if [ ! -f "$lock_file" ]; then
1288
+ printf 'none\n'
1289
+ return 0
1290
+ fi
1291
+
1292
+ lock_pid=$(work_ally_channel_lock_field "$lock_file" pid || true)
1293
+ lock_workspace=$(work_ally_channel_lock_field "$lock_file" workspace_root || true)
1294
+
1295
+ if [ -n "$lock_pid" ] && work_ally_is_pid_alive "$lock_pid"; then
1296
+ if [ -n "$lock_workspace" ] && [ "$lock_workspace" = "$WORK_ALLY_WORKSPACE_ROOT" ]; then
1297
+ printf 'owned\n'
1298
+ else
1299
+ printf 'busy\n'
1300
+ fi
1301
+ return 0
1302
+ fi
1303
+
1304
+ printf 'stale\n'
1305
+ }
1306
+
1307
+ work_ally_release_channel_lock() {
1308
+ local lock_file lock_workspace lock_pid expected_pid
1309
+ expected_pid="${1:-}"
1310
+ lock_file=$(work_ally_channel_lock_file || true)
1311
+ [ -n "$lock_file" ] || return 0
1312
+ [ -f "$lock_file" ] || return 0
1313
+
1314
+ lock_workspace=$(work_ally_channel_lock_field "$lock_file" workspace_root || true)
1315
+ lock_pid=$(work_ally_channel_lock_field "$lock_file" pid || true)
1316
+
1317
+ if [ -n "$lock_workspace" ] && [ "$lock_workspace" != "$WORK_ALLY_WORKSPACE_ROOT" ]; then
1318
+ return 0
1319
+ fi
1320
+ if [ -n "$expected_pid" ] && [ -n "$lock_pid" ] && [ "$expected_pid" != "$lock_pid" ]; then
1321
+ return 0
1322
+ fi
1323
+
1324
+ rm -f "$lock_file"
1325
+ }
1326
+
1327
+ work_ally_log_file_for() {
1328
+ local name="$1"
1329
+ local stamp
1330
+ if [ -n "${WORK_ALLY_TIMEZONE:-}" ]; then
1331
+ stamp=$(TZ="$WORK_ALLY_TIMEZONE" date +%F 2>/dev/null || date +%F)
1332
+ else
1333
+ stamp=$(date +%F)
1334
+ fi
1335
+ printf '%s/%s-%s.log\n' "$WORK_ALLY_LOG_DIR" "$name" "$stamp"
1336
+ }
1337
+
1338
+ work_ally_prune_logs() {
1339
+ local retention name file now epoch threshold stamp
1340
+ retention=$(work_ally_log_retention_days)
1341
+ now=$(date +%s)
1342
+ threshold=$((retention * 24 * 60 * 60))
1343
+ for file in "$WORK_ALLY_LOG_DIR"/*.log; do
1344
+ [ -e "$file" ] || continue
1345
+ name=$(basename "$file")
1346
+ case "$name" in
1347
+ *-????-??-??.log)
1348
+ stamp=${name##*-}
1349
+ stamp=${stamp%.log}
1350
+ if epoch=$(date -j -f %F "$stamp" +%s 2>/dev/null); then
1351
+ if [ $((now - epoch)) -gt "$threshold" ]; then
1352
+ rm -f "$file"
1353
+ fi
1354
+ fi
1355
+ ;;
1356
+ esac
1357
+ done
1358
+ }
1359
+
1360
+ work_ally_tail_log() {
1361
+ local name="$1"
1362
+ local file
1363
+ file=$(work_ally_log_file_for "$name")
1364
+ mkdir -p "$WORK_ALLY_LOG_DIR"
1365
+ [ -f "$file" ] || touch "$file"
1366
+ work_ally_prune_logs
1367
+ tail -f "$file"
1368
+ }
1369
+
1370
+ work_ally_json_escape() {
1371
+ local value="${1-}"
1372
+ value=${value//\\/\\\\}
1373
+ value=${value//\"/\\\"}
1374
+ value=${value//$'\n'/\\n}
1375
+ value=${value//$'\r'/\\r}
1376
+ value=${value//$'\t'/\\t}
1377
+ printf '%s' "$value"
1378
+ }
1379
+
1380
+ work_ally_health_delivery_fields_json() {
1381
+ local file="$1"
1382
+ [ -f "$file" ] || return 0
1383
+
1384
+ local status channel kind code message guidance updated
1385
+ status=$(work_ally_json_field "$file" delivery_status)
1386
+ [ -n "$status" ] || return 0
1387
+ channel=$(work_ally_json_field "$file" delivery_channel)
1388
+ kind=$(work_ally_json_field "$file" delivery_error_kind)
1389
+ code=$(work_ally_json_field "$file" delivery_error_code)
1390
+ message=$(work_ally_json_field "$file" delivery_message)
1391
+ guidance=$(work_ally_json_field "$file" delivery_guidance)
1392
+ updated=$(work_ally_json_field "$file" delivery_updated_at)
1393
+
1394
+ printf ' "delivery_status": "%s",\n' "$(work_ally_json_escape "$status")"
1395
+ [ -n "$channel" ] && printf ' "delivery_channel": "%s",\n' "$(work_ally_json_escape "$channel")"
1396
+ [ -n "$kind" ] && printf ' "delivery_error_kind": "%s",\n' "$(work_ally_json_escape "$kind")"
1397
+ [ -n "$code" ] && printf ' "delivery_error_code": "%s",\n' "$(work_ally_json_escape "$code")"
1398
+ [ -n "$message" ] && printf ' "delivery_message": "%s",\n' "$(work_ally_json_escape "$message")"
1399
+ [ -n "$guidance" ] && printf ' "delivery_guidance": "%s",\n' "$(work_ally_json_escape "$guidance")"
1400
+ [ -n "$updated" ] && printf ' "delivery_updated_at": "%s",\n' "$(work_ally_json_escape "$updated")"
1401
+ }
1402
+
1403
+ work_ally_write_health() {
1404
+ local name="$1"
1405
+ local status="$2"
1406
+ local extra_json health_file delivery_json
1407
+ if [ "$#" -ge 3 ] && [ -n "$3" ]; then
1408
+ extra_json="$3"
1409
+ else
1410
+ extra_json='{}'
1411
+ fi
1412
+
1413
+ health_file="$(work_ally_health_file "$name")"
1414
+ delivery_json=""
1415
+ if [ "$name" = "bridge" ]; then
1416
+ delivery_json=$(work_ally_health_delivery_fields_json "$health_file")
1417
+ fi
1418
+
1419
+ {
1420
+ printf '{\n'
1421
+ printf ' "status": "%s",\n' "$status"
1422
+ printf ' "updated_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1423
+ if [ -n "$delivery_json" ]; then
1424
+ printf '%s' "$delivery_json"
1425
+ fi
1426
+ printf ' "extra": %s\n' "$extra_json"
1427
+ printf '}\n'
1428
+ } > "$health_file"
1429
+ }
1430
+
1431
+ work_ally_install_bridge_dependencies() {
1432
+ local package_dir="$WORK_ALLY_IMPLEMENTATION_DIR"
1433
+ local cache_dir="${WORK_ALLY_NPM_CACHE_DIR:-$package_dir/.npm-cache}"
1434
+ if [ -d "$package_dir/node_modules" ]; then
1435
+ return 0
1436
+ fi
1437
+ work_ally_require_cmd npm
1438
+ work_ally_note "Installing bridge dependencies"
1439
+ (
1440
+ cd "$package_dir"
1441
+ export npm_config_cache="$cache_dir"
1442
+ export NPM_CONFIG_CACHE="$cache_dir"
1443
+ if [ -f package-lock.json ]; then
1444
+ npm ci --cache "$cache_dir"
1445
+ else
1446
+ npm install --cache "$cache_dir"
1447
+ fi
1448
+ )
1449
+ work_ally_ok "Bridge dependencies installed"
1450
+ }