@swarmclawai/swarmclaw 1.9.1 → 1.9.2

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
@@ -399,6 +399,16 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.9.2 Highlights
403
+
404
+ Bundled competitor-parity release: Hermes-style reasoning hygiene, deterministic delegation routing, Mission Control task workflow polish, OpenClaw export hardening, and Paperclip-style timeout hygiene.
405
+
406
+ - **Stateful reasoning tag scrubber.** String-streamed `<think>`, `<thinking>`, `<reasoning>`, `<thought>`, and `<REASONING_SCRATCHPAD>` blocks are removed across split deltas and routed into SwarmClaw's thinking stream instead of leaking into visible answers.
407
+ - **Deterministic delegation profiles.** `manage_tasks` now accepts explicit `workType` and `requiredCapabilities` routing hints, returns a stable `routeKey`, and can auto-assign unowned work without a classifier call when the profile is explicit.
408
+ - **Assignment workflow transitions.** Newly assigned backlog/triage/todo tasks move into the `in_progress` workflow lane without changing their runtime status or queueing execution.
409
+ - **Knowledge hygiene pruning.** Archived or superseded knowledge sources can now be pruned after a retention window, with prune actions recorded in the hygiene summary.
410
+ - **Collision-safe exports and timeout hardening.** Portability exports support timestamped attachment filenames, the sandbox browser image build has a configurable timeout, and release notes now carry the macOS quarantine workaround for ad-hoc signed desktop builds.
411
+
402
412
  ### v1.9.1 Highlights
403
413
 
404
414
  Task execution workspace release: the first Paperclip-style work-control slice for task-scoped workspaces, preview handoffs, and liveness evidence.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
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",
@@ -84,10 +84,10 @@
84
84
  "lint:baseline": "node ./scripts/lint-baseline.mjs check",
85
85
  "lint:baseline:update": "node ./scripts/lint-baseline.mjs update",
86
86
  "cli": "node ./bin/swarmclaw.js",
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",
87
+ "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/ensure-sandbox-browser-image.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/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/tasks/task-execution-workspace.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/tasks/task-workspace-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/agents/delegation-advisory.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/chat-execution/reasoning-tag-scrubber.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/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.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/tasks/task-workspace-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/portability/export/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",
@@ -10,6 +10,7 @@ const args = new Set(process.argv.slice(2))
10
10
  const quiet = args.has('--quiet')
11
11
  const required = args.has('--required')
12
12
  const image = process.env.SWARMCLAW_SANDBOX_BROWSER_IMAGE || 'swarmclaw-sandbox-browser:bookworm-slim'
13
+ const DEFAULT_BUILD_TIMEOUT_MS = 20 * 60 * 1000
13
14
  const SOURCE_LABEL = 'swarmclaw.sandboxBrowserSourceHash'
14
15
 
15
16
  function log(message) {
@@ -29,6 +30,11 @@ function run(command, commandArgs, options = {}) {
29
30
  })
30
31
  }
31
32
 
33
+ function readPositiveInteger(value, fallback) {
34
+ const parsed = Number.parseInt(String(value || ''), 10)
35
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
36
+ }
37
+
32
38
  function commandExists(name) {
33
39
  const lookup = process.platform === 'win32' ? 'where' : 'which'
34
40
  const result = run(lookup, [name])
@@ -56,6 +62,7 @@ function readImageLabel(name, label) {
56
62
  }
57
63
 
58
64
  function buildImage(sourceHash) {
65
+ const timeoutMs = readPositiveInteger(process.env.SWARMCLAW_SANDBOX_BROWSER_BUILD_TIMEOUT_MS, DEFAULT_BUILD_TIMEOUT_MS)
59
66
  log(`Building sandbox browser image ${image}...`)
60
67
  const result = spawnSync(
61
68
  'docker',
@@ -69,13 +76,16 @@ function buildImage(sourceHash) {
69
76
  {
70
77
  cwd,
71
78
  stdio: 'inherit',
79
+ timeout: timeoutMs,
72
80
  },
73
81
  )
74
82
  if (result.error || (result.status ?? 1) !== 0) {
83
+ const timedOut = result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM'
84
+ const detail = timedOut ? ` Build timed out after ${timeoutMs}ms.` : ''
75
85
  if (required) {
76
- fail(`Failed to build sandbox browser image ${image}.`, result.status ?? 1)
86
+ fail(`Failed to build sandbox browser image ${image}.${detail}`, result.status ?? 1)
77
87
  }
78
- log(`Skipping sandbox browser image after build failure.`)
88
+ log(`Skipping sandbox browser image after build failure.${detail}`)
79
89
  return false
80
90
  }
81
91
  log(`Sandbox browser image ready: ${image}`)
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import {
3
3
  getKnowledgeHygieneSummary,
4
+ pruneArchivedKnowledgeSources,
4
5
  runKnowledgeHygieneMaintenance,
5
6
  } from '@/lib/server/knowledge-sources'
6
7
 
@@ -8,6 +9,23 @@ export async function GET() {
8
9
  return NextResponse.json(await getKnowledgeHygieneSummary())
9
10
  }
10
11
 
11
- export async function POST() {
12
+ export async function POST(req: Request) {
13
+ let body: Record<string, unknown> | null = null
14
+ if ((req.headers.get('content-type') || '').includes('application/json')) {
15
+ try {
16
+ const parsed = await req.json()
17
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) body = parsed as Record<string, unknown>
18
+ } catch {
19
+ body = null
20
+ }
21
+ }
22
+
23
+ if (body?.action === 'prune') {
24
+ const olderThanDays = typeof body.olderThanDays === 'number' ? body.olderThanDays : null
25
+ const result = await pruneArchivedKnowledgeSources({ olderThanDays })
26
+ const summary = await getKnowledgeHygieneSummary()
27
+ return NextResponse.json({ ...summary, prune: result })
28
+ }
29
+
12
30
  return NextResponse.json(await runKnowledgeHygieneMaintenance())
13
31
  }
@@ -0,0 +1,17 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { GET } from './route'
5
+ import { buildPortableExportFilename } from '@/lib/server/portability/export'
6
+
7
+ describe('GET /api/portability/export', () => {
8
+ it('returns a collision-resistant attachment filename for downloads', async () => {
9
+ const response = await GET(new Request('http://local/api/portability/export?download=true'))
10
+ assert.equal(response.status, 200)
11
+ assert.equal(response.headers.get('content-type'), 'application/json; charset=utf-8')
12
+ const disposition = response.headers.get('content-disposition') || ''
13
+ assert.match(disposition, /^attachment; filename="swarmclaw-export-\d{8}-\d{6}\d{3}Z\.json"$/)
14
+ const body = await response.json()
15
+ assert.equal(disposition, `attachment; filename="${buildPortableExportFilename(body)}"`)
16
+ })
17
+ })
@@ -1,8 +1,17 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { exportConfig } from '@/lib/server/portability/export'
2
+ import { buildPortableExportFilename, exportConfig } from '@/lib/server/portability/export'
3
3
  export const dynamic = 'force-dynamic'
4
4
 
5
- export async function GET() {
5
+ export async function GET(req: Request) {
6
6
  const manifest = exportConfig()
7
+ const { searchParams } = new URL(req.url)
8
+ if (searchParams.get('download') === 'true') {
9
+ return new NextResponse(JSON.stringify(manifest, null, 2), {
10
+ headers: {
11
+ 'content-type': 'application/json; charset=utf-8',
12
+ 'content-disposition': `attachment; filename="${buildPortableExportFilename(manifest)}"`,
13
+ },
14
+ })
15
+ }
7
16
  return NextResponse.json(manifest)
8
17
  }
@@ -124,6 +124,7 @@ describe('delegation-advisory', () => {
124
124
  assert.equal(result.shouldDelegate, true)
125
125
  assert.equal(result.style, 'managerial')
126
126
  assert.equal(result.recommended?.agentId, 'builder')
127
+ assert.equal(result.recommended?.routeKey, 'coding:coding,debugging,implementation:builder')
127
128
  assert.match(advisory.formatDelegationRationale(result.recommended), /coding/i)
128
129
  })
129
130
 
@@ -20,6 +20,7 @@ export interface DelegationTaskProfile {
20
20
  export interface DelegationCandidateFit {
21
21
  agentId: string
22
22
  agentName: string
23
+ routeKey: string
23
24
  score: number
24
25
  availability: 'idle' | 'working' | 'unknown'
25
26
  matchedCapabilities: string[]
@@ -76,6 +77,14 @@ function matchedCapabilities(agentCapabilities: string[] | undefined, requiredCa
76
77
  return requiredCapabilities.filter((entry) => agentSet.has(entry.toLowerCase()))
77
78
  }
78
79
 
80
+ function buildRouteKey(agent: Agent, profile: DelegationTaskProfile): string {
81
+ const required = profile.requiredCapabilities
82
+ .map((entry) => entry.toLowerCase())
83
+ .sort()
84
+ .join(',')
85
+ return [profile.workType, required || 'general', agent.id].join(':')
86
+ }
87
+
79
88
  function roleAdjustment(agent: Agent, profile: DelegationTaskProfile): number {
80
89
  const role = agent.role === 'coordinator' ? 'coordinator' : 'worker'
81
90
  if (profile.workType === 'operations') {
@@ -141,6 +150,7 @@ function buildCandidateFit(
141
150
  return {
142
151
  agentId: agent.id,
143
152
  agentName: agent.name,
153
+ routeKey: buildRouteKey(agent, profile),
144
154
  score,
145
155
  availability,
146
156
  matchedCapabilities: matched,
@@ -30,6 +30,7 @@ import {
30
30
  createReasoningContentMetadata,
31
31
  extractReasoningContentDelta,
32
32
  } from '@/lib/providers/deepseek-reasoning-chat-openai'
33
+ import { StreamingReasoningTagScrubber } from '@/lib/server/chat-execution/reasoning-tag-scrubber'
33
34
 
34
35
  // ---------------------------------------------------------------------------
35
36
  // LangGraph event kind constants
@@ -92,10 +93,24 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
92
93
  let toolEndCount = 0
93
94
  const iterationText = { value: '' }
94
95
  const toolPerfEnds = new Map<string, (extra?: Record<string, unknown>) => number>()
96
+ const reasoningTagScrubber = new StreamingReasoningTagScrubber()
95
97
 
96
98
  /** Interval for progress checkpoint nudges */
97
99
  const PROGRESS_CHECK_INTERVAL = 10
98
100
 
101
+ const emitThinking = (text: string) => {
102
+ if (!text) return
103
+ state.accumulatedThinking += text
104
+ write(`data: ${JSON.stringify({ t: 'thinking', text })}\n\n`)
105
+ }
106
+
107
+ const appendScrubbedText = (text: string) => {
108
+ if (!text) return
109
+ const scrubbed = reasoningTagScrubber.feed(text)
110
+ if (scrubbed.reasoning) emitThinking(scrubbed.reasoning)
111
+ if (scrubbed.visible) state.appendText(scrubbed.visible, iterationText, write)
112
+ }
113
+
99
114
  for await (const event of eventStream) {
100
115
  const kind = event.event
101
116
 
@@ -104,27 +119,24 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
104
119
  const chunk = event.data?.chunk
105
120
  const reasoningDelta = extractReasoningContentDelta(chunk?.additional_kwargs as Record<string, unknown> | undefined)
106
121
  if (reasoningDelta) {
107
- state.accumulatedThinking += reasoningDelta
108
- write(`data: ${JSON.stringify({ t: 'thinking', text: reasoningDelta })}\n\n`)
122
+ emitThinking(reasoningDelta)
109
123
  write(`data: ${JSON.stringify({ t: 'md', text: JSON.stringify(createReasoningContentMetadata(reasoningDelta)) })}\n\n`)
110
124
  }
111
125
  if (chunk?.content) {
112
126
  if (Array.isArray(chunk.content)) {
113
127
  for (const block of chunk.content) {
114
128
  if (block.type === 'thinking' && block.thinking) {
115
- state.accumulatedThinking += block.thinking
116
- write(`data: ${JSON.stringify({ t: 'thinking', text: block.thinking })}\n\n`)
129
+ emitThinking(block.thinking)
117
130
  } else if (typeof block.text === 'string' && block.text.startsWith('[[thinking]]')) {
118
- state.accumulatedThinking += block.text.slice(12)
119
- write(`data: ${JSON.stringify({ t: 'thinking', text: block.text.slice(12) })}\n\n`)
131
+ emitThinking(block.text.slice(12))
120
132
  } else if (block.text) {
121
- state.appendText(block.text, iterationText, write)
133
+ appendScrubbedText(block.text)
122
134
  }
123
135
  }
124
136
  } else {
125
137
  const text = typeof chunk.content === 'string' ? chunk.content : ''
126
138
  if (text) {
127
- state.appendText(text, iterationText, write)
139
+ appendScrubbedText(text)
128
140
  }
129
141
  }
130
142
  }
@@ -351,6 +363,10 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
351
363
  }
352
364
  }
353
365
 
366
+ const finalScrubbed = reasoningTagScrubber.flush()
367
+ if (finalScrubbed.reasoning) emitThinking(finalScrubbed.reasoning)
368
+ if (finalScrubbed.visible) state.appendText(finalScrubbed.visible, iterationText, write)
369
+
354
370
  return {
355
371
  reachedExecutionBoundary,
356
372
  executionFollowthroughReason,
@@ -0,0 +1,117 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { StreamingReasoningTagScrubber } from './reasoning-tag-scrubber'
4
+ import { ChatTurnState } from './chat-turn-state'
5
+ import { processIterationEvents } from './iteration-event-handler'
6
+
7
+ function drive(deltas: string[]) {
8
+ const scrubber = new StreamingReasoningTagScrubber()
9
+ let visible = ''
10
+ let reasoning = ''
11
+ for (const delta of deltas) {
12
+ const result = scrubber.feed(delta)
13
+ visible += result.visible
14
+ reasoning += result.reasoning
15
+ }
16
+ const final = scrubber.flush()
17
+ visible += final.visible
18
+ reasoning += final.reasoning
19
+ return { visible, reasoning }
20
+ }
21
+
22
+ test('strips closed reasoning pairs from visible text and captures reasoning', () => {
23
+ assert.deepEqual(
24
+ drive(['Hello <think>private note</think> world']),
25
+ { visible: 'Hello world', reasoning: 'private note' },
26
+ )
27
+ })
28
+
29
+ test('handles all supported tag variants case-insensitively', () => {
30
+ assert.deepEqual(
31
+ drive(['<THINKING>a</Thinking><reasoning>b</reasoning><thought>c</thought><REASONING_SCRATCHPAD>d</REASONING_SCRATCHPAD>done']),
32
+ { visible: 'done', reasoning: 'abcd' },
33
+ )
34
+ })
35
+
36
+ test('holds split opening tags across stream deltas', () => {
37
+ assert.deepEqual(
38
+ drive(['<', 'think>reasoning</think>', 'done']),
39
+ { visible: 'done', reasoning: 'reasoning' },
40
+ )
41
+ })
42
+
43
+ test('captures reasoning until a split close tag resolves', () => {
44
+ assert.deepEqual(
45
+ drive(['<think>first', ' second</th', 'ink>answer']),
46
+ { visible: 'answer', reasoning: 'first second' },
47
+ )
48
+ })
49
+
50
+ test('does not treat a mid-line prose mention as an open reasoning block', () => {
51
+ const text = 'Use the <think> element for examples'
52
+ assert.deepEqual(drive([text]), { visible: text, reasoning: '' })
53
+ })
54
+
55
+ test('does strip a bounded mid-line pair because it is model reasoning markup', () => {
56
+ assert.deepEqual(
57
+ drive(['Use <think>hidden</think>this answer']),
58
+ { visible: 'Use this answer', reasoning: 'hidden' },
59
+ )
60
+ })
61
+
62
+ test('drops unterminated reasoning content at flush after capturing streamed content', () => {
63
+ assert.deepEqual(
64
+ drive(['Visible\n<think>private reasoning with no close']),
65
+ { visible: 'Visible\n', reasoning: 'private reasoning with no close' },
66
+ )
67
+ })
68
+
69
+ test('reset clears an interrupted reasoning block and buffered partial tags', () => {
70
+ const scrubber = new StreamingReasoningTagScrubber()
71
+ assert.deepEqual(scrubber.feed('<think>hanging'), { visible: '', reasoning: 'hanging' })
72
+ scrubber.reset()
73
+ assert.deepEqual(scrubber.feed('fresh<'), { visible: 'fresh', reasoning: '' })
74
+ scrubber.reset()
75
+ assert.deepEqual(scrubber.feed('content'), { visible: 'content', reasoning: '' })
76
+ })
77
+
78
+ test('processIterationEvents routes split reasoning tags away from visible deltas', async () => {
79
+ async function* eventStream() {
80
+ yield { event: 'on_chat_model_stream', data: { chunk: { content: '<' } } }
81
+ yield { event: 'on_chat_model_stream', data: { chunk: { content: 'think>private' } } }
82
+ yield { event: 'on_chat_model_stream', data: { chunk: { content: ' reasoning</th' } } }
83
+ yield { event: 'on_chat_model_stream', data: { chunk: { content: 'ink>done' } } }
84
+ }
85
+
86
+ const state = new ChatTurnState()
87
+ const writes: string[] = []
88
+ const outcome = await processIterationEvents({
89
+ eventStream: eventStream(),
90
+ state,
91
+ timers: {
92
+ armIdleWatchdog: () => undefined,
93
+ clearIdleWatchdog: () => undefined,
94
+ clearRequiredToolKickoff: () => undefined,
95
+ } as never,
96
+ loopTracker: {} as never,
97
+ toolEventTracker: {} as never,
98
+ session: { id: 'sess_reasoning_tags', provider: 'openai', model: 'gpt-4o-mini' } as never,
99
+ message: 'answer briefly',
100
+ write: (data: string) => writes.push(data),
101
+ sessionExtensions: [],
102
+ boundedExternalExecutionTask: false,
103
+ toolToExtensionMap: {},
104
+ iterationController: new AbortController(),
105
+ })
106
+
107
+ assert.equal(outcome.iterationText, 'done')
108
+ assert.equal(state.fullText, 'done')
109
+ assert.equal(state.accumulatedThinking, 'private reasoning')
110
+
111
+ const rendered = writes.join('')
112
+ assert.match(rendered, /"t":"thinking"/)
113
+ assert.match(rendered, /"text":"private"/)
114
+ assert.match(rendered, /"text":" reasoning"/)
115
+ assert.doesNotMatch(rendered, /"t":"d"[^\n]*private/)
116
+ assert.doesNotMatch(rendered, /<think>|<\/think>/i)
117
+ })
@@ -0,0 +1,219 @@
1
+ export interface ReasoningTagScrubResult {
2
+ visible: string
3
+ reasoning: string
4
+ }
5
+
6
+ const TAG_NAMES = [
7
+ 'think',
8
+ 'thinking',
9
+ 'reasoning',
10
+ 'thought',
11
+ 'reasoning_scratchpad',
12
+ ] as const
13
+
14
+ const OPEN_TAGS = TAG_NAMES.map((name) => `<${name}>`)
15
+ const CLOSE_TAGS = TAG_NAMES.map((name) => `</${name}>`)
16
+ const MAX_TAG_LENGTH = Math.max(...OPEN_TAGS.map((tag) => tag.length), ...CLOSE_TAGS.map((tag) => tag.length))
17
+
18
+ function isWhitespace(text: string): boolean {
19
+ for (let i = 0; i < text.length; i++) {
20
+ const code = text.charCodeAt(i)
21
+ if (code !== 9 && code !== 10 && code !== 11 && code !== 12 && code !== 13 && code !== 32) return false
22
+ }
23
+ return true
24
+ }
25
+
26
+ function matchTagAt(lowerText: string, index: number, tags: string[]): number {
27
+ for (const tag of tags) {
28
+ if (lowerText.startsWith(tag, index)) return tag.length
29
+ }
30
+ return 0
31
+ }
32
+
33
+ function findFirstTag(text: string, tags: string[]): { index: number; length: number } {
34
+ const lowerText = text.toLowerCase()
35
+ let bestIndex = -1
36
+ let bestLength = 0
37
+ for (const tag of tags) {
38
+ const index = lowerText.indexOf(tag)
39
+ if (index !== -1 && (bestIndex === -1 || index < bestIndex)) {
40
+ bestIndex = index
41
+ bestLength = tag.length
42
+ }
43
+ }
44
+ return { index: bestIndex, length: bestLength }
45
+ }
46
+
47
+ function maxPartialSuffix(text: string, tags: string[]): number {
48
+ const lowerText = text.toLowerCase()
49
+ const maxLength = Math.min(MAX_TAG_LENGTH - 1, lowerText.length)
50
+ let best = 0
51
+ for (let length = 1; length <= maxLength; length++) {
52
+ const suffix = lowerText.slice(-length)
53
+ if (tags.some((tag) => tag.startsWith(suffix))) best = length
54
+ }
55
+ return best
56
+ }
57
+
58
+ function stripOrphanCloseTags(text: string): string {
59
+ const lowerText = text.toLowerCase()
60
+ let output = ''
61
+ let index = 0
62
+ while (index < text.length) {
63
+ const closeLength = matchTagAt(lowerText, index, CLOSE_TAGS)
64
+ if (closeLength > 0) {
65
+ index += closeLength
66
+ while (index < text.length && isWhitespace(text[index])) index++
67
+ continue
68
+ }
69
+ output += text[index]
70
+ index++
71
+ }
72
+ return output
73
+ }
74
+
75
+ export class StreamingReasoningTagScrubber {
76
+ private inBlock = false
77
+ private buffer = ''
78
+ private lastVisibleEndedNewline = true
79
+
80
+ reset(): void {
81
+ this.inBlock = false
82
+ this.buffer = ''
83
+ this.lastVisibleEndedNewline = true
84
+ }
85
+
86
+ feed(text: string): ReasoningTagScrubResult {
87
+ if (!text) return { visible: '', reasoning: '' }
88
+ let buffer = this.buffer + text
89
+ this.buffer = ''
90
+ let visible = ''
91
+ let reasoning = ''
92
+
93
+ while (buffer) {
94
+ if (this.inBlock) {
95
+ const close = findFirstTag(buffer, CLOSE_TAGS)
96
+ if (close.index === -1) {
97
+ const held = maxPartialSuffix(buffer, CLOSE_TAGS)
98
+ const captureEnd = held ? buffer.length - held : buffer.length
99
+ reasoning += buffer.slice(0, captureEnd)
100
+ this.buffer = held ? buffer.slice(-held) : ''
101
+ return { visible, reasoning }
102
+ }
103
+ reasoning += buffer.slice(0, close.index)
104
+ buffer = buffer.slice(close.index + close.length)
105
+ this.inBlock = false
106
+ continue
107
+ }
108
+
109
+ const pair = this.findEarliestClosedPair(buffer)
110
+ const open = this.findOpenAtBoundary(buffer)
111
+
112
+ if (pair && (open.index === -1 || pair.start <= open.index)) {
113
+ const preceding = stripOrphanCloseTags(buffer.slice(0, pair.start))
114
+ if (preceding) {
115
+ visible += preceding
116
+ this.lastVisibleEndedNewline = preceding.endsWith('\n')
117
+ }
118
+ reasoning += buffer.slice(pair.contentStart, pair.contentEnd)
119
+ buffer = buffer.slice(pair.end)
120
+ continue
121
+ }
122
+
123
+ if (open.index !== -1) {
124
+ const preceding = stripOrphanCloseTags(buffer.slice(0, open.index))
125
+ if (preceding) {
126
+ visible += preceding
127
+ this.lastVisibleEndedNewline = preceding.endsWith('\n')
128
+ }
129
+ this.inBlock = true
130
+ buffer = buffer.slice(open.index + open.length)
131
+ continue
132
+ }
133
+
134
+ const held = Math.max(maxPartialSuffix(buffer, OPEN_TAGS), maxPartialSuffix(buffer, CLOSE_TAGS))
135
+ const emitText = held ? buffer.slice(0, -held) : buffer
136
+ this.buffer = held ? buffer.slice(-held) : ''
137
+ if (emitText) {
138
+ const cleaned = stripOrphanCloseTags(emitText)
139
+ if (cleaned) {
140
+ visible += cleaned
141
+ this.lastVisibleEndedNewline = cleaned.endsWith('\n')
142
+ }
143
+ }
144
+ return { visible, reasoning }
145
+ }
146
+
147
+ return { visible, reasoning }
148
+ }
149
+
150
+ flush(): ReasoningTagScrubResult {
151
+ if (this.inBlock) {
152
+ this.inBlock = false
153
+ this.buffer = ''
154
+ return { visible: '', reasoning: '' }
155
+ }
156
+ const tail = stripOrphanCloseTags(this.buffer)
157
+ this.buffer = ''
158
+ if (tail) this.lastVisibleEndedNewline = tail.endsWith('\n')
159
+ return { visible: tail, reasoning: '' }
160
+ }
161
+
162
+ private findEarliestClosedPair(text: string): {
163
+ start: number
164
+ contentStart: number
165
+ contentEnd: number
166
+ end: number
167
+ } | null {
168
+ const lowerText = text.toLowerCase()
169
+ let best: { start: number; contentStart: number; contentEnd: number; end: number } | null = null
170
+ for (const name of TAG_NAMES) {
171
+ const openTag = `<${name}>`
172
+ const closeTag = `</${name}>`
173
+ let searchFrom = 0
174
+ while (searchFrom < lowerText.length) {
175
+ const start = lowerText.indexOf(openTag, searchFrom)
176
+ if (start === -1) break
177
+ const contentStart = start + openTag.length
178
+ const contentEnd = lowerText.indexOf(closeTag, contentStart)
179
+ if (contentEnd !== -1) {
180
+ const candidate = {
181
+ start,
182
+ contentStart,
183
+ contentEnd,
184
+ end: contentEnd + closeTag.length,
185
+ }
186
+ if (!best || candidate.start < best.start) best = candidate
187
+ break
188
+ }
189
+ searchFrom = contentStart
190
+ }
191
+ }
192
+ return best
193
+ }
194
+
195
+ private findOpenAtBoundary(text: string): { index: number; length: number } {
196
+ const lowerText = text.toLowerCase()
197
+ let best = { index: -1, length: 0 }
198
+ for (const openTag of OPEN_TAGS) {
199
+ let searchFrom = 0
200
+ while (searchFrom < lowerText.length) {
201
+ const index = lowerText.indexOf(openTag, searchFrom)
202
+ if (index === -1) break
203
+ if (this.isBlockBoundary(text.slice(0, index))) {
204
+ if (best.index === -1 || index < best.index) best = { index, length: openTag.length }
205
+ break
206
+ }
207
+ searchFrom = index + openTag.length
208
+ }
209
+ }
210
+ return best
211
+ }
212
+
213
+ private isBlockBoundary(preceding: string): boolean {
214
+ if (!preceding) return this.lastVisibleEndedNewline
215
+ const lastNewline = Math.max(preceding.lastIndexOf('\n'), preceding.lastIndexOf('\r'))
216
+ if (lastNewline !== -1) return isWhitespace(preceding.slice(lastNewline + 1))
217
+ return this.lastVisibleEndedNewline && isWhitespace(preceding)
218
+ }
219
+ }
@@ -259,3 +259,48 @@ test('runKnowledgeHygieneMaintenance keeps same-content sources separate when vi
259
259
  assert.equal(output.agent1Hits, 2)
260
260
  assert.equal(output.agent2Hits, 1)
261
261
  })
262
+
263
+ test('pruneArchivedKnowledgeSources deletes old archived sources and records prune actions', () => {
264
+ const output = runWithTempDataDir<{
265
+ pruned: number
266
+ sourceStillExists: boolean
267
+ hitCount: number
268
+ actionKind: string | null
269
+ }>(`
270
+ const knowledgeMod = await import('./src/lib/server/knowledge-sources.ts')
271
+ const storageMod = await import('./src/lib/server/storage.ts')
272
+ const knowledge = knowledgeMod.default || knowledgeMod
273
+ const storage = storageMod.default || storageMod
274
+
275
+ const source = await knowledge.createKnowledgeSource({
276
+ kind: 'manual',
277
+ title: 'Old Archived Notes',
278
+ content: 'prune candidate payload',
279
+ })
280
+
281
+ await knowledge.archiveKnowledgeSource(source.source.id, { reason: 'obsolete' })
282
+ const oldTimestamp = Date.now() - 60 * 24 * 60 * 60 * 1000
283
+ storage.patchKnowledgeSource(source.source.id, (current) => current ? {
284
+ ...current,
285
+ archivedAt: oldTimestamp,
286
+ maintenanceUpdatedAt: oldTimestamp,
287
+ updatedAt: oldTimestamp,
288
+ } : null)
289
+
290
+ const result = await knowledge.pruneArchivedKnowledgeSources({ olderThanDays: 30 })
291
+ const summary = await knowledge.getKnowledgeHygieneSummary()
292
+ const hits = await knowledge.searchKnowledgeHits({ query: 'candidate', includeArchived: true })
293
+
294
+ console.log(JSON.stringify({
295
+ pruned: result.pruned,
296
+ sourceStillExists: Boolean(storage.loadKnowledgeSource(source.source.id)),
297
+ hitCount: hits.length,
298
+ actionKind: summary.recentActions.find((action) => action.sourceId === source.source.id)?.kind || null,
299
+ }))
300
+ `, { prefix: 'swarmclaw-knowledge-prune-' })
301
+
302
+ assert.equal(output.pruned, 1)
303
+ assert.equal(output.sourceStillExists, false)
304
+ assert.equal(output.hitCount, 0)
305
+ assert.equal(output.actionKind, 'prune')
306
+ })
@@ -31,6 +31,7 @@ import {
31
31
  import { onNextIdleWindow } from '@/lib/server/runtime/idle-window'
32
32
 
33
33
  const KNOWLEDGE_STALE_AFTER_MS = 1000 * 60 * 60 * 24 * 14
34
+ const DEFAULT_PRUNE_ARCHIVED_AFTER_DAYS = 30
34
35
  const CHUNK_TARGET_CHARS = 2200
35
36
  const CHUNK_OVERLAP_CHARS = 320
36
37
  const MAX_KNOWLEDGE_SCAN = 10_000
@@ -1049,6 +1050,38 @@ export async function supersedeKnowledgeSource(
1049
1050
  return getKnowledgeSourceDetail(updated.id)
1050
1051
  }
1051
1052
 
1053
+ export async function pruneArchivedKnowledgeSources(input?: {
1054
+ olderThanDays?: number | null
1055
+ now?: number
1056
+ }): Promise<{ pruned: number; sourceIds: string[] }> {
1057
+ await ensureLegacyKnowledgeBackfill()
1058
+ const now = typeof input?.now === 'number' && Number.isFinite(input.now) ? input.now : Date.now()
1059
+ const olderThanDays = typeof input?.olderThanDays === 'number' && Number.isFinite(input.olderThanDays)
1060
+ ? Math.max(1, Math.trunc(input.olderThanDays))
1061
+ : DEFAULT_PRUNE_ARCHIVED_AFTER_DAYS
1062
+ const cutoff = now - olderThanDays * 24 * 60 * 60 * 1000
1063
+ const sourceIds: string[] = []
1064
+
1065
+ for (const source of listStoredSources()) {
1066
+ if (!sourceIsArchived(source) && !sourceIsSuperseded(source)) continue
1067
+ const lifecycleAt = source.archivedAt || source.maintenanceUpdatedAt || source.updatedAt
1068
+ if (lifecycleAt > cutoff) continue
1069
+ const title = source.title
1070
+ const removed = await deleteKnowledgeSource(source.id)
1071
+ if (!removed) continue
1072
+ sourceIds.push(source.id)
1073
+ recordMaintenanceAction({
1074
+ kind: 'prune',
1075
+ sourceId: source.id,
1076
+ relatedSourceId: source.duplicateOfSourceId || source.supersededBySourceId || null,
1077
+ summary: `Pruned ${title}`,
1078
+ createdAt: now,
1079
+ })
1080
+ }
1081
+
1082
+ return { pruned: sourceIds.length, sourceIds }
1083
+ }
1084
+
1052
1085
  function sameSourceOrigin(left: KnowledgeSource, right: KnowledgeSource): boolean {
1053
1086
  if (left.id === right.id) return false
1054
1087
  if (left.sourceUrl && right.sourceUrl) return left.sourceUrl === right.sourceUrl
@@ -34,6 +34,16 @@ export interface PortableManifest {
34
34
  extensions?: PortableExtensionRef[]
35
35
  }
36
36
 
37
+ export function buildPortableExportFilename(manifest: Pick<PortableManifest, 'exportedAt'> = { exportedAt: new Date().toISOString() }): string {
38
+ const safeStamp = manifest.exportedAt
39
+ .replaceAll(':', '')
40
+ .replaceAll('.', '')
41
+ .replaceAll('-', '')
42
+ .replace('T', '-')
43
+ .replace('Z', 'Z')
44
+ return `swarmclaw-export-${safeStamp}.json`
45
+ }
46
+
37
47
  export type PortableAgent = Omit<Agent,
38
48
  | 'id' | 'credentialId' | 'fallbackCredentialIds' | 'apiEndpoint'
39
49
  | 'threadSessionId' | 'lastUsedAt' | 'totalCost' | 'trashedAt'
@@ -43,6 +43,7 @@ import {
43
43
  buildDelegationTaskProfile,
44
44
  formatDelegationRationale,
45
45
  resolveDelegationAdvisory,
46
+ type DelegationWorkType,
46
47
  } from '@/lib/server/agents/delegation-advisory'
47
48
  import type { ToolBuildContext } from './context'
48
49
  import { normalizeToolInputArgs } from './normalize-tool-args'
@@ -102,6 +103,17 @@ function buildTaskDelegationText(parsed: Record<string, unknown>): string {
102
103
  return [title, description].filter(Boolean).join('\n\n').trim()
103
104
  }
104
105
 
106
+ function normalizeDelegationWorkType(value: unknown): DelegationWorkType | null {
107
+ return value === 'coding'
108
+ || value === 'research'
109
+ || value === 'writing'
110
+ || value === 'review'
111
+ || value === 'operations'
112
+ || value === 'general'
113
+ ? value
114
+ : null
115
+ }
116
+
105
117
  async function resolveManagedTaskDelegation(params: {
106
118
  parsed: Record<string, unknown>
107
119
  agents: ReturnType<typeof loadAgents>
@@ -122,9 +134,11 @@ async function resolveManagedTaskDelegation(params: {
122
134
  return { assignedAgentId: params.assignedAgentId, advisory: null }
123
135
  }
124
136
 
137
+ const decisionStartedAt = Date.now()
125
138
  const explicitCapabilities = normalizeStringList(params.parsed.requiredCapabilities)
139
+ const explicitWorkType = normalizeDelegationWorkType(params.parsed.workType)
126
140
  const classificationText = buildTaskDelegationText(params.parsed)
127
- const classification = (!explicitCapabilities.length && classificationText && params.ctx?.sessionId)
141
+ const classification = (!explicitCapabilities.length && !explicitWorkType && classificationText && params.ctx?.sessionId)
128
142
  ? await classifyMessage({
129
143
  sessionId: params.ctx.sessionId,
130
144
  agentId: currentAgentId,
@@ -134,6 +148,7 @@ async function resolveManagedTaskDelegation(params: {
134
148
 
135
149
  const profile = buildDelegationTaskProfile({
136
150
  classification,
151
+ workType: explicitWorkType,
137
152
  requiredCapabilities: explicitCapabilities,
138
153
  })
139
154
  if (!profile.substantial) {
@@ -167,10 +182,18 @@ async function resolveManagedTaskDelegation(params: {
167
182
  advisory: {
168
183
  recommendedAgentId: recommended.agentId,
169
184
  recommendedAgentName: recommended.agentName,
185
+ routeKey: recommended.routeKey,
170
186
  rationale: formatDelegationRationale(recommended),
171
187
  workType: profile.workType,
172
188
  requiredCapabilities: profile.requiredCapabilities,
173
189
  autoAssigned,
190
+ routingMode: explicitCapabilities.length || explicitWorkType
191
+ ? 'deterministic'
192
+ : classification
193
+ ? 'classified'
194
+ : 'default',
195
+ decisionLatencyMs: Date.now() - decisionStartedAt,
196
+ score: Number(recommended.score.toFixed(3)),
174
197
  },
175
198
  }
176
199
  }
@@ -465,7 +488,7 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
465
488
  }
466
489
  description += '\n\nCreate/update calls accept either `data` as a JSON string or direct top-level fields like `title`, `description`, `status`, `agentId`, and `projectId`.'
467
490
  if (canAssignOtherAgents) {
468
- description += '\n\nWhen you omit an assignee, the runtime may auto-assign the task to a materially better-fit teammate based on `requiredCapabilities` or the classified work type. If you set an explicit assignee, it is respected in v1, but the response may include `delegationAdvisory` when another teammate is a better fit.'
491
+ description += '\n\nWhen you omit an assignee, the runtime may auto-assign the task to a materially better-fit teammate based on `requiredCapabilities`, explicit `workType`, or the classified work type. Use `workType:"coding"|"research"|"writing"|"review"|"operations"|"general"` for deterministic routing without a classifier call. If you set an explicit assignee, it is respected in v1, but the response may include `delegationAdvisory` when another teammate is a better fit.'
469
492
  }
470
493
  description += '\n\nFor follow-up work, set `continueFromTaskId` (or `followUpToTaskId`) to a prior task ID. The new task will inherit the predecessor\'s project/agent/session context, block on that task by default, and reuse its execution session when possible.'
471
494
  if (ctx?.projectId) {
@@ -172,7 +172,7 @@ describe('manage_tasks tool', () => {
172
172
  action: 'create',
173
173
  title: 'Implement API integration',
174
174
  description: 'Build the API client and fix the failing tests.',
175
- requiredCapabilities: ['coding'],
175
+ workType: 'coding',
176
176
  status: 'backlog',
177
177
  })
178
178
 
@@ -185,7 +185,11 @@ describe('manage_tasks tool', () => {
185
185
  assert.equal(output.stored.agentId, 'builder')
186
186
  assert.equal(output.response.delegationAdvisory.autoAssigned, true)
187
187
  assert.equal(output.response.delegationAdvisory.recommendedAgentId, 'builder')
188
- assert.deepEqual(output.response.delegationAdvisory.requiredCapabilities, ['coding'])
188
+ assert.deepEqual(output.response.delegationAdvisory.requiredCapabilities, ['coding', 'implementation', 'debugging'])
189
+ assert.equal(output.response.delegationAdvisory.routingMode, 'deterministic')
190
+ assert.match(output.response.delegationAdvisory.routeKey, /^coding:coding,debugging,implementation:builder$/)
191
+ assert.equal(typeof output.response.delegationAdvisory.decisionLatencyMs, 'number')
192
+ assert.equal(output.stored.workflowStateId, 'in_progress')
189
193
  })
190
194
 
191
195
  it('keeps an explicit assignee but returns delegation advisory when another teammate is a better fit', () => {
@@ -262,5 +266,6 @@ describe('manage_tasks tool', () => {
262
266
  assert.equal(output.stored.agentId, 'researcher')
263
267
  assert.equal(output.response.delegationAdvisory.autoAssigned, false)
264
268
  assert.equal(output.response.delegationAdvisory.recommendedAgentId, 'builder')
269
+ assert.equal(output.response.delegationAdvisory.routingMode, 'deterministic')
265
270
  })
266
271
  })
@@ -28,7 +28,11 @@ import {
28
28
  type PrepareTaskExecutionWorkspaceOptions,
29
29
  } from '@/lib/server/tasks/task-execution-workspace'
30
30
  import { resolveTaskAgentFromDescription } from '@/lib/server/tasks/task-mention'
31
- import { applyTaskPatch, prepareTaskCreation } from '@/lib/server/tasks/task-service'
31
+ import {
32
+ applyTaskPatch,
33
+ prepareTaskCreation,
34
+ resolveAssignmentWorkflowStateTransition,
35
+ } from '@/lib/server/tasks/task-service'
32
36
  import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
33
37
  import { queueSwarmFeedTaskCompletionWake } from '@/lib/server/swarmfeed-runtime'
34
38
  import { notify } from '@/lib/server/ws-hub'
@@ -427,12 +431,25 @@ export function bulkUpdateTasksFromRoute(body: Record<string, unknown>): Service
427
431
  }
428
432
  }
429
433
  if ('agentId' in body) {
434
+ const previousAgentId = tasks[id].agentId
435
+ const previousWorkflowStateId = tasks[id].workflowStateId || null
430
436
  tasks[id].agentId = body.agentId === null ? '' : String(body.agentId)
437
+ const workflowTransition = resolveAssignmentWorkflowStateTransition({
438
+ previousAgentId,
439
+ nextAgentId: tasks[id].agentId,
440
+ previousWorkflowStateId,
441
+ explicitWorkflowState: Object.prototype.hasOwnProperty.call(body, 'workflowStateId'),
442
+ })
443
+ if (workflowTransition) tasks[id].workflowStateId = workflowTransition
431
444
  }
432
445
  if ('projectId' in body) {
433
446
  if (body.projectId === null) delete tasks[id].projectId
434
447
  else tasks[id].projectId = String(body.projectId)
435
448
  }
449
+ if ('workflowStateId' in body) {
450
+ if (body.workflowStateId === null) delete tasks[id].workflowStateId
451
+ else tasks[id].workflowStateId = String(body.workflowStateId)
452
+ }
436
453
  tasks[id].updatedAt = Date.now()
437
454
  updated += 1
438
455
  results.push(id)
@@ -3,8 +3,11 @@ import { describe, it } from 'node:test'
3
3
 
4
4
  import { computeTaskFingerprint } from '@/lib/task-dedupe'
5
5
  import type { BoardTask } from '@/types'
6
-
7
- import { applyTaskPatch, prepareTaskCreation } from '@/lib/server/tasks/task-service'
6
+ import {
7
+ applyTaskPatch,
8
+ prepareTaskCreation,
9
+ resolveAssignmentWorkflowStateTransition,
10
+ } from '@/lib/server/tasks/task-service'
8
11
 
9
12
  function makeTask(overrides: Partial<BoardTask> = {}): BoardTask {
10
13
  return {
@@ -106,3 +109,58 @@ describe('task service helpers', () => {
106
109
  assert.equal(task.status, 'queued')
107
110
  })
108
111
  })
112
+
113
+ describe('task-service assignment workflow transitions', () => {
114
+ it('moves newly assigned backlog workflow tasks to in_progress without queueing runtime work', () => {
115
+ const task = makeTask({ agentId: '', workflowStateId: 'backlog' })
116
+ applyTaskPatch({
117
+ task,
118
+ patch: { agentId: 'agent-builder' },
119
+ now: 100,
120
+ })
121
+
122
+ assert.equal(task.agentId, 'agent-builder')
123
+ assert.equal(task.status, 'backlog')
124
+ assert.equal(task.workflowStateId, 'in_progress')
125
+ assert.equal(task.updatedAt, 100)
126
+ })
127
+
128
+ it('preserves explicit workflow state patches', () => {
129
+ const task = makeTask({ workflowStateId: 'todo' })
130
+ applyTaskPatch({
131
+ task,
132
+ patch: { agentId: 'agent-builder', workflowStateId: 'needs_review' },
133
+ now: 100,
134
+ })
135
+
136
+ assert.equal(task.workflowStateId, 'needs_review')
137
+ })
138
+
139
+ it('seeds assigned task creation into the in_progress workflow lane', () => {
140
+ const prepared = prepareTaskCreation({
141
+ input: {
142
+ title: 'Build the client',
143
+ description: '',
144
+ agentId: 'builder',
145
+ },
146
+ tasks: {},
147
+ now: 200,
148
+ })
149
+
150
+ assert.equal(prepared.ok, true)
151
+ if (prepared.ok) {
152
+ assert.equal(prepared.task.status, 'backlog')
153
+ assert.equal(prepared.task.workflowStateId, 'in_progress')
154
+ }
155
+ })
156
+
157
+ it('leaves already-started workflow states alone', () => {
158
+ const next = resolveAssignmentWorkflowStateTransition({
159
+ previousAgentId: '',
160
+ nextAgentId: 'agent-builder',
161
+ previousWorkflowStateId: 'needs_review',
162
+ })
163
+
164
+ assert.equal(next, null)
165
+ })
166
+ })
@@ -20,6 +20,28 @@ const TASK_STATUS_VALUES = new Set([
20
20
  'archived',
21
21
  ])
22
22
 
23
+ const ASSIGNMENT_START_WORKFLOW_STATES = new Set(['triage', 'backlog', 'todo'])
24
+
25
+ function hasOwn(record: Record<string, unknown>, key: string): boolean {
26
+ return Object.prototype.hasOwnProperty.call(record, key)
27
+ }
28
+
29
+ export function resolveAssignmentWorkflowStateTransition(params: {
30
+ previousAgentId?: string | null
31
+ nextAgentId?: string | null
32
+ previousWorkflowStateId?: string | null
33
+ explicitWorkflowState?: boolean
34
+ }): string | null {
35
+ if (params.explicitWorkflowState) return null
36
+ const previousAgentId = typeof params.previousAgentId === 'string' ? params.previousAgentId.trim() : ''
37
+ const nextAgentId = typeof params.nextAgentId === 'string' ? params.nextAgentId.trim() : ''
38
+ if (!nextAgentId || nextAgentId === previousAgentId) return null
39
+ const currentWorkflow = typeof params.previousWorkflowStateId === 'string' && params.previousWorkflowStateId.trim()
40
+ ? params.previousWorkflowStateId.trim()
41
+ : 'backlog'
42
+ return ASSIGNMENT_START_WORKFLOW_STATES.has(currentWorkflow) ? 'in_progress' : null
43
+ }
44
+
23
45
  export function deriveTaskTitle(input: { title?: unknown; description?: unknown }): string {
24
46
  const explicit = typeof input.title === 'string' ? input.title.replace(/\s+/g, ' ').trim() : ''
25
47
  if (explicit && !/^untitled task$/i.test(explicit)) return explicit.slice(0, 120)
@@ -157,6 +179,7 @@ export type PrepareTaskCreationResult =
157
179
 
158
180
  export function prepareTaskCreation(options: PrepareTaskCreationOptions): PrepareTaskCreationResult {
159
181
  const seed = options.seed ? { ...options.seed } : {}
182
+ delete seed.workType
160
183
  const explicitTitle = typeof options.input.title === 'string' ? options.input.title.trim() : ''
161
184
  const derivedTitle = deriveTaskTitle(options.input)
162
185
  const nextTitle = options.deriveTitleFromDescription
@@ -194,6 +217,9 @@ export function prepareTaskCreation(options: PrepareTaskCreationOptions): Prepar
194
217
  qualityGate,
195
218
  },
196
219
  })
220
+ if (!task.workflowStateId && task.agentId) {
221
+ task.workflowStateId = 'in_progress'
222
+ }
197
223
  task.fingerprint = computeTaskFingerprint(task.title || 'Untitled Task', task.agentId || '')
198
224
 
199
225
  const duplicate = task.fingerprint
@@ -227,6 +253,8 @@ export interface ApplyTaskPatchOptions {
227
253
 
228
254
  export function applyTaskPatch(options: ApplyTaskPatchOptions): BoardTask {
229
255
  const nextPatch = { ...options.patch }
256
+ const previousAgentId = options.task.agentId
257
+ const previousWorkflowStateId = options.task.workflowStateId || null
230
258
  if (Object.prototype.hasOwnProperty.call(nextPatch, 'status')) {
231
259
  const normalized = normalizeTaskStatusInput(nextPatch.status, options.task.status)
232
260
  if (normalized) nextPatch.status = normalized
@@ -240,6 +268,13 @@ export function applyTaskPatch(options: ApplyTaskPatchOptions): BoardTask {
240
268
 
241
269
  Object.assign(options.task, nextPatch, { updatedAt: options.now })
242
270
  if (options.clearProjectIdWhenNull && nextPatch.projectId === null) delete options.task.projectId
271
+ const workflowTransition = resolveAssignmentWorkflowStateTransition({
272
+ previousAgentId,
273
+ nextAgentId: options.task.agentId,
274
+ previousWorkflowStateId,
275
+ explicitWorkflowState: hasOwn(nextPatch, 'workflowStateId'),
276
+ })
277
+ if (workflowTransition) options.task.workflowStateId = workflowTransition
243
278
 
244
279
  if (options.task.status === 'completed') {
245
280
  const { validation } = refreshTaskCompletionValidation(options.task, options.settings)
@@ -200,6 +200,8 @@ export const TaskCreateSchema = z.object({
200
200
  retryBackoffSec: z.number().optional(),
201
201
  priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
202
202
  dueAt: z.number().nullable().optional(),
203
+ workflowStateId: z.string().nullable().optional(),
204
+ requiredCapabilities: z.array(z.string()).optional(),
203
205
  provisionWorkspace: z.boolean().optional(),
204
206
  previewLinks: z.array(z.object({
205
207
  id: z.string().optional(),
package/src/types/misc.ts CHANGED
@@ -486,7 +486,7 @@ export interface MemoryEntry {
486
486
 
487
487
  export type KnowledgeSourceKind = 'manual' | 'file' | 'url'
488
488
  export type KnowledgeSyncStatus = 'ready' | 'syncing' | 'error'
489
- export type KnowledgeHygieneActionKind = 'sync' | 'reindex' | 'archive' | 'restore' | 'supersede'
489
+ export type KnowledgeHygieneActionKind = 'sync' | 'reindex' | 'archive' | 'restore' | 'supersede' | 'prune'
490
490
  export type KnowledgeHygieneFindingKind = 'stale' | 'duplicate' | 'overlap' | 'broken' | 'archived' | 'superseded'
491
491
 
492
492
  export interface KnowledgeCitation {