crewly 1.20.365 → 1.20.366

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 (136) hide show
  1. package/config/skills/agent/core/attach-file/SKILL.md +7 -0
  2. package/config/skills/agent/core/attach-file/execute.sh +8 -3
  3. package/config/skills/agent/core/complete-task/SKILL.md +4 -2
  4. package/config/skills/agent/core/complete-task/execute.sh +9 -1
  5. package/config/skills/agent/core/complete-task/explicit-id.test.sh +13 -2
  6. package/config/skills/agent/core/create-task/SKILL.md +2 -0
  7. package/config/skills/agent/core/create-task/execute.sh +27 -1
  8. package/config/skills/agent/core/create-task/execute.test.sh +67 -0
  9. package/config/skills/agent/core/handoff-task/execute.sh +12 -5
  10. package/config/skills/agent/core/handoff-task/explicit-id.test.sh +70 -0
  11. package/config/skills/agent/core/reply-channel/SKILL.md +12 -5
  12. package/config/skills/agent/core/reply-channel/execute.sh +17 -2
  13. package/config/skills/agent/core/reply-channel/execute.test.sh +15 -2
  14. package/config/skills/agent/core/slack-post/SKILL.md +3 -3
  15. package/config/skills/orchestrator/delegate-task/SKILL.md +1 -1
  16. package/config/skills/orchestrator/reply-slack/execute.sh +6 -2
  17. package/config/skills/orchestrator/send-pdf-to-slack/SKILL.md +2 -1
  18. package/config/skills/orchestrator/send-pdf-to-slack/execute.sh +10 -2
  19. package/config/skills/registry.json +6 -6
  20. package/config/skills/team-leader/delegate-task/SKILL.md +1 -1
  21. package/config/skills/team-leader/delegate-task/execute.sh +6 -0
  22. package/dist/backend/backend/src/constants.d.ts +26 -0
  23. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  24. package/dist/backend/backend/src/constants.js +26 -0
  25. package/dist/backend/backend/src/constants.js.map +1 -1
  26. package/dist/backend/backend/src/controllers/agent-hooks/agent-hooks.controller.d.ts.map +1 -1
  27. package/dist/backend/backend/src/controllers/agent-hooks/agent-hooks.controller.js +7 -3
  28. package/dist/backend/backend/src/controllers/agent-hooks/agent-hooks.controller.js.map +1 -1
  29. package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts +12 -0
  30. package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
  31. package/dist/backend/backend/src/controllers/chat/chat.controller.js +56 -7
  32. package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
  33. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts +9 -0
  34. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts.map +1 -1
  35. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js +111 -11
  36. package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js.map +1 -1
  37. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.d.ts.map +1 -1
  38. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js +52 -4
  39. package/dist/backend/backend/src/controllers/monitoring/terminal.controller.js.map +1 -1
  40. package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
  41. package/dist/backend/backend/src/controllers/slack/slack.controller.js +58 -4
  42. package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
  43. package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.d.ts.map +1 -1
  44. package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js +36 -2
  45. package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js.map +1 -1
  46. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +16 -10
  47. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  48. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +19 -11
  49. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  50. package/dist/backend/backend/src/services/messaging/agent-message-envelope.d.ts +8 -0
  51. package/dist/backend/backend/src/services/messaging/agent-message-envelope.d.ts.map +1 -1
  52. package/dist/backend/backend/src/services/messaging/agent-message-envelope.js +10 -1
  53. package/dist/backend/backend/src/services/messaging/agent-message-envelope.js.map +1 -1
  54. package/dist/backend/backend/src/services/messaging/owner-hook-message.d.ts +62 -20
  55. package/dist/backend/backend/src/services/messaging/owner-hook-message.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/messaging/owner-hook-message.js +178 -39
  57. package/dist/backend/backend/src/services/messaging/owner-hook-message.js.map +1 -1
  58. package/dist/backend/backend/src/services/orc/agent-mention-post-guard.d.ts +8 -5
  59. package/dist/backend/backend/src/services/orc/agent-mention-post-guard.d.ts.map +1 -1
  60. package/dist/backend/backend/src/services/orc/agent-mention-post-guard.js +13 -10
  61. package/dist/backend/backend/src/services/orc/agent-mention-post-guard.js.map +1 -1
  62. package/dist/backend/backend/src/services/orc/message-route.d.ts +129 -0
  63. package/dist/backend/backend/src/services/orc/message-route.d.ts.map +1 -0
  64. package/dist/backend/backend/src/services/orc/message-route.js +197 -0
  65. package/dist/backend/backend/src/services/orc/message-route.js.map +1 -0
  66. package/dist/backend/backend/src/services/orc/reply-destination-resolver.d.ts +49 -6
  67. package/dist/backend/backend/src/services/orc/reply-destination-resolver.d.ts.map +1 -1
  68. package/dist/backend/backend/src/services/orc/reply-destination-resolver.js +284 -69
  69. package/dist/backend/backend/src/services/orc/reply-destination-resolver.js.map +1 -1
  70. package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts +64 -8
  71. package/dist/backend/backend/src/services/orc/reply-destination.wiring.d.ts.map +1 -1
  72. package/dist/backend/backend/src/services/orc/reply-destination.wiring.js +215 -100
  73. package/dist/backend/backend/src/services/orc/reply-destination.wiring.js.map +1 -1
  74. package/dist/backend/backend/src/services/orc/triggered-by.d.ts.map +1 -1
  75. package/dist/backend/backend/src/services/orc/triggered-by.js +9 -0
  76. package/dist/backend/backend/src/services/orc/triggered-by.js.map +1 -1
  77. package/dist/backend/backend/src/services/orc/work-item-destination.d.ts +2 -1
  78. package/dist/backend/backend/src/services/orc/work-item-destination.d.ts.map +1 -1
  79. package/dist/backend/backend/src/services/orc/work-item-destination.js +1 -1
  80. package/dist/backend/backend/src/services/orc/work-item-destination.js.map +1 -1
  81. package/dist/backend/backend/src/services/orc/work-item-destination.wiring.d.ts +10 -1
  82. package/dist/backend/backend/src/services/orc/work-item-destination.wiring.d.ts.map +1 -1
  83. package/dist/backend/backend/src/services/orc/work-item-destination.wiring.js +25 -4
  84. package/dist/backend/backend/src/services/orc/work-item-destination.wiring.js.map +1 -1
  85. package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.d.ts +1 -0
  86. package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.d.ts.map +1 -1
  87. package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts +1 -1
  88. package/dist/backend/backend/src/services/slack/slack-agent-post.service.d.ts.map +1 -1
  89. package/dist/backend/backend/src/services/slack/slack-agent-post.service.js +20 -13
  90. package/dist/backend/backend/src/services/slack/slack-agent-post.service.js.map +1 -1
  91. package/dist/backend/backend/src/services/slack/slack.service.d.ts +20 -1
  92. package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
  93. package/dist/backend/backend/src/services/slack/slack.service.js +58 -3
  94. package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
  95. package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts +31 -0
  96. package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
  97. package/dist/backend/backend/src/services/task-pool/task-pool.service.js +62 -5
  98. package/dist/backend/backend/src/services/task-pool/task-pool.service.js.map +1 -1
  99. package/dist/backend/backend/src/services/trace/turn-trigger.d.ts +34 -1
  100. package/dist/backend/backend/src/services/trace/turn-trigger.d.ts.map +1 -1
  101. package/dist/backend/backend/src/services/trace/turn-trigger.js +74 -9
  102. package/dist/backend/backend/src/services/trace/turn-trigger.js.map +1 -1
  103. package/dist/backend/build-info.json +2 -2
  104. package/dist/cli/backend/src/constants.d.ts +26 -0
  105. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  106. package/dist/cli/backend/src/constants.js +26 -0
  107. package/dist/cli/backend/src/constants.js.map +1 -1
  108. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +16 -10
  109. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  110. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +19 -11
  111. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  112. package/dist/cli/backend/src/services/messaging/agent-message-envelope.d.ts +8 -0
  113. package/dist/cli/backend/src/services/messaging/agent-message-envelope.d.ts.map +1 -1
  114. package/dist/cli/backend/src/services/messaging/agent-message-envelope.js +10 -1
  115. package/dist/cli/backend/src/services/messaging/agent-message-envelope.js.map +1 -1
  116. package/dist/cli/backend/src/services/orc/message-route.d.ts +129 -0
  117. package/dist/cli/backend/src/services/orc/message-route.d.ts.map +1 -0
  118. package/dist/cli/backend/src/services/orc/message-route.js +197 -0
  119. package/dist/cli/backend/src/services/orc/message-route.js.map +1 -0
  120. package/dist/cli/backend/src/services/orc/work-item-destination.d.ts +2 -1
  121. package/dist/cli/backend/src/services/orc/work-item-destination.d.ts.map +1 -1
  122. package/dist/cli/backend/src/services/orc/work-item-destination.js +1 -1
  123. package/dist/cli/backend/src/services/orc/work-item-destination.js.map +1 -1
  124. package/dist/cli/backend/src/services/slack/slack.service.d.ts +20 -1
  125. package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
  126. package/dist/cli/backend/src/services/slack/slack.service.js +58 -3
  127. package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
  128. package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts +31 -0
  129. package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
  130. package/dist/cli/backend/src/services/task-pool/task-pool.service.js +62 -5
  131. package/dist/cli/backend/src/services/task-pool/task-pool.service.js.map +1 -1
  132. package/dist/cli/backend/src/services/trace/turn-trigger.d.ts +34 -1
  133. package/dist/cli/backend/src/services/trace/turn-trigger.d.ts.map +1 -1
  134. package/dist/cli/backend/src/services/trace/turn-trigger.js +74 -9
  135. package/dist/cli/backend/src/services/trace/turn-trigger.js.map +1 -1
  136. package/package.json +1 -1
@@ -47,6 +47,8 @@ backend resolves the Slack channel, the thread and your bot identity from it.
47
47
  | `--title` | no | Title shown above the file in Slack |
48
48
  | `--comment` | no | A line of text posted with the file |
49
49
  | `--thread` | no | The thread the file belongs to: the `[SLACK-THREAD:<key>]` key from your prompt, or the id `reply-channel` takes. **Pass it** when you have more than one thread going — without it the file follows the answer you just posted, else the oldest thread still waiting on you. |
50
+ | `--work-item` | no | The work item the file delivers (the id in your `[CREWLY-DISPATCH]`). The file goes into the thread that work came from — use it when the work was delegated to you from a conversation you are not in. No `--channel` needed. |
51
+ | `--ticket` / `--to` | no | The ticket, or the message id from your prompt, the file answers. No `--channel` needed. |
50
52
 
51
53
  ## When to use it
52
54
 
@@ -76,6 +78,11 @@ it to the owner if they ask why the name differs.
76
78
 
77
79
  ## Failures
78
80
 
81
+ `{"success":false,"code":"thread_unresolved","error":"File NOT sent: …"}` —
82
+ Crewly could not tell which thread the file answers. The file is **never**
83
+ posted at the channel's top level instead: run it again with `--work-item <id>`
84
+ (from your `[CREWLY-DISPATCH]`), `--to <message id>` or `--ticket <id>`.
85
+
79
86
  `{"success":false,"error":"not_a_slack_channel"}` — that chat channel is not
80
87
  mirrored to Slack. Nothing to attach to; reply with text.
81
88
 
@@ -7,13 +7,14 @@
7
7
  #
8
8
  # Usage: execute.sh --channel <chatChannelId> --path <file> [--name <n>]
9
9
  # [--title <t>] [--comment <c>] [--thread <messageId>]
10
+ # [--work-item <id>] [--ticket <id>] [--to <messageId>]
10
11
  set -euo pipefail
11
12
 
12
13
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
14
  # shellcheck source=/dev/null
14
15
  source "$SCRIPT_DIR/../../../_common/lib.sh"
15
16
 
16
- CHANNEL=""; FILE_PATH=""; NAME=""; TITLE=""; COMMENT=""; THREAD=""; TICKET=""; TO=""
17
+ CHANNEL=""; FILE_PATH=""; NAME=""; TITLE=""; COMMENT=""; THREAD=""; TICKET=""; TO=""; WORK_ITEM=""
17
18
 
18
19
  usage() {
19
20
  cat <<'USAGE'
@@ -27,6 +28,7 @@ attach-file — send a file into your Slack channel
27
28
  --thread <id> The thread the file belongs to ([SLACK-THREAD:<key>] key, or a chat thread id)
28
29
  --ticket <id> The ticket the file is for (TKT-187 / CE-7) — Crewly finds its thread (no --channel needed)
29
30
  --to <id> The message the file answers (from your prompt) — no --channel needed
31
+ --work-item <id> The work item the file delivers ([CREWLY-DISPATCH] id) — Crewly posts it in that work's thread (no --channel needed)
30
32
  USAGE
31
33
  }
32
34
 
@@ -40,13 +42,14 @@ while [ $# -gt 0 ]; do
40
42
  --thread|-t) THREAD="${2:-}"; shift 2 ;;
41
43
  --ticket) TICKET="${2:-}"; shift 2 ;;
42
44
  --to) TO="${2:-}"; shift 2 ;;
45
+ --work-item|--workitem) WORK_ITEM="${2:-}"; shift 2 ;;
43
46
  -h|--help) usage; exit 0 ;;
44
47
  *) unknown_arg_exit "$1" ;;
45
48
  esac
46
49
  done
47
50
 
48
- if { [ -z "$CHANNEL" ] && [ -z "$TICKET" ] && [ -z "$TO" ]; } || [ -z "$FILE_PATH" ]; then
49
- echo '{"error":"Missing required parameter: --path, and --channel (or --ticket / --to)"}' >&2
51
+ if { [ -z "$CHANNEL" ] && [ -z "$TICKET" ] && [ -z "$TO" ] && [ -z "$WORK_ITEM" ]; } || [ -z "$FILE_PATH" ]; then
52
+ echo '{"error":"Missing required parameter: --path, and --channel (or --work-item / --ticket / --to)"}' >&2
50
53
  usage >&2
51
54
  exit 1
52
55
  fi
@@ -65,10 +68,12 @@ BODY=$(jq -cn \
65
68
  --arg threadId "$THREAD" \
66
69
  --arg ticket "$TICKET" \
67
70
  --arg to "$TO" \
71
+ --arg workItemId "$WORK_ITEM" \
68
72
  '{filePath: $filePath}
69
73
  + (if $channelId != "" then {channelId: $channelId} else {} end)
70
74
  + (if $ticket != "" then {ticket: $ticket} else {} end)
71
75
  + (if $to != "" then {to: $to} else {} end)
76
+ + (if $workItemId != "" then {workItemId: $workItemId} else {} end)
72
77
  + (if $filename != "" then {filename: $filename} else {} end)
73
78
  + (if $title != "" then {title: $title} else {} end)
74
79
  + (if $comment != "" then {comment: $comment} else {} end)
@@ -62,14 +62,16 @@ Resolution order:
62
62
  1. `workItemId`, if you pass it — always wins.
63
63
  2. Otherwise, ONLY when you named no task (no `workItemId`, no `taskId`): a lookup of the WorkItem currently `running` against your
64
64
  `sessionName` (`GET /api/task-pool/items?status=running&target=<session>`).
65
+ It is used only when exactly ONE item is running. With several, the skill
66
+ refuses (non-zero exit) and lists their ids: pass `workItemId` with one.
65
67
  3. If you passed `taskId` without `workItemId`, the skill
66
68
  refuses (non-zero exit): it never substitutes your running item for an id you named.
67
69
  4. If nothing resolves, the skill exits non-zero with an error naming the
68
70
  session it searched. It never reports success without completing something.
69
71
 
70
72
  Pass `workItemId` whenever you know it — the lookup in step 2 depends on the
71
- pool state at that instant and cannot disambiguate two concurrently running
72
- items for one session.
73
+ pool state at that instant, and with two running items it refuses rather
74
+ than guess.
73
75
 
74
76
  ## Evidence contract (#873)
75
77
 
@@ -138,9 +138,17 @@ fi
138
138
  if [ -z "$WORK_ITEM_ID" ] && [ -n "$TASK_ID" ]; then
139
139
  error_exit "Nothing was completed: you passed taskId '${TASK_ID}' but no 'workItemId', and this skill does not guess which WorkItem you meant. Pass 'workItemId' (find it with GET /api/task-pool/items?target=${SESSION_NAME}) and call again."
140
140
  fi
141
+ # The pool lookup only stands in for an explicit id when exactly ONE WorkItem
142
+ # is running: with several, picking the first closed the wrong one and sent
143
+ # its result to the wrong place (message-routing audit 2026-10-18).
141
144
  if [ -z "$WORK_ITEM_ID" ]; then
142
145
  POOL_RESP=$(api_call_full GET "/task-pool/items?status=running&target=${SESSION_NAME}" 2>/dev/null || echo '{}')
143
- WORK_ITEM_ID=$(echo "$POOL_RESP" | jq -r '.workItems[0].id // .data[0].id // empty' 2>/dev/null || true)
146
+ RUNNING_IDS=$(echo "$POOL_RESP" | jq -r '(.workItems // .data // [])[]?.id // empty' 2>/dev/null || true)
147
+ RUNNING_COUNT=$(printf '%s\n' "$RUNNING_IDS" | grep -c . || true)
148
+ if [ "${RUNNING_COUNT:-0}" -gt 1 ]; then
149
+ error_exit "Nothing was completed: ${RUNNING_COUNT} WorkItems are running for '${SESSION_NAME}' ($(printf '%s' "$RUNNING_IDS" | tr '\n' ' ' | sed 's/ *$//')) and this skill does not guess which one you finished. Pass 'workItemId' with one of them and call again."
150
+ fi
151
+ WORK_ITEM_ID=$(printf '%s\n' "$RUNNING_IDS" | head -n1)
144
152
  fi
145
153
 
146
154
  # Neither an explicit `workItemId` nor the pool lookup produced a target.
@@ -10,6 +10,8 @@ check() {
10
10
 
11
11
  PORT=18854
12
12
  STUB_LOG="$(mktemp)"; export STUB_LOG; export STUB_PORT="$PORT"
13
+ STUB_RUNNING="$(mktemp)"; export STUB_RUNNING
14
+ echo 'wi-running' > "$STUB_RUNNING"
13
15
  : > "$STUB_LOG"
14
16
  python3 -u <<'PY' >/dev/null 2>&1 &
15
17
  from http.server import BaseHTTPRequestHandler, HTTPServer
@@ -23,7 +25,8 @@ class H(BaseHTTPRequestHandler):
23
25
  open(LOG, 'a').write(self.command + ' ' + self.path + '\n')
24
26
  def do_GET(self):
25
27
  self.note()
26
- return self.reply(200, {'workItems': [{'id': 'wi-running'}]})
28
+ ids = [l.strip() for l in open(os.environ['STUB_RUNNING']) if l.strip()]
29
+ return self.reply(200, {'workItems': [{'id': i} for i in ids]})
27
30
  def do_POST(self):
28
31
  n = int(self.headers.get('content-length', '0')); self.rfile.read(n)
29
32
  self.note()
@@ -33,7 +36,7 @@ HTTPServer(('127.0.0.1', PORT), H).serve_forever()
33
36
  PY
34
37
  STUB_PID=$!
35
38
  disown "$STUB_PID" 2>/dev/null || true
36
- trap 'kill "$STUB_PID" >/dev/null 2>&1; rm -f "$STUB_LOG"' EXIT
39
+ trap 'kill "$STUB_PID" >/dev/null 2>&1; rm -f "$STUB_LOG" "$STUB_RUNNING"' EXIT
37
40
  for i in $(seq 1 30); do curl -s -o /dev/null -m 0.2 "http://127.0.0.1:${PORT}/" 2>/dev/null && break; sleep 0.1; done
38
41
 
39
42
  run() { CREWLY_SESSION_NAME=dev-1 CREWLY_API_URL="http://127.0.0.1:${PORT}" bash "$EXEC" "$1" 2>&1; echo "exit=$?"; }
@@ -52,5 +55,13 @@ check "explicit workItemId: completes exactly that one" "$(grep -v heartbeat "$S
52
55
  run '{"sessionName":"dev-1","summary":"done"}' >/dev/null
53
56
  check "no id at all: running-item lookup still works" "$(tail -1 "$STUB_LOG")" "POST /api/task-pool/complete/wi-running"
54
57
 
58
+ # Several running items: no `[0]` pick (message-routing audit 2026-10-18).
59
+ printf 'wi-a\nwi-b\n' > "$STUB_RUNNING"
60
+ : > "$STUB_LOG"
61
+ OUT=$(run '{"sessionName":"dev-1","summary":"done"}')
62
+ check "two running, no id: refused" "$(printf '%s' "$OUT" | grep -c 'exit=1')" "1"
63
+ check "two running, no id: lists both ids" "$(printf '%s' "$OUT" | grep -c 'wi-a wi-b')" "1"
64
+ check "two running, no id: nothing completed" "$(grep -c 'POST /api/task-pool/complete' "$STUB_LOG")" "0"
65
+
55
66
  echo "complete-task: $PASS passed, $FAIL failed"
56
67
  [ "$FAIL" -eq 0 ]
@@ -52,6 +52,8 @@ Create a new task via the `/api/task-management/create` endpoint. This allows te
52
52
  | `--owner` | `owner` | No | Responsible role: `orchestrator`, `team_lead`, `agent` (default), `system`. A role, not a session name — use `--session` for the session |
53
53
  | `--description` / `-d` | `description` | No | Short summary of the task |
54
54
  | `--brief` | `briefMarkdown` | No | Long-form brief in markdown. Inline text, or `@/path/to/file.md`. Max 16384 bytes |
55
+ | `--thread` | `thread` | No | Owner thread the task answers: the Slack thread key from your prompt. A task you assign to yourself inherits no owner thread from your last turn — name it here when the owner should get its result |
56
+ | `--conversation` | `conversation` | No | Owner conversation the task answers (chat-v2 id) |
55
57
  | `--request-id` / `-R` | `requestId` | No | Ticket this work is for: the id (or `TKT-123`) from the `[TICKET:TKT-123 <id>]` line of the message you are acting on. Omit it and the task is still linked when your current turn has exactly one ticket |
56
58
 
57
59
  ### Always attach a brief
@@ -32,6 +32,10 @@ Options:
32
32
  Should carry Goal + Expected Outcome + Eval Criteria. Max 16384 bytes.
33
33
  --request-id | -R Ticket this task is for: the id (or TKT-123) from the
34
34
  [TICKET:TKT-123 <id>] line of the message you are working on
35
+ --thread Owner thread the task answers (Slack thread key from your
36
+ prompt). A task you assign to yourself inherits no owner
37
+ thread: name it here, or its result goes nowhere public
38
+ --conversation Owner conversation the task answers (chat-v2 id)
35
39
  --json | -j Raw JSON payload (same as legacy)
36
40
  --help | -h Show this help
37
41
  EOF_USAGE
@@ -48,6 +52,8 @@ OWNER=""
48
52
  DESCRIPTION=""
49
53
  BRIEF=""
50
54
  REQUEST_ID=""
55
+ OWNER_THREAD=""
56
+ OWNER_CONVERSATION=""
51
57
 
52
58
  # Detect legacy JSON argument
53
59
  if [[ $# -gt 0 && ${1:0:1} == '{' ]]; then
@@ -97,6 +103,14 @@ while [[ $# -gt 0 ]]; do
97
103
  REQUEST_ID="$2"
98
104
  shift 2
99
105
  ;;
106
+ --thread)
107
+ OWNER_THREAD="$2"
108
+ shift 2
109
+ ;;
110
+ --conversation)
111
+ OWNER_CONVERSATION="$2"
112
+ shift 2
113
+ ;;
100
114
  --json|-j)
101
115
  INPUT_JSON="$2"
102
116
  shift 2
@@ -141,6 +155,8 @@ if [ -n "$INPUT_JSON" ]; then
141
155
  [ -z "$DESCRIPTION" ] && DESCRIPTION=$(printf '%s' "$INPUT" | jq -r '.description // empty')
142
156
  [ -z "$BRIEF" ] && BRIEF=$(printf '%s' "$INPUT" | jq -r '.briefMarkdown // .brief // empty')
143
157
  [ -z "$REQUEST_ID" ] && REQUEST_ID=$(printf '%s' "$INPUT" | jq -r '.requestId // empty')
158
+ [ -z "$OWNER_THREAD" ] && OWNER_THREAD=$(printf '%s' "$INPUT" | jq -r '.thread // .ownerThread // empty')
159
+ [ -z "$OWNER_CONVERSATION" ] && OWNER_CONVERSATION=$(printf '%s' "$INPUT" | jq -r '.conversation // .conversationId // .ownerConversation // empty')
144
160
  fi
145
161
 
146
162
  # Apply defaults
@@ -212,12 +228,16 @@ WORK_ITEM=$(jq -n \
212
228
  --arg description "$DESCRIPTION" \
213
229
  --arg briefMarkdown "$BRIEF" \
214
230
  --arg requestId "$REQUEST_ID" \
231
+ --arg ownerThread "$OWNER_THREAD" \
232
+ --arg ownerConversation "$OWNER_CONVERSATION" \
215
233
  '{
216
234
  title: $title,
217
235
  type: "delegate",
218
236
  owner: $owner,
219
237
  target: (if $target == "" then null else $target end),
220
- metadata: { projectPath: $projectPath, milestone: $milestone, priority: $priority }
238
+ metadata: ({ projectPath: $projectPath, milestone: $milestone, priority: $priority }
239
+ + (if $ownerThread != "" then {ownerThread: $ownerThread} else {} end)
240
+ + (if $ownerConversation != "" then {ownerConversation: $ownerConversation} else {} end))
221
241
  }
222
242
  + (if $description != "" then {description: $description} else {} end)
223
243
  + (if $briefMarkdown != "" then {briefMarkdown: $briefMarkdown} else {} end)
@@ -228,4 +248,10 @@ if [ -n "$OUTPUT_SCHEMA" ] && [ "$OUTPUT_SCHEMA" != "" ]; then
228
248
  WORK_ITEM=$(printf '%s' "$WORK_ITEM" | jq --argjson schema "$OUTPUT_SCHEMA" '.metadata += {outputSchema: $schema}')
229
249
  fi
230
250
 
251
+ # A task with no owner place answers no owner thread: say so (message-routing
252
+ # audit 2026-10-18: a self-assigned task no longer borrows your last one).
253
+ if [ -z "$OWNER_THREAD" ] && [ -z "$OWNER_CONVERSATION" ] && [ -z "$REQUEST_ID" ]; then
254
+ echo "Note: no --thread / --conversation given — this task answers no owner thread unless it is delegated from your current work. Pass --thread <key> to tie it to the owner's request." >&2
255
+ fi
256
+
231
257
  api_call POST "/task-pool/add" "$WORK_ITEM"
@@ -0,0 +1,67 @@
1
+ #!/bin/bash
2
+ # Tests for create-task carrying the owner place it is given (--thread / --conversation)
3
+ # and saying so when it is given none (message-routing audit 2026-10-18) — run with: bash execute.test.sh
4
+ set -u
5
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ EXEC="$HERE/execute.sh"
7
+ PASS=0; FAIL=0
8
+ check() {
9
+ if [ "$2" = "$3" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $1"; echo " want: $3"; echo " got: $2"; fi
10
+ }
11
+
12
+ PORT=18856
13
+ STUB_LOG="$(mktemp)"; export STUB_LOG; export STUB_PORT="$PORT"
14
+ STUB_BODIES="$(mktemp)"; export STUB_BODIES
15
+ STUB_RUNNING="$(mktemp)"; export STUB_RUNNING
16
+ python3 -u <<'PY' >/dev/null 2>&1 &
17
+ from http.server import BaseHTTPRequestHandler, HTTPServer
18
+ import json, os
19
+ LOG = os.environ['STUB_LOG']; PORT = int(os.environ['STUB_PORT'])
20
+ class H(BaseHTTPRequestHandler):
21
+ def reply(self, code, obj):
22
+ self.send_response(code); self.send_header('Content-Type', 'application/json'); self.end_headers()
23
+ self.wfile.write(json.dumps(obj).encode())
24
+ def note(self, body=''):
25
+ open(LOG, 'a').write(self.command + ' ' + self.path + '\n')
26
+ if body:
27
+ try: body = json.dumps(json.loads(body), separators=(',', ':'))
28
+ except Exception: body = body.replace('\n', ' ')
29
+ open(os.environ['STUB_BODIES'], 'a').write(self.path + ' ' + body + '\n')
30
+ def do_GET(self):
31
+ self.note()
32
+ ids = [l.strip() for l in open(os.environ['STUB_RUNNING']) if l.strip()]
33
+ return self.reply(200, {'workItems': [{'id': i} for i in ids]})
34
+ def do_POST(self):
35
+ n = int(self.headers.get('content-length', '0')); body = self.rfile.read(n).decode()
36
+ self.note(body)
37
+ return self.reply(200, {'success': True})
38
+ def log_message(self, *a, **k): pass
39
+ HTTPServer(('127.0.0.1', PORT), H).serve_forever()
40
+ PY
41
+ STUB_PID=$!
42
+ disown "$STUB_PID" 2>/dev/null || true
43
+ trap 'kill "$STUB_PID" >/dev/null 2>&1; rm -f "$STUB_LOG" "$STUB_BODIES" "$STUB_RUNNING"' EXIT
44
+ for i in $(seq 1 30); do curl -s -o /dev/null -m 0.2 "http://127.0.0.1:${PORT}/" 2>/dev/null && break; sleep 0.1; done
45
+
46
+ run() { CREWLY_SESSION_NAME=dev-1 CREWLY_API_URL="http://127.0.0.1:${PORT}" bash "$EXEC" "$1" 2>&1; echo "exit=$?"; }
47
+ reset() { : > "$STUB_LOG"; : > "$STUB_BODIES"; }
48
+
49
+ : > "$STUB_RUNNING"
50
+
51
+ reset
52
+ OUT=$(run '{"projectPath":"/tmp/p","task":"Write the report","sessionName":"dev-1","thread":"C0A:1.2"}')
53
+ check "--thread: stored as metadata.ownerThread" "$(grep '/task-pool/add' "$STUB_BODIES" | grep -c '"ownerThread":"C0A:1.2"')" "1"
54
+ check "--thread: no note" "$(printf '%s' "$OUT" | grep -c 'answers no owner thread')" "0"
55
+
56
+ reset
57
+ CREWLY_SESSION_NAME=dev-1 CREWLY_API_URL="http://127.0.0.1:${PORT}" bash "$EXEC" --project-path /tmp/p --task "Write it" --session dev-1 --conversation room-1 >/dev/null 2>&1
58
+ check "--conversation flag: stored as metadata.ownerConversation" "$(grep '/task-pool/add' "$STUB_BODIES" | grep -c '"ownerConversation":"room-1"')" "1"
59
+
60
+ reset
61
+ OUT=$(run '{"projectPath":"/tmp/p","task":"Write the report","sessionName":"dev-1"}')
62
+ check "no owner place: still created" "$(grep -c 'POST /api/task-pool/add' "$STUB_LOG")" "1"
63
+ check "no owner place: says it answers no owner thread" "$(printf '%s' "$OUT" | grep -c 'answers no owner thread')" "1"
64
+ check "no owner place: no owner fields sent" "$(grep '/task-pool/add' "$STUB_BODIES" | grep -c 'ownerThread\|ownerConversation')" "0"
65
+
66
+ echo "create-task: $PASS passed, $FAIL failed"
67
+ [ "$FAIL" -eq 0 ]
@@ -54,10 +54,17 @@ CREWLY_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)"
54
54
  TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
55
55
 
56
56
  # --- Step 0: Resolve target WorkItem if not supplied ---
57
- # Resolve order: explicit workItemId > the agent's currently-running claim.
57
+ # Resolve order: explicit workItemId > the agent's ONE running claim. With
58
+ # several running, the first one was handed over (and its owner thread with
59
+ # it) — this skill no longer guesses (message-routing audit 2026-10-18).
58
60
  if [ -z "$WORK_ITEM_ID" ]; then
59
61
  POOL_RESP=$(api_call_full GET "/task-pool/items?status=running&target=${SESSION_NAME}" 2>/dev/null || echo '{}')
60
- WORK_ITEM_ID=$(echo "$POOL_RESP" | jq -r '.workItems[0].id // .data[0].id // empty' 2>/dev/null || true)
62
+ RUNNING_IDS=$(echo "$POOL_RESP" | jq -r '(.workItems // .data // [])[]?.id // empty' 2>/dev/null || true)
63
+ RUNNING_COUNT=$(printf '%s\n' "$RUNNING_IDS" | grep -c . || true)
64
+ if [ "${RUNNING_COUNT:-0}" -gt 1 ]; then
65
+ error_exit "Nothing was handed off: ${RUNNING_COUNT} WorkItems are running for '${SESSION_NAME}' ($(printf '%s' "$RUNNING_IDS" | tr '\n' ' ' | sed 's/ *$//')) and this skill does not guess which one you mean. Pass 'workItemId' with one of them and call again."
66
+ fi
67
+ WORK_ITEM_ID=$(printf '%s\n' "$RUNNING_IDS" | head -n1)
61
68
  fi
62
69
 
63
70
  # --- Step 1: Fission guard check (rate limit before handoff) ---
@@ -101,7 +108,7 @@ HANDOFF_MSG_FILE=$(mktemp)
101
108
  trap 'rm -f "$HANDOFF_MSG_FILE"' EXIT
102
109
 
103
110
  cat > "$HANDOFF_MSG_FILE" << MSG_EOF
104
- [TASK HANDOFF] from ${SESSION_NAME}
111
+ [TASK HANDOFF] from ${SESSION_NAME}${WORK_ITEM_ID:+ — WorkItem: ${WORK_ITEM_ID} {route: work-item ${WORK_ITEM_ID}}}
105
112
 
106
113
  ## Reason for Handoff
107
114
  ${REASON}
@@ -157,8 +164,8 @@ deliver_result=$(api_call POST "/terminal/${TO}/write" "$DELIVER_BODY" 2>/dev/nu
157
164
 
158
165
  # --- Step 4: Notify orchestrator/TL about the handoff ---
159
166
  NOTIFY_MSG="[HANDOFF] ${SESSION_NAME} → ${TO}: ${REASON}"
160
- NOTIFY_BODY=$(jq -n --arg content "$NOTIFY_MSG" --arg senderName "$SESSION_NAME" \
161
- '{content: $content, senderName: $senderName, senderType: "agent"}')
167
+ NOTIFY_BODY=$(jq -n --arg content "$NOTIFY_MSG" --arg senderName "$SESSION_NAME" --arg workItemId "${WORK_ITEM_ID:-}" \
168
+ '{content: $content, senderName: $senderName, senderType: "agent"} + (if $workItemId == "" then {} else {workItemId: $workItemId} end)')
162
169
  api_call POST "/chat/agent-response" "$NOTIFY_BODY" >/dev/null 2>&1 || true
163
170
 
164
171
  # --- Step 5: Output result as safe JSON ---
@@ -0,0 +1,70 @@
1
+ #!/bin/bash
2
+ # Tests for handoff-task naming its WorkItem and never guessing among several
3
+ # running ones (message-routing audit 2026-10-18) — run with: bash explicit-id.test.sh
4
+ set -u
5
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ EXEC="$HERE/execute.sh"
7
+ PASS=0; FAIL=0
8
+ check() {
9
+ if [ "$2" = "$3" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $1"; echo " want: $3"; echo " got: $2"; fi
10
+ }
11
+
12
+ PORT=18855
13
+ STUB_LOG="$(mktemp)"; export STUB_LOG; export STUB_PORT="$PORT"
14
+ STUB_BODIES="$(mktemp)"; export STUB_BODIES
15
+ STUB_RUNNING="$(mktemp)"; export STUB_RUNNING
16
+ python3 -u <<'PY' >/dev/null 2>&1 &
17
+ from http.server import BaseHTTPRequestHandler, HTTPServer
18
+ import json, os
19
+ LOG = os.environ['STUB_LOG']; PORT = int(os.environ['STUB_PORT'])
20
+ class H(BaseHTTPRequestHandler):
21
+ def reply(self, code, obj):
22
+ self.send_response(code); self.send_header('Content-Type', 'application/json'); self.end_headers()
23
+ self.wfile.write(json.dumps(obj).encode())
24
+ def note(self, body=''):
25
+ open(LOG, 'a').write(self.command + ' ' + self.path + '\n')
26
+ if body:
27
+ try: body = json.dumps(json.loads(body), separators=(',', ':'))
28
+ except Exception: body = body.replace('\n', ' ')
29
+ open(os.environ['STUB_BODIES'], 'a').write(self.path + ' ' + body + '\n')
30
+ def do_GET(self):
31
+ self.note()
32
+ ids = [l.strip() for l in open(os.environ['STUB_RUNNING']) if l.strip()]
33
+ return self.reply(200, {'workItems': [{'id': i} for i in ids]})
34
+ def do_POST(self):
35
+ n = int(self.headers.get('content-length', '0')); body = self.rfile.read(n).decode()
36
+ self.note(body)
37
+ return self.reply(200, {'success': True})
38
+ def log_message(self, *a, **k): pass
39
+ HTTPServer(('127.0.0.1', PORT), H).serve_forever()
40
+ PY
41
+ STUB_PID=$!
42
+ disown "$STUB_PID" 2>/dev/null || true
43
+ trap 'kill "$STUB_PID" >/dev/null 2>&1; rm -f "$STUB_LOG" "$STUB_BODIES" "$STUB_RUNNING"' EXIT
44
+ for i in $(seq 1 30); do curl -s -o /dev/null -m 0.2 "http://127.0.0.1:${PORT}/" 2>/dev/null && break; sleep 0.1; done
45
+
46
+ run() { CREWLY_SESSION_NAME=dev-1 CREWLY_API_URL="http://127.0.0.1:${PORT}" bash "$EXEC" "$1" 2>&1; echo "exit=$?"; }
47
+ reset() { : > "$STUB_LOG"; : > "$STUB_BODIES"; }
48
+
49
+ # Several running items and no id: refused, nothing handed over.
50
+ printf 'wi-a\nwi-b\n' > "$STUB_RUNNING"; reset
51
+ OUT=$(run '{"sessionName":"dev-1","to":"dev-2","reason":"swap"}')
52
+ check "two running, no id: refused" "$(printf '%s' "$OUT" | grep -c 'exit=1')" "1"
53
+ check "two running, no id: lists both ids" "$(printf '%s' "$OUT" | grep -c 'wi-a wi-b')" "1"
54
+ check "two running, no id: nothing delivered" "$(grep -c '/terminal/' "$STUB_LOG")" "0"
55
+
56
+ # An explicit id: the hand-over text carries it and its route; the notice names it.
57
+ reset
58
+ run '{"sessionName":"dev-1","to":"dev-2","reason":"swap","workItemId":"wi-b"}' >/dev/null
59
+ check "explicit id: hand-over names the WorkItem and its route" \
60
+ "$(grep '/terminal/dev-2' "$STUB_BODIES" | grep -c 'WorkItem: wi-b {route: work-item wi-b}')" "1"
61
+ check "explicit id: [HANDOFF] notice carries the workItemId" \
62
+ "$(grep '/chat/agent-response' "$STUB_BODIES" | grep -c '"workItemId":"wi-b"')" "1"
63
+
64
+ # One running item, no id: that one is used.
65
+ printf 'wi-only\n' > "$STUB_RUNNING"; reset
66
+ run '{"sessionName":"dev-1","to":"dev-2","reason":"swap"}' >/dev/null
67
+ check "one running, no id: hands over that one" "$(grep -c 'POST /api/task-pool/items/wi-only/handoff' "$STUB_LOG")" "1"
68
+
69
+ echo "handoff-task: $PASS passed, $FAIL failed"
70
+ [ "$FAIL" -eq 0 ]
@@ -56,6 +56,11 @@ a `[SLACK-THREAD:<key>]` tag in your prompt — the reply lands in exactly that
56
56
  thread. Answer each thread in its own thread; never bundle answers for
57
57
  different threads into one message.
58
58
 
59
+ In a Slack team channel a post with no `--thread` is refused
60
+ (`409 thread_required`): it is never put at the channel's top level by
61
+ itself. Pass the thread from your prompt; use `send-message` to reach a
62
+ colleague; pass `--new-thread` only for a deliberate new top-level post.
63
+
59
64
  To post in a **Crewly channel** you are a member of (a room shared with
60
65
  agents of other teams — `list-channels` shows yours), pass its name with a
61
66
  `#`, or its id:
@@ -159,11 +164,13 @@ with a non-zero code.
159
164
 
160
165
  A message from another agent arrives as `[MESSAGE from <name> (<team>) — a
161
166
  colleague, not the owner. Answer with send-message --to <session>; …]`.
162
- Answer it with `send-message --to <session>`. A post into the owner's thread
163
- that only @-mentions agents, sent while your latest message came from an
164
- agent, is refused (`409 agent_mention_in_owner_thread`) with the
165
- send-message command to run instead. If the owner should read it, post it
166
- without the @-mention.
167
+ Answer it with `send-message --to <session>`. Agents never talk to each
168
+ other in the owner's thread: a post into it that only @-mentions agents is
169
+ not posted. It is delivered privately to the agents it @-mentions (the
170
+ skill prints `"posted":false,"deliveredPrivately":true,"to":[…]`), or, when
171
+ that is not possible, refused (`409 agent_mention_in_owner_thread`) with the
172
+ send-message command to run instead. Post in the owner's thread only your
173
+ final answer to the owner, without the @-mention.
167
174
 
168
175
  ## What not to put in a channel
169
176
 
@@ -40,6 +40,9 @@ Options:
40
40
  "<you> is working on it…" in the Slack thread, which your
41
41
  real reply then replaces. Use it when a message was only
42
42
  passed to you to judge, and you have decided to answer.
43
+ --new-thread A deliberate new top-level post in a Slack team room. Without
44
+ --thread or --new-thread such a post is refused there
45
+ (409 thread_required), never put at the top level silently.
43
46
  --adds-new Post even though the agent chosen to answer the owner
44
47
  already replied in this thread -- when you have something
45
48
  new, or when you were asked (without it such a post is
@@ -66,6 +69,7 @@ WORKING=""
66
69
  HANDOFF=""
67
70
  MESSAGE_REF=""
68
71
  ADDS_NEW=""
72
+ NEW_THREAD=""
69
73
  FILES_JSON="[]"
70
74
 
71
75
  # Detect legacy JSON argument as $1
@@ -92,6 +96,10 @@ while [[ $# -gt 0 ]]; do
92
96
  ADDS_NEW="1"
93
97
  shift
94
98
  ;;
99
+ --new-thread)
100
+ NEW_THREAD="1"
101
+ shift
102
+ ;;
95
103
  --handoff)
96
104
  HANDOFF="$2"
97
105
  shift 2
@@ -252,7 +260,7 @@ fi
252
260
  # Build JSON body. Export CONTENT/CMID so python can read them safely without
253
261
  # quoting the values through the shell (avoids escaping hell for multi-line
254
262
  # content with quotes, backslashes, backticks, etc.).
255
- BODY=$(CONTENT="$CONTENT" CMID="$CMID" THREAD_ID="$THREAD_ID" INTERIM="${INTERIM:-}" ADDS_NEW="$ADDS_NEW" FILES_JSON="$FILES_JSON" PROJECT_PATH="${CREWLY_PROJECT_PATH:-$PWD}" python3 -c '
263
+ BODY=$(CONTENT="$CONTENT" CMID="$CMID" THREAD_ID="$THREAD_ID" INTERIM="${INTERIM:-}" ADDS_NEW="$ADDS_NEW" NEW_THREAD="$NEW_THREAD" FILES_JSON="$FILES_JSON" PROJECT_PATH="${CREWLY_PROJECT_PATH:-$PWD}" python3 -c '
256
264
  import os, json
257
265
  p = {"content": os.environ["CONTENT"], "contentType": "markdown"}
258
266
  files = json.loads(os.environ.get("FILES_JSON", "[]"))
@@ -264,6 +272,8 @@ if os.environ.get("INTERIM"):
264
272
  p["interim"] = True
265
273
  if os.environ.get("ADDS_NEW"):
266
274
  p["addsNew"] = True
275
+ if os.environ.get("NEW_THREAD"):
276
+ p["newThread"] = True
267
277
  cmid = os.environ.get("CMID", "")
268
278
  if cmid:
269
279
  p["clientMessageId"] = cmid
@@ -285,7 +295,12 @@ try:
285
295
  except Exception:
286
296
  print(json.dumps({"success": True, "channelId": os.environ["CHANNEL_ID"], "raw": raw}, separators=(",", ":")))
287
297
  raise SystemExit(0)
288
- mid = (d.get("data") or {}).get("id", "")
298
+ data = d.get("data") or {}
299
+ if data.get("deliveredPrivately"):
300
+ # R6: an agents-only post never lands in the owner thread; it went to the agents.
301
+ print(json.dumps({"success": True, "posted": False, "deliveredPrivately": True, "to": data.get("to", []), "note": data.get("note", "")}, separators=(",", ":"), ensure_ascii=False))
302
+ raise SystemExit(0)
303
+ mid = data.get("id", "")
289
304
  out = {"success": True}
290
305
  if mid:
291
306
  out["messageId"] = mid
@@ -42,10 +42,16 @@ class H(BaseHTTPRequestHandler):
42
42
  }
43
43
  with open(LOG_PATH, 'w') as f:
44
44
  f.write(json.dumps(log))
45
- self.send_response(201)
45
+ if '@Atlas' in body:
46
+ # R6: an agents-only post in an owner thread goes to the agent privately.
47
+ self.send_response(200)
48
+ reply = {'success': True, 'data': {'deliveredPrivately': True, 'to': ['tt-atlas-1'], 'note': 'Not posted in the owner thread'}}
49
+ else:
50
+ self.send_response(201)
51
+ reply = {'success': True, 'data': {'id': 'm-stub-123', 'channelId': 'chan-xyz'}}
46
52
  self.send_header('Content-Type', 'application/json')
47
53
  self.end_headers()
48
- self.wfile.write(json.dumps({'success': True, 'data': {'id': 'm-stub-123', 'channelId': 'chan-xyz'}}).encode('utf-8'))
54
+ self.wfile.write(json.dumps(reply).encode('utf-8'))
49
55
  def do_GET(self):
50
56
  # Channel lookup by name (reply-channel --channel '#name').
51
57
  if self.path.startswith('/api/channels/%23tech-brief'):
@@ -198,6 +204,13 @@ else
198
204
  PASS=$((PASS + 1)); echo " ✓ a plain reply carries no files list"
199
205
  fi
200
206
 
207
+ echo "test: an agents-only post delivered privately says it was not posted (R6)"
208
+ : > "$STUB_LOG"
209
+ OUT=$(run_skill --channel chan-xyz --content "@Atlas the numbers" 2>&1 || true)
210
+ assert_contains "reports posted:false" "$OUT" '"posted":false'
211
+ assert_contains "reports deliveredPrivately" "$OUT" '"deliveredPrivately":true'
212
+ assert_contains "names the agent it went to" "$OUT" 'tt-atlas-1'
213
+
201
214
  echo
202
215
  echo "========================================"
203
216
  echo "reply-channel skill: PASS=$PASS FAIL=$FAIL"
@@ -59,7 +59,7 @@ reply in the same Slack thread.
59
59
 
60
60
  ```bash
61
61
  bash config/skills/agent/core/slack-post/execute.sh \
62
- --target "#general" \
62
+ --target "#general" --new-topic \
63
63
  --text "Deploy finished, staging is on 1.4.2."
64
64
  ```
65
65
 
@@ -71,7 +71,7 @@ bash config/skills/agent/core/slack-post/execute.sh --target "@steve" --text "..
71
71
  bash config/skills/agent/core/slack-post/execute.sh --target C0123ABCD --thread 1712345678.000100 --text "..."
72
72
 
73
73
  # Long text from a file, or piped
74
- bash .../execute.sh --target "#general" --text-file /tmp/report.md
74
+ bash .../execute.sh --target "#general" --new-topic --text-file /tmp/report.md
75
75
  echo "..." | bash .../execute.sh --target "@steve"
76
76
  ```
77
77
 
@@ -111,7 +111,7 @@ two are told apart. Write ids exactly as Slack shows them.
111
111
  | `--text` | `-m` | Message text (required unless piped or `--text-file`) |
112
112
  | `--text-file` | | Read the text from a file |
113
113
  | `--thread` | `-t` | Slack thread timestamp to reply inside, or the `[SLACK-THREAD:<key>]` key from your prompt |
114
- | `--new-topic` | | A new top-level message. Use it for scheduled output (a briefing, a triage summary): without it, a post into a conversation where you still owe an answer from the last 30 minutes goes into that thread as the answer |
114
+ | `--new-topic` | | A new top-level message (scheduled output, a briefing, a new subject). **A channel post needs `--thread` or `--new-topic`**: with neither it is refused (409 `thread_required`), never placed by guess. A post with no thread where you still owe an answer is refused too, and the error names that thread — pass it as `--thread` |
115
115
  | `--file` | `-f` | Upload a file into the channel/thread right after the text (repeatable, up to 5; relative paths resolve against your project). Sent by your own bot, or the workspace bot with your name when you have none. Output has `filesNotSent` with Slack's error code if one did not go |
116
116
  | `--json` | `-j` | Raw JSON payload |
117
117
 
@@ -48,7 +48,7 @@ The script auto-resolves `config/skills/...` references to absolute paths so del
48
48
  | `--force-cross-team` | `forceCrossTeam` | No | Allow cross-team delegation |
49
49
  | `--request-id` / `-R` | `requestId` | No | Ticket (Request) this work is for — the id or `TKT-123` from the `[TICKET:TKT-123 <id>]` line of the message you are acting on. Links the WorkItem into `Request.workItemIds[]`. Omit it and the WorkItem is still linked when your current turn has exactly one ticket |
50
50
  | `--ticket` | `ticket` | No | Project ticket this work is for (e.g. `APP-12`). Without it a ticket is created for you when the target works on a project — see Project tickets |
51
- | `--thread` | `thread` | No | The owner's Slack thread this work was asked / approved in — the key from the `[SLACK-THREAD:<key>]` tag of the owner's message. Gives the work the owner origin (see Owner work below) |
51
+ | `--thread` | `thread` | No | The owner's Slack thread this work was asked / approved in — the key from the `[SLACK-THREAD:<key>]` tag of the owner's message. A `<conversation id>:<chat thread id>` pair from the chat works too. Gives the work the owner origin (see Owner work below) |
52
52
  | `--conversation` | `conversation` | No | The owner's Crewly chat conversation this work was asked / approved in — the id from `[CHAT:<id>]`. Same as `--thread` for chat |
53
53
  | `--fallback-minutes` | — | No | Minutes until the ONE §3.0 fallback check fires for this delegation (default `120` ≈ 2× a TL milestone ETA). `0` disables it. Not cancelled on completion — keep it at 2× ETA, never a poll interval |
54
54
 
@@ -185,9 +185,11 @@ if [ -n "$IMAGE_PATH" ]; then
185
185
  --arg filePath "$IMAGE_PATH" \
186
186
  --arg initialComment "${TEXT:-}" \
187
187
  --arg threadTs "${THREAD_TS:-}" \
188
+ --argjson allowNewThread "$ALLOW_NEW_THREAD" \
188
189
  '{channelId: $channelId, filePath: $filePath} +
189
190
  (if $initialComment != "" then {initialComment: $initialComment} else {} end) +
190
- (if $threadTs != "" then {threadTs: $threadTs} else {} end)')
191
+ (if $threadTs != "" then {threadTs: $threadTs} else {} end) +
192
+ (if $allowNewThread then {allowNewThread: true} else {} end)')
191
193
 
192
194
  api_call POST "/slack/upload-image" "$BODY"
193
195
  elif [ -n "$FILE_PATH" ]; then
@@ -197,9 +199,11 @@ elif [ -n "$FILE_PATH" ]; then
197
199
  --arg filePath "$FILE_PATH" \
198
200
  --arg initialComment "${TEXT:-}" \
199
201
  --arg threadTs "${THREAD_TS:-}" \
202
+ --argjson allowNewThread "$ALLOW_NEW_THREAD" \
200
203
  '{channelId: $channelId, filePath: $filePath} +
201
204
  (if $initialComment != "" then {initialComment: $initialComment} else {} end) +
202
- (if $threadTs != "" then {threadTs: $threadTs} else {} end)')
205
+ (if $threadTs != "" then {threadTs: $threadTs} else {} end) +
206
+ (if $allowNewThread then {allowNewThread: true} else {} end)')
203
207
 
204
208
  api_call POST "/slack/upload-file" "$BODY"
205
209
  else
@@ -63,7 +63,8 @@ bash config/skills/orchestrator/send-pdf-to-slack/execute.sh \
63
63
  | `--file`, `-f` | Yes | Path to the markdown file to convert |
64
64
  | `--title`, `-T` | No | Title for the uploaded PDF (defaults to filename) |
65
65
  | `--text`, `-t` | No | Initial comment to include with the upload |
66
- | `--thread`, `-r` | No | Slack thread timestamp for threaded upload |
66
+ | `--thread`, `-r` | No | The Slack thread timestamp the PDF answers. Without it the harness picks the thread of your work, or the upload is refused (409 `thread_unresolved`) — it is never posted at the top level by default |
67
+ | `--allow-new-thread` | No | Post at the channel's top level on purpose (a new thread) |
67
68
 
68
69
  ## Example: Daily Report with Infographics
69
70