openclaw-telegram-manager 2.3.2 → 2.5.0

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 (55) hide show
  1. package/README.md +75 -58
  2. package/dist/commands/autopilot.d.ts +4 -0
  3. package/dist/commands/autopilot.d.ts.map +1 -1
  4. package/dist/commands/autopilot.js +14 -11
  5. package/dist/commands/autopilot.js.map +1 -1
  6. package/dist/commands/daily-report.d.ts +6 -0
  7. package/dist/commands/daily-report.d.ts.map +1 -1
  8. package/dist/commands/daily-report.js +17 -16
  9. package/dist/commands/daily-report.js.map +1 -1
  10. package/dist/commands/doctor-all.d.ts.map +1 -1
  11. package/dist/commands/doctor-all.js +72 -11
  12. package/dist/commands/doctor-all.js.map +1 -1
  13. package/dist/commands/doctor.d.ts.map +1 -1
  14. package/dist/commands/doctor.js +3 -11
  15. package/dist/commands/doctor.js.map +1 -1
  16. package/dist/commands/init.d.ts.map +1 -1
  17. package/dist/commands/init.js +51 -33
  18. package/dist/commands/init.js.map +1 -1
  19. package/dist/commands/rename.d.ts.map +1 -1
  20. package/dist/commands/rename.js +2 -4
  21. package/dist/commands/rename.js.map +1 -1
  22. package/dist/commands/snooze.js +2 -2
  23. package/dist/commands/snooze.js.map +1 -1
  24. package/dist/commands/status.d.ts.map +1 -1
  25. package/dist/commands/status.js +74 -6
  26. package/dist/commands/status.js.map +1 -1
  27. package/dist/commands/sync.js +1 -1
  28. package/dist/commands/sync.js.map +1 -1
  29. package/dist/commands/upgrade.js +2 -2
  30. package/dist/commands/upgrade.js.map +1 -1
  31. package/dist/lib/capsule.js +3 -3
  32. package/dist/lib/capsule.js.map +1 -1
  33. package/dist/lib/doctor-checks.d.ts.map +1 -1
  34. package/dist/lib/doctor-checks.js +26 -59
  35. package/dist/lib/doctor-checks.js.map +1 -1
  36. package/dist/lib/include-generator.d.ts +1 -1
  37. package/dist/lib/include-generator.js +4 -4
  38. package/dist/lib/include-generator.js.map +1 -1
  39. package/dist/lib/registry.d.ts.map +1 -1
  40. package/dist/lib/registry.js +3 -0
  41. package/dist/lib/registry.js.map +1 -1
  42. package/dist/lib/telegram.d.ts +9 -4
  43. package/dist/lib/telegram.d.ts.map +1 -1
  44. package/dist/lib/telegram.js +87 -76
  45. package/dist/lib/telegram.js.map +1 -1
  46. package/dist/lib/types.d.ts +2 -2
  47. package/dist/lib/types.js +1 -1
  48. package/dist/lib/types.js.map +1 -1
  49. package/dist/plugin.js +665 -591
  50. package/dist/setup.js +49 -4
  51. package/dist/setup.js.map +1 -1
  52. package/dist/tool.js +0 -18
  53. package/dist/tool.js.map +1 -1
  54. package/package.json +10 -1
  55. package/skills/tm/SKILL.md +8 -8
package/dist/plugin.js CHANGED
@@ -8850,9 +8850,9 @@ var TopicEntrySchema = Type.Object({
8850
8850
  lastMessageAt: Type.Union([Type.String(), Type.Null()]),
8851
8851
  lastDoctorReportAt: Type.Union([Type.String(), Type.Null()]),
8852
8852
  lastDoctorRunAt: Type.Union([Type.String(), Type.Null()]),
8853
+ lastDailyReportAt: Type.Union([Type.String(), Type.Null()]),
8853
8854
  lastCapsuleWriteAt: Type.Union([Type.String(), Type.Null()]),
8854
8855
  snoozeUntil: Type.Union([Type.String(), Type.Null()]),
8855
- ignoreChecks: Type.Array(Type.String()),
8856
8856
  consecutiveSilentDoctors: Type.Integer({ minimum: 0 }),
8857
8857
  lastPostError: Type.Union([Type.String({ maxLength: MAX_POST_ERROR_LENGTH }), Type.Null()]),
8858
8858
  extras: Type.Record(Type.String(), Type.Unknown())
@@ -8925,6 +8925,9 @@ var migrations = {
8925
8925
  if (entry["lastCapsuleWriteAt"] === void 0) {
8926
8926
  entry["lastCapsuleWriteAt"] = null;
8927
8927
  }
8928
+ if (entry["lastDailyReportAt"] === void 0) {
8929
+ entry["lastDailyReportAt"] = null;
8930
+ }
8928
8931
  }
8929
8932
  }
8930
8933
  return data;
@@ -9128,21 +9131,19 @@ _Describe what this topic is about._
9128
9131
  `,
9129
9132
  "STATUS.md": (name) => `# Status: ${name}
9130
9133
 
9131
- > This file is maintained by the agent \u2014 just send messages in the chat.
9132
-
9133
9134
  ## Last done (UTC)
9134
9135
 
9135
9136
  ${(/* @__PURE__ */ new Date()).toISOString()}
9136
9137
 
9137
- _Waiting for first instructions._
9138
+ Topic created. Waiting for first instructions.
9138
9139
 
9139
9140
  ## Next actions (now)
9140
9141
 
9141
- 1. _e.g. Set up project scaffolding_
9142
+ _None yet._
9142
9143
 
9143
9144
  ## Upcoming actions
9144
9145
 
9145
- _See TODO.md for full backlog._
9146
+ _None yet._
9146
9147
  `,
9147
9148
  "TODO.md": (name) => `# TODO: ${name}
9148
9149
 
@@ -9211,7 +9212,7 @@ function scaffoldCapsule(projectsBase, slug, name, type) {
9211
9212
  throw new Error(`Path escapes projects directory: ${slug}`);
9212
9213
  }
9213
9214
  if (rejectSymlink(projectsBase)) {
9214
- throw new Error(`Projects base is a symlink: ${projectsBase}`);
9215
+ throw new Error("Detected an unsafe file system configuration (symlink)");
9215
9216
  }
9216
9217
  fs4.mkdirSync(capsuleDir, { recursive: false });
9217
9218
  for (const file of BASE_FILES) {
@@ -9239,7 +9240,7 @@ function upgradeCapsule(projectsBase, slug, name, type, currentVersion) {
9239
9240
  throw new Error(`Path escapes projects directory: ${slug}`);
9240
9241
  }
9241
9242
  if (rejectSymlink(capsuleDir)) {
9242
- throw new Error(`Capsule directory is a symlink: ${capsuleDir}`);
9243
+ throw new Error(`Topic directory is a symlink: ${capsuleDir}`);
9243
9244
  }
9244
9245
  const addedFiles = [];
9245
9246
  for (const file of BASE_FILES) {
@@ -9271,8 +9272,8 @@ function upgradeCapsule(projectsBase, slug, name, type, currentVersion) {
9271
9272
  }
9272
9273
 
9273
9274
  // src/commands/init.ts
9274
- import * as fs6 from "node:fs";
9275
- import * as path6 from "node:path";
9275
+ import * as fs7 from "node:fs";
9276
+ import * as path7 from "node:path";
9276
9277
 
9277
9278
  // src/lib/auth.ts
9278
9279
  var AuthTier = {
@@ -9329,27 +9330,39 @@ function checkAuthorization(userId, command, registry, topicAllowFrom) {
9329
9330
 
9330
9331
  // src/lib/telegram.ts
9331
9332
  var TELEGRAM_MSG_LIMIT = 4096;
9332
- function buildDailyReport(data) {
9333
- const n = htmlEscape(data.name);
9333
+ var HEALTH_LABELS = {
9334
+ fresh: "\u2705 Active",
9335
+ // green check
9336
+ stale: "\u23F3 Inactive",
9337
+ // hourglass
9338
+ blocked: "\u26A0\uFE0F Blocked"
9339
+ // warning
9340
+ };
9341
+ function buildDailyReport(data, format = "html") {
9342
+ const isHtml = format === "html";
9343
+ const esc = (s) => isHtml ? htmlEscape(s) : s;
9344
+ const bold = (s) => isHtml ? `<b>${s}</b>` : `**${s}**`;
9345
+ const n = esc(data.name);
9346
+ const healthLabel = HEALTH_LABELS[data.health] ?? data.health;
9334
9347
  const lines = [
9335
- `<b>Daily Report: ${n}</b>`,
9348
+ bold(`Daily Report: ${n}`),
9336
9349
  "",
9337
- `<b>Done today</b>`,
9338
- htmlEscape(data.doneContent),
9350
+ bold("Done today"),
9351
+ esc(data.doneContent),
9339
9352
  "",
9340
- `<b>New learnings</b>`,
9341
- htmlEscape(data.learningsContent),
9353
+ bold("New learnings"),
9354
+ esc(data.learningsContent),
9342
9355
  "",
9343
- `<b>Blockers/Risks</b>`,
9344
- htmlEscape(data.blockersContent),
9356
+ bold("Blockers/Risks"),
9357
+ esc(data.blockersContent),
9345
9358
  "",
9346
- `<b>Next actions (now)</b>`,
9347
- htmlEscape(data.nextContent),
9359
+ bold("Next actions (now)"),
9360
+ esc(data.nextContent),
9348
9361
  "",
9349
- `<b>Upcoming</b>`,
9350
- htmlEscape(data.upcomingContent),
9362
+ bold("Upcoming"),
9363
+ esc(data.upcomingContent),
9351
9364
  "",
9352
- `<b>Health:</b> ${data.health}`
9365
+ `${bold("Health:")} ${healthLabel}`
9353
9366
  ];
9354
9367
  return truncateMessage(lines.join("\n"));
9355
9368
  }
@@ -9364,13 +9377,9 @@ function buildDoctorButtons(groupId, threadId, secret, userId) {
9364
9377
  const cb = (action) => buildCallbackData(action, groupId, threadId, secret, userId);
9365
9378
  return buildInlineKeyboard([
9366
9379
  [
9367
- { text: "Fix", callback_data: cb("fix") },
9368
9380
  { text: "Snooze 7d", callback_data: cb("snooze7d") },
9369
- { text: "Snooze 30d", callback_data: cb("snooze30d") }
9370
- ],
9371
- [
9372
- { text: "Archive", callback_data: cb("archive") },
9373
- { text: "Ignore check", callback_data: cb("ignore") }
9381
+ { text: "Snooze 30d", callback_data: cb("snooze30d") },
9382
+ { text: "Archive topic", callback_data: cb("archive") }
9374
9383
  ]
9375
9384
  ]);
9376
9385
  }
@@ -9397,23 +9406,19 @@ function buildInitConfirmButton(groupId, threadId, secret, userId, type) {
9397
9406
  const cb = buildCallbackData(actionMap[type], groupId, threadId, secret, userId);
9398
9407
  return buildInlineKeyboard([[{ text: "Use this name", callback_data: cb }]]);
9399
9408
  }
9400
- function buildTopicCard(name, slug, type, capsuleVersion) {
9409
+ function buildTopicCard(name, slug, type) {
9401
9410
  return [
9402
9411
  `**Topic: ${name}**`,
9403
- `Type: ${type} | Version: ${capsuleVersion}`,
9404
- `Capsule: projects/${slug}/`,
9412
+ `**Type:** ${type}`,
9413
+ `**Stored in:** projects/${slug}/`,
9405
9414
  "",
9406
9415
  "**How it works**",
9407
- "Just send your instructions in this topic. The agent",
9408
- "maintains STATUS.md and TODO.md automatically as it",
9409
- "works \u2014 nothing is lost on reset or context compaction.",
9410
- "Doctor checks run periodically and alert you if anything",
9411
- "needs attention.",
9416
+ "Talk to the AI in this topic like you normally would \u2014 describe what you need, ask questions, or give instructions. Progress, TODOs, and decisions are tracked automatically so nothing is lost between sessions.",
9412
9417
  "",
9413
- "**Commands:**",
9414
- "/tm status \u2014 quick STATUS.md view",
9418
+ "**Available commands:**",
9419
+ "/tm status \u2014 see current progress",
9415
9420
  "/tm doctor \u2014 run health checks",
9416
- "/tm rename <name> \u2014 rename this topic",
9421
+ "/tm rename new-name \u2014 rename this topic",
9417
9422
  "/tm list \u2014 all topics",
9418
9423
  "/tm archive \u2014 archive this topic",
9419
9424
  "/tm help \u2014 full command reference"
@@ -9423,7 +9428,7 @@ function buildInitWelcomeHtml() {
9423
9428
  return [
9424
9429
  "<b>Set up a new topic workcell</b>",
9425
9430
  "",
9426
- "A workcell gives this topic its own capsule \u2014 a set of markdown files (STATUS.md, TODO.md, etc.) that persist across resets and context compaction.",
9431
+ "A workcell gives this topic a persistent memory \u2014 The AI writes status, TODOs, and notes to disk so context survives between sessions.",
9427
9432
  "",
9428
9433
  "<b>Pick a type:</b>",
9429
9434
  "\u2022 <b>Coding</b> \u2014 adds ARCHITECTURE.md + DEPLOY.md",
@@ -9441,59 +9446,61 @@ function buildInitNameConfirmHtml(name, type) {
9441
9446
  `Name: <b>${n}</b>`,
9442
9447
  `Type: ${t}`,
9443
9448
  "",
9444
- "This name appears in status reports and doctor checks.",
9449
+ "You'll see this name in reports and health checks.",
9445
9450
  "",
9446
9451
  `For a custom name: <code>/tm init your-name ${t}</code>`
9447
9452
  ].join("\n");
9448
9453
  }
9449
- function buildTopicCardHtml(name, slug, type, capsuleVersion) {
9454
+ function buildTopicCardHtml(name, slug, type) {
9450
9455
  const n = htmlEscape(name);
9451
9456
  const s = htmlEscape(slug);
9452
9457
  const t = htmlEscape(type);
9453
9458
  return [
9454
9459
  `<b>Topic: ${n}</b>`,
9455
- `Type: ${t} | Version: ${capsuleVersion}`,
9456
- `Capsule: <code>projects/${s}/</code>`,
9460
+ `<b>Type:</b> ${t}`,
9461
+ `<b>Stored in:</b> <code>projects/${s}/</code>`,
9457
9462
  "",
9458
9463
  "<b>How it works</b>",
9459
- "Just send your instructions in this topic. The agent",
9460
- "maintains STATUS.md and TODO.md automatically as it",
9461
- "works \u2014 nothing is lost on reset or context compaction.",
9462
- "Doctor checks run periodically and alert you if anything",
9463
- "needs attention.",
9464
+ "Talk to the AI in this topic like you normally would \u2014 describe what you need, ask questions, or give instructions. Progress, TODOs, and decisions are tracked automatically so nothing is lost between sessions.",
9464
9465
  "",
9465
- "<b>Commands:</b>",
9466
- "/tm status \u2014 quick STATUS.md view",
9466
+ "<b>Available commands:</b>",
9467
+ "/tm status \u2014 see current progress",
9467
9468
  "/tm doctor \u2014 run health checks",
9468
- "/tm rename &lt;name&gt; \u2014 rename this topic",
9469
+ "/tm rename new-name \u2014 rename this topic",
9469
9470
  "/tm list \u2014 all topics",
9470
9471
  "/tm archive \u2014 archive this topic",
9471
9472
  "/tm help \u2014 full command reference"
9472
9473
  ].join("\n");
9473
9474
  }
9475
+ function formatCommands(text, isHtml) {
9476
+ return text.replace(
9477
+ /\/tm\s\S+(?:\s\S+)*/g,
9478
+ (match) => isHtml ? `<code>${htmlEscape(match)}</code>` : `\`${match}\``
9479
+ );
9480
+ }
9474
9481
  function buildDoctorReport(name, results, format = "markdown") {
9475
9482
  const isHtml = format === "html";
9476
9483
  const n = isHtml ? htmlEscape(name) : name;
9477
9484
  const bold = (s) => isHtml ? `<b>${s}</b>` : `**${s}**`;
9478
- const code = (s) => isHtml ? `<code>${s}</code>` : `\`${s}\``;
9479
- const lines = [bold(`Doctor: ${n}`), ""];
9480
- if (results.length === 0) {
9481
- lines.push("All checks passed.");
9485
+ const lines = [bold(`Health check: ${n}`), ""];
9486
+ const significant = results.filter((r) => r.severity !== Severity.INFO);
9487
+ if (significant.length === 0) {
9488
+ lines.push("All good \u2014 no issues found.");
9482
9489
  return lines.join("\n");
9483
9490
  }
9484
- for (const r of results) {
9491
+ for (let i = 0; i < significant.length; i++) {
9492
+ const r = significant[i];
9485
9493
  const icon = severityIcon(r.severity);
9486
9494
  const msg = isHtml ? htmlEscape(r.message) : r.message;
9487
- const checkId = isHtml ? htmlEscape(r.checkId) : r.checkId;
9488
- const fix = r.fixable ? " [fixable]" : "";
9489
- lines.push(`${icon} ${code(checkId)}: ${msg}${fix}`);
9495
+ lines.push(`${icon} ${msg}`);
9490
9496
  if (r.remediation) {
9491
- const rem = isHtml ? htmlEscape(r.remediation) : r.remediation;
9492
- lines.push(` \u2192 ${rem}`);
9497
+ const rem = formatCommands(r.remediation, isHtml);
9498
+ lines.push(` \u2192 ${rem}`);
9499
+ }
9500
+ if (i < significant.length - 1) {
9501
+ lines.push("");
9493
9502
  }
9494
9503
  }
9495
- lines.push("");
9496
- lines.push("Reply /tm doctor to re-check, or use the buttons below.");
9497
9504
  return truncateMessage(lines.join("\n"));
9498
9505
  }
9499
9506
  function severityIcon(severity) {
@@ -9515,38 +9522,44 @@ function buildHelpCard() {
9515
9522
  return [
9516
9523
  "**Topic Manager Commands**",
9517
9524
  "",
9518
- "/tm init \u2014 register this topic",
9519
- "/tm status \u2014 quick STATUS.md view",
9520
- "/tm doctor \u2014 run health checks",
9521
- "/tm doctor --all \u2014 check all topics",
9522
- "/tm rename <name> \u2014 rename this topic",
9525
+ "**Basics**",
9526
+ "/tm init \u2014 set up this topic",
9527
+ "/tm status \u2014 see current progress",
9523
9528
  "/tm list \u2014 all topics",
9524
- "/tm sync \u2014 re-apply config",
9525
- "/tm upgrade \u2014 update capsule template",
9526
- "/tm snooze <Nd> \u2014 snooze doctor (7d, 30d, etc.)",
9527
- "/tm archive \u2014 archive topic",
9528
- "/tm unarchive \u2014 reactivate topic",
9529
- "/tm autopilot [enable|disable|status] \u2014 daily sweeps",
9530
- "/tm daily-report \u2014 generate daily status report",
9531
- "/tm help \u2014 this message"
9529
+ "/tm help \u2014 this message",
9530
+ "",
9531
+ "**Health & reports**",
9532
+ "/tm doctor \u2014 run health checks",
9533
+ "/tm doctor --all \u2014 check all topics at once",
9534
+ "/tm daily-report \u2014 post a daily summary",
9535
+ "/tm autopilot enable \u2014 automatic daily health checks",
9536
+ "/tm autopilot disable \u2014 turn off automatic checks",
9537
+ "",
9538
+ "**Manage topics**",
9539
+ "/tm rename new-name \u2014 rename this topic",
9540
+ "/tm snooze 7d \u2014 pause health checks (e.g. 7d, 30d)",
9541
+ "/tm archive \u2014 archive this topic",
9542
+ "/tm unarchive \u2014 bring back an archived topic",
9543
+ "/tm sync \u2014 fix config if out of sync",
9544
+ "/tm upgrade \u2014 update topic files to latest version"
9532
9545
  ].join("\n");
9533
9546
  }
9534
9547
  function buildListMessage(topics) {
9535
9548
  if (topics.length === 0) {
9536
- return "**Topic Registry** (0 topics)\n\nNo topics registered.";
9549
+ return "**Your topics**\n\nNo topics yet. Type /tm init in any topic to get started.";
9537
9550
  }
9538
9551
  const sorted = [...topics].sort((a, b) => {
9539
9552
  const order = { active: 0, snoozed: 1, archived: 2 };
9540
9553
  return (order[a.status] ?? 3) - (order[b.status] ?? 3);
9541
9554
  });
9542
- const lines = [`**Topic Registry** (${topics.length} topics)`, ""];
9555
+ const count = topics.length;
9556
+ const lines = [`**Your topics** (${count})`, ""];
9543
9557
  let rendered = 0;
9544
9558
  for (const t of sorted) {
9545
- const entry = [
9546
- `**${t.name}** [${t.type}] ${t.status}`,
9547
- ` Last active: ${t.lastMessageAt ? relativeTime(t.lastMessageAt) : "never"}`,
9548
- ` Thread: #${t.threadId}`
9549
- ].join("\n");
9559
+ const activity = t.lastMessageAt ? relativeTime(t.lastMessageAt) : "no activity yet";
9560
+ const statusTag = t.status !== "active" ? ` \u2014 ${t.status}` : "";
9561
+ const entry = `**${t.name}** \xB7 ${t.type}${statusTag}
9562
+ ${activity}`;
9550
9563
  const tentative = [...lines, entry, ""].join("\n");
9551
9564
  if (tentative.length > TELEGRAM_MSG_LIMIT - 40) {
9552
9565
  const remaining = sorted.length - rendered;
@@ -9629,7 +9642,7 @@ function getSystemPromptTemplate(name, slug, absoluteWorkspacePath) {
9629
9642
  return `You are the assistant for the Telegram topic: ${name}.
9630
9643
 
9631
9644
  Determinism rules:
9632
- - Source of truth is the project capsule at: ${absoluteWorkspacePath}/projects/${slug}/
9645
+ - Source of truth is the project folder at: ${absoluteWorkspacePath}/projects/${slug}/
9633
9646
  - After /reset, /new, or context compaction: ALWAYS re-read STATUS.md,
9634
9647
  then TODO.md, then LEARNINGS.md (last 20 entries), then COMMANDS.md
9635
9648
  before continuing work. Do not rely on summarized memory for paths,
@@ -9657,10 +9670,10 @@ Learning capture:
9657
9670
 
9658
9671
  Separation:
9659
9672
  - Your workspace is strictly projects/${slug}/. Do not read, write, or reference
9660
- files in any other topic's capsule directory.
9673
+ files in any other topic's project directory.
9661
9674
  - If the user mentions another topic by name or slug, ask for explicit
9662
9675
  confirmation before mixing work: "This references topic X \u2014 switch context?"
9663
- - Never copy data between topic capsules without explicit user instruction.
9676
+ - Never copy data between topic folders without explicit user instruction.
9664
9677
  - Ask one clarifying question if the next action is ambiguous.`;
9665
9678
  }
9666
9679
  function computeRegistryHash(topics) {
@@ -9858,6 +9871,119 @@ async function getConfigWrites(rpc) {
9858
9871
  }
9859
9872
  }
9860
9873
 
9874
+ // src/commands/autopilot.ts
9875
+ import * as fs6 from "node:fs";
9876
+ import * as path6 from "node:path";
9877
+ var MARKER_START = "<!-- TM_AUTOPILOT_START -->";
9878
+ var MARKER_END = "<!-- TM_AUTOPILOT_END -->";
9879
+ var HEARTBEAT_BLOCK = `${MARKER_START}
9880
+ ## Topic Manager \u2014 Balanced Autopilot
9881
+
9882
+ Execute these steps IN ORDER. Do not skip steps.
9883
+
9884
+ 1. CALL \`topic_manager\` with command "doctor --all" to health-check all active topics.
9885
+ This runs health checks AND posts daily progress reports for each eligible topic automatically.
9886
+ 2. For each topic where STATUS.md "Last done (UTC)" is >3 days old:
9887
+ post a brief nudge in that topic asking the user for a status update.
9888
+ 3. If no actions were taken in steps 1-2, do nothing (HEARTBEAT_OK).
9889
+ ${MARKER_END}`;
9890
+ var HEARTBEAT_FILENAME = "HEARTBEAT.md";
9891
+ async function handleAutopilot(ctx, args) {
9892
+ const { workspaceDir, userId } = ctx;
9893
+ if (!userId) {
9894
+ return { text: "Missing context: userId not available." };
9895
+ }
9896
+ const registry = readRegistry(workspaceDir);
9897
+ const auth = checkAuthorization(userId, "autopilot", registry);
9898
+ if (!auth.authorized) {
9899
+ return { text: auth.message ?? "Not authorized." };
9900
+ }
9901
+ const subCommand = args.trim().toLowerCase() || "enable";
9902
+ switch (subCommand) {
9903
+ case "enable":
9904
+ return handleEnable(ctx);
9905
+ case "disable":
9906
+ return handleDisable(ctx);
9907
+ case "status":
9908
+ return handleStatus(ctx);
9909
+ default:
9910
+ return { text: `Unknown autopilot sub-command: "${subCommand}". Use enable, disable, or status.` };
9911
+ }
9912
+ }
9913
+ async function handleEnable(ctx) {
9914
+ const { workspaceDir } = ctx;
9915
+ const heartbeatPath = path6.join(workspaceDir, HEARTBEAT_FILENAME);
9916
+ let content = "";
9917
+ try {
9918
+ if (fs6.existsSync(heartbeatPath)) {
9919
+ content = fs6.readFileSync(heartbeatPath, "utf-8");
9920
+ }
9921
+ } catch {
9922
+ }
9923
+ if (content.includes(MARKER_START)) {
9924
+ await withRegistry(workspaceDir, (data) => {
9925
+ data.autopilotEnabled = true;
9926
+ });
9927
+ return { text: "Autopilot is already enabled." };
9928
+ }
9929
+ const newContent = content ? content.trimEnd() + "\n\n" + HEARTBEAT_BLOCK + "\n" : HEARTBEAT_BLOCK + "\n";
9930
+ fs6.writeFileSync(heartbeatPath, newContent, { mode: 416 });
9931
+ await withRegistry(workspaceDir, (data) => {
9932
+ data.autopilotEnabled = true;
9933
+ });
9934
+ return {
9935
+ text: "**Autopilot enabled.**\nHealth checks will run automatically every day."
9936
+ };
9937
+ }
9938
+ async function handleDisable(ctx) {
9939
+ const { workspaceDir } = ctx;
9940
+ const heartbeatPath = path6.join(workspaceDir, HEARTBEAT_FILENAME);
9941
+ if (!fs6.existsSync(heartbeatPath)) {
9942
+ await withRegistry(workspaceDir, (data) => {
9943
+ data.autopilotEnabled = false;
9944
+ });
9945
+ return { text: "Autopilot is already disabled." };
9946
+ }
9947
+ let content = fs6.readFileSync(heartbeatPath, "utf-8");
9948
+ if (!content.includes(MARKER_START)) {
9949
+ await withRegistry(workspaceDir, (data) => {
9950
+ data.autopilotEnabled = false;
9951
+ });
9952
+ return { text: "Autopilot is already disabled." };
9953
+ }
9954
+ const startIdx = content.indexOf(MARKER_START);
9955
+ const endIdx = content.indexOf(MARKER_END);
9956
+ if (startIdx >= 0 && endIdx >= 0) {
9957
+ const before = content.slice(0, startIdx);
9958
+ const after = content.slice(endIdx + MARKER_END.length);
9959
+ content = (before + after).replace(/\n{3,}/g, "\n\n").trim();
9960
+ if (content) {
9961
+ fs6.writeFileSync(heartbeatPath, content + "\n", { mode: 416 });
9962
+ } else {
9963
+ fs6.unlinkSync(heartbeatPath);
9964
+ }
9965
+ }
9966
+ await withRegistry(workspaceDir, (data) => {
9967
+ data.autopilotEnabled = false;
9968
+ });
9969
+ return {
9970
+ text: "**Autopilot disabled.**\nAutomatic health checks are now off."
9971
+ };
9972
+ }
9973
+ async function handleStatus(ctx) {
9974
+ const { workspaceDir } = ctx;
9975
+ const registry = readRegistry(workspaceDir);
9976
+ const enabled = registry.autopilotEnabled;
9977
+ const lastRun = registry.lastDoctorAllRunAt ? relativeTime(registry.lastDoctorAllRunAt) : "never";
9978
+ const lines = [
9979
+ `**Autopilot:** ${enabled ? "enabled" : "disabled"}`,
9980
+ `**Last health check run:** ${lastRun}`
9981
+ ];
9982
+ return {
9983
+ text: lines.join("\n")
9984
+ };
9985
+ }
9986
+
9861
9987
  // src/commands/init.ts
9862
9988
  var VALID_TYPES = /* @__PURE__ */ new Set(["coding", "research", "marketing", "custom"]);
9863
9989
  function deriveTopicName(nameArg, messageContext, threadId) {
@@ -9881,10 +10007,10 @@ async function handleInit(ctx, args) {
9881
10007
  return { text: "Missing context: groupId, threadId, or userId not available. Run this command inside a Telegram forum topic." };
9882
10008
  }
9883
10009
  if (!validateGroupId(groupId)) {
9884
- return { text: "Invalid groupId format." };
10010
+ return { text: "Something went wrong \u2014 this doesn't look like a valid forum topic." };
9885
10011
  }
9886
10012
  if (!validateThreadId(threadId)) {
9887
- return { text: "Invalid threadId format." };
10013
+ return { text: "Something went wrong \u2014 this doesn't look like a valid forum topic." };
9888
10014
  }
9889
10015
  const registry = readRegistry(workspaceDir);
9890
10016
  const auth = checkAuthorization(userId, "init", registry);
@@ -9915,19 +10041,19 @@ async function handleInit(ctx, args) {
9915
10041
  const name = deriveTopicName(nameArg, messageContext, threadId);
9916
10042
  const existingSlugs = new Set(Object.values(registry.topics).map((t) => t.slug));
9917
10043
  const finalSlug = generateSlug(threadId, groupId, existingSlugs);
9918
- const projectsBase = path6.join(workspaceDir, "projects");
10044
+ const projectsBase = path7.join(workspaceDir, "projects");
9919
10045
  if (!jailCheck(projectsBase, finalSlug)) {
9920
- return { text: "Path safety check failed. Slug may escape the projects directory." };
10046
+ return { text: "Setup failed \u2014 internal path validation error. Please try again." };
9921
10047
  }
9922
10048
  if (rejectSymlink(projectsBase)) {
9923
- return { text: "Projects base is a symlink. Aborting for security." };
10049
+ return { text: "Setup failed \u2014 detected an unsafe file system configuration." };
9924
10050
  }
9925
- if (fs6.existsSync(path6.join(projectsBase, finalSlug))) {
9926
- return { text: `Directory projects/${finalSlug}/ already exists on disk.` };
10051
+ if (fs7.existsSync(path7.join(projectsBase, finalSlug))) {
10052
+ return { text: "A folder for this topic already exists. Run /tm doctor to investigate." };
9927
10053
  }
9928
- const targetPath = path6.join(projectsBase, finalSlug);
10054
+ const targetPath = path7.join(projectsBase, finalSlug);
9929
10055
  if (rejectSymlink(targetPath)) {
9930
- return { text: "Target path is a symlink. Aborting for security." };
10056
+ return { text: "Setup failed \u2014 detected an unsafe file system configuration." };
9931
10057
  }
9932
10058
  const isFirstUser = registry.topicManagerAdmins.length === 0;
9933
10059
  try {
@@ -9944,9 +10070,9 @@ async function handleInit(ctx, args) {
9944
10070
  lastMessageAt: (/* @__PURE__ */ new Date()).toISOString(),
9945
10071
  lastDoctorReportAt: null,
9946
10072
  lastDoctorRunAt: null,
10073
+ lastDailyReportAt: null,
9947
10074
  lastCapsuleWriteAt: null,
9948
10075
  snoozeUntil: null,
9949
- ignoreChecks: [],
9950
10076
  consecutiveSilentDoctors: 0,
9951
10077
  lastPostError: null,
9952
10078
  extras: {}
@@ -9954,12 +10080,32 @@ async function handleInit(ctx, args) {
9954
10080
  data.topics[key] = newEntry;
9955
10081
  if (isFirstUser) {
9956
10082
  data.topicManagerAdmins.push(userId);
10083
+ data.autopilotEnabled = true;
9957
10084
  }
9958
10085
  });
9959
10086
  } catch (err) {
9960
10087
  const msg = err instanceof Error ? err.message : String(err);
9961
10088
  return { text: `Failed to initialize topic: ${msg}` };
9962
10089
  }
10090
+ if (isFirstUser) {
10091
+ try {
10092
+ const heartbeatPath = path7.join(workspaceDir, HEARTBEAT_FILENAME);
10093
+ let hbContent = "";
10094
+ try {
10095
+ if (fs7.existsSync(heartbeatPath)) {
10096
+ hbContent = fs7.readFileSync(heartbeatPath, "utf-8");
10097
+ }
10098
+ } catch {
10099
+ }
10100
+ if (!hbContent.includes(MARKER_START)) {
10101
+ const newContent = hbContent ? hbContent.trimEnd() + "\n\n" + HEARTBEAT_BLOCK + "\n" : HEARTBEAT_BLOCK + "\n";
10102
+ const tmpPath = heartbeatPath + ".tmp";
10103
+ fs7.writeFileSync(tmpPath, newContent, { mode: 416 });
10104
+ fs7.renameSync(tmpPath, heartbeatPath);
10105
+ }
10106
+ } catch {
10107
+ }
10108
+ }
9963
10109
  let restartMsg = "";
9964
10110
  const configWritesEnabled = await getConfigWrites(ctx.rpc);
9965
10111
  if (configWritesEnabled) {
@@ -9973,35 +10119,24 @@ async function handleInit(ctx, args) {
9973
10119
  } catch (err) {
9974
10120
  const msg = err instanceof Error ? err.message : String(err);
9975
10121
  restartMsg = `
9976
- Warning: include generation failed: ${msg}`;
10122
+ Warning: config sync failed: ${msg}`;
9977
10123
  }
9978
10124
  }
9979
10125
  appendAudit(
9980
10126
  workspaceDir,
9981
10127
  buildAuditEntry(userId, "init", finalSlug, `Initialized topic name="${name}" type=${topicType} group=${groupId} thread=${threadId}`)
9982
10128
  );
9983
- const topicCard = buildTopicCard(name, finalSlug, topicType, CAPSULE_VERSION);
9984
- let adminNote = "";
9985
- let adminNoteHtml = "";
9986
- if (isFirstUser) {
9987
- adminNote = "\n\nYou are the first user and have been added as a telegram-manager admin.";
9988
- adminNoteHtml = "\n\nYou are the first user and have been added as a telegram-manager admin.";
9989
- }
9990
- const autopilotTip = "\n\nTip: Enable daily health sweeps with /tm autopilot enable";
9991
- const autopilotTipHtml = "\n\nTip: Enable daily health sweeps with <code>/tm autopilot enable</code>";
10129
+ const topicCard = buildTopicCard(name, finalSlug, topicType);
9992
10130
  if (ctx.postFn && groupId && threadId) {
9993
10131
  try {
9994
- const htmlCard = buildTopicCardHtml(name, finalSlug, topicType, CAPSULE_VERSION);
9995
- await ctx.postFn(groupId, threadId, `${htmlCard}${adminNoteHtml}${autopilotTipHtml}`);
9996
- return {
9997
- text: `Topic "${name}" initialized as ${topicType}. Capsule: projects/${finalSlug}/`,
9998
- pin: true
9999
- };
10132
+ const htmlCard = buildTopicCardHtml(name, finalSlug, topicType);
10133
+ await ctx.postFn(groupId, threadId, htmlCard);
10134
+ return { text: "", pin: true };
10000
10135
  } catch {
10001
10136
  }
10002
10137
  }
10003
10138
  return {
10004
- text: `${topicCard}${adminNote}${restartMsg}${autopilotTip}`,
10139
+ text: `${topicCard}${restartMsg}`,
10005
10140
  pin: true
10006
10141
  };
10007
10142
  }
@@ -10017,10 +10152,10 @@ async function buildTypePicker(ctx) {
10017
10152
  return { text: "Missing context: groupId, threadId, or userId not available. Run this command inside a Telegram forum topic." };
10018
10153
  }
10019
10154
  if (!validateGroupId(groupId)) {
10020
- return { text: "Invalid groupId format." };
10155
+ return { text: "Something went wrong \u2014 this doesn't look like a valid forum topic." };
10021
10156
  }
10022
10157
  if (!validateThreadId(threadId)) {
10023
- return { text: "Invalid threadId format." };
10158
+ return { text: "Something went wrong \u2014 this doesn't look like a valid forum topic." };
10024
10159
  }
10025
10160
  const registry = readRegistry(workspaceDir);
10026
10161
  const auth = checkAuthorization(userId, "init", registry);
@@ -10056,10 +10191,10 @@ async function handleInitTypeSelect(ctx, type) {
10056
10191
  return { text: "Missing context: groupId, threadId, or userId not available. Run this command inside a Telegram forum topic." };
10057
10192
  }
10058
10193
  if (!validateGroupId(groupId)) {
10059
- return { text: "Invalid groupId format." };
10194
+ return { text: "Something went wrong \u2014 this doesn't look like a valid forum topic." };
10060
10195
  }
10061
10196
  if (!validateThreadId(threadId)) {
10062
- return { text: "Invalid threadId format." };
10197
+ return { text: "Something went wrong \u2014 this doesn't look like a valid forum topic." };
10063
10198
  }
10064
10199
  const registry = readRegistry(workspaceDir);
10065
10200
  const auth = checkAuthorization(userId, "init", registry);
@@ -10081,7 +10216,7 @@ async function handleInitTypeSelect(ctx, type) {
10081
10216
  if (ctx.postFn) {
10082
10217
  try {
10083
10218
  await ctx.postFn(groupId, threadId, buildInitNameConfirmHtml(name, type), keyboard);
10084
- return { text: `Type selected: ${type}. Confirm the name or type /tm init <name> ${type}.` };
10219
+ return { text: `Type selected: ${type}. Confirm the name or type /tm init your-name ${type}.` };
10085
10220
  } catch {
10086
10221
  }
10087
10222
  }
@@ -10097,90 +10232,81 @@ function buildInitConfirmMessage(name, type) {
10097
10232
  return [
10098
10233
  "**Almost there!**",
10099
10234
  "",
10100
- `Name: **${name}** | Type: ${type}`,
10235
+ `Name: **${name}**`,
10236
+ `Type: ${type}`,
10101
10237
  "",
10102
- "This name appears in status reports and doctor checks.",
10238
+ "You'll see this name in reports and health checks.",
10103
10239
  "",
10104
- `To use a different name: /tm init <name> ${type}`
10240
+ `For a custom name: \`/tm init your-name ${type}\``
10105
10241
  ].join("\n");
10106
10242
  }
10107
10243
 
10108
10244
  // src/commands/doctor.ts
10109
- import * as fs8 from "node:fs";
10110
- import * as path8 from "node:path";
10245
+ import * as fs9 from "node:fs";
10246
+ import * as path9 from "node:path";
10111
10247
 
10112
10248
  // src/lib/doctor-checks.ts
10113
10249
  var import_json52 = __toESM(require_lib(), 1);
10114
- import * as fs7 from "node:fs";
10115
- import * as path7 from "node:path";
10116
- function isIgnored(entry, checkId) {
10117
- return entry.ignoreChecks.includes(checkId);
10118
- }
10250
+ import * as fs8 from "node:fs";
10251
+ import * as path8 from "node:path";
10119
10252
  function check(severity, checkId, message, fixable, remediation) {
10120
10253
  return remediation ? { severity, checkId, message, fixable, remediation } : { severity, checkId, message, fixable };
10121
10254
  }
10122
10255
  function runRegistryChecks(entry, projectsBase) {
10123
10256
  const results = [];
10124
- const capsuleDir = path7.join(projectsBase, entry.slug);
10125
- if (!fs7.existsSync(capsuleDir)) {
10257
+ const capsuleDir = path8.join(projectsBase, entry.slug);
10258
+ if (!fs8.existsSync(capsuleDir)) {
10126
10259
  results.push(
10127
- check(Severity.ERROR, "pathMissing", `Capsule path does not exist: projects/${entry.slug}/`, false, "Run /tm init to create the capsule, or remove the registry entry")
10260
+ check(Severity.ERROR, "pathMissing", `Project folder is missing (projects/${entry.slug}/)`, false, "Run /tm init to recreate it")
10128
10261
  );
10129
10262
  return results;
10130
10263
  }
10131
10264
  try {
10132
- const stat = fs7.statSync(capsuleDir);
10265
+ const stat = fs8.statSync(capsuleDir);
10133
10266
  if (!stat.isDirectory()) {
10134
10267
  results.push(
10135
- check(Severity.ERROR, "pathNotDir", `projects/${entry.slug} exists but is not a directory`, false)
10268
+ check(Severity.ERROR, "pathNotDir", "Topic path exists but is not a folder", false)
10136
10269
  );
10137
10270
  }
10138
10271
  } catch {
10139
10272
  results.push(
10140
- check(Severity.ERROR, "pathStatFailed", `Cannot stat projects/${entry.slug}/`, false)
10273
+ check(Severity.ERROR, "pathStatFailed", "Cannot verify topic folder on disk", false)
10141
10274
  );
10142
10275
  }
10143
10276
  return results;
10144
10277
  }
10145
10278
  function runCapsuleChecks(entry, projectsBase) {
10146
10279
  const results = [];
10147
- const capsuleDir = path7.join(projectsBase, entry.slug);
10148
- if (!fs7.existsSync(capsuleDir)) return results;
10149
- if (!fs7.existsSync(path7.join(capsuleDir, "STATUS.md"))) {
10280
+ const capsuleDir = path8.join(projectsBase, entry.slug);
10281
+ if (!fs8.existsSync(capsuleDir)) return results;
10282
+ if (!fs8.existsSync(path8.join(capsuleDir, "STATUS.md"))) {
10150
10283
  results.push(
10151
- check(Severity.ERROR, "statusMissing", "STATUS.md is missing from capsule", true, "Run /tm upgrade to recreate STATUS.md, or restore from .tm-backup/STATUS.md if available")
10284
+ check(Severity.ERROR, "statusMissing", "Status file is missing", true, "Run /tm upgrade to recreate it")
10152
10285
  );
10153
10286
  }
10154
- if (!fs7.existsSync(path7.join(capsuleDir, "TODO.md"))) {
10155
- if (!isIgnored(entry, "todoMissing")) {
10156
- results.push(
10157
- check(Severity.WARN, "todoMissing", "TODO.md is missing from capsule", true, "Run /tm upgrade to recreate TODO.md")
10158
- );
10159
- }
10287
+ if (!fs8.existsSync(path8.join(capsuleDir, "TODO.md"))) {
10288
+ results.push(
10289
+ check(Severity.WARN, "todoMissing", "TODO file is missing", true, "Run /tm upgrade to recreate it")
10290
+ );
10160
10291
  }
10161
10292
  const overlays = OVERLAY_FILES[entry.type] ?? [];
10162
10293
  for (const file of overlays) {
10163
- if (!fs7.existsSync(path7.join(capsuleDir, file))) {
10164
- const checkId = `overlayMissing:${file}`;
10165
- if (!isIgnored(entry, checkId)) {
10166
- results.push(
10167
- check(Severity.INFO, checkId, `Optional overlay ${file} missing for type "${entry.type}"`, true)
10168
- );
10169
- }
10170
- }
10171
- }
10172
- if (entry.capsuleVersion < CAPSULE_VERSION) {
10173
- if (!isIgnored(entry, "capsuleVersionBehind")) {
10294
+ if (!fs8.existsSync(path8.join(capsuleDir, file))) {
10174
10295
  results.push(
10175
- check(
10176
- Severity.INFO,
10177
- "capsuleVersionBehind",
10178
- `Capsule version ${entry.capsuleVersion} is behind current ${CAPSULE_VERSION}. Will auto-upgrade on next command.`,
10179
- false
10180
- )
10296
+ check(Severity.INFO, `overlayMissing:${file}`, `Optional overlay ${file} missing for type "${entry.type}"`, true)
10181
10297
  );
10182
10298
  }
10183
10299
  }
10300
+ if (entry.capsuleVersion < CAPSULE_VERSION) {
10301
+ results.push(
10302
+ check(
10303
+ Severity.INFO,
10304
+ "capsuleVersionBehind",
10305
+ `Capsule version ${entry.capsuleVersion} is behind current ${CAPSULE_VERSION}. Will auto-upgrade on next command.`,
10306
+ false
10307
+ )
10308
+ );
10309
+ }
10184
10310
  return results;
10185
10311
  }
10186
10312
  var LAST_DONE_RE = /^##\s*Last done\s*\(UTC\)/im;
@@ -10191,49 +10317,41 @@ var ADHOC_RE = /\[AD-HOC\]/g;
10191
10317
  function runStatusQualityChecks(statusContent, entry) {
10192
10318
  const results = [];
10193
10319
  if (!LAST_DONE_RE.test(statusContent)) {
10194
- if (!isIgnored(entry, "lastDoneMissing")) {
10195
- results.push(
10196
- check(Severity.ERROR, "lastDoneMissing", 'STATUS.md missing "Last done (UTC)" section', true, 'Add "## Last done (UTC)" section with a timestamp to STATUS.md, or restore from .tm-backup/STATUS.md if available')
10197
- );
10198
- }
10320
+ results.push(
10321
+ check(Severity.ERROR, "lastDoneMissing", "Status file is missing the last activity section", true, "The AI will fix this on next interaction")
10322
+ );
10199
10323
  } else {
10200
10324
  const lastDoneIndex = statusContent.search(LAST_DONE_RE);
10201
10325
  const sectionAfter = statusContent.slice(lastDoneIndex);
10202
10326
  const nextSectionIndex = sectionAfter.indexOf("\n## ", 1);
10203
10327
  const lastDoneSection = nextSectionIndex > 0 ? sectionAfter.slice(0, nextSectionIndex) : sectionAfter;
10204
10328
  if (!TIMESTAMP_RE.test(lastDoneSection)) {
10205
- if (!isIgnored(entry, "lastDoneNoTimestamp")) {
10206
- results.push(
10207
- check(Severity.ERROR, "lastDoneNoTimestamp", 'STATUS.md "Last done" section has no timestamp', true, 'Add a YYYY-MM-DDTHH:MM timestamp under "Last done (UTC)" in STATUS.md, or restore from .tm-backup/STATUS.md if available')
10208
- );
10209
- }
10329
+ results.push(
10330
+ check(Severity.ERROR, "lastDoneNoTimestamp", "Last activity has no timestamp", true, "The AI will fix this on next interaction")
10331
+ );
10210
10332
  } else if (entry.status === "active") {
10211
10333
  const tsMatch = lastDoneSection.match(TIMESTAMP_RE);
10212
10334
  if (tsMatch) {
10213
10335
  const ts = new Date(tsMatch[0]);
10214
10336
  const ageDays = (Date.now() - ts.getTime()) / (1e3 * 60 * 60 * 24);
10215
10337
  if (ageDays > 3) {
10216
- if (!isIgnored(entry, "lastDoneStale")) {
10217
- results.push(
10218
- check(
10219
- Severity.WARN,
10220
- "lastDoneStale",
10221
- `STATUS.md "Last done" timestamp is ${Math.floor(ageDays)} days old`,
10222
- false,
10223
- 'Update the "Last done (UTC)" timestamp in STATUS.md or /tm snooze 7d'
10224
- )
10225
- );
10226
- }
10338
+ results.push(
10339
+ check(
10340
+ Severity.WARN,
10341
+ "lastDoneStale",
10342
+ `No activity for ${Math.floor(ageDays)} days`,
10343
+ false,
10344
+ "Send a message to resume, or /tm snooze 7d to silence"
10345
+ )
10346
+ );
10227
10347
  }
10228
10348
  }
10229
10349
  }
10230
10350
  }
10231
10351
  if (!NEXT_ACTIONS_RE.test(statusContent)) {
10232
- if (!isIgnored(entry, "nextActionsMissing")) {
10233
- results.push(
10234
- check(Severity.ERROR, "nextActionsMissing", 'STATUS.md missing "Next actions (now)" section', true, 'Add "## Next actions (now)" section with task IDs to STATUS.md')
10235
- );
10236
- }
10352
+ results.push(
10353
+ check(Severity.ERROR, "nextActionsMissing", "Status file is missing the next actions section", true, "The AI will fix this on next interaction")
10354
+ );
10237
10355
  } else {
10238
10356
  const nextActionsIndex = statusContent.search(NEXT_ACTIONS_RE);
10239
10357
  const sectionAfter = statusContent.slice(nextActionsIndex);
@@ -10242,17 +10360,15 @@ function runStatusQualityChecks(statusContent, entry) {
10242
10360
  const taskIds = nextActionsSection.match(TASK_ID_RE) ?? [];
10243
10361
  const adhocs = nextActionsSection.match(ADHOC_RE) ?? [];
10244
10362
  if (taskIds.length === 0 && adhocs.length === 0) {
10245
- if (!isIgnored(entry, "nextActionsEmpty")) {
10246
- results.push(
10247
- check(
10248
- Severity.WARN,
10249
- "nextActionsEmpty",
10250
- '"Next actions (now)" has no task IDs or entries',
10251
- false,
10252
- 'Add task IDs like [T-1] under "Next actions (now)" in STATUS.md'
10253
- )
10254
- );
10255
- }
10363
+ results.push(
10364
+ check(
10365
+ Severity.WARN,
10366
+ "nextActionsEmpty",
10367
+ "No next actions defined yet",
10368
+ false,
10369
+ "Send a message with your next task to get started"
10370
+ )
10371
+ );
10256
10372
  }
10257
10373
  }
10258
10374
  return results;
@@ -10273,9 +10389,9 @@ function runNextVsTodoChecks(statusContent, todoContent) {
10273
10389
  check(
10274
10390
  Severity.WARN,
10275
10391
  "nextNotInTodo",
10276
- `${missing.length} task IDs in "Next actions (now)" not found in TODO.md: ${missing.join(", ")}`,
10392
+ `${missing.length} tasks referenced in next actions don't exist in the TODO list: ${missing.join(", ")}`,
10277
10393
  false,
10278
- "Add missing task IDs to TODO.md or remove stale ones from STATUS.md"
10394
+ "The AI will clean these up on next interaction"
10279
10395
  )
10280
10396
  );
10281
10397
  }
@@ -10286,21 +10402,17 @@ function runCommandsLinksChecks(entry, capsuleFiles) {
10286
10402
  if (entry.type === "coding") {
10287
10403
  const commandsContent = capsuleFiles.get("COMMANDS.md");
10288
10404
  if (commandsContent !== void 0 && isEffectivelyEmpty(commandsContent)) {
10289
- if (!isIgnored(entry, "commandsEmpty")) {
10290
- results.push(
10291
- check(Severity.INFO, "commandsEmpty", "COMMANDS.md is empty for a coding topic", false, "Add build/test/deploy commands to COMMANDS.md")
10292
- );
10293
- }
10405
+ results.push(
10406
+ check(Severity.INFO, "commandsEmpty", "COMMANDS.md is empty for a coding topic", false, "Add build/test/deploy commands to COMMANDS.md")
10407
+ );
10294
10408
  }
10295
10409
  }
10296
10410
  if (entry.type === "coding" || entry.type === "research") {
10297
10411
  const linksContent = capsuleFiles.get("LINKS.md");
10298
10412
  if (linksContent !== void 0 && isEffectivelyEmpty(linksContent)) {
10299
- if (!isIgnored(entry, "linksEmpty")) {
10300
- results.push(
10301
- check(Severity.INFO, "linksEmpty", "LINKS.md is empty for a coding/research topic", false, "Add URLs and endpoints to LINKS.md")
10302
- );
10303
- }
10413
+ results.push(
10414
+ check(Severity.INFO, "linksEmpty", "LINKS.md is empty for a coding/research topic", false, "Add URLs and endpoints to LINKS.md")
10415
+ );
10304
10416
  }
10305
10417
  }
10306
10418
  return results;
@@ -10317,13 +10429,13 @@ function runCronChecks(cronContent, cronJobsPath) {
10317
10429
  const hasJobIds = lines.some((line) => JOB_ID_RE.test(line));
10318
10430
  if (!hasJobIds) {
10319
10431
  results.push(
10320
- check(Severity.WARN, "cronNoJobIds", "CRON.md lists jobs but has no recognizable job IDs", false)
10432
+ check(Severity.WARN, "cronNoJobIds", "Scheduled jobs are listed but have no recognizable job IDs", false)
10321
10433
  );
10322
10434
  return results;
10323
10435
  }
10324
- if (cronJobsPath && fs7.existsSync(cronJobsPath)) {
10436
+ if (cronJobsPath && fs8.existsSync(cronJobsPath)) {
10325
10437
  try {
10326
- const jobsRaw = fs7.readFileSync(cronJobsPath, "utf-8");
10438
+ const jobsRaw = fs8.readFileSync(cronJobsPath, "utf-8");
10327
10439
  const jobs = JSON.parse(jobsRaw);
10328
10440
  const knownJobIds = new Set(Object.keys(jobs));
10329
10441
  for (const line of lines) {
@@ -10333,7 +10445,7 @@ function runCronChecks(cronContent, cronJobsPath) {
10333
10445
  check(
10334
10446
  Severity.WARN,
10335
10447
  "cronJobNotFound",
10336
- `Job ID "${match[0]}" from CRON.md not found in cron/jobs.json`,
10448
+ `Scheduled job "${match[0]}" not found in the jobs registry`,
10337
10449
  false
10338
10450
  )
10339
10451
  );
@@ -10354,36 +10466,28 @@ function runConfigChecks(entry, includeContent, registry) {
10354
10466
  }
10355
10467
  const groupConfig = includeObj[entry.groupId];
10356
10468
  if (!groupConfig) {
10357
- if (!isIgnored(entry, "configGroupMissing")) {
10358
- results.push(
10359
- check(Severity.WARN, "configGroupMissing", `Group ${entry.groupId} missing from generated include`, false, "Run /tm sync to add group to generated include")
10360
- );
10361
- }
10469
+ results.push(
10470
+ check(Severity.WARN, "configGroupMissing", "Config is out of sync", false, "Run /tm sync to fix")
10471
+ );
10362
10472
  return results;
10363
10473
  }
10364
10474
  const topics = groupConfig["topics"];
10365
10475
  const topicConfig = topics?.[entry.threadId];
10366
10476
  if (!topicConfig) {
10367
- if (!isIgnored(entry, "configTopicMissing")) {
10368
- results.push(
10369
- check(Severity.WARN, "configTopicMissing", `Topic config missing for thread ${entry.threadId}`, false, "Run /tm sync to add topic config to generated include")
10370
- );
10371
- }
10477
+ results.push(
10478
+ check(Severity.WARN, "configTopicMissing", "Topic not found in system config", false, "Run /tm sync to fix")
10479
+ );
10372
10480
  return results;
10373
10481
  }
10374
10482
  if (!topicConfig["systemPrompt"]) {
10375
- if (!isIgnored(entry, "configNoSystemPrompt")) {
10376
- results.push(
10377
- check(Severity.WARN, "configNoSystemPrompt", "Per-topic systemPrompt is missing in generated include", false, "Run /tm sync to regenerate systemPrompt in config")
10378
- );
10379
- }
10483
+ results.push(
10484
+ check(Severity.WARN, "configNoSystemPrompt", "AI instructions are missing for this topic", false, "Run /tm sync to fix")
10485
+ );
10380
10486
  }
10381
10487
  if (!topicConfig["skills"] || !Array.isArray(topicConfig["skills"])) {
10382
- if (!isIgnored(entry, "configNoSkills")) {
10383
- results.push(
10384
- check(Severity.WARN, "configNoSkills", "Per-topic skills list is missing in generated include", false, "Run /tm sync to regenerate skills list in config")
10385
- );
10386
- }
10488
+ results.push(
10489
+ check(Severity.WARN, "configNoSkills", "Command list is missing for this topic", false, "Run /tm sync to fix")
10490
+ );
10387
10491
  }
10388
10492
  return results;
10389
10493
  }
@@ -10395,9 +10499,9 @@ function runIncludeDriftCheck(includeFileContent, registry) {
10395
10499
  check(
10396
10500
  Severity.WARN,
10397
10501
  "includeDrift",
10398
- "Generated include file has no registry-hash comment. Run /tm sync.",
10502
+ "Config is out of sync with your topics",
10399
10503
  false,
10400
- "Run /tm sync to regenerate the config include"
10504
+ "Run /tm sync to fix"
10401
10505
  )
10402
10506
  );
10403
10507
  return results;
@@ -10408,9 +10512,9 @@ function runIncludeDriftCheck(includeFileContent, registry) {
10408
10512
  check(
10409
10513
  Severity.WARN,
10410
10514
  "includeDrift",
10411
- "Generated include is out of sync with registry. Run /tm sync.",
10515
+ "Config is out of sync with your topics",
10412
10516
  false,
10413
- "Run /tm sync to regenerate the config include"
10517
+ "Run /tm sync to fix"
10414
10518
  )
10415
10519
  );
10416
10520
  }
@@ -10423,9 +10527,9 @@ function runSpamControlCheck(entry) {
10423
10527
  check(
10424
10528
  Severity.INFO,
10425
10529
  "spamControl",
10426
- `${entry.consecutiveSilentDoctors} consecutive doctor reports with no user interaction. Auto-snoozing for 30 days.`,
10530
+ `No activity for a while \u2014 auto-snoozing for 30 days`,
10427
10531
  true,
10428
- "Interact with the topic or /tm snooze 30d to silence reports"
10532
+ "Send a message to resume"
10429
10533
  )
10430
10534
  );
10431
10535
  }
@@ -10433,16 +10537,16 @@ function runSpamControlCheck(entry) {
10433
10537
  }
10434
10538
  function runAllChecksForTopic(entry, projectsBase, includeContent, registry, cronJobsPath) {
10435
10539
  const results = [];
10436
- const capsuleDir = path7.join(projectsBase, entry.slug);
10540
+ const capsuleDir = path8.join(projectsBase, entry.slug);
10437
10541
  results.push(...runRegistryChecks(entry, projectsBase));
10438
- if (!fs7.existsSync(capsuleDir)) return results;
10542
+ if (!fs8.existsSync(capsuleDir)) return results;
10439
10543
  results.push(...runCapsuleChecks(entry, projectsBase));
10440
10544
  const capsuleFiles = readCapsuleFiles(capsuleDir);
10441
10545
  const statusContent = capsuleFiles.get("STATUS.md");
10442
10546
  if (statusContent) {
10443
10547
  results.push(...runStatusQualityChecks(statusContent, entry));
10444
10548
  const todoContent = capsuleFiles.get("TODO.md");
10445
- if (todoContent && !isIgnored(entry, "nextNotInTodo")) {
10549
+ if (todoContent) {
10446
10550
  results.push(...runNextVsTodoChecks(statusContent, todoContent));
10447
10551
  }
10448
10552
  }
@@ -10465,16 +10569,16 @@ var BACKUP_FILES = ["STATUS.md", "TODO.md"];
10465
10569
  function backupCapsuleIfHealthy(projectsBase, slug, results) {
10466
10570
  const hasIssues = results.some((r) => r.severity === Severity.ERROR || r.severity === Severity.WARN);
10467
10571
  if (hasIssues) return;
10468
- const capsuleDir = path7.join(projectsBase, slug);
10469
- const backupDir = path7.join(capsuleDir, BACKUP_DIR);
10470
- if (!fs7.existsSync(backupDir)) {
10471
- fs7.mkdirSync(backupDir, { recursive: true });
10572
+ const capsuleDir = path8.join(projectsBase, slug);
10573
+ const backupDir = path8.join(capsuleDir, BACKUP_DIR);
10574
+ if (!fs8.existsSync(backupDir)) {
10575
+ fs8.mkdirSync(backupDir, { recursive: true });
10472
10576
  }
10473
10577
  for (const file of BACKUP_FILES) {
10474
- const src = path7.join(capsuleDir, file);
10475
- const dst = path7.join(backupDir, file);
10476
- if (fs7.existsSync(src)) {
10477
- fs7.copyFileSync(src, dst);
10578
+ const src = path8.join(capsuleDir, file);
10579
+ const dst = path8.join(backupDir, file);
10580
+ if (fs8.existsSync(src)) {
10581
+ fs8.copyFileSync(src, dst);
10478
10582
  }
10479
10583
  }
10480
10584
  }
@@ -10497,10 +10601,10 @@ function readCapsuleFiles(capsuleDir) {
10497
10601
  "METRICS.md"
10498
10602
  ];
10499
10603
  for (const name of filenames) {
10500
- const filePath = path7.join(capsuleDir, name);
10604
+ const filePath = path8.join(capsuleDir, name);
10501
10605
  try {
10502
- if (fs7.existsSync(filePath)) {
10503
- files.set(name, fs7.readFileSync(filePath, "utf-8"));
10606
+ if (fs8.existsSync(filePath)) {
10607
+ files.set(name, fs8.readFileSync(filePath, "utf-8"));
10504
10608
  }
10505
10609
  } catch {
10506
10610
  }
@@ -10524,23 +10628,23 @@ async function handleDoctor(ctx) {
10524
10628
  if (!entry) {
10525
10629
  return { text: "This topic is not registered. Run /tm init first." };
10526
10630
  }
10527
- const projectsBase = path8.join(workspaceDir, "projects");
10631
+ const projectsBase = path9.join(workspaceDir, "projects");
10528
10632
  if (!jailCheck(projectsBase, entry.slug)) {
10529
- return { text: "Path safety check failed." };
10633
+ return { text: "Something went wrong \u2014 path validation failed." };
10530
10634
  }
10531
- const capsuleDir = path8.join(projectsBase, entry.slug);
10635
+ const capsuleDir = path9.join(projectsBase, entry.slug);
10532
10636
  if (rejectSymlink(capsuleDir)) {
10533
- return { text: "Capsule directory is a symlink. Aborting for security." };
10637
+ return { text: "Something went wrong \u2014 detected an unsafe file system configuration." };
10534
10638
  }
10535
10639
  let includeContent;
10536
10640
  const incPath = includePath(configDir);
10537
10641
  try {
10538
- if (fs8.existsSync(incPath)) {
10539
- includeContent = fs8.readFileSync(incPath, "utf-8");
10642
+ if (fs9.existsSync(incPath)) {
10643
+ includeContent = fs9.readFileSync(incPath, "utf-8");
10540
10644
  }
10541
10645
  } catch {
10542
10646
  }
10543
- const cronJobsPath = path8.join(configDir, "cron", "jobs.json");
10647
+ const cronJobsPath = path9.join(configDir, "cron", "jobs.json");
10544
10648
  const results = runAllChecksForTopic(
10545
10649
  entry,
10546
10650
  projectsBase,
@@ -10556,13 +10660,6 @@ async function handleDoctor(ctx) {
10556
10660
  registry.callbackSecret,
10557
10661
  userId
10558
10662
  );
10559
- const textCommands = [
10560
- "",
10561
- "Or use text commands:",
10562
- "/tm snooze 7d",
10563
- "/tm snooze 30d",
10564
- "/tm archive"
10565
- ].join("\n");
10566
10663
  await withRegistry(workspaceDir, (data) => {
10567
10664
  const topic = data.topics[key];
10568
10665
  if (topic) {
@@ -10570,45 +10667,182 @@ async function handleDoctor(ctx) {
10570
10667
  }
10571
10668
  });
10572
10669
  return {
10573
- text: reportText + textCommands,
10670
+ text: reportText,
10574
10671
  inlineKeyboard: keyboard
10575
10672
  };
10576
10673
  }
10577
10674
 
10578
10675
  // src/commands/doctor-all.ts
10579
- import * as fs9 from "node:fs";
10580
- import * as path9 from "node:path";
10581
- async function handleDoctorAll(ctx) {
10582
- const { workspaceDir, configDir, userId, logger } = ctx;
10583
- if (!userId) {
10584
- return { text: "Missing context: userId not available." };
10676
+ import * as fs11 from "node:fs";
10677
+ import * as path11 from "node:path";
10678
+
10679
+ // src/commands/daily-report.ts
10680
+ import * as fs10 from "node:fs";
10681
+ import * as path10 from "node:path";
10682
+ async function handleDailyReport(ctx) {
10683
+ const { workspaceDir, groupId, threadId, logger } = ctx;
10684
+ if (!groupId || !threadId) {
10685
+ return { text: "Missing context: must be called from a topic thread." };
10585
10686
  }
10687
+ const key = topicKey(groupId, threadId);
10586
10688
  const registry = readRegistry(workspaceDir);
10587
- const auth = checkAuthorization(userId, "doctor-all", registry);
10588
- if (!auth.authorized) {
10589
- return { text: auth.message ?? "Not authorized." };
10689
+ const entry = registry.topics[key];
10690
+ if (!entry) {
10691
+ return { text: "This topic is not registered. Run /tm init first." };
10590
10692
  }
10591
- if (registry.lastDoctorAllRunAt) {
10592
- const lastRun = new Date(registry.lastDoctorAllRunAt).getTime();
10593
- const elapsed = Date.now() - lastRun;
10594
- if (elapsed < DOCTOR_ALL_COOLDOWN_MS) {
10595
- const remainingMin = Math.ceil((DOCTOR_ALL_COOLDOWN_MS - elapsed) / 6e4);
10596
- return {
10597
- text: `Doctor-all was run ${Math.floor(elapsed / 6e4)} minutes ago. Try again in ${remainingMin} minute(s).`
10598
- };
10693
+ if (entry.lastDailyReportAt) {
10694
+ const lastReport = new Date(entry.lastDailyReportAt);
10695
+ const now = /* @__PURE__ */ new Date();
10696
+ if (lastReport.getUTCFullYear() === now.getUTCFullYear() && lastReport.getUTCMonth() === now.getUTCMonth() && lastReport.getUTCDate() === now.getUTCDate()) {
10697
+ return { text: "Daily report already generated today. Try again tomorrow." };
10698
+ }
10699
+ }
10700
+ const projectsBase = path10.join(workspaceDir, "projects");
10701
+ const capsuleDir = path10.join(projectsBase, entry.slug);
10702
+ if (!fs10.existsSync(capsuleDir)) {
10703
+ return { text: "Topic files not found. Run /tm init to set up this topic." };
10704
+ }
10705
+ const statusContent = readFileOrNull(path10.join(capsuleDir, "STATUS.md"));
10706
+ const todoContent = readFileOrNull(path10.join(capsuleDir, "TODO.md"));
10707
+ const learningsContent = readFileOrNull(path10.join(capsuleDir, "LEARNINGS.md"));
10708
+ const doneContent = extractDoneSection(statusContent);
10709
+ const newLearnings = extractTodayLearnings(learningsContent);
10710
+ const blockers = extractBlockers(todoContent);
10711
+ const nextContent = extractNextActions(statusContent);
10712
+ const upcomingContent = extractUpcoming(statusContent);
10713
+ const health = computeHealth(entry.lastMessageAt, statusContent, blockers);
10714
+ const reportData = {
10715
+ name: entry.name,
10716
+ doneContent,
10717
+ learningsContent: newLearnings,
10718
+ blockersContent: blockers,
10719
+ nextContent,
10720
+ upcomingContent,
10721
+ health
10722
+ };
10723
+ if (ctx.postFn) {
10724
+ try {
10725
+ const htmlReport = buildDailyReport(reportData, "html");
10726
+ await ctx.postFn(groupId, threadId, htmlReport);
10727
+ await withRegistry(workspaceDir, (data) => {
10728
+ const e = data.topics[key];
10729
+ if (e) {
10730
+ e.lastDailyReportAt = (/* @__PURE__ */ new Date()).toISOString();
10731
+ }
10732
+ });
10733
+ } catch (err) {
10734
+ const msg = err instanceof Error ? err.message : String(err);
10735
+ logger.error(`[daily-report] Post failed: ${msg}`);
10736
+ return { text: `Daily report generated but post failed: ${msg}` };
10737
+ }
10738
+ } else {
10739
+ await withRegistry(workspaceDir, (data) => {
10740
+ const e = data.topics[key];
10741
+ if (e) {
10742
+ e.lastDailyReportAt = (/* @__PURE__ */ new Date()).toISOString();
10743
+ }
10744
+ });
10745
+ }
10746
+ return { text: buildDailyReport(reportData, "markdown") };
10747
+ }
10748
+ function readFileOrNull(filePath) {
10749
+ try {
10750
+ return fs10.readFileSync(filePath, "utf-8");
10751
+ } catch {
10752
+ return null;
10753
+ }
10754
+ }
10755
+ function extractDoneSection(statusContent) {
10756
+ if (!statusContent) return "_No STATUS.md found._";
10757
+ const match = statusContent.match(/^##\s*Last done\s*\(UTC\)\s*\n([\s\S]*?)(?=\n##\s|\n*$)/im);
10758
+ if (!match) return '_No "Last done" section found._';
10759
+ const text = match[1]?.trim();
10760
+ return text || "_Empty._";
10761
+ }
10762
+ function extractTodayLearnings(learningsContent) {
10763
+ if (!learningsContent) return "_No LEARNINGS.md found._";
10764
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
10765
+ const lines = learningsContent.split("\n");
10766
+ const todayLines = [];
10767
+ let inTodaySection = false;
10768
+ for (const line of lines) {
10769
+ if (line.startsWith("## ") && line.includes(today)) {
10770
+ inTodaySection = true;
10771
+ continue;
10772
+ }
10773
+ if (inTodaySection && line.startsWith("## ")) {
10774
+ break;
10775
+ }
10776
+ if (inTodaySection && line.trim()) {
10777
+ todayLines.push(line);
10778
+ }
10779
+ }
10780
+ return todayLines.length > 0 ? todayLines.join("\n") : "_None today._";
10781
+ }
10782
+ function extractBlockers(todoContent) {
10783
+ if (!todoContent) return "_No TODO.md found._";
10784
+ const lines = todoContent.split("\n");
10785
+ const blockerLines = lines.filter(
10786
+ (l) => /\[BLOCKED\]/i.test(l) || /\bblocked\b/i.test(l)
10787
+ );
10788
+ return blockerLines.length > 0 ? blockerLines.join("\n") : "_None._";
10789
+ }
10790
+ function extractNextActions(statusContent) {
10791
+ if (!statusContent) return "_No STATUS.md found._";
10792
+ const match = statusContent.match(/^##\s*Next (?:3 )?actions(?: \(now\))?\s*\n([\s\S]*?)(?=\n##\s|\n*$)/im);
10793
+ if (!match) return '_No "Next actions" section found._';
10794
+ const text = match[1]?.trim();
10795
+ return text || "_Empty._";
10796
+ }
10797
+ function extractUpcoming(statusContent) {
10798
+ if (!statusContent) return "_No STATUS.md found._";
10799
+ const match = statusContent.match(/^##\s*Upcoming actions\s*\n([\s\S]*?)(?=\n##\s|\n*$)/im);
10800
+ if (!match) return '_No "Upcoming actions" section found._';
10801
+ const text = match[1]?.trim();
10802
+ return text || "_Empty._";
10803
+ }
10804
+ function computeHealth(lastMessageAt, statusContent, blockers) {
10805
+ if (blockers && blockers !== "_None._" && blockers !== "_No TODO.md found._") {
10806
+ return "blocked";
10807
+ }
10808
+ if (!lastMessageAt) return "stale";
10809
+ const hoursSinceActivity = (Date.now() - new Date(lastMessageAt).getTime()) / 36e5;
10810
+ if (hoursSinceActivity > 72) return "stale";
10811
+ return "fresh";
10812
+ }
10813
+
10814
+ // src/commands/doctor-all.ts
10815
+ async function handleDoctorAll(ctx) {
10816
+ const { workspaceDir, configDir, userId, logger } = ctx;
10817
+ if (!userId) {
10818
+ return { text: "Missing context: userId not available." };
10819
+ }
10820
+ const registry = readRegistry(workspaceDir);
10821
+ const auth = checkAuthorization(userId, "doctor-all", registry);
10822
+ if (!auth.authorized) {
10823
+ return { text: auth.message ?? "Not authorized." };
10824
+ }
10825
+ if (registry.lastDoctorAllRunAt) {
10826
+ const lastRun = new Date(registry.lastDoctorAllRunAt).getTime();
10827
+ const elapsed = Date.now() - lastRun;
10828
+ if (elapsed < DOCTOR_ALL_COOLDOWN_MS) {
10829
+ const remainingMin = Math.ceil((DOCTOR_ALL_COOLDOWN_MS - elapsed) / 6e4);
10830
+ return {
10831
+ text: `Health checks were run ${Math.floor(elapsed / 6e4)} minutes ago. Try again in ${remainingMin} minute(s).`
10832
+ };
10599
10833
  }
10600
10834
  }
10601
10835
  const now = /* @__PURE__ */ new Date();
10602
- const projectsBase = path9.join(workspaceDir, "projects");
10836
+ const projectsBase = path11.join(workspaceDir, "projects");
10603
10837
  let includeContent;
10604
10838
  const incPath = includePath(configDir);
10605
10839
  try {
10606
- if (fs9.existsSync(incPath)) {
10607
- includeContent = fs9.readFileSync(incPath, "utf-8");
10840
+ if (fs11.existsSync(incPath)) {
10841
+ includeContent = fs11.readFileSync(incPath, "utf-8");
10608
10842
  }
10609
10843
  } catch {
10610
10844
  }
10611
- const cronJobsPath = path9.join(configDir, "cron", "jobs.json");
10845
+ const cronJobsPath = path11.join(configDir, "cron", "jobs.json");
10612
10846
  const allEntries = Object.entries(registry.topics);
10613
10847
  const reports = [];
10614
10848
  const errors = [];
@@ -10703,6 +10937,54 @@ async function handleDoctorAll(ctx) {
10703
10937
  }
10704
10938
  }
10705
10939
  }
10940
+ let dailyReportSuccesses = 0;
10941
+ let dailyReportSkipped = 0;
10942
+ const dailyReportKeys = /* @__PURE__ */ new Set();
10943
+ if (ctx.postFn && reports.length > 0) {
10944
+ const rateLimitedPost = createRateLimitedPoster(ctx.postFn);
10945
+ const nowDate = now.toISOString().slice(0, 10);
10946
+ for (const report of reports) {
10947
+ const key = `${report.groupId}:${report.threadId}`;
10948
+ const entry = registry.topics[key];
10949
+ if (!entry) continue;
10950
+ if (entry.lastDailyReportAt) {
10951
+ const lastReport = new Date(entry.lastDailyReportAt);
10952
+ const lastDate = `${lastReport.getUTCFullYear()}-${String(lastReport.getUTCMonth() + 1).padStart(2, "0")}-${String(lastReport.getUTCDate()).padStart(2, "0")}`;
10953
+ if (lastDate === nowDate) {
10954
+ dailyReportSkipped++;
10955
+ continue;
10956
+ }
10957
+ }
10958
+ const capsuleDir = path11.join(projectsBase, entry.slug);
10959
+ const statusContent = readFileOrNull(path11.join(capsuleDir, "STATUS.md"));
10960
+ const todoContent = readFileOrNull(path11.join(capsuleDir, "TODO.md"));
10961
+ const learningsContent = readFileOrNull(path11.join(capsuleDir, "LEARNINGS.md"));
10962
+ const doneContent = extractDoneSection(statusContent);
10963
+ const newLearnings = extractTodayLearnings(learningsContent);
10964
+ const blockers = extractBlockers(todoContent);
10965
+ const nextContent = extractNextActions(statusContent);
10966
+ const upcomingContent = extractUpcoming(statusContent);
10967
+ const health = computeHealth(entry.lastMessageAt, statusContent, blockers);
10968
+ const reportData = {
10969
+ name: entry.name,
10970
+ doneContent,
10971
+ learningsContent: newLearnings,
10972
+ blockersContent: blockers,
10973
+ nextContent,
10974
+ upcomingContent,
10975
+ health
10976
+ };
10977
+ try {
10978
+ const htmlReport = buildDailyReport(reportData, "html");
10979
+ await rateLimitedPost(report.groupId, report.threadId, htmlReport);
10980
+ dailyReportSuccesses++;
10981
+ dailyReportKeys.add(key);
10982
+ } catch (err) {
10983
+ const msg = err instanceof Error ? err.message : String(err);
10984
+ logger.error(`[doctor-all] Daily report post failed for ${entry.slug}: ${msg}`);
10985
+ }
10986
+ }
10987
+ }
10706
10988
  await withRegistry(workspaceDir, (data) => {
10707
10989
  data.lastDoctorAllRunAt = now.toISOString();
10708
10990
  for (const [_key, entry] of Object.entries(data.topics)) {
@@ -10725,16 +11007,23 @@ async function handleDoctorAll(ctx) {
10725
11007
  entry.consecutiveSilentDoctors = 0;
10726
11008
  }
10727
11009
  }
11010
+ for (const key of dailyReportKeys) {
11011
+ const entry = data.topics[key];
11012
+ if (entry) {
11013
+ entry.lastDailyReportAt = now.toISOString();
11014
+ }
11015
+ }
10728
11016
  });
10729
11017
  const lines = [
10730
- `**Doctor All Summary**`,
11018
+ `**Health Check Summary**`,
10731
11019
  "",
10732
- `Processed: ${processed}`,
10733
- `Skipped (ineligible): ${skipped}`,
10734
- `Total: ${allEntries.length}`
11020
+ `Checked: ${processed}`,
11021
+ `Skipped: ${skipped}`,
11022
+ `Total topics: ${allEntries.length}`
10735
11023
  ];
10736
11024
  if (ctx.postFn) {
10737
11025
  lines.push(`Posted: ${postSuccesses}, Post failures: ${postErrors}`);
11026
+ lines.push(`Daily reports: ${dailyReportSuccesses} sent, ${dailyReportSkipped} skipped`);
10738
11027
  }
10739
11028
  if (errors.length > 0) {
10740
11029
  lines.push("");
@@ -10748,10 +11037,7 @@ async function handleDoctorAll(ctx) {
10748
11037
  }
10749
11038
  if (migrationGroups.length > 0) {
10750
11039
  lines.push("");
10751
- lines.push("**Possible group migrations detected:**");
10752
- for (const gid of migrationGroups) {
10753
- lines.push(`- Group ${gid}: all topics failed. Check for group migration.`);
10754
- }
11040
+ lines.push(`**Warning:** ${migrationGroups.length} group(s) had all topics fail. The group may have been migrated or deleted.`);
10755
11041
  }
10756
11042
  return {
10757
11043
  text: lines.join("\n")
@@ -10791,9 +11077,64 @@ async function handleList(ctx) {
10791
11077
  }
10792
11078
 
10793
11079
  // src/commands/status.ts
10794
- import * as fs10 from "node:fs";
10795
- import * as path10 from "node:path";
10796
- async function handleStatus(ctx) {
11080
+ import * as fs12 from "node:fs";
11081
+ import * as path12 from "node:path";
11082
+ var LAST_DONE_RE2 = /^##\s*Last done\s*\(UTC\)\s*\n([\s\S]*?)(?=\n##\s|\n*$)/im;
11083
+ var NEXT_ACTIONS_RE2 = /^##\s*Next (?:3 )?actions(?: \(now\))?\s*\n([\s\S]*?)(?=\n##\s|\n*$)/im;
11084
+ var UPCOMING_RE = /^##\s*Upcoming actions\s*\n([\s\S]*?)(?=\n##\s|\n*$)/im;
11085
+ var ISO_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
11086
+ function extractTimestamp(content) {
11087
+ const match = content.match(LAST_DONE_RE2);
11088
+ if (!match) return null;
11089
+ const iso = match[1]?.match(ISO_RE);
11090
+ return iso ? iso[0] : null;
11091
+ }
11092
+ function extractSection(content, re) {
11093
+ const match = content.match(re);
11094
+ if (!match) return "";
11095
+ return match[1]?.trim() ?? "";
11096
+ }
11097
+ function isPlaceholder(text) {
11098
+ if (!text) return true;
11099
+ const stripped = text.replace(/[_*]/g, "").trim().toLowerCase();
11100
+ return stripped === "none yet." || stripped === "none yet" || stripped === "" || stripped.startsWith("e.g.");
11101
+ }
11102
+ function formatSection(raw) {
11103
+ if (isPlaceholder(raw)) return "_None yet._";
11104
+ return raw;
11105
+ }
11106
+ function formatStatus(name, content) {
11107
+ const timestamp = extractTimestamp(content);
11108
+ const nextRaw = extractSection(content, NEXT_ACTIONS_RE2);
11109
+ const upcomingRaw = extractSection(content, UPCOMING_RE);
11110
+ const doneMatch = content.match(LAST_DONE_RE2);
11111
+ let lastDoneBody = "";
11112
+ if (doneMatch) {
11113
+ const section = doneMatch[1]?.trim() ?? "";
11114
+ lastDoneBody = section.replace(ISO_RE, "").trim();
11115
+ }
11116
+ const lines = [
11117
+ `**${name}**`,
11118
+ ""
11119
+ ];
11120
+ if (timestamp) {
11121
+ lines.push(`**Last activity:** ${relativeTime(timestamp)}`);
11122
+ }
11123
+ if (lastDoneBody && !isPlaceholder(lastDoneBody)) {
11124
+ lines.push(lastDoneBody);
11125
+ }
11126
+ lines.push("");
11127
+ lines.push("**Next actions**");
11128
+ lines.push(formatSection(nextRaw));
11129
+ const upcomingFormatted = formatSection(upcomingRaw);
11130
+ if (upcomingFormatted !== "_None yet._") {
11131
+ lines.push("");
11132
+ lines.push("**Upcoming**");
11133
+ lines.push(upcomingFormatted);
11134
+ }
11135
+ return lines.join("\n");
11136
+ }
11137
+ async function handleStatus2(ctx) {
10797
11138
  const { workspaceDir, userId, groupId, threadId } = ctx;
10798
11139
  if (!userId || !groupId || !threadId) {
10799
11140
  return { text: "Missing context: userId, groupId, or threadId not available." };
@@ -10808,26 +11149,26 @@ async function handleStatus(ctx) {
10808
11149
  if (!entry) {
10809
11150
  return { text: "This topic is not registered. Run /tm init first." };
10810
11151
  }
10811
- const projectsBase = path10.join(workspaceDir, "projects");
10812
- const capsuleDir = path10.join(projectsBase, entry.slug);
11152
+ const projectsBase = path12.join(workspaceDir, "projects");
11153
+ const capsuleDir = path12.join(projectsBase, entry.slug);
10813
11154
  if (!jailCheck(projectsBase, entry.slug)) {
10814
- return { text: "Path safety check failed." };
11155
+ return { text: "Something went wrong \u2014 path validation failed." };
10815
11156
  }
10816
11157
  if (rejectSymlink(capsuleDir)) {
10817
- return { text: "Capsule directory is a symlink. Aborting for security." };
11158
+ return { text: "Something went wrong \u2014 detected an unsafe file system configuration." };
10818
11159
  }
10819
- const statusPath = path10.join(capsuleDir, "STATUS.md");
10820
- if (!fs10.existsSync(statusPath)) {
10821
- return { text: "STATUS.md not found in capsule. Run /tm doctor to diagnose." };
11160
+ const statusPath = path12.join(capsuleDir, "STATUS.md");
11161
+ if (!fs12.existsSync(statusPath)) {
11162
+ return { text: "No status available yet. Run /tm doctor to diagnose." };
10822
11163
  }
10823
11164
  try {
10824
- const content = fs10.readFileSync(statusPath, "utf-8");
11165
+ const content = fs12.readFileSync(statusPath, "utf-8");
10825
11166
  return {
10826
- text: truncateMessage(content)
11167
+ text: truncateMessage(formatStatus(entry.name, content))
10827
11168
  };
10828
11169
  } catch (err) {
10829
11170
  const msg = err instanceof Error ? err.message : String(err);
10830
- return { text: `Failed to read STATUS.md: ${msg}` };
11171
+ return { text: `Failed to read topic status: ${msg}` };
10831
11172
  }
10832
11173
  }
10833
11174
 
@@ -10852,7 +11193,7 @@ async function handleSync(ctx) {
10852
11193
  }
10853
11194
  const restartResult = await triggerRestart(rpc, logger);
10854
11195
  const topicCount = Object.keys(registry.topics).length;
10855
- let text = `Include regenerated from ${topicCount} topic(s). Config synced.`;
11196
+ let text = `Config synced for ${topicCount} topic(s).`;
10856
11197
  if (!restartResult.success && restartResult.fallbackMessage) {
10857
11198
  text += "\n" + restartResult.fallbackMessage;
10858
11199
  }
@@ -10869,7 +11210,7 @@ async function handleRename(ctx, newName) {
10869
11210
  }
10870
11211
  const trimmedName = newName.trim();
10871
11212
  if (!trimmedName) {
10872
- return { text: "Usage: /tm rename <new-name>" };
11213
+ return { text: "What should the new name be? Example: /tm rename my-project" };
10873
11214
  }
10874
11215
  if (trimmedName.length > MAX_NAME_LENGTH) {
10875
11216
  return { text: `Name too long (max ${MAX_NAME_LENGTH} characters).` };
@@ -10914,16 +11255,13 @@ Warning: include generation failed: ${msg}`;
10914
11255
  workspaceDir,
10915
11256
  buildAuditEntry(userId, "rename", entry.slug, `Renamed from "${oldName}" to "${trimmedName}"`)
10916
11257
  );
10917
- const topicCard = buildTopicCard(trimmedName, entry.slug, entry.type, entry.capsuleVersion);
10918
11258
  return {
10919
- text: `Topic renamed from **${oldName}** to **${trimmedName}**.
10920
-
10921
- ${topicCard}${restartMsg}`
11259
+ text: `Topic renamed from **${oldName}** to **${trimmedName}**.${restartMsg}`
10922
11260
  };
10923
11261
  }
10924
11262
 
10925
11263
  // src/commands/upgrade.ts
10926
- import * as path11 from "node:path";
11264
+ import * as path13 from "node:path";
10927
11265
  async function handleUpgrade(ctx) {
10928
11266
  const { workspaceDir, userId, groupId, threadId } = ctx;
10929
11267
  if (!userId || !groupId || !threadId) {
@@ -10941,10 +11279,10 @@ async function handleUpgrade(ctx) {
10941
11279
  }
10942
11280
  if (entry.capsuleVersion >= CAPSULE_VERSION) {
10943
11281
  return {
10944
- text: `Topic **${entry.name}** is already at capsule version ${CAPSULE_VERSION}. No upgrade needed.`
11282
+ text: `Topic **${entry.name}** is already up to date. No upgrade needed.`
10945
11283
  };
10946
11284
  }
10947
- const projectsBase = path11.join(workspaceDir, "projects");
11285
+ const projectsBase = path13.join(workspaceDir, "projects");
10948
11286
  const result = upgradeCapsule(projectsBase, entry.slug, entry.name, entry.type, entry.capsuleVersion);
10949
11287
  if (!result.upgraded) {
10950
11288
  return {
@@ -10960,7 +11298,7 @@ async function handleUpgrade(ctx) {
10960
11298
  const addedList = result.addedFiles.length > 0 ? `
10961
11299
  Added files: ${result.addedFiles.join(", ")}` : "\nNo new files added.";
10962
11300
  return {
10963
- text: `Topic **${entry.name}** upgraded from v${entry.capsuleVersion} to v${result.newVersion}.${addedList}`
11301
+ text: `Topic **${entry.name}** upgraded.${addedList}`
10964
11302
  };
10965
11303
  }
10966
11304
 
@@ -10973,7 +11311,7 @@ async function handleSnooze(ctx, args) {
10973
11311
  }
10974
11312
  const trimmed = args.trim();
10975
11313
  if (!trimmed) {
10976
- return { text: "Usage: /tm snooze <Nd> (e.g., 7d, 30d)" };
11314
+ return { text: "How long to snooze? Example: /tm snooze 7d" };
10977
11315
  }
10978
11316
  const match = DURATION_RE.exec(trimmed);
10979
11317
  if (!match) {
@@ -11007,7 +11345,7 @@ async function handleSnooze(ctx, args) {
11007
11345
  buildAuditEntry(userId, "snooze", entry.slug, `Snoozed for ${days} days until ${snoozeUntil}`)
11008
11346
  );
11009
11347
  return {
11010
- text: `Topic **${entry.name}** snoozed for ${days} days (until ${snoozeUntil}).`
11348
+ text: `Topic **${entry.name}** snoozed for ${days} days. Health checks will resume automatically after that.`
11011
11349
  };
11012
11350
  }
11013
11351
 
@@ -11076,253 +11414,6 @@ Warning: include generation failed: ${msg}`;
11076
11414
  };
11077
11415
  }
11078
11416
 
11079
- // src/commands/autopilot.ts
11080
- import * as fs11 from "node:fs";
11081
- import * as path12 from "node:path";
11082
- var MARKER_START = "<!-- TM_AUTOPILOT_START -->";
11083
- var MARKER_END = "<!-- TM_AUTOPILOT_END -->";
11084
- var HEARTBEAT_BLOCK = `${MARKER_START}
11085
- ## Topic Manager \u2014 Balanced Autopilot
11086
-
11087
- Execute these steps IN ORDER. Do not skip steps.
11088
-
11089
- 1. CALL \`topic_manager\` with command "doctor --all" to health-check all active topics.
11090
- This handles stale timestamps, task ID mismatches, and posting errors automatically.
11091
- 2. For each topic where STATUS.md "Last done (UTC)" is >3 days old:
11092
- post a brief nudge in that topic asking the user for a status update.
11093
- 3. If no actions were taken in steps 1-2, do nothing (HEARTBEAT_OK).
11094
- ${MARKER_END}`;
11095
- var HEARTBEAT_FILENAME = "HEARTBEAT.md";
11096
- async function handleAutopilot(ctx, args) {
11097
- const { workspaceDir, userId } = ctx;
11098
- if (!userId) {
11099
- return { text: "Missing context: userId not available." };
11100
- }
11101
- const registry = readRegistry(workspaceDir);
11102
- const auth = checkAuthorization(userId, "autopilot", registry);
11103
- if (!auth.authorized) {
11104
- return { text: auth.message ?? "Not authorized." };
11105
- }
11106
- const subCommand = args.trim().toLowerCase() || "enable";
11107
- switch (subCommand) {
11108
- case "enable":
11109
- return handleEnable(ctx);
11110
- case "disable":
11111
- return handleDisable(ctx);
11112
- case "status":
11113
- return handleStatus2(ctx);
11114
- default:
11115
- return { text: `Unknown autopilot sub-command: "${subCommand}". Use enable, disable, or status.` };
11116
- }
11117
- }
11118
- async function handleEnable(ctx) {
11119
- const { workspaceDir } = ctx;
11120
- const heartbeatPath = path12.join(workspaceDir, HEARTBEAT_FILENAME);
11121
- let content = "";
11122
- try {
11123
- if (fs11.existsSync(heartbeatPath)) {
11124
- content = fs11.readFileSync(heartbeatPath, "utf-8");
11125
- }
11126
- } catch {
11127
- }
11128
- if (content.includes(MARKER_START)) {
11129
- await withRegistry(workspaceDir, (data) => {
11130
- data.autopilotEnabled = true;
11131
- });
11132
- return { text: "Autopilot is already enabled." };
11133
- }
11134
- const newContent = content ? content.trimEnd() + "\n\n" + HEARTBEAT_BLOCK + "\n" : HEARTBEAT_BLOCK + "\n";
11135
- fs11.writeFileSync(heartbeatPath, newContent, { mode: 416 });
11136
- await withRegistry(workspaceDir, (data) => {
11137
- data.autopilotEnabled = true;
11138
- });
11139
- return {
11140
- text: "**Autopilot enabled.**\nDaily health sweeps will run via the OpenClaw heartbeat."
11141
- };
11142
- }
11143
- async function handleDisable(ctx) {
11144
- const { workspaceDir } = ctx;
11145
- const heartbeatPath = path12.join(workspaceDir, HEARTBEAT_FILENAME);
11146
- if (!fs11.existsSync(heartbeatPath)) {
11147
- await withRegistry(workspaceDir, (data) => {
11148
- data.autopilotEnabled = false;
11149
- });
11150
- return { text: "Autopilot is not enabled (no HEARTBEAT.md found)." };
11151
- }
11152
- let content = fs11.readFileSync(heartbeatPath, "utf-8");
11153
- if (!content.includes(MARKER_START)) {
11154
- await withRegistry(workspaceDir, (data) => {
11155
- data.autopilotEnabled = false;
11156
- });
11157
- return { text: "Autopilot is not enabled (no marker found in HEARTBEAT.md)." };
11158
- }
11159
- const startIdx = content.indexOf(MARKER_START);
11160
- const endIdx = content.indexOf(MARKER_END);
11161
- if (startIdx >= 0 && endIdx >= 0) {
11162
- const before = content.slice(0, startIdx);
11163
- const after = content.slice(endIdx + MARKER_END.length);
11164
- content = (before + after).replace(/\n{3,}/g, "\n\n").trim();
11165
- if (content) {
11166
- fs11.writeFileSync(heartbeatPath, content + "\n", { mode: 416 });
11167
- } else {
11168
- fs11.unlinkSync(heartbeatPath);
11169
- }
11170
- }
11171
- await withRegistry(workspaceDir, (data) => {
11172
- data.autopilotEnabled = false;
11173
- });
11174
- return {
11175
- text: "**Autopilot disabled.**\nDaily sweeps will no longer run automatically."
11176
- };
11177
- }
11178
- async function handleStatus2(ctx) {
11179
- const { workspaceDir } = ctx;
11180
- const registry = readRegistry(workspaceDir);
11181
- const enabled = registry.autopilotEnabled;
11182
- const lastRun = registry.lastDoctorAllRunAt ?? "never";
11183
- const lines = [
11184
- `**Autopilot:** ${enabled ? "enabled" : "disabled"}`,
11185
- `**Last doctor-all run:** ${lastRun}`
11186
- ];
11187
- return {
11188
- text: lines.join("\n")
11189
- };
11190
- }
11191
-
11192
- // src/commands/daily-report.ts
11193
- import * as fs12 from "node:fs";
11194
- import * as path13 from "node:path";
11195
- async function handleDailyReport(ctx) {
11196
- const { workspaceDir, groupId, threadId, logger } = ctx;
11197
- if (!groupId || !threadId) {
11198
- return { text: "Missing context: must be called from a topic thread." };
11199
- }
11200
- const key = topicKey(groupId, threadId);
11201
- const registry = readRegistry(workspaceDir);
11202
- const entry = registry.topics[key];
11203
- if (!entry) {
11204
- return { text: "This topic is not registered. Run /tm init first." };
11205
- }
11206
- if (entry.lastDoctorReportAt) {
11207
- const lastReport = new Date(entry.lastDoctorReportAt);
11208
- const now = /* @__PURE__ */ new Date();
11209
- if (lastReport.getUTCFullYear() === now.getUTCFullYear() && lastReport.getUTCMonth() === now.getUTCMonth() && lastReport.getUTCDate() === now.getUTCDate()) {
11210
- return { text: "Daily report already generated today. Try again tomorrow." };
11211
- }
11212
- }
11213
- const projectsBase = path13.join(workspaceDir, "projects");
11214
- const capsuleDir = path13.join(projectsBase, entry.slug);
11215
- if (!fs12.existsSync(capsuleDir)) {
11216
- return { text: `Capsule directory not found: projects/${entry.slug}/` };
11217
- }
11218
- const statusContent = readFileOrNull(path13.join(capsuleDir, "STATUS.md"));
11219
- const todoContent = readFileOrNull(path13.join(capsuleDir, "TODO.md"));
11220
- const learningsContent = readFileOrNull(path13.join(capsuleDir, "LEARNINGS.md"));
11221
- const doneContent = extractDoneSection(statusContent);
11222
- const newLearnings = extractTodayLearnings(learningsContent);
11223
- const blockers = extractBlockers(todoContent);
11224
- const nextContent = extractNextActions(statusContent);
11225
- const upcomingContent = extractUpcoming(statusContent);
11226
- const health = computeHealth(entry.lastMessageAt, statusContent, blockers);
11227
- const reportText = buildDailyReport({
11228
- name: entry.name,
11229
- doneContent,
11230
- learningsContent: newLearnings,
11231
- blockersContent: blockers,
11232
- nextContent,
11233
- upcomingContent,
11234
- health
11235
- });
11236
- if (ctx.postFn) {
11237
- try {
11238
- await ctx.postFn(groupId, threadId, reportText);
11239
- await withRegistry(workspaceDir, (data) => {
11240
- const e = data.topics[key];
11241
- if (e) {
11242
- e.lastDoctorReportAt = (/* @__PURE__ */ new Date()).toISOString();
11243
- }
11244
- });
11245
- } catch (err) {
11246
- const msg = err instanceof Error ? err.message : String(err);
11247
- logger.error(`[daily-report] Post failed: ${msg}`);
11248
- return { text: `Daily report generated but post failed: ${msg}` };
11249
- }
11250
- } else {
11251
- await withRegistry(workspaceDir, (data) => {
11252
- const e = data.topics[key];
11253
- if (e) {
11254
- e.lastDoctorReportAt = (/* @__PURE__ */ new Date()).toISOString();
11255
- }
11256
- });
11257
- }
11258
- return { text: reportText };
11259
- }
11260
- function readFileOrNull(filePath) {
11261
- try {
11262
- return fs12.readFileSync(filePath, "utf-8");
11263
- } catch {
11264
- return null;
11265
- }
11266
- }
11267
- function extractDoneSection(statusContent) {
11268
- if (!statusContent) return "_No STATUS.md found._";
11269
- const match = statusContent.match(/^##\s*Last done\s*\(UTC\)\s*\n([\s\S]*?)(?=\n##\s|\n*$)/im);
11270
- if (!match) return '_No "Last done" section found._';
11271
- const text = match[1]?.trim();
11272
- return text || "_Empty._";
11273
- }
11274
- function extractTodayLearnings(learningsContent) {
11275
- if (!learningsContent) return "_No LEARNINGS.md found._";
11276
- const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
11277
- const lines = learningsContent.split("\n");
11278
- const todayLines = [];
11279
- let inTodaySection = false;
11280
- for (const line of lines) {
11281
- if (line.startsWith("## ") && line.includes(today)) {
11282
- inTodaySection = true;
11283
- continue;
11284
- }
11285
- if (inTodaySection && line.startsWith("## ")) {
11286
- break;
11287
- }
11288
- if (inTodaySection && line.trim()) {
11289
- todayLines.push(line);
11290
- }
11291
- }
11292
- return todayLines.length > 0 ? todayLines.join("\n") : "_None today._";
11293
- }
11294
- function extractBlockers(todoContent) {
11295
- if (!todoContent) return "_No TODO.md found._";
11296
- const lines = todoContent.split("\n");
11297
- const blockerLines = lines.filter(
11298
- (l) => /\[BLOCKED\]/i.test(l) || /\bblocked\b/i.test(l)
11299
- );
11300
- return blockerLines.length > 0 ? blockerLines.join("\n") : "_None._";
11301
- }
11302
- function extractNextActions(statusContent) {
11303
- if (!statusContent) return "_No STATUS.md found._";
11304
- const match = statusContent.match(/^##\s*Next (?:3 )?actions(?: \(now\))?\s*\n([\s\S]*?)(?=\n##\s|\n*$)/im);
11305
- if (!match) return '_No "Next actions" section found._';
11306
- const text = match[1]?.trim();
11307
- return text || "_Empty._";
11308
- }
11309
- function extractUpcoming(statusContent) {
11310
- if (!statusContent) return "_No STATUS.md found._";
11311
- const match = statusContent.match(/^##\s*Upcoming actions\s*\n([\s\S]*?)(?=\n##\s|\n*$)/im);
11312
- if (!match) return '_No "Upcoming actions" section found._';
11313
- const text = match[1]?.trim();
11314
- return text || "_Empty._";
11315
- }
11316
- function computeHealth(lastMessageAt, statusContent, blockers) {
11317
- if (blockers && blockers !== "_None._" && blockers !== "_No TODO.md found._") {
11318
- return "blocked";
11319
- }
11320
- if (!lastMessageAt) return "stale";
11321
- const hoursSinceActivity = (Date.now() - new Date(lastMessageAt).getTime()) / 36e5;
11322
- if (hoursSinceActivity > 72) return "stale";
11323
- return "fresh";
11324
- }
11325
-
11326
11417
  // src/commands/help.ts
11327
11418
  function handleHelp(_ctx) {
11328
11419
  return {
@@ -11381,7 +11472,7 @@ function createTopicManagerTool(deps) {
11381
11472
  case "list":
11382
11473
  return await handleList(ctx);
11383
11474
  case "status":
11384
- return await handleStatus(ctx);
11475
+ return await handleStatus2(ctx);
11385
11476
  case "sync":
11386
11477
  return await handleSync(ctx);
11387
11478
  case "rename":
@@ -11505,33 +11596,16 @@ async function handleCallback(data, ctx) {
11505
11596
  return { text: "Topic not found." };
11506
11597
  }
11507
11598
  switch (action) {
11508
- case "fix":
11509
- return handleCallbackFix(cbCtx);
11510
11599
  case "snooze7d":
11511
11600
  return handleSnooze(cbCtx, "7d");
11512
11601
  case "snooze30d":
11513
11602
  return handleSnooze(cbCtx, "30d");
11514
11603
  case "archive":
11515
11604
  return handleArchive(cbCtx);
11516
- case "ignore": {
11517
- return {
11518
- text: `To ignore a specific check, use: /tm snooze or contact an admin. The "Ignore" action requires specifying a check ID.`
11519
- };
11520
- }
11521
11605
  default:
11522
11606
  return { text: `Unknown callback action: ${action}` };
11523
11607
  }
11524
11608
  }
11525
- async function handleCallbackFix(ctx) {
11526
- const { userId, workspaceDir } = ctx;
11527
- if (userId) {
11528
- appendAudit(
11529
- workspaceDir,
11530
- buildAuditEntry(userId, "doctor fix", "callback", "Fix callback triggered")
11531
- );
11532
- }
11533
- return handleDoctor(ctx);
11534
- }
11535
11609
 
11536
11610
  // src/index.ts
11537
11611
  function resolveConfigDir() {