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.
- package/README.md +75 -58
- package/dist/commands/autopilot.d.ts +4 -0
- package/dist/commands/autopilot.d.ts.map +1 -1
- package/dist/commands/autopilot.js +14 -11
- package/dist/commands/autopilot.js.map +1 -1
- package/dist/commands/daily-report.d.ts +6 -0
- package/dist/commands/daily-report.d.ts.map +1 -1
- package/dist/commands/daily-report.js +17 -16
- package/dist/commands/daily-report.js.map +1 -1
- package/dist/commands/doctor-all.d.ts.map +1 -1
- package/dist/commands/doctor-all.js +72 -11
- package/dist/commands/doctor-all.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +3 -11
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +51 -33
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/rename.d.ts.map +1 -1
- package/dist/commands/rename.js +2 -4
- package/dist/commands/rename.js.map +1 -1
- package/dist/commands/snooze.js +2 -2
- package/dist/commands/snooze.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +74 -6
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/sync.js +1 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/commands/upgrade.js +2 -2
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/lib/capsule.js +3 -3
- package/dist/lib/capsule.js.map +1 -1
- package/dist/lib/doctor-checks.d.ts.map +1 -1
- package/dist/lib/doctor-checks.js +26 -59
- package/dist/lib/doctor-checks.js.map +1 -1
- package/dist/lib/include-generator.d.ts +1 -1
- package/dist/lib/include-generator.js +4 -4
- package/dist/lib/include-generator.js.map +1 -1
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/registry.js +3 -0
- package/dist/lib/registry.js.map +1 -1
- package/dist/lib/telegram.d.ts +9 -4
- package/dist/lib/telegram.d.ts.map +1 -1
- package/dist/lib/telegram.js +87 -76
- package/dist/lib/telegram.js.map +1 -1
- package/dist/lib/types.d.ts +2 -2
- package/dist/lib/types.js +1 -1
- package/dist/lib/types.js.map +1 -1
- package/dist/plugin.js +665 -591
- package/dist/setup.js +49 -4
- package/dist/setup.js.map +1 -1
- package/dist/tool.js +0 -18
- package/dist/tool.js.map +1 -1
- package/package.json +10 -1
- 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
|
-
|
|
9138
|
+
Topic created. Waiting for first instructions.
|
|
9138
9139
|
|
|
9139
9140
|
## Next actions (now)
|
|
9140
9141
|
|
|
9141
|
-
|
|
9142
|
+
_None yet._
|
|
9142
9143
|
|
|
9143
9144
|
## Upcoming actions
|
|
9144
9145
|
|
|
9145
|
-
|
|
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(
|
|
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(`
|
|
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
|
|
9275
|
-
import * as
|
|
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
|
-
|
|
9333
|
-
|
|
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
|
-
|
|
9348
|
+
bold(`Daily Report: ${n}`),
|
|
9336
9349
|
"",
|
|
9337
|
-
|
|
9338
|
-
|
|
9350
|
+
bold("Done today"),
|
|
9351
|
+
esc(data.doneContent),
|
|
9339
9352
|
"",
|
|
9340
|
-
|
|
9341
|
-
|
|
9353
|
+
bold("New learnings"),
|
|
9354
|
+
esc(data.learningsContent),
|
|
9342
9355
|
"",
|
|
9343
|
-
|
|
9344
|
-
|
|
9356
|
+
bold("Blockers/Risks"),
|
|
9357
|
+
esc(data.blockersContent),
|
|
9345
9358
|
"",
|
|
9346
|
-
|
|
9347
|
-
|
|
9359
|
+
bold("Next actions (now)"),
|
|
9360
|
+
esc(data.nextContent),
|
|
9348
9361
|
"",
|
|
9349
|
-
|
|
9350
|
-
|
|
9362
|
+
bold("Upcoming"),
|
|
9363
|
+
esc(data.upcomingContent),
|
|
9351
9364
|
"",
|
|
9352
|
-
|
|
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
|
|
9409
|
+
function buildTopicCard(name, slug, type) {
|
|
9401
9410
|
return [
|
|
9402
9411
|
`**Topic: ${name}**`,
|
|
9403
|
-
|
|
9404
|
-
|
|
9412
|
+
`**Type:** ${type}`,
|
|
9413
|
+
`**Stored in:** projects/${slug}/`,
|
|
9405
9414
|
"",
|
|
9406
9415
|
"**How it works**",
|
|
9407
|
-
"
|
|
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
|
-
"**
|
|
9414
|
-
"/tm status \u2014
|
|
9418
|
+
"**Available commands:**",
|
|
9419
|
+
"/tm status \u2014 see current progress",
|
|
9415
9420
|
"/tm doctor \u2014 run health checks",
|
|
9416
|
-
"/tm rename
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
9456
|
-
|
|
9460
|
+
`<b>Type:</b> ${t}`,
|
|
9461
|
+
`<b>Stored in:</b> <code>projects/${s}/</code>`,
|
|
9457
9462
|
"",
|
|
9458
9463
|
"<b>How it works</b>",
|
|
9459
|
-
"
|
|
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>
|
|
9466
|
-
"/tm status \u2014
|
|
9466
|
+
"<b>Available commands:</b>",
|
|
9467
|
+
"/tm status \u2014 see current progress",
|
|
9467
9468
|
"/tm doctor \u2014 run health checks",
|
|
9468
|
-
"/tm rename
|
|
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
|
|
9479
|
-
const
|
|
9480
|
-
if (
|
|
9481
|
-
lines.push("All
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
9492
|
-
lines.push(`
|
|
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
|
-
"
|
|
9519
|
-
"/tm
|
|
9520
|
-
"/tm
|
|
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
|
|
9525
|
-
"
|
|
9526
|
-
"
|
|
9527
|
-
"/tm
|
|
9528
|
-
"/tm
|
|
9529
|
-
"/tm
|
|
9530
|
-
"/tm
|
|
9531
|
-
"/tm
|
|
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 "**
|
|
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
|
|
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
|
|
9546
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
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: "
|
|
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 =
|
|
10044
|
+
const projectsBase = path7.join(workspaceDir, "projects");
|
|
9919
10045
|
if (!jailCheck(projectsBase, finalSlug)) {
|
|
9920
|
-
return { text: "
|
|
10046
|
+
return { text: "Setup failed \u2014 internal path validation error. Please try again." };
|
|
9921
10047
|
}
|
|
9922
10048
|
if (rejectSymlink(projectsBase)) {
|
|
9923
|
-
return { text: "
|
|
10049
|
+
return { text: "Setup failed \u2014 detected an unsafe file system configuration." };
|
|
9924
10050
|
}
|
|
9925
|
-
if (
|
|
9926
|
-
return { text:
|
|
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 =
|
|
10054
|
+
const targetPath = path7.join(projectsBase, finalSlug);
|
|
9929
10055
|
if (rejectSymlink(targetPath)) {
|
|
9930
|
-
return { text: "
|
|
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:
|
|
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
|
|
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
|
|
9995
|
-
await ctx.postFn(groupId, threadId,
|
|
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}${
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
|
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}
|
|
10235
|
+
`Name: **${name}**`,
|
|
10236
|
+
`Type: ${type}`,
|
|
10101
10237
|
"",
|
|
10102
|
-
"
|
|
10238
|
+
"You'll see this name in reports and health checks.",
|
|
10103
10239
|
"",
|
|
10104
|
-
`
|
|
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
|
|
10110
|
-
import * as
|
|
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
|
|
10115
|
-
import * as
|
|
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 =
|
|
10125
|
-
if (!
|
|
10257
|
+
const capsuleDir = path8.join(projectsBase, entry.slug);
|
|
10258
|
+
if (!fs8.existsSync(capsuleDir)) {
|
|
10126
10259
|
results.push(
|
|
10127
|
-
check(Severity.ERROR, "pathMissing", `
|
|
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 =
|
|
10265
|
+
const stat = fs8.statSync(capsuleDir);
|
|
10133
10266
|
if (!stat.isDirectory()) {
|
|
10134
10267
|
results.push(
|
|
10135
|
-
check(Severity.ERROR, "pathNotDir",
|
|
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",
|
|
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 =
|
|
10148
|
-
if (!
|
|
10149
|
-
if (!
|
|
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", "
|
|
10284
|
+
check(Severity.ERROR, "statusMissing", "Status file is missing", true, "Run /tm upgrade to recreate it")
|
|
10152
10285
|
);
|
|
10153
10286
|
}
|
|
10154
|
-
if (!
|
|
10155
|
-
|
|
10156
|
-
|
|
10157
|
-
|
|
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 (!
|
|
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
|
-
|
|
10195
|
-
|
|
10196
|
-
|
|
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
|
-
|
|
10206
|
-
|
|
10207
|
-
|
|
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
|
-
|
|
10217
|
-
|
|
10218
|
-
|
|
10219
|
-
|
|
10220
|
-
|
|
10221
|
-
|
|
10222
|
-
|
|
10223
|
-
|
|
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
|
-
|
|
10233
|
-
|
|
10234
|
-
|
|
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
|
-
|
|
10246
|
-
|
|
10247
|
-
|
|
10248
|
-
|
|
10249
|
-
|
|
10250
|
-
|
|
10251
|
-
|
|
10252
|
-
|
|
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}
|
|
10392
|
+
`${missing.length} tasks referenced in next actions don't exist in the TODO list: ${missing.join(", ")}`,
|
|
10277
10393
|
false,
|
|
10278
|
-
"
|
|
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
|
-
|
|
10290
|
-
|
|
10291
|
-
|
|
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
|
-
|
|
10300
|
-
|
|
10301
|
-
|
|
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", "
|
|
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 &&
|
|
10436
|
+
if (cronJobsPath && fs8.existsSync(cronJobsPath)) {
|
|
10325
10437
|
try {
|
|
10326
|
-
const jobsRaw =
|
|
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
|
-
`
|
|
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
|
-
|
|
10358
|
-
|
|
10359
|
-
|
|
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
|
-
|
|
10368
|
-
|
|
10369
|
-
|
|
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
|
-
|
|
10376
|
-
|
|
10377
|
-
|
|
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
|
-
|
|
10383
|
-
|
|
10384
|
-
|
|
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
|
-
"
|
|
10502
|
+
"Config is out of sync with your topics",
|
|
10399
10503
|
false,
|
|
10400
|
-
"Run /tm sync to
|
|
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
|
-
"
|
|
10515
|
+
"Config is out of sync with your topics",
|
|
10412
10516
|
false,
|
|
10413
|
-
"Run /tm sync to
|
|
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
|
-
|
|
10530
|
+
`No activity for a while \u2014 auto-snoozing for 30 days`,
|
|
10427
10531
|
true,
|
|
10428
|
-
"
|
|
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 =
|
|
10540
|
+
const capsuleDir = path8.join(projectsBase, entry.slug);
|
|
10437
10541
|
results.push(...runRegistryChecks(entry, projectsBase));
|
|
10438
|
-
if (!
|
|
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
|
|
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 =
|
|
10469
|
-
const backupDir =
|
|
10470
|
-
if (!
|
|
10471
|
-
|
|
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 =
|
|
10475
|
-
const dst =
|
|
10476
|
-
if (
|
|
10477
|
-
|
|
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 =
|
|
10604
|
+
const filePath = path8.join(capsuleDir, name);
|
|
10501
10605
|
try {
|
|
10502
|
-
if (
|
|
10503
|
-
files.set(name,
|
|
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 =
|
|
10631
|
+
const projectsBase = path9.join(workspaceDir, "projects");
|
|
10528
10632
|
if (!jailCheck(projectsBase, entry.slug)) {
|
|
10529
|
-
return { text: "
|
|
10633
|
+
return { text: "Something went wrong \u2014 path validation failed." };
|
|
10530
10634
|
}
|
|
10531
|
-
const capsuleDir =
|
|
10635
|
+
const capsuleDir = path9.join(projectsBase, entry.slug);
|
|
10532
10636
|
if (rejectSymlink(capsuleDir)) {
|
|
10533
|
-
return { text: "
|
|
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 (
|
|
10539
|
-
includeContent =
|
|
10642
|
+
if (fs9.existsSync(incPath)) {
|
|
10643
|
+
includeContent = fs9.readFileSync(incPath, "utf-8");
|
|
10540
10644
|
}
|
|
10541
10645
|
} catch {
|
|
10542
10646
|
}
|
|
10543
|
-
const cronJobsPath =
|
|
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
|
|
10670
|
+
text: reportText,
|
|
10574
10671
|
inlineKeyboard: keyboard
|
|
10575
10672
|
};
|
|
10576
10673
|
}
|
|
10577
10674
|
|
|
10578
10675
|
// src/commands/doctor-all.ts
|
|
10579
|
-
import * as
|
|
10580
|
-
import * as
|
|
10581
|
-
|
|
10582
|
-
|
|
10583
|
-
|
|
10584
|
-
|
|
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
|
|
10588
|
-
if (!
|
|
10589
|
-
return { text:
|
|
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 (
|
|
10592
|
-
const
|
|
10593
|
-
const
|
|
10594
|
-
if (
|
|
10595
|
-
|
|
10596
|
-
|
|
10597
|
-
|
|
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 =
|
|
10836
|
+
const projectsBase = path11.join(workspaceDir, "projects");
|
|
10603
10837
|
let includeContent;
|
|
10604
10838
|
const incPath = includePath(configDir);
|
|
10605
10839
|
try {
|
|
10606
|
-
if (
|
|
10607
|
-
includeContent =
|
|
10840
|
+
if (fs11.existsSync(incPath)) {
|
|
10841
|
+
includeContent = fs11.readFileSync(incPath, "utf-8");
|
|
10608
10842
|
}
|
|
10609
10843
|
} catch {
|
|
10610
10844
|
}
|
|
10611
|
-
const cronJobsPath =
|
|
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
|
-
`**
|
|
11018
|
+
`**Health Check Summary**`,
|
|
10731
11019
|
"",
|
|
10732
|
-
`
|
|
10733
|
-
`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(
|
|
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
|
|
10795
|
-
import * as
|
|
10796
|
-
|
|
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 =
|
|
10812
|
-
const capsuleDir =
|
|
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: "
|
|
11155
|
+
return { text: "Something went wrong \u2014 path validation failed." };
|
|
10815
11156
|
}
|
|
10816
11157
|
if (rejectSymlink(capsuleDir)) {
|
|
10817
|
-
return { text: "
|
|
11158
|
+
return { text: "Something went wrong \u2014 detected an unsafe file system configuration." };
|
|
10818
11159
|
}
|
|
10819
|
-
const statusPath =
|
|
10820
|
-
if (!
|
|
10821
|
-
return { text: "
|
|
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 =
|
|
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
|
|
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 = `
|
|
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: "
|
|
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
|
|
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
|
|
11282
|
+
text: `Topic **${entry.name}** is already up to date. No upgrade needed.`
|
|
10945
11283
|
};
|
|
10946
11284
|
}
|
|
10947
|
-
const projectsBase =
|
|
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
|
|
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: "
|
|
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
|
|
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
|
|
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() {
|