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,135 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = path.dirname(__filename);
|
|
6
|
+
/** Files scaffolded from templates/workspace/ into the workspace on first run. */
|
|
7
|
+
const TEMPLATE_FILES = [
|
|
8
|
+
'BOOTSTRAP.md',
|
|
9
|
+
'SOUL.md',
|
|
10
|
+
'IDENTITY.md',
|
|
11
|
+
'USER.md',
|
|
12
|
+
'AGENTS.md',
|
|
13
|
+
'TOOLS.md',
|
|
14
|
+
'HEARTBEAT.md',
|
|
15
|
+
'MEMORY.md',
|
|
16
|
+
];
|
|
17
|
+
/** Marker text present in the template IDENTITY.md but removed during onboarding. */
|
|
18
|
+
const IDENTITY_TEMPLATE_MARKER = '*(pick something you like)*';
|
|
19
|
+
/**
|
|
20
|
+
* Onboarding is considered complete when IDENTITY.md exists and no longer
|
|
21
|
+
* contains the template placeholder text. USER.md is NOT checked because
|
|
22
|
+
* it's designed to be incrementally filled in — existing installs may have
|
|
23
|
+
* a populated USER.md that still contains the template intro line, and
|
|
24
|
+
* flagging that as "not onboarded" would force re-onboarding and overwrite
|
|
25
|
+
* user-authored content.
|
|
26
|
+
*
|
|
27
|
+
* Once complete, BOOTSTRAP.md is no longer scaffolded and any stale copy is auto-deleted.
|
|
28
|
+
*/
|
|
29
|
+
export async function isOnboardingComplete(workspaceCwd) {
|
|
30
|
+
const identityPath = path.join(workspaceCwd, 'IDENTITY.md');
|
|
31
|
+
try {
|
|
32
|
+
const content = await fs.readFile(identityPath, 'utf-8');
|
|
33
|
+
if (content.includes(IDENTITY_TEMPLATE_MARKER))
|
|
34
|
+
return false;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Ensure workspace PA template files exist. Copies any missing files from
|
|
43
|
+
* `templates/workspace/` to the workspace directory. Never overwrites existing files.
|
|
44
|
+
*
|
|
45
|
+
* When onboarding is complete (IDENTITY.md has real content), BOOTSTRAP.md is
|
|
46
|
+
* excluded from scaffolding and any existing copy is auto-deleted to prevent
|
|
47
|
+
* wasted context tokens and confusing first-run instructions.
|
|
48
|
+
*
|
|
49
|
+
* @returns list of files that were newly created (empty if workspace was already set up)
|
|
50
|
+
*/
|
|
51
|
+
export async function ensureWorkspaceBootstrapFiles(workspaceCwd, log) {
|
|
52
|
+
const templatesDir = path.join(__dirname, '..', 'templates', 'workspace');
|
|
53
|
+
await fs.mkdir(workspaceCwd, { recursive: true });
|
|
54
|
+
const forceBootstrap = process.env.DISCOCLAW_FORCE_BOOTSTRAP === '1';
|
|
55
|
+
if (forceBootstrap) {
|
|
56
|
+
log?.warn({ workspaceCwd }, 'workspace:bootstrap DISCOCLAW_FORCE_BOOTSTRAP=1 is active — BOOTSTRAP.md will be forcibly (re)created from template. ' +
|
|
57
|
+
'This env var is for one-shot use; unset it after this restart.');
|
|
58
|
+
}
|
|
59
|
+
const onboarded = await isOnboardingComplete(workspaceCwd);
|
|
60
|
+
const created = [];
|
|
61
|
+
for (const file of TEMPLATE_FILES) {
|
|
62
|
+
// Skip BOOTSTRAP.md entirely once onboarding is complete.
|
|
63
|
+
if (file === 'BOOTSTRAP.md' && onboarded)
|
|
64
|
+
continue;
|
|
65
|
+
const dest = path.join(workspaceCwd, file);
|
|
66
|
+
try {
|
|
67
|
+
await fs.access(dest);
|
|
68
|
+
// File already exists — don't overwrite.
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
const src = path.join(templatesDir, file);
|
|
72
|
+
await fs.copyFile(src, dest);
|
|
73
|
+
// Inject the system timezone into USER.md for new workspaces.
|
|
74
|
+
if (file === 'USER.md') {
|
|
75
|
+
const content = await fs.readFile(dest, 'utf-8');
|
|
76
|
+
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
77
|
+
await fs.writeFile(dest, content.replace('- **Timezone:**', `- **Timezone:** ${systemTz}`), 'utf-8');
|
|
78
|
+
}
|
|
79
|
+
created.push(file);
|
|
80
|
+
log?.info({ file, workspaceCwd }, 'workspace:bootstrap recreated missing file');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// BOOTSTRAP.md-specific post-loop logic.
|
|
84
|
+
if (forceBootstrap) {
|
|
85
|
+
// Force path: delete existing BOOTSTRAP.md (if any), then copy from template.
|
|
86
|
+
const bootstrapDest = path.join(workspaceCwd, 'BOOTSTRAP.md');
|
|
87
|
+
try {
|
|
88
|
+
await fs.unlink(bootstrapDest);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (err.code !== 'ENOENT')
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
const templateBootstrapPath = path.join(templatesDir, 'BOOTSTRAP.md');
|
|
95
|
+
await fs.copyFile(templateBootstrapPath, bootstrapDest);
|
|
96
|
+
log?.info({ workspaceCwd }, 'workspace:bootstrap BOOTSTRAP.md force-created from template');
|
|
97
|
+
}
|
|
98
|
+
else if (onboarded) {
|
|
99
|
+
// Normal onboarded path: warn about stale file, then auto-delete.
|
|
100
|
+
const bootstrapPath = path.join(workspaceCwd, 'BOOTSTRAP.md');
|
|
101
|
+
let bootstrapExists = false;
|
|
102
|
+
try {
|
|
103
|
+
await fs.access(bootstrapPath);
|
|
104
|
+
bootstrapExists = true;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// File doesn't exist — nothing to clean up.
|
|
108
|
+
}
|
|
109
|
+
if (bootstrapExists) {
|
|
110
|
+
log?.warn({ workspaceCwd }, 'workspace:bootstrap stale BOOTSTRAP.md found in onboarded workspace — auto-deleting. ' +
|
|
111
|
+
'If this recurs on every restart, check for external automation or macOS app conflicts ' +
|
|
112
|
+
'that may be re-creating the file.');
|
|
113
|
+
try {
|
|
114
|
+
await fs.unlink(bootstrapPath);
|
|
115
|
+
log?.info({ workspaceCwd }, 'workspace:bootstrap auto-deleted stale BOOTSTRAP.md');
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.code === 'ENOENT') {
|
|
119
|
+
// Race: another process deleted it between access and unlink.
|
|
120
|
+
log?.info({ workspaceCwd }, 'workspace:bootstrap stale BOOTSTRAP.md already deleted by another process');
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
log?.warn({ workspaceCwd, error: err.message }, 'workspace:bootstrap failed to auto-delete stale BOOTSTRAP.md — check file permissions');
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Ensure the daily log directory exists for file-based memory.
|
|
130
|
+
await fs.mkdir(path.join(workspaceCwd, 'memory'), { recursive: true });
|
|
131
|
+
if (created.length > 0) {
|
|
132
|
+
log?.info({ created, workspaceCwd }, 'workspace:bootstrap scaffolded PA files');
|
|
133
|
+
}
|
|
134
|
+
return created;
|
|
135
|
+
}
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
// Spy on unlink for deletion verification and error injection in tests.
|
|
7
|
+
// We spy directly on the imported `fs` default object rather than using vi.mock,
|
|
8
|
+
// because vi.mock with ESM default imports is fragile.
|
|
9
|
+
import { ensureWorkspaceBootstrapFiles, isOnboardingComplete } from './workspace-bootstrap.js';
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
const ALL_TEMPLATE_FILES = [
|
|
13
|
+
'BOOTSTRAP.md',
|
|
14
|
+
'SOUL.md',
|
|
15
|
+
'IDENTITY.md',
|
|
16
|
+
'USER.md',
|
|
17
|
+
'AGENTS.md',
|
|
18
|
+
'TOOLS.md',
|
|
19
|
+
'HEARTBEAT.md',
|
|
20
|
+
'MEMORY.md',
|
|
21
|
+
];
|
|
22
|
+
/** Real IDENTITY.md content that passes onboarding check (no template marker). */
|
|
23
|
+
const REAL_IDENTITY = '# Identity\n\nName: Claw\nVibe: Snarky but helpful\nEmoji: \u{1F980}\nCreature: A sentient crustacean AI';
|
|
24
|
+
/** Real USER.md content that passes onboarding check (no template marker). */
|
|
25
|
+
const REAL_USER = '# USER.md - About Your Human\n\n- **Name:** Test User\n- **What to call them:** Test\n';
|
|
26
|
+
/** Helper to write both IDENTITY.md and USER.md with real content. */
|
|
27
|
+
async function writeOnboardedFiles(workspace) {
|
|
28
|
+
await fs.writeFile(path.join(workspace, 'IDENTITY.md'), REAL_IDENTITY, 'utf-8');
|
|
29
|
+
await fs.writeFile(path.join(workspace, 'USER.md'), REAL_USER, 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
/** Helper to create a mock logger with info + warn. */
|
|
32
|
+
function mockLog() {
|
|
33
|
+
return { info: vi.fn(), warn: vi.fn() };
|
|
34
|
+
}
|
|
35
|
+
describe('isOnboardingComplete', () => {
|
|
36
|
+
const dirs = [];
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
for (const d of dirs)
|
|
39
|
+
await fs.rm(d, { recursive: true, force: true });
|
|
40
|
+
dirs.length = 0;
|
|
41
|
+
});
|
|
42
|
+
it('returns false when IDENTITY.md does not exist', async () => {
|
|
43
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-onboard-'));
|
|
44
|
+
dirs.push(workspace);
|
|
45
|
+
expect(await isOnboardingComplete(workspace)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
it('returns false when IDENTITY.md still contains template placeholder', async () => {
|
|
48
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-onboard-'));
|
|
49
|
+
dirs.push(workspace);
|
|
50
|
+
await fs.writeFile(path.join(workspace, 'IDENTITY.md'), '# IDENTITY.md - Who Am I?\n\n- **Name:**\n *(pick something you like)*\n- **Creature:**\n', 'utf-8');
|
|
51
|
+
expect(await isOnboardingComplete(workspace)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it('returns false when IDENTITY.md is the untouched scaffolded template', async () => {
|
|
54
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-onboard-'));
|
|
55
|
+
dirs.push(workspace);
|
|
56
|
+
// Scaffold files — this copies the real template IDENTITY.md.
|
|
57
|
+
await ensureWorkspaceBootstrapFiles(workspace);
|
|
58
|
+
expect(await isOnboardingComplete(workspace)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
it('returns true when both IDENTITY.md and USER.md have real content', async () => {
|
|
61
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-onboard-'));
|
|
62
|
+
dirs.push(workspace);
|
|
63
|
+
await writeOnboardedFiles(workspace);
|
|
64
|
+
expect(await isOnboardingComplete(workspace)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
it('returns true when IDENTITY.md is real but USER.md is missing (only IDENTITY checked)', async () => {
|
|
67
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-onboard-'));
|
|
68
|
+
dirs.push(workspace);
|
|
69
|
+
await fs.writeFile(path.join(workspace, 'IDENTITY.md'), REAL_IDENTITY, 'utf-8');
|
|
70
|
+
expect(await isOnboardingComplete(workspace)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
it('returns true when IDENTITY.md is real but USER.md is still template (only IDENTITY checked)', async () => {
|
|
73
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-onboard-'));
|
|
74
|
+
dirs.push(workspace);
|
|
75
|
+
await fs.writeFile(path.join(workspace, 'IDENTITY.md'), REAL_IDENTITY, 'utf-8');
|
|
76
|
+
// Copy the template USER.md which contains the marker text
|
|
77
|
+
const templateUser = await fs.readFile(path.join(__dirname, '..', 'templates', 'workspace', 'USER.md'), 'utf-8');
|
|
78
|
+
await fs.writeFile(path.join(workspace, 'USER.md'), templateUser, 'utf-8');
|
|
79
|
+
expect(await isOnboardingComplete(workspace)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('ensureWorkspaceBootstrapFiles', () => {
|
|
83
|
+
const dirs = [];
|
|
84
|
+
afterEach(async () => {
|
|
85
|
+
vi.unstubAllEnvs();
|
|
86
|
+
for (const d of dirs) {
|
|
87
|
+
await fs.rm(d, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
dirs.length = 0;
|
|
90
|
+
});
|
|
91
|
+
it('scaffolds all template files into an empty workspace (fresh onboarding)', async () => {
|
|
92
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
93
|
+
dirs.push(workspace);
|
|
94
|
+
const created = await ensureWorkspaceBootstrapFiles(workspace);
|
|
95
|
+
expect(created.sort()).toEqual([...ALL_TEMPLATE_FILES].sort());
|
|
96
|
+
for (const file of ALL_TEMPLATE_FILES) {
|
|
97
|
+
const content = await fs.readFile(path.join(workspace, file), 'utf-8');
|
|
98
|
+
expect(content.length).toBeGreaterThan(0);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
it('does not overwrite existing files', async () => {
|
|
102
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
103
|
+
dirs.push(workspace);
|
|
104
|
+
// Pre-populate SOUL.md with custom content.
|
|
105
|
+
const customContent = '# My custom soul';
|
|
106
|
+
await fs.writeFile(path.join(workspace, 'SOUL.md'), customContent, 'utf-8');
|
|
107
|
+
const created = await ensureWorkspaceBootstrapFiles(workspace);
|
|
108
|
+
// SOUL.md should NOT be in the created list.
|
|
109
|
+
expect(created).not.toContain('SOUL.md');
|
|
110
|
+
// Custom content should be preserved.
|
|
111
|
+
const soul = await fs.readFile(path.join(workspace, 'SOUL.md'), 'utf-8');
|
|
112
|
+
expect(soul).toBe(customContent);
|
|
113
|
+
// Other files should be scaffolded.
|
|
114
|
+
expect(created).toContain('BOOTSTRAP.md');
|
|
115
|
+
expect(created).toContain('IDENTITY.md');
|
|
116
|
+
});
|
|
117
|
+
it('creates workspace directory if it does not exist', async () => {
|
|
118
|
+
const parent = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
119
|
+
dirs.push(parent);
|
|
120
|
+
const workspace = path.join(parent, 'nested', 'workspace');
|
|
121
|
+
const created = await ensureWorkspaceBootstrapFiles(workspace);
|
|
122
|
+
expect(created.length).toBe(ALL_TEMPLATE_FILES.length);
|
|
123
|
+
const stat = await fs.stat(workspace);
|
|
124
|
+
expect(stat.isDirectory()).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
it('creates memory/ directory for daily logs', async () => {
|
|
127
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
128
|
+
dirs.push(workspace);
|
|
129
|
+
await ensureWorkspaceBootstrapFiles(workspace);
|
|
130
|
+
const stat = await fs.stat(path.join(workspace, 'memory'));
|
|
131
|
+
expect(stat.isDirectory()).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
it('returns empty array when all files already exist', async () => {
|
|
134
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
135
|
+
dirs.push(workspace);
|
|
136
|
+
// First run — scaffolds everything.
|
|
137
|
+
await ensureWorkspaceBootstrapFiles(workspace);
|
|
138
|
+
// Second run — nothing to do.
|
|
139
|
+
const created = await ensureWorkspaceBootstrapFiles(workspace);
|
|
140
|
+
expect(created).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
it('skips BOOTSTRAP.md scaffolding when onboarding is complete', async () => {
|
|
143
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
144
|
+
dirs.push(workspace);
|
|
145
|
+
// Simulate completed onboarding: both files have real content.
|
|
146
|
+
await writeOnboardedFiles(workspace);
|
|
147
|
+
const created = await ensureWorkspaceBootstrapFiles(workspace);
|
|
148
|
+
expect(created).not.toContain('BOOTSTRAP.md');
|
|
149
|
+
// BOOTSTRAP.md should not exist on disk.
|
|
150
|
+
await expect(fs.access(path.join(workspace, 'BOOTSTRAP.md'))).rejects.toThrow();
|
|
151
|
+
// Other files should still be scaffolded.
|
|
152
|
+
expect(created).toContain('SOUL.md');
|
|
153
|
+
});
|
|
154
|
+
it('auto-deletes stale BOOTSTRAP.md when onboarding is complete', async () => {
|
|
155
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
156
|
+
dirs.push(workspace);
|
|
157
|
+
// First run — scaffolds everything including BOOTSTRAP.md.
|
|
158
|
+
await ensureWorkspaceBootstrapFiles(workspace);
|
|
159
|
+
expect(await fs.access(path.join(workspace, 'BOOTSTRAP.md')).then(() => true)).toBe(true);
|
|
160
|
+
// Simulate completed onboarding: write real content to both files.
|
|
161
|
+
await writeOnboardedFiles(workspace);
|
|
162
|
+
// Second run — should auto-delete BOOTSTRAP.md.
|
|
163
|
+
const log = mockLog();
|
|
164
|
+
await ensureWorkspaceBootstrapFiles(workspace, log);
|
|
165
|
+
await expect(fs.access(path.join(workspace, 'BOOTSTRAP.md'))).rejects.toThrow();
|
|
166
|
+
expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ workspaceCwd: workspace }), expect.stringContaining('auto-deleted stale BOOTSTRAP.md'));
|
|
167
|
+
});
|
|
168
|
+
// --- Plan-027 tests: force bootstrap env var ---
|
|
169
|
+
it('DISCOCLAW_FORCE_BOOTSTRAP=1 creates BOOTSTRAP.md in onboarded workspace', async () => {
|
|
170
|
+
vi.stubEnv('DISCOCLAW_FORCE_BOOTSTRAP', '1');
|
|
171
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
172
|
+
dirs.push(workspace);
|
|
173
|
+
await writeOnboardedFiles(workspace);
|
|
174
|
+
const log = mockLog();
|
|
175
|
+
await ensureWorkspaceBootstrapFiles(workspace, log);
|
|
176
|
+
// BOOTSTRAP.md should exist despite onboarding being complete.
|
|
177
|
+
await expect(fs.access(path.join(workspace, 'BOOTSTRAP.md'))).resolves.toBeUndefined();
|
|
178
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ workspaceCwd: workspace }), expect.stringContaining('DISCOCLAW_FORCE_BOOTSTRAP'));
|
|
179
|
+
});
|
|
180
|
+
it('DISCOCLAW_FORCE_BOOTSTRAP=1 replaces existing BOOTSTRAP.md with template', async () => {
|
|
181
|
+
vi.stubEnv('DISCOCLAW_FORCE_BOOTSTRAP', '1');
|
|
182
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
183
|
+
dirs.push(workspace);
|
|
184
|
+
await writeOnboardedFiles(workspace);
|
|
185
|
+
await fs.writeFile(path.join(workspace, 'BOOTSTRAP.md'), 'corrupted bootstrap content', 'utf-8');
|
|
186
|
+
await ensureWorkspaceBootstrapFiles(workspace);
|
|
187
|
+
const content = await fs.readFile(path.join(workspace, 'BOOTSTRAP.md'), 'utf-8');
|
|
188
|
+
// Read the actual template to compare.
|
|
189
|
+
const templateContent = await fs.readFile(path.join(__dirname, '..', 'templates', 'workspace', 'BOOTSTRAP.md'), 'utf-8');
|
|
190
|
+
expect(content).toBe(templateContent);
|
|
191
|
+
});
|
|
192
|
+
it('DISCOCLAW_FORCE_BOOTSTRAP=1 does NOT overwrite other template files', async () => {
|
|
193
|
+
vi.stubEnv('DISCOCLAW_FORCE_BOOTSTRAP', '1');
|
|
194
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
195
|
+
dirs.push(workspace);
|
|
196
|
+
await fs.writeFile(path.join(workspace, 'IDENTITY.md'), REAL_IDENTITY, 'utf-8');
|
|
197
|
+
const customFiles = {
|
|
198
|
+
'AGENTS.md': 'My custom agents config',
|
|
199
|
+
'SOUL.md': 'My soul',
|
|
200
|
+
'TOOLS.md': 'My tools',
|
|
201
|
+
'USER.md': 'My user',
|
|
202
|
+
'HEARTBEAT.md': 'My heartbeat',
|
|
203
|
+
'MEMORY.md': 'My memory',
|
|
204
|
+
};
|
|
205
|
+
for (const [file, content] of Object.entries(customFiles)) {
|
|
206
|
+
await fs.writeFile(path.join(workspace, file), content, 'utf-8');
|
|
207
|
+
}
|
|
208
|
+
await ensureWorkspaceBootstrapFiles(workspace);
|
|
209
|
+
// All custom files should retain their content byte-for-byte.
|
|
210
|
+
for (const [file, expected] of Object.entries(customFiles)) {
|
|
211
|
+
const actual = await fs.readFile(path.join(workspace, file), 'utf-8');
|
|
212
|
+
expect(actual).toBe(expected);
|
|
213
|
+
}
|
|
214
|
+
// BOOTSTRAP.md should be created from template.
|
|
215
|
+
await expect(fs.access(path.join(workspace, 'BOOTSTRAP.md'))).resolves.toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
it('DISCOCLAW_FORCE_BOOTSTRAP=true does NOT trigger force mode (strict equality)', async () => {
|
|
218
|
+
vi.stubEnv('DISCOCLAW_FORCE_BOOTSTRAP', 'true');
|
|
219
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
220
|
+
dirs.push(workspace);
|
|
221
|
+
await writeOnboardedFiles(workspace);
|
|
222
|
+
await ensureWorkspaceBootstrapFiles(workspace);
|
|
223
|
+
// BOOTSTRAP.md should NOT exist — onboarding is complete and force is not active.
|
|
224
|
+
await expect(fs.access(path.join(workspace, 'BOOTSTRAP.md'))).rejects.toThrow();
|
|
225
|
+
});
|
|
226
|
+
// --- Plan-027 tests: stale warning ---
|
|
227
|
+
it('stale BOOTSTRAP.md emits log.warn before auto-delete', async () => {
|
|
228
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
229
|
+
dirs.push(workspace);
|
|
230
|
+
await writeOnboardedFiles(workspace);
|
|
231
|
+
await fs.writeFile(path.join(workspace, 'BOOTSTRAP.md'), 'stale content', 'utf-8');
|
|
232
|
+
const log = mockLog();
|
|
233
|
+
await ensureWorkspaceBootstrapFiles(workspace, log);
|
|
234
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ workspaceCwd: workspace }), expect.stringContaining('stale BOOTSTRAP.md'));
|
|
235
|
+
await expect(fs.access(path.join(workspace, 'BOOTSTRAP.md'))).rejects.toThrow();
|
|
236
|
+
expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ workspaceCwd: workspace }), expect.stringContaining('auto-deleted stale BOOTSTRAP.md'));
|
|
237
|
+
});
|
|
238
|
+
it('no warning when BOOTSTRAP.md does not exist in onboarded workspace', async () => {
|
|
239
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
240
|
+
dirs.push(workspace);
|
|
241
|
+
await writeOnboardedFiles(workspace);
|
|
242
|
+
const log = mockLog();
|
|
243
|
+
await ensureWorkspaceBootstrapFiles(workspace, log);
|
|
244
|
+
expect(log.warn).not.toHaveBeenCalled();
|
|
245
|
+
});
|
|
246
|
+
it('DISCOCLAW_FORCE_BOOTSTRAP=1 on brand-new workspace creates all files', async () => {
|
|
247
|
+
vi.stubEnv('DISCOCLAW_FORCE_BOOTSTRAP', '1');
|
|
248
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
249
|
+
dirs.push(workspace);
|
|
250
|
+
const log = mockLog();
|
|
251
|
+
const created = await ensureWorkspaceBootstrapFiles(workspace, log);
|
|
252
|
+
// All template files should exist.
|
|
253
|
+
for (const file of ALL_TEMPLATE_FILES) {
|
|
254
|
+
await expect(fs.access(path.join(workspace, file))).resolves.toBeUndefined();
|
|
255
|
+
}
|
|
256
|
+
// Force warning should fire even on brand-new workspace.
|
|
257
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ workspaceCwd: workspace }), expect.stringContaining('DISCOCLAW_FORCE_BOOTSTRAP'));
|
|
258
|
+
});
|
|
259
|
+
it('DISCOCLAW_FORCE_BOOTSTRAP=1 with stale BOOTSTRAP.md emits only force warning', async () => {
|
|
260
|
+
vi.stubEnv('DISCOCLAW_FORCE_BOOTSTRAP', '1');
|
|
261
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
262
|
+
dirs.push(workspace);
|
|
263
|
+
await writeOnboardedFiles(workspace);
|
|
264
|
+
await fs.writeFile(path.join(workspace, 'BOOTSTRAP.md'), 'stale content', 'utf-8');
|
|
265
|
+
const log = mockLog();
|
|
266
|
+
await ensureWorkspaceBootstrapFiles(workspace, log);
|
|
267
|
+
// Only one warn call — the force-active warning, not the stale warning.
|
|
268
|
+
expect(log.warn).toHaveBeenCalledTimes(1);
|
|
269
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ workspaceCwd: workspace }), expect.stringContaining('DISCOCLAW_FORCE_BOOTSTRAP'));
|
|
270
|
+
expect(log.warn).not.toHaveBeenCalledWith(expect.anything(), expect.stringContaining('stale BOOTSTRAP.md'));
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
// --- Plan-027 tests: unlink error handling ---
|
|
274
|
+
// Uses vi.spyOn on the imported fs default object to intercept unlink calls.
|
|
275
|
+
describe('ensureWorkspaceBootstrapFiles — unlink error handling', () => {
|
|
276
|
+
const dirs = [];
|
|
277
|
+
let unlinkSpy;
|
|
278
|
+
const originalUnlink = fs.unlink.bind(fs);
|
|
279
|
+
afterEach(async () => {
|
|
280
|
+
vi.unstubAllEnvs();
|
|
281
|
+
if (unlinkSpy)
|
|
282
|
+
unlinkSpy.mockRestore();
|
|
283
|
+
for (const d of dirs) {
|
|
284
|
+
await fs.rm(d, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
dirs.length = 0;
|
|
287
|
+
});
|
|
288
|
+
it('force path unlink re-throws non-ENOENT errors', async () => {
|
|
289
|
+
vi.stubEnv('DISCOCLAW_FORCE_BOOTSTRAP', '1');
|
|
290
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
291
|
+
dirs.push(workspace);
|
|
292
|
+
await writeOnboardedFiles(workspace);
|
|
293
|
+
// Spy on unlink to throw EPERM for BOOTSTRAP.md paths.
|
|
294
|
+
unlinkSpy = vi.spyOn(fs, 'unlink').mockImplementation(async (p) => {
|
|
295
|
+
if (typeof p === 'string' && p.endsWith('BOOTSTRAP.md')) {
|
|
296
|
+
const err = new Error('EPERM');
|
|
297
|
+
err.code = 'EPERM';
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
return originalUnlink(p);
|
|
301
|
+
});
|
|
302
|
+
await expect(ensureWorkspaceBootstrapFiles(workspace)).rejects.toThrow(expect.objectContaining({ code: 'EPERM' }));
|
|
303
|
+
});
|
|
304
|
+
it('auto-delete path unlink re-throws non-ENOENT errors', async () => {
|
|
305
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-bootstrap-'));
|
|
306
|
+
dirs.push(workspace);
|
|
307
|
+
await writeOnboardedFiles(workspace);
|
|
308
|
+
await fs.writeFile(path.join(workspace, 'BOOTSTRAP.md'), 'stale content', 'utf-8');
|
|
309
|
+
// Spy on unlink to throw EPERM for BOOTSTRAP.md paths.
|
|
310
|
+
unlinkSpy = vi.spyOn(fs, 'unlink').mockImplementation(async (p) => {
|
|
311
|
+
if (typeof p === 'string' && p.endsWith('BOOTSTRAP.md')) {
|
|
312
|
+
const err = new Error('EPERM');
|
|
313
|
+
err.code = 'EPERM';
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
return originalUnlink(p);
|
|
317
|
+
});
|
|
318
|
+
const log = mockLog();
|
|
319
|
+
await expect(ensureWorkspaceBootstrapFiles(workspace, log)).rejects.toThrow(expect.objectContaining({ code: 'EPERM' }));
|
|
320
|
+
// Both the stale warning and the failure warning should have fired.
|
|
321
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ workspaceCwd: workspace }), expect.stringContaining('stale BOOTSTRAP.md'));
|
|
322
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ workspaceCwd: workspace }), expect.stringContaining('failed to auto-delete'));
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
// --- Template content completeness tests ---
|
|
326
|
+
// Ensures templates contain critical operational knowledge so a fresh install
|
|
327
|
+
// produces a fully operational bot without manual additions.
|
|
328
|
+
describe('template content — AGENTS.md', () => {
|
|
329
|
+
const templatesDir = path.join(__dirname, '..', 'templates', 'workspace');
|
|
330
|
+
let agents;
|
|
331
|
+
// Read template once for all content checks.
|
|
332
|
+
it('template file exists and is non-empty', async () => {
|
|
333
|
+
agents = await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
334
|
+
expect(agents.length).toBeGreaterThan(0);
|
|
335
|
+
});
|
|
336
|
+
it('contains Discord action batching rules', async () => {
|
|
337
|
+
agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
338
|
+
expect(agents).toContain('Discord Action Batching');
|
|
339
|
+
expect(agents).toContain('one action per type per response');
|
|
340
|
+
});
|
|
341
|
+
it('contains response economy guidance', async () => {
|
|
342
|
+
agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
343
|
+
expect(agents).toContain('Response Economy');
|
|
344
|
+
});
|
|
345
|
+
it('contains knowledge cutoff awareness section', async () => {
|
|
346
|
+
agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
347
|
+
expect(agents).toContain('Knowledge Cutoff Awareness');
|
|
348
|
+
expect(agents).toContain('use the web to verify');
|
|
349
|
+
});
|
|
350
|
+
it('contains session completion workflow', async () => {
|
|
351
|
+
agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
352
|
+
expect(agents).toContain('Landing the Plane');
|
|
353
|
+
expect(agents).toContain('git push');
|
|
354
|
+
});
|
|
355
|
+
it('contains plan-audit-implement workflow', async () => {
|
|
356
|
+
agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
357
|
+
expect(agents).toContain('Plan-Audit-Implement Workflow');
|
|
358
|
+
expect(agents).toContain('DRAFT');
|
|
359
|
+
expect(agents).toContain('APPROVED');
|
|
360
|
+
});
|
|
361
|
+
it('references TOOLS.md for forge/plan/memory action types', async () => {
|
|
362
|
+
agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
363
|
+
expect(agents).toContain('See TOOLS.md');
|
|
364
|
+
expect(agents).toContain('discord-action');
|
|
365
|
+
});
|
|
366
|
+
it('contains Discord formatting rules', async () => {
|
|
367
|
+
agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
368
|
+
expect(agents).toContain('Discord Formatting');
|
|
369
|
+
expect(agents).toContain('No markdown tables in Discord');
|
|
370
|
+
});
|
|
371
|
+
it('contains bead creation guidance', async () => {
|
|
372
|
+
agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
373
|
+
expect(agents).toContain('Bead Creation');
|
|
374
|
+
});
|
|
375
|
+
it('contains git commit hash guidance', async () => {
|
|
376
|
+
agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
377
|
+
expect(agents).toContain('Git Commits');
|
|
378
|
+
expect(agents).toContain('short commit hash');
|
|
379
|
+
});
|
|
380
|
+
it('uses correct !memory remember syntax (not !memory add)', async () => {
|
|
381
|
+
agents ??= await fs.readFile(path.join(templatesDir, 'AGENTS.md'), 'utf-8');
|
|
382
|
+
expect(agents).toContain('!memory remember');
|
|
383
|
+
expect(agents).not.toContain('!memory add');
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
describe('template content — TOOLS.md', () => {
|
|
387
|
+
const templatesDir = path.join(__dirname, '..', 'templates', 'workspace');
|
|
388
|
+
let tools;
|
|
389
|
+
it('template file exists and is non-empty', async () => {
|
|
390
|
+
tools = await fs.readFile(path.join(templatesDir, 'TOOLS.md'), 'utf-8');
|
|
391
|
+
expect(tools.length).toBeGreaterThan(0);
|
|
392
|
+
});
|
|
393
|
+
it('contains browser automation section', async () => {
|
|
394
|
+
tools ??= await fs.readFile(path.join(templatesDir, 'TOOLS.md'), 'utf-8');
|
|
395
|
+
expect(tools).toContain('Browser Automation');
|
|
396
|
+
expect(tools).toContain('agent-browser');
|
|
397
|
+
});
|
|
398
|
+
it('documents all 5 browser modes (WebFetch, Playwright headless/headed, CDP headless/headed)', async () => {
|
|
399
|
+
tools ??= await fs.readFile(path.join(templatesDir, 'TOOLS.md'), 'utf-8');
|
|
400
|
+
expect(tools).toContain('WebFetch');
|
|
401
|
+
expect(tools).toContain('Playwright headless');
|
|
402
|
+
expect(tools).toContain('Playwright headed');
|
|
403
|
+
expect(tools).toContain('CDP headless');
|
|
404
|
+
expect(tools).toContain('CDP headed');
|
|
405
|
+
});
|
|
406
|
+
it('contains service operations section', async () => {
|
|
407
|
+
tools ??= await fs.readFile(path.join(templatesDir, 'TOOLS.md'), 'utf-8');
|
|
408
|
+
expect(tools).toContain('Service Operations');
|
|
409
|
+
expect(tools).toContain('systemctl --user');
|
|
410
|
+
});
|
|
411
|
+
it('contains plan-audit-implement workflow', async () => {
|
|
412
|
+
tools ??= await fs.readFile(path.join(templatesDir, 'TOOLS.md'), 'utf-8');
|
|
413
|
+
expect(tools).toContain('Plan-Audit-Implement Workflow');
|
|
414
|
+
});
|
|
415
|
+
// --- All 13 Discord action types ---
|
|
416
|
+
it('documents all 4 forge action types', async () => {
|
|
417
|
+
tools ??= await fs.readFile(path.join(templatesDir, 'TOOLS.md'), 'utf-8');
|
|
418
|
+
for (const action of ['forgeCreate', 'forgeResume', 'forgeStatus', 'forgeCancel']) {
|
|
419
|
+
expect(tools).toContain(action);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
it('documents all 6 plan action types', async () => {
|
|
423
|
+
tools ??= await fs.readFile(path.join(templatesDir, 'TOOLS.md'), 'utf-8');
|
|
424
|
+
for (const action of ['planList', 'planShow', 'planApprove', 'planClose', 'planCreate', 'planRun']) {
|
|
425
|
+
expect(tools).toContain(action);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
it('documents all 3 memory action types', async () => {
|
|
429
|
+
tools ??= await fs.readFile(path.join(templatesDir, 'TOOLS.md'), 'utf-8');
|
|
430
|
+
for (const action of ['memoryRemember', 'memoryForget', 'memoryShow']) {
|
|
431
|
+
expect(tools).toContain(action);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
it('contains discord-action block syntax examples', async () => {
|
|
435
|
+
tools ??= await fs.readFile(path.join(templatesDir, 'TOOLS.md'), 'utf-8');
|
|
436
|
+
expect(tools).toContain('<discord-action>');
|
|
437
|
+
expect(tools).toContain('"type"');
|
|
438
|
+
});
|
|
439
|
+
it('warns against sending commands as text messages', async () => {
|
|
440
|
+
tools ??= await fs.readFile(path.join(templatesDir, 'TOOLS.md'), 'utf-8');
|
|
441
|
+
expect(tools).toContain("bot-sent messages don't trigger command handlers");
|
|
442
|
+
});
|
|
443
|
+
it('documents restart convenience commands', async () => {
|
|
444
|
+
tools ??= await fs.readFile(path.join(templatesDir, 'TOOLS.md'), 'utf-8');
|
|
445
|
+
expect(tools).toContain('!restart');
|
|
446
|
+
expect(tools).toContain('Discord Convenience Commands');
|
|
447
|
+
});
|
|
448
|
+
it('contains service operation guardrails', async () => {
|
|
449
|
+
tools ??= await fs.readFile(path.join(templatesDir, 'TOOLS.md'), 'utf-8');
|
|
450
|
+
expect(tools).toContain('Always ask before restart');
|
|
451
|
+
expect(tools).toContain('Guardrails');
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
describe('template content — no personalization leak', () => {
|
|
455
|
+
const templatesDir = path.join(__dirname, '..', 'templates', 'workspace');
|
|
456
|
+
const FORBIDDEN_TOKENS = ['David', 'Escondido', 'Chelsea', 'marshmonkey'];
|
|
457
|
+
for (const file of ['AGENTS.md', 'TOOLS.md']) {
|
|
458
|
+
it(`${file} does not contain user-specific tokens`, async () => {
|
|
459
|
+
const content = await fs.readFile(path.join(templatesDir, file), 'utf-8');
|
|
460
|
+
for (const token of FORBIDDEN_TOKENS) {
|
|
461
|
+
expect(content).not.toContain(token);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
describe('scaffolded workspace contains operational content', () => {
|
|
467
|
+
const dirs = [];
|
|
468
|
+
afterEach(async () => {
|
|
469
|
+
for (const d of dirs)
|
|
470
|
+
await fs.rm(d, { recursive: true, force: true });
|
|
471
|
+
dirs.length = 0;
|
|
472
|
+
});
|
|
473
|
+
it('fresh scaffold produces AGENTS.md with all critical sections', async () => {
|
|
474
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-content-'));
|
|
475
|
+
dirs.push(workspace);
|
|
476
|
+
await ensureWorkspaceBootstrapFiles(workspace);
|
|
477
|
+
const agents = await fs.readFile(path.join(workspace, 'AGENTS.md'), 'utf-8');
|
|
478
|
+
const requiredSections = [
|
|
479
|
+
'Discord Action Batching',
|
|
480
|
+
'Response Economy',
|
|
481
|
+
'Knowledge Cutoff Awareness',
|
|
482
|
+
'Landing the Plane',
|
|
483
|
+
'Plan-Audit-Implement Workflow',
|
|
484
|
+
'Discord Formatting',
|
|
485
|
+
'Bead Creation',
|
|
486
|
+
];
|
|
487
|
+
for (const section of requiredSections) {
|
|
488
|
+
expect(agents).toContain(section);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
it('fresh scaffold produces TOOLS.md with all 13 action types', async () => {
|
|
492
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-content-'));
|
|
493
|
+
dirs.push(workspace);
|
|
494
|
+
await ensureWorkspaceBootstrapFiles(workspace);
|
|
495
|
+
const tools = await fs.readFile(path.join(workspace, 'TOOLS.md'), 'utf-8');
|
|
496
|
+
const allActionTypes = [
|
|
497
|
+
'forgeCreate', 'forgeResume', 'forgeStatus', 'forgeCancel',
|
|
498
|
+
'planList', 'planShow', 'planApprove', 'planClose', 'planCreate', 'planRun',
|
|
499
|
+
'memoryRemember', 'memoryForget', 'memoryShow',
|
|
500
|
+
];
|
|
501
|
+
for (const action of allActionTypes) {
|
|
502
|
+
expect(tools).toContain(action);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
it('fresh scaffold produces TOOLS.md with browser automation and service ops', async () => {
|
|
506
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-content-'));
|
|
507
|
+
dirs.push(workspace);
|
|
508
|
+
await ensureWorkspaceBootstrapFiles(workspace);
|
|
509
|
+
const tools = await fs.readFile(path.join(workspace, 'TOOLS.md'), 'utf-8');
|
|
510
|
+
expect(tools).toContain('Browser Automation');
|
|
511
|
+
expect(tools).toContain('Service Operations');
|
|
512
|
+
expect(tools).toContain('Plan-Audit-Implement Workflow');
|
|
513
|
+
});
|
|
514
|
+
});
|