@synity/bitrix-skills 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/LICENSE +21 -0
  3. package/README.md +83 -0
  4. package/bin/bitrix-skills.js +3 -0
  5. package/dist/cli.js +1510 -0
  6. package/dist/features/bx-task/install.js +111 -0
  7. package/dist/features/task-sync/index.js +1053 -0
  8. package/package.json +69 -0
  9. package/src/features/bx/assets/SKILL.md +34 -0
  10. package/src/features/bx/feature.json +8 -0
  11. package/src/features/bx-calendar/assets/SKILL.md +61 -0
  12. package/src/features/bx-calendar/assets/availability.md +65 -0
  13. package/src/features/bx-calendar/assets/meeting.md +87 -0
  14. package/src/features/bx-calendar/assets/reminder.md +71 -0
  15. package/src/features/bx-calendar/assets/sync.md +70 -0
  16. package/src/features/bx-calendar/feature.json +10 -0
  17. package/src/features/bx-crm/assets/SKILL.md +59 -0
  18. package/src/features/bx-crm/assets/commerce.md +96 -0
  19. package/src/features/bx-crm/assets/onboard.md +127 -0
  20. package/src/features/bx-crm/assets/report.md +98 -0
  21. package/src/features/bx-crm/assets/research.md +71 -0
  22. package/src/features/bx-crm/feature.json +10 -0
  23. package/src/features/bx-task/assets/SKILL.md +148 -0
  24. package/src/features/bx-task/assets/lib/bx-api.sh +39 -0
  25. package/src/features/bx-task/assets/lib/bx-checklist.sh +127 -0
  26. package/src/features/bx-task/assets/lib/bx-resolve-task.sh +41 -0
  27. package/src/features/bx-task/assets/lib/bx-state.sh +131 -0
  28. package/src/features/bx-task/assets/lib/bx-tasks.sh +109 -0
  29. package/src/features/bx-task/assets/references/bootstrap.md +184 -0
  30. package/src/features/bx-task/assets/references/feature.md +97 -0
  31. package/src/features/bx-task/assets/references/init-templates/cli-tool.md +47 -0
  32. package/src/features/bx-task/assets/references/init-templates/generic.md +31 -0
  33. package/src/features/bx-task/assets/references/init-templates/library.md +45 -0
  34. package/src/features/bx-task/assets/references/init-templates/monorepo.md +38 -0
  35. package/src/features/bx-task/assets/references/init-templates/npm-package.md +40 -0
  36. package/src/features/bx-task/assets/references/init-templates/web-app.md +46 -0
  37. package/src/features/bx-task/assets/references/init.md +107 -0
  38. package/src/features/bx-task/assets/references/roadmap.md +93 -0
  39. package/src/features/bx-task/assets/references/summary.md +269 -0
  40. package/src/features/bx-task/assets/references/sync.md +104 -0
  41. package/src/features/bx-task/assets/references/time-log.md +214 -0
  42. package/src/features/bx-task/feature.json +10 -0
  43. package/src/features/bx-task/install.ts +117 -0
  44. package/src/features/task-sync/assets/docs/bitrix-task-reference.md +318 -0
  45. package/src/features/task-sync/assets/docs/bitrix-task-sync.md +254 -0
  46. package/src/features/task-sync/assets/githooks/commit-msg +44 -0
  47. package/src/features/task-sync/assets/githooks/install.sh +15 -0
  48. package/src/features/task-sync/assets/manifest.json +108 -0
  49. package/src/features/task-sync/assets/rules/00-bitrix-task-sync.md +161 -0
  50. package/src/features/task-sync/assets/scripts/bitrix-attach-files.sh +55 -0
  51. package/src/features/task-sync/assets/scripts/bitrix-lib.sh +540 -0
  52. package/src/features/task-sync/assets/scripts/bitrix-render-digest.sh +116 -0
  53. package/src/features/task-sync/assets/scripts/bitrix-session-check.sh +51 -0
  54. package/src/features/task-sync/assets/scripts/bitrix-session-sync.sh +89 -0
  55. package/src/features/task-sync/assets/scripts/bitrix-skill-end.sh +165 -0
  56. package/src/features/task-sync/assets/scripts/bitrix-skill-start.sh +58 -0
  57. package/src/features/task-sync/assets/scripts/lib/bb-formatter.sh +110 -0
  58. package/src/features/task-sync/assets/scripts/lib/bitrix-lib.sh +540 -0
  59. package/src/features/task-sync/assets/scripts/lib/time-helpers.sh +57 -0
  60. package/src/features/task-sync/assets/workflows/bitrix-sync.yml +85 -0
  61. package/src/features/task-sync/commands/install.ts +296 -0
  62. package/src/features/task-sync/commands/uninstall.ts +189 -0
  63. package/src/features/task-sync/commands/update.ts +11 -0
  64. package/src/features/task-sync/commands/verify.ts +141 -0
  65. package/src/features/task-sync/feature.json +12 -0
  66. package/src/features/task-sync/index.ts +121 -0
  67. package/src/features/task-sync/lib/dest-map.ts +96 -0
  68. package/src/features/task-sync/lib/drift-check.ts +47 -0
  69. package/src/features/task-sync/lib/file-ops.ts +36 -0
  70. package/src/features/task-sync/lib/manifest.ts +66 -0
  71. package/src/features/task-sync/lib/project-root.ts +38 -0
  72. package/src/features/task-sync/lib/settings-merge.ts +112 -0
  73. package/src/features/task-sync/lib/skill-refs.ts +106 -0
  74. package/src/features/task-sync/lib/task-id-finder.ts +31 -0
  75. package/src/features/task-sync/lib/token-extractor.ts +52 -0
  76. package/src/features/task-sync/lib/version.ts +36 -0
  77. package/src/features/task-sync/types.ts +40 -0
@@ -0,0 +1,540 @@
1
+ #!/bin/bash
2
+ # Shared Bitrix REST helpers for Claude Code hooks
3
+ # Requires: bash, awk, curl, jq (POSIX awk — works on macOS BSD, Linux GNU, Windows Git Bash/WSL)
4
+ # Env: BITRIX_WEBHOOK_URL (e.g. https://xxx.bitrix24.com/rest/1/token123/)
5
+ #
6
+ # API notes (MCP-DERIVED: bitrix-dev-mcp @ 2026-05-09; verified on portal 106 @ 2026-05-10):
7
+ # - tasks.task.chat.message.send → REST v3, requires /rest/api/<user>/<token>/ URL.
8
+ # b24_call auto-rewrites URL when method matches v3 pattern.
9
+ # Bitrix renders task chat with BB code, NOT markdown.
10
+ # Use [b]bold[/b], [br], [disk=ID], [user=ID], [code]…[/code].
11
+ # - tasks.task.startTimer/pauseTimer → REST v2. NOT used: noisy (one system msg per skill).
12
+ # Replaced by single retroactive task.elapseditem.add at Stop.
13
+ # - task.elapseditem.add (legacy) → WORKS on this portal. ARFIELDS:{SECONDS,COMMENT_TEXT}.
14
+ # v3 tasks.task.elapsedtime.add returns error 100 — DO NOT USE.
15
+ # - task.commentitem.add → posts to same dialog as chat (single channel on portal 106).
16
+ # Switching API does NOT separate channels — reduce noise instead.
17
+ # - batch → up to 50 cmds, supports $result[cmd] chaining
18
+
19
+ # Cross-platform tmp dir (TMPDIR set on macOS/Windows, fallback /tmp on Linux)
20
+ SESSION_DIR="${TMPDIR:-/tmp}/claude-b24"
21
+ SESSION_DIR="${SESSION_DIR%/}"
22
+ mkdir -p "$SESSION_DIR"
23
+
24
+ # Preflight: warn once per session if required tools missing.
25
+ # Hooks must NOT block the user — print to stderr and continue silently.
26
+ _b24_preflight_done=0
27
+ _b24_preflight() {
28
+ [[ "$_b24_preflight_done" == "1" ]] && return 0
29
+ _b24_preflight_done=1
30
+ local missing=""
31
+ for tool in awk jq curl; do
32
+ command -v "$tool" >/dev/null 2>&1 || missing="${missing} ${tool}"
33
+ done
34
+ if [[ -n "$missing" ]]; then
35
+ echo "[bitrix-lib] missing required tools:${missing}. Bitrix sync disabled. Install via brew/apt/choco." >&2
36
+ return 1
37
+ fi
38
+ return 0
39
+ }
40
+
41
+ # Read field value from CLAUDE.md (key = TASK_ID|CREATOR_ID|RESPONSIBLE_ID|PORTAL).
42
+ # Format: "KEY: value [# optional comment]" — prints first whitespace-delimited token after "KEY:".
43
+ # Uses awk (POSIX) so it works on macOS BSD where grep -P is unavailable.
44
+ _b24_md_field() {
45
+ local file="$1"
46
+ local key="$2"
47
+ awk -v k="^${key}:" '$0 ~ k { print $2; exit }' "$file" 2>/dev/null
48
+ }
49
+
50
+ # Walk up from given dir, return first TASK_ID found in CLAUDE.md
51
+ find_task_id() {
52
+ local dir="${1:-$(pwd)}"
53
+ while [[ "$dir" != "/" ]]; do
54
+ local md="$dir/CLAUDE.md"
55
+ if [[ -f "$md" ]]; then
56
+ local id
57
+ id=$(_b24_md_field "$md" "TASK_ID")
58
+ if [[ -n "$id" ]]; then
59
+ echo "$id"
60
+ return 0
61
+ fi
62
+ fi
63
+ dir=$(dirname "$dir")
64
+ done
65
+ return 1
66
+ }
67
+
68
+ # Walk up from given dir, find first CLAUDE.md with TASK_ID, parse all task meta.
69
+ # Output: JSON {task_id, creator_id, responsible_id, portal} — empty string for missing fields.
70
+ # Returns 1 if no TASK_ID found.
71
+ find_task_meta() {
72
+ local dir="${1:-$(pwd)}"
73
+ while [[ "$dir" != "/" ]]; do
74
+ local md="$dir/CLAUDE.md"
75
+ if [[ -f "$md" ]]; then
76
+ local task_id
77
+ task_id=$(_b24_md_field "$md" "TASK_ID")
78
+ if [[ -n "$task_id" ]]; then
79
+ local creator_id responsible_id portal
80
+ creator_id=$(_b24_md_field "$md" "CREATOR_ID")
81
+ responsible_id=$(_b24_md_field "$md" "RESPONSIBLE_ID")
82
+ portal=$(_b24_md_field "$md" "PORTAL")
83
+ jq -n \
84
+ --arg tid "$task_id" \
85
+ --arg cid "$creator_id" \
86
+ --arg rid "$responsible_id" \
87
+ --arg portal "$portal" \
88
+ '{task_id: $tid, creator_id: $cid, responsible_id: $rid, portal: $portal}'
89
+ return 0
90
+ fi
91
+ fi
92
+ dir=$(dirname "$dir")
93
+ done
94
+ return 1
95
+ }
96
+
97
+ # POST JSON to Bitrix REST webhook; prints response JSON to stdout, silent on transport error.
98
+ # Auto-rewrites URL to /rest/api/ when method is v3 (tasks.task.chat.*).
99
+ b24_call() {
100
+ local method="$1"
101
+ local body="${2-}"
102
+ [[ -z "$body" ]] && body='{}'
103
+ [[ -z "${BITRIX_WEBHOOK_URL:-}" ]] && return 1
104
+ local url="${BITRIX_WEBHOOK_URL%/}"
105
+ case "$method" in
106
+ tasks.task.chat.*)
107
+ [[ "$url" != *"/rest/api/"* ]] && url=$(echo "$url" | sed 's|/rest/|/rest/api/|')
108
+ ;;
109
+ esac
110
+ curl -sS -m 10 -X POST "${url}/${method}" \
111
+ -H "Content-Type: application/json" \
112
+ -d "$body" 2>/dev/null
113
+ }
114
+
115
+ # Execute batch of up to 50 cmds; supports $result[cmd] chaining
116
+ # Usage: b24_batch <json_cmd_object> [halt=1]
117
+ b24_batch() {
118
+ local cmds="$1"
119
+ local halt="${2:-1}"
120
+ b24_call "batch" "$(jq -n --argjson cmd "$cmds" --argjson h "$halt" '{halt:$h,cmd:$cmd}')"
121
+ }
122
+
123
+ # Post message to Bitrix task chat (tasks.task.chat.message.send)
124
+ b24_comment() {
125
+ local task_id="$1"
126
+ local message="$2"
127
+ b24_call "tasks.task.chat.message.send" \
128
+ "$(jq -n --argjson id "$task_id" --arg txt "$message" \
129
+ '{fields:{taskId:$id,text:$txt}}')"
130
+ }
131
+
132
+ # Start native timer on task. KEPT for backward compat — NOT used by current hooks.
133
+ # tasks.task.startTimer / pauseTimer creates one system message per skill = noisy.
134
+ # Hooks now use retroactive b24_log_elapsed_time at Stop instead.
135
+ b24_timer_start() {
136
+ local task_id="$1"
137
+ b24_call "tasks.task.startTimer" \
138
+ "$(jq -n --argjson id "$task_id" '{taskId:$id}')" >/dev/null || true
139
+ }
140
+
141
+ b24_timer_stop() {
142
+ local task_id="$1"
143
+ b24_call "tasks.task.pauseTimer" \
144
+ "$(jq -n --argjson id "$task_id" '{taskId:$id}')" >/dev/null || true
145
+ }
146
+
147
+ # Add retroactive elapsed time entry to task (legacy task.elapseditem.add — v3 broken on portal).
148
+ # Updates timeSpentInLogs cumulatively. Bitrix system message format:
149
+ # "<user> activated time tracking. Duration: N seconds."
150
+ # b24_log_elapsed_time <task_id> <seconds> [<comment>]
151
+ b24_log_elapsed_time() {
152
+ local task_id="$1"
153
+ local seconds="${2:-0}"
154
+ local comment="${3:-AI session}"
155
+ [[ "$seconds" -le 0 ]] && return 0
156
+ b24_call "task.elapseditem.add" \
157
+ "$(jq -n \
158
+ --argjson tid "$task_id" \
159
+ --argjson sec "$seconds" \
160
+ --arg txt "$comment" \
161
+ '{TASKID:$tid,ARFIELDS:{SECONDS:$sec,COMMENT_TEXT:$txt}}')" >/dev/null || true
162
+ }
163
+
164
+ # Format duration in seconds → "Xs" (<60s) or "Xm Ys" / "Xm" (≥60s, no rounding inflation).
165
+ # Examples: 30 → "30s"; 90 → "1m 30s"; 312 → "5m 12s"; 600 → "10m"
166
+ _format_duration() {
167
+ local sec="${1:-0}"
168
+ [[ ! "$sec" =~ ^[0-9]+$ ]] && sec=0
169
+ if [[ "$sec" -lt 60 ]]; then
170
+ printf '%ds' "$sec"
171
+ else
172
+ local m=$((sec / 60))
173
+ local s=$((sec % 60))
174
+ if [[ "$s" -eq 0 ]]; then
175
+ printf '%dm' "$m"
176
+ else
177
+ printf '%dm %ds' "$m" "$s"
178
+ fi
179
+ fi
180
+ }
181
+
182
+ # Sync plan phases as named checklists via batch API (max 50 cmds total)
183
+ # Usage: b24_sync_checklist <task_id> <phases_json>
184
+ # phases_json: '[{"name":"Phase 1","items":["item a","item b"]},...]'
185
+ b24_sync_checklist() {
186
+ local task_id="$1"
187
+ local phases_json="$2"
188
+ local cmd='{}' i=0
189
+ local phase_count
190
+ phase_count=$(echo "$phases_json" | jq 'length')
191
+
192
+ for ((i=0; i<phase_count; i++)); do
193
+ local cl_key="cl_${i}"
194
+ local phase_name encoded_name
195
+ phase_name=$(echo "$phases_json" | jq -r ".[$i].name")
196
+ encoded_name=$(printf '%s' "$phase_name" | jq -Rr '@uri')
197
+
198
+ # PARENT_ID=0 → create new named checklist
199
+ cmd=$(echo "$cmd" | jq \
200
+ --arg k "$cl_key" \
201
+ --arg v "task.checklistitem.add?TASKID=${task_id}&FIELDS[TITLE]=${encoded_name}&FIELDS[PARENT_ID]=0" \
202
+ '. + {($k): $v}')
203
+
204
+ local item_count j
205
+ item_count=$(echo "$phases_json" | jq ".[$i].items | length")
206
+
207
+ for ((j=0; j<item_count; j++)); do
208
+ local item_key encoded_item
209
+ # \$result → literal $result in the batch cmd string (not bash expansion)
210
+ encoded_item=$(echo "$phases_json" | jq -r ".[$i].items[$j]" | jq -Rr '@uri')
211
+ item_key="item_${i}_${j}"
212
+
213
+ cmd=$(echo "$cmd" | jq \
214
+ --arg k "$item_key" \
215
+ --arg v "task.checklistitem.add?TASKID=${task_id}&FIELDS[TITLE]=${encoded_item}&FIELDS[PARENT_ID]=\$result[${cl_key}]" \
216
+ '. + {($k): $v}')
217
+ done
218
+ done
219
+
220
+ local total
221
+ total=$(echo "$cmd" | jq 'length')
222
+ if [[ "$total" -gt 50 ]]; then
223
+ echo "Warning: $total cmds exceeds batch limit of 50. Sync skipped." >&2
224
+ return 1
225
+ fi
226
+
227
+ b24_batch "$cmd"
228
+ }
229
+
230
+ # Map skill name to emoji
231
+ # Return 0 (true) if text has human-readable content worth posting to Bitrix chat.
232
+ # Skips: empty, pure JSON object/array, strings shorter than 20 non-whitespace chars.
233
+ _has_useful_content() {
234
+ local text="${1:-}"
235
+ [[ -z "$text" ]] && return 1
236
+ # Pure JSON object or array → skip (e.g. {"success":true} or [])
237
+ local first="${text:0:1}"
238
+ if [[ "$first" == "{" || "$first" == "[" ]]; then
239
+ echo "$text" | jq -e 'type == "object" or type == "array"' >/dev/null 2>&1 && return 1
240
+ fi
241
+ # Too short after stripping whitespace → skip
242
+ local trimmed="${text//[[:space:]]/}"
243
+ [[ ${#trimmed} -lt 20 ]] && return 1
244
+ return 0
245
+ }
246
+
247
+ skill_emoji() {
248
+ case "$1" in
249
+ brainstorm) echo "🧠" ;;
250
+ research) echo "🔍" ;;
251
+ cook) echo "👨‍🍳" ;;
252
+ debug) echo "🐛" ;;
253
+ fix) echo "🔧" ;;
254
+ review) echo "👀" ;;
255
+ plan|planner) echo "📋" ;;
256
+ test|tester) echo "🧪" ;;
257
+ security-review) echo "🔒" ;;
258
+ *) echo "⚡" ;;
259
+ esac
260
+ }
261
+
262
+ # ── Phase 2: Time tracking lifecycle ────────────────────────────────────────
263
+
264
+ # Atomic skill counter (flock-based) so overlapping PreToolUse calls are safe.
265
+ _skill_counter_path() { echo "$SESSION_DIR/skill-counter-${1}"; }
266
+
267
+ _skill_counter_incr() {
268
+ local f; f=$(_skill_counter_path "$1")
269
+ (
270
+ flock -x 200
271
+ local cur=0
272
+ [[ -f "$f" ]] && cur=$(cat "$f")
273
+ cur=$((cur + 1))
274
+ echo "$cur" > "$f"
275
+ echo "$cur"
276
+ ) 200>"${f}.lock"
277
+ }
278
+
279
+ _skill_counter_decr() {
280
+ local f; f=$(_skill_counter_path "$1")
281
+ (
282
+ flock -x 200
283
+ local cur=0
284
+ [[ -f "$f" ]] && cur=$(cat "$f")
285
+ cur=$((cur - 1)); [[ "$cur" -lt 0 ]] && cur=0
286
+ echo "$cur" > "$f"
287
+ echo "$cur"
288
+ ) 200>"${f}.lock"
289
+ }
290
+
291
+ # Return current task status (string: "1"=new "2"=waiting "3"=in_progress "5"=closed)
292
+ b24_task_status() {
293
+ local task_id="$1"
294
+ b24_call "tasks.task.get" \
295
+ "$(jq -n --argjson id "$task_id" '{taskId:$id,select:["STATUS"]}')" \
296
+ | jq -r '.result.task.status // empty'
297
+ }
298
+
299
+ # Ensure allowTimeTracking='Y'. Silently enable — no chat audit (system noise).
300
+ b24_ensure_timetracking() {
301
+ local task_id="$1"
302
+ local get_resp allow_flag
303
+ get_resp=$(b24_call "tasks.task.get" \
304
+ "$(jq -n --argjson id "$task_id" '{taskId:$id,select:["ALLOW_TIME_TRACKING"]}')")
305
+ allow_flag=$(echo "$get_resp" | jq -r '.result.task.allowTimeTracking // "N"')
306
+ [[ "$allow_flag" == "Y" ]] && return 0
307
+
308
+ b24_call "tasks.task.update" \
309
+ "$(jq -n --argjson id "$task_id" '{taskId:$id,fields:{ALLOW_TIME_TRACKING:"Y"}}')" >/dev/null || true
310
+ }
311
+
312
+ # ── Phase 1: Token tracking ──────────────────────────────────────────────────
313
+
314
+ # One-time: ensure UF_AI_TOKENS_TOTAL user field exists on TASKS_TASK entity.
315
+ b24_ensure_uf() {
316
+ local result err
317
+ result=$(b24_call "task.item.userfield.add" \
318
+ "$(jq -n '{PARAMS:{USER_TYPE_ID:"double",FIELD_NAME:"UF_AI_TOKENS_TOTAL",
319
+ XML_ID:"UF_AI_TOKENS_TOTAL",LABEL:"AI Tokens (cumulative)",
320
+ SORT:500,MULTIPLE:"N",MANDATORY:"N",SETTINGS:{DEFAULT_VALUE:0,PRECISION:0}}}')")
321
+ err=$(echo "$result" | jq -r '.error // empty')
322
+ # "ERROR_CORE" = already exists → treat as success
323
+ [[ -n "$err" && "$err" != "ERROR_CORE" ]] && \
324
+ echo "[bitrix-lib] b24_ensure_uf warning: $err" >&2
325
+ return 0
326
+ }
327
+
328
+ # Read current UF_AI_TOKENS_TOTAL value from task (returns integer).
329
+ b24_task_get_uf_tokens() {
330
+ local task_id="$1"
331
+ local val
332
+ val=$(b24_call "tasks.task.get" \
333
+ "$(jq -n --argjson id "$task_id" '{taskId:$id,select:["UF_AI_TOKENS_TOTAL"]}')" \
334
+ | jq -r '.result.task.ufAiTokensTotal // "0"')
335
+ # Coerce string → integer (Bitrix v3 returns camelCase string values)
336
+ echo "${val%%.*}" | tr -cd '0-9' || echo "0"
337
+ }
338
+
339
+ # Write UF_AI_TOKENS_TOTAL value to task.
340
+ b24_task_set_uf_tokens() {
341
+ local task_id="$1"
342
+ local total="$2"
343
+ b24_call "tasks.task.update" \
344
+ "$(jq -n --argjson id "$task_id" --argjson val "$total" \
345
+ '{taskId:$id,fields:{UF_AI_TOKENS_TOTAL:$val}}')" >/dev/null || true
346
+ }
347
+
348
+ # Parse session tokens from transcript JSONL; returns JSON {input,output,cacheRead,cacheCreation}.
349
+ _compute_session_tokens() {
350
+ local transcript="$1"
351
+ [[ ! -f "$transcript" ]] && echo '{"input":0,"output":0,"cacheRead":0,"cacheCreation":0}' && return
352
+ jq -s '
353
+ [ .[] | select(.type == "assistant") | .message.usage // empty |
354
+ { i: (.input_tokens // 0),
355
+ o: (.output_tokens // 0),
356
+ cr: (.cache_read_input_tokens // 0),
357
+ cc: (.cache_creation_input_tokens // 0) }
358
+ ] |
359
+ reduce .[] as $u ({input:0,output:0,cacheRead:0,cacheCreation:0};
360
+ .input += $u.i | .output += $u.o | .cacheRead += $u.cr | .cacheCreation += $u.cc
361
+ )
362
+ ' "$transcript" 2>/dev/null || echo '{"input":0,"output":0,"cacheRead":0,"cacheCreation":0}'
363
+ }
364
+
365
+ # Read transcript, accumulate UF, return formatted summary line.
366
+ # b24_finalize_session_tokens <task_id> <transcript_path>
367
+ b24_finalize_session_tokens() {
368
+ local task_id="$1"
369
+ local transcript="$2"
370
+ local session input output cache_read new_total current
371
+
372
+ session=$(_compute_session_tokens "$transcript")
373
+ input=$(echo "$session" | jq -r '.input // 0')
374
+ output=$(echo "$session" | jq -r '.output // 0')
375
+ cache_read=$(echo "$session" | jq -r '.cacheRead // 0')
376
+
377
+ local session_total=$((input + output))
378
+ if [[ "$session_total" -le 0 ]]; then
379
+ echo "[bts] token probe found 0 tokens (transcript=${transcript}) — UF skipped" >&2
380
+ return 0
381
+ fi
382
+
383
+ current=$(b24_task_get_uf_tokens "$task_id")
384
+ new_total=$((${current:-0} + session_total))
385
+ b24_task_set_uf_tokens "$task_id" "$new_total"
386
+
387
+ local inp_k out_k cr_k
388
+ inp_k=$(awk "BEGIN{printf \"%.1f\", $input/1000}")
389
+ out_k=$(awk "BEGIN{printf \"%.1f\", $output/1000}")
390
+ cr_k=$(awk "BEGIN{printf \"%.1f\", $cache_read/1000}")
391
+ printf '🪙 %sk in / %sk out / %sk cached | task total: %s tokens' \
392
+ "$inp_k" "$out_k" "$cr_k" "$new_total"
393
+ }
394
+
395
+ # ── Phase 3: File attach (drive) ─────────────────────────────────────────────
396
+
397
+ # Resolve group drive root folder for a task; caches in session tmp.
398
+ # Returns ROOT_OBJECT_ID. Falls back to user drive + warns.
399
+ # b24_disk_get_root_folder <task_id> <session_id>
400
+ b24_disk_get_root_folder() {
401
+ local task_id="$1"
402
+ local session_id="${2:-default}"
403
+ local cache_file="$SESSION_DIR/disk-root-${session_id}"
404
+
405
+ if [[ -f "$cache_file" ]]; then
406
+ cat "$cache_file"
407
+ return 0
408
+ fi
409
+
410
+ # Get groupId from task
411
+ local group_id
412
+ group_id=$(b24_call "tasks.task.get" \
413
+ "$(jq -n --argjson id "$task_id" '{taskId:$id,select:["GROUP_ID"]}')" \
414
+ | jq -r '.result.task.groupId // empty')
415
+
416
+ local resp folder_id
417
+ if [[ -n "$group_id" && "$group_id" != "0" ]]; then
418
+ resp=$(b24_call "disk.storage.getlist" \
419
+ "$(jq -n --argjson eid "$group_id" '{filter:{ENTITY_TYPE:"group",ENTITY_ID:$eid}}')")
420
+ folder_id=$(echo "$resp" | jq -r '.result[0].ROOT_OBJECT_ID // empty')
421
+ fi
422
+
423
+ if [[ -z "$folder_id" ]]; then
424
+ # Fallback: user personal drive (warn to stderr, not chat)
425
+ resp=$(b24_call "disk.storage.getlist" '{"filter":{"ENTITY_TYPE":"user"}}')
426
+ folder_id=$(echo "$resp" | jq -r '.result[0].ROOT_OBJECT_ID // empty')
427
+ echo "[bts] no group drive — using user personal drive" >&2
428
+ fi
429
+
430
+ [[ -z "$folder_id" ]] && return 1
431
+ echo "$folder_id" > "$cache_file"
432
+ echo "$folder_id"
433
+ }
434
+
435
+ # Ensure named subfolder exists under parent; returns folder ID.
436
+ # b24_disk_ensure_folder <parent_folder_id> <name>
437
+ b24_disk_ensure_folder() {
438
+ local parent_id="$1"
439
+ local name="$2"
440
+
441
+ local children_resp existing_id
442
+ children_resp=$(b24_call "disk.folder.getchildren" \
443
+ "$(jq -n --argjson pid "$parent_id" '{id:$pid,filter:{TYPE:"folder"}}')")
444
+ existing_id=$(echo "$children_resp" | jq -r \
445
+ --arg n "$name" '.result[] | select(.NAME == $n) | .ID' | head -1)
446
+
447
+ if [[ -n "$existing_id" ]]; then
448
+ echo "$existing_id"
449
+ return 0
450
+ fi
451
+
452
+ local new_resp
453
+ new_resp=$(b24_call "disk.folder.addsubfolder" \
454
+ "$(jq -n --argjson pid "$parent_id" --arg n "$name" '{id:$pid,data:{NAME:$n}}')")
455
+ echo "$new_resp" | jq -r '.result.ID // empty'
456
+ }
457
+
458
+ # Upload file to drive folder; returns drive object ID.
459
+ # b24_disk_upload <folder_id> <file_path>
460
+ b24_disk_upload() {
461
+ local folder_id="$1"
462
+ local file_path="$2"
463
+ local file_name; file_name=$(basename "$file_path")
464
+ local b64; b64=$(base64 -i "$file_path" 2>/dev/null || base64 "$file_path")
465
+ b64=$(echo "$b64" | tr -d '\n')
466
+
467
+ b24_call "disk.folder.uploadfile" \
468
+ "$(jq -n \
469
+ --argjson fid "$folder_id" \
470
+ --arg name "$file_name" \
471
+ --arg content "$b64" \
472
+ '{id:$fid,data:{NAME:$name},fileContent:[$name,$content],generateUniqueName:true}')" \
473
+ | jq -r '.result.ID // empty'
474
+ }
475
+
476
+ # Attach drive file to task (legacy method — v3 tasks.task.file.attach broken on portal).
477
+ # b24_task_attach_file <task_id> <drive_file_id>
478
+ # Echoes attachmentId on success, empty on failure.
479
+ b24_task_attach_file() {
480
+ local task_id="$1"
481
+ local file_id="$2"
482
+ local resp
483
+ resp=$(b24_call "tasks.task.files.attach" \
484
+ "$(jq -n --argjson tid "$task_id" --argjson fid "$file_id" \
485
+ '{taskId:$tid,fileId:$fid}')")
486
+ echo "$resp" | jq -r '.result.attachmentId // empty'
487
+ }
488
+
489
+ # High-level: resolve group drive root, build folder path AI Sessions/<repo>/<rel_path>/,
490
+ # upload and attach file. Returns drive file ID or empty on failure.
491
+ # b24_attach_artifact <task_id> <file_path> <session_id> [<rel_dir>]
492
+ b24_attach_artifact() {
493
+ local task_id="$1"
494
+ local file_path="$2"
495
+ local session_id="${3:-default}"
496
+ local rel_dir="${4:-}"
497
+
498
+ [[ ! -f "$file_path" ]] && return 1
499
+
500
+ local root_folder_id
501
+ root_folder_id=$(b24_disk_get_root_folder "$task_id" "$session_id") || return 1
502
+
503
+ # Ensure AI Sessions/ folder
504
+ local ai_folder_id
505
+ ai_folder_id=$(b24_disk_ensure_folder "$root_folder_id" "AI Sessions") || return 1
506
+
507
+ # Repo name from git — use git-common-dir to resolve correctly inside worktrees.
508
+ # Normal repo: --git-common-dir = ".git" (relative) → use --show-toplevel
509
+ # Worktree: --git-common-dir = "/abs/path/to/main/.git" → dirname gives real root
510
+ local repo_name _git_common
511
+ _git_common=$(git rev-parse --git-common-dir 2>/dev/null)
512
+ if [[ "${_git_common:0:1}" == "/" ]]; then
513
+ repo_name=$(basename "$(dirname "$_git_common")" 2>/dev/null || echo "repo")
514
+ else
515
+ repo_name=$(git rev-parse --show-toplevel 2>/dev/null | xargs basename 2>/dev/null || echo "repo")
516
+ fi
517
+ local repo_folder_id
518
+ repo_folder_id=$(b24_disk_ensure_folder "$ai_folder_id" "$repo_name") || return 1
519
+
520
+ # Optional sub-directory (e.g. plan dir)
521
+ local target_folder_id="$repo_folder_id"
522
+ if [[ -n "$rel_dir" ]]; then
523
+ # Walk each segment of rel_dir
524
+ local segment
525
+ IFS='/' read -ra segs <<< "$rel_dir"
526
+ for segment in "${segs[@]}"; do
527
+ [[ -z "$segment" ]] && continue
528
+ target_folder_id=$(b24_disk_ensure_folder "$target_folder_id" "$segment") || return 1
529
+ done
530
+ fi
531
+
532
+ local drive_file_id
533
+ drive_file_id=$(b24_disk_upload "$target_folder_id" "$file_path") || return 1
534
+ [[ -z "$drive_file_id" ]] && return 1
535
+
536
+ local attach_id
537
+ attach_id=$(b24_task_attach_file "$task_id" "$drive_file_id") || true
538
+ [[ -z "$attach_id" ]] && echo "[bts] warn: task attach failed for drive_id=$drive_file_id" >&2
539
+ echo "$drive_file_id"
540
+ }
@@ -0,0 +1,57 @@
1
+ #!/bin/bash
2
+ # Duration formatting helpers — standalone, no external dependencies.
3
+ # Ported from _format_duration in bitrix-lib.sh.
4
+ #
5
+ # Can be sourced directly or used standalone.
6
+ #
7
+ # Usage:
8
+ # source "$(dirname "$0")/time-helpers.sh"
9
+ # _format_duration 312 # → "5m 12s"
10
+ # _parse_duration "5m 12s" # → 312
11
+
12
+ # Format duration in seconds → "Xs" (<60s) or "Xm Ys" / "Xm" (>=60s).
13
+ # No rounding inflation — exact integer arithmetic.
14
+ #
15
+ # Examples:
16
+ # 30 → "30s"
17
+ # 90 → "1m 30s"
18
+ # 312 → "5m 12s"
19
+ # 600 → "10m"
20
+ _format_duration() {
21
+ local sec="${1:-0}"
22
+ [[ ! "$sec" =~ ^[0-9]+$ ]] && sec=0
23
+ if [[ "$sec" -lt 60 ]]; then
24
+ printf '%ds' "$sec"
25
+ else
26
+ local m=$(( sec / 60 ))
27
+ local s=$(( sec % 60 ))
28
+ if [[ "$s" -eq 0 ]]; then
29
+ printf '%dm' "$m"
30
+ else
31
+ printf '%dm %ds' "$m" "$s"
32
+ fi
33
+ fi
34
+ }
35
+
36
+ # Parse a human-readable duration string back to seconds.
37
+ # Accepts: "30s", "5m", "5m 12s"
38
+ # Returns 0 for unrecognised input.
39
+ _parse_duration() {
40
+ local str="${1:-}"
41
+ # "Xm Ys"
42
+ if [[ "$str" =~ ^([0-9]+)m[[:space:]]+([0-9]+)s$ ]]; then
43
+ echo $(( BASH_REMATCH[1] * 60 + BASH_REMATCH[2] ))
44
+ return 0
45
+ fi
46
+ # "Xm"
47
+ if [[ "$str" =~ ^([0-9]+)m$ ]]; then
48
+ echo $(( BASH_REMATCH[1] * 60 ))
49
+ return 0
50
+ fi
51
+ # "Xs"
52
+ if [[ "$str" =~ ^([0-9]+)s$ ]]; then
53
+ echo "${BASH_REMATCH[1]}"
54
+ return 0
55
+ fi
56
+ echo 0
57
+ }
@@ -0,0 +1,85 @@
1
+ name: Bitrix Task Sync
2
+
3
+ on:
4
+ pull_request:
5
+ types: [closed]
6
+ branches: [main, develop]
7
+
8
+ concurrency:
9
+ group: bitrix-sync-${{ github.event.pull_request.number }}
10
+ cancel-in-progress: false
11
+
12
+ jobs:
13
+ preflight:
14
+ if: github.event.pull_request.merged == true
15
+ runs-on: ubuntu-latest
16
+ timeout-minutes: 1
17
+ steps:
18
+ - name: Verify BITRIX_WEBHOOK_URL secret
19
+ env:
20
+ WEBHOOK: ${{ secrets.BITRIX_WEBHOOK_URL }}
21
+ run: |
22
+ if [[ -z "$WEBHOOK" ]]; then
23
+ echo "::error::BITRIX_WEBHOOK_URL secret not set. Add it via Settings → Secrets and variables → Actions."
24
+ exit 1
25
+ fi
26
+ if ! [[ "$WEBHOOK" =~ ^https://[^/]+\.bitrix24\.[a-z.]+/rest/[0-9]+/[a-z0-9]+/?$ ]]; then
27
+ echo "::error::BITRIX_WEBHOOK_URL format invalid (expected https://<portal>.bitrix24.<tld>/rest/<user_id>/<token>/)"
28
+ exit 1
29
+ fi
30
+ echo "✓ Webhook URL format OK"
31
+
32
+ notify-bitrix:
33
+ needs: preflight
34
+ if: github.event.pull_request.merged == true
35
+ runs-on: ubuntu-latest
36
+ timeout-minutes: 2
37
+ steps:
38
+ - name: Extract task ID
39
+ id: extract
40
+ env:
41
+ BRANCH: ${{ github.event.pull_request.head.ref }}
42
+ PR_BODY: ${{ github.event.pull_request.body }}
43
+ run: |
44
+ # Priority 1: [B24:ID] in commit message or PR body
45
+ TASK_ID=$(echo "$PR_BODY" | grep -oP '(?<=\[B24:)\d+(?=\])' | head -1)
46
+
47
+ # Priority 2: task ID from branch name (feature/{id}-slug)
48
+ if [[ -z "$TASK_ID" && "$BRANCH" =~ ^(feature|fix)/([0-9]+)- ]]; then
49
+ TASK_ID="${BASH_REMATCH[2]}"
50
+ fi
51
+
52
+ if [[ -z "$TASK_ID" ]]; then
53
+ echo "No task ID found — skipping"
54
+ echo "skip=true" >> $GITHUB_OUTPUT
55
+ else
56
+ echo "task_id=$TASK_ID" >> $GITHUB_OUTPUT
57
+ echo "branch=$BRANCH" >> $GITHUB_OUTPUT
58
+ fi
59
+
60
+ - name: Post to Bitrix task chat
61
+ if: steps.extract.outputs.skip != 'true'
62
+ env:
63
+ BITRIX_WEBHOOK_URL: ${{ secrets.BITRIX_WEBHOOK_URL }}
64
+ PR_TITLE: ${{ github.event.pull_request.title }}
65
+ PR_AUTHOR: ${{ github.event.pull_request.user.login }}
66
+ PR_URL: ${{ github.event.pull_request.html_url }}
67
+ run: |
68
+ TASK_ID="${{ steps.extract.outputs.task_id }}"
69
+ BRANCH="${{ steps.extract.outputs.branch }}"
70
+ TARGET="${{ github.event.pull_request.base.ref }}"
71
+ PR_NUM="${{ github.event.pull_request.number }}"
72
+
73
+ ENV_LABEL=$([[ "$TARGET" == "main" ]] && echo "[PROD]" || echo "[DEV]")
74
+
75
+ MSG=$(printf "[B]%s ✓ Merged PR #%s:[/B] %s\n[I]by %s | %s → %s[/I]\n[URL=%s]View on GitHub[/URL]" \
76
+ "$ENV_LABEL" "$PR_NUM" "$PR_TITLE" "$PR_AUTHOR" "$BRANCH" "$TARGET" "$PR_URL")
77
+
78
+ BASE_URL="${BITRIX_WEBHOOK_URL%/}"
79
+ [[ "$BASE_URL" != *"/rest/api/"* ]] && BASE_URL=$(echo "$BASE_URL" | sed 's|/rest/|/rest/api/|')
80
+
81
+ curl -sS -X POST "${BASE_URL}/tasks.task.chat.message.send" \
82
+ -H "Content-Type: application/json" \
83
+ -d "$(jq -n --arg id "$TASK_ID" --arg msg "$MSG" \
84
+ '{fields:{taskId:($id|tonumber),text:$msg}}')" \
85
+ --max-time 15 || echo "Warning: Bitrix notification failed (non-blocking)"