discoclaw 0.1.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/.context/README.md +42 -0
- package/.context/architecture.md +58 -0
- package/.context/bot-setup.md +24 -0
- package/.context/dev.md +230 -0
- package/.context/discord.md +144 -0
- package/.context/memory.md +257 -0
- package/.context/ops.md +59 -0
- package/.context/pa-safety.md +47 -0
- package/.context/pa.md +118 -0
- package/.context/project.md +43 -0
- package/.context/runtime.md +253 -0
- package/.context/tasks.md +71 -0
- package/.context/tools.md +75 -0
- package/.env.example +88 -0
- package/.env.example.full +378 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/dist/beads/auto-tag.js +2 -0
- package/dist/beads/auto-tag.test.js +62 -0
- package/dist/beads/bd-cli.js +9 -0
- package/dist/beads/bd-cli.test.js +495 -0
- package/dist/beads/bead-hooks-cli.js +149 -0
- package/dist/beads/bead-sync-cli.js +5 -0
- package/dist/beads/bead-sync-cli.test.js +72 -0
- package/dist/beads/bead-sync-coordinator.js +4 -0
- package/dist/beads/bead-sync-coordinator.test.js +239 -0
- package/dist/beads/bead-sync-watcher.js +2 -0
- package/dist/beads/bead-sync-watcher.test.js +96 -0
- package/dist/beads/bead-sync.js +7 -0
- package/dist/beads/bead-sync.test.js +876 -0
- package/dist/beads/bead-thread-cache.js +8 -0
- package/dist/beads/bead-thread-cache.test.js +91 -0
- package/dist/beads/discord-sync.js +18 -0
- package/dist/beads/discord-sync.test.js +782 -0
- package/dist/beads/find-bead-by-thread.test.js +36 -0
- package/dist/beads/forum-guard.js +2 -0
- package/dist/beads/forum-guard.test.js +204 -0
- package/dist/beads/initialize.js +3 -0
- package/dist/beads/initialize.test.js +304 -0
- package/dist/beads/types.js +10 -0
- package/dist/cli/daemon-installer.js +225 -0
- package/dist/cli/daemon-installer.test.js +289 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/init-wizard.js +374 -0
- package/dist/cli/init-wizard.test.js +191 -0
- package/dist/config.js +385 -0
- package/dist/config.test.js +589 -0
- package/dist/cron/auto-tag.js +100 -0
- package/dist/cron/auto-tag.test.js +91 -0
- package/dist/cron/cadence.js +74 -0
- package/dist/cron/cadence.test.js +53 -0
- package/dist/cron/cron-sync-coordinator.js +66 -0
- package/dist/cron/cron-sync-coordinator.test.js +118 -0
- package/dist/cron/cron-sync.js +165 -0
- package/dist/cron/cron-sync.test.js +228 -0
- package/dist/cron/cron-tag-map-watcher.js +128 -0
- package/dist/cron/cron-tag-map-watcher.test.js +155 -0
- package/dist/cron/default-timezone.js +23 -0
- package/dist/cron/default-timezone.test.js +30 -0
- package/dist/cron/discord-sync.js +205 -0
- package/dist/cron/discord-sync.test.js +353 -0
- package/dist/cron/executor.js +303 -0
- package/dist/cron/executor.test.js +614 -0
- package/dist/cron/forum-sync.js +347 -0
- package/dist/cron/forum-sync.test.js +539 -0
- package/dist/cron/job-lock.js +164 -0
- package/dist/cron/job-lock.test.js +178 -0
- package/dist/cron/parser.js +68 -0
- package/dist/cron/parser.test.js +115 -0
- package/dist/cron/run-control.js +24 -0
- package/dist/cron/run-control.test.js +27 -0
- package/dist/cron/run-stats.js +265 -0
- package/dist/cron/run-stats.test.js +160 -0
- package/dist/cron/scheduler.js +97 -0
- package/dist/cron/scheduler.test.js +112 -0
- package/dist/cron/tag-map.js +47 -0
- package/dist/cron/tag-map.test.js +64 -0
- package/dist/cron/types.js +1 -0
- package/dist/discoclaw-plan-format.test.js +137 -0
- package/dist/discoclaw-recipe-format.test.js +137 -0
- package/dist/discord/abort-registry.js +70 -0
- package/dist/discord/action-categories.js +36 -0
- package/dist/discord/action-types.js +1 -0
- package/dist/discord/action-utils.js +58 -0
- package/dist/discord/action-utils.test.js +58 -0
- package/dist/discord/actions-beads.js +1 -0
- package/dist/discord/actions-beads.test.js +372 -0
- package/dist/discord/actions-bot-profile.js +107 -0
- package/dist/discord/actions-bot-profile.test.js +138 -0
- package/dist/discord/actions-channels.js +427 -0
- package/dist/discord/actions-channels.test.js +697 -0
- package/dist/discord/actions-config.js +173 -0
- package/dist/discord/actions-config.test.js +322 -0
- package/dist/discord/actions-crons.js +586 -0
- package/dist/discord/actions-crons.test.js +499 -0
- package/dist/discord/actions-defer.js +60 -0
- package/dist/discord/actions-defer.test.js +134 -0
- package/dist/discord/actions-forge.js +134 -0
- package/dist/discord/actions-forge.test.js +206 -0
- package/dist/discord/actions-guild.js +301 -0
- package/dist/discord/actions-guild.test.js +386 -0
- package/dist/discord/actions-memory.js +106 -0
- package/dist/discord/actions-memory.test.js +248 -0
- package/dist/discord/actions-messaging.js +401 -0
- package/dist/discord/actions-messaging.test.js +738 -0
- package/dist/discord/actions-moderation.js +65 -0
- package/dist/discord/actions-moderation.test.js +88 -0
- package/dist/discord/actions-plan.js +445 -0
- package/dist/discord/actions-plan.test.js +610 -0
- package/dist/discord/actions-poll.js +38 -0
- package/dist/discord/actions-poll.test.js +93 -0
- package/dist/discord/actions-tasks.js +3 -0
- package/dist/discord/actions-tasks.test.js +418 -0
- package/dist/discord/actions.js +600 -0
- package/dist/discord/actions.test.js +522 -0
- package/dist/discord/allowed-mentions.js +3 -0
- package/dist/discord/allowed-mentions.test.js +17 -0
- package/dist/discord/allowlist.js +29 -0
- package/dist/discord/allowlist.test.js +24 -0
- package/dist/discord/audit-handler.js +191 -0
- package/dist/discord/audit-handler.test.js +361 -0
- package/dist/discord/bot.js +141 -0
- package/dist/discord/channel-context.js +181 -0
- package/dist/discord/defer-scheduler.js +45 -0
- package/dist/discord/destructive-confirmation.js +128 -0
- package/dist/discord/destructive-confirmation.test.js +49 -0
- package/dist/discord/discord-plan-auto-implement.test.js +18 -0
- package/dist/discord/durable-memory.js +145 -0
- package/dist/discord/durable-memory.test.js +281 -0
- package/dist/discord/durable-write-queue.js +4 -0
- package/dist/discord/file-download.js +308 -0
- package/dist/discord/file-download.test.js +303 -0
- package/dist/discord/forge-audit-verdict.js +140 -0
- package/dist/discord/forge-auto-implement.js +80 -0
- package/dist/discord/forge-auto-implement.test.js +110 -0
- package/dist/discord/forge-commands.js +698 -0
- package/dist/discord/forge-commands.test.js +1606 -0
- package/dist/discord/forge-plan-registry.js +68 -0
- package/dist/discord/forge-plan-registry.test.js +127 -0
- package/dist/discord/forum-count-sync.js +130 -0
- package/dist/discord/forum-count-sync.test.js +200 -0
- package/dist/discord/health-command.js +98 -0
- package/dist/discord/health-command.test.js +195 -0
- package/dist/discord/help-command.js +22 -0
- package/dist/discord/help-command.test.js +49 -0
- package/dist/discord/image-download.js +201 -0
- package/dist/discord/image-download.test.js +499 -0
- package/dist/discord/inflight-replies.js +228 -0
- package/dist/discord/inflight-replies.test.js +295 -0
- package/dist/discord/json-extract.js +110 -0
- package/dist/discord/keyed-queue.js +22 -0
- package/dist/discord/memory-commands.js +85 -0
- package/dist/discord/memory-commands.test.js +159 -0
- package/dist/discord/memory-timing.integration.test.js +159 -0
- package/dist/discord/message-coordinator.js +2347 -0
- package/dist/discord/message-coordinator.onboarding.test.js +183 -0
- package/dist/discord/message-coordinator.plan-run.test.js +264 -0
- package/dist/discord/message-history.js +53 -0
- package/dist/discord/message-history.test.js +95 -0
- package/dist/discord/models-command.js +59 -0
- package/dist/discord/models-command.test.js +150 -0
- package/dist/discord/nickname.test.js +76 -0
- package/dist/discord/onboarding-completion.js +55 -0
- package/dist/discord/onboarding-completion.test.js +176 -0
- package/dist/discord/output-common.js +178 -0
- package/dist/discord/output-common.test.js +198 -0
- package/dist/discord/output-utils.js +156 -0
- package/dist/discord/parse-identity-name.test.js +129 -0
- package/dist/discord/plan-commands.js +612 -0
- package/dist/discord/plan-commands.test.js +1622 -0
- package/dist/discord/plan-manager.js +1491 -0
- package/dist/discord/plan-manager.test.js +2380 -0
- package/dist/discord/plan-parser.js +110 -0
- package/dist/discord/plan-parser.test.js +63 -0
- package/dist/discord/plan-run-phase-start.js +20 -0
- package/dist/discord/plan-run-phase-start.test.js +29 -0
- package/dist/discord/platform-message.js +45 -0
- package/dist/discord/platform-message.test.js +110 -0
- package/dist/discord/prompt-common.js +240 -0
- package/dist/discord/prompt-common.test.js +423 -0
- package/dist/discord/reaction-handler.js +691 -0
- package/dist/discord/reaction-handler.test.js +1574 -0
- package/dist/discord/reaction-prompts.js +118 -0
- package/dist/discord/reaction-prompts.test.js +253 -0
- package/dist/discord/reply-reference.js +66 -0
- package/dist/discord/reply-reference.test.js +125 -0
- package/dist/discord/restart-command.js +143 -0
- package/dist/discord/restart-command.test.js +196 -0
- package/dist/discord/runtime-utils.js +43 -0
- package/dist/discord/runtime-utils.test.js +112 -0
- package/dist/discord/session-key.js +7 -0
- package/dist/discord/session-key.test.js +13 -0
- package/dist/discord/shortterm-memory.js +166 -0
- package/dist/discord/shortterm-memory.test.js +345 -0
- package/dist/discord/shutdown-context.js +122 -0
- package/dist/discord/shutdown-context.test.js +279 -0
- package/dist/discord/startup-profile.test.js +214 -0
- package/dist/discord/status-channel.js +190 -0
- package/dist/discord/status-channel.test.js +282 -0
- package/dist/discord/status-command.js +206 -0
- package/dist/discord/status-command.test.js +341 -0
- package/dist/discord/streaming-progress.js +107 -0
- package/dist/discord/streaming-progress.test.js +93 -0
- package/dist/discord/summarizer.js +89 -0
- package/dist/discord/summarizer.test.js +245 -0
- package/dist/discord/system-bootstrap.js +396 -0
- package/dist/discord/system-bootstrap.test.js +724 -0
- package/dist/discord/thread-context.js +169 -0
- package/dist/discord/thread-context.test.js +386 -0
- package/dist/discord/tool-aware-queue.js +116 -0
- package/dist/discord/tool-aware-queue.test.js +180 -0
- package/dist/discord/update-command.js +127 -0
- package/dist/discord/update-command.test.js +275 -0
- package/dist/discord/user-errors.js +40 -0
- package/dist/discord/user-errors.test.js +31 -0
- package/dist/discord/user-turn-to-durable.js +111 -0
- package/dist/discord/user-turn-to-durable.test.js +273 -0
- package/dist/discord-followup.test.js +677 -0
- package/dist/discord.channel-context.test.js +95 -0
- package/dist/discord.fail-closed.test.js +199 -0
- package/dist/discord.health-command.integration.test.js +140 -0
- package/dist/discord.js +190 -0
- package/dist/discord.prompt-context.test.js +1431 -0
- package/dist/discord.render.test.js +621 -0
- package/dist/discord.status-wiring.test.js +187 -0
- package/dist/engine/claudeCli.js +137 -0
- package/dist/engine/types.js +1 -0
- package/dist/group-queue.js +25 -0
- package/dist/health/credential-check.js +175 -0
- package/dist/health/credential-check.test.js +401 -0
- package/dist/health/startup-healing.js +139 -0
- package/dist/health/startup-healing.test.js +298 -0
- package/dist/identity.js +36 -0
- package/dist/index.js +1378 -0
- package/dist/logging/logger-like.js +1 -0
- package/dist/observability/memory-sampler.js +51 -0
- package/dist/observability/memory-sampler.test.js +93 -0
- package/dist/observability/metrics.js +88 -0
- package/dist/observability/metrics.test.js +42 -0
- package/dist/onboarding/onboarding-flow.js +246 -0
- package/dist/onboarding/onboarding-flow.test.js +238 -0
- package/dist/onboarding/onboarding-writer.js +102 -0
- package/dist/onboarding/onboarding-writer.test.js +143 -0
- package/dist/pidlock.js +187 -0
- package/dist/pidlock.test.js +128 -0
- package/dist/pipeline/engine.js +206 -0
- package/dist/pipeline/engine.test.js +771 -0
- package/dist/root-policy.js +21 -0
- package/dist/root-policy.test.js +55 -0
- package/dist/runtime/claude-code-cli.js +35 -0
- package/dist/runtime/claude-code-cli.test.js +1199 -0
- package/dist/runtime/cli-adapter.js +584 -0
- package/dist/runtime/cli-output-parsers.js +108 -0
- package/dist/runtime/cli-shared.js +96 -0
- package/dist/runtime/cli-shared.test.js +104 -0
- package/dist/runtime/cli-strategy.js +6 -0
- package/dist/runtime/codex-cli.js +16 -0
- package/dist/runtime/codex-cli.test.js +862 -0
- package/dist/runtime/concurrency-limit.js +80 -0
- package/dist/runtime/concurrency-limit.test.js +137 -0
- package/dist/runtime/gemini-cli.js +16 -0
- package/dist/runtime/gemini-cli.test.js +413 -0
- package/dist/runtime/long-running-process.js +415 -0
- package/dist/runtime/long-running-process.test.js +318 -0
- package/dist/runtime/model-smoke-helpers.js +160 -0
- package/dist/runtime/model-smoke.test.js +194 -0
- package/dist/runtime/model-tiers.js +33 -0
- package/dist/runtime/model-tiers.test.js +65 -0
- package/dist/runtime/openai-auth.js +151 -0
- package/dist/runtime/openai-auth.test.js +361 -0
- package/dist/runtime/openai-compat.js +178 -0
- package/dist/runtime/openai-compat.test.js +449 -0
- package/dist/runtime/process-pool.js +93 -0
- package/dist/runtime/process-pool.test.js +148 -0
- package/dist/runtime/registry.js +15 -0
- package/dist/runtime/registry.test.js +47 -0
- package/dist/runtime/session-scanner.js +186 -0
- package/dist/runtime/session-scanner.test.js +257 -0
- package/dist/runtime/strategies/claude-strategy.js +193 -0
- package/dist/runtime/strategies/codex-strategy.js +161 -0
- package/dist/runtime/strategies/gemini-strategy.js +64 -0
- package/dist/runtime/strategies/template-strategy.js +85 -0
- package/dist/runtime/tool-capabilities.js +27 -0
- package/dist/runtime/tool-capabilities.test.js +24 -0
- package/dist/runtime/tool-labels.js +48 -0
- package/dist/runtime/types.js +2 -0
- package/dist/sessionManager.js +47 -0
- package/dist/sessions.js +18 -0
- package/dist/tasks/architecture-contract.js +33 -0
- package/dist/tasks/architecture-contract.test.js +90 -0
- package/dist/tasks/auto-tag.js +50 -0
- package/dist/tasks/auto-tag.test.js +64 -0
- package/dist/tasks/bd-cli.js +164 -0
- package/dist/tasks/bd-cli.test.js +359 -0
- package/dist/tasks/bead-sync.js +1 -0
- package/dist/tasks/context-summary.js +27 -0
- package/dist/tasks/discord-sync.js +3 -0
- package/dist/tasks/discord-sync.test.js +685 -0
- package/dist/tasks/discord-types.js +4 -0
- package/dist/tasks/find-task-by-thread.test.js +36 -0
- package/dist/tasks/forum-guard.js +81 -0
- package/dist/tasks/forum-guard.test.js +192 -0
- package/dist/tasks/initialize.js +77 -0
- package/dist/tasks/initialize.test.js +263 -0
- package/dist/tasks/logger-types.js +1 -0
- package/dist/tasks/metrics-types.js +3 -0
- package/dist/tasks/migrate.js +33 -0
- package/dist/tasks/migrate.test.js +156 -0
- package/dist/tasks/path-defaults.js +67 -0
- package/dist/tasks/path-defaults.test.js +73 -0
- package/dist/tasks/runtime-types.js +1 -0
- package/dist/tasks/service.js +33 -0
- package/dist/tasks/service.test.js +51 -0
- package/dist/tasks/store.js +238 -0
- package/dist/tasks/store.test.js +417 -0
- package/dist/tasks/sync-context.js +1 -0
- package/dist/tasks/sync-contract.js +24 -0
- package/dist/tasks/sync-contract.test.js +25 -0
- package/dist/tasks/sync-coordinator-metrics.js +41 -0
- package/dist/tasks/sync-coordinator-retries.js +71 -0
- package/dist/tasks/sync-coordinator.js +96 -0
- package/dist/tasks/sync-coordinator.test.js +501 -0
- package/dist/tasks/sync-types.js +1 -0
- package/dist/tasks/sync-watcher.js +27 -0
- package/dist/tasks/sync-watcher.test.js +92 -0
- package/dist/tasks/tag-map.js +36 -0
- package/dist/tasks/tag-map.test.js +54 -0
- package/dist/tasks/task-action-contract.js +16 -0
- package/dist/tasks/task-action-contract.test.js +16 -0
- package/dist/tasks/task-action-executor.js +18 -0
- package/dist/tasks/task-action-executor.test.js +420 -0
- package/dist/tasks/task-action-mutation-helpers.js +17 -0
- package/dist/tasks/task-action-mutations.js +151 -0
- package/dist/tasks/task-action-prompt.js +62 -0
- package/dist/tasks/task-action-read-ops.js +73 -0
- package/dist/tasks/task-action-runner-types.js +1 -0
- package/dist/tasks/task-action-thread-sync.js +82 -0
- package/dist/tasks/task-actions.js +3 -0
- package/dist/tasks/task-cli.js +227 -0
- package/dist/tasks/task-context.js +1 -0
- package/dist/tasks/task-lifecycle.js +46 -0
- package/dist/tasks/task-lifecycle.test.js +35 -0
- package/dist/tasks/task-sync-apply-plan.js +95 -0
- package/dist/tasks/task-sync-apply-types.js +12 -0
- package/dist/tasks/task-sync-apply.js +319 -0
- package/dist/tasks/task-sync-cli.js +89 -0
- package/dist/tasks/task-sync-cli.test.js +70 -0
- package/dist/tasks/task-sync-engine.js +88 -0
- package/dist/tasks/task-sync-engine.test.js +934 -0
- package/dist/tasks/task-sync-phase-apply.js +171 -0
- package/dist/tasks/task-sync-pipeline.js +2 -0
- package/dist/tasks/task-sync-pipeline.test.js +265 -0
- package/dist/tasks/task-sync-reconcile-plan.js +182 -0
- package/dist/tasks/task-sync-reconcile.js +144 -0
- package/dist/tasks/task-sync.js +56 -0
- package/dist/tasks/task-sync.test.js +86 -0
- package/dist/tasks/thread-cache.js +42 -0
- package/dist/tasks/thread-cache.test.js +89 -0
- package/dist/tasks/thread-contracts.test.js +711 -0
- package/dist/tasks/thread-forum-ops.js +68 -0
- package/dist/tasks/thread-helpers.js +86 -0
- package/dist/tasks/thread-helpers.test.js +33 -0
- package/dist/tasks/thread-lifecycle-ops.js +144 -0
- package/dist/tasks/thread-ops-shared.js +21 -0
- package/dist/tasks/thread-ops.js +2 -0
- package/dist/tasks/types.js +20 -0
- package/dist/tasks/types.test.js +60 -0
- package/dist/test-setup.js +11 -0
- package/dist/test-setup.test.js +42 -0
- package/dist/transport/types.js +1 -0
- package/dist/validate.js +41 -0
- package/dist/validate.test.js +94 -0
- package/dist/version.js +15 -0
- package/dist/version.test.js +31 -0
- package/dist/webhook/server.js +199 -0
- package/dist/webhook/server.test.js +460 -0
- package/dist/workspace-bootstrap.js +135 -0
- package/dist/workspace-bootstrap.test.js +514 -0
- package/dist/workspace-permissions.js +134 -0
- package/dist/workspace-permissions.test.js +181 -0
- package/package.json +74 -0
- package/scripts/cron/cron-tag-map.json +9 -0
- package/scripts/tasks/tag-map.json +10 -0
- package/systemd/discoclaw.service +19 -0
- package/templates/recipes/integration.discoclaw-recipe.md +171 -0
- package/templates/workspace/AGENTS.md +217 -0
- package/templates/workspace/BOOTSTRAP.md +1 -0
- package/templates/workspace/HEARTBEAT.md +10 -0
- package/templates/workspace/IDENTITY.md +16 -0
- package/templates/workspace/MEMORY.md +24 -0
- package/templates/workspace/SOUL.md +52 -0
- package/templates/workspace/TOOLS.md +304 -0
- package/templates/workspace/USER.md +37 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const MODERATION_TYPE_MAP = {
|
|
2
|
+
timeout: true, kick: true, ban: true,
|
|
3
|
+
};
|
|
4
|
+
export const MODERATION_ACTION_TYPES = new Set(Object.keys(MODERATION_TYPE_MAP));
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Executor
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
export async function executeModerationAction(action, ctx) {
|
|
9
|
+
const { guild } = ctx;
|
|
10
|
+
switch (action.type) {
|
|
11
|
+
case 'timeout': {
|
|
12
|
+
const member = await guild.members.fetch(action.userId).catch(() => null);
|
|
13
|
+
if (!member)
|
|
14
|
+
return { ok: false, error: `Member "${action.userId}" not found` };
|
|
15
|
+
const minutes = action.durationMinutes ?? 5;
|
|
16
|
+
const ms = minutes * 60 * 1000;
|
|
17
|
+
await member.timeout(ms, action.reason);
|
|
18
|
+
return { ok: true, summary: `Timed out ${member.displayName} for ${minutes} minutes${action.reason ? `: ${action.reason}` : ''}` };
|
|
19
|
+
}
|
|
20
|
+
case 'kick': {
|
|
21
|
+
const member = await guild.members.fetch(action.userId).catch(() => null);
|
|
22
|
+
if (!member)
|
|
23
|
+
return { ok: false, error: `Member "${action.userId}" not found` };
|
|
24
|
+
const name = member.displayName;
|
|
25
|
+
await member.kick(action.reason);
|
|
26
|
+
return { ok: true, summary: `Kicked ${name}${action.reason ? `: ${action.reason}` : ''}` };
|
|
27
|
+
}
|
|
28
|
+
case 'ban': {
|
|
29
|
+
const member = await guild.members.fetch(action.userId).catch(() => null);
|
|
30
|
+
if (!member)
|
|
31
|
+
return { ok: false, error: `Member "${action.userId}" not found` };
|
|
32
|
+
const name = member.displayName;
|
|
33
|
+
await member.ban({
|
|
34
|
+
reason: action.reason,
|
|
35
|
+
deleteMessageSeconds: (action.deleteMessageDays ?? 0) * 86400,
|
|
36
|
+
});
|
|
37
|
+
return { ok: true, summary: `Banned ${name}${action.reason ? `: ${action.reason}` : ''}` };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Prompt section
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
export function moderationActionsPromptSection() {
|
|
45
|
+
return `### Moderation
|
|
46
|
+
|
|
47
|
+
All moderation actions are destructive. **Always confirm with the user before executing.**
|
|
48
|
+
|
|
49
|
+
**timeout** — Temporarily mute a member:
|
|
50
|
+
\`\`\`
|
|
51
|
+
<discord-action>{"type":"timeout","userId":"123","durationMinutes":10,"reason":"Spamming"}</discord-action>
|
|
52
|
+
\`\`\`
|
|
53
|
+
- \`durationMinutes\` (optional): Default 5 minutes.
|
|
54
|
+
|
|
55
|
+
**kick** — Kick a member from the server:
|
|
56
|
+
\`\`\`
|
|
57
|
+
<discord-action>{"type":"kick","userId":"123","reason":"Rule violation"}</discord-action>
|
|
58
|
+
\`\`\`
|
|
59
|
+
|
|
60
|
+
**ban** — Ban a member from the server:
|
|
61
|
+
\`\`\`
|
|
62
|
+
<discord-action>{"type":"ban","userId":"123","reason":"Repeated violations","deleteMessageDays":1}</discord-action>
|
|
63
|
+
\`\`\`
|
|
64
|
+
- \`deleteMessageDays\` (optional): Delete messages from the last N days (0–7).`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { executeModerationAction } from './actions-moderation.js';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
function makeMockMember(overrides = {}) {
|
|
7
|
+
return {
|
|
8
|
+
id: overrides.id ?? 'user1',
|
|
9
|
+
displayName: overrides.displayName ?? 'TestUser',
|
|
10
|
+
user: { username: overrides.username ?? 'testuser' },
|
|
11
|
+
timeout: vi.fn(async () => { }),
|
|
12
|
+
kick: vi.fn(async () => { }),
|
|
13
|
+
ban: vi.fn(async () => { }),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function makeCtx(members) {
|
|
17
|
+
const memberMap = new Map();
|
|
18
|
+
for (const m of members)
|
|
19
|
+
memberMap.set(m.id, m);
|
|
20
|
+
return {
|
|
21
|
+
guild: {
|
|
22
|
+
members: {
|
|
23
|
+
fetch: vi.fn(async (id) => {
|
|
24
|
+
const m = memberMap.get(id);
|
|
25
|
+
if (!m)
|
|
26
|
+
throw new Error('not found');
|
|
27
|
+
return m;
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
client: {},
|
|
32
|
+
channelId: 'ch1',
|
|
33
|
+
messageId: 'msg1',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Tests
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
describe('timeout', () => {
|
|
40
|
+
it('times out a member with default duration', async () => {
|
|
41
|
+
const member = makeMockMember({ id: 'u1', displayName: 'Alice' });
|
|
42
|
+
const ctx = makeCtx([member]);
|
|
43
|
+
const result = await executeModerationAction({ type: 'timeout', userId: 'u1' }, ctx);
|
|
44
|
+
expect(result).toEqual({ ok: true, summary: 'Timed out Alice for 5 minutes' });
|
|
45
|
+
expect(member.timeout).toHaveBeenCalledWith(5 * 60 * 1000, undefined);
|
|
46
|
+
});
|
|
47
|
+
it('times out with custom duration and reason', async () => {
|
|
48
|
+
const member = makeMockMember({ id: 'u1', displayName: 'Bob' });
|
|
49
|
+
const ctx = makeCtx([member]);
|
|
50
|
+
const result = await executeModerationAction({ type: 'timeout', userId: 'u1', durationMinutes: 30, reason: 'Spamming' }, ctx);
|
|
51
|
+
expect(result.ok).toBe(true);
|
|
52
|
+
expect(result.summary).toContain('30 minutes');
|
|
53
|
+
expect(result.summary).toContain('Spamming');
|
|
54
|
+
expect(member.timeout).toHaveBeenCalledWith(30 * 60 * 1000, 'Spamming');
|
|
55
|
+
});
|
|
56
|
+
it('fails when member not found', async () => {
|
|
57
|
+
const ctx = makeCtx([]);
|
|
58
|
+
const result = await executeModerationAction({ type: 'timeout', userId: 'nope' }, ctx);
|
|
59
|
+
expect(result).toEqual({ ok: false, error: 'Member "nope" not found' });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('kick', () => {
|
|
63
|
+
it('kicks a member', async () => {
|
|
64
|
+
const member = makeMockMember({ id: 'u1', displayName: 'Spammer' });
|
|
65
|
+
const ctx = makeCtx([member]);
|
|
66
|
+
const result = await executeModerationAction({ type: 'kick', userId: 'u1', reason: 'Rule violation' }, ctx);
|
|
67
|
+
expect(result).toEqual({ ok: true, summary: 'Kicked Spammer: Rule violation' });
|
|
68
|
+
expect(member.kick).toHaveBeenCalledWith('Rule violation');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('ban', () => {
|
|
72
|
+
it('bans a member with message deletion', async () => {
|
|
73
|
+
const member = makeMockMember({ id: 'u1', displayName: 'BadActor' });
|
|
74
|
+
const ctx = makeCtx([member]);
|
|
75
|
+
const result = await executeModerationAction({ type: 'ban', userId: 'u1', reason: 'Repeated violations', deleteMessageDays: 7 }, ctx);
|
|
76
|
+
expect(result).toEqual({ ok: true, summary: 'Banned BadActor: Repeated violations' });
|
|
77
|
+
expect(member.ban).toHaveBeenCalledWith({
|
|
78
|
+
reason: 'Repeated violations',
|
|
79
|
+
deleteMessageSeconds: 7 * 86400,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
it('bans without reason', async () => {
|
|
83
|
+
const member = makeMockMember({ id: 'u1', displayName: 'User' });
|
|
84
|
+
const ctx = makeCtx([member]);
|
|
85
|
+
const result = await executeModerationAction({ type: 'ban', userId: 'u1' }, ctx);
|
|
86
|
+
expect(result).toEqual({ ok: true, summary: 'Banned User' });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { findPlanFile, listPlanFiles, updatePlanFileStatus, handlePlanCommand, preparePlanRun, closePlanIfComplete, resolvePlanHeaderTaskId, NO_PHASES_SENTINEL, } from './plan-commands.js';
|
|
2
|
+
import { runNextPhase, resolveProjectCwd, readPhasesFile, buildPostRunSummary } from './plan-manager.js';
|
|
3
|
+
import { acquireWriterLock, addRunningPlan, removeRunningPlan, isPlanRunning, } from './forge-plan-registry.js';
|
|
4
|
+
import { NO_MENTIONS } from './allowed-mentions.js';
|
|
5
|
+
const DEFAULT_PLAN_PHASE_TIMEOUT_MS = 1_800_000;
|
|
6
|
+
const PLAN_TYPE_MAP = {
|
|
7
|
+
planList: true,
|
|
8
|
+
planShow: true,
|
|
9
|
+
planApprove: true,
|
|
10
|
+
planClose: true,
|
|
11
|
+
planCreate: true,
|
|
12
|
+
planRun: true,
|
|
13
|
+
};
|
|
14
|
+
export const PLAN_ACTION_TYPES = new Set(Object.keys(PLAN_TYPE_MAP));
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Executor
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export async function executePlanAction(action, ctx, planCtx) {
|
|
19
|
+
switch (action.type) {
|
|
20
|
+
case 'planList': {
|
|
21
|
+
const plans = await listPlanFiles(planCtx.plansDir);
|
|
22
|
+
if (plans.length === 0) {
|
|
23
|
+
return { ok: true, summary: 'No plans found.' };
|
|
24
|
+
}
|
|
25
|
+
// Filter by status if provided.
|
|
26
|
+
let filtered = plans;
|
|
27
|
+
if (action.status) {
|
|
28
|
+
const statusUpper = action.status.toUpperCase();
|
|
29
|
+
filtered = plans.filter((p) => p.header.status.toUpperCase() === statusUpper);
|
|
30
|
+
if (filtered.length === 0) {
|
|
31
|
+
return { ok: true, summary: `No plans with status "${action.status}".` };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Sort by planId.
|
|
35
|
+
filtered.sort((a, b) => a.header.planId.localeCompare(b.header.planId));
|
|
36
|
+
const lines = filtered.map((p) => {
|
|
37
|
+
const taskId = resolvePlanHeaderTaskId(p.header);
|
|
38
|
+
return `\`${p.header.planId}\` [${p.header.status}] — ${p.header.title}${taskId ? ` (task: \`${taskId}\`)` : ''}`;
|
|
39
|
+
});
|
|
40
|
+
return { ok: true, summary: lines.join('\n') };
|
|
41
|
+
}
|
|
42
|
+
case 'planShow': {
|
|
43
|
+
if (!action.planId) {
|
|
44
|
+
return { ok: false, error: 'planShow requires a planId' };
|
|
45
|
+
}
|
|
46
|
+
const found = await findPlanFile(planCtx.plansDir, action.planId);
|
|
47
|
+
if (!found) {
|
|
48
|
+
return { ok: false, error: `Plan not found: ${action.planId}` };
|
|
49
|
+
}
|
|
50
|
+
const taskId = resolvePlanHeaderTaskId(found.header);
|
|
51
|
+
const lines = [
|
|
52
|
+
`**${found.header.planId}** — ${found.header.title}`,
|
|
53
|
+
`Status: ${found.header.status}`,
|
|
54
|
+
...(taskId ? [`Task: \`${taskId}\``] : []),
|
|
55
|
+
`Project: ${found.header.project}`,
|
|
56
|
+
`Created: ${found.header.created}`,
|
|
57
|
+
];
|
|
58
|
+
return { ok: true, summary: lines.join('\n') };
|
|
59
|
+
}
|
|
60
|
+
case 'planApprove': {
|
|
61
|
+
if (!action.planId) {
|
|
62
|
+
return { ok: false, error: 'planApprove requires a planId' };
|
|
63
|
+
}
|
|
64
|
+
const found = await findPlanFile(planCtx.plansDir, action.planId);
|
|
65
|
+
if (!found) {
|
|
66
|
+
return { ok: false, error: `Plan not found: ${action.planId}` };
|
|
67
|
+
}
|
|
68
|
+
if (found.header.status === 'IMPLEMENTING') {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: `Plan is currently being implemented. Cancel it first.`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
await updatePlanFileStatus(found.filePath, 'APPROVED');
|
|
75
|
+
// Update backing task to in_progress.
|
|
76
|
+
const taskId = resolvePlanHeaderTaskId(found.header);
|
|
77
|
+
if (taskId) {
|
|
78
|
+
try {
|
|
79
|
+
planCtx.taskStore.update(taskId, { status: 'in_progress' });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// best-effort
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { ok: true, summary: `Plan **${found.header.planId}** approved for implementation.` };
|
|
86
|
+
}
|
|
87
|
+
case 'planClose': {
|
|
88
|
+
if (!action.planId) {
|
|
89
|
+
return { ok: false, error: 'planClose requires a planId' };
|
|
90
|
+
}
|
|
91
|
+
const found = await findPlanFile(planCtx.plansDir, action.planId);
|
|
92
|
+
if (!found) {
|
|
93
|
+
return { ok: false, error: `Plan not found: ${action.planId}` };
|
|
94
|
+
}
|
|
95
|
+
if (found.header.status === 'IMPLEMENTING') {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: `Plan is currently being implemented. Cancel it first.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
await updatePlanFileStatus(found.filePath, 'CLOSED');
|
|
102
|
+
// Close backing task.
|
|
103
|
+
const taskId = resolvePlanHeaderTaskId(found.header);
|
|
104
|
+
if (taskId) {
|
|
105
|
+
try {
|
|
106
|
+
planCtx.taskStore.close(taskId, 'Plan closed');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// best-effort
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, summary: `Plan **${found.header.planId}** closed.` };
|
|
113
|
+
}
|
|
114
|
+
case 'planCreate': {
|
|
115
|
+
if (!action.description) {
|
|
116
|
+
return { ok: false, error: 'planCreate requires a description' };
|
|
117
|
+
}
|
|
118
|
+
const opts = {
|
|
119
|
+
workspaceCwd: planCtx.workspaceCwd,
|
|
120
|
+
taskStore: planCtx.taskStore,
|
|
121
|
+
};
|
|
122
|
+
const result = await handlePlanCommand({ action: 'create', args: action.description, context: action.context }, opts);
|
|
123
|
+
// handlePlanCommand returns a human-readable string. Check for error patterns.
|
|
124
|
+
if (result.startsWith('Failed') || result.startsWith('Plan command error')) {
|
|
125
|
+
return { ok: false, error: result };
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, summary: result };
|
|
128
|
+
}
|
|
129
|
+
case 'planRun': {
|
|
130
|
+
if ((planCtx.depth ?? 0) >= 1) {
|
|
131
|
+
return { ok: false, error: 'planRun blocked: recursion depth >= 1 (plan run cannot spawn another plan run)' };
|
|
132
|
+
}
|
|
133
|
+
if (!action.planId) {
|
|
134
|
+
return { ok: false, error: 'planRun requires a planId' };
|
|
135
|
+
}
|
|
136
|
+
if (!planCtx.runtime || !planCtx.model) {
|
|
137
|
+
return { ok: false, error: 'planRun requires runtime and model to be configured' };
|
|
138
|
+
}
|
|
139
|
+
if (isPlanRunning(action.planId)) {
|
|
140
|
+
return { ok: false, error: `A multi-phase run is already in progress for ${action.planId}.` };
|
|
141
|
+
}
|
|
142
|
+
const runPlanId = action.planId;
|
|
143
|
+
const planOpts = {
|
|
144
|
+
workspaceCwd: planCtx.workspaceCwd,
|
|
145
|
+
taskStore: planCtx.taskStore,
|
|
146
|
+
};
|
|
147
|
+
// Validate the plan and get phase info synchronously.
|
|
148
|
+
const prepResult = await preparePlanRun(action.planId, planOpts);
|
|
149
|
+
if ('error' in prepResult) {
|
|
150
|
+
const isAllDone = prepResult.error.startsWith(NO_PHASES_SENTINEL);
|
|
151
|
+
return isAllDone
|
|
152
|
+
? { ok: true, summary: `All phases already complete for ${action.planId}.` }
|
|
153
|
+
: { ok: false, error: prepResult.error };
|
|
154
|
+
}
|
|
155
|
+
let projectCwd;
|
|
156
|
+
try {
|
|
157
|
+
projectCwd = resolveProjectCwd(prepResult.planContent, planCtx.workspaceCwd);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
return { ok: false, error: `Failed to resolve project directory: ${String(err instanceof Error ? err.message : err)}` };
|
|
161
|
+
}
|
|
162
|
+
const maxPhases = planCtx.maxPlanRunPhases ?? 50;
|
|
163
|
+
const timeoutMs = planCtx.phaseTimeoutMs ?? DEFAULT_PLAN_PHASE_TIMEOUT_MS;
|
|
164
|
+
const onProgress = planCtx.onProgress ?? (async () => { });
|
|
165
|
+
const PROGRESS_THROTTLE_MS = 3_000;
|
|
166
|
+
addRunningPlan(action.planId);
|
|
167
|
+
// Fire and forget — plan run executes asynchronously.
|
|
168
|
+
void (async () => {
|
|
169
|
+
// Send initial status message and set up live edits (best-effort).
|
|
170
|
+
let runChannel;
|
|
171
|
+
let statusMsg;
|
|
172
|
+
let lastStatusEditAt = 0;
|
|
173
|
+
const phaseStartMessages = new Map();
|
|
174
|
+
const onPlanEvent = async (event) => {
|
|
175
|
+
if (event.type === 'phase_start') {
|
|
176
|
+
if (phaseStartMessages.has(event.phase.id) || !runChannel)
|
|
177
|
+
return;
|
|
178
|
+
try {
|
|
179
|
+
const sent = await runChannel.send({
|
|
180
|
+
content: `**${event.phase.title}**...`,
|
|
181
|
+
allowedMentions: NO_MENTIONS,
|
|
182
|
+
});
|
|
183
|
+
if (sent && typeof sent.edit === 'function') {
|
|
184
|
+
phaseStartMessages.set(event.phase.id, sent);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
planCtx.log?.warn({ err, planId: runPlanId, phaseId: event.phase.id }, 'plan:action:run phase-start post failed');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else if (event.type === 'phase_complete') {
|
|
192
|
+
const phaseMsg = phaseStartMessages.get(event.phase.id);
|
|
193
|
+
if (!phaseMsg)
|
|
194
|
+
return;
|
|
195
|
+
const indicator = event.status === 'done' ? '[x]' : event.status === 'failed' ? '[!]' : '[-]';
|
|
196
|
+
try {
|
|
197
|
+
await phaseMsg.edit({
|
|
198
|
+
content: `${indicator} **${event.phase.title}**`,
|
|
199
|
+
allowedMentions: NO_MENTIONS,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// best-effort
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const phaseOpts = {
|
|
208
|
+
runtime: planCtx.runtime,
|
|
209
|
+
model: planCtx.model,
|
|
210
|
+
projectCwd,
|
|
211
|
+
addDirs: [],
|
|
212
|
+
timeoutMs,
|
|
213
|
+
workspaceCwd: planCtx.workspaceCwd,
|
|
214
|
+
log: planCtx.log,
|
|
215
|
+
maxAuditFixAttempts: planCtx.maxAuditFixAttempts,
|
|
216
|
+
onPlanEvent,
|
|
217
|
+
};
|
|
218
|
+
try {
|
|
219
|
+
const channel = await ctx.client.channels.fetch(ctx.channelId);
|
|
220
|
+
if (channel && 'send' in channel) {
|
|
221
|
+
runChannel = channel;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// best-effort — phase-start posts and completion fall back gracefully
|
|
226
|
+
}
|
|
227
|
+
if (!planCtx.skipCompletionNotify && runChannel) {
|
|
228
|
+
try {
|
|
229
|
+
const sent = await runChannel.send({
|
|
230
|
+
content: `**Plan run started:** \`${runPlanId}\` — starting phase ${prepResult.nextPhase.id}: ${prepResult.nextPhase.title}`,
|
|
231
|
+
allowedMentions: NO_MENTIONS,
|
|
232
|
+
});
|
|
233
|
+
if (sent && typeof sent.edit === 'function') {
|
|
234
|
+
statusMsg = sent;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// best-effort — missing status message is non-fatal
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Edit the status message, honouring throttle. Pass force=true to bypass throttle.
|
|
242
|
+
async function editStatus(content, force = false) {
|
|
243
|
+
if (!statusMsg)
|
|
244
|
+
return;
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
if (!force && now - lastStatusEditAt < PROGRESS_THROTTLE_MS)
|
|
247
|
+
return;
|
|
248
|
+
lastStatusEditAt = now;
|
|
249
|
+
try {
|
|
250
|
+
await statusMsg.edit({ content, allowedMentions: NO_MENTIONS });
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// best-effort
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Compose onProgress with status message edits.
|
|
257
|
+
const wrappedOnProgress = async (msg) => {
|
|
258
|
+
await onProgress(msg);
|
|
259
|
+
await editStatus(msg);
|
|
260
|
+
};
|
|
261
|
+
let phasesRun = 0;
|
|
262
|
+
let stopReason;
|
|
263
|
+
let stopMessage;
|
|
264
|
+
let hitMaxPhases = false;
|
|
265
|
+
for (let i = 0; i < maxPhases; i++) {
|
|
266
|
+
const release = await acquireWriterLock();
|
|
267
|
+
let phaseResult;
|
|
268
|
+
try {
|
|
269
|
+
phaseResult = await runNextPhase(prepResult.phasesFilePath, prepResult.planFilePath, phaseOpts, wrappedOnProgress);
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
release();
|
|
273
|
+
}
|
|
274
|
+
if (phaseResult.result === 'done') {
|
|
275
|
+
phasesRun++;
|
|
276
|
+
// Force-edit on phase completion boundary.
|
|
277
|
+
await editStatus(`**Plan run in progress:** \`${action.planId}\` — phase complete (${phasesRun} done so far)`, true);
|
|
278
|
+
}
|
|
279
|
+
else if (phaseResult.result === 'nothing_to_run') {
|
|
280
|
+
// Force-edit to reflect no more phases.
|
|
281
|
+
await editStatus(`**Plan run finishing:** \`${action.planId}\` — no more phases to run`, true);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
// Any error/stale/corrupt/audit_failed/retry_blocked stops the loop.
|
|
286
|
+
stopReason = phaseResult.result;
|
|
287
|
+
stopMessage = phaseResult.error ?? phaseResult.message ?? phaseResult.result;
|
|
288
|
+
planCtx.log?.warn({ planId: runPlanId, result: phaseResult.result, phasesRun }, 'plan:action:run stopped');
|
|
289
|
+
// Force-edit to reflect stop.
|
|
290
|
+
await editStatus(`**Plan run stopped:** \`${runPlanId}\` — ${stopMessage ?? stopReason}`, true);
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
if (i === maxPhases - 1) {
|
|
294
|
+
hitMaxPhases = true;
|
|
295
|
+
}
|
|
296
|
+
// Yield between phases.
|
|
297
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
298
|
+
}
|
|
299
|
+
planCtx.log?.info({ planId: runPlanId, phasesRun }, 'plan:action:run complete');
|
|
300
|
+
// Auto-close plan if all phases are terminal
|
|
301
|
+
let autoClosed = false;
|
|
302
|
+
let runError;
|
|
303
|
+
try {
|
|
304
|
+
const closeResult = await closePlanIfComplete(prepResult.phasesFilePath, prepResult.planFilePath, planCtx.taskStore, acquireWriterLock, planCtx.log);
|
|
305
|
+
autoClosed = closeResult.closed;
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
runError = err;
|
|
309
|
+
planCtx.log?.error({ err, planId: runPlanId }, 'plan:action:run failed');
|
|
310
|
+
}
|
|
311
|
+
// Build the final outcome content — always, so onRunComplete can use it even when skipCompletionNotify is set.
|
|
312
|
+
const lines = [
|
|
313
|
+
`**Plan run complete:** \`${runPlanId}\``,
|
|
314
|
+
`Phases run: ${phasesRun}`,
|
|
315
|
+
];
|
|
316
|
+
if (hitMaxPhases && !stopReason) {
|
|
317
|
+
lines.push(`Stopped: reached max-phase limit (${maxPhases})`);
|
|
318
|
+
}
|
|
319
|
+
if (stopReason) {
|
|
320
|
+
lines.push(`Stopped: ${stopMessage ?? stopReason}`);
|
|
321
|
+
}
|
|
322
|
+
if (runError) {
|
|
323
|
+
lines.push(`Error: ${runError instanceof Error ? runError.message : String(runError)}`);
|
|
324
|
+
}
|
|
325
|
+
if (autoClosed) {
|
|
326
|
+
lines.push('Plan auto-closed — all phases terminal.');
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const phases = readPhasesFile(prepResult.phasesFilePath, { log: planCtx.log });
|
|
330
|
+
const budget = Math.max(0, 2000 - lines.join('\n').length - 50);
|
|
331
|
+
const summary = buildPostRunSummary(phases, budget);
|
|
332
|
+
if (summary) {
|
|
333
|
+
lines.push(summary);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch (summaryErr) {
|
|
337
|
+
planCtx.log?.error({ err: summaryErr, planId: runPlanId }, 'plan:action:run summary failed');
|
|
338
|
+
}
|
|
339
|
+
const finalContent = lines.join('\n');
|
|
340
|
+
// Edit status message to show final outcome (preserved for backwards compatibility).
|
|
341
|
+
if (!planCtx.skipCompletionNotify) {
|
|
342
|
+
try {
|
|
343
|
+
if (statusMsg) {
|
|
344
|
+
// Edit the existing status message in place.
|
|
345
|
+
try {
|
|
346
|
+
await statusMsg.edit({ content: finalContent, allowedMentions: NO_MENTIONS });
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// best-effort
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Post a standalone completion message as a new chat message.
|
|
353
|
+
if (runChannel) {
|
|
354
|
+
try {
|
|
355
|
+
await runChannel.send({ content: finalContent, allowedMentions: NO_MENTIONS });
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// best-effort
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else if (!statusMsg) {
|
|
362
|
+
// Fall back to sending a new message if we never got runChannel or statusMsg.
|
|
363
|
+
try {
|
|
364
|
+
const channel = await ctx.client.channels.fetch(ctx.channelId);
|
|
365
|
+
if (channel && 'send' in channel) {
|
|
366
|
+
await channel.send({ content: finalContent, allowedMentions: NO_MENTIONS });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// best-effort
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
// best-effort — do not rethrow
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Notify caller (e.g. forge auto-implement) with the final content — best-effort.
|
|
379
|
+
try {
|
|
380
|
+
await planCtx.onRunComplete?.(finalContent);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// best-effort
|
|
384
|
+
}
|
|
385
|
+
})().catch((err) => {
|
|
386
|
+
planCtx.log?.error({ err, planId: runPlanId }, 'plan:action:run failed');
|
|
387
|
+
}).finally(() => {
|
|
388
|
+
removeRunningPlan(runPlanId);
|
|
389
|
+
});
|
|
390
|
+
return { ok: true, summary: `Plan run started for **${action.planId}** — starting phase ${prepResult.nextPhase.id}: ${prepResult.nextPhase.title}` };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// Prompt section
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
export function planActionsPromptSection() {
|
|
398
|
+
return `### Plan Management
|
|
399
|
+
|
|
400
|
+
**planList** — List all plans (optionally filter by status):
|
|
401
|
+
\`\`\`
|
|
402
|
+
<discord-action>{"type":"planList"}</discord-action>
|
|
403
|
+
<discord-action>{"type":"planList","status":"APPROVED"}</discord-action>
|
|
404
|
+
\`\`\`
|
|
405
|
+
- \`status\` (optional): Filter by plan status (DRAFT, REVIEW, APPROVED, IMPLEMENTING, CLOSED).
|
|
406
|
+
|
|
407
|
+
**planShow** — Show plan details:
|
|
408
|
+
\`\`\`
|
|
409
|
+
<discord-action>{"type":"planShow","planId":"plan-042"}</discord-action>
|
|
410
|
+
\`\`\`
|
|
411
|
+
- \`planId\` (required): The plan ID or backing task ID (legacy header IDs are still accepted).
|
|
412
|
+
|
|
413
|
+
**planApprove** — Approve a plan for implementation:
|
|
414
|
+
\`\`\`
|
|
415
|
+
<discord-action>{"type":"planApprove","planId":"plan-042"}</discord-action>
|
|
416
|
+
\`\`\`
|
|
417
|
+
- \`planId\` (required): The plan ID to approve.
|
|
418
|
+
|
|
419
|
+
**planClose** — Close/abandon a plan:
|
|
420
|
+
\`\`\`
|
|
421
|
+
<discord-action>{"type":"planClose","planId":"plan-042"}</discord-action>
|
|
422
|
+
\`\`\`
|
|
423
|
+
- \`planId\` (required): The plan ID to close.
|
|
424
|
+
|
|
425
|
+
**planCreate** — Create a new plan (drafts a plan file and backing task):
|
|
426
|
+
\`\`\`
|
|
427
|
+
<discord-action>{"type":"planCreate","description":"Add retry logic to webhook handler","context":"Optional extra context"}</discord-action>
|
|
428
|
+
\`\`\`
|
|
429
|
+
- \`description\` (required): What the plan is for.
|
|
430
|
+
- \`context\` (optional): Additional context appended to the plan.
|
|
431
|
+
|
|
432
|
+
**planRun** — Execute all remaining phases of a plan (fire-and-forget):
|
|
433
|
+
\`\`\`
|
|
434
|
+
<discord-action>{"type":"planRun","planId":"plan-042"}</discord-action>
|
|
435
|
+
\`\`\`
|
|
436
|
+
- \`planId\` (required): The plan ID to execute.
|
|
437
|
+
- The plan must be in APPROVED or IMPLEMENTING status. Phases run sequentially with the writer lock. On successful completion of all phases, the plan is auto-closed and the backing task is closed.
|
|
438
|
+
|
|
439
|
+
#### Plan Guidelines
|
|
440
|
+
- Use planList to check existing plans before creating duplicates.
|
|
441
|
+
- Plans go through statuses: DRAFT → REVIEW → APPROVED → IMPLEMENTING → CLOSED.
|
|
442
|
+
- Use forgeCreate to draft+audit a plan, or planCreate for a bare plan file without forge auditing.
|
|
443
|
+
- Approving a plan marks its backing task as in_progress.
|
|
444
|
+
- Use planRun to execute approved plans autonomously.`;
|
|
445
|
+
}
|