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,64 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { loadCronTagMapStrict, reloadCronTagMapInPlace } from './tag-map.js';
|
|
4
|
+
vi.mock('node:fs/promises');
|
|
5
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
|
6
|
+
describe('loadCronTagMapStrict', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.resetAllMocks();
|
|
9
|
+
});
|
|
10
|
+
it('parses valid tag map', async () => {
|
|
11
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ monitoring: 'tag-1', daily: 'tag-2' }));
|
|
12
|
+
const result = await loadCronTagMapStrict('/tmp/tags.json');
|
|
13
|
+
expect(result).toEqual({ monitoring: 'tag-1', daily: 'tag-2' });
|
|
14
|
+
});
|
|
15
|
+
it('throws on invalid JSON', async () => {
|
|
16
|
+
mockReadFile.mockResolvedValue('not json {{{');
|
|
17
|
+
await expect(loadCronTagMapStrict('/tmp/tags.json')).rejects.toThrow();
|
|
18
|
+
});
|
|
19
|
+
it('throws on array', async () => {
|
|
20
|
+
mockReadFile.mockResolvedValue(JSON.stringify(['a', 'b']));
|
|
21
|
+
await expect(loadCronTagMapStrict('/tmp/tags.json')).rejects.toThrow('must be a JSON object');
|
|
22
|
+
});
|
|
23
|
+
it('throws on null', async () => {
|
|
24
|
+
mockReadFile.mockResolvedValue(JSON.stringify(null));
|
|
25
|
+
await expect(loadCronTagMapStrict('/tmp/tags.json')).rejects.toThrow('must be a JSON object');
|
|
26
|
+
});
|
|
27
|
+
it('throws on number', async () => {
|
|
28
|
+
mockReadFile.mockResolvedValue(JSON.stringify(42));
|
|
29
|
+
await expect(loadCronTagMapStrict('/tmp/tags.json')).rejects.toThrow('must be a JSON object');
|
|
30
|
+
});
|
|
31
|
+
it('throws on non-string value', async () => {
|
|
32
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ monitoring: 123 }));
|
|
33
|
+
await expect(loadCronTagMapStrict('/tmp/tags.json')).rejects.toThrow('must be a string');
|
|
34
|
+
});
|
|
35
|
+
it('throws on read error', async () => {
|
|
36
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
37
|
+
await expect(loadCronTagMapStrict('/tmp/tags.json')).rejects.toThrow('ENOENT');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('reloadCronTagMapInPlace', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.resetAllMocks();
|
|
43
|
+
});
|
|
44
|
+
it('mutates existing map in-place and returns new count', async () => {
|
|
45
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ a: '1', b: '2', c: '3' }));
|
|
46
|
+
const tagMap = { old: 'val' };
|
|
47
|
+
const count = await reloadCronTagMapInPlace('/tmp/tags.json', tagMap);
|
|
48
|
+
expect(count).toBe(3);
|
|
49
|
+
expect(tagMap).toEqual({ a: '1', b: '2', c: '3' });
|
|
50
|
+
expect(tagMap).not.toHaveProperty('old');
|
|
51
|
+
});
|
|
52
|
+
it('preserves existing map on read failure', async () => {
|
|
53
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
54
|
+
const tagMap = { existing: 'val' };
|
|
55
|
+
await expect(reloadCronTagMapInPlace('/tmp/tags.json', tagMap)).rejects.toThrow('ENOENT');
|
|
56
|
+
expect(tagMap).toEqual({ existing: 'val' });
|
|
57
|
+
});
|
|
58
|
+
it('preserves existing map on validation failure', async () => {
|
|
59
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ good: 'ok', bad: 123 }));
|
|
60
|
+
const tagMap = { existing: 'val' };
|
|
61
|
+
await expect(reloadCronTagMapInPlace('/tmp/tags.json', tagMap)).rejects.toThrow('must be a string');
|
|
62
|
+
expect(tagMap).toEqual({ existing: 'val' });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
const REPO_ROOT = path.join(__dirname, '..');
|
|
8
|
+
const REQUIRED_HEADINGS = [
|
|
9
|
+
'# DiscoClaw Plan',
|
|
10
|
+
'## Metadata',
|
|
11
|
+
'## Use Case',
|
|
12
|
+
'## Scope',
|
|
13
|
+
'## Integration Contract',
|
|
14
|
+
'## Implementation Steps',
|
|
15
|
+
'## Acceptance Tests',
|
|
16
|
+
'## Risk, Permissions, Rollback',
|
|
17
|
+
'## Handoff Prompt (Consumer Agent)',
|
|
18
|
+
'## Changelog',
|
|
19
|
+
];
|
|
20
|
+
const REQUIRED_METADATA_KEYS = [
|
|
21
|
+
'spec_version',
|
|
22
|
+
'plan_id',
|
|
23
|
+
'title',
|
|
24
|
+
'author',
|
|
25
|
+
'source',
|
|
26
|
+
'license',
|
|
27
|
+
'created_at',
|
|
28
|
+
'integration_type',
|
|
29
|
+
'discoclaw_min_version',
|
|
30
|
+
'risk_level',
|
|
31
|
+
];
|
|
32
|
+
function escapeRegExp(text) {
|
|
33
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
34
|
+
}
|
|
35
|
+
function stripQuotes(value) {
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
38
|
+
return trimmed.slice(1, -1).trim();
|
|
39
|
+
}
|
|
40
|
+
return trimmed;
|
|
41
|
+
}
|
|
42
|
+
function parseFrontmatter(content) {
|
|
43
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n/);
|
|
44
|
+
if (!match)
|
|
45
|
+
return {};
|
|
46
|
+
const lines = match[1].split('\n');
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
51
|
+
continue;
|
|
52
|
+
const idx = trimmed.indexOf(':');
|
|
53
|
+
if (idx <= 0)
|
|
54
|
+
continue;
|
|
55
|
+
const key = trimmed.slice(0, idx).trim();
|
|
56
|
+
const value = stripQuotes(trimmed.slice(idx + 1));
|
|
57
|
+
out[key] = value;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function headingCount(content, heading) {
|
|
62
|
+
const re = new RegExp(`^${escapeRegExp(heading)}$`, 'gm');
|
|
63
|
+
return [...content.matchAll(re)].length;
|
|
64
|
+
}
|
|
65
|
+
function getSection(content, heading) {
|
|
66
|
+
const escaped = escapeRegExp(heading);
|
|
67
|
+
const re = new RegExp(`^${escaped}\\n([\\s\\S]*?)(?=^## |\\Z)`, 'm');
|
|
68
|
+
const match = content.match(re);
|
|
69
|
+
return (match?.[1] ?? '').trim();
|
|
70
|
+
}
|
|
71
|
+
async function findPlanFiles(dir) {
|
|
72
|
+
const results = [];
|
|
73
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const full = path.join(dir, entry.name);
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
results.push(...(await findPlanFiles(full)));
|
|
78
|
+
}
|
|
79
|
+
else if (entry.isFile() && entry.name.endsWith('.discoclaw-plan.md')) {
|
|
80
|
+
results.push(full);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
async function loadPlanFiles() {
|
|
86
|
+
const plansDir = path.join(REPO_ROOT, 'plans');
|
|
87
|
+
const planFiles = await findPlanFiles(plansDir);
|
|
88
|
+
return [
|
|
89
|
+
path.join(REPO_ROOT, 'templates', 'plans', 'integration.discoclaw-plan.md'),
|
|
90
|
+
...planFiles,
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
describe('discoclaw-plan format', () => {
|
|
94
|
+
it('enforces frontmatter metadata, required headings, and risk-gated contract rules', async () => {
|
|
95
|
+
const files = await loadPlanFiles();
|
|
96
|
+
expect(files.length).toBeGreaterThan(1);
|
|
97
|
+
for (const filePath of files) {
|
|
98
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
99
|
+
const metadata = parseFrontmatter(content);
|
|
100
|
+
for (const key of REQUIRED_METADATA_KEYS) {
|
|
101
|
+
expect(metadata[key], `${path.relative(REPO_ROOT, filePath)} missing frontmatter key: ${key}`).toBeTruthy();
|
|
102
|
+
}
|
|
103
|
+
expect(metadata.spec_version, `${path.relative(REPO_ROOT, filePath)} invalid spec_version`).toBe('1.0');
|
|
104
|
+
expect(['runtime', 'actions', 'context']).toContain(metadata.integration_type);
|
|
105
|
+
expect(['low', 'medium', 'high']).toContain(metadata.risk_level);
|
|
106
|
+
for (const heading of REQUIRED_HEADINGS) {
|
|
107
|
+
expect(headingCount(content, heading), `${path.relative(REPO_ROOT, filePath)} heading count for ${heading}`).toBe(1);
|
|
108
|
+
}
|
|
109
|
+
const isTemplate = path.relative(REPO_ROOT, filePath) === 'templates/plans/integration.discoclaw-plan.md';
|
|
110
|
+
if (!isTemplate) {
|
|
111
|
+
const expectedPlanId = path.basename(filePath, '.discoclaw-plan.md');
|
|
112
|
+
expect(metadata.plan_id, `${path.relative(REPO_ROOT, filePath)} plan_id should match filename`).toBe(expectedPlanId);
|
|
113
|
+
}
|
|
114
|
+
const integrationSection = getSection(content, '## Integration Contract');
|
|
115
|
+
const acceptanceSection = getSection(content, '## Acceptance Tests');
|
|
116
|
+
const hasIntegrationJson = integrationSection.includes('```json');
|
|
117
|
+
const hasAcceptanceJson = acceptanceSection.includes('```json');
|
|
118
|
+
if (metadata.risk_level === 'low') {
|
|
119
|
+
if (!hasIntegrationJson) {
|
|
120
|
+
expect(integrationSection).toMatch(/Files to add:/);
|
|
121
|
+
expect(integrationSection).toMatch(/Files to modify:/);
|
|
122
|
+
expect(integrationSection).toMatch(/Environment changes:/);
|
|
123
|
+
expect(integrationSection).toMatch(/Runtime behavior changes:/);
|
|
124
|
+
expect(integrationSection).toMatch(/Out of scope:/);
|
|
125
|
+
}
|
|
126
|
+
if (!hasAcceptanceJson) {
|
|
127
|
+
expect(acceptanceSection).toMatch(/Scenarios:/);
|
|
128
|
+
expect(acceptanceSection).toMatch(/Required checks:/);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
expect(hasIntegrationJson, `${path.relative(REPO_ROOT, filePath)} medium/high plan missing integration JSON`).toBe(true);
|
|
133
|
+
expect(hasAcceptanceJson, `${path.relative(REPO_ROOT, filePath)} medium/high plan missing acceptance JSON`).toBe(true);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
const REPO_ROOT = path.join(__dirname, '..');
|
|
8
|
+
const REQUIRED_HEADINGS = [
|
|
9
|
+
'# DiscoClaw Recipe',
|
|
10
|
+
'## Metadata',
|
|
11
|
+
'## Use Case',
|
|
12
|
+
'## Scope',
|
|
13
|
+
'## Integration Contract',
|
|
14
|
+
'## Implementation Steps',
|
|
15
|
+
'## Acceptance Tests',
|
|
16
|
+
'## Risk, Permissions, Rollback',
|
|
17
|
+
'## Handoff Prompt (Consumer Agent)',
|
|
18
|
+
'## Changelog',
|
|
19
|
+
];
|
|
20
|
+
const REQUIRED_METADATA_KEYS = [
|
|
21
|
+
'spec_version',
|
|
22
|
+
'plan_id',
|
|
23
|
+
'title',
|
|
24
|
+
'author',
|
|
25
|
+
'source',
|
|
26
|
+
'license',
|
|
27
|
+
'created_at',
|
|
28
|
+
'integration_type',
|
|
29
|
+
'discoclaw_min_version',
|
|
30
|
+
'risk_level',
|
|
31
|
+
];
|
|
32
|
+
function escapeRegExp(text) {
|
|
33
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
34
|
+
}
|
|
35
|
+
function stripQuotes(value) {
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
38
|
+
return trimmed.slice(1, -1).trim();
|
|
39
|
+
}
|
|
40
|
+
return trimmed;
|
|
41
|
+
}
|
|
42
|
+
function parseFrontmatter(content) {
|
|
43
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n/);
|
|
44
|
+
if (!match)
|
|
45
|
+
return {};
|
|
46
|
+
const lines = match[1].split('\n');
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
51
|
+
continue;
|
|
52
|
+
const idx = trimmed.indexOf(':');
|
|
53
|
+
if (idx <= 0)
|
|
54
|
+
continue;
|
|
55
|
+
const key = trimmed.slice(0, idx).trim();
|
|
56
|
+
const value = stripQuotes(trimmed.slice(idx + 1));
|
|
57
|
+
out[key] = value;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function headingCount(content, heading) {
|
|
62
|
+
const re = new RegExp(`^${escapeRegExp(heading)}$`, 'gm');
|
|
63
|
+
return [...content.matchAll(re)].length;
|
|
64
|
+
}
|
|
65
|
+
function getSection(content, heading) {
|
|
66
|
+
const escaped = escapeRegExp(heading);
|
|
67
|
+
const re = new RegExp(`^${escaped}\\n([\\s\\S]*?)(?=^## |\\Z)`, 'm');
|
|
68
|
+
const match = content.match(re);
|
|
69
|
+
return (match?.[1] ?? '').trim();
|
|
70
|
+
}
|
|
71
|
+
async function findRecipeFiles(dir) {
|
|
72
|
+
const results = [];
|
|
73
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const full = path.join(dir, entry.name);
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
results.push(...(await findRecipeFiles(full)));
|
|
78
|
+
}
|
|
79
|
+
else if (entry.isFile() && entry.name.endsWith('.discoclaw-recipe.md')) {
|
|
80
|
+
results.push(full);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
async function loadRecipeFiles() {
|
|
86
|
+
const recipesDir = path.join(REPO_ROOT, 'recipes');
|
|
87
|
+
const recipeFiles = await findRecipeFiles(recipesDir);
|
|
88
|
+
return [
|
|
89
|
+
path.join(REPO_ROOT, 'templates', 'recipes', 'integration.discoclaw-recipe.md'),
|
|
90
|
+
...recipeFiles,
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
describe('discoclaw-recipe format', () => {
|
|
94
|
+
it('enforces frontmatter metadata, required headings, and risk-gated contract rules', async () => {
|
|
95
|
+
const files = await loadRecipeFiles();
|
|
96
|
+
expect(files.length).toBeGreaterThan(1);
|
|
97
|
+
for (const filePath of files) {
|
|
98
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
99
|
+
const metadata = parseFrontmatter(content);
|
|
100
|
+
for (const key of REQUIRED_METADATA_KEYS) {
|
|
101
|
+
expect(metadata[key], `${path.relative(REPO_ROOT, filePath)} missing frontmatter key: ${key}`).toBeTruthy();
|
|
102
|
+
}
|
|
103
|
+
expect(metadata.spec_version, `${path.relative(REPO_ROOT, filePath)} invalid spec_version`).toBe('1.0');
|
|
104
|
+
expect(['runtime', 'actions', 'context']).toContain(metadata.integration_type);
|
|
105
|
+
expect(['low', 'medium', 'high']).toContain(metadata.risk_level);
|
|
106
|
+
for (const heading of REQUIRED_HEADINGS) {
|
|
107
|
+
expect(headingCount(content, heading), `${path.relative(REPO_ROOT, filePath)} heading count for ${heading}`).toBe(1);
|
|
108
|
+
}
|
|
109
|
+
const isTemplate = path.relative(REPO_ROOT, filePath) === 'templates/recipes/integration.discoclaw-recipe.md';
|
|
110
|
+
if (!isTemplate) {
|
|
111
|
+
const expectedPlanId = path.basename(filePath, '.discoclaw-recipe.md');
|
|
112
|
+
expect(metadata.plan_id, `${path.relative(REPO_ROOT, filePath)} plan_id should match filename`).toBe(expectedPlanId);
|
|
113
|
+
}
|
|
114
|
+
const integrationSection = getSection(content, '## Integration Contract');
|
|
115
|
+
const acceptanceSection = getSection(content, '## Acceptance Tests');
|
|
116
|
+
const hasIntegrationJson = integrationSection.includes('```json');
|
|
117
|
+
const hasAcceptanceJson = acceptanceSection.includes('```json');
|
|
118
|
+
if (metadata.risk_level === 'low') {
|
|
119
|
+
if (!hasIntegrationJson) {
|
|
120
|
+
expect(integrationSection).toMatch(/Files to add:/);
|
|
121
|
+
expect(integrationSection).toMatch(/Files to modify:/);
|
|
122
|
+
expect(integrationSection).toMatch(/Environment changes:/);
|
|
123
|
+
expect(integrationSection).toMatch(/Runtime behavior changes:/);
|
|
124
|
+
expect(integrationSection).toMatch(/Out of scope:/);
|
|
125
|
+
}
|
|
126
|
+
if (!hasAcceptanceJson) {
|
|
127
|
+
expect(acceptanceSection).toMatch(/Scenarios:/);
|
|
128
|
+
expect(acceptanceSection).toMatch(/Required checks:/);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
expect(hasIntegrationJson, `${path.relative(REPO_ROOT, filePath)} medium/high plan missing integration JSON`).toBe(true);
|
|
133
|
+
expect(hasAcceptanceJson, `${path.relative(REPO_ROOT, filePath)} medium/high plan missing acceptance JSON`).toBe(true);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// In-memory registry mapping Discord message IDs to AbortController instances.
|
|
2
|
+
// Supports a short-lived cooldown window after disposal so that stale 🛑 taps
|
|
3
|
+
// on recently-finished messages are silently consumed rather than forwarded.
|
|
4
|
+
const COOLDOWN_MS = 15_000;
|
|
5
|
+
const active = new Map();
|
|
6
|
+
const cooldown = new Set();
|
|
7
|
+
/**
|
|
8
|
+
* Register an AbortController for a message that is about to start streaming.
|
|
9
|
+
*
|
|
10
|
+
* Returns:
|
|
11
|
+
* - `signal` — pass to RuntimeInvokeParams.signal
|
|
12
|
+
* - `dispose` — call when the stream ends; moves the entry into a cooldown
|
|
13
|
+
* set so that a belated 🛑 tap is silently consumed for ~15 s.
|
|
14
|
+
*/
|
|
15
|
+
export function registerAbort(messageId) {
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
active.set(messageId, controller);
|
|
18
|
+
function dispose() {
|
|
19
|
+
active.delete(messageId);
|
|
20
|
+
cooldown.add(messageId);
|
|
21
|
+
setTimeout(() => cooldown.delete(messageId), COOLDOWN_MS);
|
|
22
|
+
}
|
|
23
|
+
return { signal: controller.signal, dispose };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Attempt to abort the stream for a message.
|
|
27
|
+
*
|
|
28
|
+
* Returns:
|
|
29
|
+
* - `true` and fires `abort()` if the message is actively streaming.
|
|
30
|
+
* - `true` (no-op) if the message is in the cooldown window (already finished).
|
|
31
|
+
* - `false` if the message ID is unknown — caller should let the reaction through.
|
|
32
|
+
*/
|
|
33
|
+
export function tryAbort(messageId) {
|
|
34
|
+
const controller = active.get(messageId);
|
|
35
|
+
if (controller) {
|
|
36
|
+
controller.abort();
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (cooldown.has(messageId)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Returns true if the message is actively streaming (abort not yet fired).
|
|
46
|
+
* Use this to distinguish an active abort from a cooldown no-op before calling tryAbort.
|
|
47
|
+
*/
|
|
48
|
+
export function isActivelyStreaming(messageId) {
|
|
49
|
+
return active.has(messageId);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Abort all active streams.
|
|
53
|
+
*
|
|
54
|
+
* Returns the number of streams that were actively streaming and aborted.
|
|
55
|
+
* Does not modify the active/cooldown sets — each stream's `dispose()` call
|
|
56
|
+
* (in its finally block) handles cleanup and cooldown the same way as a
|
|
57
|
+
* single-message abort via `tryAbort`.
|
|
58
|
+
*/
|
|
59
|
+
export function tryAbortAll() {
|
|
60
|
+
const controllers = [...active.values()];
|
|
61
|
+
for (const controller of controllers) {
|
|
62
|
+
controller.abort();
|
|
63
|
+
}
|
|
64
|
+
return controllers.length;
|
|
65
|
+
}
|
|
66
|
+
/** Clear all state. Only for use in tests. */
|
|
67
|
+
export function _resetForTest() {
|
|
68
|
+
active.clear();
|
|
69
|
+
cooldown.clear();
|
|
70
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Action types that return data Claude likely wants to process (read-only queries).
|
|
2
|
+
// When any of these succeed, the auto-follow-up loop re-invokes Claude with the results.
|
|
3
|
+
export const QUERY_ACTION_TYPES = new Set([
|
|
4
|
+
// Channels
|
|
5
|
+
'channelList',
|
|
6
|
+
'channelInfo',
|
|
7
|
+
'threadListArchived',
|
|
8
|
+
'forumTagList',
|
|
9
|
+
// Messaging
|
|
10
|
+
'readMessages',
|
|
11
|
+
'fetchMessage',
|
|
12
|
+
'listPins',
|
|
13
|
+
// Guild
|
|
14
|
+
'memberInfo',
|
|
15
|
+
'roleInfo',
|
|
16
|
+
'searchMessages',
|
|
17
|
+
'eventList',
|
|
18
|
+
// Tasks
|
|
19
|
+
'taskList',
|
|
20
|
+
'taskShow',
|
|
21
|
+
// Crons
|
|
22
|
+
'cronList',
|
|
23
|
+
'cronShow',
|
|
24
|
+
// Plans
|
|
25
|
+
'planList',
|
|
26
|
+
'planShow',
|
|
27
|
+
// Memory
|
|
28
|
+
'memoryShow',
|
|
29
|
+
// Config
|
|
30
|
+
'modelShow',
|
|
31
|
+
// Forge
|
|
32
|
+
'forgeStatus',
|
|
33
|
+
]);
|
|
34
|
+
export function hasQueryAction(actionTypes) {
|
|
35
|
+
return actionTypes.some((t) => QUERY_ACTION_TYPES.has(t));
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ChannelType } from 'discord.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a channel reference to a text-based guild channel.
|
|
4
|
+
* Accepts:
|
|
5
|
+
* - A numeric channel ID string (e.g. "123456789")
|
|
6
|
+
* - A channel name with or without # prefix (e.g. "#general" or "general")
|
|
7
|
+
*/
|
|
8
|
+
export function resolveChannel(guild, ref) {
|
|
9
|
+
const cleaned = ref.replace(/^#/, '').trim();
|
|
10
|
+
if (!cleaned)
|
|
11
|
+
return undefined;
|
|
12
|
+
// Try by ID first (numeric strings).
|
|
13
|
+
const byId = guild.channels.cache.get(cleaned);
|
|
14
|
+
if (byId) {
|
|
15
|
+
// If the ID matched but isn't text-based, don't fall through to name lookup —
|
|
16
|
+
// the caller explicitly referenced this channel by ID.
|
|
17
|
+
return isTextBased(byId) ? byId : undefined;
|
|
18
|
+
}
|
|
19
|
+
// Try by name (case-insensitive).
|
|
20
|
+
const byName = guild.channels.cache.find((ch) => isTextBased(ch) && ch.name.toLowerCase() === cleaned.toLowerCase());
|
|
21
|
+
return byName;
|
|
22
|
+
}
|
|
23
|
+
function isTextBased(ch) {
|
|
24
|
+
// We only want channels that are meaningfully sendable via `.send()`.
|
|
25
|
+
return (ch.type === ChannelType.GuildText ||
|
|
26
|
+
ch.type === ChannelType.GuildAnnouncement ||
|
|
27
|
+
ch.type === ChannelType.PublicThread ||
|
|
28
|
+
ch.type === ChannelType.PrivateThread ||
|
|
29
|
+
ch.type === ChannelType.AnnouncementThread);
|
|
30
|
+
}
|
|
31
|
+
/** Human-readable channel type name for error messages. */
|
|
32
|
+
export function describeChannelType(ch) {
|
|
33
|
+
switch (ch?.type) {
|
|
34
|
+
case ChannelType.GuildForum: return 'forum';
|
|
35
|
+
case ChannelType.GuildMedia: return 'media';
|
|
36
|
+
case ChannelType.GuildVoice: return 'voice';
|
|
37
|
+
case ChannelType.GuildStageVoice: return 'stage';
|
|
38
|
+
case ChannelType.GuildCategory: return 'category';
|
|
39
|
+
default: return 'unsupported';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Look up a channel by ref (ID or name) and return it raw, without filtering
|
|
44
|
+
* by type. Returns undefined only if the channel truly doesn't exist.
|
|
45
|
+
*/
|
|
46
|
+
export function findChannelRaw(guild, ref) {
|
|
47
|
+
const cleaned = ref.replace(/^#/, '').trim();
|
|
48
|
+
if (!cleaned)
|
|
49
|
+
return undefined;
|
|
50
|
+
const byId = guild.channels.cache.get(cleaned);
|
|
51
|
+
if (byId)
|
|
52
|
+
return byId;
|
|
53
|
+
return guild.channels.cache.find((ch) => ch.name.toLowerCase() === cleaned.toLowerCase());
|
|
54
|
+
}
|
|
55
|
+
/** Format a timestamp for display. */
|
|
56
|
+
export function fmtTime(date) {
|
|
57
|
+
return date.toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
|
58
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import { resolveChannel, findChannelRaw, describeChannelType } from './action-utils.js';
|
|
4
|
+
function makeGuild(channels) {
|
|
5
|
+
return {
|
|
6
|
+
channels: {
|
|
7
|
+
cache: {
|
|
8
|
+
get: vi.fn((id) => channels.find((c) => String(c.id) === String(id))),
|
|
9
|
+
find: vi.fn((pred) => channels.find((c) => pred(c))),
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
describe('resolveChannel', () => {
|
|
15
|
+
it('does not treat voice channels as sendable targets', () => {
|
|
16
|
+
const voice = { id: '123', name: 'voice', type: ChannelType.GuildVoice };
|
|
17
|
+
const guild = makeGuild([voice]);
|
|
18
|
+
const out = resolveChannel(guild, '123');
|
|
19
|
+
expect(out).toBeUndefined();
|
|
20
|
+
expect(guild.channels.cache.find).not.toHaveBeenCalled();
|
|
21
|
+
});
|
|
22
|
+
it('does not resolve forum channels by name', () => {
|
|
23
|
+
const forum = { id: 'f1', name: 'general', type: ChannelType.GuildForum };
|
|
24
|
+
const text = { id: 't1', name: 'other', type: ChannelType.GuildText, send: vi.fn() };
|
|
25
|
+
const guild = makeGuild([forum, text]);
|
|
26
|
+
const out = resolveChannel(guild, 'general');
|
|
27
|
+
expect(out).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('findChannelRaw', () => {
|
|
31
|
+
it('finds a forum channel by ID', () => {
|
|
32
|
+
const forum = { id: '123', name: 'beads', type: ChannelType.GuildForum };
|
|
33
|
+
const guild = makeGuild([forum]);
|
|
34
|
+
const out = findChannelRaw(guild, '123');
|
|
35
|
+
expect(out).toBe(forum);
|
|
36
|
+
});
|
|
37
|
+
it('finds a forum channel by name', () => {
|
|
38
|
+
const forum = { id: '123', name: 'beads', type: ChannelType.GuildForum };
|
|
39
|
+
const guild = makeGuild([forum]);
|
|
40
|
+
const out = findChannelRaw(guild, 'beads');
|
|
41
|
+
expect(out).toBe(forum);
|
|
42
|
+
});
|
|
43
|
+
it('returns undefined for nonexistent channel', () => {
|
|
44
|
+
const guild = makeGuild([]);
|
|
45
|
+
expect(findChannelRaw(guild, '999')).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('describeChannelType', () => {
|
|
49
|
+
it('returns "forum" for forum channels', () => {
|
|
50
|
+
expect(describeChannelType({ type: ChannelType.GuildForum })).toBe('forum');
|
|
51
|
+
});
|
|
52
|
+
it('returns "voice" for voice channels', () => {
|
|
53
|
+
expect(describeChannelType({ type: ChannelType.GuildVoice })).toBe('voice');
|
|
54
|
+
});
|
|
55
|
+
it('returns "unsupported" for unknown types', () => {
|
|
56
|
+
expect(describeChannelType({ type: 999 })).toBe('unsupported');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { TASK_ACTION_TYPES as BEAD_ACTION_TYPES, executeTaskAction as executeBeadAction, taskActionsPromptSection as beadActionsPromptSection, } from './actions-tasks.js';
|