crewly 1.20.435 → 1.20.437
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/config/skills/agent/core/reproduce-ui/SKILL.md +35 -13
- package/config/skills/agent/core/reproduce-ui/execute.sh +14 -5
- package/config/skills/agent/core/reproduce-ui/execute.test.sh +72 -2
- package/config/skills/agent/core/reproduce-ui/reproduce.mjs +173 -3
- package/config/sops/developer/debugging.md +1 -1
- package/dist/backend/build-info.json +2 -2
- package/package.json +1 -1
- package/packages/crewly-agent/src/runtime/agent-runner.service.test.ts +59 -0
- package/packages/crewly-agent/src/runtime/agent-runner.service.ts +84 -3
- package/packages/crewly-agent/src/runtime/subagent.test.ts +466 -0
- package/packages/crewly-agent/src/runtime/subagent.ts +510 -0
- package/packages/crewly-agent/src/runtime/tool-registry.ts +4 -0
- package/packages/crewly-agent/src/runtime/types.ts +36 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: Reproduce UI
|
|
3
|
-
description:
|
|
4
|
-
version: 1.
|
|
3
|
+
description: Turn a UI bug report (a screenshot and a sentence) into evidence - match it to a feature-map entry, open that feature in a headless browser and save screenshot, console errors, failed requests and timings, plus one line for the ticket - before you reproduce or fix it.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
category: development
|
|
6
6
|
skillType: claude-skill
|
|
7
7
|
assignableRoles:
|
|
@@ -17,6 +17,7 @@ triggers:
|
|
|
17
17
|
- reproduce
|
|
18
18
|
- screenshot bug
|
|
19
19
|
- UI bug
|
|
20
|
+
- bug report
|
|
20
21
|
- feature map
|
|
21
22
|
tags:
|
|
22
23
|
- frontend
|
|
@@ -39,6 +40,10 @@ for every route how a user reaches it, what it should do and which
|
|
|
39
40
|
`data-testid` anchors it has; this skill turns one map entry into a run.
|
|
40
41
|
|
|
41
42
|
```bash
|
|
43
|
+
# From the bug report: the owner's sentence plus the words you read on the
|
|
44
|
+
# screenshot (headings, tab names, button labels). Picks the feature, runs it.
|
|
45
|
+
bash execute.sh --report "runs tab is blank — Tickets, Board · Requests · Runs"
|
|
46
|
+
|
|
42
47
|
# By route (tabs go in the query string, as the map shows them)
|
|
43
48
|
bash execute.sh --route "/tickets?tab=runs"
|
|
44
49
|
|
|
@@ -55,20 +60,37 @@ bash execute.sh --feature Usage --dry-run
|
|
|
55
60
|
What you get back is one JSON line with `dir`: the evidence folder under
|
|
56
61
|
`$CREWLY_HOME/evidence/<utc stamp>-<route>/` holding `screenshot.png`,
|
|
57
62
|
`console.json` (console errors, warnings and page errors), `requests.json`
|
|
58
|
-
(failed requests and responses ≥ 400), `report.md` and `summary.json
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
(failed requests and responses ≥ 400), `report.md` and `summary.json`; its
|
|
64
|
+
`ticketLine` is the one line to put in the ticket ("Reproduced `/x`: key
|
|
65
|
+
element NOT found; 1 console error; 2 failed requests (first: POST /api/b →
|
|
66
|
+
500); evidence: <dir>"). Exit 0 means the key element appeared; exit 2 means
|
|
67
|
+
it did not (the evidence is still there — that is the bug, or the wrong
|
|
68
|
+
selector); exit 1 is a usage or browser problem; exit 3 means the report
|
|
69
|
+
matched no single feature (the candidates are on stderr — pass `--route`).
|
|
70
|
+
|
|
71
|
+
How `--report` matches: every word of the report is looked up in each
|
|
72
|
+
feature's title and **Expected** line (2 points), its route, **Reach** and
|
|
73
|
+
**Elements** (1 point), and its tab names (4 points, and the tab becomes
|
|
74
|
+
`?tab=`). A feature wins with two or more matching words, 3+ points and half
|
|
75
|
+
again as much as the runner-up. The words on the screenshot are what make
|
|
76
|
+
it unambiguous: a report of "blank page" alone matches nothing; "blank page,
|
|
77
|
+
Tickets, Runs" opens `/tickets?tab=runs`. Read the screenshot first and put
|
|
78
|
+
what you see into the report text.
|
|
62
79
|
|
|
63
80
|
How to use it on a report:
|
|
64
81
|
|
|
65
|
-
1.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
1. Write the report text: the owner's sentence, then the headings, tab
|
|
83
|
+
names and labels you can read on the screenshot. Run `--report` with it
|
|
84
|
+
(or `--report-file`). If it exits 3, pick the route from the candidates it
|
|
85
|
+
printed and run `--route`.
|
|
86
|
+
2. Compare `screenshot.png` with the owner's screenshot; read `console.json`
|
|
87
|
+
and `requests.json` first — a failed `/api/...` call explains most blank
|
|
88
|
+
or stale pages. `report.md` quotes the report and says which words chose
|
|
89
|
+
the feature, so a wrong match is visible at a glance.
|
|
90
|
+
3. Put `ticketLine` in the ticket (the evidence folder stays on this
|
|
91
|
+
machine; quote the folder path and the key lines, not the files), then
|
|
92
|
+
fix. After the fix, run it again and put the second line under the first
|
|
93
|
+
as proof.
|
|
72
94
|
|
|
73
95
|
Notes:
|
|
74
96
|
|
|
@@ -21,11 +21,15 @@ print_usage() {
|
|
|
21
21
|
Usage:
|
|
22
22
|
bash execute.sh --route /tickets?tab=runs Open a route, wait for its key element, save evidence
|
|
23
23
|
bash execute.sh --feature "Run detail" --param id=<uuid> Resolve the route from the feature map
|
|
24
|
+
bash execute.sh --report "runs tab is blank, Tickets" Match a bug report to a feature, then run it
|
|
24
25
|
bash execute.sh --route /wiki --wait '[data-testid="wiki-recent-list"]' --full-page
|
|
25
26
|
|
|
26
27
|
Options:
|
|
27
28
|
--route Path to open (with ?tab= when the page has tabs)
|
|
28
29
|
--feature A route or a word from a Routes row of FEATURE_MAP.md (needs the map)
|
|
30
|
+
--report A bug report: the owner's sentence plus the words you read on the
|
|
31
|
+
screenshot; scored against the map's Features (needs the map)
|
|
32
|
+
--report-file The same, read from a file
|
|
29
33
|
--param k=v Value for a :param in the route (repeatable)
|
|
30
34
|
--wait CSS selector to wait for (default: the feature's first data-testid, else body)
|
|
31
35
|
--map FEATURE_MAP.md path (default: <project>/frontend/FEATURE_MAP.md when present)
|
|
@@ -38,17 +42,20 @@ Options:
|
|
|
38
42
|
--help | -h Show this help
|
|
39
43
|
|
|
40
44
|
Output: one JSON line with the evidence folder (screenshot.png, console.json,
|
|
41
|
-
requests.json, report.md, summary.json
|
|
42
|
-
|
|
45
|
+
requests.json, report.md, summary.json; summary.ticketLine is the line to put
|
|
46
|
+
in the ticket). Exit 0 when the element appeared, 2 when it did not (evidence
|
|
47
|
+
still saved), 1 on a usage or browser error, 3 when the report matches no
|
|
48
|
+
single feature (candidates on stderr; pass --route).
|
|
43
49
|
EOF_USAGE
|
|
44
50
|
}
|
|
45
51
|
|
|
46
|
-
ROUTE=""; FEATURE=""; WAIT=""; MAP=""; BASE_URL=""; OUT=""; TIMEOUT=""; FULL_PAGE=""; BROWSER=""; DRY_RUN=""
|
|
52
|
+
ROUTE=""; FEATURE=""; REPORT=""; WAIT=""; MAP=""; BASE_URL=""; OUT=""; TIMEOUT=""; FULL_PAGE=""; BROWSER=""; DRY_RUN=""
|
|
47
53
|
PARAMS=()
|
|
48
54
|
|
|
49
55
|
if [[ $# -gt 0 && ${1:0:1} == '{' ]]; then
|
|
50
56
|
ROUTE=$(printf '%s' "$1" | jq -r '.route // empty')
|
|
51
57
|
FEATURE=$(printf '%s' "$1" | jq -r '.feature // empty')
|
|
58
|
+
REPORT=$(printf '%s' "$1" | jq -r '.report // empty')
|
|
52
59
|
WAIT=$(printf '%s' "$1" | jq -r '.wait // empty')
|
|
53
60
|
MAP=$(printf '%s' "$1" | jq -r '.map // empty')
|
|
54
61
|
BASE_URL=$(printf '%s' "$1" | jq -r '.baseUrl // empty')
|
|
@@ -64,6 +71,8 @@ while [[ $# -gt 0 ]]; do
|
|
|
64
71
|
case "$1" in
|
|
65
72
|
--route) [ $# -ge 2 ] || error_exit "--route requires a value"; ROUTE="$2"; shift 2 ;;
|
|
66
73
|
--feature) [ $# -ge 2 ] || error_exit "--feature requires a value"; FEATURE="$2"; shift 2 ;;
|
|
74
|
+
--report) [ $# -ge 2 ] || error_exit "--report requires text"; REPORT="$2"; shift 2 ;;
|
|
75
|
+
--report-file) [ $# -ge 2 ] || error_exit "--report-file requires a path"; [ -f "$2" ] || error_exit "--report-file not found: $2"; REPORT="$(cat "$2")"; shift 2 ;;
|
|
67
76
|
--param) [ $# -ge 2 ] || error_exit "--param requires k=v"; PARAMS+=("$2"); shift 2 ;;
|
|
68
77
|
--wait) [ $# -ge 2 ] || error_exit "--wait requires a selector"; WAIT="$2"; shift 2 ;;
|
|
69
78
|
--map) [ $# -ge 2 ] || error_exit "--map requires a path"; MAP="$2"; shift 2 ;;
|
|
@@ -78,7 +87,7 @@ while [[ $# -gt 0 ]]; do
|
|
|
78
87
|
esac
|
|
79
88
|
done
|
|
80
89
|
|
|
81
|
-
[ -n "$ROUTE" ] || [ -n "$FEATURE" ] || error_exit "one of --route or --
|
|
90
|
+
[ -n "$ROUTE" ] || [ -n "$FEATURE" ] || [ -n "$REPORT" ] || error_exit "one of --route, --feature or --report is required (--help for usage)"
|
|
82
91
|
command -v node >/dev/null 2>&1 || error_exit "node is required"
|
|
83
92
|
|
|
84
93
|
# The map: explicit, else the project's checkout, else the package's own copy (a repo install).
|
|
@@ -95,6 +104,6 @@ for kv in "${PARAMS[@]+"${PARAMS[@]}"}"; do
|
|
|
95
104
|
export "RU_PARAM_${key}=${val}"
|
|
96
105
|
done
|
|
97
106
|
|
|
98
|
-
RU_MAIN=1 RU_ROUTE="$ROUTE" RU_FEATURE="$FEATURE" RU_WAIT="$WAIT" RU_MAP="$MAP" RU_BASE_URL="$BASE_URL" \
|
|
107
|
+
RU_MAIN=1 RU_ROUTE="$ROUTE" RU_FEATURE="$FEATURE" RU_REPORT="$REPORT" RU_WAIT="$WAIT" RU_MAP="$MAP" RU_BASE_URL="$BASE_URL" \
|
|
99
108
|
RU_OUT="$OUT" RU_TIMEOUT_MS="$TIMEOUT" RU_FULL_PAGE="$FULL_PAGE" RU_BROWSER="$BROWSER" RU_DRY_RUN="$DRY_RUN" \
|
|
100
109
|
RU_PKG_ROOT="$PKG_ROOT" node "${SCRIPT_DIR}/reproduce.mjs"
|
|
@@ -34,19 +34,22 @@ cat > "$MAP" <<'EOF_MAP'
|
|
|
34
34
|
|
|
35
35
|
### Dashboard (`/`)
|
|
36
36
|
|
|
37
|
+
- **Expected**: "Waiting on you" first, then "Crew now" with the health bar.
|
|
37
38
|
- **Elements**: `dashboard`, `crew-now`.
|
|
38
39
|
|
|
39
40
|
### Tickets hub (`/tickets`, tabs `board` · `runs`)
|
|
40
41
|
|
|
42
|
+
- **Expected**: Board = kanban columns; Runs = one row per work item.
|
|
41
43
|
- **Elements**: `tickets-panel-${tab}`, `tickets-page`, `tickets-board`.
|
|
42
44
|
|
|
43
45
|
### Run detail (`/tickets/runs/:id`)
|
|
44
46
|
|
|
47
|
+
- **Expected**: the work item's header, metrics and timeline.
|
|
45
48
|
- **Elements**: `workitem-detail-loading|error|not-found`, `workitem-detail`.
|
|
46
49
|
|
|
47
50
|
### Usage (`/usage`)
|
|
48
51
|
|
|
49
|
-
- **Expected**: one headline.
|
|
52
|
+
- **Expected**: one headline with tokens by team.
|
|
50
53
|
EOF_MAP
|
|
51
54
|
export CREWLY_API_URL="http://127.0.0.1:18799"
|
|
52
55
|
export CREWLY_HOME="$T/home"
|
|
@@ -60,7 +63,7 @@ check "help names --feature" "$(printf '%s' "$OUT" | grep -c -- '--feature')" "$
|
|
|
60
63
|
# ---- no route / feature ----
|
|
61
64
|
ERR=$(bash "$EXEC" --dry-run 2>&1 >/dev/null); RC=$?
|
|
62
65
|
check "no route: exit 1" "$RC" "1"
|
|
63
|
-
check "no route: error says so" "$(printf '%s' "$ERR" | jq -r .error | grep -c 'route or --
|
|
66
|
+
check "no route: error says so" "$(printf '%s' "$ERR" | jq -r .error | grep -c 'route, --feature or --report')" "1"
|
|
64
67
|
|
|
65
68
|
# ---- route + map: wait selector from the Elements line; ${} and a|b skipped ----
|
|
66
69
|
OUT=$(bash "$EXEC" --route "/tickets?tab=runs" --map "$MAP" --dry-run)
|
|
@@ -113,6 +116,73 @@ OUT=$(bash "$EXEC" '{"route":"/usage","map":"'"$MAP"'","dryRun":true,"params":{"
|
|
|
113
116
|
check "json input: route" "$(printf '%s' "$OUT" | jq -r .path)" "/usage"
|
|
114
117
|
check "json input: fullPage" "$(printf '%s' "$OUT" | jq -r .fullPage)" "true"
|
|
115
118
|
|
|
119
|
+
# ---- --report: a bug report matched to a feature (slice 3) ----
|
|
120
|
+
OUT=$(bash "$EXEC" --report "the runs tab under tickets is blank" --map "$MAP" --dry-run)
|
|
121
|
+
check "report: tab word picks the hub with ?tab=" "$(printf '%s' "$OUT" | jq -r .route)" "/tickets?tab=runs"
|
|
122
|
+
check "report: wait = that tab's panel" "$(printf '%s' "$OUT" | jq -r .waitFor)" '[data-testid="tickets-panel-runs"]'
|
|
123
|
+
check "report: match title" "$(printf '%s' "$OUT" | jq -r .match.title)" "Tickets hub"
|
|
124
|
+
check "report: matched words (plural dropped)" "$(printf '%s' "$OUT" | jq -c .match.matched)" '["run","ticket"]'
|
|
125
|
+
check "report: runner-up listed" "$(printf '%s' "$OUT" | jq -r '.match.candidates[0].route')" "/tickets/runs/:id"
|
|
126
|
+
check "report: text kept in the plan" "$(printf '%s' "$OUT" | jq -r .report)" "the runs tab under tickets is blank"
|
|
127
|
+
|
|
128
|
+
OUT=$(bash "$EXEC" --report "Tokens by team headline missing on the usage page" --map "$MAP" --dry-run)
|
|
129
|
+
check "report: Expected words pick Usage" "$(printf '%s' "$OUT" | jq -r .route)" "/usage"
|
|
130
|
+
|
|
131
|
+
OUT=$(bash "$EXEC" --report "Waiting on you never loads, Crew now spins" --map "$MAP" --dry-run)
|
|
132
|
+
check "report: Dashboard by its Expected words" "$(printf '%s' "$OUT" | jq -r .route)" "/"
|
|
133
|
+
|
|
134
|
+
ERR=$(bash "$EXEC" --report "nothing happens at all" --map "$MAP" --dry-run 2>&1 >/dev/null); RC=$?
|
|
135
|
+
check "report with no match: exit 3" "$RC" "3"
|
|
136
|
+
check "report with no match: says so" "$(printf '%s' "$ERR" | jq -r .error | grep -c 'matches no feature')" "1"
|
|
137
|
+
|
|
138
|
+
ERR=$(bash "$EXEC" --report "tickets" --map "$MAP" --dry-run 2>&1 >/dev/null); RC=$?
|
|
139
|
+
check "one-word report: exit 3" "$RC" "3"
|
|
140
|
+
check "one-word report: asks for more words" "$(printf '%s' "$ERR" | jq -r .error | grep -c 'too weakly')" "1"
|
|
141
|
+
check "one-word report: best candidate first" "$(printf '%s' "$ERR" | jq -r '.candidates[0].route')" "/tickets"
|
|
142
|
+
|
|
143
|
+
ERR=$(bash "$EXEC" --report "the work item is wrong" --map "$MAP" --dry-run 2>&1 >/dev/null); RC=$?
|
|
144
|
+
check "even report: exit 3" "$RC" "3"
|
|
145
|
+
check "even report: says equally" "$(printf '%s' "$ERR" | jq -r .error | grep -c 'about equally')" "1"
|
|
146
|
+
check "even report: lists candidates" "$(printf '%s' "$ERR" | jq -r '.candidates | length' )" "2"
|
|
147
|
+
|
|
148
|
+
printf 'runs tab under\ntickets is blank\n' > "$T/report.txt"
|
|
149
|
+
OUT=$(bash "$EXEC" --report-file "$T/report.txt" --map "$MAP" --dry-run)
|
|
150
|
+
check "report-file: same match" "$(printf '%s' "$OUT" | jq -r .route)" "/tickets?tab=runs"
|
|
151
|
+
ERR=$(bash "$EXEC" --report-file "$T/nope.txt" --map "$MAP" --dry-run 2>&1 >/dev/null); RC=$?
|
|
152
|
+
check "report-file missing: exit 1" "$RC" "1"
|
|
153
|
+
|
|
154
|
+
OUT=$(bash "$EXEC" '{"report":"runs tab under tickets","map":"'"$MAP"'","dryRun":true}')
|
|
155
|
+
check "json input: report" "$(printf '%s' "$OUT" | jq -r .route)" "/tickets?tab=runs"
|
|
156
|
+
|
|
157
|
+
OUT=$(bash "$EXEC" --route /usage --report "runs tab under tickets" --map "$MAP" --dry-run)
|
|
158
|
+
check "route beats report" "$(printf '%s' "$OUT" | jq -r .route)" "/usage"
|
|
159
|
+
check "route beats report: no match block" "$(printf '%s' "$OUT" | jq -r .match)" "null"
|
|
160
|
+
|
|
161
|
+
ERR=$(bash "$EXEC" --report "runs tab" --map "$T/missing.md" --dry-run 2>&1 >/dev/null); RC=$?
|
|
162
|
+
check "report without map: exit 1" "$RC" "1"
|
|
163
|
+
check "report without map: explains" "$(printf '%s' "$ERR" | jq -r .error | grep -c 'needs the feature map')" "1"
|
|
164
|
+
|
|
165
|
+
# ---- ticketLine / report sections (pure functions, no browser) ----
|
|
166
|
+
OUT=$(cd "$HERE" && node --input-type=module -e '
|
|
167
|
+
import { ticketLine, renderReport } from "./reproduce.mjs";
|
|
168
|
+
const reqs = [{ url: "http://x/api/a", method: "GET", status: 404 }, { url: "http://x/api/b", method: "POST", status: 500 }];
|
|
169
|
+
const s = { route: "/tickets?tab=runs", url: "http://x/tickets?tab=runs", finalUrl: "", title: "T", waitFor: "[data-testid=\"p\"]", waitSource: "map",
|
|
170
|
+
found: false, error: "Timeout 60000ms", timings: { navigationMs: 1, elementMs: null, totalMs: 2 }, consoleErrors: 1, failedRequests: 2, dir: "/e/1",
|
|
171
|
+
screenshot: null, feature: null, report: "runs tab blank\nsecond line", match: { title: "Tickets hub", score: 6, matched: ["run", "ticket"] } };
|
|
172
|
+
s.ticketLine = ticketLine(s, reqs);
|
|
173
|
+
console.log(s.ticketLine);
|
|
174
|
+
console.log(renderReport(s, [{ type: "error", text: "boom" }], reqs));
|
|
175
|
+
')
|
|
176
|
+
check "ticketLine: not found + 5xx first" "$(printf '%s' "$OUT" | sed -n 1p)" 'Reproduced `/tickets?tab=runs`: key element `[data-testid="p"]` NOT found (Timeout 60000ms); 1 console error; 2 failed requests (first: POST http://x/api/b → 500); evidence: /e/1'
|
|
177
|
+
check "report.md: match line" "$(printf '%s' "$OUT" | grep -c 'Matched from the report: Tickets hub (score 6, words: run, ticket)')" "1"
|
|
178
|
+
check "report.md: ticket section" "$(printf '%s' "$OUT" | grep -c '^## For the ticket')" "1"
|
|
179
|
+
check "report.md: report quoted" "$(printf '%s' "$OUT" | grep -c '^> second line')" "1"
|
|
180
|
+
OUT=$(cd "$HERE" && node --input-type=module -e '
|
|
181
|
+
import { ticketLine } from "./reproduce.mjs";
|
|
182
|
+
console.log(ticketLine({ route: "/", found: true, consoleErrors: 0, failedRequests: 0, dir: "/e/2" }, []));
|
|
183
|
+
')
|
|
184
|
+
check "ticketLine: rendered, nothing failed" "$OUT" 'Reproduced `/`: rendered; 0 console errors; 0 failed requests; evidence: /e/2'
|
|
185
|
+
|
|
116
186
|
ERR=$(bash "$EXEC" --route relative --dry-run 2>&1 >/dev/null); RC=$?
|
|
117
187
|
check "route must start with /" "$(printf '%s' "$ERR" | jq -r .error | grep -c 'must start with /')" "1"
|
|
118
188
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* reproduce-ui — open a dashboard feature by its feature-map entry and save
|
|
3
|
-
* evidence (#811
|
|
3
|
+
* evidence (#811 slices 2–3, specs/2026-10-26-feature-map.md).
|
|
4
4
|
*
|
|
5
5
|
* Driven by execute.sh through environment variables:
|
|
6
6
|
* RU_ROUTE route to open, e.g. `/tickets?tab=runs` (or RU_FEATURE)
|
|
7
7
|
* RU_FEATURE a word of a Routes row of the map, resolved to its route
|
|
8
|
+
* RU_REPORT a bug report (the owner's sentence plus the words read off
|
|
9
|
+
* the screenshot); matched to one Features section of the map
|
|
8
10
|
* RU_WAIT CSS selector to wait for; default = the first data-testid
|
|
9
11
|
* of the feature's Elements line in the map, else `body`
|
|
10
12
|
* RU_MAP path of FEATURE_MAP.md (optional; lookups need it)
|
|
@@ -18,7 +20,8 @@
|
|
|
18
20
|
*
|
|
19
21
|
* Output: one JSON line on stdout — the plan (dry run) or the summary with the
|
|
20
22
|
* evidence folder. Exit 0 when the element appeared, 2 when it did not (the
|
|
21
|
-
* evidence is still written), 1 on a usage or launch error
|
|
23
|
+
* evidence is still written), 1 on a usage or launch error, 3 when a report
|
|
24
|
+
* matches no single feature (the candidates are on stderr).
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
27
|
import { createRequire } from 'node:module';
|
|
@@ -131,6 +134,128 @@ export function defaultWaitSelector(markdown, route, tab) {
|
|
|
131
134
|
return null;
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Words that say nothing about which feature a report is about.
|
|
139
|
+
*/
|
|
140
|
+
const REPORT_STOPWORDS = new Set([
|
|
141
|
+
'the', 'and', 'for', 'with', 'this', 'that', 'there', 'then', 'than', 'when', 'what', 'where', 'which', 'who',
|
|
142
|
+
'why', 'how', 'but', 'not', 'nothing', 'none', 'all', 'any', 'some', 'are', 'was', 'were', 'has', 'have',
|
|
143
|
+
'had', 'does', 'did', 'can', 'cannot', 'could', 'should', 'would', 'will', 'just', 'still', 'again', 'also',
|
|
144
|
+
'only', 'very', 'too', 'from', 'into', 'onto', 'out', 'over', 'under', 'after', 'before', 'about', 'here',
|
|
145
|
+
'see', 'sees', 'seen', 'shows', 'show', 'shown', 'showing', 'looks', 'look', 'like', 'seems', 'says', 'said',
|
|
146
|
+
'page', 'pages', 'screen', 'screenshot', 'tab', 'tabs', 'click', 'clicked', 'clicking', 'open', 'opens',
|
|
147
|
+
'opened', 'opening', 'get', 'gets', 'got', 'its', 'you', 'your', 'our', 'they', 'them', 'their', 'one', 'two',
|
|
148
|
+
'bug', 'broken', 'wrong', 'blank', 'empty', 'missing', 'error', 'errors', 'fails', 'failed', 'failing',
|
|
149
|
+
'loading', 'loads', 'load', 'stuck', 'forever', 'instead', 'please', 'fix', 'crewly', 'dashboard',
|
|
150
|
+
'something', 'anything', 'everything', 'thing', 'things', 'off', 'yet', 'now',
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The comparable words of a text: lower-case, letters and digits only,
|
|
155
|
+
* three characters or more, no stopwords, a trailing plural `s` dropped so
|
|
156
|
+
* that "runs" meets "run".
|
|
157
|
+
*
|
|
158
|
+
* @param {string} text - Any text
|
|
159
|
+
* @returns {Set<string>}
|
|
160
|
+
*/
|
|
161
|
+
export function reportTokens(text) {
|
|
162
|
+
const out = new Set();
|
|
163
|
+
for (const raw of (text || '').toLowerCase().split(/[^a-z0-9]+/)) {
|
|
164
|
+
if (raw.length < 3 || REPORT_STOPWORDS.has(raw)) continue;
|
|
165
|
+
out.add(raw.length > 3 && raw.endsWith('s') && !raw.endsWith('ss') ? raw.slice(0, -1) : raw);
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* One Features section of the map, broken into the word sets a report is
|
|
172
|
+
* scored against.
|
|
173
|
+
*
|
|
174
|
+
* @typedef {object} FeatureBlock
|
|
175
|
+
* @property {string} title - The heading without the parenthesis
|
|
176
|
+
* @property {string} route - The first route in the heading
|
|
177
|
+
* @property {string[]} tabs - Tab names from `tabs \`a\` · \`b\``
|
|
178
|
+
* @property {Set<string>} strong - Title and Expected words (weight 2)
|
|
179
|
+
* @property {Set<string>} weak - Route segments, Reach, Elements words (weight 1)
|
|
180
|
+
*/
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The Features sections of the map.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} markdown - FEATURE_MAP.md
|
|
186
|
+
* @returns {FeatureBlock[]}
|
|
187
|
+
*/
|
|
188
|
+
export function featureBlocks(markdown) {
|
|
189
|
+
const blocks = [];
|
|
190
|
+
for (const block of markdownSection(markdown, '## Features').split(/\n(?=### )/)) {
|
|
191
|
+
const lines = block.split('\n');
|
|
192
|
+
const heading = lines[0] ?? '';
|
|
193
|
+
if (!heading.startsWith('### ')) continue;
|
|
194
|
+
const route = /`(\/[^`]*)`/.exec(heading)?.[1];
|
|
195
|
+
if (!route) continue;
|
|
196
|
+
const title = heading.slice(4).replace(/\s*\(.*$/, '').trim();
|
|
197
|
+
const tabsText = /tabs\s+((?:`[^`]+`\s*(?:·\s*)?)+)/.exec(heading)?.[1] ?? '';
|
|
198
|
+
const tabs = [...tabsText.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
|
|
199
|
+
const lineOf = (label) => lines.find((l) => l.includes(`**${label}**`)) ?? '';
|
|
200
|
+
const strong = reportTokens(`${title} ${lineOf('Expected')}`);
|
|
201
|
+
const weak = reportTokens(`${route.replace(/:[A-Za-z_]+/g, ' ')} ${lineOf('Reach')} ${lineOf('Elements').replace(/[-_$]/g, ' ')}`);
|
|
202
|
+
blocks.push({ title, route, tabs, strong, weak });
|
|
203
|
+
}
|
|
204
|
+
return blocks;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Score every feature against a bug report: a report word found in the
|
|
209
|
+
* feature's title or Expected line counts 2, in its route, Reach or
|
|
210
|
+
* Elements 1, and a word that is one of the feature's tab names 4 (the tab
|
|
211
|
+
* also becomes the `?tab=` of the route). The best feature wins when at
|
|
212
|
+
* least two words matched, it scores 3 or more, and half again as much as
|
|
213
|
+
* the runner-up; otherwise the caller gets the candidates and asks for
|
|
214
|
+
* `--route`. One word is never enough: "Usage" alone is a `--feature`.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} markdown - FEATURE_MAP.md
|
|
217
|
+
* @param {string} report - The owner's sentence plus words read off the screenshot
|
|
218
|
+
* @returns {{chosen: object|null, candidates: object[], tokens: string[]}}
|
|
219
|
+
* candidates: `{route, title, score, matched}` sorted best first (score > 0 only)
|
|
220
|
+
*/
|
|
221
|
+
export function matchReport(markdown, report) {
|
|
222
|
+
const tokens = [...reportTokens(report)];
|
|
223
|
+
const scored = [];
|
|
224
|
+
for (const b of featureBlocks(markdown)) {
|
|
225
|
+
let score = 0;
|
|
226
|
+
const matched = [];
|
|
227
|
+
let tab = null;
|
|
228
|
+
for (const t of tokens) {
|
|
229
|
+
const tabHit = b.tabs.find((name) => reportTokens(name).has(t));
|
|
230
|
+
const w = tabHit ? 4 : b.strong.has(t) ? 2 : b.weak.has(t) ? 1 : 0;
|
|
231
|
+
if (!w) continue;
|
|
232
|
+
score += w;
|
|
233
|
+
matched.push(t);
|
|
234
|
+
if (tabHit && !tab) tab = tabHit;
|
|
235
|
+
}
|
|
236
|
+
if (score > 0) scored.push({ route: tab ? `${b.route}?tab=${tab}` : b.route, title: b.title, score, matched });
|
|
237
|
+
}
|
|
238
|
+
scored.sort((a, c) => c.score - a.score || a.route.localeCompare(c.route));
|
|
239
|
+
const [best, second] = scored;
|
|
240
|
+
const confident = best && best.matched.length >= 2 && best.score >= 3 && (!second || best.score >= second.score * 1.5);
|
|
241
|
+
return { chosen: confident ? best : null, candidates: scored.slice(0, 5), tokens };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Why a report could not be matched, for the error line.
|
|
246
|
+
*
|
|
247
|
+
* @param {{candidates: object[]}} m - From {@link matchReport}
|
|
248
|
+
* @returns {string}
|
|
249
|
+
*/
|
|
250
|
+
export function matchFailure(m) {
|
|
251
|
+
if (!m.candidates.length) return 'the report matches no feature in the map; add the words you see on the screenshot, or pass --route';
|
|
252
|
+
const best = m.candidates.map((c) => `${c.route} [${c.score}]`).join(', ');
|
|
253
|
+
if (m.candidates.length === 1 || m.candidates[0].score >= m.candidates[1].score * 1.5) {
|
|
254
|
+
return `the report matches a feature too weakly (one word only, or under 3 points); add the words you see on the screenshot, or pass --route (best: ${best})`;
|
|
255
|
+
}
|
|
256
|
+
return `the report matches several features about equally; pass --route (best: ${best})`;
|
|
257
|
+
}
|
|
258
|
+
|
|
134
259
|
/**
|
|
135
260
|
* The Routes row whose pattern matches an opened path (`/tickets/runs/abc`
|
|
136
261
|
* → `/tickets/runs/:id`); an exact match wins, the query string is ignored.
|
|
@@ -191,6 +316,25 @@ export function buildPlan() {
|
|
|
191
316
|
const map = mapPath ? readFileSync(mapPath, 'utf8') : null;
|
|
192
317
|
let route = env.RU_ROUTE || '';
|
|
193
318
|
let row = null;
|
|
319
|
+
let match = null;
|
|
320
|
+
if (!route && !env.RU_FEATURE && env.RU_REPORT) {
|
|
321
|
+
if (!map) fail(`--report needs the feature map; pass --map or --route (looked for ${env.RU_MAP || 'FEATURE_MAP.md'})`);
|
|
322
|
+
const m = matchReport(map, env.RU_REPORT);
|
|
323
|
+
if (!m.chosen) {
|
|
324
|
+
process.stderr.write(
|
|
325
|
+
`${JSON.stringify({
|
|
326
|
+
ok: false,
|
|
327
|
+
error: matchFailure(m),
|
|
328
|
+
candidates: m.candidates,
|
|
329
|
+
tokens: m.tokens,
|
|
330
|
+
})}\n`,
|
|
331
|
+
);
|
|
332
|
+
process.exit(3);
|
|
333
|
+
}
|
|
334
|
+
route = m.chosen.route;
|
|
335
|
+
match = { ...m.chosen, candidates: m.candidates.slice(1) };
|
|
336
|
+
row = matchRouteRow(routeRows(map), route);
|
|
337
|
+
}
|
|
194
338
|
if (!route && env.RU_FEATURE) {
|
|
195
339
|
if (!map) fail(`--feature needs the feature map; pass --map or --route (looked for ${env.RU_MAP || 'FEATURE_MAP.md'})`);
|
|
196
340
|
const r = resolveFeature(map, env.RU_FEATURE);
|
|
@@ -200,7 +344,7 @@ export function buildPlan() {
|
|
|
200
344
|
} else if (route && map) {
|
|
201
345
|
row = matchRouteRow(routeRows(map), route);
|
|
202
346
|
}
|
|
203
|
-
if (!route) fail('one of --route or --
|
|
347
|
+
if (!route) fail('one of --route, --feature or --report is required');
|
|
204
348
|
if (!route.startsWith('/')) fail('--route must start with /');
|
|
205
349
|
const params = {};
|
|
206
350
|
for (const [k, v] of Object.entries(env)) if (k.startsWith('RU_PARAM_') && v) params[k.slice('RU_PARAM_'.length)] = v;
|
|
@@ -221,10 +365,30 @@ export function buildPlan() {
|
|
|
221
365
|
fullPage: env.RU_FULL_PAGE === '1',
|
|
222
366
|
map: mapPath,
|
|
223
367
|
feature: row ? { page: row.page, reach: row.reach, job: row.job } : null,
|
|
368
|
+
report: env.RU_REPORT || null,
|
|
369
|
+
match,
|
|
224
370
|
outRoot,
|
|
225
371
|
};
|
|
226
372
|
}
|
|
227
373
|
|
|
374
|
+
/**
|
|
375
|
+
* One line for the ticket: what was opened, whether it rendered, and what
|
|
376
|
+
* the console and network said, with the evidence folder.
|
|
377
|
+
*
|
|
378
|
+
* @param {object} s - Summary
|
|
379
|
+
* @param {Array<object>} requests - Failed requests
|
|
380
|
+
* @returns {string}
|
|
381
|
+
*/
|
|
382
|
+
export function ticketLine(s, requests) {
|
|
383
|
+
const worst = requests.find((r) => r.status >= 500) ?? requests.find((r) => r.failure) ?? requests[0];
|
|
384
|
+
const parts = [
|
|
385
|
+
`Reproduced \`${s.route}\`: ${s.found ? 'rendered' : `key element \`${s.waitFor}\` NOT found${s.error ? ` (${s.error})` : ''}`}`,
|
|
386
|
+
`${s.consoleErrors} console error${s.consoleErrors === 1 ? '' : 's'}`,
|
|
387
|
+
`${s.failedRequests} failed request${s.failedRequests === 1 ? '' : 's'}${worst ? ` (first: ${worst.method} ${worst.url} → ${worst.status ?? worst.failure})` : ''}`,
|
|
388
|
+
];
|
|
389
|
+
return `${parts.join('; ')}; evidence: ${s.dir}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
228
392
|
/**
|
|
229
393
|
* Load playwright-core from the Crewly package.
|
|
230
394
|
*
|
|
@@ -320,7 +484,10 @@ async function run(plan) {
|
|
|
320
484
|
dir,
|
|
321
485
|
screenshot: existsSync(screenshot) ? screenshot : null,
|
|
322
486
|
feature: plan.feature,
|
|
487
|
+
report: plan.report,
|
|
488
|
+
match: plan.match,
|
|
323
489
|
};
|
|
490
|
+
summary.ticketLine = ticketLine(summary, requests);
|
|
324
491
|
writeFileSync(join(dir, 'console.json'), `${JSON.stringify(console_, null, 2)}\n`);
|
|
325
492
|
writeFileSync(join(dir, 'requests.json'), `${JSON.stringify(requests, null, 2)}\n`);
|
|
326
493
|
writeFileSync(join(dir, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`);
|
|
@@ -348,6 +515,9 @@ export function renderReport(s, console_, requests) {
|
|
|
348
515
|
`- Screenshot: ${s.screenshot ? 'screenshot.png' : '(none)'}`,
|
|
349
516
|
];
|
|
350
517
|
if (s.feature) lines.push(`- Feature: ${s.feature.job} — reached from ${s.feature.reach}; page \`${s.feature.page}\``);
|
|
518
|
+
if (s.match) lines.push(`- Matched from the report: ${s.match.title} (score ${s.match.score}, words: ${s.match.matched.join(', ')})`);
|
|
519
|
+
if (s.ticketLine) lines.push('', '## For the ticket', '', s.ticketLine);
|
|
520
|
+
if (s.report) lines.push('', '## Report', '', ...s.report.split('\n').map((l) => `> ${l}`));
|
|
351
521
|
lines.push('', `## Console errors (${console_.length})`, '');
|
|
352
522
|
lines.push(...(console_.length ? console_.map((c) => `- [${c.type}] ${c.text}`) : ['- none']));
|
|
353
523
|
lines.push('', `## Failed requests (${requests.length})`, '');
|
|
@@ -39,7 +39,7 @@ Name **one command** you have already run at least once that shows the user's ex
|
|
|
39
39
|
|
|
40
40
|
## 2. Reproduce and minimise
|
|
41
41
|
|
|
42
|
-
For a dashboard (UI) report, run the `reproduce-ui` skill on the feature first: `bash config/skills/agent/core/reproduce-ui/execute.sh --
|
|
42
|
+
For a dashboard (UI) report, run the `reproduce-ui` skill on the feature first. A screenshot and a sentence is enough: read the screenshot, then `bash config/skills/agent/core/reproduce-ui/execute.sh --report "<the sentence> <headings, tab names and labels you see>"` matches it to a `frontend/FEATURE_MAP.md` entry and opens it (exit 3 = ambiguous: it lists the candidate routes, run again with `--route`; `--feature <word>` and `--route "/tickets?tab=runs"` work when you already know the page). Read its `console.json` and `requests.json` before the screenshot — a failed `/api/...` call explains most blank or stale pages. Put its `ticketLine` (from the JSON it prints, also in `report.md`) in the ticket; after the fix run it again and add the second line as proof.
|
|
43
43
|
|
|
44
44
|
Cut inputs one at a time. Done when every remaining element is load-bearing.
|
|
45
45
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crewly",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.437",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Multi-agent orchestration platform for AI coding teams — coordinates Claude Code, Gemini CLI, and Codex agents with a real-time web dashboard",
|
|
6
6
|
"workspaces": [
|
|
@@ -253,6 +253,65 @@ describe('AgentRunnerService', () => {
|
|
|
253
253
|
});
|
|
254
254
|
});
|
|
255
255
|
|
|
256
|
+
describe('sub-agents (#1317)', () => {
|
|
257
|
+
const okResult = {
|
|
258
|
+
text: 'done',
|
|
259
|
+
steps: [{ toolCalls: [], toolResults: [] }],
|
|
260
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
261
|
+
finishReason: 'stop',
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
it('gives a normal agent spawn_subagent on top of the full tool set', async () => {
|
|
265
|
+
await runner.initialize();
|
|
266
|
+
mockGenerateText.mockResolvedValueOnce(okResult);
|
|
267
|
+
await runner.run('hi');
|
|
268
|
+
const tools = mockGenerateText.mock.calls[0][0].tools as Record<string, unknown>;
|
|
269
|
+
expect(tools.spawn_subagent).toBeDefined();
|
|
270
|
+
expect(tools.report_to_parent).toBeUndefined();
|
|
271
|
+
expect(tools.send_message).toBeDefined();
|
|
272
|
+
expect(tools.read_file).toBeDefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('gives a sub-agent only the confined file/search/shell tools plus report_to_parent', async () => {
|
|
276
|
+
const child = new AgentRunnerService(
|
|
277
|
+
{
|
|
278
|
+
...baseConfig,
|
|
279
|
+
sessionName: 'test-session/sub-1',
|
|
280
|
+
projectPath: '/tmp/proj',
|
|
281
|
+
subagent: { cwd: '/tmp/proj', readOnly: false, depth: 1, parentSession: 'test-session' },
|
|
282
|
+
},
|
|
283
|
+
mockModelManager,
|
|
284
|
+
mockApiClient,
|
|
285
|
+
);
|
|
286
|
+
child._generateTextFn = mockGenerateText;
|
|
287
|
+
await child.initialize();
|
|
288
|
+
mockGenerateText.mockResolvedValueOnce(okResult);
|
|
289
|
+
await child.run('task');
|
|
290
|
+
const tools = mockGenerateText.mock.calls[0][0].tools as Record<string, unknown>;
|
|
291
|
+
expect(tools.report_to_parent).toBeDefined();
|
|
292
|
+
expect(tools.read_file).toBeDefined();
|
|
293
|
+
expect(tools.bash_exec).toBeDefined();
|
|
294
|
+
expect(tools.spawn_subagent).toBeUndefined();
|
|
295
|
+
expect(tools.send_message).toBeUndefined();
|
|
296
|
+
expect(tools.delegate_task).toBeUndefined();
|
|
297
|
+
expect(tools.remember).toBeUndefined();
|
|
298
|
+
expect(child.getSubagentReport()).toBeNull();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('folds a child\'s tokens into the parent\'s next run result and totals, once', async () => {
|
|
302
|
+
await runner.initialize();
|
|
303
|
+
runner.recordChildUsage({ input: 1000, output: 200, cachedInput: 50 });
|
|
304
|
+
mockGenerateText.mockResolvedValueOnce(okResult);
|
|
305
|
+
const first = await runner.run('one');
|
|
306
|
+
expect(first.usage).toEqual({ input: 1010, output: 205, cachedInput: 50 });
|
|
307
|
+
expect(runner.getState().totalTokens).toEqual({ input: 1010, output: 205 });
|
|
308
|
+
|
|
309
|
+
mockGenerateText.mockResolvedValueOnce(okResult);
|
|
310
|
+
const second = await runner.run('two');
|
|
311
|
+
expect(second.usage).toEqual({ input: 10, output: 5, cachedInput: 0 });
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
256
315
|
describe('serial queue', () => {
|
|
257
316
|
beforeEach(async () => {
|
|
258
317
|
await runner.initialize();
|