@rubytech/taskmaster 1.13.4 → 1.14.2
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/dist/agents/workspace-migrations.js +128 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/gateway-cli/run.js +36 -16
- package/dist/control-ui/assets/{index-BiGN9NNG.js → index-B3nkSwMP.js} +22 -20
- package/dist/control-ui/assets/index-B3nkSwMP.js.map +1 -0
- package/dist/control-ui/index.html +1 -1
- package/dist/daemon/service-port.js +109 -0
- package/dist/gateway/server-methods/config.js +44 -0
- package/dist/infra/update-global.js +4 -1
- package/dist/infra/update-runner.js +8 -4
- package/dist/macos/gateway-daemon.js +26 -8
- package/dist/memory/manager.js +14 -3
- package/package.json +1 -1
- package/skills/sales-closer/SKILL.md +29 -0
- package/skills/sales-closer/references/close-tracking.md +86 -0
- package/skills/sales-closer/references/closing-framework.md +112 -0
- package/skills/sales-closer/references/objection-handling.md +101 -0
- package/templates/beagle-zanzibar/agents/admin/AGENTS.md +56 -4
- package/templates/beagle-zanzibar/agents/admin/BOOTSTRAP.md +34 -11
- package/templates/beagle-zanzibar/agents/admin/HEARTBEAT.md +1 -0
- package/templates/beagle-zanzibar/agents/public/AGENTS.md +15 -2
- package/templates/beagle-zanzibar/memory/public/knowledge-base.md +13 -0
- package/templates/beagle-zanzibar/memory/public/terms.md +81 -0
- package/templates/beagle-zanzibar/skills/beagle-zanzibar/SKILL.md +4 -0
- package/templates/beagle-zanzibar/skills/beagle-zanzibar/references/pin-qr.md +52 -0
- package/templates/beagle-zanzibar/skills/beagle-zanzibar/references/post-ride.md +13 -0
- package/templates/beagle-zanzibar/skills/beagle-zanzibar/references/ride-matching.md +23 -17
- package/templates/beagle-zanzibar/skills/beagle-zanzibar/references/route-learning.md +61 -0
- package/templates/beagle-zanzibar/skills/stripe/SKILL.md +28 -0
- package/templates/beagle-zanzibar/skills/stripe/references/payment-links.md +71 -0
- package/dist/control-ui/assets/index-BiGN9NNG.js.map +0 -1
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<title>Taskmaster Control</title>
|
|
7
7
|
<meta name="color-scheme" content="dark light" />
|
|
8
8
|
<link rel="icon" type="image/png" href="./favicon.png" />
|
|
9
|
-
<script type="module" crossorigin src="./assets/index-
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-B3nkSwMP.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="./assets/index-l54GcTyj.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { resolveLaunchAgentPlistPath } from "./launchd.js";
|
|
6
|
+
import { resolveSystemdUserUnitPath } from "./systemd.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
/**
|
|
9
|
+
* Update the gateway service unit (systemd or launchd) to use a new port.
|
|
10
|
+
*
|
|
11
|
+
* Replaces `--port <N>` in the ExecStart / ProgramArguments and
|
|
12
|
+
* `TASKMASTER_GATEWAY_PORT=<N>` in the service environment using targeted
|
|
13
|
+
* string replacement. This preserves all other directives (ExecStartPre,
|
|
14
|
+
* StartLimitBurst, watchdog paths, etc.) that are not round-tripped by the
|
|
15
|
+
* parse helpers.
|
|
16
|
+
*
|
|
17
|
+
* Returns `{ updated: false }` when:
|
|
18
|
+
* - The process is not supervised (no unit file found).
|
|
19
|
+
* - The unit has no `--port` argument (the in-process SIGUSR1 restart already
|
|
20
|
+
* handles port changes in that case).
|
|
21
|
+
*
|
|
22
|
+
* On Linux: callers should schedule `process.exit(0)` so systemd
|
|
23
|
+
* (Restart=always) restarts the gateway with the updated ExecStart.
|
|
24
|
+
*
|
|
25
|
+
* On macOS: this function spawns a detached shell that calls
|
|
26
|
+
* `launchctl bootout` (which sends SIGTERM to the running process) and then
|
|
27
|
+
* `launchctl bootstrap` (which reloads the plist and starts the service via
|
|
28
|
+
* RunAtLoad=true). The caller does NOT need to call process.exit() — the
|
|
29
|
+
* bootout handles termination.
|
|
30
|
+
*/
|
|
31
|
+
export async function tryUpdateServiceUnitPort(newPort) {
|
|
32
|
+
const platform = process.platform;
|
|
33
|
+
if (platform === "linux") {
|
|
34
|
+
return updateSystemdPort(newPort);
|
|
35
|
+
}
|
|
36
|
+
if (platform === "darwin") {
|
|
37
|
+
return updateLaunchdPort(newPort);
|
|
38
|
+
}
|
|
39
|
+
return { updated: false, reason: `unsupported platform: ${platform}` };
|
|
40
|
+
}
|
|
41
|
+
async function updateSystemdPort(newPort) {
|
|
42
|
+
const unitPath = resolveSystemdUserUnitPath(process.env);
|
|
43
|
+
let content;
|
|
44
|
+
try {
|
|
45
|
+
content = await fs.readFile(unitPath, "utf8");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { updated: false, reason: "no systemd unit file" };
|
|
49
|
+
}
|
|
50
|
+
// If --port is not baked into ExecStart, the in-process SIGUSR1 restart
|
|
51
|
+
// already re-reads the config port — no unit update needed.
|
|
52
|
+
if (!/\s--port\s+\d+/.test(content)) {
|
|
53
|
+
return { updated: false, reason: "no --port in ExecStart" };
|
|
54
|
+
}
|
|
55
|
+
const updated = content
|
|
56
|
+
.replace(/(\s--port\s+)\d+/g, `$1${newPort}`)
|
|
57
|
+
.replace(/TASKMASTER_GATEWAY_PORT=\d+/g, `TASKMASTER_GATEWAY_PORT=${newPort}`);
|
|
58
|
+
await fs.writeFile(unitPath, updated, "utf8");
|
|
59
|
+
try {
|
|
60
|
+
await execFileAsync("systemctl", ["--user", "daemon-reload"], { encoding: "utf8" });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Non-fatal — the unit file is updated on disk; daemon-reload failure
|
|
64
|
+
// means the in-memory definition is stale but the next restart will pick up
|
|
65
|
+
// the correct file regardless.
|
|
66
|
+
}
|
|
67
|
+
return { updated: true, platform: "linux" };
|
|
68
|
+
}
|
|
69
|
+
async function updateLaunchdPort(newPort) {
|
|
70
|
+
const env = process.env;
|
|
71
|
+
const plistPath = resolveLaunchAgentPlistPath(env);
|
|
72
|
+
let content;
|
|
73
|
+
try {
|
|
74
|
+
content = await fs.readFile(plistPath, "utf8");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return { updated: false, reason: "no launchd plist" };
|
|
78
|
+
}
|
|
79
|
+
// If --port is not in ProgramArguments, the in-process restart already works.
|
|
80
|
+
if (!/<string>--port<\/string>/.test(content)) {
|
|
81
|
+
return { updated: false, reason: "no --port in ProgramArguments" };
|
|
82
|
+
}
|
|
83
|
+
const updated = content
|
|
84
|
+
// Replace --port value in ProgramArguments
|
|
85
|
+
.replace(/(<string>--port<\/string>\s*)<string>\d+<\/string>/, `$1<string>${newPort}</string>`)
|
|
86
|
+
// Replace TASKMASTER_GATEWAY_PORT value in EnvironmentVariables
|
|
87
|
+
.replace(/(<key>TASKMASTER_GATEWAY_PORT<\/key>\s*)<string>\d+<\/string>/, `$1<string>${newPort}</string>`);
|
|
88
|
+
await fs.writeFile(plistPath, updated, "utf8");
|
|
89
|
+
// Spawn a detached shell that outlives our process:
|
|
90
|
+
// 1. sleep 2 — give the gateway time to respond to the UI before being killed
|
|
91
|
+
// 2. bootout — removes the service from launchd and sends SIGTERM to us
|
|
92
|
+
// 3. sleep 3 — wait for our process to exit cleanly
|
|
93
|
+
// 4. bootstrap — reloads the updated plist and auto-starts the service (RunAtLoad=true)
|
|
94
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
95
|
+
const domain = uid !== null ? `gui/${uid}` : "gui/501";
|
|
96
|
+
const label = env.TASKMASTER_LAUNCHD_LABEL ?? "bot.taskmaster.gateway";
|
|
97
|
+
const shellCmd = [
|
|
98
|
+
"sleep 2",
|
|
99
|
+
`launchctl bootout '${domain}/${label}'`,
|
|
100
|
+
"sleep 3",
|
|
101
|
+
`launchctl bootstrap '${domain}' '${plistPath}'`,
|
|
102
|
+
].join(" && ");
|
|
103
|
+
const child = spawn("sh", ["-c", shellCmd], {
|
|
104
|
+
detached: true,
|
|
105
|
+
stdio: "ignore",
|
|
106
|
+
});
|
|
107
|
+
child.unref();
|
|
108
|
+
return { updated: true, platform: "darwin" };
|
|
109
|
+
}
|
|
@@ -5,6 +5,7 @@ import { applyMergePatch } from "../../config/merge-patch.js";
|
|
|
5
5
|
import { buildConfigSchema } from "../../config/schema.js";
|
|
6
6
|
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
|
7
7
|
import { formatDoctorNonInteractiveHint, writeRestartSentinel, } from "../../infra/restart-sentinel.js";
|
|
8
|
+
import { tryUpdateServiceUnitPort } from "../../daemon/service-port.js";
|
|
8
9
|
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
|
9
10
|
import { loadTaskmasterPlugins } from "../../plugins/loader.js";
|
|
10
11
|
import { ErrorCodes, errorShape, formatValidationErrors, validateConfigApplyParams, validateConfigGetParams, validateConfigPatchParams, validateConfigSchemaParams, validateConfigSetParams, } from "../protocol/index.js";
|
|
@@ -165,6 +166,49 @@ export const configHandlers = {
|
|
|
165
166
|
// instead of killing the process with SIGUSR1. Use for changes that the
|
|
166
167
|
// dynamic reload system handles (channel config, hooks, cron, etc.).
|
|
167
168
|
const skipRestart = params.skipRestart === true;
|
|
169
|
+
// Port change: the --port arg and TASKMASTER_GATEWAY_PORT env var are baked
|
|
170
|
+
// into the service unit at daemon-install time. A plain SIGUSR1 in-process
|
|
171
|
+
// restart would reuse the original startup port, so we must update the unit
|
|
172
|
+
// file and let the supervisor restart the process with the new args instead.
|
|
173
|
+
if (!skipRestart) {
|
|
174
|
+
const newGatewayPort = validated.config.gateway?.port;
|
|
175
|
+
const oldGatewayPort = snapshot.config.gateway?.port;
|
|
176
|
+
if (typeof newGatewayPort === "number" && newGatewayPort !== oldGatewayPort) {
|
|
177
|
+
const portUpdate = await tryUpdateServiceUnitPort(newGatewayPort);
|
|
178
|
+
if (portUpdate.updated) {
|
|
179
|
+
const effectiveDelayMs = restartDelayMs ?? 2000;
|
|
180
|
+
const sentinelPayload = {
|
|
181
|
+
kind: "config-apply",
|
|
182
|
+
status: "ok",
|
|
183
|
+
ts: Date.now(),
|
|
184
|
+
sessionKey,
|
|
185
|
+
message: note ?? null,
|
|
186
|
+
doctorHint: formatDoctorNonInteractiveHint(),
|
|
187
|
+
stats: { mode: "config.patch", root: CONFIG_PATH_TASKMASTER },
|
|
188
|
+
};
|
|
189
|
+
let portSentinelPath = null;
|
|
190
|
+
try {
|
|
191
|
+
portSentinelPath = await writeRestartSentinel(sentinelPayload);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
portSentinelPath = null;
|
|
195
|
+
}
|
|
196
|
+
respond(true, {
|
|
197
|
+
ok: true,
|
|
198
|
+
path: CONFIG_PATH_TASKMASTER,
|
|
199
|
+
config: validated.config,
|
|
200
|
+
restart: { ok: true },
|
|
201
|
+
sentinel: portSentinelPath ? { path: portSentinelPath } : null,
|
|
202
|
+
}, undefined);
|
|
203
|
+
// On Linux, exit so systemd (Restart=always) restarts with the updated unit.
|
|
204
|
+
// On macOS, the detached launchctl shell handles termination via bootout.
|
|
205
|
+
if (portUpdate.platform === "linux") {
|
|
206
|
+
setTimeout(() => process.exit(0), effectiveDelayMs);
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
168
212
|
let restart = null;
|
|
169
213
|
let sentinelPath = null;
|
|
170
214
|
if (!skipRestart) {
|
|
@@ -77,7 +77,10 @@ export async function detectGlobalInstallManagerByPresence(runCommand, timeoutMs
|
|
|
77
77
|
return null;
|
|
78
78
|
}
|
|
79
79
|
export function globalInstallArgs(manager, spec, needsSudo = false) {
|
|
80
|
-
|
|
80
|
+
// Use -n (non-interactive) so sudo fails immediately with exit code 1 when a
|
|
81
|
+
// password is required, rather than blocking indefinitely waiting for TTY input.
|
|
82
|
+
// On systems with NOPASSWD configured (e.g. Pi deployments) this is a no-op.
|
|
83
|
+
const prefix = needsSudo ? ["sudo", "-n"] : [];
|
|
81
84
|
if (manager === "pnpm")
|
|
82
85
|
return [...prefix, "pnpm", "add", "-g", spec];
|
|
83
86
|
if (manager === "bun")
|
|
@@ -439,8 +439,11 @@ export async function runGatewayUpdate(opts = {}) {
|
|
|
439
439
|
const beforeVersion = await readPackageVersion(pkgRoot);
|
|
440
440
|
const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs);
|
|
441
441
|
if (globalManager) {
|
|
442
|
-
//
|
|
443
|
-
//
|
|
442
|
+
// Use sudo when the install directory is not user-writable (e.g. system npm
|
|
443
|
+
// roots like /usr/lib/node_modules). sudo is invoked with -n (non-interactive)
|
|
444
|
+
// so it fails immediately with exit code 1 when a password is required instead
|
|
445
|
+
// of blocking forever waiting for TTY input. On systems with NOPASSWD configured
|
|
446
|
+
// (typical Pi deployments) the -n flag is a no-op and the install proceeds normally.
|
|
444
447
|
let needsSudo = false;
|
|
445
448
|
if (os.platform() !== "win32" && process.getuid?.() !== 0) {
|
|
446
449
|
try {
|
|
@@ -451,10 +454,11 @@ export async function runGatewayUpdate(opts = {}) {
|
|
|
451
454
|
}
|
|
452
455
|
}
|
|
453
456
|
const spec = `@rubytech/taskmaster@${normalizeTag(opts.tag)}`;
|
|
457
|
+
const installArgv = globalInstallArgs(globalManager, spec, needsSudo);
|
|
454
458
|
const updateStep = await runStep({
|
|
455
459
|
runCommand,
|
|
456
|
-
name: "
|
|
457
|
-
argv:
|
|
460
|
+
name: installArgv.join(" "),
|
|
461
|
+
argv: installArgv,
|
|
458
462
|
cwd: pkgRoot,
|
|
459
463
|
timeoutMs,
|
|
460
464
|
env: { PATH: augmentedPath },
|
|
@@ -29,7 +29,7 @@ async function main() {
|
|
|
29
29
|
const Long = mod.default ?? mod;
|
|
30
30
|
globalThis.Long = Long;
|
|
31
31
|
}
|
|
32
|
-
const [{ loadConfig }, { startGatewayServer }, { setGatewayWsLogStyle }, { setVerbose }, { acquireGatewayLock, GatewayLockError }, { consumeGatewaySigusr1RestartAuthorization, isGatewaySigusr1RestartExternallyAllowed }, { defaultRuntime }, { enableConsoleCapture, setConsoleTimestampPrefix },] = await Promise.all([
|
|
32
|
+
const [{ loadConfig, resolveGatewayPort }, { startGatewayServer }, { setGatewayWsLogStyle }, { setVerbose }, { acquireGatewayLock, GatewayLockError }, { consumeGatewaySigusr1RestartAuthorization, isGatewaySigusr1RestartExternallyAllowed }, { defaultRuntime }, { enableConsoleCapture, setConsoleTimestampPrefix },] = await Promise.all([
|
|
33
33
|
import("../config/config.js"),
|
|
34
34
|
import("../gateway/server.js"),
|
|
35
35
|
import("../gateway/ws-logging.js"),
|
|
@@ -46,13 +46,31 @@ async function main() {
|
|
|
46
46
|
const wsLogStyle = wsLogRaw === "compact" ? "compact" : wsLogRaw === "full" ? "full" : "auto";
|
|
47
47
|
setGatewayWsLogStyle(wsLogStyle);
|
|
48
48
|
const cfg = loadConfig();
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
// Explicit --port arg: fixed for lifetime of process.
|
|
50
|
+
const portArgRaw = argValue(args, "--port");
|
|
51
|
+
const portArgOverride = portArgRaw !== undefined ? Number.parseInt(portArgRaw, 10) : null;
|
|
52
|
+
if (portArgRaw !== undefined &&
|
|
53
|
+
(portArgOverride === null || Number.isNaN(portArgOverride) || portArgOverride <= 0)) {
|
|
54
|
+
defaultRuntime.error(`Invalid --port (${portArgRaw})`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
// When no explicit --port is given, re-read on each restart so UI port changes
|
|
58
|
+
// take effect in-process. Config port is preferred over TASKMASTER_GATEWAY_PORT
|
|
59
|
+
// because the env var is set at daemon-install time and doesn't update when the
|
|
60
|
+
// user edits the port via the UI. Falls back to env var / default when the
|
|
61
|
+
// config key is absent.
|
|
62
|
+
const resolveStartPort = () => {
|
|
63
|
+
if (portArgOverride !== null)
|
|
64
|
+
return portArgOverride;
|
|
65
|
+
const currentCfg = loadConfig();
|
|
66
|
+
const cfgPort = currentCfg.gateway?.port;
|
|
67
|
+
return typeof cfgPort === "number" && Number.isFinite(cfgPort) && cfgPort > 0
|
|
68
|
+
? cfgPort
|
|
69
|
+
: resolveGatewayPort(currentCfg);
|
|
70
|
+
};
|
|
71
|
+
const port = resolveStartPort();
|
|
54
72
|
if (Number.isNaN(port) || port <= 0) {
|
|
55
|
-
defaultRuntime.error(`Invalid
|
|
73
|
+
defaultRuntime.error(`Invalid port (${port})`);
|
|
56
74
|
process.exit(1);
|
|
57
75
|
}
|
|
58
76
|
const bindRaw = argValue(args, "--bind") ?? process.env.TASKMASTER_GATEWAY_BIND ?? cfg.gateway?.bind ?? "lan";
|
|
@@ -152,7 +170,7 @@ async function main() {
|
|
|
152
170
|
// eslint-disable-next-line no-constant-condition
|
|
153
171
|
while (true) {
|
|
154
172
|
try {
|
|
155
|
-
server = await startGatewayServer(
|
|
173
|
+
server = await startGatewayServer(resolveStartPort(), { bind });
|
|
156
174
|
}
|
|
157
175
|
catch (err) {
|
|
158
176
|
cleanupSignals();
|
package/dist/memory/manager.js
CHANGED
|
@@ -83,6 +83,17 @@ function expandPathTemplate(pattern, ctx) {
|
|
|
83
83
|
function normalizePhoneInMemoryPath(relPath) {
|
|
84
84
|
return relPath.replace(/^(memory\/users\/)(\d)/i, "$1+$2");
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Ensure group IDs in memory/groups/ paths include the canonical @g.us suffix.
|
|
88
|
+
* AI agents sometimes omit the suffix when constructing paths
|
|
89
|
+
* (e.g. "memory/groups/120363423828326592/members/..." instead of
|
|
90
|
+
* "memory/groups/120363423828326592@g.us/members/...").
|
|
91
|
+
* The session peer and filesystem convention always use the full JID with @g.us.
|
|
92
|
+
* Without this, scope patterns like memory/groups/{peer}/** won't match the path.
|
|
93
|
+
*/
|
|
94
|
+
function normalizeGroupIdInMemoryPath(relPath) {
|
|
95
|
+
return relPath.replace(/^(memory\/groups\/)(\d{15,})(?!\S*@)(\/)/i, "$1$2@g.us$3");
|
|
96
|
+
}
|
|
86
97
|
/**
|
|
87
98
|
* Simple glob pattern matcher supporting * and **.
|
|
88
99
|
* - * matches any characters except /
|
|
@@ -527,7 +538,7 @@ export class MemoryIndexManager {
|
|
|
527
538
|
return this.syncing;
|
|
528
539
|
}
|
|
529
540
|
async readFile(params) {
|
|
530
|
-
const relPath = normalizePhoneInMemoryPath(normalizeRelPath(params.relPath));
|
|
541
|
+
const relPath = normalizeGroupIdInMemoryPath(normalizePhoneInMemoryPath(normalizeRelPath(params.relPath)));
|
|
531
542
|
if (!relPath || !isMemoryPath(relPath)) {
|
|
532
543
|
throw new Error(relPath
|
|
533
544
|
? `invalid path "${relPath}" — must start with "memory/" (e.g. "memory/admin/file.md")`
|
|
@@ -567,7 +578,7 @@ export class MemoryIndexManager {
|
|
|
567
578
|
* matching the session's scope configuration.
|
|
568
579
|
*/
|
|
569
580
|
async writeFile(params) {
|
|
570
|
-
const relPath = normalizePhoneInMemoryPath(normalizeRelPath(params.relPath));
|
|
581
|
+
const relPath = normalizeGroupIdInMemoryPath(normalizePhoneInMemoryPath(normalizeRelPath(params.relPath)));
|
|
571
582
|
if (!relPath || !isMemoryPath(relPath)) {
|
|
572
583
|
throw new Error(relPath
|
|
573
584
|
? `invalid path "${relPath}" — must start with "memory/" (e.g. "memory/admin/file.md")`
|
|
@@ -615,7 +626,7 @@ export class MemoryIndexManager {
|
|
|
615
626
|
let relPath;
|
|
616
627
|
if (params.destFolder) {
|
|
617
628
|
// Explicit folder — use as-is (scope checking enforces access)
|
|
618
|
-
relPath = normalizePhoneInMemoryPath(`${params.destFolder}/${params.destFilename}`);
|
|
629
|
+
relPath = normalizeGroupIdInMemoryPath(normalizePhoneInMemoryPath(`${params.destFolder}/${params.destFilename}`));
|
|
619
630
|
}
|
|
620
631
|
else {
|
|
621
632
|
// Default: memory/users/{peer}/media/{filename}
|
package/package.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sales-closer
|
|
3
|
+
description: "Detects buying signals in customer conversations and guides the agent to close sales effectively. Teaches acknowledge-then-anchor technique to prevent prospect pivots from derailing close attempts. Reinforces business owner offers and tracks close outcomes."
|
|
4
|
+
metadata: {"taskmaster":{"always":true,"emoji":"🎯","skillKey":"sales-closer"}}
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Sales Closer
|
|
8
|
+
|
|
9
|
+
You are always watching for buying signals in customer conversations. When a prospect shows intent, your priority shifts from informing to closing.
|
|
10
|
+
|
|
11
|
+
## Three Rules
|
|
12
|
+
|
|
13
|
+
1. **Detect buying signals** — Enthusiasm, future-tense commitment, pricing/setup/next-step questions, returning after a gap, sharing detailed business info unprompted. When you detect these, enter closing mode.
|
|
14
|
+
|
|
15
|
+
2. **Acknowledge-then-anchor** — When a prospect asks a question mid-close, give a brief answer, then immediately re-anchor to the commitment ask. Never let a question fully derail the close. The sequence is always: brief answer → re-anchor. Not: full answer → trailing close.
|
|
16
|
+
|
|
17
|
+
3. **Reinforce the owner's offers** — When the business owner presents options in the conversation, actively frame and reinforce those options for the prospect. Never let an owner's offer go un-anchored.
|
|
18
|
+
|
|
19
|
+
## What Closing Is Not
|
|
20
|
+
|
|
21
|
+
Never pressure, manipulate, or create false urgency. Never repeat the same close more than twice consecutively. Never close over genuine unresolved concerns — address them first. Never commit to pricing or terms the business owner has not authorised.
|
|
22
|
+
|
|
23
|
+
## References
|
|
24
|
+
|
|
25
|
+
Load the relevant reference when the situation requires it:
|
|
26
|
+
|
|
27
|
+
- **Closing methodology** → `references/closing-framework.md` — buying signals, closing techniques, the golden rule, when to close vs when to wait
|
|
28
|
+
- **Objections & pivots** → `references/objection-handling.md` — distinguishing pivots from objections, acknowledge-then-anchor patterns, common objection types
|
|
29
|
+
- **Tracking & follow-up** → `references/close-tracking.md` — persisting close attempts to memory, CRM updates, follow-up scheduling, escalation after stalled closes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Close Tracking
|
|
2
|
+
|
|
3
|
+
## Why Track
|
|
4
|
+
|
|
5
|
+
Closing is iterative. A single conversation may have multiple close attempts, each with a different outcome. Tracking close attempts in memory enables:
|
|
6
|
+
|
|
7
|
+
- Pattern recognition: which techniques work for this prospect?
|
|
8
|
+
- Follow-up discipline: when did we agree to check back?
|
|
9
|
+
- Escalation context: if handing off to the owner, what has already been tried?
|
|
10
|
+
- Pipeline accuracy: is this prospect in "enquiry" or genuinely moving toward "booked"?
|
|
11
|
+
|
|
12
|
+
## Where to Write
|
|
13
|
+
|
|
14
|
+
Close tracking data lives in the prospect's memory profile. The path depends on the session type:
|
|
15
|
+
|
|
16
|
+
- **DM sessions:** `memory/users/{phone}/profile.md` — append to the prospect's user profile
|
|
17
|
+
- **Group sessions:** `memory/groups/{groupId}/members/{phone}.md` — append to the prospect's group member profile
|
|
18
|
+
|
|
19
|
+
The public agent can only write to user-scoped memory (`memory/users/{peer}/**`) and group-scoped memory (`memory/groups/{peer}/**`) for the current session's peer. In a group conversation, write to the group member file — not to the user's DM profile.
|
|
20
|
+
|
|
21
|
+
## What to Record
|
|
22
|
+
|
|
23
|
+
After each close attempt in a customer conversation, write a brief entry to the prospect's memory profile:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
## Sales Close Log
|
|
27
|
+
|
|
28
|
+
### [Date] — [Close technique used]
|
|
29
|
+
- **Ask:** [What was asked — e.g. "Shall we get you set up with a trial?"]
|
|
30
|
+
- **Outcome:** [committed / pivoted / objected / deferred]
|
|
31
|
+
- **Detail:** [What happened — e.g. "Prospect asked about CRM integration, acknowledged-then-anchored, prospect said they'd think about it"]
|
|
32
|
+
- **Follow-up:** [Next action — e.g. "Check back Thursday" or "None — committed"]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Keep entries concise. The purpose is context for the next interaction, not a transcript.
|
|
36
|
+
|
|
37
|
+
## When to Update the CRM Contact Record
|
|
38
|
+
|
|
39
|
+
The CRM pipeline (managed by the business-assistant skill via `contact_update`) should be updated at these moments:
|
|
40
|
+
|
|
41
|
+
| Close outcome | CRM action |
|
|
42
|
+
|--------------|------------|
|
|
43
|
+
| Prospect commits to a purchase/subscription | Update status to `booked` (or the appropriate stage) |
|
|
44
|
+
| Prospect commits to a trial | Update status to `booked`, add note: "Trial started [date]" |
|
|
45
|
+
| Quote accepted | Update status from `quoted` to `booked` |
|
|
46
|
+
| Prospect explicitly declines | Update status to `archived`, add note with reason |
|
|
47
|
+
| Prospect defers | Keep current status, add `lastContact` date and follow-up note |
|
|
48
|
+
|
|
49
|
+
Do not update the CRM status on pivots or mid-conversation objections — only on definitive outcomes.
|
|
50
|
+
|
|
51
|
+
## Follow-Up Scheduling
|
|
52
|
+
|
|
53
|
+
When a prospect defers ("not right now", "let me think about it", "check back next week"):
|
|
54
|
+
|
|
55
|
+
1. Agree on a specific follow-up date — ask: "When would be good to check back?"
|
|
56
|
+
2. If no date given, default to 3 business days
|
|
57
|
+
3. Write the follow-up date to memory: `Follow-up: [date] — [context]`
|
|
58
|
+
4. When the follow-up date arrives and the agent receives a cron trigger or the prospect messages again, use the **Return Close** technique — they were interested, just needed time
|
|
59
|
+
|
|
60
|
+
## Escalation Rules
|
|
61
|
+
|
|
62
|
+
Escalate to the business owner when:
|
|
63
|
+
|
|
64
|
+
1. **Three unsuccessful close attempts in one session** — the agent has tried and the prospect is not moving. Summarise what was tried and hand off.
|
|
65
|
+
2. **Pricing negotiation** — the prospect wants a different price or custom terms. The agent does not have authority.
|
|
66
|
+
3. **Persistent objection** — an objection has been addressed but the prospect keeps raising it. The concern may need a human touch.
|
|
67
|
+
4. **Prospect requests human contact** — always respect this immediately.
|
|
68
|
+
|
|
69
|
+
When escalating, send the business owner a structured summary:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
**Sales escalation — [Prospect name]**
|
|
73
|
+
|
|
74
|
+
What they need: [1-2 sentences]
|
|
75
|
+
Where we are: [Current pipeline stage]
|
|
76
|
+
What was tried: [Close techniques used and outcomes]
|
|
77
|
+
What's blocking: [The specific objection or stall]
|
|
78
|
+
Recommended next step: [Your suggestion for the owner]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Integration with Quote Follow-Ups
|
|
82
|
+
|
|
83
|
+
The business-assistant skill's quoting reference already has follow-up nudges (3-day nudge, 7-day escalation). When a quote follow-up is triggered, apply closing intent — do not send a passive "just checking in" message. Instead:
|
|
84
|
+
|
|
85
|
+
- **3-day nudge:** Use the Summary Close — recap the quote, restate value, ask directly: "Shall I book this in?"
|
|
86
|
+
- **7-day escalation:** Escalate to the business owner with full context, not just "no response." Include: what was quoted, what follow-up has been sent, and a recommendation (chase, re-quote at different price, or close as lost).
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Closing Framework
|
|
2
|
+
|
|
3
|
+
## Buying Signals
|
|
4
|
+
|
|
5
|
+
A buying signal is anything the prospect says or does that indicates they are mentally moving toward commitment. Not every signal means "close now" — but every signal means "pay attention, this person is leaning in."
|
|
6
|
+
|
|
7
|
+
**Verbal signals:**
|
|
8
|
+
- Enthusiasm: "love it", "sounds great", "exactly what I need", "brilliant"
|
|
9
|
+
- Future-tense commitment: "when we set this up", "I'd want to", "once I'm onboarded"
|
|
10
|
+
- Asking about pricing, packages, timelines, or next steps
|
|
11
|
+
- Asking about integration with their existing tools — they are mentally placing the product in their workflow
|
|
12
|
+
- Sharing detailed business information unprompted — they are investing in the relationship
|
|
13
|
+
- Asking operational questions: "who would I contact for support?", "how does billing work?"
|
|
14
|
+
|
|
15
|
+
**Behavioural signals:**
|
|
16
|
+
- Returning to a conversation after a gap — they have been thinking about it; they have already decided
|
|
17
|
+
- Responding quickly to messages — engagement indicates interest
|
|
18
|
+
- Introducing the product to colleagues or partners — social commitment
|
|
19
|
+
- Completing onboarding steps before being asked
|
|
20
|
+
|
|
21
|
+
**Critical insight:** When a prospect returns after a gap (hours or days), they are almost certainly ready. Do not re-sell. Confirm and close: "Great to have you back! Sounds like you're ready — shall we confirm [specific next step]?"
|
|
22
|
+
|
|
23
|
+
## Closing Techniques
|
|
24
|
+
|
|
25
|
+
These are principles, not scripts. The agent adapts the language to the conversation's tone and the business's communication style.
|
|
26
|
+
|
|
27
|
+
### Direct Close
|
|
28
|
+
|
|
29
|
+
Ask for the commitment directly. Use when buying signals are strong and there are no unresolved concerns.
|
|
30
|
+
|
|
31
|
+
> "Shall I get you set up?"
|
|
32
|
+
> "Ready to go ahead?"
|
|
33
|
+
> "Let's get you started — shall we?"
|
|
34
|
+
|
|
35
|
+
### Choice Close
|
|
36
|
+
|
|
37
|
+
Present two options, both of which are a "yes." Use when the business owner has presented options, or when you need to move from "if" to "which."
|
|
38
|
+
|
|
39
|
+
> "Would you prefer the standard subscription or the Founders Club?"
|
|
40
|
+
> "Shall we start with the trial or go straight to full setup?"
|
|
41
|
+
|
|
42
|
+
### Summary Close
|
|
43
|
+
|
|
44
|
+
Recap what the prospect has told you — their needs, pain points, and what the product solves — then close. Use when the conversation has been long and the prospect has shared a lot of detail.
|
|
45
|
+
|
|
46
|
+
> "So you need 24/7 coverage for enquiries, something to handle the hours Anneke isn't available, and a way to capture leads from your socials. That's exactly what we do — shall we get you set up?"
|
|
47
|
+
|
|
48
|
+
### Next-Step Close
|
|
49
|
+
|
|
50
|
+
Propose a specific, concrete next step rather than an abstract commitment. Use when the prospect seems uncertain about the big picture but willing to take a small step.
|
|
51
|
+
|
|
52
|
+
> "The next step is getting your WhatsApp number configured — takes about 10 minutes. Shall we do that now?"
|
|
53
|
+
> "Joel can walk you through the pricing options — shall I get him to send those over?"
|
|
54
|
+
|
|
55
|
+
### Return Close
|
|
56
|
+
|
|
57
|
+
When a prospect returns after a gap. They have been thinking about it — they are ready. Confirm, don't re-sell.
|
|
58
|
+
|
|
59
|
+
> "Great to hear from you! Sounds like you're ready to move forward — shall we confirm [next step]?"
|
|
60
|
+
|
|
61
|
+
## The Golden Rule
|
|
62
|
+
|
|
63
|
+
**Close before answering the next question.**
|
|
64
|
+
|
|
65
|
+
When a prospect asks a question during a close attempt, the natural instinct is to answer the question fully — then append a close as an afterthought. This is wrong. The close gets buried and ignored.
|
|
66
|
+
|
|
67
|
+
The correct sequence:
|
|
68
|
+
|
|
69
|
+
1. Briefly acknowledge the question (1-2 sentences max)
|
|
70
|
+
2. Immediately re-anchor to the commitment ask
|
|
71
|
+
3. If they still want a deeper answer, they will ask again — and now you can answer without losing the close
|
|
72
|
+
|
|
73
|
+
**Example (from the Alex session):**
|
|
74
|
+
|
|
75
|
+
Wrong:
|
|
76
|
+
> "Loop CRM doesn't have a direct integration yet, but here are three ways we can work around it... [detailed explanation]. So, ready to give it a go?"
|
|
77
|
+
|
|
78
|
+
Right:
|
|
79
|
+
> "Good question on Loop — no direct integration yet, but we've got workarounds that work well. We can dig into the details once you're set up. So — shall we get you started?"
|
|
80
|
+
|
|
81
|
+
The prospect who genuinely needs the CRM answer before committing will say so. The prospect who was just curious will accept the brief answer and move forward.
|
|
82
|
+
|
|
83
|
+
## When to Close vs When to Wait
|
|
84
|
+
|
|
85
|
+
**Close when:**
|
|
86
|
+
- Buying signals are present
|
|
87
|
+
- Discovery is reasonably complete (you understand their needs)
|
|
88
|
+
- The business owner has presented or approved an offer
|
|
89
|
+
- The prospect returns after a gap
|
|
90
|
+
|
|
91
|
+
**Wait when:**
|
|
92
|
+
- The prospect has genuine unresolved concerns (not pivots — actual objections)
|
|
93
|
+
- You do not yet understand what they need (discovery incomplete)
|
|
94
|
+
- The business owner has not set pricing or terms
|
|
95
|
+
- The prospect has explicitly said "not now" and given a reason — respect it, set a follow-up
|
|
96
|
+
|
|
97
|
+
**Escalate when:**
|
|
98
|
+
- The prospect asks about pricing the agent is not authorised to quote
|
|
99
|
+
- The close has been attempted 3 times in one session without success
|
|
100
|
+
- The prospect wants to negotiate terms
|
|
101
|
+
- The prospect wants to speak to a human
|
|
102
|
+
|
|
103
|
+
## Reinforcing the Owner's Offers
|
|
104
|
+
|
|
105
|
+
When the business owner presents an offer in the conversation (pricing, packages, special deals), the agent must:
|
|
106
|
+
|
|
107
|
+
1. **Register the offer** — note exactly what was presented
|
|
108
|
+
2. **Frame the options** — if the owner gave multiple options, present them clearly: "So you've got two options: [A] or [B]. Which sounds right for you?"
|
|
109
|
+
3. **Anchor to the offer** — in subsequent messages, reference the specific offer rather than generic closing language
|
|
110
|
+
4. **Never modify or add to the offer** — only reinforce what the owner actually said
|
|
111
|
+
|
|
112
|
+
If the owner's offer goes unanswered by the prospect, circle back to it within the conversation: "Joel mentioned the Founders Club option earlier — have you had a chance to think about that?"
|