crewly 1.20.448 → 1.20.450
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/checkup-analyse/SKILL.md +99 -0
- package/config/skills/agent/checkup-analyse/analyse.mjs +603 -0
- package/config/skills/agent/checkup-analyse/execute.sh +34 -0
- package/config/skills/agent/checkup-analyse/execute.test.sh +126 -0
- package/config/skills/agent/checkup-report/SKILL.md +96 -0
- package/config/skills/agent/checkup-report/app/index.html +265 -0
- package/config/skills/agent/checkup-report/execute.sh +37 -0
- package/config/skills/agent/checkup-report/execute.test.sh +137 -0
- package/config/skills/agent/checkup-report/report.mjs +194 -0
- package/config/skills/agent/ingest-exports/execute.sh +1 -1
- package/dist/backend/build-info.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Tests for checkup-report — run with: bash execute.test.sh
|
|
3
|
+
# Offline: runs ingest-exports + checkup-analyse on the ingest fixtures first. Needs node and jq.
|
|
4
|
+
set -u
|
|
5
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
EXEC="$HERE/execute.sh"
|
|
7
|
+
INGEST="$HERE/../ingest-exports/execute.sh"
|
|
8
|
+
ANALYSE="$HERE/../checkup-analyse/execute.sh"
|
|
9
|
+
FX="$HERE/../ingest-exports/fixtures"
|
|
10
|
+
PASS=0; FAIL=0
|
|
11
|
+
check() {
|
|
12
|
+
local name="$1"; local got="$2"; local want="$3"
|
|
13
|
+
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " want: $want"; echo " got: $got"; fi
|
|
14
|
+
}
|
|
15
|
+
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
|
|
16
|
+
bash "$INGEST" --in "$FX" --out "$T/ingest" >/dev/null || { echo "ingest-exports failed"; exit 1; }
|
|
17
|
+
bash "$INGEST" --in "$FX" --out "$T/ingest-hashed" --hash-names >/dev/null
|
|
18
|
+
bash "$ANALYSE" --in "$T/ingest" --as-of 2026-10-26 --out "$T/an" >/dev/null || { echo "checkup-analyse failed"; exit 1; }
|
|
19
|
+
bash "$ANALYSE" --in "$T/ingest-hashed" --as-of 2026-10-26 --out "$T/anh" >/dev/null
|
|
20
|
+
|
|
21
|
+
# 1. The App page follows the Crewly Apps rules (sandbox: no network, no storage, old Safari)
|
|
22
|
+
PAGE="$HERE/app/index.html"
|
|
23
|
+
check "page exists" "$([ -f "$PAGE" ] && echo yes || echo no)" "yes"
|
|
24
|
+
check "viewport meta" "$(grep -c 'name="viewport"' "$PAGE")" "1"
|
|
25
|
+
check "no external script" "$(grep -c -E '<script[^>]+src=' "$PAGE")" "0"
|
|
26
|
+
check "no fetch/XHR" "$(grep -c -E '\bfetch\(|XMLHttpRequest|WebSocket' "$PAGE")" "0"
|
|
27
|
+
check "no localStorage/cookies" "$(grep -c -E 'localStorage|sessionStorage|document\.cookie' "$PAGE")" "0"
|
|
28
|
+
check "no alert/confirm/prompt" "$(grep -c -E '\b(alert|confirm|prompt)\(' "$PAGE")" "0"
|
|
29
|
+
check "no innerHTML with data" "$(grep -c 'innerHTML' "$PAGE")" "0"
|
|
30
|
+
check "no optional chaining / nullish (iOS 12)" "$(grep -c -E '\?\.[a-zA-Z(]|\?\?' "$PAGE")" "0"
|
|
31
|
+
check "no arrow functions / class fields (iOS 12)" "$(grep -c -E '=>' "$PAGE")" "0"
|
|
32
|
+
check "replaceChildren polyfilled" "$(grep -c 'Element.prototype.replaceChildren = ' "$PAGE")" "1"
|
|
33
|
+
check "reads report/current" "$(grep -c "crewly.db.get('report', 'current')" "$PAGE")" "1"
|
|
34
|
+
check "writes intake/answers" "$(grep -c "crewly.db.set('intake', 'answers'" "$PAGE")" "1"
|
|
35
|
+
check "lists daily" "$(grep -c "crewly.db.list('daily'" "$PAGE")" "1"
|
|
36
|
+
check "subscribes to report changes" "$(grep -c "crewly.db.subscribe('report'" "$PAGE")" "1"
|
|
37
|
+
check "data-crewly-id on the key elements" "$(grep -o 'data-crewly-id="[^"]*"' "$PAGE" | sort -u | wc -l | tr -d ' ' | awk '{print ($1>=15)?"enough":"few"}')" "enough"
|
|
38
|
+
check "page JS parses (node)" "$(node -e '
|
|
39
|
+
const fs=require("fs");const html=fs.readFileSync(process.argv[1],"utf8");
|
|
40
|
+
const m=html.match(/<script>([\s\S]*?)<\/script>/);try{new Function(m[1]);console.log("ok")}catch(e){console.log("syntax: "+e.message)}' "$PAGE")" "ok"
|
|
41
|
+
|
|
42
|
+
# 2. --app-dir copies the page into the project for publish-app
|
|
43
|
+
OUT="$(bash "$EXEC" --app-dir "$T/checkup-app")"; RC=$?
|
|
44
|
+
check "app-dir exits 0" "$RC" "0"
|
|
45
|
+
check "index.html copied" "$([ -f "$T/checkup-app/index.html" ] && echo yes || echo no)" "yes"
|
|
46
|
+
check "copied list" "$(printf '%s' "$OUT" | jq -c '.copied')" '["index.html"]'
|
|
47
|
+
check "next says publish-app" "$(printf '%s' "$OUT" | jq -r '.next' | cut -c1-11)" "publish-app"
|
|
48
|
+
check "copy is identical" "$(cmp -s "$PAGE" "$T/checkup-app/index.html" && echo same || echo differs)" "same"
|
|
49
|
+
|
|
50
|
+
# 3. --breaks builds the report document
|
|
51
|
+
cat > "$T/intake.json" <<'EOF'
|
|
52
|
+
{"apps":["Square","Jotform","Jackrabbit","Mailchimp","QuickBooks"],"slowestTask":"copying sign-ups into the roster","fears":["Unpaid invoices"],"appsPerCustomer":"3","firstNumber":"Money owed to me","savedAt":"2026-10-26T10:00:00.000Z"}
|
|
53
|
+
EOF
|
|
54
|
+
ROUT="$(bash "$EXEC" --breaks "$T/an/breaks.json" --business "Oak Pottery" --intake "$T/intake.json" --out "$T/data")"; RC=$?
|
|
55
|
+
check "breaks exits 0" "$RC" "0"
|
|
56
|
+
check "report-doc written" "$([ -f "$T/data/report-doc.json" ] && echo yes || echo no)" "yes"
|
|
57
|
+
D="$T/data/report-doc.json"
|
|
58
|
+
check "business" "$(jq -r '.business' "$D")" "Oak Pottery"
|
|
59
|
+
check "as-of carried" "$(jq -r '.asOf' "$D")" "2026-10-26"
|
|
60
|
+
check "top3 carried" "$(jq -c '.top3' "$D")" "$(jq -c '.top3' "$T/an/breaks.json")"
|
|
61
|
+
check "totals carried" "$(jq -c '.totals' "$D")" "$(jq -c '.totals' "$T/an/breaks.json")"
|
|
62
|
+
check "8 breaks" "$(jq -r '.breaks | length' "$D")" "8"
|
|
63
|
+
check "no raw items in the doc" "$(jq -r '[.breaks[] | has("items")] | any' "$D")" "false"
|
|
64
|
+
check "estimate keeps the formula" "$(jq -r '.breaks[] | select(.type=="overdue_invoices") | .estimate.formula | length > 20' "$D")" "true"
|
|
65
|
+
check "examples for overdue invoices" "$(jq -r '.breaks[] | select(.type=="overdue_invoices") | .examples | length' "$D")" "2"
|
|
66
|
+
check " example text" "$(jq -r '.breaks[] | select(.type=="overdue_invoices") | .examples[0]' "$D")" "Harbor Dental: invoice 1042 \$1250, 26 days overdue"
|
|
67
|
+
check "example for unpaid sign-up" "$(jq -r '.breaks[] | select(.type=="signed_up_unpaid") | .examples[0]' "$D")" "Sam Rivera: Kids Pottery Sat 10am on 2026-09-10, \$180 unpaid"
|
|
68
|
+
check "example for double entry" "$(jq -r '.breaks[] | select(.type=="double_entry") | .examples[0]' "$D")" "Maria Lopez is in jackrabbit, mailchimp, square"
|
|
69
|
+
check "examples capped at 3 by default" "$(jq -r '.breaks[] | select(.type=="double_entry") | .examples | length' "$D")" "3"
|
|
70
|
+
check "first number: money owed" "$(jq -r '.firstNumber.key' "$D")" "money_owed"
|
|
71
|
+
check " value = overdue + unpaid sign-ups" "$(jq -r '.firstNumber.value' "$D")" "\$3,571"
|
|
72
|
+
check " asked text kept" "$(jq -r '.firstNumber.asked' "$D")" "Money owed to me"
|
|
73
|
+
check "summary line has top3" "$(printf '%s' "$ROUT" | jq -r '.top3[0]')" "overdue_invoices"
|
|
74
|
+
check "summary line next = app-data set" "$(printf '%s' "$ROUT" | jq -r '.next' | grep -c '^app-data --app <appId> --set report current --data-file ')" "1"
|
|
75
|
+
check "files carried" "$(jq -r '.files | length' "$D")" "7"
|
|
76
|
+
check "assumptions carried" "$(jq -r '.assumptions.hourlyValue' "$D")" "30"
|
|
77
|
+
check "doc small" "$(wc -c < "$D" | awk '{print ($1<20000)?"small":"big"}')" "small"
|
|
78
|
+
|
|
79
|
+
# 4. Examples cap, other first-number choices, intake in app-data output shape, free-text first number
|
|
80
|
+
bash "$EXEC" --breaks "$T/an/breaks.json" --examples 1 --out "$T/data1" >/dev/null
|
|
81
|
+
check "--examples 1" "$(jq -r '.breaks[] | select(.type=="double_entry") | .examples | length' "$T/data1/report-doc.json")" "1"
|
|
82
|
+
bash "$EXEC" --breaks "$T/an/breaks.json" --examples 4 --out "$T/data4x" >/dev/null
|
|
83
|
+
check "--examples 4 reaches the conflict example" "$(jq -r '.breaks[] | select(.type=="double_entry") | .examples[3]' "$T/data4x/report-doc.json")" "Tom Baker is in jotform, mailchimp (phone missing in mailchimp)"
|
|
84
|
+
bash "$EXEC" --breaks "$T/an/breaks.json" --examples 0 --out "$T/data0" >/dev/null
|
|
85
|
+
check "--examples 0 → none" "$(jq -r '[.breaks[].examples | length] | add' "$T/data0/report-doc.json")" "0"
|
|
86
|
+
printf '{"success":true,"id":"answers","data":{"apps":["Square"],"firstNumber":"Inquiries I missed"},"rev":1}' > "$T/intake-appdata.json"
|
|
87
|
+
bash "$EXEC" --breaks "$T/an/breaks.json" --intake "$T/intake-appdata.json" --out "$T/data2" >/dev/null
|
|
88
|
+
check "intake from app-data output shape" "$(jq -r '.firstNumber.key' "$T/data2/report-doc.json")" "missed_inquiries"
|
|
89
|
+
check " value" "$(jq -r '.firstNumber.value' "$T/data2/report-doc.json")" "1"
|
|
90
|
+
printf '{"firstNumber":"how many hours we lose typing things twice"}' > "$T/intake-free.json"
|
|
91
|
+
bash "$EXEC" --breaks "$T/an/breaks.json" --intake "$T/intake-free.json" --out "$T/data3" >/dev/null
|
|
92
|
+
check "free-text first number mapped" "$(jq -r '.firstNumber.key' "$T/data3/report-doc.json")" "double_entry_hours"
|
|
93
|
+
printf '{"firstNumber":"Customers who lapsed"}' > "$T/intake-lapsed.json"
|
|
94
|
+
bash "$EXEC" --breaks "$T/an/breaks.json" --intake "$T/intake-lapsed.json" --out "$T/data4" >/dev/null
|
|
95
|
+
check "lapsed first number" "$(jq -r '.firstNumber.key' "$T/data4/report-doc.json")" "lapsed"
|
|
96
|
+
printf '{"firstNumber":"the weather"}' > "$T/intake-odd.json"
|
|
97
|
+
bash "$EXEC" --breaks "$T/an/breaks.json" --intake "$T/intake-odd.json" --out "$T/data5" >/dev/null
|
|
98
|
+
check "unmappable first number → no firstNumber" "$(jq -r 'has("firstNumber")' "$T/data5/report-doc.json")" "false"
|
|
99
|
+
check " but a note" "$(jq -r '.notes | map(select(test("the weather"))) | length' "$T/data5/report-doc.json")" "1"
|
|
100
|
+
bash "$EXEC" --breaks "$T/an/breaks.json" --out "$T/data6" >/dev/null
|
|
101
|
+
check "no intake → no firstNumber, no note" "$(jq -r 'has("firstNumber"), (.notes|length)' "$T/data6/report-doc.json" | tr '\n' ' ')" "false 0 "
|
|
102
|
+
|
|
103
|
+
# 5. Hashed data: no names, examples say "names hashed"
|
|
104
|
+
bash "$EXEC" --breaks "$T/anh/breaks.json" --out "$T/datah" >/dev/null
|
|
105
|
+
check "hashed: no plain names in the doc" "$(grep -c -E "Lopez|Rivera|Baker|Chen|Natarajan|example\.test" "$T/datah/report-doc.json")" "0"
|
|
106
|
+
check "hashed: no hashes leak either" "$(grep -c '"h:' "$T/datah/report-doc.json")" "0"
|
|
107
|
+
check "hashed: example says names hashed" "$(jq -r '.breaks[] | select(.type=="signed_up_unpaid") | .examples[0]' "$T/datah/report-doc.json")" "1 person (names hashed)"
|
|
108
|
+
check "hashed: invoice customers hashed too → count only" "$(jq -r '.breaks[] | select(.type=="overdue_invoices") | .examples[0]' "$T/datah/report-doc.json")" "2 people (names hashed)"
|
|
109
|
+
|
|
110
|
+
# 6. --daily
|
|
111
|
+
DOUT="$(bash "$EXEC" --daily "Replied to 3 open inquiries; 1 booked." --date 2026-10-27 --out "$T/data")"
|
|
112
|
+
check "daily exits 0" "$?" "0"
|
|
113
|
+
check "daily file" "$([ -f "$T/data/daily-2026-10-27.json" ] && echo yes || echo no)" "yes"
|
|
114
|
+
check "daily doc" "$(jq -c '{date,summary}' "$T/data/daily-2026-10-27.json")" '{"date":"2026-10-27","summary":"Replied to 3 open inquiries; 1 booked."}'
|
|
115
|
+
check "daily next = app-data set daily <date>" "$(printf '%s' "$DOUT" | jq -r '.next' | grep -c '^app-data --app <appId> --set daily 2026-10-27 --data-file ')" "1"
|
|
116
|
+
check "daily default date = today" "$(bash "$EXEC" --daily "x" --out "$T/data" | jq -r '.dailyDoc' | grep -c "daily-$(date -u +%F).json")" "1"
|
|
117
|
+
|
|
118
|
+
# 7. JSON input and combined calls
|
|
119
|
+
JOUT="$(bash "$EXEC" "{\"appDir\":\"$T/japp\",\"breaks\":\"$T/an/breaks.json\",\"business\":\"Oak\",\"out\":\"$T/jdata\"}")"
|
|
120
|
+
check "json input: app copied" "$([ -f "$T/japp/index.html" ] && echo yes || echo no)" "yes"
|
|
121
|
+
check "json input: report written" "$(jq -r '.business' "$T/jdata/report-doc.json")" "Oak"
|
|
122
|
+
check "json input: summary has both" "$(printf '%s' "$JOUT" | jq -r '(.copied|length), (.top3|length)' | tr '\n' ' ')" "1 3 "
|
|
123
|
+
|
|
124
|
+
# 8. Errors
|
|
125
|
+
bash "$EXEC" >/dev/null 2>&1; check "no args exits 1" "$?" "1"
|
|
126
|
+
bash "$EXEC" --breaks "$T/an/breaks.json" >/dev/null 2>&1; check "breaks without --out exits 1" "$?" "1"
|
|
127
|
+
bash "$EXEC" --breaks "$T/nope.json" --out "$T/x" >/dev/null 2>&1; check "missing breaks exits 2" "$?" "2"
|
|
128
|
+
printf '{"foo":1}' > "$T/notbreaks.json"
|
|
129
|
+
bash "$EXEC" --breaks "$T/notbreaks.json" --out "$T/x" >/dev/null 2>&1; check "wrong file exits 2" "$?" "2"
|
|
130
|
+
bash "$EXEC" --breaks "$T/an/breaks.json" --intake "$T/nope.json" --out "$T/x" >/dev/null 2>&1; check "missing intake exits 2" "$?" "2"
|
|
131
|
+
bash "$EXEC" --daily "x" --date 27/10/2026 --out "$T/x" >/dev/null 2>&1; check "bad date exits 1" "$?" "1"
|
|
132
|
+
bash "$EXEC" --daily " " --out "$T/x" >/dev/null 2>&1; check "empty daily exits 1" "$?" "1"
|
|
133
|
+
bash "$EXEC" --breaks "$T/an/breaks.json" --examples lots --out "$T/x" >/dev/null 2>&1; check "bad examples exits 1" "$?" "1"
|
|
134
|
+
bash "$EXEC" --bogus >/dev/null 2>&1; check "unknown flag exits 1" "$?" "1"
|
|
135
|
+
|
|
136
|
+
echo "checkup-report: $PASS passed, $FAIL failed"
|
|
137
|
+
[ "$FAIL" -eq 0 ]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// checkup-report — the report step of the small-business check-up
|
|
4
|
+
// (crewly#1062). Turns `breaks.json` from checkup-analyse into the document
|
|
5
|
+
// the bundled report App renders (report/current), optionally places the
|
|
6
|
+
// number the owner asked to see first (from the intake answers), writes
|
|
7
|
+
// daily-summary documents, and copies the App's files into the project so
|
|
8
|
+
// publish-app can upload them.
|
|
9
|
+
//
|
|
10
|
+
// node report.mjs --app-dir ./checkup-app # copy the App page to publish
|
|
11
|
+
// node report.mjs --breaks …/breaks.json --business "Oak Pottery" [--intake answers.json] --out ./checkup-data
|
|
12
|
+
// node report.mjs --daily "Replied to 3 open inquiries; 1 booked." [--date 2026-10-27] --out ./checkup-data
|
|
13
|
+
// =============================================================================
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, readdirSync, statSync } from 'node:fs';
|
|
15
|
+
import { join, resolve, dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const APP_SRC = join(HERE, 'app');
|
|
20
|
+
const DEFAULT_EXAMPLES = 3;
|
|
21
|
+
/** Crewly App documents are capped at 256 KB; stay well under it. */
|
|
22
|
+
const MAX_DOC_BYTES = 200 * 1024;
|
|
23
|
+
|
|
24
|
+
/** The "which number first" choices offered by the intake form, with how each is read off the breaks. */
|
|
25
|
+
const FIRST_NUMBER_RULES = [
|
|
26
|
+
{ key: 'money_owed', match: /owed|owe|invoice|unpaid|money|cash|balance/i, label: 'Owed to you: overdue invoices plus unpaid sign-ups',
|
|
27
|
+
value: (by) => '$' + Math.round((by.overdue_invoices?.estimate.outstanding || 0) + (by.signed_up_unpaid?.estimate.outstanding || 0)).toLocaleString('en-US') },
|
|
28
|
+
{ key: 'missed_inquiries', match: /inquir|lead|enquir|missed|unanswered/i, label: 'Inquiries with no sign-up or payment after them',
|
|
29
|
+
value: (by) => String(by.inquiry_no_follow_through?.count || 0) },
|
|
30
|
+
{ key: 'double_entry_hours', match: /double|entry|typing|hours|time|twice/i, label: 'Hours a week spent keying the same people into more than one system (estimate)',
|
|
31
|
+
value: (by) => `${by.double_entry?.estimate.hoursPerWeek || 0} h/week` },
|
|
32
|
+
{ key: 'lapsed', match: /laps|stopped|churn|left|came back|coming/i, label: 'Customers with no activity for a while',
|
|
33
|
+
value: (by) => String(by.lapsed?.count || 0) },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Short, owner-readable examples for a break's first items; none when the data is hashed. */
|
|
37
|
+
function examplesFor(b, limit) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const it of b.items.slice(0, limit)) {
|
|
40
|
+
const who = it.person || it.customer || '';
|
|
41
|
+
if (!who || String(who).startsWith('h:')) continue;
|
|
42
|
+
switch (b.type) {
|
|
43
|
+
case 'inquiry_no_follow_through': out.push(`${who} asked about ${it.item || 'a class'} on ${it.inquiredAt}`); break;
|
|
44
|
+
case 'signed_up_unpaid': out.push(`${who}: ${it.item || 'sign-up'} on ${it.signedUpAt}, $${it.fee} unpaid`); break;
|
|
45
|
+
case 'paid_not_listed': out.push(`${who} paid $${it.amount} on ${it.paidAt} (${it.item || 'no item'}) and is on no roster`); break;
|
|
46
|
+
case 'dropped': out.push(`${who}: ${it.item || 'sign-up'} dropped${it.droppedAt ? ` on ${it.droppedAt}` : ''}`); break;
|
|
47
|
+
case 'lapsed': out.push(`${who}: last seen ${it.lastActivity}, ${it.daysSince} days ago`); break;
|
|
48
|
+
case 'overdue_invoices': out.push(`${who}: invoice ${it.number || ''} $${it.balance}, ${it.daysOverdue} days overdue`); break;
|
|
49
|
+
case 'double_entry': out.push(`${who} is in ${it.systems.join(', ')}${it.conflicts.length ? ` (${it.conflicts.join('; ')})` : ''}`); break;
|
|
50
|
+
case 'not_on_mailing_list': out.push(`${who}`); break;
|
|
51
|
+
default: out.push(who);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!out.length && b.items.length && String(b.items[0].person || b.items[0].customer || '').startsWith('h:')) {
|
|
55
|
+
out.push(`${b.count} ${b.count === 1 ? 'person' : 'people'} (names hashed)`);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the report document the App renders from checkup-analyse output.
|
|
62
|
+
* 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.
|
|
64
|
+
*/
|
|
65
|
+
export function buildReportDoc(breaks, opts = {}) {
|
|
66
|
+
const by = Object.fromEntries(breaks.breaks.map((b) => [b.type, b]));
|
|
67
|
+
const limit = Number.isFinite(opts.examples) ? opts.examples : DEFAULT_EXAMPLES;
|
|
68
|
+
const doc = {
|
|
69
|
+
business: opts.business || null,
|
|
70
|
+
generatedAt: new Date().toISOString(),
|
|
71
|
+
asOf: breaks.asOf,
|
|
72
|
+
period: breaks.period,
|
|
73
|
+
stats: { people: breaks.stats.people, systems: breaks.stats.systems, records: breaks.stats.records },
|
|
74
|
+
totals: breaks.totals,
|
|
75
|
+
breaks: breaks.breaks.map((b) => ({
|
|
76
|
+
type: b.type,
|
|
77
|
+
label: b.label,
|
|
78
|
+
count: b.count,
|
|
79
|
+
estimate: { hoursPerWeek: b.estimate.hoursPerWeek, dollarsPerMonth: b.estimate.dollarsPerMonth, formula: b.estimate.formula, isEstimate: true },
|
|
80
|
+
whatAnAgentCouldDo: b.whatAnAgentCouldDo,
|
|
81
|
+
examples: examplesFor(b, limit),
|
|
82
|
+
})),
|
|
83
|
+
top3: breaks.top3,
|
|
84
|
+
notes: breaks.notes || [],
|
|
85
|
+
files: (breaks.files || []).map((f) => ({ file: f.file, source: f.source, kind: f.kind, rows: f.rows })),
|
|
86
|
+
assumptions: breaks.assumptions,
|
|
87
|
+
};
|
|
88
|
+
const want = opts.intake && opts.intake.firstNumber ? String(opts.intake.firstNumber) : '';
|
|
89
|
+
if (want) {
|
|
90
|
+
const rule = FIRST_NUMBER_RULES.find((r) => r.match.test(want));
|
|
91
|
+
if (rule) doc.firstNumber = { asked: want, key: rule.key, label: rule.label, value: rule.value(by) };
|
|
92
|
+
else doc.notes = [...doc.notes, `The owner asked to see "${want}" first; no break maps to it, so the top 3 lead.`];
|
|
93
|
+
}
|
|
94
|
+
return doc;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Read intake answers from a plain document or from app-data's `{ success, data }` output. */
|
|
98
|
+
function readIntake(path) {
|
|
99
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
100
|
+
return raw && raw.data && typeof raw.data === 'object' && !raw.apps ? raw.data : raw;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Copy the App page into a directory inside the project so publish-app can upload it. */
|
|
104
|
+
function copyApp(dir) {
|
|
105
|
+
mkdirSync(dir, { recursive: true });
|
|
106
|
+
const copied = [];
|
|
107
|
+
for (const f of readdirSync(APP_SRC)) {
|
|
108
|
+
const src = join(APP_SRC, f);
|
|
109
|
+
if (!statSync(src).isFile()) continue;
|
|
110
|
+
copyFileSync(src, join(dir, f));
|
|
111
|
+
copied.push(f);
|
|
112
|
+
}
|
|
113
|
+
return copied;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function out(obj, code) {
|
|
117
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
118
|
+
process.exit(code);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
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);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function main(argv) {
|
|
126
|
+
const o = { examples: DEFAULT_EXAMPLES };
|
|
127
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
128
|
+
const a = argv[i];
|
|
129
|
+
const next = () => { i += 1; if (i >= argv.length) usage(); return argv[i]; };
|
|
130
|
+
if (a === '--app-dir') o.appDir = next();
|
|
131
|
+
else if (a === '--breaks') o.breaks = next();
|
|
132
|
+
else if (a === '--business') o.business = next();
|
|
133
|
+
else if (a === '--intake') o.intake = next();
|
|
134
|
+
else if (a === '--examples') o.examples = Number(next());
|
|
135
|
+
else if (a === '--out') o.out = next();
|
|
136
|
+
else if (a === '--daily') o.daily = next();
|
|
137
|
+
else if (a === '--date') o.date = next();
|
|
138
|
+
else usage();
|
|
139
|
+
}
|
|
140
|
+
if (!o.appDir && !o.breaks && !o.daily) usage();
|
|
141
|
+
if ((o.breaks || o.daily) && !o.out) usage();
|
|
142
|
+
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
|
+
if (!Number.isFinite(o.examples) || o.examples < 0) out({ success: false, error: '--examples needs a number >= 0' }, 1);
|
|
144
|
+
|
|
145
|
+
const result = { success: true };
|
|
146
|
+
if (o.appDir) {
|
|
147
|
+
result.appDir = resolve(o.appDir);
|
|
148
|
+
result.copied = copyApp(o.appDir);
|
|
149
|
+
result.next = `publish-app --dir ${o.appDir} --name "<business> check-up" --notify`;
|
|
150
|
+
}
|
|
151
|
+
if (o.breaks) {
|
|
152
|
+
if (!existsSync(o.breaks)) out({ success: false, error: `${o.breaks}: not found (run checkup-analyse with --out first)` }, 2);
|
|
153
|
+
let breaks;
|
|
154
|
+
try {
|
|
155
|
+
breaks = JSON.parse(readFileSync(o.breaks, 'utf8'));
|
|
156
|
+
} catch (e) {
|
|
157
|
+
out({ success: false, error: `${o.breaks}: ${e.message}` }, 2);
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(breaks.breaks) || !breaks.totals) out({ success: false, error: `${o.breaks}: not a checkup-analyse breaks.json` }, 2);
|
|
160
|
+
let intake = null;
|
|
161
|
+
if (o.intake) {
|
|
162
|
+
if (!existsSync(o.intake)) out({ success: false, error: `${o.intake}: not found` }, 2);
|
|
163
|
+
try {
|
|
164
|
+
intake = readIntake(o.intake);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
out({ success: false, error: `${o.intake}: ${e.message}` }, 2);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const doc = buildReportDoc(breaks, { business: o.business, intake, examples: o.examples });
|
|
170
|
+
const json = JSON.stringify(doc, null, 2);
|
|
171
|
+
if (Buffer.byteLength(json) > MAX_DOC_BYTES) out({ success: false, error: `report document is ${Buffer.byteLength(json)} bytes; lower --examples` }, 2);
|
|
172
|
+
mkdirSync(o.out, { recursive: true });
|
|
173
|
+
writeFileSync(join(o.out, 'report-doc.json'), json);
|
|
174
|
+
result.reportDoc = resolve(join(o.out, 'report-doc.json'));
|
|
175
|
+
result.top3 = doc.top3;
|
|
176
|
+
result.totals = doc.totals;
|
|
177
|
+
result.firstNumber = doc.firstNumber || null;
|
|
178
|
+
result.next = `app-data --app <appId> --set report current --data-file ${join(o.out, 'report-doc.json')}`;
|
|
179
|
+
}
|
|
180
|
+
if (o.daily) {
|
|
181
|
+
const summary = String(o.daily).trim();
|
|
182
|
+
if (!summary) out({ success: false, error: '--daily needs text' }, 1);
|
|
183
|
+
const date = o.date || new Date().toISOString().slice(0, 10);
|
|
184
|
+
const doc = { date, summary, postedAt: new Date().toISOString() };
|
|
185
|
+
mkdirSync(o.out, { recursive: true });
|
|
186
|
+
const file = join(o.out, `daily-${date}.json`);
|
|
187
|
+
writeFileSync(file, JSON.stringify(doc, null, 2));
|
|
188
|
+
result.dailyDoc = resolve(file);
|
|
189
|
+
result.next = `app-data --app <appId> --set daily ${date} --data-file ${file}`;
|
|
190
|
+
}
|
|
191
|
+
out(result, 0);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (import.meta.url === `file://${process.argv[1]}`) main(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crewly",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.450",
|
|
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": [
|