Repository navigation
Expand file tree
/
Copy pathasana-get-context.sh
More file actions
executable file
·395 lines (369 loc) · 15.9 KB
/
Copy pathasana-get-context.sh
File metadata and controls
executable file
·395 lines (369 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
#!/usr/bin/env bash
# asana-get-context.sh
# Fetch concise context from an Asana task for implementation or PR creation.
#
# Usage:
# asana-get-context.sh <task_gid_or_url>
# asana-get-context.sh --task-url <url>
# asana-get-context.sh --task <task_gid>
#
# Accepts a raw task GID or a full Asana URL. URL formats supported:
# https://app.asana.com/0/<project_gid>/<task_gid>[/f]
# https://app.asana.com/1/<project_gid>/task/<task_gid>[/f]
#
# Requires env var: ASANA_TOKEN
#
# Output (compact, agent-friendly):
# TASK_NAME: <name>
# TASK_DESCRIPTION: <notes, full text; over TEXT_CEILING chars it is cut with a
# marker naming the total size and the full-text file>
# PRIORITY: <value>
# STATUS: <value>
# IMPLEMENTOR: <name>
# REVIEWER: <name>
# DESCRIPTION_AUTHORSHIP: creator + every notes edit, tagged [operator|other|agent]
# COMMENTS: <count> (ALL comments, oldest first, full text, each tagged
# [operator], [other], or [agent]; newest kept inline
# when the thread exceeds TEXT_CEILING, older ones elided with a marker
# pointing at the full-thread file)
# PARENT: <gid> <name> [if task has a parent]
# SUBTASKS: <count> [if any; then per subtask a "<gid> [open|done] <name>" line + an indented "DESC: <notes>" line (eager body, truncated)]
# DEPENDENCIES: / DEPENDENTS: [if any; same per-line format]
# DESCRIPTION_FILE: / COMMENTS_FILE: <path> [always; full text on disk for grep/Read]
# ATTACHMENTS: <count> files
# DOWNLOADED: <count> files to <dir>
# UNPACKED: <zip> -> <dir> (<count> files) [if ZIPs present]
# PDF_TEXT: <path> (from <file>, <chars> chars) [if PDF has text]
# PDF_PAGES: <dir> (<count> pages from <file>) [if PDF is image-based]
# RTF_TEXT: <path> (from <file>, <chars> chars) [if RTFs present]
set -euo pipefail
# Parse arguments: accept positional, --task, or --task-url
RAW_INPUT=""
while [[ $# -gt 0 ]]; do
case "$1" in
--task-url|--task)
RAW_INPUT="${2:-}"
shift 2
;;
-*)
echo "Unknown flag: $1" >&2
exit 1
;;
*)
RAW_INPUT="$1"
shift
;;
esac
done
if [[ -z "$RAW_INPUT" ]]; then
echo "Usage: asana-get-context.sh <task_gid_or_url>" >&2
exit 1
fi
# Extract task GID: accept a raw numeric GID or any Asana URL containing one.
# Strips trailing path segments (/f, /subtask/…) and query strings.
if [[ "$RAW_INPUT" =~ /task/([0-9]+) ]]; then
TASK_GID="${BASH_REMATCH[1]}"
elif [[ "$RAW_INPUT" =~ /([0-9]+)(/f)?([?#].*)?$ ]]; then
TASK_GID="${BASH_REMATCH[1]}"
elif [[ "$RAW_INPUT" =~ ^[0-9]+$ ]]; then
TASK_GID="$RAW_INPUT"
else
echo "Error: could not extract task GID from: $RAW_INPUT" >&2
exit 1
fi
# Token: prefer $ASANA_TOKEN, else fall back to credentials.json (.asana_token),
# the same source update-status.sh uses — spawned agent shells lack the env var.
if [[ -z "${ASANA_TOKEN:-}" ]]; then
CRED="$HOME/.config/agent-watcher/credentials.json"
[[ -f "$CRED" ]] && ASANA_TOKEN="$(jq -r '.asana_token // empty' "$CRED" 2>/dev/null)"
fi
if [[ -z "${ASANA_TOKEN:-}" ]]; then
echo "Error: ASANA_TOKEN not set and not found in credentials.json (.asana_token)" >&2
exit 1
fi
API="https://app.asana.com/api/1.0"
AUTH="Authorization: Bearer $ASANA_TOKEN"
export ASANA_TOKEN
DOWNLOAD_DIR="/tmp/asana-task-$TASK_GID"
mkdir -p "$DOWNLOAD_DIR"
# Inline text ceiling. Task text is ingested in FULL (a capped description or
# comment is the top cause of runs planning from a partial spec and hand-rolling
# raw API fetches to recover the rest). The ceiling only guards against a pasted
# log dump; the full text is always on disk regardless.
TEXT_CEILING="${ASANA_TEXT_CEILING:-60000}"
# Fetch task + custom fields + relationship pointers. Parent/dependencies/
# dependents ride the same call. Pointers are gid + state + name ONLY — this
# script never fetches related-task content and never recurses; the calling
# skill decides what (if anything) to walk.
OPERATOR_GID=$(curl -s --max-time 10 "$API/users/me?opt_fields=gid" -H "$AUTH" | jq -r '.data.gid // empty')
export OPERATOR_GID
TASK_JSON=$(curl -s "$API/tasks/$TASK_GID?opt_fields=name,notes,num_subtasks,created_by.name,created_by.gid,created_at,parent.name,dependencies.name,dependencies.completed,dependents.name,dependents.completed,custom_fields.gid,custom_fields.name,custom_fields.display_value" \
-H "$AUTH")
export TASK_JSON
printf '%s' "$TASK_JSON" | python3 -c "
import sys, json, os
data = json.load(sys.stdin)['data']
print(f\"TASK_NAME: {data['name']}\")
notes = (data.get('notes') or '').strip()
ceiling = int('$TEXT_CEILING')
desc_path = '$DOWNLOAD_DIR/description.txt'
with open(desc_path, 'w') as fh:
fh.write(notes)
if len(notes) > ceiling:
notes = notes[:ceiling] + f\"\\n[TRUNCATED at {ceiling} of {len(notes)} chars; full text: {desc_path}]\"
print(f\"TASK_DESCRIPTION: {notes or '(empty)'}\")
print(f\"DESCRIPTION_FILE: {desc_path}\")
# Matched by GID, and only fields owned by the Engineering Board or the
# jon-claude project. A task is multi-homed, so its custom_fields also carry
# other boards' fields (a marketing priority, legacy Status/Implementor/Reviewer);
# those are other teams' state and a same-named one reads as ours if shown. The
# jon-claude agent_* fields are the watcher's inputs, not task context, so they
# are not printed.
# Gids come from the field registry (asana-config.json custom_fields).
_cf = json.load(open(os.path.expanduser('~/.config/agent-watcher/asana-config.json')))['custom_fields']
FIELDS = {_cf[k]['gid']: label for k, label in (
('priority', 'PRIORITY'), ('loe', 'LOE'), ('repo', 'REPO'), ('category', 'CATEGORY'),
('release', 'RELEASE'), # Release (4.x.x): CHANGELOG placement signal
('build', 'BUILD'), # Build (staging/cheese): placement + routing
)}
for f in data.get('custom_fields', []):
label = FIELDS.get(f['gid'])
if label:
val = f.get('display_value') or '(not set)'
print(f'{label}: {val}')
# Relationship pointers — lines omitted entirely when empty so the common
# single-task case adds zero output.
parent = data.get('parent')
if parent:
print(f\"PARENT: {parent['gid']} {(parent.get('name') or '')[:80]}\")
for label, key in (('DEPENDENCIES', 'dependencies'), ('DEPENDENTS', 'dependents')):
rows = data.get(key) or []
if rows:
print(f'{label}:')
for t in rows:
state = 'done' if t.get('completed') else 'open'
print(f\" {t['gid']} [{state}] {(t.get('name') or '')[:80]}\")
"
# Subtasks (separate endpoint). Skipped entirely when the task has none. Bodies are
# fetched EAGERLY (notes inline via opt_fields, one call, no extra round-trips) because
# subtasks hold split-out requirements/context for related (often other-repo) work — the
# active run needs them, not just the pointer. Each DESC is truncated with the gid to walk
# via asana-get-context.sh for the full content (comments/attachments/nested subtasks).
SUBTASK_COUNT=$(printf '%s' "$TASK_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin)['data'].get('num_subtasks') or 0)")
if [[ "$SUBTASK_COUNT" -gt 0 ]]; then
curl -s "$API/tasks/$TASK_GID/subtasks?opt_fields=name,completed,notes" \
-H "$AUTH" | python3 -c "
import sys, json
rows = json.load(sys.stdin)['data']
# 'QA: ...' subtasks are manual verification items written at the QA handoff
# (asana-task-update.sh, pr-land). They are for a human tester, never scope, so
# a run sees only their count.
qa = [t for t in rows if (t.get('name') or '').startswith('QA: ')]
rows = [t for t in rows if t not in qa]
if qa:
print(f'QA SUBTASKS HIDDEN: {len(qa)} (manual verification items for QA; not scope, do not read or complete them)')
if rows:
print(f'SUBTASKS: {len(rows)}')
for t in rows:
state = 'done' if t.get('completed') else 'open'
print(f\" {t['gid']} [{state}] {(t.get('name') or '')[:80]}\")
notes = (t.get('notes') or '').strip().replace('\n', ' ')
if notes:
if len(notes) > 1000:
notes = notes[:1000] + f\"... (truncated; asana-get-context.sh {t['gid']} for full)\"
print(f' DESC: {notes}')
"
fi
# Comments: ALL of them, oldest first, full text, each tagged by author class:
# [operator] the token owner (the person who arms tasks and has final say)
# [agent] orch-written (🥋 … 👊 wrapper, agent-authored-text.sh)
# [other] any other human (support, product, other devs)
# Newlines inside a comment are kept (continuation lines indented). The full
# thread is always written to COMMENTS_FILE; when the inline thread would exceed
# TEXT_CEILING the OLDEST comments are elided first.
#
# Also computed here, from the same stories: DESCRIPTION_AUTHORSHIP (creator and
# every notes edit, tagged). The tags are what the planner reads to apply
# task-review operator-final-say; whether other people's text conflicts with
# the operator's is the planner's call, gated by the plan's Conflicts line in
# require-plan-before-developing.sh, not by timestamps here.
# Every page of the thread: a task with more than 100 stories pages, and the
# newest comments are on the last page.
"$(dirname "$0")/asana-paged-get.sh" "tasks/$TASK_GID/stories?opt_fields=resource_subtype,text,created_by.name,created_by.gid,created_at&limit=100" \
| python3 -c "
import sys, json, os
data = json.load(sys.stdin)['data']
op = os.environ.get('OPERATOR_GID') or ''
task = json.loads(os.environ.get('TASK_JSON') or '{}').get('data', {})
ceiling = int('$TEXT_CEILING')
path = '$DOWNLOAD_DIR/comments.txt'
# Authored class from the ONE marker test (~/.config/agent-watcher/lib/agent-authored.jq),
# one jq call for every story.
import subprocess
_cl = subprocess.run(['jq', '-c', '-L', os.path.expanduser('~/.config/agent-watcher/lib'), '--arg', 'op', op,
'include \"agent-authored\"; [.[] | authored_class(\$op)]'],
input=json.dumps(data), capture_output=True, text=True).stdout
_cls = dict(zip(map(id, data), json.loads(_cl or '[]')))
def cls(st):
return _cls.get(id(st), 'other')
comments = [st for st in data if st.get('resource_subtype') == 'comment_added']
edits = [st for st in data if st.get('resource_subtype') == 'notes_changed']
def fmt(c):
author = (c.get('created_by') or {}).get('name', 'unknown')
text = (c.get('text') or '').strip().replace('\\n', '\\n ')
return f' [{c.get(\"created_at\", \"\")[:10]}] [{cls(c)}] {author}: {text}'
blocks = [fmt(c) for c in comments]
with open(path, 'w') as fh:
fh.write('\\n'.join(blocks))
# description authorship
cb = task.get('created_by') or {}
ctag = 'operator' if cb.get('gid') == op else 'other'
desc = f\"created by {cb.get('name', '?')} [{ctag}] {(task.get('created_at') or '')[:10]}\"
for e in edits:
desc += f\"; edited by {(e.get('created_by') or {}).get('name', '?')} [{cls(e)}] {e.get('created_at', '')[:10]}\"
notes = (task.get('notes') or '')
if '===== CURRENT STATE (agent-maintained' in notes:
desc += '; agent CURRENT STATE section present'
print(f'DESCRIPTION_AUTHORSHIP: {desc}')
# inline thread
if not comments:
print('COMMENTS: (none)')
else:
total = sum(len(b) for b in blocks)
keep = blocks
omitted = 0
if total > ceiling:
keep, used = [], 0
for b in reversed(blocks):
if used + len(b) > ceiling: break
keep.insert(0, b); used += len(b)
omitted = len(blocks) - len(keep)
print(f'COMMENTS: {len(comments)}')
if omitted:
print(f' [{omitted} older comment(s), {total - sum(len(b) for b in keep)} chars, elided; full thread: {path}]')
for b in keep:
print(b)
print(f'COMMENTS_FILE: {path}')
"
# Fetch attachments — download all supported types, then post-process
# Ingestion marker, written UNCONDITIONALLY (attachments or not): proof this
# script ran for the gid. require-plan-before-developing.sh gates the
# Developing transition on it — a run that hand-rolls its own task fetch
# (raw curl with notes-only opt_fields) never sees attachments and never
# writes this marker (2026-08-24, task 1217796671374968: planned from a
# one-line notes field while two repro screenshots sat on the task).
date -u +%Y-%m-%dT%H:%M:%SZ > "$DOWNLOAD_DIR/.context-fetched"
# Phase 1: Download all supported attachments
curl -s "$API/tasks/$TASK_GID/attachments?opt_fields=name,resource_subtype,download_url" \
-H "$AUTH" | python3 -c "
import sys, json, os, urllib.request
data = json.load(sys.stdin)['data']
if not data:
print('ATTACHMENTS: (none)')
sys.exit(0)
DOWNLOAD_EXTS = {
'.md', '.txt', '.json', '.csv', '.log', '.yaml', '.yml',
'.ips', '.crash',
'.pdf',
'.rtf',
'.zip',
'.png', '.jpg', '.jpeg', '.gif', '.webp',
}
download_dir = '$DOWNLOAD_DIR'
downloaded = []
print(f'ATTACHMENTS: {len(data)} files')
for a in data:
# The name is whatever the uploader typed: keep the last path component
# so it cannot leave download_dir.
name = os.path.basename(a.get('name') or 'unnamed')
url = a.get('download_url')
ext = os.path.splitext(name)[1].lower()
if ext in DOWNLOAD_EXTS and url and name not in ('.', '..'):
os.makedirs(download_dir, exist_ok=True)
dest = os.path.join(download_dir, name)
try:
urllib.request.urlretrieve(url, dest)
downloaded.append(dest)
print(f' - {name} (downloaded)')
except Exception as e:
print(f' - {name} (download failed: {e})')
else:
print(f' - {name}')
if downloaded:
print(f'DOWNLOADED: {len(downloaded)} files to {download_dir}')
for d in downloaded:
print(f' {d}')
"
# Phase 2: Unpack ZIP archives (may produce more files to process)
shopt -s nullglob
for zip_file in "$DOWNLOAD_DIR"/*.zip; do
subdir="$DOWNLOAD_DIR/$(basename "$zip_file" .zip)"
if unzip -o -q "$zip_file" -d "$subdir" 2>/dev/null; then
file_count=$(find "$subdir" -type f 2>/dev/null | wc -l | tr -d ' ')
echo "UNPACKED: $(basename "$zip_file") -> $subdir ($file_count files)"
rm "$zip_file"
else
echo "UNPACK_FAILED: $(basename "$zip_file")"
fi
done
shopt -u nullglob
# Phase 3: Process PDFs (text extraction first, image fallback)
process_pdf() {
local pdf="$1"
local base="${pdf%.pdf}"
local fname
fname="$(basename "$pdf")"
if command -v pdftotext &>/dev/null; then
local text
text=$(pdftotext "$pdf" - 2>/dev/null || true)
local char_count
char_count=$(printf '%s' "$text" | tr -d '[:space:]' | wc -c | tr -d ' ')
if [[ "$char_count" -gt 100 ]]; then
printf '%s' "$text" > "${base}.txt"
echo "PDF_TEXT: ${base}.txt (from $fname, ${char_count} chars)"
return
fi
fi
if command -v pdftoppm &>/dev/null; then
local pages_dir="${base}_pages"
mkdir -p "$pages_dir"
pdftoppm -png -r 150 "$pdf" "$pages_dir/page" 2>/dev/null
local page_count
page_count=$(find "$pages_dir" -name 'page-*.png' 2>/dev/null | wc -l | tr -d ' ')
if [[ "$page_count" -gt 0 ]]; then
echo "PDF_PAGES: $pages_dir ($page_count pages from $fname)"
else
echo "PDF_CONVERT_FAILED: $fname"
fi
else
echo "PDF_SKIPPED: $fname (install poppler-utils for text/image extraction)"
fi
}
if [[ -d "$DOWNLOAD_DIR" ]]; then
while IFS= read -r pdf; do
process_pdf "$pdf"
done < <(find "$DOWNLOAD_DIR" -name '*.pdf' -type f 2>/dev/null)
fi
# Phase 4: Convert RTFs to plain text (macOS textutil)
process_rtf() {
local rtf="$1"
local base="${rtf%.rtf}"
local fname
fname="$(basename "$rtf")"
if command -v textutil &>/dev/null; then
if textutil -convert txt "$rtf" -output "${base}.txt" 2>/dev/null; then
local char_count
char_count=$(wc -c < "${base}.txt" | tr -d ' ')
echo "RTF_TEXT: ${base}.txt (from $fname, ${char_count} chars)"
else
echo "RTF_CONVERT_FAILED: $fname"
fi
else
echo "RTF_SKIPPED: $fname (textutil not available)"
fi
}
if [[ -d "$DOWNLOAD_DIR" ]]; then
while IFS= read -r rtf; do
process_rtf "$rtf"
done < <(find "$DOWNLOAD_DIR" -name '*.rtf' -type f 2>/dev/null)
fi