@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.
- package/CHANGELOG.md +169 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/bin/bitrix-skills.js +3 -0
- package/dist/cli.js +1510 -0
- package/dist/features/bx-task/install.js +111 -0
- package/dist/features/task-sync/index.js +1053 -0
- package/package.json +69 -0
- package/src/features/bx/assets/SKILL.md +34 -0
- package/src/features/bx/feature.json +8 -0
- package/src/features/bx-calendar/assets/SKILL.md +61 -0
- package/src/features/bx-calendar/assets/availability.md +65 -0
- package/src/features/bx-calendar/assets/meeting.md +87 -0
- package/src/features/bx-calendar/assets/reminder.md +71 -0
- package/src/features/bx-calendar/assets/sync.md +70 -0
- package/src/features/bx-calendar/feature.json +10 -0
- package/src/features/bx-crm/assets/SKILL.md +59 -0
- package/src/features/bx-crm/assets/commerce.md +96 -0
- package/src/features/bx-crm/assets/onboard.md +127 -0
- package/src/features/bx-crm/assets/report.md +98 -0
- package/src/features/bx-crm/assets/research.md +71 -0
- package/src/features/bx-crm/feature.json +10 -0
- package/src/features/bx-task/assets/SKILL.md +148 -0
- package/src/features/bx-task/assets/lib/bx-api.sh +39 -0
- package/src/features/bx-task/assets/lib/bx-checklist.sh +127 -0
- package/src/features/bx-task/assets/lib/bx-resolve-task.sh +41 -0
- package/src/features/bx-task/assets/lib/bx-state.sh +131 -0
- package/src/features/bx-task/assets/lib/bx-tasks.sh +109 -0
- package/src/features/bx-task/assets/references/bootstrap.md +184 -0
- package/src/features/bx-task/assets/references/feature.md +97 -0
- package/src/features/bx-task/assets/references/init-templates/cli-tool.md +47 -0
- package/src/features/bx-task/assets/references/init-templates/generic.md +31 -0
- package/src/features/bx-task/assets/references/init-templates/library.md +45 -0
- package/src/features/bx-task/assets/references/init-templates/monorepo.md +38 -0
- package/src/features/bx-task/assets/references/init-templates/npm-package.md +40 -0
- package/src/features/bx-task/assets/references/init-templates/web-app.md +46 -0
- package/src/features/bx-task/assets/references/init.md +107 -0
- package/src/features/bx-task/assets/references/roadmap.md +93 -0
- package/src/features/bx-task/assets/references/summary.md +269 -0
- package/src/features/bx-task/assets/references/sync.md +104 -0
- package/src/features/bx-task/assets/references/time-log.md +214 -0
- package/src/features/bx-task/feature.json +10 -0
- package/src/features/bx-task/install.ts +117 -0
- package/src/features/task-sync/assets/docs/bitrix-task-reference.md +318 -0
- package/src/features/task-sync/assets/docs/bitrix-task-sync.md +254 -0
- package/src/features/task-sync/assets/githooks/commit-msg +44 -0
- package/src/features/task-sync/assets/githooks/install.sh +15 -0
- package/src/features/task-sync/assets/manifest.json +108 -0
- package/src/features/task-sync/assets/rules/00-bitrix-task-sync.md +161 -0
- package/src/features/task-sync/assets/scripts/bitrix-attach-files.sh +55 -0
- package/src/features/task-sync/assets/scripts/bitrix-lib.sh +540 -0
- package/src/features/task-sync/assets/scripts/bitrix-render-digest.sh +116 -0
- package/src/features/task-sync/assets/scripts/bitrix-session-check.sh +51 -0
- package/src/features/task-sync/assets/scripts/bitrix-session-sync.sh +89 -0
- package/src/features/task-sync/assets/scripts/bitrix-skill-end.sh +165 -0
- package/src/features/task-sync/assets/scripts/bitrix-skill-start.sh +58 -0
- package/src/features/task-sync/assets/scripts/lib/bb-formatter.sh +110 -0
- package/src/features/task-sync/assets/scripts/lib/bitrix-lib.sh +540 -0
- package/src/features/task-sync/assets/scripts/lib/time-helpers.sh +57 -0
- package/src/features/task-sync/assets/workflows/bitrix-sync.yml +85 -0
- package/src/features/task-sync/commands/install.ts +296 -0
- package/src/features/task-sync/commands/uninstall.ts +189 -0
- package/src/features/task-sync/commands/update.ts +11 -0
- package/src/features/task-sync/commands/verify.ts +141 -0
- package/src/features/task-sync/feature.json +12 -0
- package/src/features/task-sync/index.ts +121 -0
- package/src/features/task-sync/lib/dest-map.ts +96 -0
- package/src/features/task-sync/lib/drift-check.ts +47 -0
- package/src/features/task-sync/lib/file-ops.ts +36 -0
- package/src/features/task-sync/lib/manifest.ts +66 -0
- package/src/features/task-sync/lib/project-root.ts +38 -0
- package/src/features/task-sync/lib/settings-merge.ts +112 -0
- package/src/features/task-sync/lib/skill-refs.ts +106 -0
- package/src/features/task-sync/lib/task-id-finder.ts +31 -0
- package/src/features/task-sync/lib/token-extractor.ts +52 -0
- package/src/features/task-sync/lib/version.ts +36 -0
- 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,116 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Pure digest renderer — accepts structured inputs, emits formatted Bitrix comment.
|
|
3
|
+
# Called by bitrix-session-sync.sh (Stop hook). Testable standalone.
|
|
4
|
+
#
|
|
5
|
+
# Usage: bitrix-render-digest.sh \
|
|
6
|
+
# --session-start <ISO> session start timestamp
|
|
7
|
+
# --skills-log <file> path to skills_log JSON array ([{skill,elapsed_seconds},...])
|
|
8
|
+
# --plan-dir <dir> path to active plan directory (optional)
|
|
9
|
+
# --token-line <text> pre-formatted token line from b24_finalize_session_tokens (optional)
|
|
10
|
+
# --attached-log <file> path to attached files JSON array (optional)
|
|
11
|
+
# --repo-root <dir> git repo root for commit log (optional)
|
|
12
|
+
#
|
|
13
|
+
# Prints formatted comment text to stdout. Exit 0 always.
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
SESSION_START=""
|
|
18
|
+
SKILLS_LOG_FILE=""
|
|
19
|
+
PLAN_DIR=""
|
|
20
|
+
TOKEN_LINE=""
|
|
21
|
+
ATTACHED_LOG_FILE=""
|
|
22
|
+
REPO_ROOT=""
|
|
23
|
+
|
|
24
|
+
while [[ $# -gt 0 ]]; do
|
|
25
|
+
case "$1" in
|
|
26
|
+
--session-start) SESSION_START="$2"; shift 2 ;;
|
|
27
|
+
--skills-log) SKILLS_LOG_FILE="$2"; shift 2 ;;
|
|
28
|
+
--plan-dir) PLAN_DIR="$2"; shift 2 ;;
|
|
29
|
+
--token-line) TOKEN_LINE="$2"; shift 2 ;;
|
|
30
|
+
--attached-log) ATTACHED_LOG_FILE="$2"; shift 2 ;;
|
|
31
|
+
--repo-root) REPO_ROOT="$2"; shift 2 ;;
|
|
32
|
+
*) shift ;;
|
|
33
|
+
esac
|
|
34
|
+
done
|
|
35
|
+
|
|
36
|
+
# ── Skills section ───────────────────────────────────────────────────────────
|
|
37
|
+
TOTAL_SECONDS=0
|
|
38
|
+
SKILLS_SUMMARY=""
|
|
39
|
+
if [[ -f "$SKILLS_LOG_FILE" ]]; then
|
|
40
|
+
TOTAL_SECONDS=$(jq '[.[].elapsed_seconds] | add // 0' "$SKILLS_LOG_FILE")
|
|
41
|
+
SKILLS_SUMMARY=$(jq -r \
|
|
42
|
+
'.[] | "- \(.skill) (\((.elapsed_seconds / 60 | floor))m)"' \
|
|
43
|
+
"$SKILLS_LOG_FILE")
|
|
44
|
+
fi
|
|
45
|
+
TOTAL_MINUTES=$(( (TOTAL_SECONDS + 59) / 60 ))
|
|
46
|
+
|
|
47
|
+
# ── Plan progress section ─────────────────────────────────────────────────────
|
|
48
|
+
PLAN_SECTION=""
|
|
49
|
+
if [[ -n "$PLAN_DIR" && -f "${PLAN_DIR}/plan.md" ]]; then
|
|
50
|
+
PLAN_TITLE=$(grep -m1 '^# Plan:' "${PLAN_DIR}/plan.md" | sed 's/^# Plan: *//' || echo "")
|
|
51
|
+
PLAN_STATUS=$(awk '/^---/{c++;next} c==1 && /^status:/{print $2;exit}' "${PLAN_DIR}/plan.md" || echo "")
|
|
52
|
+
CHECKED=0
|
|
53
|
+
TOTAL_BOXES=0
|
|
54
|
+
for f in "${PLAN_DIR}"/phase-*.md; do
|
|
55
|
+
[[ ! -f "$f" ]] && continue
|
|
56
|
+
CHECKED=$(( CHECKED + $(grep -c '^\- \[x\]' "$f" 2>/dev/null || echo 0) ))
|
|
57
|
+
TOTAL_BOXES=$(( TOTAL_BOXES + $(grep -c '^\- \[.\]' "$f" 2>/dev/null || echo 0) ))
|
|
58
|
+
done
|
|
59
|
+
if [[ -n "$PLAN_TITLE" ]]; then
|
|
60
|
+
PLAN_SECTION="📋 Plan: ${PLAN_TITLE}"
|
|
61
|
+
[[ -n "$PLAN_STATUS" ]] && PLAN_SECTION="${PLAN_SECTION} [${PLAN_STATUS}]"
|
|
62
|
+
[[ "$TOTAL_BOXES" -gt 0 ]] && \
|
|
63
|
+
PLAN_SECTION="${PLAN_SECTION} — ${CHECKED}/${TOTAL_BOXES} tasks done"
|
|
64
|
+
fi
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# ── Attached files section ────────────────────────────────────────────────────
|
|
68
|
+
FILES_SECTION=""
|
|
69
|
+
if [[ -f "$ATTACHED_LOG_FILE" ]]; then
|
|
70
|
+
FILE_COUNT=$(jq 'length' "$ATTACHED_LOG_FILE" 2>/dev/null || echo 0)
|
|
71
|
+
if [[ "$FILE_COUNT" -gt 0 ]]; then
|
|
72
|
+
FILES_SECTION="📎 Files attached (${FILE_COUNT}):"
|
|
73
|
+
while IFS= read -r fp; do
|
|
74
|
+
FILES_SECTION="${FILES_SECTION}
|
|
75
|
+
- $(basename "$fp")"
|
|
76
|
+
done < <(jq -r '.[]' "$ATTACHED_LOG_FILE" 2>/dev/null || true)
|
|
77
|
+
fi
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
# ── Commits section ───────────────────────────────────────────────────────────
|
|
81
|
+
COMMITS_SECTION=""
|
|
82
|
+
if [[ -n "$REPO_ROOT" && -n "$SESSION_START" ]]; then
|
|
83
|
+
COMMIT_LINES=$(git -C "$REPO_ROOT" log \
|
|
84
|
+
--since="$SESSION_START" --oneline --no-merges 2>/dev/null || echo "")
|
|
85
|
+
if [[ -n "$COMMIT_LINES" ]]; then
|
|
86
|
+
COMMITS_SECTION="🔀 Commits this session:
|
|
87
|
+
$(echo "$COMMIT_LINES" | sed 's/^/ /')"
|
|
88
|
+
fi
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
# ── Render ────────────────────────────────────────────────────────────────────
|
|
92
|
+
OUTPUT="[Claude] Session summary (${SESSION_START:-?})"
|
|
93
|
+
|
|
94
|
+
[[ -n "$PLAN_SECTION" ]] && OUTPUT="${OUTPUT}
|
|
95
|
+
|
|
96
|
+
${PLAN_SECTION}"
|
|
97
|
+
|
|
98
|
+
[[ -n "$SKILLS_SUMMARY" ]] && OUTPUT="${OUTPUT}
|
|
99
|
+
|
|
100
|
+
${SKILLS_SUMMARY}
|
|
101
|
+
|
|
102
|
+
Tổng thời gian: ${TOTAL_MINUTES} phút"
|
|
103
|
+
|
|
104
|
+
[[ -n "$TOKEN_LINE" ]] && OUTPUT="${OUTPUT}
|
|
105
|
+
|
|
106
|
+
${TOKEN_LINE}"
|
|
107
|
+
|
|
108
|
+
[[ -n "$FILES_SECTION" ]] && OUTPUT="${OUTPUT}
|
|
109
|
+
|
|
110
|
+
${FILES_SECTION}"
|
|
111
|
+
|
|
112
|
+
[[ -n "$COMMITS_SECTION" ]] && OUTPUT="${OUTPUT}
|
|
113
|
+
|
|
114
|
+
${COMMITS_SECTION}"
|
|
115
|
+
|
|
116
|
+
printf '%s\n' "$OUTPUT"
|