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,698 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createPlan, parsePlanFileHeader, resolvePlanHeaderTaskId } from './plan-commands.js';
|
|
4
|
+
import { runPipeline } from '../pipeline/engine.js';
|
|
5
|
+
import { auditPlanStructure, deriveVerdict, maxReviewNumber } from './audit-handler.js';
|
|
6
|
+
import { resolveModel } from '../runtime/model-tiers.js';
|
|
7
|
+
import { parseAuditVerdict } from './forge-audit-verdict.js';
|
|
8
|
+
import { getSection, parsePlan } from './plan-parser.js';
|
|
9
|
+
export { parseAuditVerdict };
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Parsing
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const RESERVED_SUBCOMMANDS = new Set(['status', 'cancel', 'help', 'audit']);
|
|
14
|
+
export function parseForgeCommand(content) {
|
|
15
|
+
const trimmed = content.trim();
|
|
16
|
+
if (!trimmed.startsWith('!forge'))
|
|
17
|
+
return null;
|
|
18
|
+
// Reject !forging, !forger, etc. — must be exactly "!forge" optionally followed by whitespace.
|
|
19
|
+
const afterPrefix = trimmed.slice('!forge'.length);
|
|
20
|
+
if (afterPrefix.length > 0 && !/^\s/.test(afterPrefix))
|
|
21
|
+
return null;
|
|
22
|
+
const rest = afterPrefix.trim();
|
|
23
|
+
if (!rest)
|
|
24
|
+
return { action: 'help', args: '' };
|
|
25
|
+
const firstWord = rest.split(/\s+/)[0].toLowerCase();
|
|
26
|
+
if (RESERVED_SUBCOMMANDS.has(firstWord)) {
|
|
27
|
+
const subArgs = rest.slice(firstWord.length).trim();
|
|
28
|
+
return { action: firstWord, args: subArgs };
|
|
29
|
+
}
|
|
30
|
+
return { action: 'create', args: rest };
|
|
31
|
+
}
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Prompt builders
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
export function buildDrafterPrompt(description, templateContent, contextSummary) {
|
|
36
|
+
return [
|
|
37
|
+
'You are a senior software engineer drafting a technical implementation plan.',
|
|
38
|
+
'',
|
|
39
|
+
'## Task',
|
|
40
|
+
'',
|
|
41
|
+
description,
|
|
42
|
+
'',
|
|
43
|
+
'## Plan Template',
|
|
44
|
+
'',
|
|
45
|
+
'Fill in this template completely. Output the complete plan file content starting with `# Plan:` and ending with the Audit Log section. Output ONLY the plan markdown — no preamble, no explanation, no commentary.',
|
|
46
|
+
'',
|
|
47
|
+
'```',
|
|
48
|
+
templateContent,
|
|
49
|
+
'```',
|
|
50
|
+
'',
|
|
51
|
+
'## Project Context',
|
|
52
|
+
'',
|
|
53
|
+
contextSummary,
|
|
54
|
+
'',
|
|
55
|
+
'## Instructions',
|
|
56
|
+
'',
|
|
57
|
+
'- Read the codebase using your tools (Read, Glob, Grep) to understand the existing code before writing the plan.',
|
|
58
|
+
'- **`## Changes` is a required top-level section.** List every file that will be created, modified, or deleted with concrete file paths. Do not place file change information inside a `## Phases` section or any other section — changes belong exclusively in `## Changes`. If you need to describe implementation sequencing, use a separate `## Phases` section.',
|
|
59
|
+
'- Be specific in the `## Changes` section — include actual file paths, function names, and type signatures.',
|
|
60
|
+
'- Identify real risks and dependencies based on the actual codebase.',
|
|
61
|
+
'- Write concrete, verifiable test cases.',
|
|
62
|
+
'- Include documentation updates in the Changes section when adding new features, config options, or public APIs. Consider: docs/*.md, .env.example files, README.md, INVENTORY.md, and inline code comments.',
|
|
63
|
+
'- Set the status to DRAFT.',
|
|
64
|
+
'- Replace all {{PLACEHOLDER}} tokens with actual values. The plan ID and task ID will be filled in by the system — use `(system)` as placeholders for those.',
|
|
65
|
+
'- Output the complete plan markdown and nothing else.',
|
|
66
|
+
].join('\n');
|
|
67
|
+
}
|
|
68
|
+
export function buildAuditorPrompt(planContent, roundNumber, projectContext, opts) {
|
|
69
|
+
const sections = [
|
|
70
|
+
'You are an adversarial senior engineer auditing a technical plan. Your job is to find flaws, gaps, and risks.',
|
|
71
|
+
'',
|
|
72
|
+
];
|
|
73
|
+
if (projectContext) {
|
|
74
|
+
sections.push('## Project Context', '', 'These are standing constraints for this project. Respect them when auditing — do not flag concerns that contradict these constraints.', '', projectContext, '');
|
|
75
|
+
}
|
|
76
|
+
sections.push('## Plan to Audit', '', '```markdown', planContent, '```', '', `## This is audit round ${roundNumber}.`);
|
|
77
|
+
const instructions = [
|
|
78
|
+
...sections,
|
|
79
|
+
'',
|
|
80
|
+
'## Instructions',
|
|
81
|
+
'',
|
|
82
|
+
];
|
|
83
|
+
if (roundNumber > 1) {
|
|
84
|
+
instructions.push('### Prior Audit History', '', 'The plan contains prior audit reviews (### Review N sections) with resolutions inline. These represent concerns that were already raised and addressed in earlier rounds.', '', '- **DO NOT re-raise concerns that were adequately resolved.** If a prior resolution is sound, move on.', '- **If a prior resolution is inadequate**, reference the specific prior review (e.g., "Review 1, Concern 3\'s resolution fails because...") and explain why it doesn\'t hold. This counts as a new concern.', '- **Focus on genuinely new issues** — things not yet examined, edge cases the prior rounds missed, or problems introduced by the revisions themselves.', '');
|
|
85
|
+
}
|
|
86
|
+
const hasTools = opts?.hasTools ?? true;
|
|
87
|
+
instructions.push('Review the plan for:', '1. Missing or underspecified details (vague scope, unclear file changes)', '2. Structural integrity — the plan MUST have a `## Changes` section with concrete file paths. If file changes are described only inside a `## Phases` section (or any section other than `## Changes`), flag it as **blocking**.', '3. Architectural issues (wrong abstraction, missing error handling, wrong patterns)', '4. Risk gaps (unidentified failure modes, missing rollback plans)', '5. Test coverage gaps (missing edge cases, untested error paths)', '6. Dependency issues (circular deps, version conflicts, missing imports)', '7. Documentation gaps (does the plan update relevant docs, README, .env.example, INVENTORY.md, or inline comments for new/changed features, config options, or public APIs? Missing doc updates are medium severity.)', '');
|
|
88
|
+
if (hasTools) {
|
|
89
|
+
instructions.push('## Verification', '', 'You have read-only access to the codebase via Read, Glob, and Grep tools. **Use them before raising concerns.** Specifically:', '- Before claiming a file is missing or incomplete, Glob/Read it.', '- Before claiming test coverage gaps, Grep for existing tests.', '- Before claiming missing error handling, Read the relevant code.', '- If your concern evaporates after checking the code, do not raise it.', '');
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
instructions.push('## Verification', '', 'You do not have access to the codebase. Audit the plan based on its text alone.', '- If you are uncertain whether a file exists or a function signature is correct, note it as a concern rather than stating it as fact.', '- Focus on logical consistency, completeness, and architectural soundness.', '');
|
|
93
|
+
}
|
|
94
|
+
instructions.push('## Output Format', '', 'Start with a fenced JSON verdict block (this is required):', '```json', '{"maxSeverity":"blocking|medium|minor|suggestion|none","shouldLoop":true|false,"summary":"brief summary","concerns":[{"title":"...","severity":"blocking|medium|minor|suggestion"}]}', '```', 'Rules:', '- `maxSeverity` must reflect the highest severity in the concerns list.', '- `shouldLoop` must be true only when `maxSeverity` is `blocking`.', '- Keep `summary` concise and factual.', '', 'After the JSON block, include human-readable notes.', '', 'For each concern, use this EXACT format:', '', '**Concern N: [title]**', 'Description of the issue.', '**Severity: blocking | medium | minor | suggestion**', '', 'Severity level definitions:', '- **blocking** — Correctness bugs, security issues, architectural flaws, missing critical functionality. The plan cannot ship with this unresolved.', '- **medium** — Substantive improvements that would make the plan better but aren\'t showstoppers. Missing edge case handling, incomplete error paths.', '- **minor** — Small issues: naming, style, minor clarity gaps. Worth noting, not worth looping over.', '- **suggestion** — Ideas for future improvement. Not problems with the current plan.', '', 'IMPORTANT: Each concern MUST have its own **Severity: X** line on a separate line. Do NOT use tables, summary grids, or any other format for severity ratings — the automated revision loop parses these markers to decide whether to trigger revisions.', '', 'Then write a verdict:', '', '**Verdict:** [one of:]', '- "Needs revision." — if any blocking severity concerns exist', '- "Ready to approve." — if no blocking concerns (medium/minor/suggestion are fine)', '', 'Be thorough but fair. Don\'t nitpick style — focus on correctness, safety, and completeness.', 'Output only the JSON block plus audit notes and verdict. No preamble.');
|
|
95
|
+
return instructions.join('\n');
|
|
96
|
+
}
|
|
97
|
+
export function buildRevisionPrompt(planContent, auditNotes, description, projectContext) {
|
|
98
|
+
const sections = [
|
|
99
|
+
'You are a senior software engineer revising a technical plan based on audit feedback.',
|
|
100
|
+
'',
|
|
101
|
+
];
|
|
102
|
+
if (projectContext) {
|
|
103
|
+
sections.push('## Project Context', '', 'These are standing constraints for this project. Respect them when revising — do not re-introduce complexity that contradicts these constraints.', '', projectContext, '');
|
|
104
|
+
}
|
|
105
|
+
sections.push('## Original Description', '', description, '', '## Current Plan', '', '```markdown', planContent, '```', '', '## Audit Feedback', '', auditNotes, '', '## Instructions', '', '- Address all blocking severity concerns. Consider medium concerns if the fix is straightforward, but do not loop over them.', '- Read the codebase using your tools if needed to resolve concerns.', '- Keep the same plan structure and format.', '- Preserve resolutions from prior audit rounds that were accepted — do not weaken, revert, or remove them unless the current audit explicitly challenges them.', '- **Push back on re-raised concerns.** If a concern is a refinement or restatement of something already resolved in a prior round, you may note it as "previously addressed" in the resolution and decline to make further changes. The auditor should raise genuinely new issues, not re-litigate resolved ones from a slightly different angle.', '- **Reject perfectionism beyond the plan\'s goal.** If a concern demands a standard higher than what the plan set out to achieve (e.g., provably decodable payloads when the goal is "reject obviously broken ones"), acknowledge the concern but explain why the current approach is sufficient. Not every valid observation requires a code change.', '- Output the complete revised plan markdown starting with `# Plan:`. Output ONLY the plan markdown — no preamble, no explanation.');
|
|
106
|
+
return sections.join('\n');
|
|
107
|
+
}
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Plan summary extraction
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
export function buildPlanSummary(planContent) {
|
|
112
|
+
const header = parsePlanFileHeader(planContent);
|
|
113
|
+
const parsedPlan = parsePlan(planContent);
|
|
114
|
+
const objective = getSection(parsedPlan, 'Objective') || '(no objective)';
|
|
115
|
+
// Extract scope (just the "In:" section if present, otherwise the whole scope block)
|
|
116
|
+
const scopeBlock = getSection(parsedPlan, 'Scope');
|
|
117
|
+
let scope = '';
|
|
118
|
+
if (scopeBlock) {
|
|
119
|
+
const scopeText = scopeBlock.trim();
|
|
120
|
+
const inMatch = scopeText.match(/\*\*In:\*\*\s*\n([\s\S]*?)(?=\n\*\*Out:\*\*|$)/);
|
|
121
|
+
scope = inMatch?.[1]?.trim() || scopeText;
|
|
122
|
+
}
|
|
123
|
+
// Extract changed files (look for file paths in the Changes section)
|
|
124
|
+
const changesBlock = getSection(parsedPlan, 'Changes');
|
|
125
|
+
const files = [];
|
|
126
|
+
if (changesBlock) {
|
|
127
|
+
const fileMatches = changesBlock.matchAll(/####\s+`([^`]+)`/g);
|
|
128
|
+
for (const m of fileMatches) {
|
|
129
|
+
files.push(m[1]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const lines = [];
|
|
133
|
+
if (header) {
|
|
134
|
+
lines.push(`**${header.planId}** — ${header.title}`);
|
|
135
|
+
lines.push(`Status: ${header.status} | Task: \`${resolvePlanHeaderTaskId(header)}\``);
|
|
136
|
+
lines.push('');
|
|
137
|
+
}
|
|
138
|
+
lines.push(`**Objective:** ${objective}`);
|
|
139
|
+
if (scope) {
|
|
140
|
+
lines.push('');
|
|
141
|
+
lines.push(`**Scope:**`);
|
|
142
|
+
lines.push(scope);
|
|
143
|
+
}
|
|
144
|
+
if (files.length > 0) {
|
|
145
|
+
lines.push('');
|
|
146
|
+
lines.push(`**Files:** ${files.map((f) => `\`${f}\``).join(', ')}`);
|
|
147
|
+
}
|
|
148
|
+
return lines.join('\n');
|
|
149
|
+
}
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Audit-round append (standalone, used by ForgeOrchestrator and !plan audit)
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
export function appendAuditRound(planContent, round, auditNotes, verdict) {
|
|
154
|
+
const date = new Date().toISOString().split('T')[0];
|
|
155
|
+
const verdictText = verdict.shouldLoop ? 'Needs revision.' : 'Ready to approve.';
|
|
156
|
+
const auditSection = [
|
|
157
|
+
'',
|
|
158
|
+
`### Review ${round} — ${date}`,
|
|
159
|
+
`**Status:** COMPLETE`,
|
|
160
|
+
'',
|
|
161
|
+
auditNotes.trim(),
|
|
162
|
+
'',
|
|
163
|
+
].join('\n');
|
|
164
|
+
// Insert before Implementation Notes section
|
|
165
|
+
const implNotesIdx = planContent.indexOf('## Implementation Notes');
|
|
166
|
+
if (implNotesIdx !== -1) {
|
|
167
|
+
return (planContent.slice(0, implNotesIdx) +
|
|
168
|
+
auditSection +
|
|
169
|
+
'\n---\n\n' +
|
|
170
|
+
planContent.slice(implNotesIdx));
|
|
171
|
+
}
|
|
172
|
+
// Fallback: append at end
|
|
173
|
+
return planContent + '\n' + auditSection;
|
|
174
|
+
}
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Runtime event-forwarding wrapper
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
/**
|
|
179
|
+
* Wraps a RuntimeAdapter so every emitted EngineEvent is forwarded to
|
|
180
|
+
* `onEvent` before being yielded to the pipeline engine. Errors thrown by
|
|
181
|
+
* `onEvent` are swallowed to prevent UI callbacks from aborting execution.
|
|
182
|
+
*/
|
|
183
|
+
function wrapWithEventForwarding(rt, onEvent) {
|
|
184
|
+
return {
|
|
185
|
+
id: rt.id,
|
|
186
|
+
capabilities: rt.capabilities,
|
|
187
|
+
invoke(params) {
|
|
188
|
+
return (async function* () {
|
|
189
|
+
for await (const evt of rt.invoke(params)) {
|
|
190
|
+
try {
|
|
191
|
+
onEvent(evt);
|
|
192
|
+
}
|
|
193
|
+
catch { /* UI callback errors must not abort execution */ }
|
|
194
|
+
yield evt;
|
|
195
|
+
}
|
|
196
|
+
})();
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// ForgeOrchestrator
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
export class ForgeOrchestrator {
|
|
204
|
+
running = false;
|
|
205
|
+
cancelRequested = false;
|
|
206
|
+
abortController = new AbortController();
|
|
207
|
+
currentPlanId;
|
|
208
|
+
opts;
|
|
209
|
+
constructor(opts) {
|
|
210
|
+
this.opts = opts;
|
|
211
|
+
}
|
|
212
|
+
get isRunning() {
|
|
213
|
+
return this.running;
|
|
214
|
+
}
|
|
215
|
+
get activePlanId() {
|
|
216
|
+
return this.running ? this.currentPlanId : undefined;
|
|
217
|
+
}
|
|
218
|
+
requestCancel() {
|
|
219
|
+
this.cancelRequested = true;
|
|
220
|
+
this.abortController.abort();
|
|
221
|
+
}
|
|
222
|
+
async run(description, onProgress, context, onEvent) {
|
|
223
|
+
if (this.running) {
|
|
224
|
+
throw new Error('A forge is already running');
|
|
225
|
+
}
|
|
226
|
+
this.running = true;
|
|
227
|
+
this.cancelRequested = false;
|
|
228
|
+
this.abortController = new AbortController();
|
|
229
|
+
this.currentPlanId = undefined;
|
|
230
|
+
const t0 = Date.now();
|
|
231
|
+
let planId = '';
|
|
232
|
+
let filePath = '';
|
|
233
|
+
try {
|
|
234
|
+
// 1. Create the plan file with a typed response.
|
|
235
|
+
// Pass context separately so task title/slug stay clean (context goes in plan body).
|
|
236
|
+
const created = await createPlan({
|
|
237
|
+
description,
|
|
238
|
+
context,
|
|
239
|
+
existingTaskId: this.opts.existingTaskId,
|
|
240
|
+
}, {
|
|
241
|
+
workspaceCwd: this.opts.workspaceCwd,
|
|
242
|
+
taskStore: this.opts.taskStore,
|
|
243
|
+
plansDir: this.opts.plansDir,
|
|
244
|
+
});
|
|
245
|
+
planId = created.planId;
|
|
246
|
+
filePath = created.filePath;
|
|
247
|
+
this.currentPlanId = planId;
|
|
248
|
+
const plansDir = this.opts.plansDir;
|
|
249
|
+
// Load the template for the drafter prompt
|
|
250
|
+
let templateContent;
|
|
251
|
+
try {
|
|
252
|
+
templateContent = await fs.readFile(path.join(plansDir, '.plan-template.md'), 'utf-8');
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// Use a simple fallback
|
|
256
|
+
templateContent = await fs.readFile(filePath, 'utf-8');
|
|
257
|
+
}
|
|
258
|
+
// Load project context once — used by drafter (via context summary), auditor, and reviser
|
|
259
|
+
const projectContext = await this.loadProjectContext();
|
|
260
|
+
// Build context summary from workspace files (includes project context and additional thread info)
|
|
261
|
+
const contextSummary = await this.buildContextSummary(projectContext, {
|
|
262
|
+
taskDescription: this.opts.taskDescription,
|
|
263
|
+
pinnedThreadSummary: this.opts.pinnedThreadSummary,
|
|
264
|
+
});
|
|
265
|
+
return await this.auditLoop({
|
|
266
|
+
planId,
|
|
267
|
+
filePath,
|
|
268
|
+
description: context ? `${description}\n\n${context}` : description,
|
|
269
|
+
startRound: 1,
|
|
270
|
+
onProgress,
|
|
271
|
+
onEvent,
|
|
272
|
+
projectContext,
|
|
273
|
+
// Draft-phase specifics (only used when startRound === 1)
|
|
274
|
+
templateContent,
|
|
275
|
+
contextSummary,
|
|
276
|
+
t0,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
const errorMsg = String(err instanceof Error ? err.message : err);
|
|
281
|
+
this.opts.log?.error({ err, planId }, 'forge:error');
|
|
282
|
+
// Write partial state if we have a file
|
|
283
|
+
if (filePath) {
|
|
284
|
+
try {
|
|
285
|
+
await this.updatePlanStatus(filePath, 'DRAFT');
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// best-effort
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
await onProgress(`Forge failed${planId ? ` during ${planId}` : ''}: ${errorMsg}${filePath ? `. Partial plan saved: \`!plan show ${planId}\`` : ''}`, { force: true });
|
|
292
|
+
return {
|
|
293
|
+
planId: planId || '(none)',
|
|
294
|
+
filePath: filePath || '',
|
|
295
|
+
finalVerdict: 'error',
|
|
296
|
+
rounds: 0,
|
|
297
|
+
reachedMaxRounds: false,
|
|
298
|
+
error: errorMsg,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
this.running = false;
|
|
303
|
+
this.currentPlanId = undefined;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async resume(planId, filePath, planTitle, onProgress, onEvent) {
|
|
307
|
+
if (this.running) {
|
|
308
|
+
throw new Error('A forge is already running');
|
|
309
|
+
}
|
|
310
|
+
this.running = true;
|
|
311
|
+
this.cancelRequested = false;
|
|
312
|
+
this.abortController = new AbortController();
|
|
313
|
+
this.currentPlanId = planId;
|
|
314
|
+
const t0 = Date.now();
|
|
315
|
+
let originalStatus = '';
|
|
316
|
+
try {
|
|
317
|
+
const planContent = await fs.readFile(filePath, 'utf-8');
|
|
318
|
+
const header = parsePlanFileHeader(planContent);
|
|
319
|
+
originalStatus = header?.status ?? '';
|
|
320
|
+
// Validate plan status
|
|
321
|
+
if (originalStatus === 'IMPLEMENTING') {
|
|
322
|
+
throw new Error('Plan is currently being implemented. Use `!plan cancel` to stop it first.');
|
|
323
|
+
}
|
|
324
|
+
if (originalStatus === 'APPROVED') {
|
|
325
|
+
throw new Error('Plan is approved — re-auditing would downgrade its status. Use `!plan audit` for a standalone audit instead.');
|
|
326
|
+
}
|
|
327
|
+
// Structural pre-flight: reject plans with high or medium structural issues
|
|
328
|
+
const structuralConcerns = auditPlanStructure(planContent);
|
|
329
|
+
const structuralVerdict = deriveVerdict(structuralConcerns);
|
|
330
|
+
if (structuralVerdict.shouldLoop) {
|
|
331
|
+
const gating = structuralConcerns.filter((c) => c.severity === 'high' || c.severity === 'medium');
|
|
332
|
+
const missing = gating.map((c) => c.title).join(', ');
|
|
333
|
+
throw new Error(`Plan has structural issues: ${missing}. Fix the plan file before re-auditing.`);
|
|
334
|
+
}
|
|
335
|
+
// Load project context
|
|
336
|
+
const projectContext = await this.loadProjectContext();
|
|
337
|
+
// Determine start round from existing reviews
|
|
338
|
+
const startRound = maxReviewNumber(planContent) + 1;
|
|
339
|
+
return await this.auditLoop({
|
|
340
|
+
planId,
|
|
341
|
+
filePath,
|
|
342
|
+
description: planTitle,
|
|
343
|
+
startRound,
|
|
344
|
+
onProgress,
|
|
345
|
+
onEvent,
|
|
346
|
+
projectContext,
|
|
347
|
+
t0,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
const errorMsg = String(err instanceof Error ? err.message : err);
|
|
352
|
+
this.opts.log?.error({ err, planId }, 'forge:resume:error');
|
|
353
|
+
// Best-effort: restore original status if we changed it
|
|
354
|
+
if (filePath && originalStatus) {
|
|
355
|
+
try {
|
|
356
|
+
await this.updatePlanStatus(filePath, originalStatus);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// best-effort
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
await onProgress(`Forge resume failed for ${planId}: ${errorMsg}`, { force: true });
|
|
363
|
+
return {
|
|
364
|
+
planId,
|
|
365
|
+
filePath,
|
|
366
|
+
finalVerdict: 'error',
|
|
367
|
+
rounds: 0,
|
|
368
|
+
reachedMaxRounds: false,
|
|
369
|
+
error: errorMsg,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
this.running = false;
|
|
374
|
+
this.currentPlanId = undefined;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// -----------------------------------------------------------------------
|
|
378
|
+
// Private helpers
|
|
379
|
+
// -----------------------------------------------------------------------
|
|
380
|
+
async auditLoop(params) {
|
|
381
|
+
const { planId, filePath, description, startRound, onProgress, onEvent, projectContext, templateContent, contextSummary, } = params;
|
|
382
|
+
const t0 = params.t0 ?? Date.now();
|
|
383
|
+
const rawDrafterModel = this.opts.drafterModel ?? this.opts.model;
|
|
384
|
+
const rawAuditorModel = this.opts.auditorModel ?? this.opts.model;
|
|
385
|
+
const drafterRt = this.opts.drafterRuntime ?? this.opts.runtime;
|
|
386
|
+
const isClaudeDrafter = drafterRt.id === 'claude_code';
|
|
387
|
+
const hasExplicitDrafterModel = Boolean(this.opts.drafterModel);
|
|
388
|
+
const drafterModel = isClaudeDrafter
|
|
389
|
+
? resolveModel(rawDrafterModel, drafterRt.id)
|
|
390
|
+
: (hasExplicitDrafterModel ? resolveModel(rawDrafterModel, drafterRt.id) : '');
|
|
391
|
+
const readOnlyTools = ['Read', 'Glob', 'Grep'];
|
|
392
|
+
const addDirs = [this.opts.cwd];
|
|
393
|
+
// Stable session keys — one per role — enable multi-turn reuse across
|
|
394
|
+
// the audit-revise loop. Keys use raw (pre-resolution) tier names so
|
|
395
|
+
// they remain stable if the tier→model mapping changes.
|
|
396
|
+
const drafterSessionKey = `forge:${planId}:${rawDrafterModel}:drafter`;
|
|
397
|
+
const auditorSessionKey = `forge:${planId}:${rawAuditorModel}:auditor`;
|
|
398
|
+
// Wrap drafter runtime to forward events to the onEvent callback when provided.
|
|
399
|
+
const effectiveDrafterRt = onEvent ? wrapWithEventForwarding(drafterRt, onEvent) : drafterRt;
|
|
400
|
+
let round = startRound - 1; // will be incremented at top of loop
|
|
401
|
+
let planContent = await fs.readFile(filePath, 'utf-8');
|
|
402
|
+
let lastAuditNotes = '';
|
|
403
|
+
let lastVerdict = { maxSeverity: 'none', shouldLoop: false };
|
|
404
|
+
// The effective max round number is startRound + maxAuditRounds - 1
|
|
405
|
+
const maxRound = startRound + this.opts.maxAuditRounds - 1;
|
|
406
|
+
while (round < maxRound) {
|
|
407
|
+
if (this.cancelRequested) {
|
|
408
|
+
await this.updatePlanStatus(filePath, 'CANCELLED');
|
|
409
|
+
return {
|
|
410
|
+
planId,
|
|
411
|
+
filePath,
|
|
412
|
+
finalVerdict: 'CANCELLED',
|
|
413
|
+
rounds: round - startRound + 1,
|
|
414
|
+
reachedMaxRounds: false,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
round++;
|
|
418
|
+
// Draft phase (only on first round of a fresh forge, not resume)
|
|
419
|
+
if (round === 1 && startRound === 1 && templateContent && contextSummary) {
|
|
420
|
+
await onProgress(`Forging ${planId}... Drafting (reading codebase)`);
|
|
421
|
+
const drafterPrompt = buildDrafterPrompt(description, templateContent, contextSummary);
|
|
422
|
+
const draftPipelineResult = await this.runCancellable({
|
|
423
|
+
steps: [{
|
|
424
|
+
kind: 'prompt',
|
|
425
|
+
prompt: drafterPrompt,
|
|
426
|
+
runtime: effectiveDrafterRt,
|
|
427
|
+
model: drafterModel,
|
|
428
|
+
tools: readOnlyTools,
|
|
429
|
+
addDirs,
|
|
430
|
+
timeoutMs: this.opts.timeoutMs,
|
|
431
|
+
sessionKey: drafterRt.capabilities.has('sessions') ? drafterSessionKey : undefined,
|
|
432
|
+
}],
|
|
433
|
+
runtime: this.opts.runtime,
|
|
434
|
+
cwd: this.opts.cwd,
|
|
435
|
+
model: this.opts.model,
|
|
436
|
+
signal: this.abortController.signal,
|
|
437
|
+
});
|
|
438
|
+
if (!draftPipelineResult) {
|
|
439
|
+
await this.updatePlanStatus(filePath, 'CANCELLED');
|
|
440
|
+
return {
|
|
441
|
+
planId,
|
|
442
|
+
filePath,
|
|
443
|
+
finalVerdict: 'CANCELLED',
|
|
444
|
+
rounds: round - startRound + 1,
|
|
445
|
+
reachedMaxRounds: false,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const draftOutput = draftPipelineResult.outputs[0] ?? '';
|
|
449
|
+
// Write the draft — preserve the header (planId, taskId) from the created file.
|
|
450
|
+
planContent = this.mergeDraftWithHeader(planContent, draftOutput);
|
|
451
|
+
await this.atomicWrite(filePath, planContent);
|
|
452
|
+
// Update task title to match the drafter's Plan title (raw user input is often messy).
|
|
453
|
+
const drafterTitleMatch = draftOutput.match(/^# Plan:\s*(.+)$/m);
|
|
454
|
+
const mergedHeader = parsePlanFileHeader(planContent);
|
|
455
|
+
const drafterTitle = drafterTitleMatch?.[1]?.trim();
|
|
456
|
+
const taskId = mergedHeader ? resolvePlanHeaderTaskId(mergedHeader) : '';
|
|
457
|
+
if (taskId && drafterTitle && drafterTitle !== description) {
|
|
458
|
+
try {
|
|
459
|
+
this.opts.taskStore.update(taskId, { title: drafterTitle });
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
// best-effort — task title update failure shouldn't block the forge
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
else if (round > startRound) {
|
|
467
|
+
await onProgress(`Forging ${planId}... Revision complete. Audit round ${round}/${maxRound}...`);
|
|
468
|
+
}
|
|
469
|
+
// Audit phase
|
|
470
|
+
await onProgress(round === startRound && startRound === 1
|
|
471
|
+
? `Forging ${planId}... Draft complete. Audit round ${round}/${maxRound}...`
|
|
472
|
+
: `Forging ${planId}... Audit round ${round}/${maxRound}...`);
|
|
473
|
+
const auditorRt = this.opts.auditorRuntime ?? this.opts.runtime;
|
|
474
|
+
const isClaudeAuditor = auditorRt.id === 'claude_code';
|
|
475
|
+
const auditorHasFileTools = auditorRt.capabilities.has('tools_fs');
|
|
476
|
+
const hasExplicitAuditorModel = Boolean(this.opts.auditorModel);
|
|
477
|
+
const effectiveAuditorModel = isClaudeAuditor
|
|
478
|
+
? resolveModel(rawAuditorModel, auditorRt.id)
|
|
479
|
+
: (hasExplicitAuditorModel ? resolveModel(rawAuditorModel, auditorRt.id) : '');
|
|
480
|
+
const auditorPrompt = buildAuditorPrompt(planContent, round, projectContext, { hasTools: auditorHasFileTools });
|
|
481
|
+
const effectiveAuditorRt = onEvent ? wrapWithEventForwarding(auditorRt, onEvent) : auditorRt;
|
|
482
|
+
const auditPipelineResult = await this.runCancellable({
|
|
483
|
+
steps: [{
|
|
484
|
+
kind: 'prompt',
|
|
485
|
+
prompt: auditorPrompt,
|
|
486
|
+
runtime: effectiveAuditorRt,
|
|
487
|
+
model: effectiveAuditorModel,
|
|
488
|
+
tools: auditorHasFileTools ? readOnlyTools : [],
|
|
489
|
+
...(auditorHasFileTools ? { addDirs } : {}),
|
|
490
|
+
timeoutMs: this.opts.timeoutMs,
|
|
491
|
+
sessionKey: auditorRt.capabilities.has('sessions') ? auditorSessionKey : undefined,
|
|
492
|
+
}],
|
|
493
|
+
runtime: this.opts.runtime,
|
|
494
|
+
cwd: this.opts.cwd,
|
|
495
|
+
model: this.opts.model,
|
|
496
|
+
signal: this.abortController.signal,
|
|
497
|
+
});
|
|
498
|
+
if (!auditPipelineResult) {
|
|
499
|
+
await this.updatePlanStatus(filePath, 'CANCELLED');
|
|
500
|
+
return {
|
|
501
|
+
planId,
|
|
502
|
+
filePath,
|
|
503
|
+
finalVerdict: 'CANCELLED',
|
|
504
|
+
rounds: round - startRound + 1,
|
|
505
|
+
reachedMaxRounds: false,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
const auditOutput = auditPipelineResult.outputs[0] ?? '';
|
|
509
|
+
lastAuditNotes = auditOutput;
|
|
510
|
+
lastVerdict = parseAuditVerdict(auditOutput);
|
|
511
|
+
// Append audit notes to the plan file
|
|
512
|
+
planContent = appendAuditRound(planContent, round, auditOutput, lastVerdict);
|
|
513
|
+
await this.atomicWrite(filePath, planContent);
|
|
514
|
+
// Check if we should loop
|
|
515
|
+
if (!lastVerdict.shouldLoop) {
|
|
516
|
+
await this.updatePlanStatus(filePath, 'REVIEW');
|
|
517
|
+
// Re-read to get updated status in the summary
|
|
518
|
+
planContent = await fs.readFile(filePath, 'utf-8');
|
|
519
|
+
const summary = buildPlanSummary(planContent);
|
|
520
|
+
const elapsed = Math.round((Date.now() - t0) / 1000);
|
|
521
|
+
await onProgress(`Forge complete. Plan ${planId} ready for review (${round - startRound + 1} round${round - startRound + 1 > 1 ? 's' : ''}, ${elapsed}s)`, { force: true });
|
|
522
|
+
return {
|
|
523
|
+
planId,
|
|
524
|
+
filePath,
|
|
525
|
+
finalVerdict: lastVerdict.maxSeverity,
|
|
526
|
+
rounds: round - startRound + 1,
|
|
527
|
+
reachedMaxRounds: false,
|
|
528
|
+
planSummary: summary,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
// Check if we've hit the cap
|
|
532
|
+
if (round >= maxRound) {
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
// Revision phase
|
|
536
|
+
await onProgress(`Forging ${planId}... Audit round ${round} found ${lastVerdict.maxSeverity} concerns. Revising...`);
|
|
537
|
+
const revisionPrompt = buildRevisionPrompt(planContent, auditOutput, description, projectContext);
|
|
538
|
+
const revisionPipelineResult = await this.runCancellable({
|
|
539
|
+
steps: [{
|
|
540
|
+
kind: 'prompt',
|
|
541
|
+
prompt: revisionPrompt,
|
|
542
|
+
runtime: effectiveDrafterRt,
|
|
543
|
+
model: drafterModel,
|
|
544
|
+
tools: readOnlyTools,
|
|
545
|
+
addDirs,
|
|
546
|
+
timeoutMs: this.opts.timeoutMs,
|
|
547
|
+
sessionKey: drafterRt.capabilities.has('sessions') ? drafterSessionKey : undefined,
|
|
548
|
+
}],
|
|
549
|
+
runtime: this.opts.runtime,
|
|
550
|
+
cwd: this.opts.cwd,
|
|
551
|
+
model: this.opts.model,
|
|
552
|
+
signal: this.abortController.signal,
|
|
553
|
+
});
|
|
554
|
+
if (!revisionPipelineResult) {
|
|
555
|
+
await this.updatePlanStatus(filePath, 'CANCELLED');
|
|
556
|
+
return {
|
|
557
|
+
planId,
|
|
558
|
+
filePath,
|
|
559
|
+
finalVerdict: 'CANCELLED',
|
|
560
|
+
rounds: round - startRound + 1,
|
|
561
|
+
reachedMaxRounds: false,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
const revisionOutput = revisionPipelineResult.outputs[0] ?? '';
|
|
565
|
+
planContent = this.mergeDraftWithHeader(planContent, revisionOutput);
|
|
566
|
+
await this.atomicWrite(filePath, planContent);
|
|
567
|
+
}
|
|
568
|
+
// Cap reached
|
|
569
|
+
planContent = planContent.replace(/(\n---\n\n## Implementation Notes)/, `\n\nVERDICT: CAP_REACHED\n$1`);
|
|
570
|
+
await this.atomicWrite(filePath, planContent);
|
|
571
|
+
await this.updatePlanStatus(filePath, 'REVIEW');
|
|
572
|
+
// Re-read to get updated status in the summary
|
|
573
|
+
planContent = await fs.readFile(filePath, 'utf-8');
|
|
574
|
+
const summary = buildPlanSummary(planContent);
|
|
575
|
+
const elapsed = Math.round((Date.now() - t0) / 1000);
|
|
576
|
+
await onProgress(`Forge stopped after ${this.opts.maxAuditRounds} audit rounds — concerns remain. Review manually: \`!plan show ${planId}\``, { force: true });
|
|
577
|
+
return {
|
|
578
|
+
planId,
|
|
579
|
+
filePath,
|
|
580
|
+
finalVerdict: lastVerdict.maxSeverity,
|
|
581
|
+
rounds: round - startRound + 1,
|
|
582
|
+
reachedMaxRounds: true,
|
|
583
|
+
planSummary: summary,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
async buildContextSummary(projectContext, opts) {
|
|
587
|
+
const contextFiles = ['SOUL.md', 'IDENTITY.md', 'USER.md', 'TOOLS.md'];
|
|
588
|
+
const sections = [];
|
|
589
|
+
for (const name of contextFiles) {
|
|
590
|
+
const p = path.join(this.opts.workspaceCwd, name);
|
|
591
|
+
try {
|
|
592
|
+
const content = await fs.readFile(p, 'utf-8');
|
|
593
|
+
sections.push(`--- ${name} ---\n${content.trimEnd()}`);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// skip missing files
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// Append project context if already loaded
|
|
600
|
+
if (projectContext) {
|
|
601
|
+
sections.push(`--- project.md (repo) ---\n${projectContext.trimEnd()}`);
|
|
602
|
+
}
|
|
603
|
+
// Append tools context from repo .context/ directory
|
|
604
|
+
try {
|
|
605
|
+
const toolsContextPath = path.join(this.opts.cwd, '.context', 'tools.md');
|
|
606
|
+
const toolsContent = await fs.readFile(toolsContextPath, 'utf-8');
|
|
607
|
+
sections.push(`--- tools.md (repo) ---\n${toolsContent.trimEnd()}`);
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
// skip if missing
|
|
611
|
+
}
|
|
612
|
+
const taskDescription = opts?.taskDescription;
|
|
613
|
+
if (taskDescription) {
|
|
614
|
+
sections.push(`--- task-description (thread) ---\n${taskDescription.trim()}`);
|
|
615
|
+
}
|
|
616
|
+
if (opts?.pinnedThreadSummary) {
|
|
617
|
+
sections.push(`--- pinned-thread summary ---\n${opts.pinnedThreadSummary.trim()}`);
|
|
618
|
+
}
|
|
619
|
+
if (sections.length === 0) {
|
|
620
|
+
return '(No workspace context files found.)';
|
|
621
|
+
}
|
|
622
|
+
return sections.join('\n\n');
|
|
623
|
+
}
|
|
624
|
+
async loadProjectContext() {
|
|
625
|
+
const projectContextPath = path.join(this.opts.cwd, '.context', 'project.md');
|
|
626
|
+
try {
|
|
627
|
+
const content = await fs.readFile(projectContextPath, 'utf-8');
|
|
628
|
+
return content.trim() || undefined;
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return undefined;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Merge drafter output into the plan file, preserving the system-generated header
|
|
636
|
+
* (plan ID, task ID, created date) from the original file.
|
|
637
|
+
*/
|
|
638
|
+
mergeDraftWithHeader(originalContent, draftOutput) {
|
|
639
|
+
// Extract the header from the original file (up to and including the first ---)
|
|
640
|
+
const headerMatch = originalContent.match(/^([\s\S]*?\*\*Project:\*\*[^\n]*\n)/);
|
|
641
|
+
if (!headerMatch)
|
|
642
|
+
return draftOutput;
|
|
643
|
+
const header = headerMatch[1];
|
|
644
|
+
// Strip any header the drafter may have generated
|
|
645
|
+
const draftBody = draftOutput.replace(/^[\s\S]*?\*\*Project:\*\*[^\n]*\n/, '');
|
|
646
|
+
// If the drafter didn't include a header, just prepend the original one
|
|
647
|
+
if (draftBody === draftOutput) {
|
|
648
|
+
// The drafter output doesn't have the header pattern — prepend the original header
|
|
649
|
+
const planTitleMatch = draftOutput.match(/^# Plan:[^\n]*\n/);
|
|
650
|
+
if (planTitleMatch) {
|
|
651
|
+
// Has a plan title but different header format — replace just the metadata
|
|
652
|
+
const titleLine = planTitleMatch[0];
|
|
653
|
+
const afterTitle = draftOutput.slice(titleLine.length);
|
|
654
|
+
const originalTitle = header.match(/^# Plan:[^\n]*\n/)?.[0] ?? '';
|
|
655
|
+
return header.replace(originalTitle, titleLine) + afterTitle;
|
|
656
|
+
}
|
|
657
|
+
return header + '\n---\n\n' + draftOutput;
|
|
658
|
+
}
|
|
659
|
+
return header + draftBody;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Runs `runPipeline` with the abort signal attached. Returns null if the run
|
|
663
|
+
* was cancelled (either the pipeline threw while `cancelRequested` was set, or
|
|
664
|
+
* it returned normally but `cancelRequested` was set before outputs are used).
|
|
665
|
+
*/
|
|
666
|
+
async runCancellable(def) {
|
|
667
|
+
try {
|
|
668
|
+
const result = await runPipeline(def);
|
|
669
|
+
if (this.cancelRequested)
|
|
670
|
+
return null;
|
|
671
|
+
return result;
|
|
672
|
+
}
|
|
673
|
+
catch (err) {
|
|
674
|
+
if (this.cancelRequested)
|
|
675
|
+
return null;
|
|
676
|
+
throw err;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async atomicWrite(filePath, content) {
|
|
680
|
+
const tmpPath = filePath + '.tmp';
|
|
681
|
+
await fs.writeFile(tmpPath, content, 'utf-8');
|
|
682
|
+
await fs.rename(tmpPath, filePath);
|
|
683
|
+
}
|
|
684
|
+
async updatePlanStatus(filePath, newStatus) {
|
|
685
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
686
|
+
const updated = content.replace(/^\*\*Status:\*\*\s*.+$/m, `**Status:** ${newStatus}`);
|
|
687
|
+
await this.atomicWrite(filePath, updated);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
export const legacyPlanImplementationCta = (planId) => `Reply \`!plan approve ${planId}\` to approve, then \`!plan run ${planId}\` to start implementation. Or \`!plan show ${planId}\` to review first.`;
|
|
691
|
+
export function buildPlanImplementationMessage(skipReason, planId) {
|
|
692
|
+
const cta = legacyPlanImplementationCta(planId);
|
|
693
|
+
if (!skipReason)
|
|
694
|
+
return cta;
|
|
695
|
+
if (skipReason.includes(cta))
|
|
696
|
+
return skipReason;
|
|
697
|
+
return `${skipReason}\n\n${cta}`;
|
|
698
|
+
}
|