@rubytech/taskmaster 1.13.3 → 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.
Files changed (41) hide show
  1. package/dist/agents/workspace-migrations.js +128 -0
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/gateway-cli/run.js +36 -16
  4. package/dist/control-ui/assets/{index-BWqMMgRV.js → index-B3nkSwMP.js} +23 -21
  5. package/dist/control-ui/assets/index-B3nkSwMP.js.map +1 -0
  6. package/dist/control-ui/assets/{index-B8I8lMfz.css → index-l54GcTyj.css} +1 -1
  7. package/dist/control-ui/index.html +2 -2
  8. package/dist/daemon/service-port.js +109 -0
  9. package/dist/gateway/server-methods/config.js +44 -0
  10. package/dist/infra/update-global.js +4 -1
  11. package/dist/infra/update-runner.js +8 -4
  12. package/dist/macos/gateway-daemon.js +26 -8
  13. package/dist/memory/manager.js +14 -3
  14. package/package.json +1 -1
  15. package/skills/sales-closer/SKILL.md +29 -0
  16. package/skills/sales-closer/references/close-tracking.md +86 -0
  17. package/skills/sales-closer/references/closing-framework.md +112 -0
  18. package/skills/sales-closer/references/objection-handling.md +101 -0
  19. package/taskmaster-docs/USER-GUIDE.md +2 -1
  20. package/templates/beagle-taxi/memory/public/knowledge-base.md +11 -11
  21. package/templates/beagle-taxi/skills/beagle-taxi/SKILL.md +1 -1
  22. package/templates/beagle-zanzibar/agents/admin/AGENTS.md +116 -0
  23. package/templates/beagle-zanzibar/agents/admin/BOOTSTRAP.md +145 -0
  24. package/templates/{zanzi-taxi → beagle-zanzibar}/agents/admin/HEARTBEAT.md +1 -0
  25. package/templates/{zanzi-taxi → beagle-zanzibar}/agents/public/AGENTS.md +15 -2
  26. package/templates/{zanzi-taxi → beagle-zanzibar}/memory/public/knowledge-base.md +13 -0
  27. package/templates/beagle-zanzibar/memory/public/terms.md +81 -0
  28. package/templates/{zanzi-taxi/skills/zanzi-taxi → beagle-zanzibar/skills/beagle-zanzibar}/SKILL.md +7 -3
  29. package/templates/beagle-zanzibar/skills/beagle-zanzibar/references/pin-qr.md +52 -0
  30. package/templates/{zanzi-taxi/skills/zanzi-taxi → beagle-zanzibar/skills/beagle-zanzibar}/references/post-ride.md +13 -0
  31. package/templates/{zanzi-taxi/skills/zanzi-taxi → beagle-zanzibar/skills/beagle-zanzibar}/references/ride-matching.md +25 -17
  32. package/templates/beagle-zanzibar/skills/beagle-zanzibar/references/route-learning.md +61 -0
  33. package/templates/beagle-zanzibar/skills/stripe/SKILL.md +28 -0
  34. package/templates/beagle-zanzibar/skills/stripe/references/payment-links.md +71 -0
  35. package/dist/control-ui/assets/index-BWqMMgRV.js.map +0 -1
  36. package/templates/zanzi-taxi/agents/admin/AGENTS.md +0 -60
  37. /package/templates/{zanzi-taxi → beagle-zanzibar}/agents/admin/IDENTITY.md +0 -0
  38. /package/templates/{zanzi-taxi → beagle-zanzibar}/agents/admin/SOUL.md +0 -0
  39. /package/templates/{zanzi-taxi → beagle-zanzibar}/agents/public/IDENTITY.md +0 -0
  40. /package/templates/{zanzi-taxi → beagle-zanzibar}/agents/public/SOUL.md +0 -0
  41. /package/templates/{zanzi-taxi/skills/zanzi-taxi → beagle-zanzibar/skills/beagle-zanzibar}/references/local-knowledge.md +0 -0
@@ -6,8 +6,8 @@
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-BWqMMgRV.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-B8I8lMfz.css">
9
+ <script type="module" crossorigin src="./assets/index-B3nkSwMP.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-l54GcTyj.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -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
- const prefix = needsSudo ? ["sudo"] : [];
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
- // Global npm installs on Linux/macOS typically need sudo unless running as root
443
- // or using a user-prefix setup (nvm, volta, etc.)
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: "global update",
457
- argv: globalInstallArgs(globalManager, spec, needsSudo),
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
- const portRaw = argValue(args, "--port") ??
50
- process.env.TASKMASTER_GATEWAY_PORT ??
51
- (typeof cfg.gateway?.port === "number" ? String(cfg.gateway.port) : "") ??
52
- "18789";
53
- const port = Number.parseInt(portRaw, 10);
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 --port (${portRaw})`);
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(port, { bind });
173
+ server = await startGatewayServer(resolveStartPort(), { bind });
156
174
  }
157
175
  catch (err) {
158
176
  cleanupSignals();
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.13.3",
3
+ "version": "1.14.2",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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?"