crewly 1.20.450 → 1.20.452

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 (40) hide show
  1. package/config/skills/agent/checkup-report/SKILL.md +22 -5
  2. package/config/skills/agent/checkup-report/app/index.html +10 -0
  3. package/config/skills/agent/checkup-report/execute.sh +6 -2
  4. package/config/skills/agent/checkup-report/execute.test.sh +37 -0
  5. package/config/skills/agent/checkup-report/report.mjs +92 -5
  6. package/config/skills/agent/spawn-subagent/SKILL.md +105 -0
  7. package/config/skills/agent/spawn-subagent/execute.sh +114 -0
  8. package/config/skills/agent/spawn-subagent/execute.test.sh +113 -0
  9. package/config/templates/small-business-checkup.json +22 -0
  10. package/dist/backend/backend/src/controllers/subagents/subagents.controller.d.ts +27 -0
  11. package/dist/backend/backend/src/controllers/subagents/subagents.controller.d.ts.map +1 -0
  12. package/dist/backend/backend/src/controllers/subagents/subagents.controller.js +61 -0
  13. package/dist/backend/backend/src/controllers/subagents/subagents.controller.js.map +1 -0
  14. package/dist/backend/backend/src/controllers/subagents/subagents.routes.d.ts +15 -0
  15. package/dist/backend/backend/src/controllers/subagents/subagents.routes.d.ts.map +1 -0
  16. package/dist/backend/backend/src/controllers/subagents/subagents.routes.js +21 -0
  17. package/dist/backend/backend/src/controllers/subagents/subagents.routes.js.map +1 -0
  18. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  19. package/dist/backend/backend/src/routes/api.routes.js +3 -0
  20. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  21. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
  22. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +8 -2
  23. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
  24. package/dist/backend/backend/src/services/agent/crewly-agent/subagent-run.service.d.ts +179 -0
  25. package/dist/backend/backend/src/services/agent/crewly-agent/subagent-run.service.d.ts.map +1 -0
  26. package/dist/backend/backend/src/services/agent/crewly-agent/subagent-run.service.js +345 -0
  27. package/dist/backend/backend/src/services/agent/crewly-agent/subagent-run.service.js.map +1 -0
  28. package/dist/backend/backend/src/services/agent/crewly-agent/types.d.ts +33 -0
  29. package/dist/backend/backend/src/services/agent/crewly-agent/types.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/agent/crewly-agent/types.js +15 -0
  31. package/dist/backend/backend/src/services/agent/crewly-agent/types.js.map +1 -1
  32. package/dist/backend/build-info.json +2 -2
  33. package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
  34. package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +8 -2
  35. package/dist/cli/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
  36. package/dist/cli/backend/src/services/agent/crewly-agent/types.d.ts +33 -0
  37. package/dist/cli/backend/src/services/agent/crewly-agent/types.d.ts.map +1 -1
  38. package/dist/cli/backend/src/services/agent/crewly-agent/types.js +15 -0
  39. package/dist/cli/backend/src/services/agent/crewly-agent/types.js.map +1 -1
  40. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: Check-up Report
3
- description: The intake and report steps of the small-business check-up as one Crewly App. Copies the bundled App page (five intake questions, the report with the top 3 breaks and what an agent could do, a 7-day daily summary) into the project for publish-app, turns checkup-analyse's breaks.json into the App's report document (with the number the owner asked to see first), and writes daily-summary documents for app-data.
4
- version: 1.0.0
3
+ description: The intake and report steps of the small-business check-up as one Crewly App. Copies the bundled App page (five intake questions, the report with the top 3 breaks and what an agent could do, a 7-day daily summary) into the project for publish-app, turns checkup-analyse's breaks.json into the App's report document (with the number the owner asked to see first), writes daily-summary documents for app-data, and keeps a per-step time log (--log-step) the report shows.
4
+ version: 1.1.0
5
5
  category: productivity
6
6
  skillType: claude-skill
7
7
  assignableRoles:
@@ -12,6 +12,7 @@ triggers:
12
12
  - publish the check-up
13
13
  - intake questions
14
14
  - daily check-up summary
15
+ - log a check-up step
15
16
  tags:
16
17
  - check-up
17
18
  - small-business
@@ -84,10 +85,26 @@ bash app-data --app <appId> --set daily 2026-10-27 --data-file ./checkup-data/da
84
85
  Say what changed and what was done, in the owner's language, under 300
85
86
  characters. Seven entries, then stop unless the owner asks for more.
86
87
 
87
- ## Time log
88
+ The free **Small Business Check-up** team template (`small-business-checkup`)
89
+ runs these steps with a Checkup Lead, a Data Analyst and an optional Reviewer
90
+ who checks `report-doc.json` before it is published; remove the Reviewer from
91
+ the team to skip that step.
88
92
 
89
- Record the start and end of each step (intake, connect, analyse, report) in
90
- your work notes; the check-up is measured by how long it really takes.
93
+ ## Time log — how long the check-up really took
94
+
95
+ Mark the start and end of every step; the report shows the minutes per step
96
+ and the total, and the owner sees them under "How long it took".
97
+
98
+ ```bash
99
+ bash execute.sh --log-step intake --start --out ./checkup-data # steps: intake, connect, analyse, report, daily
100
+ bash execute.sh --log-step intake --end --out ./checkup-data
101
+ ```
102
+
103
+ `--out` is the same directory as the report; the log is `timelog.json` there
104
+ and `--breaks` folds it in as `timing` (closed steps with minutes, the total,
105
+ and any step still open). A second `--start` on an open step restarts it;
106
+ `--end` with no open entry fails (exit 2) so a forgotten start is noticed.
107
+ `--at <iso>` back-fills a time you know. One `daily` entry per day is fine.
91
108
 
92
109
  ## Report back
93
110
 
@@ -68,6 +68,7 @@
68
68
  <div id="notes-wrap" class="hidden"><h2>Could not check</h2><ul class="plain" id="notes" data-crewly-id="notes"></ul></div>
69
69
  <h2>Files</h2>
70
70
  <ul class="plain" id="files" data-crewly-id="files"></ul>
71
+ <div id="timing-wrap" class="hidden"><h2>How long it took</h2><ul class="plain" id="timing" data-crewly-id="timing"></ul></div>
71
72
  <h2>Assumptions</h2>
72
73
  <ul class="plain" id="assumptions" data-crewly-id="assumptions"></ul>
73
74
  <button class="secondary" id="ask" data-crewly-id="ask">Ask about this report</button>
@@ -193,6 +194,15 @@
193
194
  (r.files || []).forEach(function (f) { files.appendChild(el('li', null, f.file + ' — ' + f.source + ' ' + f.kind + ', ' + f.rows + ' rows')); });
194
195
  var as = $('assumptions'); as.replaceChildren();
195
196
  Object.keys(r.assumptions || {}).forEach(function (k) { as.appendChild(el('li', null, k + ': ' + r.assumptions[k])); });
197
+ var timing = $('timing'); timing.replaceChildren();
198
+ var t = r.timing;
199
+ if (t && t.steps && t.steps.length) {
200
+ t.steps.forEach(function (s) {
201
+ timing.appendChild(el('li', null, s.step + ': ' + (s.endedAt ? s.minutes + ' min' : 'still running')));
202
+ });
203
+ timing.appendChild(el('li', 'note', 'Total ' + t.totalMinutes + ' min of agent time' + (t.open && t.open.length ? ' so far' : '')));
204
+ }
205
+ $('timing-wrap').classList.toggle('hidden', !(t && t.steps && t.steps.length));
196
206
  }
197
207
  $('ask').onclick = function () {
198
208
  crewly.notify('The owner opened the check-up report and tapped "Ask about this report". Reply in their DM with a one-line offer to walk through the top 3.').then(function () {
@@ -9,6 +9,8 @@
9
9
  # bash execute.sh --app-dir ./checkup-app # copy the App page
10
10
  # bash execute.sh --breaks …/analysis/breaks.json --business "Oak Pottery" --intake ./intake.json --out ./checkup-data
11
11
  # bash execute.sh --daily "Replied to 3 open inquiries; 1 booked." --out ./checkup-data
12
+ # bash execute.sh --log-step intake --start --out ./checkup-data # per-step time log (timelog.json)
13
+ # bash execute.sh '{"logStep":"intake","mark":"end","out":"./checkup-data"}'
12
14
  # bash execute.sh '{"breaks":"…/breaks.json","business":"Oak Pottery","out":"./checkup-data"}'
13
15
  #
14
16
  # Output: one JSON line with the files written and the exact next command
@@ -23,13 +25,15 @@ command -v node >/dev/null 2>&1 || { printf '{"success":false,"error":"node is r
23
25
  ARGS=()
24
26
  if [[ $# -gt 0 && ${1:0:1} == '{' ]]; then
25
27
  command -v jq >/dev/null 2>&1 || { printf '{"success":false,"error":"jq is required to read JSON input"}\n'; exit 1; }
26
- for key in appDir breaks business intake examples out daily date; do
28
+ for key in appDir breaks business intake examples out daily date logStep at; do
27
29
  v=$(printf '%s' "$1" | jq -r --arg k "$key" '.[$k] // empty')
28
30
  if [ -n "$v" ]; then
29
- case "$key" in appDir) flag=--app-dir ;; *) flag="--$key" ;; esac
31
+ case "$key" in appDir) flag=--app-dir ;; logStep) flag=--log-step ;; *) flag="--$key" ;; esac
30
32
  ARGS+=("$flag" "$v")
31
33
  fi
32
34
  done
35
+ mark=$(printf '%s' "$1" | jq -r '.mark // empty')
36
+ case "$mark" in start|end) ARGS+=("--$mark") ;; esac
33
37
  shift
34
38
  fi
35
39
  ARGS+=("$@")
@@ -133,5 +133,42 @@ bash "$EXEC" --daily " " --out "$T/x" >/dev/null 2>&1; check "empty daily exit
133
133
  bash "$EXEC" --breaks "$T/an/breaks.json" --examples lots --out "$T/x" >/dev/null 2>&1; check "bad examples exits 1" "$?" "1"
134
134
  bash "$EXEC" --bogus >/dev/null 2>&1; check "unknown flag exits 1" "$?" "1"
135
135
 
136
+ # 9. --log-step: per-step time log
137
+ check "page has a timing block" "$(grep -c 'data-crewly-id="timing"' "$PAGE")" "1"
138
+ bash "$EXEC" --log-step intake --start --at 2026-10-26T09:00:00Z --out "$T/tl" >/dev/null; check "log start exits 0" "$?" "0"
139
+ check "timelog written" "$([ -f "$T/tl/timelog.json" ] && echo yes || echo no)" "yes"
140
+ check "open entry" "$(jq -c '.steps[0] | {step, endedAt, minutes}' "$T/tl/timelog.json")" '{"step":"intake","endedAt":null,"minutes":null}'
141
+ LOUT="$(bash "$EXEC" --log-step intake --end --at 2026-10-26T09:12:30Z --out "$T/tl")"
142
+ check "log end exits 0" "$?" "0"
143
+ check "minutes = 12.5" "$(jq -r '.steps[0].minutes' "$T/tl/timelog.json")" "12.5"
144
+ check "end output carries the entry" "$(printf '%s' "$LOUT" | jq -r '.entry.endedAt')" "2026-10-26T09:12:30.000Z"
145
+ check "end output totals" "$(printf '%s' "$LOUT" | jq -c '.timing | {totalMinutes, open}')" '{"totalMinutes":12.5,"open":[]}'
146
+ bash "$EXEC" --log-step connect --start --at 2026-10-26T09:13:00Z --out "$T/tl" >/dev/null
147
+ bash "$EXEC" --log-step connect --start --at 2026-10-26T09:14:00Z --out "$T/tl" >/dev/null
148
+ check "restart replaces the open start" "$(jq -r '[.steps[] | select(.step=="connect")] | length, .[0].startedAt' "$T/tl/timelog.json" | tr '\n' ' ')" "1 2026-10-26T09:14:00.000Z "
149
+ bash "$EXEC" --log-step connect --end --at 2026-10-26T10:14:00Z --out "$T/tl" >/dev/null
150
+ bash "$EXEC" --log-step analyse --start --at 2026-10-26T10:15:00Z --out "$T/tl" >/dev/null
151
+ bash "$EXEC" --log-step analyse --end --at 2026-10-26T10:21:00Z --out "$T/tl" >/dev/null
152
+ bash "$EXEC" --log-step report --start --at 2026-10-26T10:22:00Z --out "$T/tl" >/dev/null
153
+ JLOG="$(bash "$EXEC" "{\"logStep\":\"report\",\"mark\":\"end\",\"at\":\"2026-10-26T10:30:00Z\",\"out\":\"$T/tl\"}")"
154
+ check "json input logs a step" "$(printf '%s' "$JLOG" | jq -r '.entry.minutes')" "8"
155
+ check "four closed steps" "$(jq -r '[.steps[] | select(.endedAt)] | length' "$T/tl/timelog.json")" "4"
156
+ check "same step twice keeps both" "$(bash "$EXEC" --log-step daily --start --at 2026-10-27T09:00:00Z --out "$T/tl" >/dev/null; bash "$EXEC" --log-step daily --end --at 2026-10-27T09:03:00Z --out "$T/tl" >/dev/null; bash "$EXEC" --log-step daily --start --at 2026-10-28T09:00:00Z --out "$T/tl" >/dev/null; jq -r '[.steps[] | select(.step=="daily")] | length' "$T/tl/timelog.json")" "2"
157
+ bash "$EXEC" --breaks "$T/an/breaks.json" --business "Oak Pottery" --out "$T/tl" >/dev/null
158
+ check "report folds the time log in" "$(jq -c '.timing | {totalMinutes, open, n: (.steps|length)}' "$T/tl/report-doc.json")" '{"totalMinutes":89.5,"open":["daily"],"n":6}'
159
+ check "timing steps carry minutes" "$(jq -r '.timing.steps[1] | "\(.step) \(.minutes)"' "$T/tl/report-doc.json")" "connect 60"
160
+ check "no time log → no timing" "$(jq -r 'has("timing")' "$D")" "false"
161
+ bash "$EXEC" --log-step cook --start --out "$T/tl" >/dev/null 2>&1; check "unknown step exits 1" "$?" "1"
162
+ bash "$EXEC" --log-step analyse --end --out "$T/tl" >/dev/null 2>&1; check "end without start exits 2" "$?" "2"
163
+ bash "$EXEC" --log-step intake --out "$T/tl" >/dev/null 2>&1; check "log-step without --start/--end exits 1" "$?" "1"
164
+ bash "$EXEC" --start --out "$T/tl" >/dev/null 2>&1; check "--start without --log-step exits 1" "$?" "1"
165
+ bash "$EXEC" --log-step intake --start >/dev/null 2>&1; check "log-step without --out exits 1" "$?" "1"
166
+ bash "$EXEC" --log-step intake --start --at yesterday --out "$T/tl" >/dev/null 2>&1; check "bad --at exits 1" "$?" "1"
167
+ bash "$EXEC" --log-step intake --start --at 2026-10-26T09:00:00Z --out "$T/tl2" >/dev/null
168
+ bash "$EXEC" --log-step intake --end --at 2026-10-26T08:00:00Z --out "$T/tl2" >/dev/null 2>&1; check "end before start exits 1" "$?" "1"
169
+ printf '{"nope":1}' > "$T/tl3.json"; mkdir -p "$T/tl3"; cp "$T/tl3.json" "$T/tl3/timelog.json"
170
+ bash "$EXEC" --log-step intake --start --out "$T/tl3" >/dev/null 2>&1; check "broken timelog exits 2" "$?" "2"
171
+ bash "$EXEC" --breaks "$T/an/breaks.json" --out "$T/tl3" >/dev/null 2>&1; check "report with broken timelog exits 2" "$?" "2"
172
+
136
173
  echo "checkup-report: $PASS passed, $FAIL failed"
137
174
  [ "$FAIL" -eq 0 ]
@@ -10,6 +10,8 @@
10
10
  // node report.mjs --app-dir ./checkup-app # copy the App page to publish
11
11
  // node report.mjs --breaks …/breaks.json --business "Oak Pottery" [--intake answers.json] --out ./checkup-data
12
12
  // node report.mjs --daily "Replied to 3 open inquiries; 1 booked." [--date 2026-10-27] --out ./checkup-data
13
+ // node report.mjs --log-step intake --start --out ./checkup-data # per-step time log → timelog.json
14
+ // node report.mjs --log-step intake --end --out ./checkup-data
13
15
  // =============================================================================
14
16
  import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, readdirSync, statSync } from 'node:fs';
15
17
  import { join, resolve, dirname } from 'node:path';
@@ -20,6 +22,9 @@ const APP_SRC = join(HERE, 'app');
20
22
  const DEFAULT_EXAMPLES = 3;
21
23
  /** Crewly App documents are capped at 256 KB; stay well under it. */
22
24
  const MAX_DOC_BYTES = 200 * 1024;
25
+ /** The check-up's steps, in order; the time log accepts no other name. */
26
+ export const STEPS = ['intake', 'connect', 'analyse', 'report', 'daily'];
27
+ const TIMELOG_FILE = 'timelog.json';
23
28
 
24
29
  /** The "which number first" choices offered by the intake form, with how each is read off the breaks. */
25
30
  const FIRST_NUMBER_RULES = [
@@ -60,7 +65,8 @@ function examplesFor(b, limit) {
60
65
  /**
61
66
  * Build the report document the App renders from checkup-analyse output.
62
67
  * Items are summarised into a few examples per break so the document stays small
63
- * and no full list of people lands in the App's data.
68
+ * and no full list of people lands in the App's data. `opts.timelog` (from
69
+ * `readTimelog`) adds a `timing` block with minutes per step.
64
70
  */
65
71
  export function buildReportDoc(breaks, opts = {}) {
66
72
  const by = Object.fromEntries(breaks.breaks.map((b) => [b.type, b]));
@@ -91,9 +97,58 @@ export function buildReportDoc(breaks, opts = {}) {
91
97
  if (rule) doc.firstNumber = { asked: want, key: rule.key, label: rule.label, value: rule.value(by) };
92
98
  else doc.notes = [...doc.notes, `The owner asked to see "${want}" first; no break maps to it, so the top 3 lead.`];
93
99
  }
100
+ // How long the check-up took, when the agent logged its steps (`--log-step`).
101
+ if (opts.timelog && opts.timelog.steps.length) doc.timing = summariseTimelog(opts.timelog);
94
102
  return doc;
95
103
  }
96
104
 
105
+ /** Read `<dir>/timelog.json`; an absent file is an empty log, a broken one throws. */
106
+ export function readTimelog(dir) {
107
+ const file = join(dir, TIMELOG_FILE);
108
+ if (!existsSync(file)) return { steps: [] };
109
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
110
+ if (!raw || !Array.isArray(raw.steps)) throw new Error(`${file}: not a time log ({ steps: [] })`);
111
+ return raw;
112
+ }
113
+
114
+ /**
115
+ * Record the start or end of a check-up step in the time log.
116
+ *
117
+ * `--start` opens an entry (a second start on a still-open step restarts it);
118
+ * `--end` closes the latest open entry of that step and fills in `minutes`.
119
+ * `at` overrides the clock (ISO timestamp) for back-filling and tests.
120
+ *
121
+ * @returns the updated log and the entry touched
122
+ * @throws when the step is unknown, `--end` finds no open entry, or `at` is not a date
123
+ */
124
+ export function logStep(log, step, mark, at) {
125
+ if (!STEPS.includes(step)) throw new Error(`unknown step "${step}"; use one of ${STEPS.join(', ')}`);
126
+ const when = at ? new Date(at) : new Date();
127
+ if (Number.isNaN(when.getTime())) throw new Error(`--at must be an ISO timestamp, got "${at}"`);
128
+ const iso = when.toISOString();
129
+ const open = [...log.steps].reverse().find((s) => s.step === step && !s.endedAt);
130
+ let entry;
131
+ if (mark === 'start') {
132
+ if (open) { open.startedAt = iso; entry = open; }
133
+ else { entry = { step, startedAt: iso, endedAt: null, minutes: null }; log.steps.push(entry); }
134
+ } else {
135
+ if (!open) throw new Error(`no open "${step}" entry to end; run --log-step ${step} --start first`);
136
+ if (when.getTime() < new Date(open.startedAt).getTime()) throw new Error(`end (${iso}) is before start (${open.startedAt})`);
137
+ open.endedAt = iso;
138
+ open.minutes = Math.round((when.getTime() - new Date(open.startedAt).getTime()) / 6000) / 10;
139
+ entry = open;
140
+ }
141
+ return { log, entry };
142
+ }
143
+
144
+ /** Fold a time log into the shape the report document carries: closed steps with minutes, plus the total. */
145
+ export function summariseTimelog(log) {
146
+ const steps = log.steps.map((s) => ({ step: s.step, startedAt: s.startedAt, endedAt: s.endedAt, minutes: s.minutes }));
147
+ const closed = steps.filter((s) => s.endedAt);
148
+ const totalMinutes = Math.round(closed.reduce((a, s) => a + (s.minutes || 0), 0) * 10) / 10;
149
+ return { steps, totalMinutes, open: steps.filter((s) => !s.endedAt).map((s) => s.step) };
150
+ }
151
+
97
152
  /** Read intake answers from a plain document or from app-data's `{ success, data }` output. */
98
153
  function readIntake(path) {
99
154
  const raw = JSON.parse(readFileSync(path, 'utf8'));
@@ -119,7 +174,7 @@ function out(obj, code) {
119
174
  }
120
175
 
121
176
  function usage() {
122
- out({ success: false, error: 'usage: report.mjs --app-dir <dir> | --breaks <breaks.json> [--business <name>] [--intake <answers.json>] [--examples N] --out <dir> | --daily "<summary>" [--date YYYY-MM-DD] --out <dir>' }, 1);
177
+ out({ success: false, error: 'usage: report.mjs --app-dir <dir> | --breaks <breaks.json> [--business <name>] [--intake <answers.json>] [--examples N] --out <dir> | --daily "<summary>" [--date YYYY-MM-DD] --out <dir> | --log-step <intake|connect|analyse|report|daily> --start|--end [--at <iso>] --out <dir>' }, 1);
123
178
  }
124
179
 
125
180
  function main(argv) {
@@ -135,14 +190,39 @@ function main(argv) {
135
190
  else if (a === '--out') o.out = next();
136
191
  else if (a === '--daily') o.daily = next();
137
192
  else if (a === '--date') o.date = next();
193
+ else if (a === '--log-step') o.logStep = next();
194
+ else if (a === '--start') o.mark = 'start';
195
+ else if (a === '--end') o.mark = 'end';
196
+ else if (a === '--at') o.at = next();
138
197
  else usage();
139
198
  }
140
- if (!o.appDir && !o.breaks && !o.daily) usage();
141
- if ((o.breaks || o.daily) && !o.out) usage();
199
+ if (!o.appDir && !o.breaks && !o.daily && !o.logStep) usage();
200
+ if ((o.breaks || o.daily || o.logStep) && !o.out) usage();
201
+ if (o.logStep && !o.mark) usage();
202
+ if (o.mark && !o.logStep) usage();
142
203
  if (o.date && !/^\d{4}-\d{2}-\d{2}$/.test(o.date)) out({ success: false, error: `--date must be YYYY-MM-DD, got "${o.date}"` }, 1);
143
204
  if (!Number.isFinite(o.examples) || o.examples < 0) out({ success: false, error: '--examples needs a number >= 0' }, 1);
144
205
 
145
206
  const result = { success: true };
207
+ if (o.logStep) {
208
+ let log;
209
+ try {
210
+ log = readTimelog(o.out);
211
+ } catch (e) {
212
+ out({ success: false, error: e.message }, 2);
213
+ }
214
+ let entry;
215
+ try {
216
+ ({ entry } = logStep(log, o.logStep, o.mark, o.at));
217
+ } catch (e) {
218
+ out({ success: false, error: e.message }, o.mark === 'end' && /no open/.test(e.message) ? 2 : 1);
219
+ }
220
+ mkdirSync(o.out, { recursive: true });
221
+ writeFileSync(join(o.out, TIMELOG_FILE), JSON.stringify(log, null, 2));
222
+ result.timelog = resolve(join(o.out, TIMELOG_FILE));
223
+ result.entry = entry;
224
+ result.timing = summariseTimelog(log);
225
+ }
146
226
  if (o.appDir) {
147
227
  result.appDir = resolve(o.appDir);
148
228
  result.copied = copyApp(o.appDir);
@@ -166,7 +246,13 @@ function main(argv) {
166
246
  out({ success: false, error: `${o.intake}: ${e.message}` }, 2);
167
247
  }
168
248
  }
169
- const doc = buildReportDoc(breaks, { business: o.business, intake, examples: o.examples });
249
+ let timelog = null;
250
+ try {
251
+ timelog = readTimelog(o.out);
252
+ } catch (e) {
253
+ out({ success: false, error: e.message }, 2);
254
+ }
255
+ const doc = buildReportDoc(breaks, { business: o.business, intake, examples: o.examples, timelog });
170
256
  const json = JSON.stringify(doc, null, 2);
171
257
  if (Buffer.byteLength(json) > MAX_DOC_BYTES) out({ success: false, error: `report document is ${Buffer.byteLength(json)} bytes; lower --examples` }, 2);
172
258
  mkdirSync(o.out, { recursive: true });
@@ -175,6 +261,7 @@ function main(argv) {
175
261
  result.top3 = doc.top3;
176
262
  result.totals = doc.totals;
177
263
  result.firstNumber = doc.firstNumber || null;
264
+ result.timing = doc.timing || null;
178
265
  result.next = `app-data --app <appId> --set report current --data-file ${join(o.out, 'report-doc.json')}`;
179
266
  }
180
267
  if (o.daily) {
@@ -0,0 +1,105 @@
1
+ ---
2
+ name: Spawn Sub-agent
3
+ description: Run one short-lived helper for a bounded task (a large read, a parallel search, a scoped fix) and get its report back. Works on every runtime - Claude Code, Codex, Gemini, crewly-agent - because the helper runs in Crewly's own in-process runtime, not yours. The helper cannot message, delegate or schedule; it reads and edits under one directory and reports once. Treat the result as evidence, not as done work.
4
+ natural_language_description: Hand a bounded task to a short-lived helper and read its report.
5
+ version: 1.0.0
6
+ category: task-management
7
+ skillType: claude-skill
8
+ assignableRoles:
9
+ - developer
10
+ - qa
11
+ - tpm
12
+ - designer
13
+ - frontend-developer
14
+ - backend-developer
15
+ - fullstack-dev
16
+ - qa-engineer
17
+ - product-manager
18
+ - architect
19
+ - generalist
20
+ - researcher
21
+ - team-leader
22
+ triggers:
23
+ - spawn sub-agent
24
+ - spawn subagent
25
+ - run a helper
26
+ - delegate to helper
27
+ - parallel research
28
+ tags:
29
+ - subagent
30
+ - delegation
31
+ - in-process
32
+ execution:
33
+ type: script
34
+ script:
35
+ file: execute.sh
36
+ interpreter: bash
37
+ timeoutMs: 660000
38
+ ---
39
+
40
+ # Spawn Sub-agent
41
+
42
+ Runs one short-lived helper for a single bounded task and returns its report.
43
+ The helper is a Crewly in-process worker (crewly#1317): it gets read tools
44
+ (`read_file`, `glob`, `grep`, `git_status`, `git_diff`, `web_search`),
45
+ write tools under its directory unless `--read-only`, and `report_to_parent`.
46
+ It cannot message anyone, delegate, schedule, touch tickets, or spawn helpers
47
+ of its own. The call blocks until the helper reports or its budget runs out.
48
+
49
+ Use it for work that is bounded and self-contained and that nobody needs to
50
+ own, review or hear about: reading a large tree for one question, three
51
+ searches in parallel, a scoped fix with a clear acceptance line. Do not use
52
+ it for work a teammate should own (that is a ticket) or for anything that
53
+ needs to talk to the owner.
54
+
55
+ ## Parameters
56
+
57
+ | Flag | JSON Field | Required | Description |
58
+ |------|-----------|----------|-------------|
59
+ | `--task` / `-t` | `task` | Yes | The task, in full, with the acceptance line. Max 12,000 chars: summarise context, do not paste it |
60
+ | `--cwd` / `-C` | `cwd` | No | Absolute directory the helper works in and may write under. Default `$CREWLY_PROJECT_PATH`, else `$PWD` |
61
+ | `--read-only` | `readOnly` | No | No shell and no file writes |
62
+ | `--max-steps` | `maxSteps` | No | Model steps granted (default 30, cap 60) |
63
+ | `--model` / `-m` | `model` | No | `provider/modelId`; default is the model Crewly's fallback settings name for in-process agents |
64
+ | `--task-file` | - | No | Read the task from a file instead of the flag |
65
+
66
+ ## Examples
67
+
68
+ ```bash
69
+ # A read-only question about the codebase
70
+ bash config/skills/agent/spawn-subagent/execute.sh --read-only \
71
+ --task "Where is the retry delay for Slack posts set? Report file and line, and the default value."
72
+
73
+ # A scoped fix in a worktree
74
+ bash config/skills/agent/spawn-subagent/execute.sh --cwd /abs/path/to/worktree \
75
+ --task "Rename helper foo() to bar() in src/x.ts and its test; run 'npx tsc --noEmit -p backend'. Report what changed and the tsc result."
76
+
77
+ # Three helpers in parallel from a shell
78
+ for q in "auth" "billing" "slack"; do
79
+ bash config/skills/agent/spawn-subagent/execute.sh --read-only --task "List every file under src that handles $q, one line each" > "/tmp/sub-$q.json" &
80
+ done; wait
81
+ ```
82
+
83
+ ## Output
84
+
85
+ JSON. `success` is true only when the helper did work and stopped on its
86
+ own. `stoppedReason` is one of `done`, `did_nothing`, `steps_exhausted`,
87
+ `incomplete`, `timeout`, `error`. `summary` is its report (capped at 6,000
88
+ chars), `toolCalls` / `toolNames` what it did, `filesChanged` what it
89
+ wrote, `usage` its tokens (booked under `sessionName`, `<you>/sub-<n>`).
90
+
91
+ ```json
92
+ {"success":true,"stoppedReason":"done","summary":"...","toolCalls":4,"toolNames":["grep","read_file"],"filesChanged":[],"usage":{"input":1200,"output":300,"cachedInput":0},"steps":5,"sessionName":"crewly-dev-1/sub-1","durationMs":18000,"model":"deepseek/deepseek-chat"}
93
+ ```
94
+
95
+ A refusal is an error with a `code`: `nested` (a helper cannot spawn),
96
+ `too_many` (3 already running for you), `task_too_long`, `cwd_invalid`,
97
+ `model_invalid`, `parent_required` (no agent session in this shell).
98
+
99
+ ## Reading the result
100
+
101
+ `toolCalls: 0` means the helper did nothing, whatever `summary` says. Check
102
+ `filesChanged` against what you asked for. The report is a claim from a
103
+ model with less context than you: verify anything you will act on, and
104
+ never relay it to the owner as your own finding without reading the files it
105
+ names.
@@ -0,0 +1,114 @@
1
+ #!/bin/bash
2
+ # Run one short-lived in-process helper for a bounded task (crewly#1317).
3
+ # Thin wrapper over POST /api/subagents; the backend starts the worker,
4
+ # waits for its one report and shuts it down. Blocks until then.
5
+ set -euo pipefail
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ source "${SCRIPT_DIR}/../_common/lib.sh"
8
+
9
+ # Wall-clock budget the backend gives a helper (600 s) plus a margin: the
10
+ # reply arrives only when the helper is done.
11
+ SUBAGENT_CURL_MAX_TIME="${CREWLY_SUBAGENT_MAX_TIME:-660}"
12
+
13
+ print_usage() {
14
+ cat <<'EOF_USAGE'
15
+ Usage:
16
+ bash execute.sh --task "..." [--cwd /abs/dir] [--read-only] [--max-steps N] [--model provider/id]
17
+ bash execute.sh --task-file /path/to/task.md [...]
18
+ bash execute.sh '{"task":"...","cwd":"/abs/dir","readOnly":true}'
19
+
20
+ Options:
21
+ --task | -t The task, in full, with its acceptance line (max 12,000 chars)
22
+ --task-file Read the task from a file
23
+ --cwd | -C Absolute directory the helper works in (default $CREWLY_PROJECT_PATH, else $PWD)
24
+ --read-only No shell and no file writes
25
+ --max-steps Model steps granted (default 30, cap 60)
26
+ --model | -m provider/modelId (default: the fallback settings' in-process model)
27
+ --json | -j Raw JSON payload (same as the legacy positional form)
28
+ --help | -h Show this help
29
+ EOF_USAGE
30
+ }
31
+
32
+ INPUT_JSON=""
33
+ TASK=""
34
+ TASK_FILE=""
35
+ CWD_ARG=""
36
+ READ_ONLY="false"
37
+ MAX_STEPS=""
38
+ MODEL=""
39
+
40
+ if [[ $# -gt 0 && ${1:0:1} == '{' ]]; then
41
+ INPUT_JSON="$1"
42
+ shift || true
43
+ fi
44
+
45
+ while [[ $# -gt 0 ]]; do
46
+ case "$1" in
47
+ --task|-t) TASK="${2:-}"; shift 2 ;;
48
+ --task-file) TASK_FILE="${2:-}"; shift 2 ;;
49
+ --cwd|-C) CWD_ARG="${2:-}"; shift 2 ;;
50
+ --read-only) READ_ONLY="true"; shift ;;
51
+ --max-steps) MAX_STEPS="${2:-}"; shift 2 ;;
52
+ --model|-m) MODEL="${2:-}"; shift 2 ;;
53
+ --json|-j) INPUT_JSON="${2:-}"; shift 2 ;;
54
+ --help|-h) print_usage; exit 0 ;;
55
+ --) shift; break ;;
56
+ *)
57
+ if [[ -z "$INPUT_JSON" && ${1:0:1} == '{' ]]; then
58
+ INPUT_JSON="$1"; shift
59
+ else
60
+ unknown_arg_exit "$1"
61
+ fi
62
+ ;;
63
+ esac
64
+ done
65
+
66
+ if [ -z "$INPUT_JSON" ] && [ -z "$TASK" ] && [ -z "$TASK_FILE" ] && [ ! -t 0 ]; then
67
+ STDIN_DATA="$(cat)"
68
+ if [[ ${STDIN_DATA:0:1} == '{' ]]; then
69
+ INPUT_JSON="$STDIN_DATA"
70
+ fi
71
+ fi
72
+
73
+ if [ -n "$INPUT_JSON" ]; then
74
+ INPUT=$(read_json_input "$INPUT_JSON")
75
+ [ -z "$TASK" ] && TASK=$(printf '%s' "$INPUT" | jq -r '.task // empty')
76
+ [ -z "$CWD_ARG" ] && CWD_ARG=$(printf '%s' "$INPUT" | jq -r '.cwd // empty')
77
+ [ "$READ_ONLY" = "false" ] && READ_ONLY=$(printf '%s' "$INPUT" | jq -r 'if .readOnly == true then "true" else "false" end')
78
+ [ -z "$MAX_STEPS" ] && MAX_STEPS=$(printf '%s' "$INPUT" | jq -r '.maxSteps // empty')
79
+ [ -z "$MODEL" ] && MODEL=$(printf '%s' "$INPUT" | jq -r '.model // empty')
80
+ fi
81
+
82
+ if [ -n "$TASK_FILE" ]; then
83
+ [ -f "$TASK_FILE" ] || error_exit "task file not found: $TASK_FILE"
84
+ TASK="$(cat "$TASK_FILE")"
85
+ fi
86
+
87
+ require_param "task (--task)" "$TASK"
88
+
89
+ if [ -z "$CWD_ARG" ]; then
90
+ CWD_ARG="${CREWLY_PROJECT_PATH:-$PWD}"
91
+ fi
92
+ case "$CWD_ARG" in
93
+ /*) ;;
94
+ *) error_exit "cwd must be an absolute path, got: $CWD_ARG" ;;
95
+ esac
96
+ [ -d "$CWD_ARG" ] || error_exit "cwd is not a directory: $CWD_ARG"
97
+
98
+ if [ -n "$MAX_STEPS" ] && ! [[ "$MAX_STEPS" =~ ^[0-9]+$ ]]; then
99
+ error_exit "--max-steps must be a whole number, got: $MAX_STEPS"
100
+ fi
101
+
102
+ BODY=$(jq -nc \
103
+ --arg task "$TASK" \
104
+ --arg cwd "$CWD_ARG" \
105
+ --argjson readOnly "$READ_ONLY" \
106
+ --arg maxSteps "$MAX_STEPS" \
107
+ --arg model "$MODEL" \
108
+ '{task: $task, cwd: $cwd, readOnly: $readOnly}
109
+ + (if $maxSteps != "" then {maxSteps: ($maxSteps | tonumber)} else {} end)
110
+ + (if $model != "" then {model: $model} else {} end)')
111
+
112
+ # The backend wraps the outcome in {success, data}; the agent wants the outcome.
113
+ OUT=$(CREWLY_API_MAX_TIME="$SUBAGENT_CURL_MAX_TIME" api_call POST "/subagents" "$BODY")
114
+ printf '%s' "$OUT" | jq -c '.data // .'
@@ -0,0 +1,113 @@
1
+ #!/bin/bash
2
+ # Tests for spawn-subagent — run with: bash execute.test.sh
3
+ # Python HTTP stub as the backend; asserts the request the skill sends and
4
+ # the JSON it prints. Separate HOME / CREWLY_HOME. Exit 0 on pass.
5
+ set -u
6
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ EXEC="$HERE/execute.sh"
8
+ PASS=0; FAIL=0
9
+ check() {
10
+ local name="$1"; local got="$2"; local want="$3"
11
+ if [ "$got" = "$want" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " want: $want"; echo " got: $got"; fi
12
+ }
13
+
14
+ PORT=18851
15
+ STUB_LOG="$(mktemp)"; export STUB_LOG; export STUB_PORT="$PORT"
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
+ OUTCOME = {'success': True, 'stoppedReason': 'done', 'summary': 'retry delay at src/slack.ts:42 = 2000',
21
+ 'toolCalls': 2, 'toolNames': ['grep', 'read_file'], 'filesChanged': [],
22
+ 'usage': {'input': 100, 'output': 20, 'cachedInput': 0}, 'steps': 3,
23
+ 'sessionName': 'dev-ella/sub-1', 'durationMs': 1500, 'model': 'deepseek/deepseek-chat'}
24
+ class H(BaseHTTPRequestHandler):
25
+ def _send(self, code, obj):
26
+ self.send_response(code); self.send_header('Content-Type', 'application/json'); self.end_headers()
27
+ self.wfile.write(json.dumps(obj).encode())
28
+ def do_GET(self): self._send(200, {'success': True})
29
+ def do_POST(self):
30
+ n = int(self.headers.get('content-length', '0')); body = self.rfile.read(n).decode() if n else ''
31
+ data = json.loads(body) if body else None
32
+ with open(LOG, 'a') as f:
33
+ f.write(json.dumps({'method': 'POST', 'path': self.path, 'body': data,
34
+ 'session': self.headers.get('X-Agent-Session')}) + '\n')
35
+ if self.path != '/api/subagents':
36
+ return self._send(200, {'success': True})
37
+ task = (data or {}).get('task', '')
38
+ if task.startswith('NESTED'):
39
+ return self._send(400, {'success': False, 'code': 'nested', 'error': 'A sub-agent cannot spawn sub-agents.'})
40
+ if task.startswith('TIMEOUT'):
41
+ out = dict(OUTCOME); out.update({'success': False, 'stoppedReason': 'timeout', 'toolCalls': 0, 'error': 'sub-agent exceeded 600000 ms'})
42
+ return self._send(200, {'success': True, 'data': out})
43
+ self._send(200, {'success': True, 'data': OUTCOME})
44
+ def log_message(self, *a, **k): pass
45
+ HTTPServer(('127.0.0.1', PORT), H).serve_forever()
46
+ PY
47
+ STUB_PID=$!
48
+ disown "$STUB_PID" 2>/dev/null || true
49
+ WORK="$(mktemp -d)"; mkdir -p "$WORK/home/.crewly" "$WORK/repo"
50
+ trap 'kill "$STUB_PID" >/dev/null 2>&1; rm -rf "$STUB_LOG" "$WORK"' EXIT
51
+ 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
52
+
53
+ ENVS=(CREWLY_API_URL="http://127.0.0.1:${PORT}" CREWLY_SESSION_NAME=dev-ella HOME="$WORK/home" CREWLY_HOME="$WORK/home/.crewly" CREWLY_PROJECT_PATH="$WORK/repo")
54
+ # stdin closed: the skill reads JSON from a pipe when no task is given, and a
55
+ # test runner's stdin is a pipe that never ends.
56
+ run() { : > "$STUB_LOG"; env "${ENVS[@]}" bash "$EXEC" "$@" 2>/dev/null </dev/null; }
57
+ run_err() { : > "$STUB_LOG"; env "${ENVS[@]}" bash "$EXEC" "$@" 2>&1 >/dev/null </dev/null; }
58
+ # The skill library's start-of-skill heartbeat is not part of what we test.
59
+ reqs() { jq -c 'select(.path != "/api/heartbeat") | {path, session, body}' "$STUB_LOG" | paste -sd'|' -; }
60
+
61
+ # 1. Minimal call: task only; cwd defaults to CREWLY_PROJECT_PATH; readOnly false; no optionals.
62
+ OUT=$(run --task "Where is the retry delay set?")
63
+ check "minimal: request" "$(reqs)" "{\"path\":\"/api/subagents\",\"session\":\"dev-ella\",\"body\":{\"task\":\"Where is the retry delay set?\",\"cwd\":\"$WORK/repo\",\"readOnly\":false}}"
64
+ check "minimal: prints the outcome unwrapped" "$(printf '%s' "$OUT" | jq -c '{success, stoppedReason, toolCalls, sessionName}')" '{"success":true,"stoppedReason":"done","toolCalls":2,"sessionName":"dev-ella/sub-1"}'
65
+
66
+ # 2. Every flag.
67
+ OUT=$(run --task "Fix it" --cwd "$WORK/repo" --read-only --max-steps 12 --model deepseek/deepseek-chat)
68
+ check "all flags" "$(jq -c 'select(.path == "/api/subagents") | .body' "$STUB_LOG")" "{\"task\":\"Fix it\",\"cwd\":\"$WORK/repo\",\"readOnly\":true,\"maxSteps\":12,\"model\":\"deepseek/deepseek-chat\"}"
69
+
70
+ # 3. Legacy JSON form.
71
+ OUT=$(run "{\"task\":\"From JSON\",\"cwd\":\"$WORK/repo\",\"readOnly\":true,\"maxSteps\":5}")
72
+ check "json form" "$(jq -c 'select(.path == "/api/subagents") | .body' "$STUB_LOG")" "{\"task\":\"From JSON\",\"cwd\":\"$WORK/repo\",\"readOnly\":true,\"maxSteps\":5}"
73
+
74
+ # 4. Task from a file keeps quotes and newlines.
75
+ printf 'Line one with "quotes"\nLine two\n' > "$WORK/task.md"
76
+ run --task-file "$WORK/task.md" >/dev/null
77
+ check "task file" "$(jq -r 'select(.path == "/api/subagents") | .body.task' "$STUB_LOG")" "$(printf 'Line one with "quotes"\nLine two\n')"
78
+
79
+ # 5. cwd falls back to PWD when CREWLY_PROJECT_PATH is unset.
80
+ : > "$STUB_LOG"
81
+ (cd "$WORK/repo" && env CREWLY_API_URL="http://127.0.0.1:${PORT}" CREWLY_SESSION_NAME=dev-ella HOME="$WORK/home" CREWLY_HOME="$WORK/home/.crewly" bash "$EXEC" --task "pwd default" >/dev/null 2>&1 </dev/null)
82
+ check "cwd defaults to PWD" "$(jq -r 'select(.path == "/api/subagents") | .body.cwd' "$STUB_LOG")" "$WORK/repo"
83
+
84
+ # 6. Refusals before any request.
85
+ OUT=$(run_err --cwd "$WORK/repo" || true)
86
+ check "task required" "$(printf '%s' "$OUT" | grep -c 'Missing required parameter: task')" "1"
87
+ check "task required: nothing sent" "$(reqs)" ""
88
+ OUT=$(run_err --task x --cwd relative/dir || true)
89
+ check "cwd must be absolute" "$(printf '%s' "$OUT" | grep -c 'must be an absolute path')" "1"
90
+ OUT=$(run_err --task x --cwd "$WORK/missing" || true)
91
+ check "cwd must exist" "$(printf '%s' "$OUT" | grep -c 'not a directory')" "1"
92
+ OUT=$(run_err --task x --max-steps many || true)
93
+ check "max-steps numeric" "$(printf '%s' "$OUT" | grep -c 'whole number')" "1"
94
+ OUT=$(run_err --task x --bogus || true)
95
+ check "unknown flag" "$(printf '%s' "$OUT" | grep -c 'Unknown argument: --bogus')" "1"
96
+
97
+ # 7. A backend refusal is an error with its code; exit non-zero.
98
+ OUT=$(run_err --task "NESTED please" || true)
99
+ check "backend refusal surfaces the code" "$(printf '%s' "$OUT" | jq -r 'select(.error == true) | .details.code' 2>/dev/null | head -1)" "nested"
100
+ set +e; run --task "NESTED please" >/dev/null 2>&1; RC=$?; set -e
101
+ check "backend refusal exits 1" "$RC" "1"
102
+
103
+ # 8. A child that timed out is still a 200 outcome with success:false.
104
+ OUT=$(run --task "TIMEOUT me")
105
+ check "timeout outcome passes through" "$(printf '%s' "$OUT" | jq -c '{success, stoppedReason, error}')" '{"success":false,"stoppedReason":"timeout","error":"sub-agent exceeded 600000 ms"}'
106
+
107
+ # 9. --help prints usage and sends nothing.
108
+ OUT=$(run --help)
109
+ check "help" "$(printf '%s' "$OUT" | grep -c -- '--read-only')" "2"
110
+ check "help sends nothing" "$(reqs)" ""
111
+
112
+ echo "spawn-subagent: $PASS passed, $FAIL failed"
113
+ [ "$FAIL" -eq 0 ]