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,2380 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fsSync from 'node:fs';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import { computePlanHash, extractFilePaths, groupFiles, extractChangeSpec, decomposePlan, serializePhases, deserializePhases, getNextPhase, updatePhaseStatus, checkStaleness, buildPhasePrompt, buildAuditFixPrompt, buildPostRunSummary, extractObjective, resolveProjectCwd, resolveContextFilePath, writePhasesFile, readPhasesFile, executePhase, runNextPhase, } from './plan-manager.js';
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
async function makeTmpDir() {
|
|
12
|
+
return fs.mkdtemp(path.join(os.tmpdir(), 'plan-manager-test-'));
|
|
13
|
+
}
|
|
14
|
+
function makeRuntime(events) {
|
|
15
|
+
return {
|
|
16
|
+
id: 'claude_code',
|
|
17
|
+
capabilities: new Set(['streaming_text']),
|
|
18
|
+
async *invoke() {
|
|
19
|
+
for (const evt of events)
|
|
20
|
+
yield evt;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function makeSuccessRuntime(text) {
|
|
25
|
+
return makeRuntime([
|
|
26
|
+
{ type: 'text_delta', text },
|
|
27
|
+
{ type: 'text_final', text },
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
function makeErrorRuntime(msg) {
|
|
31
|
+
return makeRuntime([
|
|
32
|
+
{ type: 'error', message: msg },
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
const SAMPLE_PLAN = `# Plan: Add phase manager
|
|
36
|
+
|
|
37
|
+
**ID:** plan-011
|
|
38
|
+
**Task:** ws-test
|
|
39
|
+
**Created:** 2026-02-12
|
|
40
|
+
**Status:** APPROVED
|
|
41
|
+
**Project:** discoclaw
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Objective
|
|
46
|
+
|
|
47
|
+
Add a plan manager that decomposes complex plans into phases.
|
|
48
|
+
|
|
49
|
+
## Changes
|
|
50
|
+
|
|
51
|
+
### File-by-file breakdown
|
|
52
|
+
|
|
53
|
+
- \`src/discord/plan-manager.ts\` — New file. Core phase logic.
|
|
54
|
+
- Types: PlanPhase, PlanPhases
|
|
55
|
+
- Functions: decomposePlan, serializePhases
|
|
56
|
+
|
|
57
|
+
- \`src/discord/plan-manager.test.ts\` — New file. Unit tests.
|
|
58
|
+
- Tests for decomposePlan
|
|
59
|
+
- Tests for serialization
|
|
60
|
+
|
|
61
|
+
- \`src/discord/plan-commands.ts\` — Add phases/run/skip subcommands.
|
|
62
|
+
- Expand PlanCommand action union
|
|
63
|
+
- Add RESERVED_SUBCOMMANDS entries
|
|
64
|
+
|
|
65
|
+
- \`src/config.ts\` — Add config entries.
|
|
66
|
+
- PLAN_PHASES_ENABLED
|
|
67
|
+
- PLAN_PHASE_MAX_CONTEXT_FILES
|
|
68
|
+
|
|
69
|
+
- \`src/discord.ts\` — Wire async execution.
|
|
70
|
+
- Writer lock
|
|
71
|
+
- Run/skip interceptors
|
|
72
|
+
|
|
73
|
+
- \`workspace/TOOLS.md\` — Document phase manager.
|
|
74
|
+
|
|
75
|
+
## Risks
|
|
76
|
+
|
|
77
|
+
- Context overflow
|
|
78
|
+
- Phase ordering
|
|
79
|
+
|
|
80
|
+
## Testing
|
|
81
|
+
|
|
82
|
+
Unit tests for all functions.
|
|
83
|
+
`;
|
|
84
|
+
const SAMPLE_PLAN_NO_CHANGES = `# Plan: Audit plan-010
|
|
85
|
+
|
|
86
|
+
**ID:** plan-010
|
|
87
|
+
**Task:** ws-audit
|
|
88
|
+
**Created:** 2026-02-12
|
|
89
|
+
**Status:** REVIEW
|
|
90
|
+
**Project:** discoclaw
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Objective
|
|
95
|
+
|
|
96
|
+
Review plan-010 implementation quality.
|
|
97
|
+
|
|
98
|
+
## Scope
|
|
99
|
+
|
|
100
|
+
Audit only — no code changes.
|
|
101
|
+
|
|
102
|
+
## Risks
|
|
103
|
+
|
|
104
|
+
None identified.
|
|
105
|
+
`;
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// computePlanHash
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
describe('computePlanHash', () => {
|
|
110
|
+
it('returns 16 hex chars', () => {
|
|
111
|
+
const hash = computePlanHash('test content');
|
|
112
|
+
expect(hash).toMatch(/^[0-9a-f]{16}$/);
|
|
113
|
+
});
|
|
114
|
+
it('same input = same hash', () => {
|
|
115
|
+
expect(computePlanHash('abc')).toBe(computePlanHash('abc'));
|
|
116
|
+
});
|
|
117
|
+
it('different input = different hash', () => {
|
|
118
|
+
expect(computePlanHash('abc')).not.toBe(computePlanHash('def'));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// extractFilePaths
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
describe('extractFilePaths', () => {
|
|
125
|
+
it('extracts standard format paths', () => {
|
|
126
|
+
const section = '- `src/discord/plan-commands.ts` — Add phases\n- `src/config.ts` — Add config';
|
|
127
|
+
expect(extractFilePaths(section)).toEqual([
|
|
128
|
+
'src/discord/plan-commands.ts',
|
|
129
|
+
'src/config.ts',
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
it('rejects backticked type names', () => {
|
|
133
|
+
const section = '- `PlanPhase` type definition\n- `src/foo.ts` — real file';
|
|
134
|
+
expect(extractFilePaths(section)).toEqual(['src/foo.ts']);
|
|
135
|
+
});
|
|
136
|
+
it('rejects backticked config keys', () => {
|
|
137
|
+
const section = '- `PLAN_PHASES_ENABLED` — config\n- `src/config.ts` — file';
|
|
138
|
+
expect(extractFilePaths(section)).toEqual(['src/config.ts']);
|
|
139
|
+
});
|
|
140
|
+
it('rejects quoted strings', () => {
|
|
141
|
+
const section = "- `'pending'` — status\n- `src/foo.ts` — file";
|
|
142
|
+
expect(extractFilePaths(section)).toEqual(['src/foo.ts']);
|
|
143
|
+
});
|
|
144
|
+
it('deduplicates paths', () => {
|
|
145
|
+
const section = '- `src/foo.ts` — first\n- `src/foo.ts` — second';
|
|
146
|
+
expect(extractFilePaths(section)).toEqual(['src/foo.ts']);
|
|
147
|
+
});
|
|
148
|
+
it('handles mixed valid/invalid', () => {
|
|
149
|
+
const section = [
|
|
150
|
+
'- `src/discord/plan-manager.ts` — New file',
|
|
151
|
+
'- `PlanPhase` type: ...',
|
|
152
|
+
'- `PLAN_PHASES_ENABLED` config',
|
|
153
|
+
'- `src/config.ts` — Add config',
|
|
154
|
+
].join('\n');
|
|
155
|
+
expect(extractFilePaths(section)).toEqual([
|
|
156
|
+
'src/discord/plan-manager.ts',
|
|
157
|
+
'src/config.ts',
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
it('extracts paths from heading format (h4)', () => {
|
|
161
|
+
const section = '#### `src/discord/forge-commands.ts`\n\nSome changes here.\n\n#### `src/discord/audit-handler.ts`\n\nMore changes.';
|
|
162
|
+
expect(extractFilePaths(section)).toEqual([
|
|
163
|
+
'src/discord/forge-commands.ts',
|
|
164
|
+
'src/discord/audit-handler.ts',
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
it('extracts paths from mixed list and heading formats', () => {
|
|
168
|
+
const section = [
|
|
169
|
+
'#### `src/discord/forge-commands.ts`',
|
|
170
|
+
'',
|
|
171
|
+
'- `AuditVerdict` type: changes',
|
|
172
|
+
'- `parseAuditVerdict()` updates',
|
|
173
|
+
'',
|
|
174
|
+
'#### `src/discord/plan-manager.ts`',
|
|
175
|
+
'',
|
|
176
|
+
'- `buildPhasePrompt()` audit section',
|
|
177
|
+
].join('\n');
|
|
178
|
+
expect(extractFilePaths(section)).toEqual([
|
|
179
|
+
'src/discord/forge-commands.ts',
|
|
180
|
+
'src/discord/plan-manager.ts',
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
it('rejects non-file-path headings', () => {
|
|
184
|
+
const section = '#### `PlanPhase` type\n\n#### `src/foo.ts`\n\n#### `PLAN_PHASES_ENABLED`';
|
|
185
|
+
expect(extractFilePaths(section)).toEqual(['src/foo.ts']);
|
|
186
|
+
});
|
|
187
|
+
it('deduplicates across list and heading formats', () => {
|
|
188
|
+
const section = '#### `src/foo.ts`\n\n- `src/foo.ts` — same file again';
|
|
189
|
+
expect(extractFilePaths(section)).toEqual(['src/foo.ts']);
|
|
190
|
+
});
|
|
191
|
+
it('extracts bold-wrapped backtick paths in list items', () => {
|
|
192
|
+
const section = '- **`src/index.ts`** (lines ~622–691) — Reorder\n- **`src/tasks/initialize.ts`** — Two options';
|
|
193
|
+
expect(extractFilePaths(section)).toEqual([
|
|
194
|
+
'src/index.ts',
|
|
195
|
+
'src/tasks/initialize.ts',
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
it('extracts bold-wrapped backtick paths in headings', () => {
|
|
199
|
+
const section = '#### **`src/discord/forge-commands.ts`**\n\nSome changes.';
|
|
200
|
+
expect(extractFilePaths(section)).toEqual(['src/discord/forge-commands.ts']);
|
|
201
|
+
});
|
|
202
|
+
it('extracts italic-wrapped backtick paths', () => {
|
|
203
|
+
const section = '- *`src/foo.ts`* — italic wrapped';
|
|
204
|
+
expect(extractFilePaths(section)).toEqual(['src/foo.ts']);
|
|
205
|
+
});
|
|
206
|
+
it('extracts standalone bold entries used in file-by-file breakdowns', () => {
|
|
207
|
+
const section = [
|
|
208
|
+
'### File-by-file breakdown',
|
|
209
|
+
'',
|
|
210
|
+
'**`src/foo/bar.ts`** — Reorder exports',
|
|
211
|
+
' - Update imports',
|
|
212
|
+
'',
|
|
213
|
+
'**`src/config/settings.ts`**',
|
|
214
|
+
' - Align with new site theming',
|
|
215
|
+
].join('\n');
|
|
216
|
+
expect(extractFilePaths(section)).toEqual(['src/foo/bar.ts', 'src/config/settings.ts']);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// groupFiles
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
describe('groupFiles', () => {
|
|
223
|
+
it('pairs module + test file', () => {
|
|
224
|
+
const files = ['src/foo.ts', 'src/foo.test.ts'];
|
|
225
|
+
const groups = groupFiles(files, 5);
|
|
226
|
+
expect(groups).toHaveLength(1);
|
|
227
|
+
expect(groups[0]).toContain('src/foo.ts');
|
|
228
|
+
expect(groups[0]).toContain('src/foo.test.ts');
|
|
229
|
+
});
|
|
230
|
+
it('groups files in same directory', () => {
|
|
231
|
+
const files = ['src/a.ts', 'src/b.ts', 'lib/c.ts'];
|
|
232
|
+
const groups = groupFiles(files, 5);
|
|
233
|
+
// src/a.ts and src/b.ts in one group, lib/c.ts in another
|
|
234
|
+
expect(groups.length).toBeGreaterThanOrEqual(2);
|
|
235
|
+
const srcGroup = groups.find((g) => g.includes('src/a.ts'));
|
|
236
|
+
expect(srcGroup).toContain('src/b.ts');
|
|
237
|
+
});
|
|
238
|
+
it('splits group exceeding maxPerGroup', () => {
|
|
239
|
+
const files = ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts'];
|
|
240
|
+
const groups = groupFiles(files, 2);
|
|
241
|
+
expect(groups.length).toBeGreaterThanOrEqual(2);
|
|
242
|
+
for (const group of groups) {
|
|
243
|
+
expect(group.length).toBeLessThanOrEqual(2);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
it('single file = group of one', () => {
|
|
247
|
+
expect(groupFiles(['src/foo.ts'], 5)).toEqual([['src/foo.ts']]);
|
|
248
|
+
});
|
|
249
|
+
it('empty list = empty groups', () => {
|
|
250
|
+
expect(groupFiles([], 5)).toEqual([]);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// extractChangeSpec
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
describe('extractChangeSpec', () => {
|
|
257
|
+
const changes = [
|
|
258
|
+
'- `src/foo.ts` — Add new function',
|
|
259
|
+
' - Add `doStuff()` method',
|
|
260
|
+
' - Update imports',
|
|
261
|
+
'',
|
|
262
|
+
'- `src/bar.ts` — Fix bug',
|
|
263
|
+
' - Handle null case',
|
|
264
|
+
'',
|
|
265
|
+
'- `src/baz.ts` — Refactor',
|
|
266
|
+
].join('\n');
|
|
267
|
+
it('extracts block for a single file', () => {
|
|
268
|
+
const spec = extractChangeSpec(changes, ['src/foo.ts']);
|
|
269
|
+
expect(spec).toContain('Add new function');
|
|
270
|
+
expect(spec).toContain('doStuff()');
|
|
271
|
+
expect(spec).not.toContain('Fix bug');
|
|
272
|
+
});
|
|
273
|
+
it('extracts blocks for multiple files', () => {
|
|
274
|
+
const spec = extractChangeSpec(changes, ['src/foo.ts', 'src/bar.ts']);
|
|
275
|
+
expect(spec).toContain('Add new function');
|
|
276
|
+
expect(spec).toContain('Fix bug');
|
|
277
|
+
});
|
|
278
|
+
it('returns fallback for missing file', () => {
|
|
279
|
+
const spec = extractChangeSpec(changes, ['src/missing.ts']);
|
|
280
|
+
expect(spec).toContain('not described in Changes section');
|
|
281
|
+
});
|
|
282
|
+
it('captures nested sub-bullets', () => {
|
|
283
|
+
const spec = extractChangeSpec(changes, ['src/foo.ts']);
|
|
284
|
+
expect(spec).toContain('Add `doStuff()` method');
|
|
285
|
+
expect(spec).toContain('Update imports');
|
|
286
|
+
});
|
|
287
|
+
it('cleanly separates adjacent file entries', () => {
|
|
288
|
+
const spec = extractChangeSpec(changes, ['src/foo.ts']);
|
|
289
|
+
expect(spec).not.toContain('Fix bug');
|
|
290
|
+
expect(spec).not.toContain('Refactor');
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// decomposePlan
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
describe('decomposePlan', () => {
|
|
297
|
+
it('plan with file changes → impl + audit phases', () => {
|
|
298
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011.md');
|
|
299
|
+
expect(phases.planId).toBe('plan-011');
|
|
300
|
+
expect(phases.phases.length).toBeGreaterThanOrEqual(2);
|
|
301
|
+
const implPhases = phases.phases.filter((p) => p.kind === 'implement');
|
|
302
|
+
const auditPhases = phases.phases.filter((p) => p.kind === 'audit');
|
|
303
|
+
expect(implPhases.length).toBeGreaterThanOrEqual(1);
|
|
304
|
+
expect(auditPhases.length).toBe(1);
|
|
305
|
+
// Audit depends on all impl phases
|
|
306
|
+
const auditPhase = auditPhases[0];
|
|
307
|
+
for (const impl of implPhases) {
|
|
308
|
+
expect(auditPhase.dependsOn).toContain(impl.id);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
it('plan with no file paths → read, implement, audit phases', () => {
|
|
312
|
+
const planPath = 'workspace/plans/plan-010.md';
|
|
313
|
+
const phases = decomposePlan(SAMPLE_PLAN_NO_CHANGES, 'plan-010', planPath);
|
|
314
|
+
expect(phases.phases).toHaveLength(3);
|
|
315
|
+
expect(phases.phases[0].kind).toBe('read');
|
|
316
|
+
expect(phases.phases[1].kind).toBe('implement');
|
|
317
|
+
const auditPhase = phases.phases[2];
|
|
318
|
+
expect(auditPhase.kind).toBe('audit');
|
|
319
|
+
expect(auditPhase.dependsOn).toEqual(['phase-2']);
|
|
320
|
+
expect(auditPhase.contextFiles).toEqual([planPath]);
|
|
321
|
+
});
|
|
322
|
+
it('contextFiles limited to per-batch files', () => {
|
|
323
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011.md');
|
|
324
|
+
const implPhases = phases.phases.filter((p) => p.kind === 'implement');
|
|
325
|
+
for (const phase of implPhases) {
|
|
326
|
+
// Each impl phase should only have its batch's files
|
|
327
|
+
expect(phase.contextFiles.length).toBeLessThanOrEqual(5);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
it('dependsOn ordering is correct', () => {
|
|
331
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011.md');
|
|
332
|
+
// First impl phase has no deps (or deps on earlier phases)
|
|
333
|
+
expect(phases.phases[0].dependsOn).toEqual([]);
|
|
334
|
+
});
|
|
335
|
+
it('changeSpec is populated for implement phases', () => {
|
|
336
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011.md');
|
|
337
|
+
const implPhases = phases.phases.filter((p) => p.kind === 'implement');
|
|
338
|
+
for (const phase of implPhases) {
|
|
339
|
+
expect(phase.changeSpec).toBeTruthy();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
it('hash is computed and stored', () => {
|
|
343
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011.md');
|
|
344
|
+
expect(phases.planContentHash).toMatch(/^[0-9a-f]{16}$/);
|
|
345
|
+
expect(phases.planContentHash).toBe(computePlanHash(SAMPLE_PLAN));
|
|
346
|
+
});
|
|
347
|
+
it('normalizes bare workspace filenames', () => {
|
|
348
|
+
const plan = SAMPLE_PLAN.replace('`workspace/TOOLS.md`', '`TOOLS.md`');
|
|
349
|
+
const phases = decomposePlan(plan, 'plan-011', 'workspace/plans/plan-011.md');
|
|
350
|
+
const allContextFiles = phases.phases.flatMap((p) => p.contextFiles);
|
|
351
|
+
// TOOLS.md should be normalized to workspace/TOOLS.md
|
|
352
|
+
const hasNormalized = allContextFiles.some((f) => f === 'workspace/TOOLS.md');
|
|
353
|
+
// The original TOOLS.md should not appear without prefix
|
|
354
|
+
const hasBare = allContextFiles.some((f) => f === 'TOOLS.md');
|
|
355
|
+
expect(hasNormalized || !hasBare).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
it('prefers Change Manifest file list over Changes heuristics', () => {
|
|
358
|
+
const plan = [
|
|
359
|
+
'# Plan: Manifest test',
|
|
360
|
+
'',
|
|
361
|
+
'**ID:** plan-011',
|
|
362
|
+
'**Task:** ws-test',
|
|
363
|
+
'**Created:** 2026-02-12',
|
|
364
|
+
'**Status:** APPROVED',
|
|
365
|
+
'**Project:** discoclaw',
|
|
366
|
+
'',
|
|
367
|
+
'## Objective',
|
|
368
|
+
'',
|
|
369
|
+
'Test manifest.',
|
|
370
|
+
'',
|
|
371
|
+
'## Changes',
|
|
372
|
+
'',
|
|
373
|
+
'- `src/from-changes.ts` — would be used without manifest',
|
|
374
|
+
'',
|
|
375
|
+
'## Change Manifest',
|
|
376
|
+
'',
|
|
377
|
+
'```json',
|
|
378
|
+
'["src/from-manifest.ts"]',
|
|
379
|
+
'```',
|
|
380
|
+
].join('\n');
|
|
381
|
+
const phases = decomposePlan(plan, 'plan-011', 'workspace/plans/plan-011.md');
|
|
382
|
+
const allContextFiles = phases.phases.flatMap((p) => p.contextFiles);
|
|
383
|
+
expect(allContextFiles.some((f) => f.includes('from-manifest.ts'))).toBe(true);
|
|
384
|
+
expect(allContextFiles.some((f) => f.includes('from-changes.ts'))).toBe(false);
|
|
385
|
+
});
|
|
386
|
+
it('falls back to Changes parsing when Change Manifest is invalid', () => {
|
|
387
|
+
const plan = [
|
|
388
|
+
'# Plan: Manifest fallback test',
|
|
389
|
+
'',
|
|
390
|
+
'**ID:** plan-011',
|
|
391
|
+
'**Task:** ws-test',
|
|
392
|
+
'**Created:** 2026-02-12',
|
|
393
|
+
'**Status:** APPROVED',
|
|
394
|
+
'**Project:** discoclaw',
|
|
395
|
+
'',
|
|
396
|
+
'## Objective',
|
|
397
|
+
'',
|
|
398
|
+
'Test manifest fallback.',
|
|
399
|
+
'',
|
|
400
|
+
'## Changes',
|
|
401
|
+
'',
|
|
402
|
+
'- `src/fallback-file.ts` — should be picked',
|
|
403
|
+
'',
|
|
404
|
+
'## Change Manifest',
|
|
405
|
+
'',
|
|
406
|
+
'```json',
|
|
407
|
+
'[{"not":"an array of paths"}]',
|
|
408
|
+
'```',
|
|
409
|
+
].join('\n');
|
|
410
|
+
const phases = decomposePlan(plan, 'plan-011', 'workspace/plans/plan-011.md');
|
|
411
|
+
const allContextFiles = phases.phases.flatMap((p) => p.contextFiles);
|
|
412
|
+
expect(allContextFiles.some((f) => f.includes('fallback-file.ts'))).toBe(true);
|
|
413
|
+
});
|
|
414
|
+
it('ignores top-level heading markers inside code fences while scanning sections', () => {
|
|
415
|
+
const plan = [
|
|
416
|
+
'# Plan: Fence test',
|
|
417
|
+
'',
|
|
418
|
+
'**ID:** plan-011',
|
|
419
|
+
'**Task:** ws-test',
|
|
420
|
+
'**Created:** 2026-02-12',
|
|
421
|
+
'**Status:** APPROVED',
|
|
422
|
+
'**Project:** discoclaw',
|
|
423
|
+
'',
|
|
424
|
+
'## Objective',
|
|
425
|
+
'',
|
|
426
|
+
'Section scanner should ignore headings in code fences.',
|
|
427
|
+
'',
|
|
428
|
+
'## Changes',
|
|
429
|
+
'',
|
|
430
|
+
'```md',
|
|
431
|
+
'## Not really a top-level section',
|
|
432
|
+
'```',
|
|
433
|
+
'',
|
|
434
|
+
'- `src/fence-safe.ts` — file entry',
|
|
435
|
+
'',
|
|
436
|
+
'## Risks',
|
|
437
|
+
'',
|
|
438
|
+
'- none',
|
|
439
|
+
].join('\n');
|
|
440
|
+
const phases = decomposePlan(plan, 'plan-011', 'workspace/plans/plan-011.md');
|
|
441
|
+
const allContextFiles = phases.phases.flatMap((p) => p.contextFiles);
|
|
442
|
+
expect(allContextFiles.some((f) => f.includes('fence-safe.ts'))).toBe(true);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Serialization round-trip
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
describe('serialization', () => {
|
|
449
|
+
it('serializePhases → deserializePhases round-trip', () => {
|
|
450
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011.md');
|
|
451
|
+
const serialized = serializePhases(phases);
|
|
452
|
+
const deserialized = deserializePhases(serialized);
|
|
453
|
+
expect(deserialized.planId).toBe(phases.planId);
|
|
454
|
+
expect(deserialized.planContentHash).toBe(phases.planContentHash);
|
|
455
|
+
expect(deserialized.phases.length).toBe(phases.phases.length);
|
|
456
|
+
for (let i = 0; i < phases.phases.length; i++) {
|
|
457
|
+
expect(deserialized.phases[i].id).toBe(phases.phases[i].id);
|
|
458
|
+
expect(deserialized.phases[i].kind).toBe(phases.phases[i].kind);
|
|
459
|
+
expect(deserialized.phases[i].status).toBe(phases.phases[i].status);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
it('handles all status values', () => {
|
|
463
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011.md');
|
|
464
|
+
// Set various statuses
|
|
465
|
+
phases.phases[0].status = 'done';
|
|
466
|
+
phases.phases[0].output = 'All good.';
|
|
467
|
+
if (phases.phases[1])
|
|
468
|
+
phases.phases[1].status = 'failed';
|
|
469
|
+
if (phases.phases[1])
|
|
470
|
+
phases.phases[1].error = 'Timeout';
|
|
471
|
+
const serialized = serializePhases(phases);
|
|
472
|
+
const deserialized = deserializePhases(serialized);
|
|
473
|
+
expect(deserialized.phases[0].status).toBe('done');
|
|
474
|
+
expect(deserialized.phases[0].output).toBe('All good.');
|
|
475
|
+
if (deserialized.phases[1]) {
|
|
476
|
+
expect(deserialized.phases[1].status).toBe('failed');
|
|
477
|
+
expect(deserialized.phases[1].error).toBe('Timeout');
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
it('failureHashes round-trips', () => {
|
|
481
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011.md');
|
|
482
|
+
phases.phases[0].failureHashes = { 'src/foo.ts': 'abc123def4567890' };
|
|
483
|
+
phases.phases[0].modifiedFiles = ['src/foo.ts'];
|
|
484
|
+
const serialized = serializePhases(phases);
|
|
485
|
+
const deserialized = deserializePhases(serialized);
|
|
486
|
+
expect(deserialized.phases[0].failureHashes).toEqual({ 'src/foo.ts': 'abc123def4567890' });
|
|
487
|
+
expect(deserialized.phases[0].modifiedFiles).toEqual(['src/foo.ts']);
|
|
488
|
+
});
|
|
489
|
+
it('throws on malformed file', () => {
|
|
490
|
+
expect(() => deserializePhases('garbage content')).toThrow();
|
|
491
|
+
});
|
|
492
|
+
it('throws on unknown status value', () => {
|
|
493
|
+
const badContent = [
|
|
494
|
+
'# Phases: plan-001 — test.md',
|
|
495
|
+
'Created: 2026-01-01',
|
|
496
|
+
'Updated: 2026-01-01',
|
|
497
|
+
'Plan hash: abc123def4567890',
|
|
498
|
+
'',
|
|
499
|
+
'## phase-1: Test',
|
|
500
|
+
'**Kind:** implement',
|
|
501
|
+
'**Status:** unknown_bad_status',
|
|
502
|
+
'**Context:** (none)',
|
|
503
|
+
'**Depends on:** (none)',
|
|
504
|
+
'',
|
|
505
|
+
'Description here',
|
|
506
|
+
'',
|
|
507
|
+
'---',
|
|
508
|
+
].join('\n');
|
|
509
|
+
expect(() => deserializePhases(badContent)).toThrow('Unknown phase status');
|
|
510
|
+
});
|
|
511
|
+
it('throws on unknown kind value', () => {
|
|
512
|
+
const badContent = [
|
|
513
|
+
'# Phases: plan-001 — test.md',
|
|
514
|
+
'Created: 2026-01-01',
|
|
515
|
+
'Updated: 2026-01-01',
|
|
516
|
+
'Plan hash: abc123def4567890',
|
|
517
|
+
'',
|
|
518
|
+
'## phase-1: Test',
|
|
519
|
+
'**Kind:** unknown_bad_kind',
|
|
520
|
+
'**Status:** pending',
|
|
521
|
+
'**Context:** (none)',
|
|
522
|
+
'**Depends on:** (none)',
|
|
523
|
+
'',
|
|
524
|
+
'Description here',
|
|
525
|
+
'',
|
|
526
|
+
'---',
|
|
527
|
+
].join('\n');
|
|
528
|
+
expect(() => deserializePhases(badContent)).toThrow('Unknown phase kind');
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// getNextPhase
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
describe('getNextPhase', () => {
|
|
535
|
+
function makePhases(statuses) {
|
|
536
|
+
return {
|
|
537
|
+
planId: 'plan-001',
|
|
538
|
+
planFile: 'test.md',
|
|
539
|
+
planContentHash: 'abc',
|
|
540
|
+
createdAt: '2026-01-01',
|
|
541
|
+
updatedAt: '2026-01-01',
|
|
542
|
+
phases: statuses.map((s) => ({
|
|
543
|
+
id: s.id,
|
|
544
|
+
title: s.id,
|
|
545
|
+
kind: 'implement',
|
|
546
|
+
description: '',
|
|
547
|
+
status: s.status,
|
|
548
|
+
dependsOn: s.dependsOn ?? [],
|
|
549
|
+
contextFiles: [],
|
|
550
|
+
})),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
it('returns first pending phase when no in-progress or failed', () => {
|
|
554
|
+
const phases = makePhases([
|
|
555
|
+
{ id: 'phase-1', status: 'pending' },
|
|
556
|
+
{ id: 'phase-2', status: 'pending' },
|
|
557
|
+
]);
|
|
558
|
+
expect(getNextPhase(phases)?.id).toBe('phase-1');
|
|
559
|
+
});
|
|
560
|
+
it('returns in-progress phase (resume)', () => {
|
|
561
|
+
const phases = makePhases([
|
|
562
|
+
{ id: 'phase-1', status: 'done' },
|
|
563
|
+
{ id: 'phase-2', status: 'in-progress' },
|
|
564
|
+
{ id: 'phase-3', status: 'pending' },
|
|
565
|
+
]);
|
|
566
|
+
expect(getNextPhase(phases)?.id).toBe('phase-2');
|
|
567
|
+
});
|
|
568
|
+
it('returns failed phase for retry', () => {
|
|
569
|
+
const phases = makePhases([
|
|
570
|
+
{ id: 'phase-1', status: 'done' },
|
|
571
|
+
{ id: 'phase-2', status: 'failed' },
|
|
572
|
+
{ id: 'phase-3', status: 'pending' },
|
|
573
|
+
]);
|
|
574
|
+
expect(getNextPhase(phases)?.id).toBe('phase-2');
|
|
575
|
+
});
|
|
576
|
+
it('returns null when all done', () => {
|
|
577
|
+
const phases = makePhases([
|
|
578
|
+
{ id: 'phase-1', status: 'done' },
|
|
579
|
+
{ id: 'phase-2', status: 'done' },
|
|
580
|
+
]);
|
|
581
|
+
expect(getNextPhase(phases)).toBeNull();
|
|
582
|
+
});
|
|
583
|
+
it('skips phases with unmet dependencies', () => {
|
|
584
|
+
const phases = makePhases([
|
|
585
|
+
{ id: 'phase-1', status: 'pending', dependsOn: [] },
|
|
586
|
+
{ id: 'phase-2', status: 'pending', dependsOn: ['phase-1'] },
|
|
587
|
+
]);
|
|
588
|
+
expect(getNextPhase(phases)?.id).toBe('phase-1');
|
|
589
|
+
});
|
|
590
|
+
it('returns null when dependencies are unmet', () => {
|
|
591
|
+
const phases = makePhases([
|
|
592
|
+
{ id: 'phase-1', status: 'skipped' },
|
|
593
|
+
{ id: 'phase-2', status: 'pending', dependsOn: ['phase-1'] },
|
|
594
|
+
]);
|
|
595
|
+
// skipped counts as met
|
|
596
|
+
expect(getNextPhase(phases)?.id).toBe('phase-2');
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
// ---------------------------------------------------------------------------
|
|
600
|
+
// checkStaleness
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
describe('checkStaleness', () => {
|
|
603
|
+
it('same content → not stale', () => {
|
|
604
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'test.md');
|
|
605
|
+
expect(checkStaleness(phases, SAMPLE_PLAN)).toEqual({ stale: false, message: '' });
|
|
606
|
+
});
|
|
607
|
+
it('different content → stale', () => {
|
|
608
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'test.md');
|
|
609
|
+
const result = checkStaleness(phases, SAMPLE_PLAN + '\nModified!');
|
|
610
|
+
expect(result.stale).toBe(true);
|
|
611
|
+
expect(result.message).toContain('changed');
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
// buildPhasePrompt
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
describe('buildPhasePrompt', () => {
|
|
618
|
+
const phase = {
|
|
619
|
+
id: 'phase-1',
|
|
620
|
+
title: 'Implement foo',
|
|
621
|
+
kind: 'implement',
|
|
622
|
+
description: 'Implement changes to foo.ts',
|
|
623
|
+
status: 'pending',
|
|
624
|
+
dependsOn: [],
|
|
625
|
+
contextFiles: ['src/foo.ts', 'src/foo.test.ts'],
|
|
626
|
+
changeSpec: '- `src/foo.ts` — Add doStuff()',
|
|
627
|
+
};
|
|
628
|
+
it('includes objective from plan', () => {
|
|
629
|
+
const prompt = buildPhasePrompt(phase, SAMPLE_PLAN);
|
|
630
|
+
expect(prompt).toContain('Add a plan manager');
|
|
631
|
+
});
|
|
632
|
+
it('includes changeSpec for implement phases', () => {
|
|
633
|
+
const prompt = buildPhasePrompt(phase, SAMPLE_PLAN);
|
|
634
|
+
expect(prompt).toContain('Add doStuff()');
|
|
635
|
+
});
|
|
636
|
+
it('includes context files', () => {
|
|
637
|
+
const prompt = buildPhasePrompt(phase, SAMPLE_PLAN);
|
|
638
|
+
expect(prompt).toContain('src/foo.ts');
|
|
639
|
+
expect(prompt).toContain('src/foo.test.ts');
|
|
640
|
+
});
|
|
641
|
+
it('implement phase includes write tools instruction', () => {
|
|
642
|
+
const prompt = buildPhasePrompt(phase, SAMPLE_PLAN);
|
|
643
|
+
expect(prompt).toContain('Write');
|
|
644
|
+
expect(prompt).toContain('Edit');
|
|
645
|
+
});
|
|
646
|
+
it('read phase uses read-only instruction', () => {
|
|
647
|
+
const readPhase = { ...phase, kind: 'read' };
|
|
648
|
+
const prompt = buildPhasePrompt(readPhase, SAMPLE_PLAN);
|
|
649
|
+
expect(prompt).toContain('Read, Glob, and Grep');
|
|
650
|
+
expect(prompt).not.toContain('Write, Edit');
|
|
651
|
+
});
|
|
652
|
+
it('audit phase includes audit-specific framing', () => {
|
|
653
|
+
const auditPhase = { ...phase, kind: 'audit' };
|
|
654
|
+
const prompt = buildPhasePrompt(auditPhase, SAMPLE_PLAN);
|
|
655
|
+
expect(prompt).toContain('Audit');
|
|
656
|
+
expect(prompt).toContain('plan specification');
|
|
657
|
+
});
|
|
658
|
+
it('injectedContext appears after objective', () => {
|
|
659
|
+
const prompt = buildPhasePrompt(phase, SAMPLE_PLAN, '### File: workspace/TOOLS.md\n```\ncontent\n```');
|
|
660
|
+
const objIdx = prompt.indexOf('Objective');
|
|
661
|
+
const injIdx = prompt.indexOf('Pre-read Context Files');
|
|
662
|
+
const specIdx = prompt.indexOf('Change Specification');
|
|
663
|
+
expect(injIdx).toBeGreaterThan(objIdx);
|
|
664
|
+
expect(specIdx).toBeGreaterThan(injIdx);
|
|
665
|
+
});
|
|
666
|
+
it('no injection block when injectedContext is undefined', () => {
|
|
667
|
+
const prompt = buildPhasePrompt(phase, SAMPLE_PLAN);
|
|
668
|
+
expect(prompt).not.toContain('Pre-read Context Files');
|
|
669
|
+
});
|
|
670
|
+
it('does not include workspace exclusion instruction', () => {
|
|
671
|
+
const prompt = buildPhasePrompt(phase, SAMPLE_PLAN);
|
|
672
|
+
expect(prompt).not.toContain('Do not modify workspace');
|
|
673
|
+
expect(prompt).not.toContain('workspace exclusion');
|
|
674
|
+
});
|
|
675
|
+
it('audit phase prompt uses new severity vocabulary', () => {
|
|
676
|
+
const auditPhase = { ...phase, kind: 'audit' };
|
|
677
|
+
const prompt = buildPhasePrompt(auditPhase, SAMPLE_PLAN);
|
|
678
|
+
expect(prompt).toContain('blocking | medium | minor | suggestion');
|
|
679
|
+
expect(prompt).not.toContain('Severity: high | medium | low');
|
|
680
|
+
});
|
|
681
|
+
it('audit phase prompt includes severity definitions', () => {
|
|
682
|
+
const auditPhase = { ...phase, kind: 'audit' };
|
|
683
|
+
const prompt = buildPhasePrompt(auditPhase, SAMPLE_PLAN);
|
|
684
|
+
expect(prompt).toContain('Correctness bugs, security issues, architectural flaws');
|
|
685
|
+
});
|
|
686
|
+
it('audit phase prompt uses blocking-only verdict logic', () => {
|
|
687
|
+
const auditPhase = { ...phase, kind: 'audit' };
|
|
688
|
+
const prompt = buildPhasePrompt(auditPhase, SAMPLE_PLAN);
|
|
689
|
+
expect(prompt).toContain('if any blocking concerns');
|
|
690
|
+
expect(prompt).toContain('if no blocking concerns');
|
|
691
|
+
expect(prompt).not.toContain('if any high/medium concerns');
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
// resolveProjectCwd
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
describe('resolveProjectCwd', () => {
|
|
698
|
+
let tmpDir;
|
|
699
|
+
let wsDir;
|
|
700
|
+
beforeEach(async () => {
|
|
701
|
+
tmpDir = await makeTmpDir();
|
|
702
|
+
wsDir = path.join(tmpDir, 'workspace');
|
|
703
|
+
await fs.mkdir(wsDir, { recursive: true });
|
|
704
|
+
});
|
|
705
|
+
afterEach(async () => {
|
|
706
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
707
|
+
});
|
|
708
|
+
it('resolves known project from plan content', async () => {
|
|
709
|
+
const projectDir = path.join(tmpDir, 'my-project');
|
|
710
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
711
|
+
const map = { 'my-project': projectDir };
|
|
712
|
+
const plan = '**Project:** my-project\n';
|
|
713
|
+
const result = resolveProjectCwd(plan, wsDir, map);
|
|
714
|
+
expect(result).toBe(projectDir);
|
|
715
|
+
});
|
|
716
|
+
it('throws for unknown project', () => {
|
|
717
|
+
const plan = '**Project:** unknown-project\n';
|
|
718
|
+
expect(() => resolveProjectCwd(plan, wsDir, {})).toThrow('not in project directory map');
|
|
719
|
+
});
|
|
720
|
+
it('throws for missing Project field', () => {
|
|
721
|
+
const plan = '**Status:** DRAFT\n';
|
|
722
|
+
expect(() => resolveProjectCwd(plan, wsDir)).toThrow('no **Project:** field');
|
|
723
|
+
});
|
|
724
|
+
it('throws when project dir does not exist', () => {
|
|
725
|
+
const map = { 'gone-project': path.join(tmpDir, 'does-not-exist') };
|
|
726
|
+
const plan = '**Project:** gone-project\n';
|
|
727
|
+
expect(() => resolveProjectCwd(plan, wsDir, map)).toThrow('does not exist');
|
|
728
|
+
});
|
|
729
|
+
it('passes validation when no symlinks to workspace', async () => {
|
|
730
|
+
const projectDir = path.join(tmpDir, 'clean-project');
|
|
731
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
732
|
+
const map = { 'clean-project': projectDir };
|
|
733
|
+
const plan = '**Project:** clean-project\n';
|
|
734
|
+
const result = resolveProjectCwd(plan, wsDir, map);
|
|
735
|
+
expect(result).toBeTruthy();
|
|
736
|
+
});
|
|
737
|
+
it('allows project dir with symlink to workspace', async () => {
|
|
738
|
+
const projectDir = path.join(tmpDir, 'linked-project');
|
|
739
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
740
|
+
await fs.symlink(wsDir, path.join(projectDir, 'ws-link'));
|
|
741
|
+
const map = { 'linked-project': projectDir };
|
|
742
|
+
const plan = '**Project:** linked-project\n';
|
|
743
|
+
expect(() => resolveProjectCwd(plan, wsDir, map)).not.toThrow();
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
// ---------------------------------------------------------------------------
|
|
747
|
+
// resolveContextFilePath
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
describe('resolveContextFilePath', () => {
|
|
750
|
+
let tmpDir;
|
|
751
|
+
let projectDir;
|
|
752
|
+
let wsDir;
|
|
753
|
+
beforeEach(async () => {
|
|
754
|
+
const rawTmpDir = await makeTmpDir();
|
|
755
|
+
// Canonicalize so tests match realpath-based return values (e.g. macOS /tmp → /private/tmp)
|
|
756
|
+
tmpDir = fsSync.realpathSync(rawTmpDir);
|
|
757
|
+
projectDir = path.join(tmpDir, 'project');
|
|
758
|
+
wsDir = path.join(tmpDir, 'workspace');
|
|
759
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
760
|
+
await fs.mkdir(wsDir, { recursive: true });
|
|
761
|
+
await fs.mkdir(path.join(projectDir, 'src', 'discord'), { recursive: true });
|
|
762
|
+
await fs.mkdir(path.join(wsDir, 'plans'), { recursive: true });
|
|
763
|
+
});
|
|
764
|
+
afterEach(async () => {
|
|
765
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
766
|
+
});
|
|
767
|
+
it('resolves source file against projectCwd', () => {
|
|
768
|
+
const resolved = resolveContextFilePath('src/discord/plan-commands.ts', projectDir, wsDir);
|
|
769
|
+
expect(resolved).toBe(path.join(projectDir, 'src/discord/plan-commands.ts'));
|
|
770
|
+
});
|
|
771
|
+
it('strips workspace/ prefix and resolves against workspaceCwd', () => {
|
|
772
|
+
const resolved = resolveContextFilePath('workspace/plans/plan-011.md', projectDir, wsDir);
|
|
773
|
+
expect(resolved).toBe(path.join(wsDir, 'plans/plan-011.md'));
|
|
774
|
+
});
|
|
775
|
+
it('workspace/TOOLS.md resolves correctly (no double workspace)', () => {
|
|
776
|
+
const resolved = resolveContextFilePath('workspace/TOOLS.md', projectDir, wsDir);
|
|
777
|
+
expect(resolved).toBe(path.join(wsDir, 'TOOLS.md'));
|
|
778
|
+
expect(resolved).not.toContain('workspace/workspace');
|
|
779
|
+
});
|
|
780
|
+
it('rejects path traversal outside both roots', () => {
|
|
781
|
+
expect(() => {
|
|
782
|
+
resolveContextFilePath('../../etc/passwd', projectDir, wsDir);
|
|
783
|
+
}).toThrow('outside allowed roots');
|
|
784
|
+
});
|
|
785
|
+
it('rejects symlink traversal outside roots', async () => {
|
|
786
|
+
const outsideDir = path.join(tmpDir, 'outside');
|
|
787
|
+
await fs.mkdir(outsideDir);
|
|
788
|
+
await fs.writeFile(path.join(outsideDir, 'secret.txt'), 'secret');
|
|
789
|
+
// Create a symlink inside project pointing outside
|
|
790
|
+
await fs.symlink(outsideDir, path.join(projectDir, 'src', 'evil-link'));
|
|
791
|
+
expect(() => {
|
|
792
|
+
resolveContextFilePath('src/evil-link/secret.txt', projectDir, wsDir);
|
|
793
|
+
}).toThrow('outside allowed roots');
|
|
794
|
+
});
|
|
795
|
+
it('allows non-existent file under legitimate parent', () => {
|
|
796
|
+
const resolved = resolveContextFilePath('src/discord/newfile.ts', projectDir, wsDir);
|
|
797
|
+
expect(resolved).toBe(path.join(projectDir, 'src/discord/newfile.ts'));
|
|
798
|
+
});
|
|
799
|
+
it('unprefixed path resolves against projectCwd', () => {
|
|
800
|
+
const resolved = resolveContextFilePath('src/config.ts', projectDir, wsDir);
|
|
801
|
+
expect(resolved).toBe(path.join(projectDir, 'src/config.ts'));
|
|
802
|
+
});
|
|
803
|
+
it('bare TOOLS.md (not normalized) resolves against projectCwd', () => {
|
|
804
|
+
// This is correct: bare names without workspace/ prefix go to projectCwd
|
|
805
|
+
const resolved = resolveContextFilePath('TOOLS.md', projectDir, wsDir);
|
|
806
|
+
expect(resolved).toBe(path.join(projectDir, 'TOOLS.md'));
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
// ---------------------------------------------------------------------------
|
|
810
|
+
// writePhasesFile (atomic writes)
|
|
811
|
+
// ---------------------------------------------------------------------------
|
|
812
|
+
describe('writePhasesFile', () => {
|
|
813
|
+
let tmpDir;
|
|
814
|
+
beforeEach(async () => {
|
|
815
|
+
tmpDir = await makeTmpDir();
|
|
816
|
+
});
|
|
817
|
+
afterEach(async () => {
|
|
818
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
819
|
+
});
|
|
820
|
+
it('writes serialized data and no .tmp remains', () => {
|
|
821
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'test.md');
|
|
822
|
+
const filePath = path.join(tmpDir, 'phases.md');
|
|
823
|
+
const jsonPath = path.join(tmpDir, 'phases.json');
|
|
824
|
+
writePhasesFile(filePath, phases);
|
|
825
|
+
expect(fsSync.existsSync(filePath)).toBe(true);
|
|
826
|
+
expect(fsSync.existsSync(jsonPath)).toBe(true);
|
|
827
|
+
expect(fsSync.existsSync(filePath + '.tmp')).toBe(false);
|
|
828
|
+
expect(fsSync.existsSync(jsonPath + '.tmp')).toBe(false);
|
|
829
|
+
const content = fsSync.readFileSync(filePath, 'utf-8');
|
|
830
|
+
expect(content).toContain('plan-011');
|
|
831
|
+
const json = JSON.parse(fsSync.readFileSync(jsonPath, 'utf-8'));
|
|
832
|
+
expect(json.version).toBe(1);
|
|
833
|
+
expect(json.planId).toBe('plan-011');
|
|
834
|
+
});
|
|
835
|
+
it('overwrites existing file atomically', () => {
|
|
836
|
+
const phases1 = decomposePlan(SAMPLE_PLAN, 'plan-011', 'test.md');
|
|
837
|
+
const filePath = path.join(tmpDir, 'phases.md');
|
|
838
|
+
writePhasesFile(filePath, phases1);
|
|
839
|
+
const phases2 = { ...phases1, updatedAt: '2026-12-31' };
|
|
840
|
+
writePhasesFile(filePath, phases2);
|
|
841
|
+
const content = fsSync.readFileSync(filePath, 'utf-8');
|
|
842
|
+
expect(content).toContain('2026-12-31');
|
|
843
|
+
});
|
|
844
|
+
it('readPhasesFile prefers json and falls back to markdown with backfill', () => {
|
|
845
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'test.md');
|
|
846
|
+
const filePath = path.join(tmpDir, 'phases.md');
|
|
847
|
+
const jsonPath = path.join(tmpDir, 'phases.json');
|
|
848
|
+
writePhasesFile(filePath, phases);
|
|
849
|
+
// Mutate json state so we can verify json-first reads.
|
|
850
|
+
const json = JSON.parse(fsSync.readFileSync(jsonPath, 'utf-8'));
|
|
851
|
+
json.updatedAt = '2099-01-01';
|
|
852
|
+
fsSync.writeFileSync(jsonPath, JSON.stringify(json, null, 2) + '\n', 'utf-8');
|
|
853
|
+
const fromJson = readPhasesFile(filePath);
|
|
854
|
+
expect(fromJson.updatedAt).toBe('2099-01-01');
|
|
855
|
+
// Corrupt json; reader should fall back to markdown and backfill json.
|
|
856
|
+
fsSync.writeFileSync(jsonPath, '{"version":1,"bad":', 'utf-8');
|
|
857
|
+
const fromMd = readPhasesFile(filePath);
|
|
858
|
+
expect(fromMd.planId).toBe('plan-011');
|
|
859
|
+
const backfilled = JSON.parse(fsSync.readFileSync(jsonPath, 'utf-8'));
|
|
860
|
+
expect(backfilled.planId).toBe('plan-011');
|
|
861
|
+
expect(backfilled.version).toBe(1);
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
// ---------------------------------------------------------------------------
|
|
865
|
+
// executePhase
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
describe('executePhase', () => {
|
|
868
|
+
const phase = {
|
|
869
|
+
id: 'phase-1',
|
|
870
|
+
title: 'Test phase',
|
|
871
|
+
kind: 'implement',
|
|
872
|
+
description: 'Test',
|
|
873
|
+
status: 'in-progress',
|
|
874
|
+
dependsOn: [],
|
|
875
|
+
contextFiles: ['src/foo.ts'],
|
|
876
|
+
};
|
|
877
|
+
const basePhases = {
|
|
878
|
+
planId: 'plan-001',
|
|
879
|
+
planFile: 'test.md',
|
|
880
|
+
planContentHash: 'abc',
|
|
881
|
+
createdAt: '2026-01-01',
|
|
882
|
+
updatedAt: '2026-01-01',
|
|
883
|
+
phases: [phase],
|
|
884
|
+
};
|
|
885
|
+
let tmpDir;
|
|
886
|
+
let projectDir;
|
|
887
|
+
let wsDir;
|
|
888
|
+
beforeEach(async () => {
|
|
889
|
+
tmpDir = await makeTmpDir();
|
|
890
|
+
projectDir = path.join(tmpDir, 'project');
|
|
891
|
+
wsDir = path.join(tmpDir, 'workspace');
|
|
892
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
893
|
+
await fs.mkdir(wsDir, { recursive: true });
|
|
894
|
+
});
|
|
895
|
+
afterEach(async () => {
|
|
896
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
897
|
+
});
|
|
898
|
+
function makeOpts(runtime) {
|
|
899
|
+
return {
|
|
900
|
+
runtime,
|
|
901
|
+
model: 'test',
|
|
902
|
+
projectCwd: projectDir,
|
|
903
|
+
addDirs: [],
|
|
904
|
+
timeoutMs: 5000,
|
|
905
|
+
workspaceCwd: wsDir,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
it('returns done on success', async () => {
|
|
909
|
+
const result = await executePhase(phase, SAMPLE_PLAN, basePhases, makeOpts(makeSuccessRuntime('Done!')));
|
|
910
|
+
expect(result.status).toBe('done');
|
|
911
|
+
expect(result.output).toBe('Done!');
|
|
912
|
+
});
|
|
913
|
+
it('returns failed on runtime error', async () => {
|
|
914
|
+
const result = await executePhase(phase, SAMPLE_PLAN, basePhases, makeOpts(makeErrorRuntime('Runtime broke')));
|
|
915
|
+
expect(result.status).toBe('failed');
|
|
916
|
+
if (result.status === 'failed') {
|
|
917
|
+
expect(result.error).toContain('Runtime broke');
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
it('does not mutate phases object', async () => {
|
|
921
|
+
const phasesCopy = JSON.parse(JSON.stringify(basePhases));
|
|
922
|
+
await executePhase(phase, SAMPLE_PLAN, basePhases, makeOpts(makeSuccessRuntime('ok')));
|
|
923
|
+
expect(basePhases).toEqual(phasesCopy);
|
|
924
|
+
});
|
|
925
|
+
it('forwards injectedContext to prompt', async () => {
|
|
926
|
+
let capturedPrompt = '';
|
|
927
|
+
const runtime = {
|
|
928
|
+
id: 'claude_code',
|
|
929
|
+
capabilities: new Set(['streaming_text']),
|
|
930
|
+
async *invoke(params) {
|
|
931
|
+
capturedPrompt = params.prompt;
|
|
932
|
+
yield { type: 'text_final', text: 'ok' };
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
await executePhase(phase, SAMPLE_PLAN, basePhases, makeOpts(runtime), '### File: workspace/TOOLS.md\ncontent');
|
|
936
|
+
expect(capturedPrompt).toContain('Pre-read Context Files');
|
|
937
|
+
expect(capturedPrompt).toContain('workspace/TOOLS.md');
|
|
938
|
+
});
|
|
939
|
+
it('passes signal through to runtime.invoke()', async () => {
|
|
940
|
+
let capturedSignal;
|
|
941
|
+
const runtime = {
|
|
942
|
+
id: 'claude_code',
|
|
943
|
+
capabilities: new Set(['streaming_text']),
|
|
944
|
+
async *invoke(params) {
|
|
945
|
+
capturedSignal = params.signal;
|
|
946
|
+
yield { type: 'text_final', text: 'ok' };
|
|
947
|
+
},
|
|
948
|
+
};
|
|
949
|
+
const ac = new AbortController();
|
|
950
|
+
const opts = makeOpts(runtime);
|
|
951
|
+
opts.signal = ac.signal;
|
|
952
|
+
await executePhase(phase, SAMPLE_PLAN, basePhases, opts);
|
|
953
|
+
expect(capturedSignal).toBe(ac.signal);
|
|
954
|
+
});
|
|
955
|
+
it('returns failed when signal is already aborted', async () => {
|
|
956
|
+
const ac = new AbortController();
|
|
957
|
+
ac.abort();
|
|
958
|
+
const runtime = {
|
|
959
|
+
id: 'claude_code',
|
|
960
|
+
capabilities: new Set(['streaming_text']),
|
|
961
|
+
async *invoke() {
|
|
962
|
+
yield { type: 'error', message: 'aborted' };
|
|
963
|
+
},
|
|
964
|
+
};
|
|
965
|
+
const opts = makeOpts(runtime);
|
|
966
|
+
opts.signal = ac.signal;
|
|
967
|
+
const result = await executePhase(phase, SAMPLE_PLAN, basePhases, opts);
|
|
968
|
+
expect(result.status).toBe('failed');
|
|
969
|
+
});
|
|
970
|
+
it('filters workspace path from implement phase addDirs', async () => {
|
|
971
|
+
let capturedAddDirs;
|
|
972
|
+
const runtime = {
|
|
973
|
+
id: 'claude_code',
|
|
974
|
+
capabilities: new Set(['streaming_text']),
|
|
975
|
+
async *invoke(params) {
|
|
976
|
+
capturedAddDirs = params.addDirs;
|
|
977
|
+
yield { type: 'text_final', text: 'ok' };
|
|
978
|
+
},
|
|
979
|
+
};
|
|
980
|
+
const opts = makeOpts(runtime);
|
|
981
|
+
opts.addDirs = [wsDir, '/other/dir'];
|
|
982
|
+
await executePhase(phase, SAMPLE_PLAN, basePhases, opts);
|
|
983
|
+
// Workspace should be filtered out, /other/dir preserved
|
|
984
|
+
expect(capturedAddDirs).not.toContain(wsDir);
|
|
985
|
+
});
|
|
986
|
+
it('includes workspace path for read/audit phases', async () => {
|
|
987
|
+
let capturedAddDirs;
|
|
988
|
+
const runtime = {
|
|
989
|
+
id: 'claude_code',
|
|
990
|
+
capabilities: new Set(['streaming_text']),
|
|
991
|
+
async *invoke(params) {
|
|
992
|
+
capturedAddDirs = params.addDirs;
|
|
993
|
+
yield { type: 'text_final', text: 'ok' };
|
|
994
|
+
},
|
|
995
|
+
};
|
|
996
|
+
const readPhase = { ...phase, kind: 'read' };
|
|
997
|
+
const opts = makeOpts(runtime);
|
|
998
|
+
await executePhase(readPhase, SAMPLE_PLAN, basePhases, opts);
|
|
999
|
+
expect(capturedAddDirs).toContain(wsDir);
|
|
1000
|
+
});
|
|
1001
|
+
it('preserves non-workspace addDirs for implement phases', async () => {
|
|
1002
|
+
let capturedAddDirs;
|
|
1003
|
+
const runtime = {
|
|
1004
|
+
id: 'claude_code',
|
|
1005
|
+
capabilities: new Set(['streaming_text']),
|
|
1006
|
+
async *invoke(params) {
|
|
1007
|
+
capturedAddDirs = params.addDirs;
|
|
1008
|
+
yield { type: 'text_final', text: 'ok' };
|
|
1009
|
+
},
|
|
1010
|
+
};
|
|
1011
|
+
const opts = makeOpts(runtime);
|
|
1012
|
+
opts.addDirs = ['/some/other/dir'];
|
|
1013
|
+
await executePhase(phase, SAMPLE_PLAN, basePhases, opts);
|
|
1014
|
+
// /some/other/dir should be passed through (it's not workspace)
|
|
1015
|
+
// Note: it only appears if addDirs has items
|
|
1016
|
+
expect(capturedAddDirs).toEqual(['/some/other/dir']);
|
|
1017
|
+
});
|
|
1018
|
+
it('forwards events to opts.onEvent via PhaseExecutionOpts', async () => {
|
|
1019
|
+
const events = [
|
|
1020
|
+
{ type: 'text_delta', text: 'working...' },
|
|
1021
|
+
{ type: 'text_final', text: 'Done!' },
|
|
1022
|
+
];
|
|
1023
|
+
const runtime = makeRuntime(events);
|
|
1024
|
+
const received = [];
|
|
1025
|
+
const opts = makeOpts(runtime);
|
|
1026
|
+
opts.onEvent = (evt) => received.push(evt);
|
|
1027
|
+
await executePhase(phase, SAMPLE_PLAN, basePhases, opts);
|
|
1028
|
+
expect(received).toEqual(events);
|
|
1029
|
+
});
|
|
1030
|
+
it('onEvent spy receives events in order across multiple events', async () => {
|
|
1031
|
+
const events = [
|
|
1032
|
+
{ type: 'text_delta', text: 'a' },
|
|
1033
|
+
{ type: 'text_delta', text: 'b' },
|
|
1034
|
+
{ type: 'text_final', text: 'ab' },
|
|
1035
|
+
];
|
|
1036
|
+
const runtime = makeRuntime(events);
|
|
1037
|
+
const received = [];
|
|
1038
|
+
const opts = makeOpts(runtime);
|
|
1039
|
+
opts.onEvent = (evt) => received.push(evt);
|
|
1040
|
+
const result = await executePhase(phase, SAMPLE_PLAN, basePhases, opts);
|
|
1041
|
+
expect(result.status).toBe('done');
|
|
1042
|
+
expect(received.map((e) => e.type)).toEqual(['text_delta', 'text_delta', 'text_final']);
|
|
1043
|
+
});
|
|
1044
|
+
it('throwing onEvent does not abort phase execution', async () => {
|
|
1045
|
+
const runtime = makeSuccessRuntime('Done!');
|
|
1046
|
+
const opts = makeOpts(runtime);
|
|
1047
|
+
opts.onEvent = () => { throw new Error('callback error'); };
|
|
1048
|
+
const result = await executePhase(phase, SAMPLE_PLAN, basePhases, opts);
|
|
1049
|
+
expect(result.status).toBe('done');
|
|
1050
|
+
expect(result.output).toBe('Done!');
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
// ---------------------------------------------------------------------------
|
|
1054
|
+
// runNextPhase
|
|
1055
|
+
// ---------------------------------------------------------------------------
|
|
1056
|
+
describe('runNextPhase', () => {
|
|
1057
|
+
let tmpDir;
|
|
1058
|
+
let projectDir;
|
|
1059
|
+
let wsDir;
|
|
1060
|
+
let plansDir;
|
|
1061
|
+
beforeEach(async () => {
|
|
1062
|
+
tmpDir = await makeTmpDir();
|
|
1063
|
+
projectDir = path.join(tmpDir, 'project');
|
|
1064
|
+
wsDir = path.join(tmpDir, 'workspace');
|
|
1065
|
+
plansDir = path.join(wsDir, 'plans');
|
|
1066
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
1067
|
+
await fs.mkdir(plansDir, { recursive: true });
|
|
1068
|
+
// Init git in project dir
|
|
1069
|
+
try {
|
|
1070
|
+
execSync('git init', { cwd: projectDir, stdio: 'pipe' });
|
|
1071
|
+
execSync('git config user.email "test@test.com"', { cwd: projectDir, stdio: 'pipe' });
|
|
1072
|
+
execSync('git config user.name "Test"', { cwd: projectDir, stdio: 'pipe' });
|
|
1073
|
+
// Create initial commit
|
|
1074
|
+
await fs.writeFile(path.join(projectDir, 'README.md'), 'test');
|
|
1075
|
+
execSync('git add . && git commit -m "init"', { cwd: projectDir, stdio: 'pipe' });
|
|
1076
|
+
}
|
|
1077
|
+
catch {
|
|
1078
|
+
// git not available — tests will still work for non-git paths
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
afterEach(async () => {
|
|
1082
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
1083
|
+
});
|
|
1084
|
+
function makeOpts(runtime) {
|
|
1085
|
+
return {
|
|
1086
|
+
runtime,
|
|
1087
|
+
model: 'test',
|
|
1088
|
+
projectCwd: projectDir,
|
|
1089
|
+
addDirs: [],
|
|
1090
|
+
timeoutMs: 5000,
|
|
1091
|
+
workspaceCwd: wsDir,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
const progressMsgs = [];
|
|
1095
|
+
const onProgress = async (msg) => { progressMsgs.push(msg); };
|
|
1096
|
+
beforeEach(() => {
|
|
1097
|
+
progressMsgs.length = 0;
|
|
1098
|
+
});
|
|
1099
|
+
it('happy path: executes next phase and writes updated status', async () => {
|
|
1100
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1101
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1102
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1103
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1104
|
+
writePhasesFile(phasesPath, phases);
|
|
1105
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('Phase done!')), onProgress);
|
|
1106
|
+
expect(result.result).toBe('done');
|
|
1107
|
+
// Read back phases file to verify status was updated
|
|
1108
|
+
const updated = deserializePhases(fsSync.readFileSync(phasesPath, 'utf-8'));
|
|
1109
|
+
expect(updated.phases[0].status).toBe('done');
|
|
1110
|
+
});
|
|
1111
|
+
it('emits a typed phase_start event before execution', async () => {
|
|
1112
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1113
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1114
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1115
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1116
|
+
writePhasesFile(phasesPath, phases);
|
|
1117
|
+
const events = [];
|
|
1118
|
+
const opts = makeOpts(makeSuccessRuntime('Phase done!'));
|
|
1119
|
+
opts.onPlanEvent = (evt) => {
|
|
1120
|
+
events.push(evt);
|
|
1121
|
+
};
|
|
1122
|
+
const result = await runNextPhase(phasesPath, planPath, opts, onProgress);
|
|
1123
|
+
expect(result.result).toBe('done');
|
|
1124
|
+
expect(events).toEqual([
|
|
1125
|
+
{
|
|
1126
|
+
type: 'phase_start',
|
|
1127
|
+
planId: 'plan-011',
|
|
1128
|
+
phase: {
|
|
1129
|
+
id: phases.phases[0].id,
|
|
1130
|
+
title: phases.phases[0].title,
|
|
1131
|
+
kind: phases.phases[0].kind,
|
|
1132
|
+
},
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
type: 'phase_complete',
|
|
1136
|
+
planId: 'plan-011',
|
|
1137
|
+
phase: {
|
|
1138
|
+
id: phases.phases[0].id,
|
|
1139
|
+
title: phases.phases[0].title,
|
|
1140
|
+
kind: phases.phases[0].kind,
|
|
1141
|
+
},
|
|
1142
|
+
status: 'done',
|
|
1143
|
+
},
|
|
1144
|
+
]);
|
|
1145
|
+
});
|
|
1146
|
+
it('stale plan → returns stale', async () => {
|
|
1147
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1148
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1149
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1150
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1151
|
+
writePhasesFile(phasesPath, phases);
|
|
1152
|
+
// Modify plan after generating phases
|
|
1153
|
+
await fs.writeFile(planPath, SAMPLE_PLAN + '\nModified!');
|
|
1154
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('ok')), onProgress);
|
|
1155
|
+
expect(result.result).toBe('stale');
|
|
1156
|
+
});
|
|
1157
|
+
it('all done → returns nothing_to_run', async () => {
|
|
1158
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1159
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1160
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1161
|
+
// Mark all phases as done
|
|
1162
|
+
for (const p of phases.phases)
|
|
1163
|
+
p.status = 'done';
|
|
1164
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1165
|
+
writePhasesFile(phasesPath, phases);
|
|
1166
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('ok')), onProgress);
|
|
1167
|
+
expect(result.result).toBe('nothing_to_run');
|
|
1168
|
+
});
|
|
1169
|
+
it('corrupt phases file → returns corrupt', async () => {
|
|
1170
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1171
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1172
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1173
|
+
fsSync.writeFileSync(phasesPath, 'garbage content', 'utf-8');
|
|
1174
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('ok')), onProgress);
|
|
1175
|
+
expect(result.result).toBe('corrupt');
|
|
1176
|
+
});
|
|
1177
|
+
it('phase failure → marks failed with error', async () => {
|
|
1178
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1179
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1180
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1181
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1182
|
+
writePhasesFile(phasesPath, phases);
|
|
1183
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeErrorRuntime('Timeout!')), onProgress);
|
|
1184
|
+
expect(result.result).toBe('failed');
|
|
1185
|
+
if (result.result === 'failed') {
|
|
1186
|
+
expect(result.error).toContain('Timeout!');
|
|
1187
|
+
}
|
|
1188
|
+
// Verify status on disk
|
|
1189
|
+
const updated = deserializePhases(fsSync.readFileSync(phasesPath, 'utf-8'));
|
|
1190
|
+
expect(updated.phases[0].status).toBe('failed');
|
|
1191
|
+
});
|
|
1192
|
+
it('retry blocked without modifiedFiles in git env', async () => {
|
|
1193
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1194
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1195
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1196
|
+
// Simulate a failed phase without modifiedFiles
|
|
1197
|
+
phases.phases[0].status = 'failed';
|
|
1198
|
+
phases.phases[0].error = 'previous error';
|
|
1199
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1200
|
+
writePhasesFile(phasesPath, phases);
|
|
1201
|
+
const phaseEvents = [];
|
|
1202
|
+
const opts = makeOpts(makeSuccessRuntime('ok'));
|
|
1203
|
+
opts.onPlanEvent = (evt) => {
|
|
1204
|
+
phaseEvents.push(evt);
|
|
1205
|
+
};
|
|
1206
|
+
const result = await runNextPhase(phasesPath, planPath, opts, onProgress);
|
|
1207
|
+
expect(result.result).toBe('retry_blocked');
|
|
1208
|
+
if (result.result === 'retry_blocked') {
|
|
1209
|
+
expect(result.message).toContain('modifiedFiles');
|
|
1210
|
+
}
|
|
1211
|
+
expect(phaseEvents).toEqual([]);
|
|
1212
|
+
// Verify status is still failed on disk (not changed to in-progress)
|
|
1213
|
+
const updated = deserializePhases(fsSync.readFileSync(phasesPath, 'utf-8'));
|
|
1214
|
+
expect(updated.phases[0].status).toBe('failed');
|
|
1215
|
+
});
|
|
1216
|
+
it('retry blocked with modifiedFiles but no failureHashes', async () => {
|
|
1217
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1218
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1219
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1220
|
+
phases.phases[0].status = 'failed';
|
|
1221
|
+
phases.phases[0].modifiedFiles = ['src/foo.ts'];
|
|
1222
|
+
// No failureHashes
|
|
1223
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1224
|
+
writePhasesFile(phasesPath, phases);
|
|
1225
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('ok')), onProgress);
|
|
1226
|
+
expect(result.result).toBe('retry_blocked');
|
|
1227
|
+
if (result.result === 'retry_blocked') {
|
|
1228
|
+
expect(result.message).toContain('failureHashes');
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
it('rollout corruption bypasses retry guard', async () => {
|
|
1232
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1233
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1234
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1235
|
+
phases.phases[0].status = 'failed';
|
|
1236
|
+
phases.phases[0].error = 'Codex: state db missing rollout path for thread abc';
|
|
1237
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1238
|
+
writePhasesFile(phasesPath, phases);
|
|
1239
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('ok')), onProgress);
|
|
1240
|
+
expect(result.result).toBe('done');
|
|
1241
|
+
});
|
|
1242
|
+
it('git commit skipped when no files modified', async () => {
|
|
1243
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1244
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1245
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1246
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1247
|
+
writePhasesFile(phasesPath, phases);
|
|
1248
|
+
// Runtime that doesn't create any files
|
|
1249
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('Done!')), onProgress);
|
|
1250
|
+
expect(result.result).toBe('done');
|
|
1251
|
+
// No git commit should be created for this phase
|
|
1252
|
+
const updated = deserializePhases(fsSync.readFileSync(phasesPath, 'utf-8'));
|
|
1253
|
+
expect(updated.phases[0].gitCommit).toBeUndefined();
|
|
1254
|
+
});
|
|
1255
|
+
});
|
|
1256
|
+
// ---------------------------------------------------------------------------
|
|
1257
|
+
// Phase progress messages and nextPhase on RunPhaseResult
|
|
1258
|
+
// ---------------------------------------------------------------------------
|
|
1259
|
+
describe('phase progress messages and nextPhase', () => {
|
|
1260
|
+
let tmpDir;
|
|
1261
|
+
let projectDir;
|
|
1262
|
+
let wsDir;
|
|
1263
|
+
let plansDir;
|
|
1264
|
+
beforeEach(async () => {
|
|
1265
|
+
tmpDir = await makeTmpDir();
|
|
1266
|
+
projectDir = path.join(tmpDir, 'project');
|
|
1267
|
+
wsDir = path.join(tmpDir, 'workspace');
|
|
1268
|
+
plansDir = path.join(wsDir, 'plans');
|
|
1269
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
1270
|
+
await fs.mkdir(plansDir, { recursive: true });
|
|
1271
|
+
// Init git in project dir
|
|
1272
|
+
try {
|
|
1273
|
+
execSync('git init', { cwd: projectDir, stdio: 'pipe' });
|
|
1274
|
+
execSync('git config user.email "test@test.com"', { cwd: projectDir, stdio: 'pipe' });
|
|
1275
|
+
execSync('git config user.name "Test"', { cwd: projectDir, stdio: 'pipe' });
|
|
1276
|
+
await fs.writeFile(path.join(projectDir, 'README.md'), 'test');
|
|
1277
|
+
execSync('git add . && git commit -m "init"', { cwd: projectDir, stdio: 'pipe' });
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
// git not available
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
afterEach(async () => {
|
|
1284
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
1285
|
+
});
|
|
1286
|
+
const progressMsgs = [];
|
|
1287
|
+
const onProgress = async (msg) => { progressMsgs.push(msg); };
|
|
1288
|
+
beforeEach(() => {
|
|
1289
|
+
progressMsgs.length = 0;
|
|
1290
|
+
});
|
|
1291
|
+
function makeOpts(runtime) {
|
|
1292
|
+
return {
|
|
1293
|
+
runtime,
|
|
1294
|
+
model: 'test',
|
|
1295
|
+
projectCwd: projectDir,
|
|
1296
|
+
addDirs: [],
|
|
1297
|
+
timeoutMs: 5000,
|
|
1298
|
+
workspaceCwd: wsDir,
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
it('phase-start message includes bold phase ID prefix', async () => {
|
|
1302
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1303
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1304
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1305
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1306
|
+
writePhasesFile(phasesPath, phases);
|
|
1307
|
+
await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('Done!')), onProgress);
|
|
1308
|
+
const firstPhaseId = phases.phases[0].id;
|
|
1309
|
+
const firstPhaseTitle = phases.phases[0].title;
|
|
1310
|
+
expect(progressMsgs.some(m => m === `**${firstPhaseId}**: Running ${firstPhaseTitle}...`)).toBe(true);
|
|
1311
|
+
});
|
|
1312
|
+
it('sub-step messages include phase ID prefix', async () => {
|
|
1313
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1314
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1315
|
+
// Build a phases file with an implement phase that has workspace context files
|
|
1316
|
+
const phases = {
|
|
1317
|
+
planId: 'plan-011',
|
|
1318
|
+
planFile: 'workspace/plans/plan-011-test.md',
|
|
1319
|
+
planContentHash: computePlanHash(SAMPLE_PLAN),
|
|
1320
|
+
createdAt: '2026-01-01',
|
|
1321
|
+
updatedAt: '2026-01-01',
|
|
1322
|
+
phases: [
|
|
1323
|
+
{
|
|
1324
|
+
id: 'phase-1',
|
|
1325
|
+
title: 'Implement plan',
|
|
1326
|
+
kind: 'implement',
|
|
1327
|
+
description: 'Implement changes.',
|
|
1328
|
+
status: 'pending',
|
|
1329
|
+
dependsOn: [],
|
|
1330
|
+
contextFiles: ['src/foo.ts', 'workspace/TOOLS.md'],
|
|
1331
|
+
},
|
|
1332
|
+
],
|
|
1333
|
+
};
|
|
1334
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1335
|
+
writePhasesFile(phasesPath, phases);
|
|
1336
|
+
await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('Done!')), onProgress);
|
|
1337
|
+
// "Executing" sub-step includes phase ID prefix
|
|
1338
|
+
expect(progressMsgs.some(m => m === '**phase-1**: Executing implement phase...')).toBe(true);
|
|
1339
|
+
// "Reading context files" sub-step includes phase ID prefix (fires because workspace/ files present)
|
|
1340
|
+
expect(progressMsgs.some(m => m === '**phase-1**: Reading context files...')).toBe(true);
|
|
1341
|
+
});
|
|
1342
|
+
it('nextPhase is present on done result for multi-phase plans', async () => {
|
|
1343
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1344
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1345
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1346
|
+
// Ensure there are at least 2 phases
|
|
1347
|
+
expect(phases.phases.length).toBeGreaterThanOrEqual(2);
|
|
1348
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1349
|
+
writePhasesFile(phasesPath, phases);
|
|
1350
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('Done!')), onProgress);
|
|
1351
|
+
expect(result.result).toBe('done');
|
|
1352
|
+
if (result.result === 'done') {
|
|
1353
|
+
expect(result.nextPhase).toBeDefined();
|
|
1354
|
+
expect(result.nextPhase.id).toBe(phases.phases[1].id);
|
|
1355
|
+
expect(result.nextPhase.title).toBe(phases.phases[1].title);
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
it('nextPhase is undefined when last phase completes', async () => {
|
|
1359
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1360
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1361
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1362
|
+
// Mark all phases except the last as done
|
|
1363
|
+
for (let i = 0; i < phases.phases.length - 1; i++) {
|
|
1364
|
+
phases.phases[i].status = 'done';
|
|
1365
|
+
phases.phases[i].output = 'Previously completed.';
|
|
1366
|
+
}
|
|
1367
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1368
|
+
writePhasesFile(phasesPath, phases);
|
|
1369
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('Final phase done!')), onProgress);
|
|
1370
|
+
expect(result.result).toBe('done');
|
|
1371
|
+
if (result.result === 'done') {
|
|
1372
|
+
expect(result.nextPhase).toBeUndefined();
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
});
|
|
1376
|
+
// ---------------------------------------------------------------------------
|
|
1377
|
+
// Workspace filename normalization in decomposePlan
|
|
1378
|
+
// ---------------------------------------------------------------------------
|
|
1379
|
+
describe('workspace filename normalization', () => {
|
|
1380
|
+
it('bare TOOLS.md normalized to workspace/TOOLS.md', () => {
|
|
1381
|
+
const plan = SAMPLE_PLAN.replace('`workspace/TOOLS.md`', '`TOOLS.md`');
|
|
1382
|
+
const phases = decomposePlan(plan, 'plan-011', 'test.md');
|
|
1383
|
+
const allFiles = phases.phases.flatMap((p) => p.contextFiles);
|
|
1384
|
+
expect(allFiles).not.toContain('TOOLS.md');
|
|
1385
|
+
});
|
|
1386
|
+
it('already-prefixed workspace/TOOLS.md is unchanged', () => {
|
|
1387
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'test.md');
|
|
1388
|
+
const allFiles = phases.phases.flatMap((p) => p.contextFiles);
|
|
1389
|
+
// Should have workspace/TOOLS.md, not workspace/workspace/TOOLS.md
|
|
1390
|
+
for (const f of allFiles) {
|
|
1391
|
+
expect(f).not.toContain('workspace/workspace');
|
|
1392
|
+
}
|
|
1393
|
+
});
|
|
1394
|
+
it('source-relative paths are unchanged', () => {
|
|
1395
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'test.md');
|
|
1396
|
+
const allFiles = phases.phases.flatMap((p) => p.contextFiles);
|
|
1397
|
+
const srcFiles = allFiles.filter((f) => f.startsWith('src/'));
|
|
1398
|
+
expect(srcFiles.length).toBeGreaterThan(0);
|
|
1399
|
+
for (const f of srcFiles) {
|
|
1400
|
+
expect(f.startsWith('workspace/src/')).toBe(false);
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
// ---------------------------------------------------------------------------
|
|
1405
|
+
// updatePhaseStatus
|
|
1406
|
+
// ---------------------------------------------------------------------------
|
|
1407
|
+
describe('updatePhaseStatus', () => {
|
|
1408
|
+
it('updates status immutably', () => {
|
|
1409
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'test.md');
|
|
1410
|
+
const original = JSON.parse(JSON.stringify(phases));
|
|
1411
|
+
const updated = updatePhaseStatus(phases, phases.phases[0].id, 'done', 'output text');
|
|
1412
|
+
expect(updated.phases[0].status).toBe('done');
|
|
1413
|
+
expect(updated.phases[0].output).toBe('output text');
|
|
1414
|
+
// Original unchanged
|
|
1415
|
+
expect(phases.phases[0].status).toBe(original.phases[0].status);
|
|
1416
|
+
});
|
|
1417
|
+
it('sets updatedAt', () => {
|
|
1418
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'test.md');
|
|
1419
|
+
const updated = updatePhaseStatus(phases, phases.phases[0].id, 'failed', undefined, 'error msg');
|
|
1420
|
+
expect(updated.updatedAt).toBeTruthy();
|
|
1421
|
+
expect(updated.phases[0].error).toBe('error msg');
|
|
1422
|
+
});
|
|
1423
|
+
});
|
|
1424
|
+
// ---------------------------------------------------------------------------
|
|
1425
|
+
// Audit verdict handling in executePhase
|
|
1426
|
+
// ---------------------------------------------------------------------------
|
|
1427
|
+
describe('executePhase audit verdict', () => {
|
|
1428
|
+
const auditPhase = {
|
|
1429
|
+
id: 'phase-2',
|
|
1430
|
+
title: 'Post-implementation audit',
|
|
1431
|
+
kind: 'audit',
|
|
1432
|
+
description: 'Audit all changes against the plan specification.',
|
|
1433
|
+
status: 'in-progress',
|
|
1434
|
+
dependsOn: ['phase-1'],
|
|
1435
|
+
contextFiles: ['src/foo.ts'],
|
|
1436
|
+
};
|
|
1437
|
+
const implPhase = {
|
|
1438
|
+
id: 'phase-1',
|
|
1439
|
+
title: 'Implement foo',
|
|
1440
|
+
kind: 'implement',
|
|
1441
|
+
description: 'Implement changes to foo.ts',
|
|
1442
|
+
status: 'in-progress',
|
|
1443
|
+
dependsOn: [],
|
|
1444
|
+
contextFiles: ['src/foo.ts'],
|
|
1445
|
+
};
|
|
1446
|
+
const basePhases = {
|
|
1447
|
+
planId: 'plan-001',
|
|
1448
|
+
planFile: 'test.md',
|
|
1449
|
+
planContentHash: 'abc',
|
|
1450
|
+
createdAt: '2026-01-01',
|
|
1451
|
+
updatedAt: '2026-01-01',
|
|
1452
|
+
phases: [implPhase, auditPhase],
|
|
1453
|
+
};
|
|
1454
|
+
let tmpDir;
|
|
1455
|
+
let projectDir;
|
|
1456
|
+
let wsDir;
|
|
1457
|
+
beforeEach(async () => {
|
|
1458
|
+
tmpDir = await makeTmpDir();
|
|
1459
|
+
projectDir = path.join(tmpDir, 'project');
|
|
1460
|
+
wsDir = path.join(tmpDir, 'workspace');
|
|
1461
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
1462
|
+
await fs.mkdir(wsDir, { recursive: true });
|
|
1463
|
+
});
|
|
1464
|
+
afterEach(async () => {
|
|
1465
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
1466
|
+
});
|
|
1467
|
+
function makeOpts(runtime) {
|
|
1468
|
+
return {
|
|
1469
|
+
runtime,
|
|
1470
|
+
model: 'test',
|
|
1471
|
+
projectCwd: projectDir,
|
|
1472
|
+
addDirs: [],
|
|
1473
|
+
timeoutMs: 5000,
|
|
1474
|
+
workspaceCwd: wsDir,
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
it('audit phase with HIGH severity returns audit_failed (backward compat)', async () => {
|
|
1478
|
+
const auditOutput = '**Concern 1: Missing error handling**\n**Severity: HIGH**\n\n**Verdict:** Needs revision.';
|
|
1479
|
+
const result = await executePhase(auditPhase, SAMPLE_PLAN, basePhases, makeOpts(makeSuccessRuntime(auditOutput)));
|
|
1480
|
+
expect(result.status).toBe('audit_failed');
|
|
1481
|
+
if (result.status === 'audit_failed') {
|
|
1482
|
+
expect(result.verdict.maxSeverity).toBe('blocking');
|
|
1483
|
+
expect(result.verdict.shouldLoop).toBe(true);
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
it('audit phase with only minor severity returns done', async () => {
|
|
1487
|
+
const auditOutput = '**Concern 1: Minor nitpick**\n**Severity: minor**\n\n**Verdict:** Ready to approve.';
|
|
1488
|
+
const result = await executePhase(auditPhase, SAMPLE_PLAN, basePhases, makeOpts(makeSuccessRuntime(auditOutput)));
|
|
1489
|
+
expect(result.status).toBe('done');
|
|
1490
|
+
});
|
|
1491
|
+
it('audit phase with only medium severity returns done (auto-approves)', async () => {
|
|
1492
|
+
const auditOutput = '**Concern 1: Missing edge case**\n**Severity: medium**\n\n**Verdict:** Needs revision.';
|
|
1493
|
+
const result = await executePhase(auditPhase, SAMPLE_PLAN, basePhases, makeOpts(makeSuccessRuntime(auditOutput)));
|
|
1494
|
+
expect(result.status).toBe('done');
|
|
1495
|
+
});
|
|
1496
|
+
it('audit phase with no severity markers returns done', async () => {
|
|
1497
|
+
const auditOutput = 'Everything looks great. No concerns.';
|
|
1498
|
+
const result = await executePhase(auditPhase, SAMPLE_PLAN, basePhases, makeOpts(makeSuccessRuntime(auditOutput)));
|
|
1499
|
+
expect(result.status).toBe('done');
|
|
1500
|
+
});
|
|
1501
|
+
it('implement phase ignores severity markers in output', async () => {
|
|
1502
|
+
const implOutput = '**Severity: HIGH**\nDone implementing.';
|
|
1503
|
+
const result = await executePhase(implPhase, SAMPLE_PLAN, basePhases, makeOpts(makeSuccessRuntime(implOutput)));
|
|
1504
|
+
expect(result.status).toBe('done');
|
|
1505
|
+
});
|
|
1506
|
+
it('audit phase with runtime error returns failed not audit_failed', async () => {
|
|
1507
|
+
const result = await executePhase(auditPhase, SAMPLE_PLAN, basePhases, makeOpts(makeErrorRuntime('Connection timeout')));
|
|
1508
|
+
expect(result.status).toBe('failed');
|
|
1509
|
+
});
|
|
1510
|
+
});
|
|
1511
|
+
// ---------------------------------------------------------------------------
|
|
1512
|
+
// Audit verdict handling in runNextPhase
|
|
1513
|
+
// ---------------------------------------------------------------------------
|
|
1514
|
+
describe('runNextPhase audit verdict', () => {
|
|
1515
|
+
let tmpDir;
|
|
1516
|
+
let projectDir;
|
|
1517
|
+
let wsDir;
|
|
1518
|
+
let plansDir;
|
|
1519
|
+
beforeEach(async () => {
|
|
1520
|
+
tmpDir = await makeTmpDir();
|
|
1521
|
+
projectDir = path.join(tmpDir, 'project');
|
|
1522
|
+
wsDir = path.join(tmpDir, 'workspace');
|
|
1523
|
+
plansDir = path.join(wsDir, 'plans');
|
|
1524
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
1525
|
+
await fs.mkdir(plansDir, { recursive: true });
|
|
1526
|
+
// Init git in project dir
|
|
1527
|
+
try {
|
|
1528
|
+
execSync('git init', { cwd: projectDir, stdio: 'pipe' });
|
|
1529
|
+
execSync('git config user.email "test@test.com"', { cwd: projectDir, stdio: 'pipe' });
|
|
1530
|
+
execSync('git config user.name "Test"', { cwd: projectDir, stdio: 'pipe' });
|
|
1531
|
+
await fs.writeFile(path.join(projectDir, 'README.md'), 'test');
|
|
1532
|
+
execSync('git add . && git commit -m "init"', { cwd: projectDir, stdio: 'pipe' });
|
|
1533
|
+
}
|
|
1534
|
+
catch {
|
|
1535
|
+
// git not available
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
afterEach(async () => {
|
|
1539
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
1540
|
+
});
|
|
1541
|
+
function makeOpts(runtime) {
|
|
1542
|
+
return {
|
|
1543
|
+
runtime,
|
|
1544
|
+
model: 'test',
|
|
1545
|
+
projectCwd: projectDir,
|
|
1546
|
+
addDirs: [],
|
|
1547
|
+
timeoutMs: 5000,
|
|
1548
|
+
workspaceCwd: wsDir,
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
const progressMsgs = [];
|
|
1552
|
+
const onProgress = async (msg) => { progressMsgs.push(msg); };
|
|
1553
|
+
beforeEach(() => {
|
|
1554
|
+
progressMsgs.length = 0;
|
|
1555
|
+
});
|
|
1556
|
+
// Helper to create a phases file with a single audit phase ready to run
|
|
1557
|
+
function writeAuditPhases(phasesPath, planPath, overrides) {
|
|
1558
|
+
const phases = {
|
|
1559
|
+
planId: 'plan-011',
|
|
1560
|
+
planFile: 'workspace/plans/plan-011-test.md',
|
|
1561
|
+
planContentHash: computePlanHash(SAMPLE_PLAN),
|
|
1562
|
+
createdAt: '2026-01-01',
|
|
1563
|
+
updatedAt: '2026-01-01',
|
|
1564
|
+
phases: [
|
|
1565
|
+
{
|
|
1566
|
+
id: 'phase-1',
|
|
1567
|
+
title: 'Implement src/discord/',
|
|
1568
|
+
kind: 'implement',
|
|
1569
|
+
description: 'Implement changes.',
|
|
1570
|
+
status: 'done',
|
|
1571
|
+
dependsOn: [],
|
|
1572
|
+
contextFiles: ['src/foo.ts'],
|
|
1573
|
+
output: 'Done.',
|
|
1574
|
+
...{}, // base phase
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
id: 'phase-2',
|
|
1578
|
+
title: 'Post-implementation audit',
|
|
1579
|
+
kind: 'audit',
|
|
1580
|
+
description: 'Audit all changes against the plan specification.',
|
|
1581
|
+
status: 'pending',
|
|
1582
|
+
dependsOn: ['phase-1'],
|
|
1583
|
+
contextFiles: ['src/foo.ts'],
|
|
1584
|
+
...overrides,
|
|
1585
|
+
},
|
|
1586
|
+
],
|
|
1587
|
+
};
|
|
1588
|
+
writePhasesFile(phasesPath, phases);
|
|
1589
|
+
}
|
|
1590
|
+
it('audit phase with blocking severity returns audit_failed result', async () => {
|
|
1591
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1592
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1593
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1594
|
+
writeAuditPhases(phasesPath, planPath);
|
|
1595
|
+
const auditOutput = '**Concern 1: Missing error handling**\n**Severity: blocking**\n\n**Verdict:** Needs revision.';
|
|
1596
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime(auditOutput)), onProgress);
|
|
1597
|
+
expect(result.result).toBe('audit_failed');
|
|
1598
|
+
if (result.result === 'audit_failed') {
|
|
1599
|
+
expect(result.verdict.maxSeverity).toBe('blocking');
|
|
1600
|
+
}
|
|
1601
|
+
// Verify on-disk status is 'failed' (not 'audit_failed')
|
|
1602
|
+
const updated = deserializePhases(fsSync.readFileSync(phasesPath, 'utf-8'));
|
|
1603
|
+
const auditPhase = updated.phases.find(p => p.id === 'phase-2');
|
|
1604
|
+
expect(auditPhase.status).toBe('failed');
|
|
1605
|
+
});
|
|
1606
|
+
it('audit phase with medium-only severity returns done (auto-approves)', async () => {
|
|
1607
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1608
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1609
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1610
|
+
writeAuditPhases(phasesPath, planPath);
|
|
1611
|
+
const auditOutput = '**Concern 1: Missing edge case**\n**Severity: medium**\n\n**Verdict:** Needs revision.';
|
|
1612
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime(auditOutput)), onProgress);
|
|
1613
|
+
expect(result.result).toBe('done');
|
|
1614
|
+
// No fix attempt messages — medium auto-approves
|
|
1615
|
+
expect(progressMsgs.some(m => m.includes('Fix attempt'))).toBe(false);
|
|
1616
|
+
});
|
|
1617
|
+
it('failed audit phase can be retried (not blocked by modifiedFiles check)', async () => {
|
|
1618
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1619
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1620
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1621
|
+
// Set audit phase to failed with no modifiedFiles (would block non-audit phases)
|
|
1622
|
+
writeAuditPhases(phasesPath, planPath, {
|
|
1623
|
+
status: 'failed',
|
|
1624
|
+
error: 'previous audit failure',
|
|
1625
|
+
});
|
|
1626
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('All good. No concerns.')), onProgress);
|
|
1627
|
+
// Should proceed to execution (not retry_blocked)
|
|
1628
|
+
expect(result.result).not.toBe('retry_blocked');
|
|
1629
|
+
});
|
|
1630
|
+
it('failed implement phase without modifiedFiles is still retry_blocked', async () => {
|
|
1631
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1632
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1633
|
+
const phases = decomposePlan(SAMPLE_PLAN, 'plan-011', 'workspace/plans/plan-011-test.md');
|
|
1634
|
+
// Simulate a failed implement phase without modifiedFiles
|
|
1635
|
+
phases.phases[0].status = 'failed';
|
|
1636
|
+
phases.phases[0].error = 'previous error';
|
|
1637
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1638
|
+
writePhasesFile(phasesPath, phases);
|
|
1639
|
+
const result = await runNextPhase(phasesPath, planPath, makeOpts(makeSuccessRuntime('ok')), onProgress);
|
|
1640
|
+
expect(result.result).toBe('retry_blocked');
|
|
1641
|
+
});
|
|
1642
|
+
});
|
|
1643
|
+
// ---------------------------------------------------------------------------
|
|
1644
|
+
// buildAuditFixPrompt
|
|
1645
|
+
// ---------------------------------------------------------------------------
|
|
1646
|
+
describe('buildAuditFixPrompt', () => {
|
|
1647
|
+
const contextFiles = ['src/foo.ts', 'src/bar.ts'];
|
|
1648
|
+
const modifiedFiles = ['src/foo.ts', 'src/baz.ts'];
|
|
1649
|
+
it('includes objective from plan', () => {
|
|
1650
|
+
const prompt = buildAuditFixPrompt(SAMPLE_PLAN, 'Audit findings here', contextFiles, modifiedFiles, 1, 2);
|
|
1651
|
+
expect(prompt).toContain('Add a plan manager');
|
|
1652
|
+
});
|
|
1653
|
+
it('includes audit findings', () => {
|
|
1654
|
+
const findings = '**Concern 1: Missing error handling**\n**Severity: blocking**';
|
|
1655
|
+
const prompt = buildAuditFixPrompt(SAMPLE_PLAN, findings, contextFiles, modifiedFiles, 1, 2);
|
|
1656
|
+
expect(prompt).toContain('Missing error handling');
|
|
1657
|
+
expect(prompt).toContain('Severity: blocking');
|
|
1658
|
+
});
|
|
1659
|
+
it('lists context files', () => {
|
|
1660
|
+
const prompt = buildAuditFixPrompt(SAMPLE_PLAN, 'findings', contextFiles, modifiedFiles, 1, 2);
|
|
1661
|
+
expect(prompt).toContain('src/foo.ts');
|
|
1662
|
+
expect(prompt).toContain('src/bar.ts');
|
|
1663
|
+
});
|
|
1664
|
+
it('includes all required sections per plan spec', () => {
|
|
1665
|
+
const prompt = buildAuditFixPrompt(SAMPLE_PLAN, 'findings', contextFiles, modifiedFiles, 1, 2);
|
|
1666
|
+
// Anti-regression instruction
|
|
1667
|
+
expect(prompt).toContain('Fix only the specific deviations');
|
|
1668
|
+
expect(prompt).toContain('Do not refactor, reorganize, or modify code that the audit did not flag');
|
|
1669
|
+
// Attempt counter
|
|
1670
|
+
expect(prompt).toContain('Fix attempt 1 of 2');
|
|
1671
|
+
// Limitation note
|
|
1672
|
+
expect(prompt).toContain('read/write file tools only');
|
|
1673
|
+
expect(prompt).toContain('cannot run tests, build commands, or install packages');
|
|
1674
|
+
// Modified files
|
|
1675
|
+
expect(prompt).toContain('src/baz.ts');
|
|
1676
|
+
// Does NOT mention Bash
|
|
1677
|
+
expect(prompt).not.toContain('Bash');
|
|
1678
|
+
});
|
|
1679
|
+
it('shows urgency escalation on final attempt', () => {
|
|
1680
|
+
const prompt = buildAuditFixPrompt(SAMPLE_PLAN, 'findings', contextFiles, modifiedFiles, 2, 2);
|
|
1681
|
+
expect(prompt).toContain('last chance');
|
|
1682
|
+
expect(prompt).toContain('Fix attempt 2 of 2');
|
|
1683
|
+
});
|
|
1684
|
+
it('handles empty modified files list', () => {
|
|
1685
|
+
const prompt = buildAuditFixPrompt(SAMPLE_PLAN, 'findings', contextFiles, [], 1, 1);
|
|
1686
|
+
expect(prompt).not.toContain('## Modified Files');
|
|
1687
|
+
});
|
|
1688
|
+
});
|
|
1689
|
+
// ---------------------------------------------------------------------------
|
|
1690
|
+
// Audit fix loop in runNextPhase
|
|
1691
|
+
// ---------------------------------------------------------------------------
|
|
1692
|
+
describe('runNextPhase audit fix loop', () => {
|
|
1693
|
+
let tmpDir;
|
|
1694
|
+
let projectDir;
|
|
1695
|
+
let wsDir;
|
|
1696
|
+
let plansDir;
|
|
1697
|
+
beforeEach(async () => {
|
|
1698
|
+
tmpDir = await makeTmpDir();
|
|
1699
|
+
projectDir = path.join(tmpDir, 'project');
|
|
1700
|
+
wsDir = path.join(tmpDir, 'workspace');
|
|
1701
|
+
plansDir = path.join(wsDir, 'plans');
|
|
1702
|
+
await fs.mkdir(projectDir, { recursive: true });
|
|
1703
|
+
await fs.mkdir(plansDir, { recursive: true });
|
|
1704
|
+
// Init git in project dir
|
|
1705
|
+
try {
|
|
1706
|
+
execSync('git init', { cwd: projectDir, stdio: 'pipe' });
|
|
1707
|
+
execSync('git config user.email "test@test.com"', { cwd: projectDir, stdio: 'pipe' });
|
|
1708
|
+
execSync('git config user.name "Test"', { cwd: projectDir, stdio: 'pipe' });
|
|
1709
|
+
await fs.writeFile(path.join(projectDir, 'README.md'), 'test');
|
|
1710
|
+
execSync('git add . && git commit -m "init"', { cwd: projectDir, stdio: 'pipe' });
|
|
1711
|
+
}
|
|
1712
|
+
catch {
|
|
1713
|
+
// git not available
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
afterEach(async () => {
|
|
1717
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
1718
|
+
});
|
|
1719
|
+
const progressMsgs = [];
|
|
1720
|
+
const onProgress = async (msg) => { progressMsgs.push(msg); };
|
|
1721
|
+
beforeEach(() => {
|
|
1722
|
+
progressMsgs.length = 0;
|
|
1723
|
+
});
|
|
1724
|
+
function writeAuditPhases(phasesPath, overrides) {
|
|
1725
|
+
const phases = {
|
|
1726
|
+
planId: 'plan-011',
|
|
1727
|
+
planFile: 'workspace/plans/plan-011-test.md',
|
|
1728
|
+
planContentHash: computePlanHash(SAMPLE_PLAN),
|
|
1729
|
+
createdAt: '2026-01-01',
|
|
1730
|
+
updatedAt: '2026-01-01',
|
|
1731
|
+
phases: [
|
|
1732
|
+
{
|
|
1733
|
+
id: 'phase-1',
|
|
1734
|
+
title: 'Implement src/discord/',
|
|
1735
|
+
kind: 'implement',
|
|
1736
|
+
description: 'Implement changes.',
|
|
1737
|
+
status: 'done',
|
|
1738
|
+
dependsOn: [],
|
|
1739
|
+
contextFiles: ['src/foo.ts'],
|
|
1740
|
+
output: 'Done.',
|
|
1741
|
+
},
|
|
1742
|
+
{
|
|
1743
|
+
id: 'phase-2',
|
|
1744
|
+
title: 'Post-implementation audit',
|
|
1745
|
+
kind: 'audit',
|
|
1746
|
+
description: 'Audit all changes against the plan specification.',
|
|
1747
|
+
status: 'pending',
|
|
1748
|
+
dependsOn: ['phase-1'],
|
|
1749
|
+
contextFiles: ['src/foo.ts'],
|
|
1750
|
+
...overrides,
|
|
1751
|
+
},
|
|
1752
|
+
],
|
|
1753
|
+
};
|
|
1754
|
+
writePhasesFile(phasesPath, phases);
|
|
1755
|
+
}
|
|
1756
|
+
it('fix loop succeeds on first attempt → returns done', async () => {
|
|
1757
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1758
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1759
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1760
|
+
writeAuditPhases(phasesPath);
|
|
1761
|
+
// First call: audit fails. After fix agent runs, second audit passes.
|
|
1762
|
+
let callCount = 0;
|
|
1763
|
+
const runtime = {
|
|
1764
|
+
id: 'claude_code',
|
|
1765
|
+
capabilities: new Set(['streaming_text']),
|
|
1766
|
+
async *invoke() {
|
|
1767
|
+
callCount++;
|
|
1768
|
+
if (callCount === 1) {
|
|
1769
|
+
// First audit: fails
|
|
1770
|
+
const text = '**Concern 1: Missing validation**\n**Severity: blocking**\n\n**Verdict:** Needs revision.';
|
|
1771
|
+
yield { type: 'text_delta', text };
|
|
1772
|
+
yield { type: 'text_final', text };
|
|
1773
|
+
}
|
|
1774
|
+
else if (callCount === 2) {
|
|
1775
|
+
// Fix agent
|
|
1776
|
+
const text = 'Fixed the validation issue.';
|
|
1777
|
+
yield { type: 'text_delta', text };
|
|
1778
|
+
yield { type: 'text_final', text };
|
|
1779
|
+
}
|
|
1780
|
+
else {
|
|
1781
|
+
// Re-audit: passes
|
|
1782
|
+
const text = 'No concerns. **Verdict:** Ready to approve.';
|
|
1783
|
+
yield { type: 'text_delta', text };
|
|
1784
|
+
yield { type: 'text_final', text };
|
|
1785
|
+
}
|
|
1786
|
+
},
|
|
1787
|
+
};
|
|
1788
|
+
const opts = {
|
|
1789
|
+
runtime,
|
|
1790
|
+
model: 'test',
|
|
1791
|
+
projectCwd: projectDir,
|
|
1792
|
+
addDirs: [],
|
|
1793
|
+
timeoutMs: 5000,
|
|
1794
|
+
workspaceCwd: wsDir,
|
|
1795
|
+
maxAuditFixAttempts: 2,
|
|
1796
|
+
};
|
|
1797
|
+
const result = await runNextPhase(phasesPath, planPath, opts, onProgress);
|
|
1798
|
+
expect(result.result).toBe('done');
|
|
1799
|
+
expect(callCount).toBe(3); // audit + fix + re-audit
|
|
1800
|
+
expect(progressMsgs.some(m => m.includes('Fix attempt 1'))).toBe(true);
|
|
1801
|
+
});
|
|
1802
|
+
it('exhausted fix attempts → rollback and return audit_failed', async () => {
|
|
1803
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1804
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1805
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1806
|
+
writeAuditPhases(phasesPath);
|
|
1807
|
+
// Create an uncommitted file that should be cleaned up by rollback
|
|
1808
|
+
await fs.writeFile(path.join(projectDir, 'dirty-file.txt'), 'should be cleaned');
|
|
1809
|
+
// Every audit returns HIGH severity — fixes never work
|
|
1810
|
+
const runtime = {
|
|
1811
|
+
id: 'claude_code',
|
|
1812
|
+
capabilities: new Set(['streaming_text']),
|
|
1813
|
+
async *invoke() {
|
|
1814
|
+
const text = '**Concern 1: Still broken**\n**Severity: HIGH**\n\n**Verdict:** Needs revision.';
|
|
1815
|
+
yield { type: 'text_delta', text };
|
|
1816
|
+
yield { type: 'text_final', text };
|
|
1817
|
+
},
|
|
1818
|
+
};
|
|
1819
|
+
const opts = {
|
|
1820
|
+
runtime,
|
|
1821
|
+
model: 'test',
|
|
1822
|
+
projectCwd: projectDir,
|
|
1823
|
+
addDirs: [],
|
|
1824
|
+
timeoutMs: 5000,
|
|
1825
|
+
workspaceCwd: wsDir,
|
|
1826
|
+
maxAuditFixAttempts: 2,
|
|
1827
|
+
};
|
|
1828
|
+
const result = await runNextPhase(phasesPath, planPath, opts, onProgress);
|
|
1829
|
+
expect(result.result).toBe('audit_failed');
|
|
1830
|
+
// Rollback should have cleaned the dirty file
|
|
1831
|
+
expect(fsSync.existsSync(path.join(projectDir, 'dirty-file.txt'))).toBe(false);
|
|
1832
|
+
expect(progressMsgs.some(m => m.includes('rolled back'))).toBe(true);
|
|
1833
|
+
});
|
|
1834
|
+
it('maxAuditFixAttempts=0 skips fix loop entirely', async () => {
|
|
1835
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1836
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1837
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1838
|
+
writeAuditPhases(phasesPath);
|
|
1839
|
+
const auditOutput = '**Concern 1: Issue**\n**Severity: blocking**\n\n**Verdict:** Needs revision.';
|
|
1840
|
+
const opts = {
|
|
1841
|
+
runtime: makeSuccessRuntime(auditOutput),
|
|
1842
|
+
model: 'test',
|
|
1843
|
+
projectCwd: projectDir,
|
|
1844
|
+
addDirs: [],
|
|
1845
|
+
timeoutMs: 5000,
|
|
1846
|
+
workspaceCwd: wsDir,
|
|
1847
|
+
maxAuditFixAttempts: 0,
|
|
1848
|
+
};
|
|
1849
|
+
const result = await runNextPhase(phasesPath, planPath, opts, onProgress);
|
|
1850
|
+
expect(result.result).toBe('audit_failed');
|
|
1851
|
+
// No fix attempt messages
|
|
1852
|
+
expect(progressMsgs.some(m => m.includes('Fix attempt'))).toBe(false);
|
|
1853
|
+
});
|
|
1854
|
+
it('no git repo → skips fix loop', async () => {
|
|
1855
|
+
// Create a non-git project dir
|
|
1856
|
+
const noGitDir = path.join(tmpDir, 'no-git-project');
|
|
1857
|
+
await fs.mkdir(noGitDir, { recursive: true });
|
|
1858
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1859
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1860
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1861
|
+
writeAuditPhases(phasesPath);
|
|
1862
|
+
const auditOutput = '**Concern 1: Issue**\n**Severity: blocking**\n\n**Verdict:** Needs revision.';
|
|
1863
|
+
const opts = {
|
|
1864
|
+
runtime: makeSuccessRuntime(auditOutput),
|
|
1865
|
+
model: 'test',
|
|
1866
|
+
projectCwd: noGitDir,
|
|
1867
|
+
addDirs: [],
|
|
1868
|
+
timeoutMs: 5000,
|
|
1869
|
+
workspaceCwd: wsDir,
|
|
1870
|
+
maxAuditFixAttempts: 2,
|
|
1871
|
+
};
|
|
1872
|
+
const result = await runNextPhase(phasesPath, planPath, opts, onProgress);
|
|
1873
|
+
expect(result.result).toBe('audit_failed');
|
|
1874
|
+
// Should not attempt fixes without git
|
|
1875
|
+
expect(progressMsgs.some(m => m.includes('Fix attempt'))).toBe(false);
|
|
1876
|
+
// Should emit skip message
|
|
1877
|
+
expect(progressMsgs.some(m => m.includes('git not available'))).toBe(true);
|
|
1878
|
+
});
|
|
1879
|
+
it('fix agent error consumes attempt and continues', async () => {
|
|
1880
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1881
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1882
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1883
|
+
writeAuditPhases(phasesPath);
|
|
1884
|
+
let callCount = 0;
|
|
1885
|
+
const runtime = {
|
|
1886
|
+
id: 'claude_code',
|
|
1887
|
+
capabilities: new Set(['streaming_text']),
|
|
1888
|
+
async *invoke() {
|
|
1889
|
+
callCount++;
|
|
1890
|
+
if (callCount === 1) {
|
|
1891
|
+
// First audit: fails
|
|
1892
|
+
const text = '**Concern 1: Problem**\n**Severity: blocking**\n\n**Verdict:** Needs revision.';
|
|
1893
|
+
yield { type: 'text_delta', text };
|
|
1894
|
+
yield { type: 'text_final', text };
|
|
1895
|
+
}
|
|
1896
|
+
else {
|
|
1897
|
+
// Fix agent: throws error (every time)
|
|
1898
|
+
yield { type: 'error', message: 'Runtime crashed' };
|
|
1899
|
+
}
|
|
1900
|
+
},
|
|
1901
|
+
};
|
|
1902
|
+
const opts = {
|
|
1903
|
+
runtime,
|
|
1904
|
+
model: 'test',
|
|
1905
|
+
projectCwd: projectDir,
|
|
1906
|
+
addDirs: [],
|
|
1907
|
+
timeoutMs: 5000,
|
|
1908
|
+
workspaceCwd: wsDir,
|
|
1909
|
+
maxAuditFixAttempts: 2,
|
|
1910
|
+
};
|
|
1911
|
+
const result = await runNextPhase(phasesPath, planPath, opts, onProgress);
|
|
1912
|
+
expect(result.result).toBe('audit_failed');
|
|
1913
|
+
// With continue behavior: audit (1) + fix error (2) + fix error (3) = 3 calls
|
|
1914
|
+
expect(callCount).toBe(3);
|
|
1915
|
+
});
|
|
1916
|
+
it('fix loop succeeds on second attempt after first re-audit fails', async () => {
|
|
1917
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1918
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1919
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1920
|
+
writeAuditPhases(phasesPath);
|
|
1921
|
+
let callCount = 0;
|
|
1922
|
+
const runtime = {
|
|
1923
|
+
id: 'claude_code',
|
|
1924
|
+
capabilities: new Set(['streaming_text']),
|
|
1925
|
+
async *invoke() {
|
|
1926
|
+
callCount++;
|
|
1927
|
+
if (callCount === 1) {
|
|
1928
|
+
// Initial audit: fails
|
|
1929
|
+
const text = '**Concern 1: Missing validation**\n**Severity: blocking**\n\n**Verdict:** Needs revision.';
|
|
1930
|
+
yield { type: 'text_delta', text };
|
|
1931
|
+
yield { type: 'text_final', text };
|
|
1932
|
+
}
|
|
1933
|
+
else if (callCount === 2) {
|
|
1934
|
+
// First fix agent
|
|
1935
|
+
const text = 'Attempted fix.';
|
|
1936
|
+
yield { type: 'text_delta', text };
|
|
1937
|
+
yield { type: 'text_final', text };
|
|
1938
|
+
}
|
|
1939
|
+
else if (callCount === 3) {
|
|
1940
|
+
// First re-audit: still fails
|
|
1941
|
+
const text = '**Concern 1: Still broken**\n**Severity: blocking**\n\n**Verdict:** Needs revision.';
|
|
1942
|
+
yield { type: 'text_delta', text };
|
|
1943
|
+
yield { type: 'text_final', text };
|
|
1944
|
+
}
|
|
1945
|
+
else if (callCount === 4) {
|
|
1946
|
+
// Second fix agent
|
|
1947
|
+
const text = 'Fixed properly this time.';
|
|
1948
|
+
yield { type: 'text_delta', text };
|
|
1949
|
+
yield { type: 'text_final', text };
|
|
1950
|
+
}
|
|
1951
|
+
else {
|
|
1952
|
+
// Second re-audit: passes
|
|
1953
|
+
const text = 'No concerns. **Verdict:** Ready to approve.';
|
|
1954
|
+
yield { type: 'text_delta', text };
|
|
1955
|
+
yield { type: 'text_final', text };
|
|
1956
|
+
}
|
|
1957
|
+
},
|
|
1958
|
+
};
|
|
1959
|
+
const opts = {
|
|
1960
|
+
runtime,
|
|
1961
|
+
model: 'test',
|
|
1962
|
+
projectCwd: projectDir,
|
|
1963
|
+
addDirs: [],
|
|
1964
|
+
timeoutMs: 5000,
|
|
1965
|
+
workspaceCwd: wsDir,
|
|
1966
|
+
maxAuditFixAttempts: 2,
|
|
1967
|
+
};
|
|
1968
|
+
const result = await runNextPhase(phasesPath, planPath, opts, onProgress);
|
|
1969
|
+
expect(result.result).toBe('done');
|
|
1970
|
+
expect(callCount).toBe(5); // audit + fix1 + re-audit1 + fix2 + re-audit2
|
|
1971
|
+
// Both progress messages emitted with correct attempt counters
|
|
1972
|
+
expect(progressMsgs.some(m => m.includes('attempting fix (1/2)'))).toBe(true);
|
|
1973
|
+
expect(progressMsgs.some(m => m.includes('attempting fix (2/2)'))).toBe(true);
|
|
1974
|
+
});
|
|
1975
|
+
it('fix agent does not receive Bash tool', async () => {
|
|
1976
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
1977
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
1978
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
1979
|
+
writeAuditPhases(phasesPath);
|
|
1980
|
+
const capturedTools = [];
|
|
1981
|
+
let callCount = 0;
|
|
1982
|
+
const runtime = {
|
|
1983
|
+
id: 'claude_code',
|
|
1984
|
+
capabilities: new Set(['streaming_text']),
|
|
1985
|
+
async *invoke(params) {
|
|
1986
|
+
callCount++;
|
|
1987
|
+
// Capture tools from the params passed to invoke
|
|
1988
|
+
if (params?.tools)
|
|
1989
|
+
capturedTools.push([...params.tools]);
|
|
1990
|
+
if (callCount === 1) {
|
|
1991
|
+
// Audit: fails
|
|
1992
|
+
const text = '**Concern 1: Issue**\n**Severity: blocking**\n\n**Verdict:** Needs revision.';
|
|
1993
|
+
yield { type: 'text_delta', text };
|
|
1994
|
+
yield { type: 'text_final', text };
|
|
1995
|
+
}
|
|
1996
|
+
else if (callCount === 2) {
|
|
1997
|
+
// Fix agent
|
|
1998
|
+
const text = 'Fixed.';
|
|
1999
|
+
yield { type: 'text_delta', text };
|
|
2000
|
+
yield { type: 'text_final', text };
|
|
2001
|
+
}
|
|
2002
|
+
else {
|
|
2003
|
+
// Re-audit: passes
|
|
2004
|
+
const text = 'No concerns. **Verdict:** Ready to approve.';
|
|
2005
|
+
yield { type: 'text_delta', text };
|
|
2006
|
+
yield { type: 'text_final', text };
|
|
2007
|
+
}
|
|
2008
|
+
},
|
|
2009
|
+
};
|
|
2010
|
+
const opts = {
|
|
2011
|
+
runtime,
|
|
2012
|
+
model: 'test',
|
|
2013
|
+
projectCwd: projectDir,
|
|
2014
|
+
addDirs: [],
|
|
2015
|
+
timeoutMs: 5000,
|
|
2016
|
+
workspaceCwd: wsDir,
|
|
2017
|
+
maxAuditFixAttempts: 1,
|
|
2018
|
+
};
|
|
2019
|
+
await runNextPhase(phasesPath, planPath, opts, onProgress);
|
|
2020
|
+
// The fix agent call is the second invocation (callCount === 2)
|
|
2021
|
+
// Check that capturedTools has at least 2 entries and the second one has no Bash
|
|
2022
|
+
expect(capturedTools.length).toBeGreaterThanOrEqual(2);
|
|
2023
|
+
const fixAgentTools = capturedTools[1];
|
|
2024
|
+
expect(fixAgentTools).not.toContain('Bash');
|
|
2025
|
+
expect(fixAgentTools).toContain('Read');
|
|
2026
|
+
expect(fixAgentTools).toContain('Write');
|
|
2027
|
+
expect(fixAgentTools).toContain('Edit');
|
|
2028
|
+
expect(fixAgentTools).toContain('Glob');
|
|
2029
|
+
expect(fixAgentTools).toContain('Grep');
|
|
2030
|
+
});
|
|
2031
|
+
it('re-audit runtime error consumes attempt and triggers rollback', async () => {
|
|
2032
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
2033
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
2034
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
2035
|
+
writeAuditPhases(phasesPath);
|
|
2036
|
+
let callCount = 0;
|
|
2037
|
+
const runtime = {
|
|
2038
|
+
id: 'claude_code',
|
|
2039
|
+
capabilities: new Set(['streaming_text']),
|
|
2040
|
+
async *invoke() {
|
|
2041
|
+
callCount++;
|
|
2042
|
+
if (callCount === 1) {
|
|
2043
|
+
// Initial audit: fails
|
|
2044
|
+
const text = '**Concern 1: Issue**\n**Severity: blocking**\n\n**Verdict:** Needs revision.';
|
|
2045
|
+
yield { type: 'text_delta', text };
|
|
2046
|
+
yield { type: 'text_final', text };
|
|
2047
|
+
}
|
|
2048
|
+
else if (callCount === 2) {
|
|
2049
|
+
// Fix agent: succeeds
|
|
2050
|
+
const text = 'Fixed the issues.';
|
|
2051
|
+
yield { type: 'text_delta', text };
|
|
2052
|
+
yield { type: 'text_final', text };
|
|
2053
|
+
}
|
|
2054
|
+
else {
|
|
2055
|
+
// Re-audit: runtime error
|
|
2056
|
+
yield { type: 'error', message: 'Model timeout' };
|
|
2057
|
+
}
|
|
2058
|
+
},
|
|
2059
|
+
};
|
|
2060
|
+
const opts = {
|
|
2061
|
+
runtime,
|
|
2062
|
+
model: 'test',
|
|
2063
|
+
projectCwd: projectDir,
|
|
2064
|
+
addDirs: [],
|
|
2065
|
+
timeoutMs: 5000,
|
|
2066
|
+
workspaceCwd: wsDir,
|
|
2067
|
+
maxAuditFixAttempts: 1,
|
|
2068
|
+
};
|
|
2069
|
+
const result = await runNextPhase(phasesPath, planPath, opts, onProgress);
|
|
2070
|
+
// Re-audit runtime error should be normalized to audit_failed after fix loop exhaustion
|
|
2071
|
+
expect(result.result).toBe('audit_failed');
|
|
2072
|
+
expect(progressMsgs.some(m => m.includes('rolled back'))).toBe(true);
|
|
2073
|
+
});
|
|
2074
|
+
it('rollback failure does not throw — returns audit_failed with warning', async () => {
|
|
2075
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
2076
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
2077
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
2078
|
+
writeAuditPhases(phasesPath);
|
|
2079
|
+
// RuntimeAdapter: audit HIGH → fix agent (corrupts git) → re-audit HIGH → rollback fails
|
|
2080
|
+
let callCount = 0;
|
|
2081
|
+
const runtime = {
|
|
2082
|
+
id: 'claude_code',
|
|
2083
|
+
capabilities: new Set(['streaming_text']),
|
|
2084
|
+
async *invoke() {
|
|
2085
|
+
callCount++;
|
|
2086
|
+
if (callCount === 2) {
|
|
2087
|
+
// Fix agent: corrupt the git repo so rollback will fail
|
|
2088
|
+
// Rename .git to break git commands
|
|
2089
|
+
fsSync.renameSync(path.join(projectDir, '.git'), path.join(projectDir, '.git-broken'));
|
|
2090
|
+
const text = 'Attempted fix.';
|
|
2091
|
+
yield { type: 'text_delta', text };
|
|
2092
|
+
yield { type: 'text_final', text };
|
|
2093
|
+
}
|
|
2094
|
+
else {
|
|
2095
|
+
// Audit calls: always fail
|
|
2096
|
+
const text = '**Concern 1: Still broken**\n**Severity: HIGH**\n\n**Verdict:** Needs revision.';
|
|
2097
|
+
yield { type: 'text_delta', text };
|
|
2098
|
+
yield { type: 'text_final', text };
|
|
2099
|
+
}
|
|
2100
|
+
},
|
|
2101
|
+
};
|
|
2102
|
+
const opts = {
|
|
2103
|
+
runtime,
|
|
2104
|
+
model: 'test',
|
|
2105
|
+
projectCwd: projectDir,
|
|
2106
|
+
addDirs: [],
|
|
2107
|
+
timeoutMs: 5000,
|
|
2108
|
+
workspaceCwd: wsDir,
|
|
2109
|
+
maxAuditFixAttempts: 1,
|
|
2110
|
+
};
|
|
2111
|
+
const result = await runNextPhase(phasesPath, planPath, opts, onProgress);
|
|
2112
|
+
// Should return audit_failed, not throw
|
|
2113
|
+
expect(result.result).toBe('audit_failed');
|
|
2114
|
+
// Should have emitted a rollback failed warning
|
|
2115
|
+
expect(progressMsgs.some(m => m.includes('rollback failed'))).toBe(true);
|
|
2116
|
+
// fixAttemptsUsed should be set
|
|
2117
|
+
if (result.result === 'audit_failed') {
|
|
2118
|
+
expect(result.fixAttemptsUsed).toBe(1);
|
|
2119
|
+
}
|
|
2120
|
+
// Restore .git for cleanup
|
|
2121
|
+
if (fsSync.existsSync(path.join(projectDir, '.git-broken'))) {
|
|
2122
|
+
fsSync.renameSync(path.join(projectDir, '.git-broken'), path.join(projectDir, '.git'));
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
it('fixAttemptsUsed is undefined when fix loop is skipped (maxAuditFixAttempts=0)', async () => {
|
|
2126
|
+
const planPath = path.join(plansDir, 'plan-011-test.md');
|
|
2127
|
+
await fs.writeFile(planPath, SAMPLE_PLAN);
|
|
2128
|
+
const phasesPath = path.join(plansDir, 'plan-011-phases.md');
|
|
2129
|
+
writeAuditPhases(phasesPath);
|
|
2130
|
+
const auditOutput = '**Concern 1: Issue**\n**Severity: blocking**\n\n**Verdict:** Needs revision.';
|
|
2131
|
+
const opts = {
|
|
2132
|
+
runtime: makeSuccessRuntime(auditOutput),
|
|
2133
|
+
model: 'test',
|
|
2134
|
+
projectCwd: projectDir,
|
|
2135
|
+
addDirs: [],
|
|
2136
|
+
timeoutMs: 5000,
|
|
2137
|
+
workspaceCwd: wsDir,
|
|
2138
|
+
maxAuditFixAttempts: 0,
|
|
2139
|
+
};
|
|
2140
|
+
const result = await runNextPhase(phasesPath, planPath, opts, onProgress);
|
|
2141
|
+
expect(result.result).toBe('audit_failed');
|
|
2142
|
+
if (result.result === 'audit_failed') {
|
|
2143
|
+
expect(result.fixAttemptsUsed).toBeUndefined();
|
|
2144
|
+
}
|
|
2145
|
+
});
|
|
2146
|
+
});
|
|
2147
|
+
// ---------------------------------------------------------------------------
|
|
2148
|
+
// extractObjective
|
|
2149
|
+
// ---------------------------------------------------------------------------
|
|
2150
|
+
describe('extractObjective', () => {
|
|
2151
|
+
it('extracts objective section from plan content', () => {
|
|
2152
|
+
const result = extractObjective(SAMPLE_PLAN);
|
|
2153
|
+
expect(result).toBe('Add a plan manager that decomposes complex plans into phases.');
|
|
2154
|
+
});
|
|
2155
|
+
it('returns fallback for missing objective', () => {
|
|
2156
|
+
const result = extractObjective('# Plan\n\n## Changes\n\nSome changes.');
|
|
2157
|
+
expect(result).toBe('(no objective found in plan)');
|
|
2158
|
+
});
|
|
2159
|
+
it('returns fallback for empty string', () => {
|
|
2160
|
+
const result = extractObjective('');
|
|
2161
|
+
expect(result).toBe('(no objective found in plan)');
|
|
2162
|
+
});
|
|
2163
|
+
});
|
|
2164
|
+
// ---------------------------------------------------------------------------
|
|
2165
|
+
// buildPostRunSummary
|
|
2166
|
+
// ---------------------------------------------------------------------------
|
|
2167
|
+
function makePhasesForSummary(overrides = {}) {
|
|
2168
|
+
return {
|
|
2169
|
+
planId: 'plan-011',
|
|
2170
|
+
planFile: 'plans/plan-011.md',
|
|
2171
|
+
planContentHash: 'abc123',
|
|
2172
|
+
createdAt: '2026-02-17',
|
|
2173
|
+
updatedAt: '2026-02-17',
|
|
2174
|
+
phases: [],
|
|
2175
|
+
...overrides,
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
describe('buildPostRunSummary', () => {
|
|
2179
|
+
it('returns empty string when there are no phases', () => {
|
|
2180
|
+
const phases = makePhasesForSummary({ phases: [] });
|
|
2181
|
+
expect(buildPostRunSummary(phases)).toBe('');
|
|
2182
|
+
});
|
|
2183
|
+
it('shows [x] indicator for done phase', () => {
|
|
2184
|
+
const phases = makePhasesForSummary({
|
|
2185
|
+
phases: [
|
|
2186
|
+
{
|
|
2187
|
+
id: 'phase-1', title: 'Implement foo', kind: 'implement', status: 'done',
|
|
2188
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2189
|
+
},
|
|
2190
|
+
],
|
|
2191
|
+
});
|
|
2192
|
+
const summary = buildPostRunSummary(phases);
|
|
2193
|
+
expect(summary).toContain('[x]');
|
|
2194
|
+
expect(summary).toContain('phase-1');
|
|
2195
|
+
expect(summary).toContain('Implement foo');
|
|
2196
|
+
});
|
|
2197
|
+
it('shows [!] indicator for failed phase', () => {
|
|
2198
|
+
const phases = makePhasesForSummary({
|
|
2199
|
+
phases: [
|
|
2200
|
+
{
|
|
2201
|
+
id: 'phase-1', title: 'Implement foo', kind: 'implement', status: 'failed',
|
|
2202
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2203
|
+
},
|
|
2204
|
+
],
|
|
2205
|
+
});
|
|
2206
|
+
expect(buildPostRunSummary(phases)).toContain('[!]');
|
|
2207
|
+
});
|
|
2208
|
+
it('shows [-] indicator for skipped phase', () => {
|
|
2209
|
+
const phases = makePhasesForSummary({
|
|
2210
|
+
phases: [
|
|
2211
|
+
{
|
|
2212
|
+
id: 'phase-1', title: 'Read plan', kind: 'read', status: 'skipped',
|
|
2213
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2214
|
+
},
|
|
2215
|
+
],
|
|
2216
|
+
});
|
|
2217
|
+
expect(buildPostRunSummary(phases)).toContain('[-]');
|
|
2218
|
+
});
|
|
2219
|
+
it('shows [~] indicator for in-progress phase', () => {
|
|
2220
|
+
const phases = makePhasesForSummary({
|
|
2221
|
+
phases: [
|
|
2222
|
+
{
|
|
2223
|
+
id: 'phase-1', title: 'Implement foo', kind: 'implement', status: 'in-progress',
|
|
2224
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2225
|
+
},
|
|
2226
|
+
],
|
|
2227
|
+
});
|
|
2228
|
+
expect(buildPostRunSummary(phases)).toContain('[~]');
|
|
2229
|
+
});
|
|
2230
|
+
it('shows [ ] indicator for pending phase', () => {
|
|
2231
|
+
const phases = makePhasesForSummary({
|
|
2232
|
+
phases: [
|
|
2233
|
+
{
|
|
2234
|
+
id: 'phase-1', title: 'Implement foo', kind: 'implement', status: 'pending',
|
|
2235
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2236
|
+
},
|
|
2237
|
+
],
|
|
2238
|
+
});
|
|
2239
|
+
expect(buildPostRunSummary(phases)).toContain('[ ]');
|
|
2240
|
+
});
|
|
2241
|
+
it('includes git commit hash when present', () => {
|
|
2242
|
+
const phases = makePhasesForSummary({
|
|
2243
|
+
phases: [
|
|
2244
|
+
{
|
|
2245
|
+
id: 'phase-1', title: 'Implement foo', kind: 'implement', status: 'done',
|
|
2246
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2247
|
+
gitCommit: 'a1b2c3d',
|
|
2248
|
+
},
|
|
2249
|
+
],
|
|
2250
|
+
});
|
|
2251
|
+
const summary = buildPostRunSummary(phases);
|
|
2252
|
+
expect(summary).toContain('a1b2c3d');
|
|
2253
|
+
});
|
|
2254
|
+
it('includes modified file count when present', () => {
|
|
2255
|
+
const phases = makePhasesForSummary({
|
|
2256
|
+
phases: [
|
|
2257
|
+
{
|
|
2258
|
+
id: 'phase-1', title: 'Implement foo', kind: 'implement', status: 'done',
|
|
2259
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2260
|
+
modifiedFiles: ['src/foo.ts', 'src/bar.ts'],
|
|
2261
|
+
},
|
|
2262
|
+
],
|
|
2263
|
+
});
|
|
2264
|
+
const summary = buildPostRunSummary(phases);
|
|
2265
|
+
expect(summary).toContain('2 files');
|
|
2266
|
+
});
|
|
2267
|
+
it('uses singular "file" for 1 modified file', () => {
|
|
2268
|
+
const phases = makePhasesForSummary({
|
|
2269
|
+
phases: [
|
|
2270
|
+
{
|
|
2271
|
+
id: 'phase-1', title: 'Implement foo', kind: 'implement', status: 'done',
|
|
2272
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2273
|
+
modifiedFiles: ['src/foo.ts'],
|
|
2274
|
+
},
|
|
2275
|
+
],
|
|
2276
|
+
});
|
|
2277
|
+
const summary = buildPostRunSummary(phases);
|
|
2278
|
+
expect(summary).toContain('1 file');
|
|
2279
|
+
expect(summary).not.toContain('1 files');
|
|
2280
|
+
});
|
|
2281
|
+
it('includes audit verdict from output', () => {
|
|
2282
|
+
const phases = makePhasesForSummary({
|
|
2283
|
+
phases: [
|
|
2284
|
+
{
|
|
2285
|
+
id: 'phase-2', title: 'Post-implementation audit', kind: 'audit', status: 'done',
|
|
2286
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2287
|
+
output: 'No concerns found.\n\n**Verdict:** Ready to approve.',
|
|
2288
|
+
},
|
|
2289
|
+
],
|
|
2290
|
+
});
|
|
2291
|
+
const summary = buildPostRunSummary(phases);
|
|
2292
|
+
expect(summary).toContain('Ready to approve.');
|
|
2293
|
+
});
|
|
2294
|
+
it('does not add verdict line if audit output has no Verdict marker', () => {
|
|
2295
|
+
const phases = makePhasesForSummary({
|
|
2296
|
+
phases: [
|
|
2297
|
+
{
|
|
2298
|
+
id: 'phase-2', title: 'Post-implementation audit', kind: 'audit', status: 'done',
|
|
2299
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2300
|
+
output: 'Looks good.',
|
|
2301
|
+
},
|
|
2302
|
+
],
|
|
2303
|
+
});
|
|
2304
|
+
const summary = buildPostRunSummary(phases);
|
|
2305
|
+
expect(summary).not.toContain(' — ');
|
|
2306
|
+
});
|
|
2307
|
+
it('includes Files changed rollup with unique files across phases', () => {
|
|
2308
|
+
const phases = makePhasesForSummary({
|
|
2309
|
+
phases: [
|
|
2310
|
+
{
|
|
2311
|
+
id: 'phase-1', title: 'Implement foo', kind: 'implement', status: 'done',
|
|
2312
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2313
|
+
modifiedFiles: ['src/foo.ts', 'src/bar.ts'],
|
|
2314
|
+
},
|
|
2315
|
+
{
|
|
2316
|
+
id: 'phase-2', title: 'Implement baz', kind: 'implement', status: 'done',
|
|
2317
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2318
|
+
modifiedFiles: ['src/baz.ts', 'src/bar.ts'], // bar.ts is a duplicate
|
|
2319
|
+
},
|
|
2320
|
+
],
|
|
2321
|
+
});
|
|
2322
|
+
const summary = buildPostRunSummary(phases);
|
|
2323
|
+
expect(summary).toContain('Files changed (3)');
|
|
2324
|
+
expect(summary).toContain('`src/foo.ts`');
|
|
2325
|
+
expect(summary).toContain('`src/bar.ts`');
|
|
2326
|
+
expect(summary).toContain('`src/baz.ts`');
|
|
2327
|
+
// bar.ts should appear only once
|
|
2328
|
+
expect(summary.split('`src/bar.ts`').length - 1).toBe(1);
|
|
2329
|
+
});
|
|
2330
|
+
it('omits Files changed section when no phases have modifiedFiles', () => {
|
|
2331
|
+
const phases = makePhasesForSummary({
|
|
2332
|
+
phases: [
|
|
2333
|
+
{
|
|
2334
|
+
id: 'phase-1', title: 'Read plan', kind: 'read', status: 'done',
|
|
2335
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2336
|
+
},
|
|
2337
|
+
],
|
|
2338
|
+
});
|
|
2339
|
+
const summary = buildPostRunSummary(phases);
|
|
2340
|
+
expect(summary).not.toContain('Files changed');
|
|
2341
|
+
});
|
|
2342
|
+
it('truncates files list with overflow count when budget is exceeded', () => {
|
|
2343
|
+
const manyFiles = Array.from({ length: 30 }, (_, i) => `src/module-${i}/long-filename-${i}.ts`);
|
|
2344
|
+
const phases = makePhasesForSummary({
|
|
2345
|
+
phases: [
|
|
2346
|
+
{
|
|
2347
|
+
id: 'phase-1', title: 'Big impl', kind: 'implement', status: 'done',
|
|
2348
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2349
|
+
modifiedFiles: manyFiles,
|
|
2350
|
+
},
|
|
2351
|
+
],
|
|
2352
|
+
});
|
|
2353
|
+
const summary = buildPostRunSummary(phases, 200);
|
|
2354
|
+
expect(summary).toContain('more)');
|
|
2355
|
+
});
|
|
2356
|
+
it('handles multiple phases with mixed statuses', () => {
|
|
2357
|
+
const phases = makePhasesForSummary({
|
|
2358
|
+
phases: [
|
|
2359
|
+
{
|
|
2360
|
+
id: 'phase-1', title: 'Implement foo', kind: 'implement', status: 'done',
|
|
2361
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2362
|
+
gitCommit: 'abc1234', modifiedFiles: ['src/foo.ts'],
|
|
2363
|
+
},
|
|
2364
|
+
{
|
|
2365
|
+
id: 'phase-2', title: 'Implement bar', kind: 'implement', status: 'failed',
|
|
2366
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2367
|
+
},
|
|
2368
|
+
{
|
|
2369
|
+
id: 'phase-3', title: 'Post-implementation audit', kind: 'audit', status: 'skipped',
|
|
2370
|
+
description: '', dependsOn: [], contextFiles: [],
|
|
2371
|
+
},
|
|
2372
|
+
],
|
|
2373
|
+
});
|
|
2374
|
+
const summary = buildPostRunSummary(phases);
|
|
2375
|
+
expect(summary).toContain('[x]');
|
|
2376
|
+
expect(summary).toContain('[!]');
|
|
2377
|
+
expect(summary).toContain('[-]');
|
|
2378
|
+
expect(summary).toContain('abc1234');
|
|
2379
|
+
});
|
|
2380
|
+
});
|