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,1491 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fsSync from 'node:fs';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { collectRuntimeText } from './runtime-utils.js';
|
|
7
|
+
import { parseAuditVerdict } from './forge-audit-verdict.js';
|
|
8
|
+
import { extractFirstJsonValue } from './json-extract.js';
|
|
9
|
+
const ROLLOUT_ERROR_PATTERNS = [
|
|
10
|
+
/rollout path missing/i,
|
|
11
|
+
/session state appears corrupted/i,
|
|
12
|
+
/state db.*rollout/i,
|
|
13
|
+
];
|
|
14
|
+
function isRolloutPathMissingError(error) {
|
|
15
|
+
if (!error)
|
|
16
|
+
return false;
|
|
17
|
+
return ROLLOUT_ERROR_PATTERNS.some((pattern) => pattern.test(error));
|
|
18
|
+
}
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Constants
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
const VALID_STATUSES = new Set(['pending', 'in-progress', 'done', 'failed', 'skipped']);
|
|
23
|
+
const VALID_KINDS = new Set(['implement', 'read', 'audit']);
|
|
24
|
+
const PHASES_STATE_VERSION = 1;
|
|
25
|
+
/** Known workspace filenames that should be normalized to workspace/ prefix. */
|
|
26
|
+
const KNOWN_WORKSPACE_FILES = new Set([
|
|
27
|
+
'TOOLS.md', 'AGENTS.md', 'MEMORY.md', 'SOUL.md', 'IDENTITY.md', 'USER.md',
|
|
28
|
+
]);
|
|
29
|
+
/** Hardcoded project directory map. */
|
|
30
|
+
const PROJECT_DIRS = {
|
|
31
|
+
discoclaw: path.join(os.homedir(), 'code/discoclaw'),
|
|
32
|
+
};
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Pure functions (no I/O)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
export function computePlanHash(planContent) {
|
|
37
|
+
return createHash('sha256').update(planContent).digest('hex').slice(0, 16);
|
|
38
|
+
}
|
|
39
|
+
export function extractFilePaths(changesSection) {
|
|
40
|
+
const deterministic = extractFilePathsDeterministic(changesSection);
|
|
41
|
+
const legacy = extractFilePathsLegacy(changesSection);
|
|
42
|
+
const merged = [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const candidate of [...deterministic, ...legacy]) {
|
|
45
|
+
if (seen.has(candidate))
|
|
46
|
+
continue;
|
|
47
|
+
seen.add(candidate);
|
|
48
|
+
merged.push(candidate);
|
|
49
|
+
}
|
|
50
|
+
return merged;
|
|
51
|
+
}
|
|
52
|
+
function extractFilePathsDeterministic(changesSection) {
|
|
53
|
+
const paths = [];
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
let inFence = false;
|
|
56
|
+
for (const line of changesSection.split('\n')) {
|
|
57
|
+
const trimmed = line.trimStart();
|
|
58
|
+
if (trimmed.startsWith('```')) {
|
|
59
|
+
inFence = !inFence;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (inFence)
|
|
63
|
+
continue;
|
|
64
|
+
const isHeading = /^#{1,6}\s+/.test(trimmed);
|
|
65
|
+
const isList = /^[-*+]\s+/.test(trimmed);
|
|
66
|
+
const isBoldEntry = /^\*{2,3}`[^`]+`\*{2,3}/.test(trimmed);
|
|
67
|
+
if (!isHeading && !isList && !isBoldEntry)
|
|
68
|
+
continue;
|
|
69
|
+
for (const token of extractBacktickTokens(line)) {
|
|
70
|
+
if (!isLikelyFilePath(token))
|
|
71
|
+
continue;
|
|
72
|
+
if (seen.has(token))
|
|
73
|
+
continue;
|
|
74
|
+
seen.add(token);
|
|
75
|
+
paths.push(token);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return paths;
|
|
79
|
+
}
|
|
80
|
+
function extractFilePathsLegacy(changesSection) {
|
|
81
|
+
const paths = [];
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
const regexes = [
|
|
84
|
+
/^[\s]*-\s+(?:\*{1,3})?`([^`]+)`(?:\*{1,3})?/gm,
|
|
85
|
+
/^#{1,6}\s+(?:\*{1,3})?`([^`]+)`(?:\*{1,3})?/gm,
|
|
86
|
+
/^\s*\*{2,3}`([^`]+)`\*{2,3}(?:\s*[—–:-].*)?$/gm,
|
|
87
|
+
];
|
|
88
|
+
for (const regex of regexes) {
|
|
89
|
+
let m;
|
|
90
|
+
while ((m = regex.exec(changesSection)) !== null) {
|
|
91
|
+
const candidate = m[1];
|
|
92
|
+
if (!isLikelyFilePath(candidate))
|
|
93
|
+
continue;
|
|
94
|
+
if (seen.has(candidate))
|
|
95
|
+
continue;
|
|
96
|
+
seen.add(candidate);
|
|
97
|
+
paths.push(candidate);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return paths;
|
|
101
|
+
}
|
|
102
|
+
function extractBacktickTokens(line) {
|
|
103
|
+
const out = [];
|
|
104
|
+
let i = 0;
|
|
105
|
+
while (i < line.length) {
|
|
106
|
+
const start = line.indexOf('`', i);
|
|
107
|
+
if (start === -1)
|
|
108
|
+
break;
|
|
109
|
+
const end = line.indexOf('`', start + 1);
|
|
110
|
+
if (end === -1)
|
|
111
|
+
break;
|
|
112
|
+
const token = line.slice(start + 1, end).trim();
|
|
113
|
+
if (token)
|
|
114
|
+
out.push(token);
|
|
115
|
+
i = end + 1;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
function isLikelyFilePath(s) {
|
|
120
|
+
// Must contain / or file extension
|
|
121
|
+
if (!s.includes('/') && !s.includes('.'))
|
|
122
|
+
return false;
|
|
123
|
+
// Reject ALL_CAPS identifiers (config keys like PLAN_PHASES_ENABLED)
|
|
124
|
+
if (/^[A-Z][A-Z0-9_]+$/.test(s))
|
|
125
|
+
return false;
|
|
126
|
+
// Reject PascalCase type names (PlanPhase, RunPhaseResult)
|
|
127
|
+
if (/^[A-Z][a-zA-Z]+$/.test(s) && !s.includes('/') && !s.includes('.'))
|
|
128
|
+
return false;
|
|
129
|
+
// Reject quoted strings ('pending', 'done')
|
|
130
|
+
if (s.startsWith("'") || s.startsWith('"'))
|
|
131
|
+
return false;
|
|
132
|
+
// Reject single words without path separators or extensions
|
|
133
|
+
if (!s.includes('/') && !/\.\w+$/.test(s))
|
|
134
|
+
return false;
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
export function groupFiles(filePaths, maxPerGroup) {
|
|
138
|
+
if (filePaths.length === 0)
|
|
139
|
+
return [];
|
|
140
|
+
// 1. Pair module + test files
|
|
141
|
+
const paired = new Map();
|
|
142
|
+
const testSuffixes = ['.test.ts', '.test.js', '.spec.ts', '.spec.js'];
|
|
143
|
+
const assignedToModule = new Set();
|
|
144
|
+
for (const fp of filePaths) {
|
|
145
|
+
const isTest = testSuffixes.some((s) => fp.endsWith(s));
|
|
146
|
+
if (isTest) {
|
|
147
|
+
// Find the module this test belongs to
|
|
148
|
+
let moduleFile;
|
|
149
|
+
for (const suffix of testSuffixes) {
|
|
150
|
+
if (fp.endsWith(suffix)) {
|
|
151
|
+
moduleFile = fp.slice(0, -suffix.length) + fp.slice(-suffix.length).replace(/\.(test|spec)\./, '.');
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (moduleFile && filePaths.includes(moduleFile)) {
|
|
156
|
+
if (!paired.has(moduleFile))
|
|
157
|
+
paired.set(moduleFile, [moduleFile]);
|
|
158
|
+
paired.get(moduleFile).push(fp);
|
|
159
|
+
assignedToModule.add(fp);
|
|
160
|
+
assignedToModule.add(moduleFile);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// 2. Group remaining files by directory
|
|
165
|
+
const dirGroups = new Map();
|
|
166
|
+
for (const fp of filePaths) {
|
|
167
|
+
if (assignedToModule.has(fp))
|
|
168
|
+
continue;
|
|
169
|
+
const dir = path.dirname(fp);
|
|
170
|
+
if (!dirGroups.has(dir))
|
|
171
|
+
dirGroups.set(dir, []);
|
|
172
|
+
dirGroups.get(dir).push(fp);
|
|
173
|
+
}
|
|
174
|
+
// 3. Merge paired groups + directory groups, respecting maxPerGroup
|
|
175
|
+
const result = [];
|
|
176
|
+
// Add paired groups first
|
|
177
|
+
for (const group of paired.values()) {
|
|
178
|
+
if (group.length <= maxPerGroup) {
|
|
179
|
+
result.push(group);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// Shouldn't happen (pairs are 2), but handle it
|
|
183
|
+
for (let i = 0; i < group.length; i += maxPerGroup) {
|
|
184
|
+
result.push(group.slice(i, i + maxPerGroup));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Add directory groups, merging small ones and splitting large ones
|
|
189
|
+
for (const [, files] of dirGroups) {
|
|
190
|
+
// Try to merge into the last result group if it's from the same directory and has room
|
|
191
|
+
if (files.length <= maxPerGroup) {
|
|
192
|
+
result.push(files);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
for (let i = 0; i < files.length; i += maxPerGroup) {
|
|
196
|
+
result.push(files.slice(i, i + maxPerGroup));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
export function extractChangeSpec(changesSection, filePaths) {
|
|
203
|
+
const blocks = [];
|
|
204
|
+
const lines = changesSection.split('\n');
|
|
205
|
+
for (const fp of filePaths) {
|
|
206
|
+
let capturing = false;
|
|
207
|
+
let block = [];
|
|
208
|
+
let foundIndent = -1;
|
|
209
|
+
for (let i = 0; i < lines.length; i++) {
|
|
210
|
+
const line = lines[i];
|
|
211
|
+
// Check if this line starts a top-level list item with our file path
|
|
212
|
+
const topMatch = line.match(/^(\s*)-\s+`([^`]+)`/);
|
|
213
|
+
if (topMatch) {
|
|
214
|
+
const indent = topMatch[1].length;
|
|
215
|
+
const matchedPath = topMatch[2];
|
|
216
|
+
if (capturing) {
|
|
217
|
+
// Hit a new top-level item — stop capturing
|
|
218
|
+
if (indent <= foundIndent) {
|
|
219
|
+
capturing = false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!capturing && matchedPath === fp) {
|
|
223
|
+
capturing = true;
|
|
224
|
+
foundIndent = indent;
|
|
225
|
+
block = [line];
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (capturing) {
|
|
230
|
+
// Check if this is a new top-level item (same or less indent) or a section header
|
|
231
|
+
if (line.match(/^#{1,4}\s/) || (topMatch && topMatch[1].length <= foundIndent)) {
|
|
232
|
+
capturing = false;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
block.push(line);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (block.length > 0) {
|
|
240
|
+
blocks.push(block.join('\n'));
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
blocks.push(`File \`${fp}\` — not described in Changes section; create per Objective.`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return blocks.join('\n\n');
|
|
247
|
+
}
|
|
248
|
+
export function decomposePlan(planContent, planId, planFile, maxContextFiles = 5) {
|
|
249
|
+
const hash = computePlanHash(planContent);
|
|
250
|
+
const now = new Date().toISOString().split('T')[0];
|
|
251
|
+
const changesSection = extractTopLevelSection(planContent, 'Changes');
|
|
252
|
+
const manifestSection = extractTopLevelSection(planContent, 'Change Manifest');
|
|
253
|
+
const manifestPaths = parseChangeManifest(manifestSection);
|
|
254
|
+
const filePaths = manifestPaths.length > 0
|
|
255
|
+
? manifestPaths
|
|
256
|
+
: extractFilePaths(changesSection);
|
|
257
|
+
const phases = [];
|
|
258
|
+
if (filePaths.length === 0) {
|
|
259
|
+
// Minimal 2-phase set for plans without file paths
|
|
260
|
+
phases.push({
|
|
261
|
+
id: 'phase-1',
|
|
262
|
+
title: 'Read and analyze plan',
|
|
263
|
+
kind: 'read',
|
|
264
|
+
description: 'Read the plan file and produce analysis notes.',
|
|
265
|
+
status: 'pending',
|
|
266
|
+
dependsOn: [],
|
|
267
|
+
contextFiles: [planFile],
|
|
268
|
+
});
|
|
269
|
+
phases.push({
|
|
270
|
+
id: 'phase-2',
|
|
271
|
+
title: 'Implement plan',
|
|
272
|
+
kind: 'implement',
|
|
273
|
+
description: 'Execute the plan objectives.',
|
|
274
|
+
status: 'pending',
|
|
275
|
+
dependsOn: ['phase-1'],
|
|
276
|
+
contextFiles: [planFile],
|
|
277
|
+
});
|
|
278
|
+
phases.push({
|
|
279
|
+
id: 'phase-3',
|
|
280
|
+
title: 'Post-implementation audit',
|
|
281
|
+
kind: 'audit',
|
|
282
|
+
description: 'Audit the implementation against the plan specification.',
|
|
283
|
+
status: 'pending',
|
|
284
|
+
dependsOn: ['phase-2'],
|
|
285
|
+
contextFiles: [planFile],
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
// Group files into batches
|
|
290
|
+
const groups = groupFiles(filePaths, maxContextFiles);
|
|
291
|
+
const implPhaseIds = [];
|
|
292
|
+
for (let i = 0; i < groups.length; i++) {
|
|
293
|
+
const group = groups[i];
|
|
294
|
+
const phaseId = `phase-${i + 1}`;
|
|
295
|
+
implPhaseIds.push(phaseId);
|
|
296
|
+
// Normalize bare workspace filenames in contextFiles
|
|
297
|
+
const contextFiles = group.map(normalizeWorkspacePath);
|
|
298
|
+
const changeSpec = extractChangeSpec(changesSection, group);
|
|
299
|
+
phases.push({
|
|
300
|
+
id: phaseId,
|
|
301
|
+
title: `Implement ${formatGroupTitle(group)}`,
|
|
302
|
+
kind: 'implement',
|
|
303
|
+
description: `Implement changes for: ${group.map((f) => `\`${f}\``).join(', ')}`,
|
|
304
|
+
status: 'pending',
|
|
305
|
+
dependsOn: i > 0 ? [implPhaseIds[i - 1]] : [],
|
|
306
|
+
contextFiles,
|
|
307
|
+
changeSpec,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
// Post-implementation audit phase
|
|
311
|
+
const auditContextFiles = filePaths.map(normalizeWorkspacePath);
|
|
312
|
+
phases.push({
|
|
313
|
+
id: `phase-${groups.length + 1}`,
|
|
314
|
+
title: 'Post-implementation audit',
|
|
315
|
+
kind: 'audit',
|
|
316
|
+
description: 'Audit all changes against the plan specification.',
|
|
317
|
+
status: 'pending',
|
|
318
|
+
dependsOn: implPhaseIds,
|
|
319
|
+
contextFiles: auditContextFiles,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
planId,
|
|
324
|
+
planFile,
|
|
325
|
+
planContentHash: hash,
|
|
326
|
+
phases,
|
|
327
|
+
createdAt: now,
|
|
328
|
+
updatedAt: now,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function extractTopLevelSection(planContent, sectionName) {
|
|
332
|
+
const lines = planContent.split('\n');
|
|
333
|
+
const target = sectionName.trim().toLowerCase();
|
|
334
|
+
let inFence = false;
|
|
335
|
+
let capturing = false;
|
|
336
|
+
const body = [];
|
|
337
|
+
for (const line of lines) {
|
|
338
|
+
const trimmed = line.trimStart();
|
|
339
|
+
if (trimmed.startsWith('```')) {
|
|
340
|
+
inFence = !inFence;
|
|
341
|
+
}
|
|
342
|
+
if (!inFence) {
|
|
343
|
+
const headingMatch = line.match(/^##\s+(.+)$/);
|
|
344
|
+
if (headingMatch) {
|
|
345
|
+
const heading = headingMatch[1].trim().toLowerCase();
|
|
346
|
+
if (!capturing && heading === target) {
|
|
347
|
+
capturing = true;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (capturing)
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (capturing)
|
|
355
|
+
body.push(line);
|
|
356
|
+
}
|
|
357
|
+
return body.join('\n').trim();
|
|
358
|
+
}
|
|
359
|
+
function parseChangeManifest(section) {
|
|
360
|
+
if (!section)
|
|
361
|
+
return [];
|
|
362
|
+
const json = extractFirstJsonValue(section, { arrayOnly: true });
|
|
363
|
+
if (!json)
|
|
364
|
+
return [];
|
|
365
|
+
try {
|
|
366
|
+
const parsed = JSON.parse(json);
|
|
367
|
+
if (!Array.isArray(parsed))
|
|
368
|
+
return [];
|
|
369
|
+
const paths = [];
|
|
370
|
+
const seen = new Set();
|
|
371
|
+
for (const value of parsed) {
|
|
372
|
+
if (typeof value !== 'string')
|
|
373
|
+
continue;
|
|
374
|
+
const candidate = value.trim();
|
|
375
|
+
if (!isLikelyFilePath(candidate))
|
|
376
|
+
continue;
|
|
377
|
+
if (seen.has(candidate))
|
|
378
|
+
continue;
|
|
379
|
+
seen.add(candidate);
|
|
380
|
+
paths.push(candidate);
|
|
381
|
+
}
|
|
382
|
+
return paths;
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return [];
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function normalizeWorkspacePath(fp) {
|
|
389
|
+
const basename = path.basename(fp);
|
|
390
|
+
if (KNOWN_WORKSPACE_FILES.has(basename) && !fp.startsWith('workspace/') && !fp.includes('/')) {
|
|
391
|
+
return `workspace/${fp}`;
|
|
392
|
+
}
|
|
393
|
+
return fp;
|
|
394
|
+
}
|
|
395
|
+
function formatGroupTitle(files) {
|
|
396
|
+
if (files.length === 1)
|
|
397
|
+
return path.basename(files[0]);
|
|
398
|
+
const dir = path.dirname(files[0]);
|
|
399
|
+
if (files.every((f) => path.dirname(f) === dir)) {
|
|
400
|
+
return `${dir}/ (${files.length} files)`;
|
|
401
|
+
}
|
|
402
|
+
return `${files.length} files`;
|
|
403
|
+
}
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// Serialization
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
export function serializePhases(phases) {
|
|
408
|
+
const lines = [];
|
|
409
|
+
lines.push(`# Phases: ${phases.planId} — ${phases.planFile}`);
|
|
410
|
+
lines.push(`Created: ${phases.createdAt}`);
|
|
411
|
+
lines.push(`Updated: ${phases.updatedAt}`);
|
|
412
|
+
lines.push(`Plan hash: ${phases.planContentHash}`);
|
|
413
|
+
lines.push('');
|
|
414
|
+
for (const phase of phases.phases) {
|
|
415
|
+
lines.push(`## ${phase.id}: ${phase.title}`);
|
|
416
|
+
lines.push(`**Kind:** ${phase.kind}`);
|
|
417
|
+
lines.push(`**Status:** ${phase.status}`);
|
|
418
|
+
lines.push(`**Context:** ${phase.contextFiles.map((f) => `\`${f}\``).join(', ') || '(none)'}`);
|
|
419
|
+
lines.push(`**Depends on:** ${phase.dependsOn.length > 0 ? phase.dependsOn.join(', ') : '(none)'}`);
|
|
420
|
+
if (phase.gitCommit)
|
|
421
|
+
lines.push(`**Git commit:** ${phase.gitCommit}`);
|
|
422
|
+
if (phase.modifiedFiles && phase.modifiedFiles.length > 0) {
|
|
423
|
+
lines.push(`**Modified files:** ${phase.modifiedFiles.map((f) => `\`${f}\``).join(', ')}`);
|
|
424
|
+
}
|
|
425
|
+
if (phase.failureHashes) {
|
|
426
|
+
lines.push(`**Failure hashes:** ${JSON.stringify(phase.failureHashes)}`);
|
|
427
|
+
}
|
|
428
|
+
lines.push('');
|
|
429
|
+
lines.push(phase.description);
|
|
430
|
+
if (phase.changeSpec) {
|
|
431
|
+
lines.push('');
|
|
432
|
+
lines.push('**Change spec:**');
|
|
433
|
+
lines.push(phase.changeSpec);
|
|
434
|
+
}
|
|
435
|
+
if (phase.output) {
|
|
436
|
+
lines.push('');
|
|
437
|
+
lines.push(`**Output:** ${phase.output}`);
|
|
438
|
+
}
|
|
439
|
+
if (phase.error) {
|
|
440
|
+
lines.push('');
|
|
441
|
+
lines.push(`**Error:** ${phase.error}`);
|
|
442
|
+
}
|
|
443
|
+
lines.push('');
|
|
444
|
+
lines.push('---');
|
|
445
|
+
lines.push('');
|
|
446
|
+
}
|
|
447
|
+
return lines.join('\n');
|
|
448
|
+
}
|
|
449
|
+
export function deserializePhases(content) {
|
|
450
|
+
const headerMatch = content.match(/^# Phases:\s*(\S+)\s*—\s*(.+)$/m);
|
|
451
|
+
if (!headerMatch)
|
|
452
|
+
throw new Error('Malformed phases file: missing header');
|
|
453
|
+
const planId = headerMatch[1];
|
|
454
|
+
const planFile = headerMatch[2].trim();
|
|
455
|
+
const createdMatch = content.match(/^Created:\s*(.+)$/m);
|
|
456
|
+
const updatedMatch = content.match(/^Updated:\s*(.+)$/m);
|
|
457
|
+
const hashMatch = content.match(/^Plan hash:\s*(\S+)$/m);
|
|
458
|
+
if (!hashMatch)
|
|
459
|
+
throw new Error('Malformed phases file: missing plan hash');
|
|
460
|
+
const createdAt = createdMatch?.[1]?.trim() ?? '';
|
|
461
|
+
const updatedAt = updatedMatch?.[1]?.trim() ?? '';
|
|
462
|
+
const planContentHash = hashMatch[1];
|
|
463
|
+
// Split into phase sections
|
|
464
|
+
const phaseSections = content.split(/^## /m).slice(1); // first split is the header
|
|
465
|
+
const phases = [];
|
|
466
|
+
for (const section of phaseSections) {
|
|
467
|
+
const idTitleMatch = section.match(/^(phase-\d+):\s*(.+)$/m);
|
|
468
|
+
if (!idTitleMatch)
|
|
469
|
+
continue;
|
|
470
|
+
const id = idTitleMatch[1];
|
|
471
|
+
const title = idTitleMatch[2].trim();
|
|
472
|
+
const kindMatch = section.match(/^\*\*Kind:\*\*\s*(\S+)/m);
|
|
473
|
+
const statusMatch = section.match(/^\*\*Status:\*\*\s*(\S+)/m);
|
|
474
|
+
const contextMatch = section.match(/^\*\*Context:\*\*\s*(.+)$/m);
|
|
475
|
+
const dependsMatch = section.match(/^\*\*Depends on:\*\*\s*(.+)$/m);
|
|
476
|
+
const commitMatch = section.match(/^\*\*Git commit:\*\*\s*(\S+)/m);
|
|
477
|
+
const modifiedMatch = section.match(/^\*\*Modified files:\*\*\s*(.+)$/m);
|
|
478
|
+
const failureHashesMatch = section.match(/^\*\*Failure hashes:\*\*\s*(.+)$/m);
|
|
479
|
+
const outputMatch = section.match(/^\*\*Output:\*\*\s*([\s\S]*?)(?=\n\*\*(?:Error|Change spec):\*\*|\n---|\n$)/m);
|
|
480
|
+
const errorMatch = section.match(/^\*\*Error:\*\*\s*([\s\S]*?)(?=\n\*\*(?:Output|Change spec):\*\*|\n---|\n$)/m);
|
|
481
|
+
const changeSpecMatch = section.match(/^\*\*Change spec:\*\*\n([\s\S]*?)(?=\n\*\*(?:Output|Error):\*\*|\n---|\n$)/m);
|
|
482
|
+
const kindValue = kindMatch?.[1]?.trim() ?? 'implement';
|
|
483
|
+
const statusValue = statusMatch?.[1]?.trim() ?? 'pending';
|
|
484
|
+
if (!VALID_KINDS.has(kindValue)) {
|
|
485
|
+
throw new Error(`Unknown phase kind: '${kindValue}' in ${id}`);
|
|
486
|
+
}
|
|
487
|
+
if (!VALID_STATUSES.has(statusValue)) {
|
|
488
|
+
throw new Error(`Unknown phase status: '${statusValue}' in ${id}`);
|
|
489
|
+
}
|
|
490
|
+
const contextRaw = contextMatch?.[1]?.trim() ?? '(none)';
|
|
491
|
+
const contextFiles = contextRaw === '(none)'
|
|
492
|
+
? []
|
|
493
|
+
: [...contextRaw.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
|
|
494
|
+
const dependsRaw = dependsMatch?.[1]?.trim() ?? '(none)';
|
|
495
|
+
const dependsOn = dependsRaw === '(none)'
|
|
496
|
+
? []
|
|
497
|
+
: dependsRaw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
498
|
+
// Extract description: text between metadata lines and first **field or ---
|
|
499
|
+
const metadataEnd = section.indexOf('\n\n');
|
|
500
|
+
let description = '';
|
|
501
|
+
if (metadataEnd !== -1) {
|
|
502
|
+
const afterMetadata = section.slice(metadataEnd + 2);
|
|
503
|
+
// Description is everything until the first **field or ---
|
|
504
|
+
const descEnd = afterMetadata.search(/^\*\*(Change spec|Output|Error|Modified files|Failure hashes):\*\*/m);
|
|
505
|
+
const dashEnd = afterMetadata.indexOf('\n---');
|
|
506
|
+
const cutoff = descEnd >= 0 ? descEnd : (dashEnd >= 0 ? dashEnd : afterMetadata.length);
|
|
507
|
+
description = afterMetadata.slice(0, cutoff).trim();
|
|
508
|
+
}
|
|
509
|
+
const phase = {
|
|
510
|
+
id,
|
|
511
|
+
title,
|
|
512
|
+
kind: kindValue,
|
|
513
|
+
description,
|
|
514
|
+
status: statusValue,
|
|
515
|
+
dependsOn,
|
|
516
|
+
contextFiles,
|
|
517
|
+
};
|
|
518
|
+
if (changeSpecMatch)
|
|
519
|
+
phase.changeSpec = changeSpecMatch[1].trim();
|
|
520
|
+
if (outputMatch)
|
|
521
|
+
phase.output = outputMatch[1].trim();
|
|
522
|
+
if (errorMatch)
|
|
523
|
+
phase.error = errorMatch[1].trim();
|
|
524
|
+
if (commitMatch)
|
|
525
|
+
phase.gitCommit = commitMatch[1];
|
|
526
|
+
if (modifiedMatch) {
|
|
527
|
+
phase.modifiedFiles = [...modifiedMatch[1].matchAll(/`([^`]+)`/g)].map((m) => m[1]);
|
|
528
|
+
}
|
|
529
|
+
if (failureHashesMatch) {
|
|
530
|
+
try {
|
|
531
|
+
phase.failureHashes = JSON.parse(failureHashesMatch[1]);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
throw new Error(`Malformed failureHashes in ${id}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
phases.push(phase);
|
|
538
|
+
}
|
|
539
|
+
if (phases.length === 0) {
|
|
540
|
+
throw new Error('Malformed phases file: no phases found');
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
planId,
|
|
544
|
+
planFile,
|
|
545
|
+
planContentHash,
|
|
546
|
+
phases,
|
|
547
|
+
createdAt,
|
|
548
|
+
updatedAt,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// Phase navigation
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
export function getNextPhase(phases) {
|
|
555
|
+
// Priority 1: resume in-progress
|
|
556
|
+
const inProgress = phases.phases.find((p) => p.status === 'in-progress');
|
|
557
|
+
if (inProgress)
|
|
558
|
+
return inProgress;
|
|
559
|
+
// Priority 2: retry failed
|
|
560
|
+
const failed = phases.phases.find((p) => p.status === 'failed');
|
|
561
|
+
if (failed)
|
|
562
|
+
return failed;
|
|
563
|
+
// Priority 3: first pending with all deps met
|
|
564
|
+
for (const phase of phases.phases) {
|
|
565
|
+
if (phase.status !== 'pending')
|
|
566
|
+
continue;
|
|
567
|
+
const depsMet = phase.dependsOn.every((depId) => {
|
|
568
|
+
const dep = phases.phases.find((p) => p.id === depId);
|
|
569
|
+
return dep?.status === 'done' || dep?.status === 'skipped';
|
|
570
|
+
});
|
|
571
|
+
if (depsMet)
|
|
572
|
+
return phase;
|
|
573
|
+
}
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
// State updates (immutable)
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
export function updatePhaseStatus(phases, phaseId, status, output, error) {
|
|
580
|
+
const now = new Date().toISOString().split('T')[0];
|
|
581
|
+
return {
|
|
582
|
+
...phases,
|
|
583
|
+
updatedAt: now,
|
|
584
|
+
phases: phases.phases.map((p) => {
|
|
585
|
+
if (p.id !== phaseId)
|
|
586
|
+
return p;
|
|
587
|
+
return {
|
|
588
|
+
...p,
|
|
589
|
+
status,
|
|
590
|
+
...(output !== undefined ? { output } : {}),
|
|
591
|
+
...(error !== undefined ? { error } : {}),
|
|
592
|
+
};
|
|
593
|
+
}),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
export function checkStaleness(phases, currentPlanContent) {
|
|
597
|
+
const currentHash = computePlanHash(currentPlanContent);
|
|
598
|
+
if (currentHash !== phases.planContentHash) {
|
|
599
|
+
return {
|
|
600
|
+
stale: true,
|
|
601
|
+
message: 'Plan file has changed since phases were generated — the existing phases may not match the current plan intent and cannot run safely.\n\n' +
|
|
602
|
+
'**Fix:** `!plan phases --regenerate <plan-id>`\n\n' +
|
|
603
|
+
'This regenerates phases from the current plan content. All phase statuses are reset to `pending` — previously completed phases will be re-executed. Git commits from completed phases are preserved on the branch, but the phase tracker loses their `done` status.',
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
return { stale: false, message: '' };
|
|
607
|
+
}
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
// Shared helpers
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
/** Extract the ## Objective section from plan content. */
|
|
612
|
+
export function extractObjective(planContent) {
|
|
613
|
+
const objMatch = planContent.match(/## Objective\s*\n([\s\S]*?)(?=\n## )/);
|
|
614
|
+
return objMatch?.[1]?.trim() ?? '(no objective found in plan)';
|
|
615
|
+
}
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
// Prompt builder
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
export function buildPhasePrompt(phase, planContent, injectedContext) {
|
|
620
|
+
const lines = [];
|
|
621
|
+
lines.push('## Objective');
|
|
622
|
+
lines.push('');
|
|
623
|
+
lines.push(extractObjective(planContent));
|
|
624
|
+
lines.push('');
|
|
625
|
+
// Inject pre-read workspace context for implement phases
|
|
626
|
+
if (injectedContext) {
|
|
627
|
+
lines.push('## Pre-read Context Files');
|
|
628
|
+
lines.push('');
|
|
629
|
+
lines.push(injectedContext);
|
|
630
|
+
lines.push('');
|
|
631
|
+
}
|
|
632
|
+
if (phase.kind === 'implement') {
|
|
633
|
+
lines.push('## Task');
|
|
634
|
+
lines.push('');
|
|
635
|
+
lines.push(phase.description);
|
|
636
|
+
lines.push('');
|
|
637
|
+
if (phase.changeSpec) {
|
|
638
|
+
lines.push('## Change Specification');
|
|
639
|
+
lines.push('');
|
|
640
|
+
lines.push(phase.changeSpec);
|
|
641
|
+
lines.push('');
|
|
642
|
+
}
|
|
643
|
+
lines.push('## Context Files');
|
|
644
|
+
lines.push('');
|
|
645
|
+
if (phase.contextFiles.length > 0) {
|
|
646
|
+
lines.push('Read these files to understand the current state, then implement the changes:');
|
|
647
|
+
for (const f of phase.contextFiles) {
|
|
648
|
+
lines.push(`- \`${f}\``);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
lines.push('');
|
|
652
|
+
lines.push('## Instructions');
|
|
653
|
+
lines.push('');
|
|
654
|
+
lines.push('Implement the specified changes using the Write, Edit, and Read tools.');
|
|
655
|
+
lines.push('After making changes, output a brief summary of what was changed.');
|
|
656
|
+
}
|
|
657
|
+
else if (phase.kind === 'read') {
|
|
658
|
+
lines.push('## Task');
|
|
659
|
+
lines.push('');
|
|
660
|
+
lines.push(phase.description);
|
|
661
|
+
lines.push('');
|
|
662
|
+
lines.push('## Context Files');
|
|
663
|
+
lines.push('');
|
|
664
|
+
if (phase.contextFiles.length > 0) {
|
|
665
|
+
lines.push('Read and analyze these files:');
|
|
666
|
+
for (const f of phase.contextFiles) {
|
|
667
|
+
lines.push(`- \`${f}\``);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
lines.push('');
|
|
671
|
+
lines.push('## Instructions');
|
|
672
|
+
lines.push('');
|
|
673
|
+
lines.push('Read the specified files and produce analysis notes. Use Read, Glob, and Grep tools only.');
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
// audit
|
|
677
|
+
lines.push('## Task');
|
|
678
|
+
lines.push('');
|
|
679
|
+
lines.push(phase.description);
|
|
680
|
+
lines.push('');
|
|
681
|
+
lines.push('## Context Files');
|
|
682
|
+
lines.push('');
|
|
683
|
+
if (phase.contextFiles.length > 0) {
|
|
684
|
+
lines.push('Audit these files against the plan specification:');
|
|
685
|
+
for (const f of phase.contextFiles) {
|
|
686
|
+
lines.push(`- \`${f}\``);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
lines.push('');
|
|
690
|
+
lines.push('## Instructions');
|
|
691
|
+
lines.push('');
|
|
692
|
+
lines.push('Compare the implementation against the plan specification. For each concern found, use this EXACT format:');
|
|
693
|
+
lines.push('');
|
|
694
|
+
lines.push('**Concern N: [title]**');
|
|
695
|
+
lines.push('Description of the deviation.');
|
|
696
|
+
lines.push('**Severity: blocking | medium | minor | suggestion**');
|
|
697
|
+
lines.push('');
|
|
698
|
+
lines.push('Severity level definitions:');
|
|
699
|
+
lines.push('- **blocking** — Correctness bugs, security issues, architectural flaws, missing critical functionality. The plan cannot ship with this unresolved.');
|
|
700
|
+
lines.push('- **medium** — Substantive improvements that would make the plan better but aren\'t showstoppers. Missing edge case handling, incomplete error paths.');
|
|
701
|
+
lines.push('- **minor** — Small issues: naming, style, minor clarity gaps. Worth noting, not worth looping over.');
|
|
702
|
+
lines.push('- **suggestion** — Ideas for future improvement. Not problems with the current plan.');
|
|
703
|
+
lines.push('');
|
|
704
|
+
lines.push('IMPORTANT: Each concern MUST have its own **Severity: X** line. Do NOT use tables, summary grids, or any other format for severity ratings — the automated fix loop parses these markers to decide whether to trigger revisions.');
|
|
705
|
+
lines.push('');
|
|
706
|
+
lines.push('End with a **Verdict:** line — either "Needs revision." (if any blocking concerns) or "Ready to approve." (if no blocking concerns).');
|
|
707
|
+
lines.push('');
|
|
708
|
+
lines.push('Use Read, Glob, and Grep tools only.');
|
|
709
|
+
}
|
|
710
|
+
return lines.join('\n');
|
|
711
|
+
}
|
|
712
|
+
// ---------------------------------------------------------------------------
|
|
713
|
+
// Post-run summary
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
/**
|
|
716
|
+
* Build a concise human-readable rollup of all phases after a plan run completes.
|
|
717
|
+
* Returns an empty string when there are no phases.
|
|
718
|
+
* The `budgetChars` parameter (default 800) caps total output length — if the
|
|
719
|
+
* files list exceeds budget, it is truncated with an overflow count.
|
|
720
|
+
*/
|
|
721
|
+
export function buildPostRunSummary(phases, budgetChars = 800) {
|
|
722
|
+
if (phases.phases.length === 0)
|
|
723
|
+
return '';
|
|
724
|
+
const statusIndicator = {
|
|
725
|
+
'done': '[x]',
|
|
726
|
+
'failed': '[!]',
|
|
727
|
+
'skipped': '[-]',
|
|
728
|
+
'in-progress': '[~]',
|
|
729
|
+
'pending': '[ ]',
|
|
730
|
+
};
|
|
731
|
+
const lines = [];
|
|
732
|
+
// Per-phase lines
|
|
733
|
+
for (const phase of phases.phases) {
|
|
734
|
+
const indicator = statusIndicator[phase.status] ?? '[ ]';
|
|
735
|
+
const commit = phase.gitCommit ? ` (${phase.gitCommit})` : '';
|
|
736
|
+
const fileCount = phase.modifiedFiles && phase.modifiedFiles.length > 0
|
|
737
|
+
? ` · ${phase.modifiedFiles.length} file${phase.modifiedFiles.length === 1 ? '' : 's'}`
|
|
738
|
+
: '';
|
|
739
|
+
let line = `${indicator} **${phase.id}:** ${phase.title}${commit}${fileCount}`;
|
|
740
|
+
// For audit phases, append a one-line verdict extracted from output
|
|
741
|
+
if (phase.kind === 'audit' && phase.output) {
|
|
742
|
+
const verdictMatch = phase.output.match(/\*\*Verdict:\*\*\s*(.+)/);
|
|
743
|
+
if (verdictMatch) {
|
|
744
|
+
line += ` — ${verdictMatch[1].trim()}`;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
lines.push(line);
|
|
748
|
+
}
|
|
749
|
+
// Collect all unique modified files across phases
|
|
750
|
+
const allFiles = [];
|
|
751
|
+
const seen = new Set();
|
|
752
|
+
for (const phase of phases.phases) {
|
|
753
|
+
for (const f of phase.modifiedFiles ?? []) {
|
|
754
|
+
if (!seen.has(f)) {
|
|
755
|
+
seen.add(f);
|
|
756
|
+
allFiles.push(f);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// Build phase section
|
|
761
|
+
const phaseSection = lines.join('\n');
|
|
762
|
+
if (allFiles.length === 0) {
|
|
763
|
+
return phaseSection;
|
|
764
|
+
}
|
|
765
|
+
// Build files section with budget enforcement
|
|
766
|
+
const headerLine = `\n\n**Files changed (${allFiles.length}):**`;
|
|
767
|
+
const budgetForFiles = Math.max(0, budgetChars - phaseSection.length - headerLine.length - 5); // 5 chars margin
|
|
768
|
+
const fileLines = [];
|
|
769
|
+
let usedChars = 0;
|
|
770
|
+
let overflow = 0;
|
|
771
|
+
for (const f of allFiles) {
|
|
772
|
+
const entry = `\`${f}\``;
|
|
773
|
+
if (usedChars + entry.length + 2 > budgetForFiles) {
|
|
774
|
+
overflow = allFiles.length - fileLines.length;
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
fileLines.push(entry);
|
|
778
|
+
usedChars += entry.length + 2; // +2 for ", " separator
|
|
779
|
+
}
|
|
780
|
+
let filesSection = headerLine + '\n' + fileLines.join(', ');
|
|
781
|
+
if (overflow > 0) {
|
|
782
|
+
filesSection += ` (+${overflow} more)`;
|
|
783
|
+
}
|
|
784
|
+
return phaseSection + filesSection;
|
|
785
|
+
}
|
|
786
|
+
export function buildAuditFixPrompt(planContent, auditOutput, contextFiles, modifiedFilesList, attemptNumber, maxAttempts) {
|
|
787
|
+
const lines = [];
|
|
788
|
+
lines.push('## Objective');
|
|
789
|
+
lines.push('');
|
|
790
|
+
lines.push(extractObjective(planContent));
|
|
791
|
+
lines.push('');
|
|
792
|
+
lines.push('## Task');
|
|
793
|
+
lines.push('');
|
|
794
|
+
if (attemptNumber === maxAttempts) {
|
|
795
|
+
lines.push(`Fix attempt ${attemptNumber} of ${maxAttempts} — this is your last chance. Make minimal, targeted fixes only.`);
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
lines.push(`Fix attempt ${attemptNumber} of ${maxAttempts}.`);
|
|
799
|
+
}
|
|
800
|
+
lines.push('The post-implementation audit found deviations that need fixing.');
|
|
801
|
+
lines.push('');
|
|
802
|
+
lines.push('## Audit Findings');
|
|
803
|
+
lines.push('');
|
|
804
|
+
lines.push(auditOutput);
|
|
805
|
+
lines.push('');
|
|
806
|
+
lines.push('## Context Files');
|
|
807
|
+
lines.push('');
|
|
808
|
+
for (const f of contextFiles) {
|
|
809
|
+
lines.push(`- \`${f}\``);
|
|
810
|
+
}
|
|
811
|
+
lines.push('');
|
|
812
|
+
if (modifiedFilesList.length > 0) {
|
|
813
|
+
lines.push('## Modified Files');
|
|
814
|
+
lines.push('');
|
|
815
|
+
for (const f of modifiedFilesList) {
|
|
816
|
+
lines.push(`- \`${f}\``);
|
|
817
|
+
}
|
|
818
|
+
lines.push('');
|
|
819
|
+
}
|
|
820
|
+
lines.push('## Instructions');
|
|
821
|
+
lines.push('');
|
|
822
|
+
lines.push('Fix only the specific deviations identified in the audit. Do not refactor, reorganize, or modify code that the audit did not flag.');
|
|
823
|
+
lines.push('You have read/write file tools only — you cannot run tests, build commands, or install packages. Focus on code-level fixes.');
|
|
824
|
+
lines.push('After making changes, output a brief summary of what was fixed.');
|
|
825
|
+
return lines.join('\n');
|
|
826
|
+
}
|
|
827
|
+
// ---------------------------------------------------------------------------
|
|
828
|
+
// Project directory resolution
|
|
829
|
+
// ---------------------------------------------------------------------------
|
|
830
|
+
export function resolveProjectCwd(planContent, workspaceCwd, projectDirMap = PROJECT_DIRS) {
|
|
831
|
+
const projectMatch = planContent.match(/^\*\*Project:\*\*\s*(.+)$/m);
|
|
832
|
+
if (!projectMatch) {
|
|
833
|
+
throw new Error('Plan has no **Project:** field. Cannot determine source directory.');
|
|
834
|
+
}
|
|
835
|
+
const projectName = projectMatch[1].trim();
|
|
836
|
+
const projectDir = projectDirMap[projectName];
|
|
837
|
+
if (!projectDir) {
|
|
838
|
+
throw new Error(`Project '${projectName}' not in project directory map. Add it to the map in plan-manager.ts or set the **Project:** field to a known project.`);
|
|
839
|
+
}
|
|
840
|
+
// Validate directory exists
|
|
841
|
+
try {
|
|
842
|
+
const stat = fsSync.statSync(projectDir);
|
|
843
|
+
if (!stat.isDirectory()) {
|
|
844
|
+
throw new Error(`Project directory is not a directory: ${projectDir}`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
catch (err) {
|
|
848
|
+
if (err.code === 'ENOENT') {
|
|
849
|
+
throw new Error(`Project directory does not exist: ${projectDir}`);
|
|
850
|
+
}
|
|
851
|
+
throw err;
|
|
852
|
+
}
|
|
853
|
+
// Note: symlinks to workspaceCwd (e.g. workspace → discoclaw-data/workspace) are
|
|
854
|
+
// allowed here. The real safety gate is resolveContextFilePath, which canonicalizes
|
|
855
|
+
// all paths and checks they resolve under an allowed root (projectCwd or workspaceCwd).
|
|
856
|
+
return projectDir;
|
|
857
|
+
}
|
|
858
|
+
// ---------------------------------------------------------------------------
|
|
859
|
+
// Context file path resolution
|
|
860
|
+
// ---------------------------------------------------------------------------
|
|
861
|
+
export function resolveContextFilePath(filePath, projectCwd, workspaceCwd) {
|
|
862
|
+
let resolved;
|
|
863
|
+
if (filePath.startsWith('workspace/')) {
|
|
864
|
+
// Strip workspace/ prefix and resolve against workspaceCwd
|
|
865
|
+
const stripped = filePath.slice('workspace/'.length);
|
|
866
|
+
resolved = path.resolve(workspaceCwd, stripped);
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
// Resolve against projectCwd
|
|
870
|
+
resolved = path.resolve(projectCwd, filePath);
|
|
871
|
+
}
|
|
872
|
+
// Canonicalize both roots
|
|
873
|
+
const realProjectCwd = safeRealpath(projectCwd);
|
|
874
|
+
const realWorkspaceCwd = safeRealpath(workspaceCwd);
|
|
875
|
+
// Canonicalize the resolved path (handle symlinks, non-existent files)
|
|
876
|
+
const realResolved = safeRealpathWalkUp(resolved);
|
|
877
|
+
// Check if under either root
|
|
878
|
+
if (realResolved === realProjectCwd ||
|
|
879
|
+
realResolved.startsWith(realProjectCwd + path.sep) ||
|
|
880
|
+
realResolved === realWorkspaceCwd ||
|
|
881
|
+
realResolved.startsWith(realWorkspaceCwd + path.sep)) {
|
|
882
|
+
return realResolved;
|
|
883
|
+
}
|
|
884
|
+
throw new Error(`Context file path '${filePath}' resolves to '${realResolved}' which is outside allowed roots ` +
|
|
885
|
+
`(project: ${realProjectCwd}, workspace: ${realWorkspaceCwd})`);
|
|
886
|
+
}
|
|
887
|
+
function safeRealpath(p) {
|
|
888
|
+
try {
|
|
889
|
+
return fsSync.realpathSync(p);
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
return path.resolve(p);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Resolve realpath, walking up to the nearest existing ancestor for non-existent files.
|
|
897
|
+
*/
|
|
898
|
+
function safeRealpathWalkUp(p) {
|
|
899
|
+
try {
|
|
900
|
+
return fsSync.realpathSync(p);
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
// Walk up to find nearest existing ancestor
|
|
904
|
+
let current = p;
|
|
905
|
+
const remaining = [];
|
|
906
|
+
while (current !== path.dirname(current)) {
|
|
907
|
+
try {
|
|
908
|
+
const real = fsSync.realpathSync(current);
|
|
909
|
+
return path.join(real, ...remaining);
|
|
910
|
+
}
|
|
911
|
+
catch {
|
|
912
|
+
remaining.unshift(path.basename(current));
|
|
913
|
+
current = path.dirname(current);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// Fallback: no ancestor exists (shouldn't happen in practice)
|
|
917
|
+
return path.resolve(p);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
// ---------------------------------------------------------------------------
|
|
921
|
+
// I/O functions
|
|
922
|
+
// ---------------------------------------------------------------------------
|
|
923
|
+
export function writePhasesFile(filePath, phases) {
|
|
924
|
+
const jsonPath = phasesJsonPath(filePath);
|
|
925
|
+
const jsonContent = serializePhasesStateJson(phases);
|
|
926
|
+
writeTextAtomically(jsonPath, jsonContent);
|
|
927
|
+
const content = serializePhases(phases);
|
|
928
|
+
writeTextAtomically(filePath, content);
|
|
929
|
+
}
|
|
930
|
+
export function readPhasesFile(filePath, opts) {
|
|
931
|
+
const jsonPath = phasesJsonPath(filePath);
|
|
932
|
+
let jsonErr;
|
|
933
|
+
if (fsSync.existsSync(jsonPath)) {
|
|
934
|
+
try {
|
|
935
|
+
const jsonContent = fsSync.readFileSync(jsonPath, 'utf-8');
|
|
936
|
+
return deserializePhasesStateJson(jsonContent);
|
|
937
|
+
}
|
|
938
|
+
catch (err) {
|
|
939
|
+
jsonErr = err;
|
|
940
|
+
opts?.log?.warn({ err, jsonPath }, 'plan-manager: phases json invalid, falling back to markdown');
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
try {
|
|
944
|
+
const content = fsSync.readFileSync(filePath, 'utf-8');
|
|
945
|
+
const phases = deserializePhases(content);
|
|
946
|
+
if (opts?.backfillJson ?? true) {
|
|
947
|
+
try {
|
|
948
|
+
const jsonContent = serializePhasesStateJson(phases);
|
|
949
|
+
writeTextAtomically(jsonPath, jsonContent);
|
|
950
|
+
}
|
|
951
|
+
catch (backfillErr) {
|
|
952
|
+
opts?.log?.warn({ err: backfillErr, jsonPath }, 'plan-manager: failed to backfill phases json from markdown');
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return phases;
|
|
956
|
+
}
|
|
957
|
+
catch (mdErr) {
|
|
958
|
+
if (!jsonErr)
|
|
959
|
+
throw mdErr;
|
|
960
|
+
throw new Error(`Failed to read phases state. JSON error: ${String(jsonErr)}. Markdown error: ${String(mdErr)}`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
function phasesJsonPath(markdownPath) {
|
|
964
|
+
return markdownPath.endsWith('.md')
|
|
965
|
+
? markdownPath.slice(0, -'.md'.length) + '.json'
|
|
966
|
+
: `${markdownPath}.json`;
|
|
967
|
+
}
|
|
968
|
+
function writeTextAtomically(filePath, content) {
|
|
969
|
+
const tmpPath = filePath + '.tmp';
|
|
970
|
+
fsSync.writeFileSync(tmpPath, content, 'utf-8');
|
|
971
|
+
try {
|
|
972
|
+
fsSync.renameSync(tmpPath, filePath);
|
|
973
|
+
}
|
|
974
|
+
catch (err) {
|
|
975
|
+
try {
|
|
976
|
+
fsSync.unlinkSync(tmpPath);
|
|
977
|
+
}
|
|
978
|
+
catch { /* best-effort cleanup */ }
|
|
979
|
+
throw err;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
function serializePhasesStateJson(phases) {
|
|
983
|
+
const state = {
|
|
984
|
+
version: PHASES_STATE_VERSION,
|
|
985
|
+
...phases,
|
|
986
|
+
};
|
|
987
|
+
return JSON.stringify(state, null, 2) + '\n';
|
|
988
|
+
}
|
|
989
|
+
function asString(value, field) {
|
|
990
|
+
if (typeof value !== 'string') {
|
|
991
|
+
throw new Error(`Malformed phases json: ${field} must be a string`);
|
|
992
|
+
}
|
|
993
|
+
return value;
|
|
994
|
+
}
|
|
995
|
+
function asStringArray(value, field) {
|
|
996
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
997
|
+
throw new Error(`Malformed phases json: ${field} must be string[]`);
|
|
998
|
+
}
|
|
999
|
+
return value;
|
|
1000
|
+
}
|
|
1001
|
+
function asFailureHashes(value, field) {
|
|
1002
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
1003
|
+
throw new Error(`Malformed phases json: ${field} must be Record<string,string>`);
|
|
1004
|
+
}
|
|
1005
|
+
const out = {};
|
|
1006
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1007
|
+
if (typeof v !== 'string') {
|
|
1008
|
+
throw new Error(`Malformed phases json: ${field}.${k} must be a string`);
|
|
1009
|
+
}
|
|
1010
|
+
out[k] = v;
|
|
1011
|
+
}
|
|
1012
|
+
return out;
|
|
1013
|
+
}
|
|
1014
|
+
function deserializePhasesStateJson(raw) {
|
|
1015
|
+
let parsed;
|
|
1016
|
+
try {
|
|
1017
|
+
parsed = JSON.parse(raw);
|
|
1018
|
+
}
|
|
1019
|
+
catch {
|
|
1020
|
+
throw new Error('Malformed phases json: invalid JSON');
|
|
1021
|
+
}
|
|
1022
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1023
|
+
throw new Error('Malformed phases json: expected object');
|
|
1024
|
+
}
|
|
1025
|
+
const obj = parsed;
|
|
1026
|
+
if (obj.version !== PHASES_STATE_VERSION) {
|
|
1027
|
+
throw new Error(`Malformed phases json: unsupported version '${String(obj.version)}'`);
|
|
1028
|
+
}
|
|
1029
|
+
const phasesRaw = obj.phases;
|
|
1030
|
+
if (!Array.isArray(phasesRaw)) {
|
|
1031
|
+
throw new Error('Malformed phases json: phases must be an array');
|
|
1032
|
+
}
|
|
1033
|
+
const phases = phasesRaw.map((phaseRaw, idx) => {
|
|
1034
|
+
if (!phaseRaw || typeof phaseRaw !== 'object' || Array.isArray(phaseRaw)) {
|
|
1035
|
+
throw new Error(`Malformed phases json: phases[${idx}] must be an object`);
|
|
1036
|
+
}
|
|
1037
|
+
const p = phaseRaw;
|
|
1038
|
+
const kind = asString(p.kind, `phases[${idx}].kind`);
|
|
1039
|
+
const status = asString(p.status, `phases[${idx}].status`);
|
|
1040
|
+
if (!VALID_KINDS.has(kind))
|
|
1041
|
+
throw new Error(`Unknown phase kind: '${kind}' in phases[${idx}]`);
|
|
1042
|
+
if (!VALID_STATUSES.has(status))
|
|
1043
|
+
throw new Error(`Unknown phase status: '${status}' in phases[${idx}]`);
|
|
1044
|
+
const phase = {
|
|
1045
|
+
id: asString(p.id, `phases[${idx}].id`),
|
|
1046
|
+
title: asString(p.title, `phases[${idx}].title`),
|
|
1047
|
+
kind: kind,
|
|
1048
|
+
description: asString(p.description, `phases[${idx}].description`),
|
|
1049
|
+
status: status,
|
|
1050
|
+
dependsOn: asStringArray(p.dependsOn, `phases[${idx}].dependsOn`),
|
|
1051
|
+
contextFiles: asStringArray(p.contextFiles, `phases[${idx}].contextFiles`),
|
|
1052
|
+
};
|
|
1053
|
+
if (typeof p.changeSpec === 'string')
|
|
1054
|
+
phase.changeSpec = p.changeSpec;
|
|
1055
|
+
if (typeof p.output === 'string')
|
|
1056
|
+
phase.output = p.output;
|
|
1057
|
+
if (typeof p.error === 'string')
|
|
1058
|
+
phase.error = p.error;
|
|
1059
|
+
if (typeof p.gitCommit === 'string')
|
|
1060
|
+
phase.gitCommit = p.gitCommit;
|
|
1061
|
+
if (p.modifiedFiles !== undefined) {
|
|
1062
|
+
phase.modifiedFiles = asStringArray(p.modifiedFiles, `phases[${idx}].modifiedFiles`);
|
|
1063
|
+
}
|
|
1064
|
+
if (p.failureHashes !== undefined) {
|
|
1065
|
+
phase.failureHashes = asFailureHashes(p.failureHashes, `phases[${idx}].failureHashes`);
|
|
1066
|
+
}
|
|
1067
|
+
return phase;
|
|
1068
|
+
});
|
|
1069
|
+
return {
|
|
1070
|
+
planId: asString(obj.planId, 'planId'),
|
|
1071
|
+
planFile: asString(obj.planFile, 'planFile'),
|
|
1072
|
+
planContentHash: asString(obj.planContentHash, 'planContentHash'),
|
|
1073
|
+
phases,
|
|
1074
|
+
createdAt: asString(obj.createdAt, 'createdAt'),
|
|
1075
|
+
updatedAt: asString(obj.updatedAt, 'updatedAt'),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
export async function executePhase(phase, planContent, phases, opts, injectedContext) {
|
|
1079
|
+
// Derive tools from phase kind
|
|
1080
|
+
const tools = phase.kind === 'implement'
|
|
1081
|
+
? ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash']
|
|
1082
|
+
: ['Read', 'Glob', 'Grep'];
|
|
1083
|
+
// Derive addDirs based on phase kind
|
|
1084
|
+
let addDirs;
|
|
1085
|
+
if (phase.kind === 'implement') {
|
|
1086
|
+
// Filter out workspace paths for implement phases
|
|
1087
|
+
const realWorkspace = safeRealpath(opts.workspaceCwd);
|
|
1088
|
+
addDirs = opts.addDirs.filter((d) => {
|
|
1089
|
+
const realD = safeRealpath(d);
|
|
1090
|
+
if (realD === realWorkspace || realD.startsWith(realWorkspace + path.sep)) {
|
|
1091
|
+
opts.log?.warn({ path: d }, 'Filtered workspace path from implement phase addDirs');
|
|
1092
|
+
return false;
|
|
1093
|
+
}
|
|
1094
|
+
return true;
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
// read/audit get workspace access
|
|
1099
|
+
addDirs = [opts.workspaceCwd, ...opts.addDirs];
|
|
1100
|
+
}
|
|
1101
|
+
const prompt = buildPhasePrompt(phase, planContent, injectedContext);
|
|
1102
|
+
try {
|
|
1103
|
+
const output = await collectRuntimeText(opts.runtime, prompt, opts.model, opts.projectCwd, tools, addDirs, opts.timeoutMs, { requireFinalEvent: true, onEvent: opts.onEvent, signal: opts.signal });
|
|
1104
|
+
if (phase.kind === 'audit') {
|
|
1105
|
+
const verdict = parseAuditVerdict(output);
|
|
1106
|
+
if (verdict.shouldLoop) {
|
|
1107
|
+
return { status: 'audit_failed', output, error: `Audit found ${verdict.maxSeverity} severity deviations`, verdict };
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return { status: 'done', output };
|
|
1111
|
+
}
|
|
1112
|
+
catch (err) {
|
|
1113
|
+
const errorMsg = String(err instanceof Error ? err.message : err);
|
|
1114
|
+
return { status: 'failed', output: '', error: errorMsg };
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
// ---------------------------------------------------------------------------
|
|
1118
|
+
// Git helpers
|
|
1119
|
+
// ---------------------------------------------------------------------------
|
|
1120
|
+
function gitAvailable(cwd) {
|
|
1121
|
+
try {
|
|
1122
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, encoding: 'utf-8', stdio: 'pipe' });
|
|
1123
|
+
return true;
|
|
1124
|
+
}
|
|
1125
|
+
catch {
|
|
1126
|
+
return false;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
function gitDiffNames(cwd) {
|
|
1130
|
+
try {
|
|
1131
|
+
const result = new Set();
|
|
1132
|
+
const unstaged = execFileSync('git', ['diff', '--name-only'], { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
1133
|
+
const staged = execFileSync('git', ['diff', '--staged', '--name-only'], { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
1134
|
+
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
1135
|
+
for (const line of [...unstaged.split('\n'), ...staged.split('\n'), ...untracked.split('\n')]) {
|
|
1136
|
+
if (line.trim())
|
|
1137
|
+
result.add(line.trim());
|
|
1138
|
+
}
|
|
1139
|
+
return result;
|
|
1140
|
+
}
|
|
1141
|
+
catch {
|
|
1142
|
+
return null;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
function hashFileContent(filePath) {
|
|
1146
|
+
try {
|
|
1147
|
+
const content = fsSync.readFileSync(filePath, 'utf-8');
|
|
1148
|
+
return createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
1149
|
+
}
|
|
1150
|
+
catch {
|
|
1151
|
+
return '';
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// ---------------------------------------------------------------------------
|
|
1155
|
+
// High-level runner
|
|
1156
|
+
// ---------------------------------------------------------------------------
|
|
1157
|
+
export async function runNextPhase(phasesFilePath, planFilePath, opts, onProgress) {
|
|
1158
|
+
// 1. Read and deserialize phases file
|
|
1159
|
+
let allPhases;
|
|
1160
|
+
try {
|
|
1161
|
+
allPhases = readPhasesFile(phasesFilePath, { backfillJson: true, log: opts.log });
|
|
1162
|
+
}
|
|
1163
|
+
catch (err) {
|
|
1164
|
+
return { result: 'corrupt', message: `Failed to read phases file: ${String(err)}` };
|
|
1165
|
+
}
|
|
1166
|
+
// 2. Read plan and check staleness
|
|
1167
|
+
let planContent;
|
|
1168
|
+
try {
|
|
1169
|
+
planContent = fsSync.readFileSync(planFilePath, 'utf-8');
|
|
1170
|
+
}
|
|
1171
|
+
catch (err) {
|
|
1172
|
+
return { result: 'corrupt', message: `Failed to read plan file: ${String(err)}` };
|
|
1173
|
+
}
|
|
1174
|
+
const staleness = checkStaleness(allPhases, planContent);
|
|
1175
|
+
if (staleness.stale) {
|
|
1176
|
+
return { result: 'stale', message: staleness.message };
|
|
1177
|
+
}
|
|
1178
|
+
// 3. Get next phase
|
|
1179
|
+
const phase = getNextPhase(allPhases);
|
|
1180
|
+
if (!phase) {
|
|
1181
|
+
return { result: 'nothing_to_run' };
|
|
1182
|
+
}
|
|
1183
|
+
// 4. Retry safety check
|
|
1184
|
+
const isGitAvailable = gitAvailable(opts.projectCwd);
|
|
1185
|
+
const allowRetryDespiteFailure = isRolloutPathMissingError(phase.error);
|
|
1186
|
+
if (phase.status === 'failed' && phase.kind !== 'audit' && !allowRetryDespiteFailure) {
|
|
1187
|
+
if (isGitAvailable) {
|
|
1188
|
+
if (!phase.modifiedFiles || phase.modifiedFiles.length === 0) {
|
|
1189
|
+
return {
|
|
1190
|
+
result: 'retry_blocked',
|
|
1191
|
+
phase,
|
|
1192
|
+
message: 'Phase failed but has no modifiedFiles — cannot safely determine what to revert. Use `!plan skip` or `!plan phases --regenerate`.',
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
if (!phase.failureHashes) {
|
|
1196
|
+
return {
|
|
1197
|
+
result: 'retry_blocked',
|
|
1198
|
+
phase,
|
|
1199
|
+
message: 'Phase has modifiedFiles but no failureHashes — cannot safely determine which files to revert. Use `!plan skip` or `!plan phases --regenerate`.',
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
// Non-git: proceed unconditionally
|
|
1204
|
+
}
|
|
1205
|
+
if (opts.onPlanEvent) {
|
|
1206
|
+
try {
|
|
1207
|
+
await opts.onPlanEvent({
|
|
1208
|
+
type: 'phase_start',
|
|
1209
|
+
planId: allPhases.planId,
|
|
1210
|
+
phase: {
|
|
1211
|
+
id: phase.id,
|
|
1212
|
+
title: phase.title,
|
|
1213
|
+
kind: phase.kind,
|
|
1214
|
+
},
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
catch (err) {
|
|
1218
|
+
opts.log?.warn({ err, planId: allPhases.planId, phaseId: phase.id }, 'plan-manager: onPlanEvent callback failed');
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
// 5. Write in-progress status to disk
|
|
1222
|
+
await onProgress(`**${phase.id}**: Running ${phase.title}...`);
|
|
1223
|
+
allPhases = updatePhaseStatus(allPhases, phase.id, 'in-progress');
|
|
1224
|
+
writePhasesFile(phasesFilePath, allPhases);
|
|
1225
|
+
// 6. Git snapshot (null = git command failed, skip modified-files tracking)
|
|
1226
|
+
const preSnapshot = isGitAvailable ? gitDiffNames(opts.projectCwd) : null;
|
|
1227
|
+
// 7. Auto-revert on retry
|
|
1228
|
+
if (phase.status === 'failed' && phase.modifiedFiles && phase.failureHashes && isGitAvailable && preSnapshot) {
|
|
1229
|
+
// Note: we are re-reading phase from the old allPhases data (before status update).
|
|
1230
|
+
// The status was 'failed' when getNextPhase returned it, and we updated to 'in-progress' in step 5.
|
|
1231
|
+
// The modifiedFiles/failureHashes are from the old data.
|
|
1232
|
+
const origPhase = allPhases.phases.find((p) => p.id === phase.id);
|
|
1233
|
+
const modFiles = origPhase?.modifiedFiles ?? phase.modifiedFiles;
|
|
1234
|
+
const failHashes = origPhase?.failureHashes ?? phase.failureHashes;
|
|
1235
|
+
const trackedToRevert = [];
|
|
1236
|
+
const untrackedToClean = [];
|
|
1237
|
+
for (const file of modFiles) {
|
|
1238
|
+
const currentHash = hashFileContent(path.join(opts.projectCwd, file));
|
|
1239
|
+
const failHash = failHashes[file];
|
|
1240
|
+
if (!failHash || currentHash !== failHash) {
|
|
1241
|
+
await onProgress(`Skipping revert of \`${file}\` — modified since last attempt. Retry will proceed with current state.`);
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
// Hash matches — safe to revert
|
|
1245
|
+
if (preSnapshot.has(file)) {
|
|
1246
|
+
// File was in pre-snapshot, so it's tracked + dirty or staged
|
|
1247
|
+
trackedToRevert.push(file);
|
|
1248
|
+
}
|
|
1249
|
+
else {
|
|
1250
|
+
// File was not in pre-execution snapshot = created by failed attempt
|
|
1251
|
+
untrackedToClean.push(file);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
if (trackedToRevert.length > 0) {
|
|
1255
|
+
try {
|
|
1256
|
+
execFileSync('git', ['checkout', '--', ...trackedToRevert], { cwd: opts.projectCwd, stdio: 'pipe' });
|
|
1257
|
+
}
|
|
1258
|
+
catch (err) {
|
|
1259
|
+
opts.log?.warn({ err, files: trackedToRevert }, 'plan-manager: revert tracked files failed');
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
if (untrackedToClean.length > 0) {
|
|
1263
|
+
try {
|
|
1264
|
+
execFileSync('git', ['clean', '-f', '--', ...untrackedToClean], { cwd: opts.projectCwd, stdio: 'pipe' });
|
|
1265
|
+
}
|
|
1266
|
+
catch (err) {
|
|
1267
|
+
opts.log?.warn({ err, files: untrackedToClean }, 'plan-manager: clean untracked files failed');
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
// 8. Context injection for implement phases
|
|
1272
|
+
const MAX_INJECTED_CONTEXT_BYTES = 100 * 1024; // 100 KB budget
|
|
1273
|
+
let injectedContext;
|
|
1274
|
+
if (phase.kind === 'implement') {
|
|
1275
|
+
const hasWorkspaceFiles = phase.contextFiles.some((cf) => cf.startsWith('workspace/'));
|
|
1276
|
+
if (hasWorkspaceFiles) {
|
|
1277
|
+
await onProgress(`**${phase.id}**: Reading context files...`);
|
|
1278
|
+
}
|
|
1279
|
+
const blocks = [];
|
|
1280
|
+
let totalBytes = 0;
|
|
1281
|
+
for (const cf of phase.contextFiles) {
|
|
1282
|
+
if (!cf.startsWith('workspace/'))
|
|
1283
|
+
continue;
|
|
1284
|
+
const stripped = cf.slice('workspace/'.length);
|
|
1285
|
+
const absPath = path.resolve(opts.workspaceCwd, stripped);
|
|
1286
|
+
try {
|
|
1287
|
+
const content = fsSync.readFileSync(absPath, 'utf-8');
|
|
1288
|
+
const block = `### File: ${cf}\n\`\`\`\n${content}\n\`\`\``;
|
|
1289
|
+
if (totalBytes + block.length > MAX_INJECTED_CONTEXT_BYTES) {
|
|
1290
|
+
opts.log?.warn({ file: cf, size: block.length, budget: MAX_INJECTED_CONTEXT_BYTES }, 'plan-manager: context file exceeds injection budget, skipping');
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
totalBytes += block.length;
|
|
1294
|
+
blocks.push(block);
|
|
1295
|
+
}
|
|
1296
|
+
catch {
|
|
1297
|
+
opts.log?.warn({ file: cf }, 'plan-manager: context file not found');
|
|
1298
|
+
blocks.push(`### File: ${cf}\n(File not found)`);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
if (blocks.length > 0) {
|
|
1302
|
+
injectedContext = blocks.join('\n\n');
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
// 9. Execute the phase
|
|
1306
|
+
// Reload the phase from allPhases to get the updated status
|
|
1307
|
+
const currentPhase = allPhases.phases.find((p) => p.id === phase.id);
|
|
1308
|
+
await onProgress(`**${phase.id}**: Executing ${phase.kind} phase...`);
|
|
1309
|
+
let result = await executePhase(currentPhase, planContent, allPhases, opts, injectedContext);
|
|
1310
|
+
// 9a. Audit fix loop: if audit failed and git is available, attempt fix→re-audit cycles
|
|
1311
|
+
const maxFixAttempts = opts.maxAuditFixAttempts ?? 2;
|
|
1312
|
+
let fixAttemptsUsed;
|
|
1313
|
+
if (result.status === 'audit_failed' && maxFixAttempts > 0) {
|
|
1314
|
+
if (!isGitAvailable) {
|
|
1315
|
+
await onProgress('Automatic fix loop skipped \u2014 git not available.');
|
|
1316
|
+
}
|
|
1317
|
+
else {
|
|
1318
|
+
let lastAuditOutput = result.output;
|
|
1319
|
+
let lastSeverity = result.verdict.maxSeverity;
|
|
1320
|
+
const realWorkspace = safeRealpath(opts.workspaceCwd);
|
|
1321
|
+
const fixAddDirs = opts.addDirs.filter((d) => {
|
|
1322
|
+
const realD = safeRealpath(d);
|
|
1323
|
+
return !(realD === realWorkspace || realD.startsWith(realWorkspace + path.sep));
|
|
1324
|
+
});
|
|
1325
|
+
for (let attempt = 1; attempt <= maxFixAttempts; attempt++) {
|
|
1326
|
+
// Progress message — different wording for first vs subsequent
|
|
1327
|
+
if (attempt === 1) {
|
|
1328
|
+
await onProgress(`**${phase.id}**: Audit found **${lastSeverity}** deviations \u2014 attempting fix (${attempt}/${maxFixAttempts})...`);
|
|
1329
|
+
}
|
|
1330
|
+
else {
|
|
1331
|
+
await onProgress(`**${phase.id}**: Audit still found deviations \u2014 attempting fix (${attempt}/${maxFixAttempts})...`);
|
|
1332
|
+
}
|
|
1333
|
+
// Compute modified files list (fresh each iteration)
|
|
1334
|
+
let modifiedFilesList = [];
|
|
1335
|
+
try {
|
|
1336
|
+
const tracked = execFileSync('git', ['diff', '--name-only', 'HEAD'], { cwd: opts.projectCwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
1337
|
+
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: opts.projectCwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
1338
|
+
const combined = [...(tracked ? tracked.split('\n') : []), ...(untracked ? untracked.split('\n') : [])];
|
|
1339
|
+
modifiedFilesList = [...new Set(combined)];
|
|
1340
|
+
}
|
|
1341
|
+
catch {
|
|
1342
|
+
// git error (no commits, corrupt index) — use empty list and continue
|
|
1343
|
+
}
|
|
1344
|
+
// Build fix prompt with full spec
|
|
1345
|
+
const fixPrompt = buildAuditFixPrompt(planContent, lastAuditOutput, currentPhase.contextFiles, modifiedFilesList, attempt, maxFixAttempts);
|
|
1346
|
+
// Run fix agent — NO Bash tool (safety boundary for automated loop)
|
|
1347
|
+
try {
|
|
1348
|
+
await collectRuntimeText(opts.runtime, fixPrompt, opts.model, opts.projectCwd, ['Read', 'Write', 'Edit', 'Glob', 'Grep'], fixAddDirs, opts.timeoutMs, { requireFinalEvent: true, onEvent: opts.onEvent, signal: opts.signal });
|
|
1349
|
+
}
|
|
1350
|
+
catch (err) {
|
|
1351
|
+
opts.log?.warn({ err, phase: phase.id, attempt }, 'plan-manager: audit fix agent failed');
|
|
1352
|
+
continue; // Consumed attempt — try again or exit loop
|
|
1353
|
+
}
|
|
1354
|
+
// Re-audit
|
|
1355
|
+
await onProgress(`**${phase.id}**: Fix attempt ${attempt} complete. Re-auditing...`);
|
|
1356
|
+
result = await executePhase(currentPhase, planContent, allPhases, opts);
|
|
1357
|
+
if (result.status === 'done') {
|
|
1358
|
+
fixAttemptsUsed = attempt;
|
|
1359
|
+
break;
|
|
1360
|
+
}
|
|
1361
|
+
else if (result.status === 'audit_failed') {
|
|
1362
|
+
lastAuditOutput = result.output;
|
|
1363
|
+
lastSeverity = result.verdict.maxSeverity;
|
|
1364
|
+
}
|
|
1365
|
+
// result.status === 'failed' (runtime error on re-audit) — consumed attempt, continue
|
|
1366
|
+
}
|
|
1367
|
+
// Exhausted fix attempts — rollback all uncommitted changes
|
|
1368
|
+
if (result.status === 'audit_failed' || result.status === 'failed') {
|
|
1369
|
+
fixAttemptsUsed = fixAttemptsUsed ?? maxFixAttempts;
|
|
1370
|
+
try {
|
|
1371
|
+
execFileSync('git', ['checkout', '.'], { cwd: opts.projectCwd, stdio: 'pipe' });
|
|
1372
|
+
execFileSync('git', ['clean', '-fd'], { cwd: opts.projectCwd, stdio: 'pipe' });
|
|
1373
|
+
await onProgress('Fix attempts exhausted \u2014 rolled back fix-agent changes.');
|
|
1374
|
+
}
|
|
1375
|
+
catch (rollbackErr) {
|
|
1376
|
+
await onProgress(`Fix attempts exhausted \u2014 rollback failed: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}. Working tree may contain partial fix-agent changes.`);
|
|
1377
|
+
}
|
|
1378
|
+
// Normalize: fix loop exhaustion always returns audit_failed, even if
|
|
1379
|
+
// the last iteration was a runtime error ('failed') rather than an audit failure.
|
|
1380
|
+
if (result.status === 'failed') {
|
|
1381
|
+
result = {
|
|
1382
|
+
status: 'audit_failed',
|
|
1383
|
+
output: lastAuditOutput,
|
|
1384
|
+
error: 'Fix loop exhausted after runtime error on re-audit',
|
|
1385
|
+
verdict: { maxSeverity: lastSeverity, shouldLoop: true },
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
// 10. Capture modified files (skip if either snapshot is unavailable)
|
|
1392
|
+
const postSnapshot = preSnapshot ? gitDiffNames(opts.projectCwd) : null;
|
|
1393
|
+
const modifiedFiles = [];
|
|
1394
|
+
if (preSnapshot && postSnapshot) {
|
|
1395
|
+
for (const file of postSnapshot) {
|
|
1396
|
+
if (!preSnapshot.has(file)) {
|
|
1397
|
+
modifiedFiles.push(file);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
// Compute failure hashes if failed
|
|
1402
|
+
let failureHashes;
|
|
1403
|
+
if (result.status === 'failed' && modifiedFiles.length > 0) {
|
|
1404
|
+
failureHashes = {};
|
|
1405
|
+
for (const file of modifiedFiles) {
|
|
1406
|
+
const hash = hashFileContent(path.join(opts.projectCwd, file));
|
|
1407
|
+
if (hash)
|
|
1408
|
+
failureHashes[file] = hash;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
// 11. Write done/failed status to disk
|
|
1412
|
+
const diskStatus = result.status === 'audit_failed' ? 'failed' : result.status;
|
|
1413
|
+
const diskError = result.status === 'done' ? undefined : result.error;
|
|
1414
|
+
allPhases = updatePhaseStatus(allPhases, phase.id, diskStatus, result.output, diskError);
|
|
1415
|
+
// Attach modifiedFiles and failureHashes to the phase
|
|
1416
|
+
allPhases = {
|
|
1417
|
+
...allPhases,
|
|
1418
|
+
phases: allPhases.phases.map((p) => {
|
|
1419
|
+
if (p.id !== phase.id)
|
|
1420
|
+
return p;
|
|
1421
|
+
return {
|
|
1422
|
+
...p,
|
|
1423
|
+
modifiedFiles: modifiedFiles.length > 0 ? modifiedFiles : undefined,
|
|
1424
|
+
failureHashes,
|
|
1425
|
+
};
|
|
1426
|
+
}),
|
|
1427
|
+
};
|
|
1428
|
+
writePhasesFile(phasesFilePath, allPhases);
|
|
1429
|
+
// 12. Git commit on success
|
|
1430
|
+
if (result.status === 'done' && isGitAvailable && modifiedFiles.length > 0) {
|
|
1431
|
+
try {
|
|
1432
|
+
execFileSync('git', ['add', ...modifiedFiles], { cwd: opts.projectCwd, stdio: 'pipe' });
|
|
1433
|
+
const commitMsg = `${allPhases.planId} ${phase.id}: ${phase.title}`;
|
|
1434
|
+
execFileSync('git', ['commit', '-m', commitMsg], { cwd: opts.projectCwd, stdio: 'pipe' });
|
|
1435
|
+
// Capture commit hash
|
|
1436
|
+
const commitHash = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: opts.projectCwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
1437
|
+
// Update phase with git commit hash
|
|
1438
|
+
allPhases = {
|
|
1439
|
+
...allPhases,
|
|
1440
|
+
phases: allPhases.phases.map((p) => {
|
|
1441
|
+
if (p.id !== phase.id)
|
|
1442
|
+
return p;
|
|
1443
|
+
return { ...p, gitCommit: commitHash };
|
|
1444
|
+
}),
|
|
1445
|
+
};
|
|
1446
|
+
writePhasesFile(phasesFilePath, allPhases);
|
|
1447
|
+
}
|
|
1448
|
+
catch (err) {
|
|
1449
|
+
// Unstage files so the next retry doesn't see stale staged state
|
|
1450
|
+
try {
|
|
1451
|
+
execFileSync('git', ['reset'], { cwd: opts.projectCwd, stdio: 'pipe' });
|
|
1452
|
+
}
|
|
1453
|
+
catch { /* best-effort */ }
|
|
1454
|
+
opts.log?.warn({ err, phase: phase.id }, 'plan-manager: git commit failed');
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
else if (result.status === 'done' && isGitAvailable && modifiedFiles.length === 0) {
|
|
1458
|
+
opts.log?.warn({ phase: phase.id }, 'plan-manager: phase completed but no files were modified');
|
|
1459
|
+
}
|
|
1460
|
+
const updatedPhase = allPhases.phases.find((p) => p.id === phase.id);
|
|
1461
|
+
// Emit phase_complete event
|
|
1462
|
+
if (opts.onPlanEvent) {
|
|
1463
|
+
const completeStatus = result.status === 'done' ? 'done' : 'failed';
|
|
1464
|
+
try {
|
|
1465
|
+
await opts.onPlanEvent({
|
|
1466
|
+
type: 'phase_complete',
|
|
1467
|
+
planId: allPhases.planId,
|
|
1468
|
+
phase: {
|
|
1469
|
+
id: updatedPhase.id,
|
|
1470
|
+
title: updatedPhase.title,
|
|
1471
|
+
kind: updatedPhase.kind,
|
|
1472
|
+
},
|
|
1473
|
+
status: completeStatus,
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
catch (err) {
|
|
1477
|
+
opts.log?.warn({ err, planId: allPhases.planId, phaseId: phase.id }, 'plan-manager: onPlanEvent phase_complete callback failed');
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (result.status === 'done') {
|
|
1481
|
+
const upcoming = getNextPhase(allPhases);
|
|
1482
|
+
const nextPhase = upcoming ? { id: upcoming.id, title: upcoming.title } : undefined;
|
|
1483
|
+
return { result: 'done', phase: updatedPhase, output: result.output, nextPhase };
|
|
1484
|
+
}
|
|
1485
|
+
else if (result.status === 'audit_failed') {
|
|
1486
|
+
return { result: 'audit_failed', phase: updatedPhase, output: result.output, verdict: result.verdict, fixAttemptsUsed };
|
|
1487
|
+
}
|
|
1488
|
+
else {
|
|
1489
|
+
return { result: 'failed', phase: updatedPhase, output: result.output, error: result.error ?? 'Unknown error' };
|
|
1490
|
+
}
|
|
1491
|
+
}
|