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,191 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { findPlanFile, parsePlanFileHeader, } from './plan-commands.js';
|
|
4
|
+
import { appendAuditRound, buildAuditorPrompt } from './forge-commands.js';
|
|
5
|
+
import { parseAuditVerdict } from './forge-audit-verdict.js';
|
|
6
|
+
import { collectRuntimeText } from './runtime-utils.js';
|
|
7
|
+
import { getSection, parsePlan } from './plan-parser.js';
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Structural audit checks (fast pre-flight gate)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const REQUIRED_SECTIONS = ['Objective', 'Scope', 'Changes', 'Risks', 'Testing'];
|
|
12
|
+
export function auditPlanStructure(content) {
|
|
13
|
+
const concerns = [];
|
|
14
|
+
const parsed = parsePlan(content);
|
|
15
|
+
// Check for required sections
|
|
16
|
+
for (const section of REQUIRED_SECTIONS) {
|
|
17
|
+
const hasSection = parsed.sections.has(section);
|
|
18
|
+
if (!hasSection) {
|
|
19
|
+
concerns.push({
|
|
20
|
+
title: `Missing section: ${section}`,
|
|
21
|
+
description: `The plan is missing the required "## ${section}" section.`,
|
|
22
|
+
severity: 'high',
|
|
23
|
+
});
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const body = getSection(parsed, section).trim();
|
|
27
|
+
// Check if the section has meaningful content (not just placeholder text)
|
|
28
|
+
if (!body || /^_.*_$/.test(body) || body.startsWith('(') || body.length < 10) {
|
|
29
|
+
concerns.push({
|
|
30
|
+
title: `Empty or placeholder: ${section}`,
|
|
31
|
+
description: `The "${section}" section appears to contain only placeholder text.`,
|
|
32
|
+
severity: 'medium',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Check for a Changes section with file paths
|
|
37
|
+
const changesBody = getSection(parsed, 'Changes').trim();
|
|
38
|
+
if (changesBody) {
|
|
39
|
+
const hasFilePaths = /`[^`]+\.[a-z]+`/.test(changesBody);
|
|
40
|
+
if (changesBody.length > 10 && !hasFilePaths) {
|
|
41
|
+
concerns.push({
|
|
42
|
+
title: 'Changes section lacks file paths',
|
|
43
|
+
description: 'The Changes section does not reference specific file paths. Plans should list concrete file-by-file changes.',
|
|
44
|
+
severity: 'medium',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Check plan status
|
|
49
|
+
const header = parsePlanFileHeader(content);
|
|
50
|
+
if (header && header.status === 'CLOSED') {
|
|
51
|
+
concerns.push({
|
|
52
|
+
title: 'Plan is closed',
|
|
53
|
+
description: 'This plan has been closed. Auditing a closed plan is unusual.',
|
|
54
|
+
severity: 'low',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return concerns;
|
|
58
|
+
}
|
|
59
|
+
export function deriveVerdict(concerns) {
|
|
60
|
+
const hasHigh = concerns.some((c) => c.severity === 'high');
|
|
61
|
+
const hasMedium = concerns.some((c) => c.severity === 'medium');
|
|
62
|
+
if (hasHigh)
|
|
63
|
+
return { maxSeverity: 'blocking', shouldLoop: true };
|
|
64
|
+
if (hasMedium)
|
|
65
|
+
return { maxSeverity: 'medium', shouldLoop: true };
|
|
66
|
+
if (concerns.length > 0)
|
|
67
|
+
return { maxSeverity: 'minor', shouldLoop: false };
|
|
68
|
+
return { maxSeverity: 'none', shouldLoop: false };
|
|
69
|
+
}
|
|
70
|
+
function formatStructuralNotes(concerns) {
|
|
71
|
+
if (concerns.length === 0)
|
|
72
|
+
return '';
|
|
73
|
+
const lines = ['## Structural Pre-flight', ''];
|
|
74
|
+
for (let i = 0; i < concerns.length; i++) {
|
|
75
|
+
const c = concerns[i];
|
|
76
|
+
lines.push(`**Concern ${i + 1}: ${c.title}**`);
|
|
77
|
+
lines.push(c.description);
|
|
78
|
+
lines.push(`**Severity: ${c.severity}**`);
|
|
79
|
+
lines.push('');
|
|
80
|
+
}
|
|
81
|
+
return lines.join('\n');
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Max review number extraction (avoids duplicate round numbers)
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
export function maxReviewNumber(content) {
|
|
87
|
+
const matches = content.matchAll(/### Review (\d+)/g);
|
|
88
|
+
let max = 0;
|
|
89
|
+
for (const m of matches) {
|
|
90
|
+
const n = parseInt(m[1], 10);
|
|
91
|
+
if (n > max)
|
|
92
|
+
max = n;
|
|
93
|
+
}
|
|
94
|
+
return max;
|
|
95
|
+
}
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Project context loader (inlined from ForgeOrchestrator pattern)
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
async function loadProjectContext(workspaceCwd) {
|
|
100
|
+
try {
|
|
101
|
+
const content = await fs.readFile(path.join(workspaceCwd, '.context', 'project.md'), 'utf-8');
|
|
102
|
+
return content.trim() || undefined;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Exported handler
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
/**
|
|
112
|
+
* Run a standalone audit against an existing plan: structural pre-flight
|
|
113
|
+
* followed by an AI-powered deep review. Appends a new review entry to
|
|
114
|
+
* the plan's Audit Log section.
|
|
115
|
+
*
|
|
116
|
+
* The writer lock is only held during the final write phase (not during
|
|
117
|
+
* the AI agent call) to avoid blocking other plan operations.
|
|
118
|
+
*/
|
|
119
|
+
export async function handlePlanAudit(opts) {
|
|
120
|
+
// 1. Find the plan file
|
|
121
|
+
const found = await findPlanFile(opts.plansDir, opts.planId);
|
|
122
|
+
if (!found)
|
|
123
|
+
return { ok: false, error: `Plan not found: ${opts.planId}` };
|
|
124
|
+
const planContent = await fs.readFile(found.filePath, 'utf-8');
|
|
125
|
+
// 2. Validate Audit Log section exists
|
|
126
|
+
if (!planContent.includes('## Audit Log')) {
|
|
127
|
+
return { ok: false, error: 'Plan file is missing an Audit Log section — cannot append audit.' };
|
|
128
|
+
}
|
|
129
|
+
// 3. Structural pre-flight (instant)
|
|
130
|
+
const structuralConcerns = auditPlanStructure(planContent);
|
|
131
|
+
const structuralVerdict = deriveVerdict(structuralConcerns);
|
|
132
|
+
// If structural audit finds high/medium issues, stop — no point burning tokens
|
|
133
|
+
if (structuralVerdict.shouldLoop) {
|
|
134
|
+
const structuralNotes = formatStructuralNotes(structuralConcerns);
|
|
135
|
+
const verdictLine = `**Verdict:** Needs revision.`;
|
|
136
|
+
const fullNotes = structuralNotes + verdictLine;
|
|
137
|
+
// Write under lock
|
|
138
|
+
const releaseLock = await opts.acquireWriterLock();
|
|
139
|
+
try {
|
|
140
|
+
const freshContent = await fs.readFile(found.filePath, 'utf-8');
|
|
141
|
+
const round = maxReviewNumber(freshContent) + 1;
|
|
142
|
+
const updated = appendAuditRound(freshContent, round, fullNotes, structuralVerdict);
|
|
143
|
+
const tmpPath = found.filePath + '.tmp';
|
|
144
|
+
await fs.writeFile(tmpPath, updated, 'utf-8');
|
|
145
|
+
await fs.rename(tmpPath, found.filePath);
|
|
146
|
+
return { ok: true, planId: found.header.planId, round, verdict: structuralVerdict };
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
releaseLock();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// 4. Load project context for the auditor
|
|
153
|
+
const projectContext = await loadProjectContext(opts.cwd);
|
|
154
|
+
// 5. Determine preliminary round number (for the auditor prompt)
|
|
155
|
+
const preliminaryRound = maxReviewNumber(planContent) + 1;
|
|
156
|
+
// 6. Invoke AI auditor agent (outside the lock)
|
|
157
|
+
let auditOutput;
|
|
158
|
+
try {
|
|
159
|
+
const rt = opts.auditorRuntime ?? opts.runtime;
|
|
160
|
+
const auditorHasFileTools = rt.capabilities.has('tools_fs');
|
|
161
|
+
const readOnlyTools = ['Read', 'Glob', 'Grep'];
|
|
162
|
+
const auditorPrompt = buildAuditorPrompt(planContent, preliminaryRound, projectContext, { hasTools: auditorHasFileTools });
|
|
163
|
+
auditOutput = await collectRuntimeText(rt, auditorPrompt, opts.auditorModel, opts.cwd, auditorHasFileTools ? readOnlyTools : [], auditorHasFileTools ? [opts.cwd] : [], opts.timeoutMs);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return { ok: false, error: `Auditor agent failed: ${String(err instanceof Error ? err.message : err)}` };
|
|
167
|
+
}
|
|
168
|
+
// 7. Parse the AI verdict
|
|
169
|
+
const aiVerdict = parseAuditVerdict(auditOutput);
|
|
170
|
+
// 8. Combine structural notes (low-severity only, since we passed the gate) with AI output
|
|
171
|
+
const structuralPrefix = formatStructuralNotes(structuralConcerns);
|
|
172
|
+
const combinedNotes = structuralPrefix
|
|
173
|
+
? structuralPrefix + '## AI Audit\n\n' + auditOutput.trim()
|
|
174
|
+
: auditOutput.trim();
|
|
175
|
+
// The AI verdict is the one that matters (structural passed the gate)
|
|
176
|
+
const finalVerdict = aiVerdict;
|
|
177
|
+
// 9. Acquire lock, re-read, and write atomically
|
|
178
|
+
const releaseLock = await opts.acquireWriterLock();
|
|
179
|
+
try {
|
|
180
|
+
const freshContent = await fs.readFile(found.filePath, 'utf-8');
|
|
181
|
+
const round = maxReviewNumber(freshContent) + 1;
|
|
182
|
+
const updated = appendAuditRound(freshContent, round, combinedNotes, finalVerdict);
|
|
183
|
+
const tmpPath = found.filePath + '.tmp';
|
|
184
|
+
await fs.writeFile(tmpPath, updated, 'utf-8');
|
|
185
|
+
await fs.rename(tmpPath, found.filePath);
|
|
186
|
+
return { ok: true, planId: found.header.planId, round, verdict: finalVerdict };
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
releaseLock();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { handlePlanAudit, auditPlanStructure, maxReviewNumber } from './audit-handler.js';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
async function makeTmpDir() {
|
|
10
|
+
return fs.mkdtemp(path.join(os.tmpdir(), 'audit-handler-test-'));
|
|
11
|
+
}
|
|
12
|
+
function makeMockRuntime(response) {
|
|
13
|
+
return {
|
|
14
|
+
id: 'claude_code',
|
|
15
|
+
capabilities: new Set(['streaming_text']),
|
|
16
|
+
invoke(_params) {
|
|
17
|
+
return (async function* () {
|
|
18
|
+
yield { type: 'text_final', text: response };
|
|
19
|
+
})();
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function makeMockRuntimeError(message) {
|
|
24
|
+
return {
|
|
25
|
+
id: 'claude_code',
|
|
26
|
+
capabilities: new Set(['streaming_text']),
|
|
27
|
+
invoke(_params) {
|
|
28
|
+
return (async function* () {
|
|
29
|
+
yield { type: 'error', message };
|
|
30
|
+
})();
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const MINIMAL_PLAN = `# Plan: Test plan
|
|
35
|
+
|
|
36
|
+
**ID:** plan-099
|
|
37
|
+
**Task:** workspace-test
|
|
38
|
+
**Created:** 2026-02-13
|
|
39
|
+
**Status:** APPROVED
|
|
40
|
+
**Project:** discoclaw
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Objective
|
|
45
|
+
|
|
46
|
+
Add a widget to the thing.
|
|
47
|
+
|
|
48
|
+
## Scope
|
|
49
|
+
|
|
50
|
+
**In:**
|
|
51
|
+
- Add the widget module
|
|
52
|
+
- Write tests
|
|
53
|
+
|
|
54
|
+
**Out:**
|
|
55
|
+
- Changing existing widgets
|
|
56
|
+
|
|
57
|
+
## Changes
|
|
58
|
+
|
|
59
|
+
### File-by-file breakdown
|
|
60
|
+
|
|
61
|
+
- \`src/widget.ts\` — New file containing the widget implementation.
|
|
62
|
+
- \`src/widget.test.ts\` — Tests for the widget.
|
|
63
|
+
|
|
64
|
+
### New files
|
|
65
|
+
- \`src/widget.ts\`
|
|
66
|
+
- \`src/widget.test.ts\`
|
|
67
|
+
|
|
68
|
+
### Deleted files
|
|
69
|
+
_(none)_
|
|
70
|
+
|
|
71
|
+
## Risks
|
|
72
|
+
|
|
73
|
+
- Widget might conflict with existing gizmo module.
|
|
74
|
+
|
|
75
|
+
## Testing
|
|
76
|
+
|
|
77
|
+
- Unit tests for widget creation, deletion, and update.
|
|
78
|
+
- Integration test with the gizmo module.
|
|
79
|
+
|
|
80
|
+
## Dependencies
|
|
81
|
+
|
|
82
|
+
_(none)_
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Audit Log
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Implementation Notes
|
|
91
|
+
|
|
92
|
+
_Filled in during/after implementation._
|
|
93
|
+
`;
|
|
94
|
+
function makeLockFn() {
|
|
95
|
+
const state = { released: false };
|
|
96
|
+
const fn = async () => {
|
|
97
|
+
return () => { state.released = true; };
|
|
98
|
+
};
|
|
99
|
+
return { fn, get released() { return state.released; } };
|
|
100
|
+
}
|
|
101
|
+
async function writeTestPlan(plansDir, content = MINIMAL_PLAN) {
|
|
102
|
+
await fs.mkdir(plansDir, { recursive: true });
|
|
103
|
+
const filePath = path.join(plansDir, 'plan-099-test-plan.md');
|
|
104
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
105
|
+
return filePath;
|
|
106
|
+
}
|
|
107
|
+
function baseOpts(plansDir, runtime, lock, workspaceCwd, cwd) {
|
|
108
|
+
return {
|
|
109
|
+
planId: 'plan-099',
|
|
110
|
+
plansDir,
|
|
111
|
+
cwd: cwd ?? workspaceCwd,
|
|
112
|
+
workspaceCwd,
|
|
113
|
+
runtime,
|
|
114
|
+
auditorModel: 'test-model',
|
|
115
|
+
timeoutMs: 30_000,
|
|
116
|
+
acquireWriterLock: lock.fn,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Unit tests: auditPlanStructure
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
describe('auditPlanStructure', () => {
|
|
123
|
+
it('returns no concerns for a complete plan', () => {
|
|
124
|
+
const concerns = auditPlanStructure(MINIMAL_PLAN);
|
|
125
|
+
expect(concerns).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
it('flags missing required sections', () => {
|
|
128
|
+
const content = `# Plan: Test\n\n## Objective\n\nDo the thing.\n\n## Scope\n\n**In:** stuff\n\n---\n\n## Audit Log\n`;
|
|
129
|
+
const concerns = auditPlanStructure(content);
|
|
130
|
+
const titles = concerns.map((c) => c.title);
|
|
131
|
+
expect(titles).toContain('Missing section: Changes');
|
|
132
|
+
expect(titles).toContain('Missing section: Risks');
|
|
133
|
+
expect(titles).toContain('Missing section: Testing');
|
|
134
|
+
});
|
|
135
|
+
it('flags placeholder sections', () => {
|
|
136
|
+
const content = MINIMAL_PLAN.replace('## Risks\n\n- Widget might conflict with existing gizmo module.', '## Risks\n\n_(TBD)_');
|
|
137
|
+
const concerns = auditPlanStructure(content);
|
|
138
|
+
expect(concerns.some((c) => c.title === 'Empty or placeholder: Risks')).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
it('treats present-but-empty required sections as placeholder, not missing', () => {
|
|
141
|
+
const content = MINIMAL_PLAN.replace('## Risks\n\n- Widget might conflict with existing gizmo module.', '## Risks\n');
|
|
142
|
+
const concerns = auditPlanStructure(content);
|
|
143
|
+
expect(concerns.some((c) => c.title === 'Empty or placeholder: Risks')).toBe(true);
|
|
144
|
+
expect(concerns.some((c) => c.title === 'Missing section: Risks')).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
it('flags Changes section without file paths', () => {
|
|
147
|
+
const content = MINIMAL_PLAN.replace(/## Changes[\s\S]*?## Risks/m, '## Changes\n\nWe will make some changes to the codebase to improve things significantly.\n\n## Risks');
|
|
148
|
+
const concerns = auditPlanStructure(content);
|
|
149
|
+
expect(concerns.some((c) => c.title === 'Changes section lacks file paths')).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Unit tests: maxReviewNumber
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
describe('maxReviewNumber', () => {
|
|
156
|
+
it('returns 0 when no reviews exist', () => {
|
|
157
|
+
expect(maxReviewNumber(MINIMAL_PLAN)).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
it('returns the max review number', () => {
|
|
160
|
+
const content = MINIMAL_PLAN.replace('## Audit Log\n', '## Audit Log\n\n### Review 1 — 2026-02-13\nStuff\n\n### Review 3 — 2026-02-13\nMore stuff\n');
|
|
161
|
+
expect(maxReviewNumber(content)).toBe(3);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Integration tests: handlePlanAudit
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
describe('handlePlanAudit', () => {
|
|
168
|
+
let tmpDir;
|
|
169
|
+
let plansDir;
|
|
170
|
+
beforeEach(async () => {
|
|
171
|
+
tmpDir = await makeTmpDir();
|
|
172
|
+
plansDir = path.join(tmpDir, 'plans');
|
|
173
|
+
});
|
|
174
|
+
it('happy path: structural passes, AI audit appends review', async () => {
|
|
175
|
+
await writeTestPlan(plansDir);
|
|
176
|
+
const runtime = makeMockRuntime('No concerns found.\n\n**Verdict:** Ready to approve.');
|
|
177
|
+
const lock = makeLockFn();
|
|
178
|
+
const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
|
|
179
|
+
expect(result.ok).toBe(true);
|
|
180
|
+
if (result.ok) {
|
|
181
|
+
expect(result.round).toBe(1);
|
|
182
|
+
// parseAuditVerdict returns 'minor' for "ready to approve" with no severity markers
|
|
183
|
+
expect(result.verdict.maxSeverity).toBe('minor');
|
|
184
|
+
expect(result.verdict.shouldLoop).toBe(false);
|
|
185
|
+
}
|
|
186
|
+
// Verify the plan file was updated
|
|
187
|
+
const updated = await fs.readFile(path.join(plansDir, 'plan-099-test-plan.md'), 'utf-8');
|
|
188
|
+
expect(updated).toContain('### Review 1');
|
|
189
|
+
expect(lock.released).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
it('plan not found', async () => {
|
|
192
|
+
await fs.mkdir(plansDir, { recursive: true });
|
|
193
|
+
const runtime = makeMockRuntime('unused');
|
|
194
|
+
const lock = makeLockFn();
|
|
195
|
+
const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
|
|
196
|
+
expect(result.ok).toBe(false);
|
|
197
|
+
if (!result.ok) {
|
|
198
|
+
expect(result.error).toContain('Plan not found');
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
it('missing Audit Log section', async () => {
|
|
202
|
+
const noAuditLog = MINIMAL_PLAN.replace('## Audit Log\n', '');
|
|
203
|
+
await writeTestPlan(plansDir, noAuditLog);
|
|
204
|
+
const runtime = makeMockRuntime('unused');
|
|
205
|
+
const lock = makeLockFn();
|
|
206
|
+
const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
|
|
207
|
+
expect(result.ok).toBe(false);
|
|
208
|
+
if (!result.ok) {
|
|
209
|
+
expect(result.error).toContain('Audit Log');
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
it('structural gate stops on high severity — no AI call', async () => {
|
|
213
|
+
// Plan missing Changes, Risks, Testing sections
|
|
214
|
+
const badPlan = `# Plan: Bad plan
|
|
215
|
+
|
|
216
|
+
**ID:** plan-099
|
|
217
|
+
**Task:** workspace-test
|
|
218
|
+
**Created:** 2026-02-13
|
|
219
|
+
**Status:** DRAFT
|
|
220
|
+
**Project:** discoclaw
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Objective
|
|
225
|
+
|
|
226
|
+
Do something.
|
|
227
|
+
|
|
228
|
+
## Scope
|
|
229
|
+
|
|
230
|
+
**In:** stuff
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Audit Log
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Implementation Notes
|
|
239
|
+
|
|
240
|
+
_Filled in during/after implementation._
|
|
241
|
+
`;
|
|
242
|
+
await writeTestPlan(plansDir, badPlan);
|
|
243
|
+
const invokespy = vi.fn();
|
|
244
|
+
const runtime = {
|
|
245
|
+
id: 'claude_code',
|
|
246
|
+
capabilities: new Set(['streaming_text']),
|
|
247
|
+
invoke: invokespy,
|
|
248
|
+
};
|
|
249
|
+
const lock = makeLockFn();
|
|
250
|
+
const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
|
|
251
|
+
expect(result.ok).toBe(true);
|
|
252
|
+
if (result.ok) {
|
|
253
|
+
expect(result.verdict.maxSeverity).toBe('blocking');
|
|
254
|
+
expect(result.verdict.shouldLoop).toBe(true);
|
|
255
|
+
}
|
|
256
|
+
// AI agent should NOT have been called
|
|
257
|
+
expect(invokespy).not.toHaveBeenCalled();
|
|
258
|
+
expect(lock.released).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
it('AI agent failure returns error without writing', async () => {
|
|
261
|
+
await writeTestPlan(plansDir);
|
|
262
|
+
const runtime = makeMockRuntimeError('Model overloaded');
|
|
263
|
+
const lock = makeLockFn();
|
|
264
|
+
const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
|
|
265
|
+
expect(result.ok).toBe(false);
|
|
266
|
+
if (!result.ok) {
|
|
267
|
+
expect(result.error).toContain('Auditor agent failed');
|
|
268
|
+
}
|
|
269
|
+
// Plan file should be untouched
|
|
270
|
+
const content = await fs.readFile(path.join(plansDir, 'plan-099-test-plan.md'), 'utf-8');
|
|
271
|
+
expect(content).not.toContain('### Review');
|
|
272
|
+
});
|
|
273
|
+
it('round numbering with existing reviews', async () => {
|
|
274
|
+
const withReviews = MINIMAL_PLAN.replace('## Audit Log\n', '## Audit Log\n\n### Review 1 — 2026-02-13\n**Status:** COMPLETE\nStuff\n\n### Review 2 — 2026-02-13\n**Status:** COMPLETE\nMore stuff\n');
|
|
275
|
+
await writeTestPlan(plansDir, withReviews);
|
|
276
|
+
const runtime = makeMockRuntime('No concerns.\n\n**Verdict:** Ready to approve.');
|
|
277
|
+
const lock = makeLockFn();
|
|
278
|
+
const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
|
|
279
|
+
expect(result.ok).toBe(true);
|
|
280
|
+
if (result.ok) {
|
|
281
|
+
expect(result.round).toBe(3);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
it('lock released even on write failure', async () => {
|
|
285
|
+
await writeTestPlan(plansDir);
|
|
286
|
+
const runtime = makeMockRuntime('No concerns.\n\n**Verdict:** Ready to approve.');
|
|
287
|
+
const lock = makeLockFn();
|
|
288
|
+
// Make the plans directory read-only so the .tmp file creation fails.
|
|
289
|
+
// chmod on the file alone won't work — rename(2) only needs directory
|
|
290
|
+
// write permission, not file write permission.
|
|
291
|
+
await fs.chmod(plansDir, 0o555);
|
|
292
|
+
// handlePlanAudit will throw from the write phase (EACCES on .tmp creation),
|
|
293
|
+
// but the try/finally must still release the lock.
|
|
294
|
+
await expect(handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir))).rejects.toThrow();
|
|
295
|
+
expect(lock.released).toBe(true);
|
|
296
|
+
// Plan file should be unchanged (write never completed)
|
|
297
|
+
await fs.chmod(plansDir, 0o755);
|
|
298
|
+
const content = await fs.readFile(path.join(plansDir, 'plan-099-test-plan.md'), 'utf-8');
|
|
299
|
+
expect(content).not.toContain('### Review');
|
|
300
|
+
});
|
|
301
|
+
it('tools_fs runtime receives tools and addDirs pointing to project cwd', async () => {
|
|
302
|
+
await writeTestPlan(plansDir);
|
|
303
|
+
const invokeSpy = vi.fn();
|
|
304
|
+
const runtime = {
|
|
305
|
+
id: 'codex',
|
|
306
|
+
capabilities: new Set(['streaming_text', 'tools_fs']),
|
|
307
|
+
invoke(params) {
|
|
308
|
+
invokeSpy(params);
|
|
309
|
+
return (async function* () {
|
|
310
|
+
yield { type: 'text_final', text: 'No concerns.\n\n**Verdict:** Ready to approve.' };
|
|
311
|
+
})();
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
const lock = makeLockFn();
|
|
315
|
+
const projectCwd = path.join(tmpDir, 'project-root');
|
|
316
|
+
await fs.mkdir(projectCwd, { recursive: true });
|
|
317
|
+
const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir, projectCwd));
|
|
318
|
+
expect(result.ok).toBe(true);
|
|
319
|
+
expect(invokeSpy).toHaveBeenCalledTimes(1);
|
|
320
|
+
const params = invokeSpy.mock.calls[0][0];
|
|
321
|
+
// Should receive the read-only tool list
|
|
322
|
+
expect(params.tools).toEqual(['Read', 'Glob', 'Grep']);
|
|
323
|
+
// addDirs should point to the project cwd, not workspaceCwd
|
|
324
|
+
expect(params.addDirs).toEqual([projectCwd]);
|
|
325
|
+
});
|
|
326
|
+
it('non-tools_fs runtime receives no tools or addDirs', async () => {
|
|
327
|
+
await writeTestPlan(plansDir);
|
|
328
|
+
const invokeSpy = vi.fn();
|
|
329
|
+
const runtime = {
|
|
330
|
+
id: 'codex',
|
|
331
|
+
capabilities: new Set(['streaming_text']),
|
|
332
|
+
invoke(params) {
|
|
333
|
+
invokeSpy(params);
|
|
334
|
+
return (async function* () {
|
|
335
|
+
yield { type: 'text_final', text: 'No concerns.\n\n**Verdict:** Ready to approve.' };
|
|
336
|
+
})();
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
const lock = makeLockFn();
|
|
340
|
+
const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
|
|
341
|
+
expect(result.ok).toBe(true);
|
|
342
|
+
expect(invokeSpy).toHaveBeenCalledTimes(1);
|
|
343
|
+
const params = invokeSpy.mock.calls[0][0];
|
|
344
|
+
// Should receive empty tools
|
|
345
|
+
expect(params.tools).toEqual([]);
|
|
346
|
+
// addDirs should be undefined (collectRuntimeText converts [] to undefined)
|
|
347
|
+
expect(params.addDirs).toBeUndefined();
|
|
348
|
+
});
|
|
349
|
+
it('empty plan ID', async () => {
|
|
350
|
+
await fs.mkdir(plansDir, { recursive: true });
|
|
351
|
+
const runtime = makeMockRuntime('unused');
|
|
352
|
+
const lock = makeLockFn();
|
|
353
|
+
const opts = baseOpts(plansDir, runtime, lock, tmpDir);
|
|
354
|
+
opts.planId = '';
|
|
355
|
+
const result = await handlePlanAudit(opts);
|
|
356
|
+
expect(result.ok).toBe(false);
|
|
357
|
+
if (!result.ok) {
|
|
358
|
+
expect(result.error).toContain('not found');
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Client, GatewayIntentBits, Partials } from 'discord.js';
|
|
2
|
+
import { isAllowlisted } from './allowlist.js';
|
|
3
|
+
import { KeyedQueue } from './keyed-queue.js';
|
|
4
|
+
function discordSessionKey(msg) {
|
|
5
|
+
if (msg.isDm)
|
|
6
|
+
return `discord:dm:${msg.authorId}`;
|
|
7
|
+
return `discord:channel:${msg.channelId}`;
|
|
8
|
+
}
|
|
9
|
+
function splitDiscord(text, limit = 2000) {
|
|
10
|
+
// Minimal fence-safe markdown chunking.
|
|
11
|
+
const normalized = text.replace(/\r\n?/g, '\n');
|
|
12
|
+
if (normalized.length <= limit)
|
|
13
|
+
return [normalized];
|
|
14
|
+
const rawLines = normalized.split('\n');
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let cur = '';
|
|
17
|
+
let inFence = false;
|
|
18
|
+
let fenceHeader = '```';
|
|
19
|
+
const ensureFenceOpen = () => {
|
|
20
|
+
if (cur)
|
|
21
|
+
return;
|
|
22
|
+
if (inFence)
|
|
23
|
+
cur = `${fenceHeader}\n`;
|
|
24
|
+
};
|
|
25
|
+
const flush = () => {
|
|
26
|
+
if (!cur)
|
|
27
|
+
return;
|
|
28
|
+
if (inFence && !cur.trimEnd().endsWith('```')) {
|
|
29
|
+
const close = '\n```';
|
|
30
|
+
if (cur.length + close.length <= limit) {
|
|
31
|
+
cur += close;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
chunks.push(cur);
|
|
35
|
+
cur = '';
|
|
36
|
+
};
|
|
37
|
+
const appendLine = (line) => {
|
|
38
|
+
ensureFenceOpen();
|
|
39
|
+
const sep = cur.length > 0 ? '\n' : '';
|
|
40
|
+
cur += sep + line;
|
|
41
|
+
};
|
|
42
|
+
for (const line of rawLines) {
|
|
43
|
+
const nextLen = (cur.length ? cur.length + 1 : 0) + line.length;
|
|
44
|
+
if (nextLen > limit) {
|
|
45
|
+
flush();
|
|
46
|
+
// Reopen fence if we flushed mid-fence.
|
|
47
|
+
ensureFenceOpen();
|
|
48
|
+
}
|
|
49
|
+
// If the line itself is too long, hard split.
|
|
50
|
+
if (line.length > limit) {
|
|
51
|
+
let rest = line;
|
|
52
|
+
while (rest.length > 0) {
|
|
53
|
+
const room = Math.max(1, limit - (cur.length ? cur.length + 1 : 0));
|
|
54
|
+
const take = rest.slice(0, room);
|
|
55
|
+
appendLine(take);
|
|
56
|
+
rest = rest.slice(room);
|
|
57
|
+
if (rest.length > 0) {
|
|
58
|
+
flush();
|
|
59
|
+
ensureFenceOpen();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
appendLine(line);
|
|
65
|
+
}
|
|
66
|
+
const trimmed = line.trimStart();
|
|
67
|
+
if (trimmed.startsWith('```')) {
|
|
68
|
+
if (!inFence) {
|
|
69
|
+
inFence = true;
|
|
70
|
+
fenceHeader = trimmed.trimEnd();
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
inFence = false;
|
|
74
|
+
fenceHeader = '```';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// If we are in a fence and we're close to the limit, proactively flush
|
|
78
|
+
// to reduce the chance of an un-closable fence close.
|
|
79
|
+
if (inFence && cur.length >= limit - 8) {
|
|
80
|
+
flush();
|
|
81
|
+
// Next line will reopen.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
flush();
|
|
85
|
+
return chunks.filter((c) => c.trim().length > 0);
|
|
86
|
+
}
|
|
87
|
+
export async function startDiscordBot(params) {
|
|
88
|
+
const client = new Client({
|
|
89
|
+
intents: [
|
|
90
|
+
GatewayIntentBits.Guilds,
|
|
91
|
+
GatewayIntentBits.GuildMessages,
|
|
92
|
+
GatewayIntentBits.MessageContent,
|
|
93
|
+
GatewayIntentBits.DirectMessages,
|
|
94
|
+
],
|
|
95
|
+
partials: [Partials.Channel],
|
|
96
|
+
});
|
|
97
|
+
const queue = new KeyedQueue();
|
|
98
|
+
client.on('messageCreate', async (msg) => {
|
|
99
|
+
if (!msg.author || msg.author.bot)
|
|
100
|
+
return;
|
|
101
|
+
if (!isAllowlisted(params.allowUserIds, msg.author.id))
|
|
102
|
+
return;
|
|
103
|
+
const isDm = msg.guildId == null;
|
|
104
|
+
const sessionKey = discordSessionKey({
|
|
105
|
+
channelId: msg.channelId,
|
|
106
|
+
authorId: msg.author.id,
|
|
107
|
+
isDm,
|
|
108
|
+
});
|
|
109
|
+
await queue.run(sessionKey, async () => {
|
|
110
|
+
const sessionId = await params.sessionManager.getOrCreate(sessionKey);
|
|
111
|
+
const reply = await msg.reply('...');
|
|
112
|
+
let finalText = '';
|
|
113
|
+
for await (const evt of params.runtime.invoke({
|
|
114
|
+
prompt: msg.content,
|
|
115
|
+
model: 'opus',
|
|
116
|
+
cwd: params.workspaceCwd,
|
|
117
|
+
sessionId,
|
|
118
|
+
tools: ['Bash', 'Read', 'Edit', 'WebSearch', 'WebFetch'],
|
|
119
|
+
timeoutMs: 10 * 60_000,
|
|
120
|
+
})) {
|
|
121
|
+
if (evt.type === 'text_final') {
|
|
122
|
+
finalText = evt.text;
|
|
123
|
+
}
|
|
124
|
+
else if (evt.type === 'error') {
|
|
125
|
+
finalText = `Error: ${evt.message}`;
|
|
126
|
+
}
|
|
127
|
+
else if (evt.type === 'text_delta' && !finalText) {
|
|
128
|
+
// Only use deltas when we don't get a final text payload.
|
|
129
|
+
finalText += evt.text;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const chunks = splitDiscord(finalText || '(no output)');
|
|
133
|
+
await reply.edit(chunks[0] ?? '(no output)');
|
|
134
|
+
for (const extra of chunks.slice(1)) {
|
|
135
|
+
await msg.channel.send(extra);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
await client.login(params.token);
|
|
140
|
+
return client;
|
|
141
|
+
}
|