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,612 @@
|
|
|
1
|
+
import fsSync from 'node:fs';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { decomposePlan, readPhasesFile, getNextPhase, updatePhaseStatus, checkStaleness, writePhasesFile, } from './plan-manager.js';
|
|
5
|
+
import { getLatestAuditVerdictFromSection, getSection, parsePlan } from './plan-parser.js';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Parsing
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const RESERVED_SUBCOMMANDS = new Set(['list', 'show', 'approve', 'close', 'cancel', 'help', 'phases', 'run', 'run-one', 'skip', 'audit']);
|
|
10
|
+
export function parsePlanCommand(content) {
|
|
11
|
+
const trimmed = content.trim();
|
|
12
|
+
if (!trimmed.startsWith('!plan'))
|
|
13
|
+
return null;
|
|
14
|
+
const rest = trimmed.slice('!plan'.length).trim();
|
|
15
|
+
// No args → help
|
|
16
|
+
if (!rest)
|
|
17
|
+
return { action: 'help', args: '' };
|
|
18
|
+
// Check reserved subcommands
|
|
19
|
+
const firstWord = rest.split(/\s+/)[0].toLowerCase();
|
|
20
|
+
if (RESERVED_SUBCOMMANDS.has(firstWord)) {
|
|
21
|
+
const subArgs = rest.slice(firstWord.length).trim();
|
|
22
|
+
return { action: firstWord, args: subArgs };
|
|
23
|
+
}
|
|
24
|
+
// Everything else is a create description
|
|
25
|
+
return { action: 'create', args: rest };
|
|
26
|
+
}
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Slug generation
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
export function toSlug(description) {
|
|
31
|
+
return description
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
34
|
+
.replace(/^-+|-+$/g, '')
|
|
35
|
+
.slice(0, 50)
|
|
36
|
+
.replace(/-+$/, '');
|
|
37
|
+
}
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Plan file header parsing
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
export function parsePlanFileHeader(content) {
|
|
42
|
+
const parsed = parsePlan(content);
|
|
43
|
+
const planId = parsed.metadata.get('ID')?.trim() ?? '';
|
|
44
|
+
if (!planId)
|
|
45
|
+
return null;
|
|
46
|
+
const taskId = parsed.metadata.get('Task')?.trim() ?? '';
|
|
47
|
+
return {
|
|
48
|
+
planId,
|
|
49
|
+
taskId,
|
|
50
|
+
status: parsed.metadata.get('Status')?.trim() ?? '',
|
|
51
|
+
title: parsed.title.trim(),
|
|
52
|
+
project: parsed.metadata.get('Project')?.trim() ?? '',
|
|
53
|
+
created: parsed.metadata.get('Created')?.trim() ?? '',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function resolvePlanHeaderTaskId(header) {
|
|
57
|
+
return header.taskId?.trim() || '';
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Plan file utilities
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
async function ensurePlansDir(plansDir) {
|
|
63
|
+
await fs.mkdir(plansDir, { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
async function getNextPlanNumber(plansDir) {
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = await fs.readdir(plansDir);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
let max = 0;
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
const match = entry.match(/^plan-(\d+)/);
|
|
76
|
+
if (match) {
|
|
77
|
+
const num = parseInt(match[1], 10);
|
|
78
|
+
if (num > max)
|
|
79
|
+
max = num;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return max + 1;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Normalize a bare number or unpadded plan-N string to canonical plan-NNN format.
|
|
86
|
+
* Returns null if the input doesn't look like a plan ID reference.
|
|
87
|
+
*/
|
|
88
|
+
export function normalizePlanId(id) {
|
|
89
|
+
// Bare number: "031" or "31" → "plan-031"
|
|
90
|
+
const bareNum = id.match(/^(\d+)$/);
|
|
91
|
+
if (bareNum)
|
|
92
|
+
return `plan-${bareNum[1].padStart(3, '0')}`;
|
|
93
|
+
// Unpadded plan-N: "plan-31" → "plan-031"
|
|
94
|
+
const planNum = id.match(/^plan-(\d+)$/);
|
|
95
|
+
if (planNum)
|
|
96
|
+
return `plan-${planNum[1].padStart(3, '0')}`;
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Check if a raw string looks like a plan-ID reference (bare number or plan-N pattern).
|
|
101
|
+
* Used to gate plan-ID lookups vs. new plan creation in the forge dispatch path.
|
|
102
|
+
*/
|
|
103
|
+
export function looksLikePlanId(id) {
|
|
104
|
+
return /^\d+$/.test(id) || /^plan-\d+$/.test(id);
|
|
105
|
+
}
|
|
106
|
+
export async function findPlanFile(plansDir, id) {
|
|
107
|
+
let entries;
|
|
108
|
+
try {
|
|
109
|
+
entries = await fs.readdir(plansDir);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const normalizedId = normalizePlanId(id);
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (!entry.endsWith('.md') || entry.startsWith('.'))
|
|
117
|
+
continue;
|
|
118
|
+
const filePath = path.join(plansDir, entry);
|
|
119
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
120
|
+
const header = parsePlanFileHeader(content);
|
|
121
|
+
if (!header)
|
|
122
|
+
continue;
|
|
123
|
+
const taskId = resolvePlanHeaderTaskId(header);
|
|
124
|
+
if (header.planId === id || taskId === id || (normalizedId && header.planId === normalizedId)) {
|
|
125
|
+
return { filePath, header };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Update the status field in a plan file. Callers must hold the workspace writer lock.
|
|
132
|
+
*/
|
|
133
|
+
export async function updatePlanFileStatus(filePath, newStatus) {
|
|
134
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
135
|
+
const updated = content.replace(/^\*\*Status:\*\*\s*.+$/m, `**Status:** ${newStatus}`);
|
|
136
|
+
await fs.writeFile(filePath, updated, 'utf-8');
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* List all plan files in the plans directory, returning parsed headers with file paths.
|
|
140
|
+
* Errors on individual files are caught and skipped.
|
|
141
|
+
*/
|
|
142
|
+
export async function listPlanFiles(plansDir) {
|
|
143
|
+
let entries;
|
|
144
|
+
try {
|
|
145
|
+
entries = await fs.readdir(plansDir);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
const results = [];
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (!entry.endsWith('.md') || entry.startsWith('.'))
|
|
153
|
+
continue;
|
|
154
|
+
try {
|
|
155
|
+
const filePath = path.join(plansDir, entry);
|
|
156
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
157
|
+
const header = parsePlanFileHeader(content);
|
|
158
|
+
if (header)
|
|
159
|
+
results.push({ filePath, header });
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// skip unreadable files
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return results;
|
|
166
|
+
}
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Inline fallback template (used when .plan-template.md is missing)
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
const FALLBACK_TEMPLATE = `# Plan: {{TITLE}}
|
|
171
|
+
|
|
172
|
+
**ID:** {{PLAN_ID}}
|
|
173
|
+
**Task:** {{TASK_ID}}
|
|
174
|
+
**Created:** {{DATE}}
|
|
175
|
+
**Status:** DRAFT
|
|
176
|
+
**Project:** {{PROJECT}}
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Objective
|
|
181
|
+
|
|
182
|
+
_Describe the objective here._
|
|
183
|
+
|
|
184
|
+
## Scope
|
|
185
|
+
|
|
186
|
+
_Define what's in and out of scope._
|
|
187
|
+
|
|
188
|
+
## Changes
|
|
189
|
+
|
|
190
|
+
_List file-by-file changes._
|
|
191
|
+
|
|
192
|
+
## Risks
|
|
193
|
+
|
|
194
|
+
_Identify risks._
|
|
195
|
+
|
|
196
|
+
## Testing
|
|
197
|
+
|
|
198
|
+
_How to verify._
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Audit Log
|
|
203
|
+
|
|
204
|
+
_Audit notes go here._
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Implementation Notes
|
|
209
|
+
|
|
210
|
+
_Filled in during/after implementation._
|
|
211
|
+
`;
|
|
212
|
+
function resolvePlansDir(opts) {
|
|
213
|
+
return opts.plansDir ?? path.join(opts.workspaceCwd, 'plans');
|
|
214
|
+
}
|
|
215
|
+
export async function createPlan(createOpts, opts) {
|
|
216
|
+
const description = createOpts.description.trim();
|
|
217
|
+
if (!description)
|
|
218
|
+
throw new Error('Usage: `!plan <description>`');
|
|
219
|
+
const plansDir = resolvePlansDir(opts);
|
|
220
|
+
await ensurePlansDir(plansDir);
|
|
221
|
+
const num = await getNextPlanNumber(plansDir);
|
|
222
|
+
const planId = `plan-${String(num).padStart(3, '0')}`;
|
|
223
|
+
const slug = toSlug(description);
|
|
224
|
+
const fileName = `${planId}-${slug}.md`;
|
|
225
|
+
const filePath = path.join(plansDir, fileName);
|
|
226
|
+
const date = new Date().toISOString().split('T')[0];
|
|
227
|
+
const trimmedContext = createOpts.context?.trim();
|
|
228
|
+
// Create backing task — or reuse existing one from task thread context.
|
|
229
|
+
let taskId;
|
|
230
|
+
const existingTaskId = createOpts.existingTaskId;
|
|
231
|
+
if (existingTaskId) {
|
|
232
|
+
taskId = existingTaskId;
|
|
233
|
+
// Ensure the reused task has the 'plan' label for label-based filtering.
|
|
234
|
+
try {
|
|
235
|
+
opts.taskStore.addLabel(taskId, 'plan');
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// best-effort — label addition failure shouldn't block plan creation.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
try {
|
|
243
|
+
// Dedup: if an open task with a matching title already exists, reuse it.
|
|
244
|
+
const normalizedTitle = description.toLowerCase();
|
|
245
|
+
const existingTasks = opts.taskStore.list({ label: 'plan' });
|
|
246
|
+
const match = existingTasks.find((task) => task.status !== 'closed' && task.title.trim().toLowerCase() === normalizedTitle);
|
|
247
|
+
if (match) {
|
|
248
|
+
taskId = match.id;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
const task = opts.taskStore.create({
|
|
252
|
+
title: description,
|
|
253
|
+
labels: ['plan'],
|
|
254
|
+
...(trimmedContext ? { description: trimmedContext.slice(0, 1800) } : {}),
|
|
255
|
+
});
|
|
256
|
+
taskId = task.id;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
throw new Error(`Failed to create backing task: ${String(err)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Load template or use fallback
|
|
264
|
+
let template;
|
|
265
|
+
const templatePath = path.join(plansDir, '.plan-template.md');
|
|
266
|
+
try {
|
|
267
|
+
template = await fs.readFile(templatePath, 'utf-8');
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
template = FALLBACK_TEMPLATE;
|
|
271
|
+
}
|
|
272
|
+
// Fill template
|
|
273
|
+
const content = template
|
|
274
|
+
.replace(/\{\{TITLE\}\}/g, description)
|
|
275
|
+
.replace(/\{\{PLAN_ID\}\}/g, planId)
|
|
276
|
+
.replace(/\{\{TASK_ID\}\}/g, taskId)
|
|
277
|
+
.replace(/\{\{DATE\}\}/g, date)
|
|
278
|
+
.replace(/\{\{PROJECT\}\}/g, 'discoclaw')
|
|
279
|
+
// Set status to DRAFT (remove the options list)
|
|
280
|
+
.replace(/\*\*Status:\*\*\s*DRAFT\s*\|[^\n]*/, '**Status:** DRAFT');
|
|
281
|
+
// Append reply context below the template body (keeps slug/task/title clean).
|
|
282
|
+
const contextSection = trimmedContext ? `\n## Context\n\n${trimmedContext}\n` : '';
|
|
283
|
+
const finalContent = content + contextSection;
|
|
284
|
+
await fs.writeFile(filePath, finalContent, 'utf-8');
|
|
285
|
+
const displayMessage = [
|
|
286
|
+
`Plan created: **${planId}** (task: \`${taskId}\`)`,
|
|
287
|
+
`File: \`workspace/plans/${fileName}\``,
|
|
288
|
+
`Description: ${description}`,
|
|
289
|
+
].join('\n');
|
|
290
|
+
return { planId, taskId, filePath, fileName, description, displayMessage };
|
|
291
|
+
}
|
|
292
|
+
export async function handlePlanCommand(cmd, opts) {
|
|
293
|
+
const plansDir = resolvePlansDir(opts);
|
|
294
|
+
try {
|
|
295
|
+
if (cmd.action === 'help') {
|
|
296
|
+
return [
|
|
297
|
+
'**!plan commands:**',
|
|
298
|
+
'- `!plan <description>` — create a new plan',
|
|
299
|
+
'- `!plan list` — list active plans',
|
|
300
|
+
'- `!plan show <plan-id|task-id>` — show plan details',
|
|
301
|
+
'- `!plan approve <plan-id|task-id>` — approve for implementation',
|
|
302
|
+
'- `!plan close <plan-id|task-id>` — close/abandon a plan',
|
|
303
|
+
'- `!plan phases <plan-id>` — show/generate phase checklist',
|
|
304
|
+
'- `!plan run <plan-id>` — execute all remaining phases',
|
|
305
|
+
'- `!plan run-one <plan-id>` — execute next pending phase only',
|
|
306
|
+
'- `!plan skip <plan-id>` — skip a failed/in-progress phase',
|
|
307
|
+
'- `!plan audit <plan-id>` — run a standalone audit against a plan',
|
|
308
|
+
].join('\n');
|
|
309
|
+
}
|
|
310
|
+
if (cmd.action === 'create') {
|
|
311
|
+
try {
|
|
312
|
+
const created = await createPlan({
|
|
313
|
+
description: cmd.args,
|
|
314
|
+
context: cmd.context,
|
|
315
|
+
existingTaskId: cmd.existingTaskId,
|
|
316
|
+
}, opts);
|
|
317
|
+
return created.displayMessage;
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
return String(err instanceof Error ? err.message : err);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (cmd.action === 'list') {
|
|
324
|
+
let entries;
|
|
325
|
+
try {
|
|
326
|
+
entries = await fs.readdir(plansDir);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return 'No plans directory found.';
|
|
330
|
+
}
|
|
331
|
+
const plans = [];
|
|
332
|
+
for (const entry of entries) {
|
|
333
|
+
if (!entry.endsWith('.md') || entry.startsWith('.'))
|
|
334
|
+
continue;
|
|
335
|
+
try {
|
|
336
|
+
const content = await fs.readFile(path.join(plansDir, entry), 'utf-8');
|
|
337
|
+
const header = parsePlanFileHeader(content);
|
|
338
|
+
if (header)
|
|
339
|
+
plans.push(header);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// skip unreadable files
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (plans.length === 0)
|
|
346
|
+
return 'No plans found.';
|
|
347
|
+
// Sort by planId
|
|
348
|
+
plans.sort((a, b) => a.planId.localeCompare(b.planId));
|
|
349
|
+
const lines = plans.map((p) => {
|
|
350
|
+
const taskId = resolvePlanHeaderTaskId(p);
|
|
351
|
+
return `- \`${p.planId}\` [${p.status}] — ${p.title}${taskId ? ` (task: \`${taskId}\`)` : ''}`;
|
|
352
|
+
});
|
|
353
|
+
return lines.join('\n');
|
|
354
|
+
}
|
|
355
|
+
if (cmd.action === 'show') {
|
|
356
|
+
if (!cmd.args)
|
|
357
|
+
return 'Usage: `!plan show <plan-id|task-id>`';
|
|
358
|
+
const found = await findPlanFile(plansDir, cmd.args);
|
|
359
|
+
if (!found)
|
|
360
|
+
return `Plan not found: ${cmd.args}`;
|
|
361
|
+
const content = await fs.readFile(found.filePath, 'utf-8');
|
|
362
|
+
const parsedPlan = parsePlan(content);
|
|
363
|
+
const objective = getSection(parsedPlan, 'Objective') || '(no objective)';
|
|
364
|
+
const auditSection = getSection(parsedPlan, 'Audit Log');
|
|
365
|
+
const latestVerdict = getLatestAuditVerdictFromSection(auditSection) ?? '(no audit yet)';
|
|
366
|
+
return [
|
|
367
|
+
`**${found.header.planId}** — ${found.header.title}`,
|
|
368
|
+
`Status: ${found.header.status}`,
|
|
369
|
+
`Task: \`${resolvePlanHeaderTaskId(found.header)}\``,
|
|
370
|
+
`Project: ${found.header.project}`,
|
|
371
|
+
`Created: ${found.header.created}`,
|
|
372
|
+
'',
|
|
373
|
+
`**Objective:** ${objective}`,
|
|
374
|
+
'',
|
|
375
|
+
`**Latest audit:** ${latestVerdict}`,
|
|
376
|
+
].join('\n');
|
|
377
|
+
}
|
|
378
|
+
if (cmd.action === 'approve') {
|
|
379
|
+
if (!cmd.args)
|
|
380
|
+
return 'Usage: `!plan approve <plan-id|task-id>`';
|
|
381
|
+
const found = await findPlanFile(plansDir, cmd.args);
|
|
382
|
+
if (!found)
|
|
383
|
+
return `Plan not found: ${cmd.args}`;
|
|
384
|
+
if (found.header.status === 'IMPLEMENTING')
|
|
385
|
+
return `Plan is currently being implemented. Use \`!plan cancel ${found.header.planId}\` to stop it first.`;
|
|
386
|
+
await updatePlanFileStatus(found.filePath, 'APPROVED');
|
|
387
|
+
// Update backing task to in_progress.
|
|
388
|
+
const taskId = resolvePlanHeaderTaskId(found.header);
|
|
389
|
+
if (taskId) {
|
|
390
|
+
try {
|
|
391
|
+
opts.taskStore.update(taskId, { status: 'in_progress' });
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// best-effort — task update failure shouldn't block approval.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return `Plan **${found.header.planId}** approved for implementation.`;
|
|
398
|
+
}
|
|
399
|
+
if (cmd.action === 'close') {
|
|
400
|
+
if (!cmd.args)
|
|
401
|
+
return 'Usage: `!plan close <plan-id|task-id>`';
|
|
402
|
+
const found = await findPlanFile(plansDir, cmd.args);
|
|
403
|
+
if (!found)
|
|
404
|
+
return `Plan not found: ${cmd.args}`;
|
|
405
|
+
if (found.header.status === 'IMPLEMENTING')
|
|
406
|
+
return `Plan is currently being implemented. Use \`!plan cancel ${found.header.planId}\` to stop it first.`;
|
|
407
|
+
await updatePlanFileStatus(found.filePath, 'CLOSED');
|
|
408
|
+
// Close backing task.
|
|
409
|
+
const taskId = resolvePlanHeaderTaskId(found.header);
|
|
410
|
+
if (taskId) {
|
|
411
|
+
try {
|
|
412
|
+
opts.taskStore.close(taskId, 'Plan closed');
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
// best-effort
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return `Plan **${found.header.planId}** closed.`;
|
|
419
|
+
}
|
|
420
|
+
if (cmd.action === 'phases') {
|
|
421
|
+
if (!cmd.args)
|
|
422
|
+
return 'Usage: `!plan phases <plan-id>`';
|
|
423
|
+
// Parse --regenerate flag
|
|
424
|
+
const regenerate = cmd.args.includes('--regenerate');
|
|
425
|
+
const planIdArg = cmd.args.replace('--regenerate', '').trim();
|
|
426
|
+
if (!planIdArg)
|
|
427
|
+
return 'Usage: `!plan phases <plan-id>`';
|
|
428
|
+
const found = await findPlanFile(plansDir, planIdArg);
|
|
429
|
+
if (!found)
|
|
430
|
+
return `Plan not found: ${planIdArg}`;
|
|
431
|
+
const phasesFileName = `${found.header.planId}-phases.md`;
|
|
432
|
+
const phasesFilePath = path.join(plansDir, phasesFileName);
|
|
433
|
+
let phases;
|
|
434
|
+
const phasesFileExists = fsSync.existsSync(phasesFilePath);
|
|
435
|
+
if (!phasesFileExists || regenerate) {
|
|
436
|
+
// Generate phases
|
|
437
|
+
const planContent = await fs.readFile(found.filePath, 'utf-8');
|
|
438
|
+
const planRelPath = `workspace/plans/${path.basename(found.filePath)}`;
|
|
439
|
+
phases = decomposePlan(planContent, found.header.planId, planRelPath, opts.maxContextFiles);
|
|
440
|
+
writePhasesFile(phasesFilePath, phases);
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
phases = readPhasesFile(phasesFilePath);
|
|
444
|
+
}
|
|
445
|
+
// Format checklist
|
|
446
|
+
return formatPhasesChecklist(phases);
|
|
447
|
+
}
|
|
448
|
+
// Note: 'run' and 'skip' are intercepted by discord.ts before reaching here.
|
|
449
|
+
return 'Unknown plan command. Try `!plan` for help.';
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
return `Plan command error: ${String(err)}`;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Phase status emoji
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
const STATUS_EMOJI = {
|
|
459
|
+
'pending': '[ ]',
|
|
460
|
+
'in-progress': '[~]',
|
|
461
|
+
'done': '[x]',
|
|
462
|
+
'failed': '[!]',
|
|
463
|
+
'skipped': '[-]',
|
|
464
|
+
};
|
|
465
|
+
function formatPhasesChecklist(phases) {
|
|
466
|
+
const lines = [];
|
|
467
|
+
lines.push(`**Phases for ${phases.planId}** (hash: \`${phases.planContentHash}\`)`);
|
|
468
|
+
lines.push('');
|
|
469
|
+
for (const phase of phases.phases) {
|
|
470
|
+
const emoji = STATUS_EMOJI[phase.status] ?? '[ ]';
|
|
471
|
+
const deps = phase.dependsOn.length > 0 ? ` (depends: ${phase.dependsOn.join(', ')})` : '';
|
|
472
|
+
lines.push(`${emoji} **${phase.id}:** ${phase.title} [${phase.kind}]${deps}`);
|
|
473
|
+
if (phase.error)
|
|
474
|
+
lines.push(` Error: ${phase.error}`);
|
|
475
|
+
if (phase.gitCommit)
|
|
476
|
+
lines.push(` Commit: \`${phase.gitCommit}\``);
|
|
477
|
+
}
|
|
478
|
+
return lines.join('\n');
|
|
479
|
+
}
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// Exported helpers for discord.ts
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
export async function handlePlanSkip(planId, opts) {
|
|
484
|
+
const plansDir = resolvePlansDir(opts);
|
|
485
|
+
const found = await findPlanFile(plansDir, planId);
|
|
486
|
+
if (!found)
|
|
487
|
+
return `Plan not found: ${planId}`;
|
|
488
|
+
const phasesFileName = `${found.header.planId}-phases.md`;
|
|
489
|
+
const phasesFilePath = path.join(plansDir, phasesFileName);
|
|
490
|
+
if (!fsSync.existsSync(phasesFilePath)) {
|
|
491
|
+
return `No phases file found for ${planId}. Run \`!plan phases ${planId}\` first.`;
|
|
492
|
+
}
|
|
493
|
+
let phases;
|
|
494
|
+
try {
|
|
495
|
+
phases = readPhasesFile(phasesFilePath);
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
return `Failed to read phases file: ${String(err)}`;
|
|
499
|
+
}
|
|
500
|
+
// Find the first in-progress or failed phase
|
|
501
|
+
const target = phases.phases.find((p) => p.status === 'in-progress' || p.status === 'failed');
|
|
502
|
+
if (!target)
|
|
503
|
+
return 'Nothing to skip.';
|
|
504
|
+
phases = updatePhaseStatus(phases, target.id, 'skipped');
|
|
505
|
+
writePhasesFile(phasesFilePath, phases);
|
|
506
|
+
return `Skipped **${target.id}**: ${target.title} (was ${target.status})`;
|
|
507
|
+
}
|
|
508
|
+
export const NO_PHASES_SENTINEL = 'No phases to run';
|
|
509
|
+
const RUNNABLE_STATUSES = new Set(['APPROVED', 'IMPLEMENTING']);
|
|
510
|
+
export async function preparePlanRun(planId, opts) {
|
|
511
|
+
const plansDir = resolvePlansDir(opts);
|
|
512
|
+
const found = await findPlanFile(plansDir, planId);
|
|
513
|
+
if (!found)
|
|
514
|
+
return { error: `Plan not found: ${planId}` };
|
|
515
|
+
// Status gate: only run phases on approved or implementing plans
|
|
516
|
+
if (!RUNNABLE_STATUSES.has(found.header.status)) {
|
|
517
|
+
return { error: `Plan ${found.header.planId} has status ${found.header.status} — must be APPROVED or IMPLEMENTING to run.` };
|
|
518
|
+
}
|
|
519
|
+
const phasesFileName = `${found.header.planId}-phases.md`;
|
|
520
|
+
const phasesFilePath = path.join(plansDir, phasesFileName);
|
|
521
|
+
// Generate phases if needed
|
|
522
|
+
if (!fsSync.existsSync(phasesFilePath)) {
|
|
523
|
+
const planContent = await fs.readFile(found.filePath, 'utf-8');
|
|
524
|
+
const planRelPath = `workspace/plans/${path.basename(found.filePath)}`;
|
|
525
|
+
const phases = decomposePlan(planContent, found.header.planId, planRelPath, opts.maxContextFiles);
|
|
526
|
+
writePhasesFile(phasesFilePath, phases);
|
|
527
|
+
}
|
|
528
|
+
// Read and validate
|
|
529
|
+
let phases;
|
|
530
|
+
try {
|
|
531
|
+
phases = readPhasesFile(phasesFilePath);
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
return { error: `Failed to read phases file: ${String(err)}` };
|
|
535
|
+
}
|
|
536
|
+
const planContent = await fs.readFile(found.filePath, 'utf-8');
|
|
537
|
+
const staleness = checkStaleness(phases, planContent);
|
|
538
|
+
if (staleness.stale)
|
|
539
|
+
return { error: staleness.message };
|
|
540
|
+
const nextPhase = getNextPhase(phases);
|
|
541
|
+
// NOTE: The multi-phase loop in discord.ts depends on NO_PHASES_SENTINEL only here
|
|
542
|
+
// (initial validation before the loop starts). The loop itself uses runNextPhase's
|
|
543
|
+
// `nothing_to_run` discriminated union result — not this sentinel string. If this
|
|
544
|
+
// error message is refactored, only the initial "already all done" detection breaks,
|
|
545
|
+
// and the failure mode is benign (user sees an error instead of "all done").
|
|
546
|
+
if (!nextPhase)
|
|
547
|
+
return { error: `${NO_PHASES_SENTINEL} — all done or dependencies unmet.` };
|
|
548
|
+
return {
|
|
549
|
+
phasesFilePath,
|
|
550
|
+
planFilePath: found.filePath,
|
|
551
|
+
planContent,
|
|
552
|
+
nextPhase,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
// Auto-close plan when all phases are terminal
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
const CLOSEABLE_STATUSES = new Set(['APPROVED', 'IMPLEMENTING']);
|
|
559
|
+
const TERMINAL_PHASE_STATUSES = new Set(['done', 'skipped']);
|
|
560
|
+
export async function closePlanIfComplete(phasesFilePath, planFilePath, taskStore, acquireLock, log) {
|
|
561
|
+
let taskId;
|
|
562
|
+
const releaseLock = await acquireLock();
|
|
563
|
+
try {
|
|
564
|
+
let phases;
|
|
565
|
+
try {
|
|
566
|
+
phases = readPhasesFile(phasesFilePath, { log });
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
log?.warn({ err, phasesFilePath }, 'closePlanIfComplete: failed to read phases state');
|
|
570
|
+
return { closed: false, reason: 'read_error' };
|
|
571
|
+
}
|
|
572
|
+
// Check whether every phase has a terminal status (done or skipped)
|
|
573
|
+
const allComplete = phases.phases.every((p) => TERMINAL_PHASE_STATUSES.has(p.status));
|
|
574
|
+
if (!allComplete) {
|
|
575
|
+
return { closed: false, reason: 'not_all_complete' };
|
|
576
|
+
}
|
|
577
|
+
// Read plan file header
|
|
578
|
+
let planContent;
|
|
579
|
+
try {
|
|
580
|
+
planContent = await fs.readFile(planFilePath, 'utf-8');
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
log?.warn({ err, planFilePath }, 'closePlanIfComplete: failed to read plan file');
|
|
584
|
+
return { closed: false, reason: 'read_error' };
|
|
585
|
+
}
|
|
586
|
+
const header = parsePlanFileHeader(planContent);
|
|
587
|
+
if (!header) {
|
|
588
|
+
log?.warn({ planFilePath }, 'closePlanIfComplete: failed to parse plan file header');
|
|
589
|
+
return { closed: false, reason: 'read_error' };
|
|
590
|
+
}
|
|
591
|
+
// Plan-status gate: only auto-close plans that were approved for execution
|
|
592
|
+
if (!CLOSEABLE_STATUSES.has(header.status)) {
|
|
593
|
+
return { closed: false, reason: 'wrong_status' };
|
|
594
|
+
}
|
|
595
|
+
taskId = resolvePlanHeaderTaskId(header) || undefined;
|
|
596
|
+
// Close the plan (under lock, as updatePlanFileStatus requires)
|
|
597
|
+
await updatePlanFileStatus(planFilePath, 'CLOSED');
|
|
598
|
+
}
|
|
599
|
+
finally {
|
|
600
|
+
releaseLock();
|
|
601
|
+
}
|
|
602
|
+
// Best-effort task close (no lock needed).
|
|
603
|
+
if (taskId) {
|
|
604
|
+
try {
|
|
605
|
+
taskStore.close(taskId, 'All phases complete');
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
log?.warn({ err, taskId }, 'closePlanIfComplete: failed to close task (best-effort)');
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return { closed: true, reason: 'all_phases_complete' };
|
|
612
|
+
}
|