@swarmclawai/swarmclaw 1.8.12 → 1.8.13

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/README.md CHANGED
@@ -8,13 +8,13 @@
8
8
  <img src="https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/public/branding/swarmclaw-org-avatar.png" alt="SwarmClaw lobster logo" width="120" />
9
9
  </p>
10
10
 
11
- <p align="center"><strong>Self-hosted runtime for autonomous AI agents.</strong> Multi-provider, MCP-native, with memory, skills, delegation, and schedules.</p>
11
+ <p align="center"><strong>The self-hosted AI agent runtime and multi-agent framework for autonomous agents.</strong> Open-source agent swarms with durable agent memory, MCP tools, skills, delegation, schedules, and 23+ LLM providers — a practical Claude Code and LangChain alternative.</p>
12
12
 
13
13
  <p align="center">
14
14
  <img src="doc/assets/screenshots/org-chart.png" alt="SwarmClaw org chart with delegation and live agent activity" width="900" />
15
15
  </p>
16
16
 
17
- SwarmClaw is a self-hosted AI runtime for OpenClaw and multi-agent work. It helps you run autonomous agents and orchestrators with heartbeats, schedules, delegation, memory, runtime skills, and reviewed conversation-to-skill learning across OpenClaw gateways and other providers.
17
+ SwarmClaw is an open-source, self-hosted AI agent runtime and multi-agent framework. Run autonomous AI agents, agent swarms, and orchestrators with heartbeats, schedules, delegation, agent memory, runtime skills, and reviewed conversation-to-skill learning across OpenClaw gateways, Claude, GPT, Gemini, OpenRouter, Ollama, and 23+ other providers. Use it as your AI agent dashboard, agent orchestration platform, and home base for self-hosted multi-agent AI workflows.
18
18
 
19
19
  GitHub: https://github.com/swarmclawai/swarmclaw
20
20
  Docs: https://swarmclaw.ai/docs
@@ -399,6 +399,14 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.8.13 Highlights
403
+
404
+ Task retry and host execute hotfix for issues [#68](https://github.com/swarmclawai/swarmclaw/issues/68) and [#69](https://github.com/swarmclawai/swarmclaw/issues/69).
405
+
406
+ - **Per-agent host execute.** Agents configured with `executeConfig.backend = "host"` now pass that setting into the runtime `execute` tool, so `persistent=true` uses the documented host backend.
407
+ - **Scheduled task validation.** Schedule-created tasks no longer get auto-classified as implementation tasks for quality gates unless they explicitly opt into a task quality gate.
408
+ - **Retry loop guard.** A task that fails again with the same retry reason is dead-lettered instead of spending another run on identical work.
409
+
402
410
  ### v1.8.12 Highlights
403
411
 
404
412
  Gateway Fleet Command release: SwarmClaw now treats OpenClaw gateways as an operator surface instead of a background provider detail.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.8.12",
3
+ "version": "1.8.13",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -87,7 +87,7 @@
87
87
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
88
88
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
89
89
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
90
- "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
90
+ "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/session-tools/execute.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
91
91
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
92
92
  "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
93
93
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -124,6 +124,14 @@ function applyTaskPolicyDefaults(task: BoardTask): void {
124
124
  if (task.deadLetteredAt === undefined) task.deadLetteredAt = null
125
125
  }
126
126
 
127
+ function formatRetryError(reason: string): string {
128
+ return `Retry scheduled after failure: ${reason}`.slice(0, 500)
129
+ }
130
+
131
+ function isRepeatedRetryFailure(previousError: unknown, reason: string): boolean {
132
+ return typeof previousError === 'string' && previousError === formatRetryError(reason)
133
+ }
134
+
127
135
  export interface TaskResumeState {
128
136
  claudeSessionId: string | null
129
137
  codexThreadId: string | null
@@ -971,9 +979,10 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
971
979
  }
972
980
  applyTaskPolicyDefaults(task)
973
981
  const now = Date.now()
982
+ const repeatedRetryFailure = isRepeatedRetryFailure(task.error, reason)
974
983
  task.attempts = (task.attempts || 0) + 1
975
984
 
976
- if ((task.attempts || 0) < (task.maxAttempts || 1)) {
985
+ if ((task.attempts || 0) < (task.maxAttempts || 1) && !repeatedRetryFailure) {
977
986
  const delayMs = jitteredBackoff((task.retryBackoffSec || 30) * 1000, Math.max(0, (task.attempts || 1) - 1), 6 * 3600_000)
978
987
  task.status = 'queued'
979
988
  task.retryScheduledAt = now + delayMs
@@ -982,7 +991,7 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
982
991
  // recovery loop burns CPU re-queueing a task that can never run.
983
992
  task.checkoutRunId = null
984
993
  task.updatedAt = now
985
- task.error = `Retry scheduled after failure: ${reason}`.slice(0, 500)
994
+ task.error = formatRetryError(reason)
986
995
  if (!task.comments) task.comments = []
987
996
  task.comments.push({
988
997
  id: genId(),
@@ -0,0 +1,98 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { test } from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
9
+
10
+ function runWithTempDataDir<T extends Record<string, unknown>>(script: string): T {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-queue-retry-policy-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: path.join(tempDir, 'data'),
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ SWARMCLAW_BUILD_MODE: '1',
20
+ },
21
+ encoding: 'utf-8',
22
+ timeout: 15000,
23
+ })
24
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
25
+ const lines = (result.stdout || '')
26
+ .trim()
27
+ .split('\n')
28
+ .map((line) => line.trim())
29
+ .filter(Boolean)
30
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
31
+ return JSON.parse(jsonLine || '{}') as T
32
+ } finally {
33
+ fs.rmSync(tempDir, { recursive: true, force: true })
34
+ }
35
+ }
36
+
37
+ test('recoverStalledRunningTasks dead-letters repeated retry failures instead of repeating identical work', () => {
38
+ const output = runWithTempDataDir<{
39
+ result: { recovered: number; deadLettered: number }
40
+ status: string | null
41
+ attempts: number | null
42
+ queued: string[]
43
+ retryScheduledAt: number | null
44
+ deadLetteredAt: number | null
45
+ error: string | null
46
+ }>(`
47
+ const storageMod = await import('@/lib/server/storage')
48
+ const queueMod = await import('@/lib/server/runtime/queue')
49
+ const storage = storageMod.default || storageMod
50
+ const queue = queueMod.default || queueMod
51
+
52
+ const now = Date.now()
53
+ const reason = 'Detected stalled run after 5m without progress'
54
+ storage.saveSettings({
55
+ ...storage.loadSettings(),
56
+ taskStallTimeoutMin: 5,
57
+ taskRetryBackoffSec: 30,
58
+ })
59
+ storage.saveTasks({
60
+ repeat: {
61
+ id: 'repeat',
62
+ title: 'Repeated structural failure',
63
+ description: 'A task that already retried the same failure reason',
64
+ status: 'running',
65
+ agentId: 'agent-a',
66
+ startedAt: now - 600_000,
67
+ updatedAt: now - 600_000,
68
+ createdAt: now - 700_000,
69
+ maxAttempts: 3,
70
+ attempts: 1,
71
+ checkoutRunId: 'repeat-run-id',
72
+ error: 'Retry scheduled after failure: ' + reason,
73
+ },
74
+ })
75
+ storage.saveQueue([])
76
+
77
+ const result = queue.recoverStalledRunningTasks()
78
+ const task = storage.loadTasks().repeat
79
+ console.log(JSON.stringify({
80
+ result,
81
+ status: task?.status ?? null,
82
+ attempts: task?.attempts ?? null,
83
+ queued: storage.loadQueue(),
84
+ retryScheduledAt: task?.retryScheduledAt ?? null,
85
+ deadLetteredAt: task?.deadLetteredAt ?? null,
86
+ error: task?.error ?? null,
87
+ }))
88
+ `)
89
+
90
+ assert.equal(output.result.recovered, 0)
91
+ assert.equal(output.result.deadLettered, 1)
92
+ assert.equal(output.status, 'failed')
93
+ assert.equal(output.attempts, 2)
94
+ assert.deepEqual(output.queued, [])
95
+ assert.equal(output.retryScheduledAt, null)
96
+ assert.equal(typeof output.deadLetteredAt, 'number')
97
+ assert.match(output.error || '', /Dead-lettered after 2\/3 attempts/)
98
+ })
@@ -94,6 +94,8 @@ export interface ToolBuildContext {
94
94
  fileAccessPolicy?: { allowedPaths?: string[]; blockedPaths?: string[] } | null
95
95
  /** Agent's sandbox config — passed to shell for session-scoped container execution */
96
96
  sandboxConfig?: NonNullable<Agent['sandboxConfig']> | null
97
+ /** Loaded agent record for tool builders that need per-agent runtime settings */
98
+ agentRecord?: Agent | null
97
99
  /** Agent's filesystem scope — 'machine' allows file access outside the workspace */
98
100
  filesystemScope?: 'workspace' | 'machine'
99
101
  }
@@ -1,7 +1,40 @@
1
- import { describe, it } from 'node:test'
2
1
  import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { describe, it, test } from 'node:test'
3
7
  import { redactSecrets, buildCredentialEnv } from './credential-env'
4
8
 
9
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
10
+
11
+ function runWithTempDataDir<T extends Record<string, unknown>>(script: string): T {
12
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-execute-tool-'))
13
+ try {
14
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
15
+ cwd: repoRoot,
16
+ env: {
17
+ ...process.env,
18
+ DATA_DIR: path.join(tempDir, 'data'),
19
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
20
+ SWARMCLAW_BUILD_MODE: '1',
21
+ },
22
+ encoding: 'utf-8',
23
+ timeout: 15000,
24
+ })
25
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
26
+ const lines = (result.stdout || '')
27
+ .trim()
28
+ .split('\n')
29
+ .map((line) => line.trim())
30
+ .filter(Boolean)
31
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
32
+ return JSON.parse(jsonLine || '{}') as T
33
+ } finally {
34
+ fs.rmSync(tempDir, { recursive: true, force: true })
35
+ }
36
+ }
37
+
5
38
  describe('credential-env', () => {
6
39
  describe('redactSecrets', () => {
7
40
  it('redacts secret values from text', () => {
@@ -56,3 +89,62 @@ describe('credential-env', () => {
56
89
  })
57
90
  })
58
91
  })
92
+
93
+ test('buildExecuteTools uses the current agent executeConfig backend', () => {
94
+ const output = runWithTempDataDir<{
95
+ hostOutput: string
96
+ sandboxOutput: string
97
+ }>(`
98
+ const storageMod = await import('@/lib/server/storage')
99
+ const executeModImport = await import('./src/lib/server/session-tools/execute.ts')
100
+ const executeMod = executeModImport.default || executeModImport
101
+ const storage = storageMod.default || storageMod
102
+ const now = Date.now()
103
+
104
+ storage.saveAgents({
105
+ 'agent-host': {
106
+ id: 'agent-host',
107
+ name: 'Host Agent',
108
+ provider: 'openai',
109
+ model: 'gpt-test',
110
+ executeConfig: { backend: 'host', network: { enabled: true }, timeout: 30 },
111
+ createdAt: now,
112
+ updatedAt: now,
113
+ },
114
+ 'agent-sandbox': {
115
+ id: 'agent-sandbox',
116
+ name: 'Sandbox Agent',
117
+ provider: 'openai',
118
+ model: 'gpt-test',
119
+ createdAt: now,
120
+ updatedAt: now,
121
+ },
122
+ })
123
+
124
+ const makeContext = (agentId) => ({
125
+ cwd: process.cwd(),
126
+ ctx: { agentId, sessionId: 'session-' + agentId },
127
+ hasExtension: (name) => name === 'execute',
128
+ hasTool: (name) => name === 'execute',
129
+ cleanupFns: [],
130
+ commandTimeoutMs: 30000,
131
+ claudeTimeoutMs: 30000,
132
+ cliProcessTimeoutMs: 30000,
133
+ persistDelegateResumeId: () => {},
134
+ readStoredDelegateResumeId: () => null,
135
+ resolveCurrentSession: () => ({ id: 'session-' + agentId, agentId }),
136
+ activeExtensions: ['execute'],
137
+ agentRecord: storage.loadAgent(agentId),
138
+ })
139
+
140
+ const hostTool = executeMod.buildExecuteTools(makeContext('agent-host'))[0]
141
+ const sandboxTool = executeMod.buildExecuteTools(makeContext('agent-sandbox'))[0]
142
+ const hostOutput = await hostTool.invoke({ code: 'printf host-ok', persistent: true })
143
+ const sandboxOutput = await sandboxTool.invoke({ code: 'printf sandbox-no', persistent: true })
144
+
145
+ console.log(JSON.stringify({ hostOutput, sandboxOutput }))
146
+ `)
147
+
148
+ assert.equal(output.hostOutput, 'host-ok')
149
+ assert.match(output.sandboxOutput, /requires `executeConfig\.backend = "host"`/)
150
+ })
@@ -26,6 +26,7 @@ import {
26
26
  normalizeAgentExecuteConfig,
27
27
  type AgentExecuteConfig,
28
28
  } from '@/lib/agent-execute-defaults'
29
+ import { loadAgent } from '../storage'
29
30
 
30
31
  const TAG = 'execute'
31
32
 
@@ -310,9 +311,9 @@ registerNativeCapability('execute', ExecuteExtension)
310
311
  export function buildExecuteTools(bctx: ToolBuildContext) {
311
312
  if (!bctx.hasExtension('execute')) return []
312
313
 
313
- // Resolve execute config from the agent
314
- const session = bctx.resolveCurrentSession?.()
315
- const agent = session?.agent as (Agent & { executeConfig?: ExecuteConfig }) | undefined
314
+ const agentId = typeof bctx.ctx?.agentId === 'string' ? bctx.ctx.agentId.trim() : ''
315
+ const agent = (bctx.agentRecord as (Agent & { executeConfig?: ExecuteConfig }) | null | undefined)
316
+ ?? (agentId ? (loadAgent(agentId) as (Agent & { executeConfig?: ExecuteConfig }) | null) : null)
316
317
  const executeConfig = normalizeAgentExecuteConfig(agent?.executeConfig)
317
318
 
318
319
  return [
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
- import type { Session } from '@/types'
3
+ import type { Agent, Session } from '@/types'
4
4
  import { dedup, errorMessage } from '@/lib/shared-utils'
5
5
  import { loadSettings, loadSession, loadAgent, loadMcpServers, patchAgent, patchSession } from '../storage'
6
6
  import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
@@ -221,6 +221,7 @@ export async function buildSessionTools(cwd: string, enabledExtensions: string[]
221
221
  activeExtensions,
222
222
  fileAccessPolicy: effectiveFileAccessPolicy,
223
223
  sandboxConfig: agentRecord?.sandboxConfig ?? null,
224
+ agentRecord: agentRecord as Agent | null,
224
225
  filesystemScope,
225
226
  }
226
227
 
@@ -49,6 +49,39 @@ test('validateTaskCompletion still enforces stricter minimum for implementation
49
49
  assert.ok(validation.reasons.some((reason) => reason.includes('Result summary is too short')))
50
50
  })
51
51
 
52
+ test('validateTaskCompletion does not auto-apply implementation quality gates to scheduled tasks', () => {
53
+ const validation = validateTaskCompletion({
54
+ title: '[Sched] Daily wiki hygiene (run #1)',
55
+ description: 'Run scripts/wiki-hygiene.mjs and post the digest to Slack.',
56
+ result: 'Ran node scripts/wiki-hygiene.mjs and posted the digest to Slack with exit code 0.',
57
+ sourceType: 'schedule',
58
+ sourceScheduleId: 'schedule-wiki',
59
+ error: null,
60
+ } as Partial<BoardTask>)
61
+
62
+ assert.equal(validation.ok, true)
63
+ })
64
+
65
+ test('validateTaskCompletion still enforces explicit quality gates on scheduled tasks', () => {
66
+ const validation = validateTaskCompletion({
67
+ title: '[Sched] Daily wiki hygiene (run #1)',
68
+ description: 'Run scripts/wiki-hygiene.mjs and post the digest to Slack.',
69
+ result: 'Ran node scripts/wiki-hygiene.mjs and posted the digest to Slack with exit code 0.',
70
+ sourceType: 'schedule',
71
+ sourceScheduleId: 'schedule-wiki',
72
+ qualityGate: {
73
+ enabled: true,
74
+ minResultChars: 20,
75
+ minEvidenceItems: 2,
76
+ requireVerification: true,
77
+ },
78
+ error: null,
79
+ } as Partial<BoardTask>)
80
+
81
+ assert.equal(validation.ok, false)
82
+ assert.ok(validation.reasons.some((reason) => reason.includes('verification evidence is required')))
83
+ })
84
+
52
85
  test('validateTaskCompletion fails implementation task with unfinished next-step language', () => {
53
86
  const validation = validateTaskCompletion({
54
87
  title: 'Build weather dashboard',
@@ -85,7 +85,8 @@ export function validateTaskCompletion(
85
85
  const report = options.report || null
86
86
  const hasExplicitQualityGate = !!task.qualityGate && typeof task.qualityGate === 'object'
87
87
  const qualityGate = normalizeTaskQualityGate(task.qualityGate || null, options.settings || null)
88
- const implementationTask = IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description)
88
+ const scheduleTask = task.sourceType === 'schedule' || typeof task.sourceScheduleId === 'string'
89
+ const implementationTask = !scheduleTask && (IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description))
89
90
 
90
91
  if (error) reasons.push('Task has a non-empty error field.')
91
92
  if (/^untitled task$/i.test(title) && !description) {