@goodandready/dsh-kanban 0.2.20 → 0.2.22

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/lib/launcher.js CHANGED
@@ -12,11 +12,38 @@
12
12
 
13
13
  import { isAbsolute, join, resolve, sep } from 'node:path'
14
14
  import { randomUUID } from 'node:crypto'
15
- import { createTaskWorktree, registerDshWorkspace } from './worktree.js'
15
+ import { createTaskWorktree, registerDshWorkspace, isGitRepo } from './worktree.js'
16
16
  import { formatHandoverPreamble, parseProgressDump } from './progress-dump.js'
17
17
 
18
18
  export const PERMISSION_RANKS = { 'read-only': 0, 'workspace-write': 1, 'danger-full-access': 2 }
19
19
 
20
+ const activeReservations = new Set()
21
+
22
+ export function reserveSessionSlot(taskId) {
23
+ if (taskId) activeReservations.add(taskId)
24
+ }
25
+
26
+ export function releaseSessionSlot(taskId) {
27
+ if (taskId) activeReservations.delete(taskId)
28
+ }
29
+
30
+ export function verifyPermissionGate({ task, permission, config }) {
31
+ if (!config?.permissionGateEnabled) return
32
+ const perm = permission || task?.permission || config?.sessionDefaultPermission || 'read-only'
33
+ const defaultPerm = config?.sessionDefaultPermission || 'read-only'
34
+ const permRank = PERMISSION_RANKS[perm] ?? Infinity
35
+ const defaultRank = PERMISSION_RANKS[defaultPerm] ?? 0
36
+ const confirmedTier = task?.permissionConfirmedTier || (task?.permissionConfirmedAt ? task?.permission : undefined)
37
+ const confirmedRank = confirmedTier ? (PERMISSION_RANKS[confirmedTier] ?? -1) : -1
38
+
39
+ if (permRank > defaultRank && permRank > confirmedRank) {
40
+ const err = new Error('Permission confirmation required')
41
+ err.key = 'permission-unconfirmed'
42
+ err.status = 403
43
+ throw err
44
+ }
45
+ }
46
+
20
47
  /**
21
48
  * Tail instructions for local board tasks.
22
49
  *
@@ -134,20 +161,44 @@ export function applyPermission({ permissions, agent, name }) {
134
161
  export async function obtainAgent({
135
162
  agents, task, config, provider, model, mintSessionId, cwdOf, logger,
136
163
  agentPreset, permission, permissions, gitRunner, workspaceRegistry,
164
+ reasoningEffort,
137
165
  }) {
166
+ const chosenPerm = permission || task?.permission || ''
167
+ const effort = reasoningEffort || task?.reasoningEffort || undefined
168
+ const agentOptions = { provider, model, ...(effort ? { reasoningEffort: effort } : {}) }
169
+
138
170
  const known = typeof task?.sessionId === 'string' && task.sessionId !== '' ? task.sessionId : undefined
139
171
 
140
172
  if (known !== undefined) {
141
173
  // Live agent — reuse active session.
142
174
  let live
143
175
  try { live = agents.get(known) } catch { live = undefined }
144
- if (live !== undefined) return { agent: live, sessionId: known, mode: 'opened' }
176
+ if (live !== undefined) {
177
+ const access = applyPermission({ permissions, agent: live, name: chosenPerm })
178
+ if (access.error !== undefined) {
179
+ const error = new Error('access tier not set')
180
+ error.key = access.error
181
+ error.status = 409
182
+ throw error
183
+ }
184
+ return { agent: live, sessionId: known, mode: 'opened' }
185
+ }
145
186
 
146
187
  // Session suspended; attempt resume.
147
188
  try {
148
- const handle = await agents.resume({ resumeSessionId: known, agentOptions: { provider, model } })
189
+ const handle = await agents.resume({ resumeSessionId: known, agentOptions })
190
+ const access = applyPermission({ permissions, agent: handle.agent, name: chosenPerm })
191
+ if (access.error !== undefined) {
192
+ const error = new Error('access tier not set')
193
+ error.key = access.error
194
+ error.status = 409
195
+ throw error
196
+ }
149
197
  return { agent: handle.agent, sessionId: handle.agent.session.id, mode: 'resumed' }
150
198
  } catch (error) {
199
+ if (error?.key === 'permission-refused' || error?.key === 'permission-unavailable' || error?.status === 409) {
200
+ throw error
201
+ }
151
202
  logger?.warn?.(`dsh-kanban: session ${known} resume failed: ${error?.message}`)
152
203
  }
153
204
  }
@@ -159,30 +210,41 @@ export async function obtainAgent({
159
210
  let workspaceId = task?.workspaceId || undefined
160
211
  let worktreeCreated = false
161
212
 
162
- if (config?.worktreeIsolation !== false && task?.repo) {
213
+ const isGit = await isGitRepo(cwd, gitRunner)
214
+ if (config?.worktreeIsolation !== false && isGit) {
215
+ let created
163
216
  try {
164
- const created = await createTaskWorktree({
217
+ created = await createTaskWorktree({
165
218
  task,
166
219
  repoDir: cwd,
167
220
  config,
168
221
  gitRunner,
169
222
  cwdOf,
170
223
  })
171
- if (created) {
172
- worktreePath = created.worktreePath
173
- branchName = created.branchName
174
- worktreeCreated = created.created === true
175
-
176
- if (workspaceRegistry) {
177
- workspaceId = await registerDshWorkspace({
178
- workspaceRegistry,
179
- worktreePath,
180
- title: `${task.owner ? task.owner + '/' : ''}${task.repo}#${task.issueNumber || task.id.slice(0, 8)}: ${task.title}`,
181
- })
182
- }
183
- }
184
224
  } catch (err) {
185
- logger?.warn?.(`dsh-kanban: failed to create worktree for ${task.id}: ${err.message}`)
225
+ const error = new Error(`worktree-creation-failed: ${err.message}`)
226
+ error.key = 'worktree-creation-failed'
227
+ error.status = 500
228
+ throw error
229
+ }
230
+
231
+ if (!created?.worktreePath) {
232
+ const error = new Error('worktree-creation-failed')
233
+ error.key = 'worktree-creation-failed'
234
+ error.status = 500
235
+ throw error
236
+ }
237
+
238
+ worktreePath = created.worktreePath
239
+ branchName = created.branchName
240
+ worktreeCreated = created.created === true
241
+
242
+ if (workspaceRegistry) {
243
+ workspaceId = await registerDshWorkspace({
244
+ workspaceRegistry,
245
+ worktreePath,
246
+ title: `${task.owner ? task.owner + '/' : ''}${task.repo}#${task.issueNumber || task.id.slice(0, 8)}: ${task.title}`,
247
+ })
186
248
  }
187
249
  }
188
250
 
@@ -197,9 +259,9 @@ export async function obtainAgent({
197
259
  sessionId: mintSessionId(),
198
260
  // Profile is set during session creation.
199
261
  meta,
200
- agentOptions: { provider, model },
262
+ agentOptions,
201
263
  })
202
- const access = applyPermission({ permissions, agent: handle.agent, name: permission })
264
+ const access = applyPermission({ permissions, agent: handle.agent, name: chosenPerm })
203
265
  if (access.error !== undefined) {
204
266
  // Session already initialized; preserve configured permissions.
205
267
  try { handle.dispose?.() } catch { /* cleanup is secondary to rejection */ }
@@ -265,9 +327,11 @@ export async function runTask({
265
327
  agents, store, task, config, provider, model,
266
328
  mintSessionId, createMessage, cwdOf, logger, text,
267
329
  agentPreset, permission, permissions, claimAs,
268
- gitRunner, workspaceRegistry, resume = false,
330
+ gitRunner, workspaceRegistry, resume = false, reasoningEffort,
269
331
  }) {
270
- // Concurrency limit check (#214)
332
+ const effort = reasoningEffort || task?.reasoningEffort || undefined
333
+
334
+ // Concurrency limit check (#214, #330)
271
335
  const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
272
336
  if (maxConcurrent > 0) {
273
337
  const activeCount = countActiveSessions({ store, agents })
@@ -275,7 +339,16 @@ export async function runTask({
275
339
  const isAlreadyRunning = task.sessionId && agents?.get?.(task.sessionId)?.status === 'running'
276
340
  if (!isAlreadyRunning && activeCount >= maxConcurrent) {
277
341
  const now = Date.now()
278
- store.updateTask(task.id, { queuedAt: task.queuedAt > 0 ? task.queuedAt : now })
342
+ store.updateTask(task.id, {
343
+ queuedAt: task.queuedAt > 0 ? task.queuedAt : now,
344
+ ...(provider ? { provider } : {}),
345
+ ...(model ? { model } : {}),
346
+ ...(permission ? { permission } : {}),
347
+ ...(effort ? { reasoningEffort: effort } : {}),
348
+ queuedRequest: JSON.stringify({
349
+ provider, model, permission, reasoningEffort: effort, agentPreset, text,
350
+ }),
351
+ })
279
352
  store.addTransition({
280
353
  taskId: task.id,
281
354
  fromCol: task.column,
@@ -286,26 +359,23 @@ export async function runTask({
286
359
  return { mode: 'queued', queued: true, taskId: task.id, activeCount, maxConcurrent }
287
360
  }
288
361
  }
289
- if (config?.permissionGateEnabled) {
290
- const perm = permission || task.permission || 'read-only'
291
- const defaultPerm = config?.sessionDefaultPermission || 'read-only'
292
- if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0)) {
293
- if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
294
- const err = new Error('Permission confirmation required')
295
- err.key = 'permission-unconfirmed'
296
- err.status = 403
297
- throw err
298
- }
299
- }
300
- }
301
362
 
302
- const taken = await obtainAgent({
303
- agents, task, config, provider, model, mintSessionId, cwdOf, logger,
304
- agentPreset, permission, permissions, gitRunner, workspaceRegistry,
305
- })
363
+ verifyPermissionGate({ task, permission, config })
306
364
 
307
- if (task.queuedAt > 0) {
308
- store.updateTask(task.id, { queuedAt: 0 })
365
+ reserveSessionSlot(task.id)
366
+ let taken
367
+ try {
368
+ taken = await obtainAgent({
369
+ agents, task, config, provider, model, mintSessionId, cwdOf, logger,
370
+ agentPreset, permission, permissions, gitRunner, workspaceRegistry,
371
+ reasoningEffort: effort,
372
+ })
373
+ } finally {
374
+ releaseSessionSlot(task.id)
375
+ }
376
+
377
+ if (task.queuedAt > 0 || task.queuedRequest) {
378
+ store.updateTask(task.id, { queuedAt: 0, queuedRequest: '' })
309
379
  }
310
380
 
311
381
  // Record run in task execution history
@@ -460,10 +530,31 @@ export async function runBatch({
460
530
  const list = (tasks ?? []).filter((task) => task !== undefined)
461
531
  if (list.length === 0) return { error: 'nothing-picked', status: 400 }
462
532
 
463
- const taken = await obtainAgent({
464
- agents, task: list[0], config, provider, model, mintSessionId, cwdOf, logger,
465
- agentPreset, permission, permissions,
466
- })
533
+ for (const t of list) {
534
+ verifyPermissionGate({ task: t, permission, config })
535
+ }
536
+
537
+ const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
538
+ if (maxConcurrent > 0) {
539
+ const activeCount = countActiveSessions({ store, agents })
540
+ if (activeCount >= maxConcurrent) {
541
+ const err = new Error('concurrent-limit-reached')
542
+ err.key = 'concurrent-limit-reached'
543
+ err.status = 429
544
+ throw err
545
+ }
546
+ }
547
+
548
+ reserveSessionSlot(list[0].id)
549
+ let taken
550
+ try {
551
+ taken = await obtainAgent({
552
+ agents, task: list[0], config, provider, model, mintSessionId, cwdOf, logger,
553
+ agentPreset, permission, permissions,
554
+ })
555
+ } finally {
556
+ releaseSessionSlot(list[0].id)
557
+ }
467
558
 
468
559
  await taken.agent.whenIdle()
469
560
  const own = typeof text === 'string' ? text.trim() : ''
@@ -473,9 +564,11 @@ export async function runBatch({
473
564
  }))
474
565
 
475
566
  if (own !== '' && list.length === 1) {
476
- send(own, 'task-start')
567
+ await send(own, 'task-start')
477
568
  } else {
478
- list.forEach((task, at) => send(buildQueuedMessage(task, config, at + 1, list.length), 'batch-queued'))
569
+ for (let at = 0; at < list.length; at++) {
570
+ await send(buildQueuedMessage(list[at], config, at + 1, list.length), 'batch-queued')
571
+ }
479
572
  }
480
573
 
481
574
  const started = []
@@ -559,12 +652,23 @@ export function unqueueTask({ store, task }) {
559
652
  */
560
653
  export function countActiveSessions({ store, agents }) {
561
654
  const sessions = liveSessions({ store, agents })
562
- return sessions.filter((s) => s.status === 'running' || s.status === 'busy' || s.status === 'thinking').length
655
+ const activeCount = sessions.filter((s) => s.status === 'running' || s.status === 'busy' || s.status === 'thinking').length
656
+ return activeCount + activeReservations.size
563
657
  }
564
658
 
565
659
  export function liveSessions({ store, agents }) {
566
660
  const byId = new Map()
567
- const all = [...store.listTasks({ board: 'main' }), ...store.listTasks({ board: 'simple' })]
661
+ let all = []
662
+ if (typeof store?.listBoards === 'function' && typeof store?.listTasks === 'function') {
663
+ const boards = store.listBoards() || []
664
+ all = boards.flatMap((b) => store.listTasks({ board: b.id }) || [])
665
+ } else if (typeof store?.listTasks === 'function') {
666
+ all = store.listTasks() || []
667
+ } else if (typeof store?.listInColumn === 'function') {
668
+ all = store.listInColumn('in-progress') || []
669
+ } else if (Array.isArray(store?.tasks)) {
670
+ all = store.tasks
671
+ }
568
672
  for (const task of all) {
569
673
  const id = typeof task.sessionId === 'string' ? task.sessionId : ''
570
674
  if (id === '') continue
@@ -602,19 +706,26 @@ export async function dispatchNextQueuedTask({
602
706
  if (queued.length === 0) return null
603
707
 
604
708
  const nextTask = queued[0]
709
+ let queuedParams = {}
710
+ if (typeof nextTask.queuedRequest === 'string' && nextTask.queuedRequest) {
711
+ try { queuedParams = JSON.parse(nextTask.queuedRequest) } catch { /* ignore */ }
712
+ }
605
713
  try {
606
714
  return await runTask({
607
715
  agents,
608
716
  store,
609
717
  task: nextTask,
610
718
  config,
611
- provider: nextTask.provider || undefined,
612
- model: nextTask.model || undefined,
719
+ provider: queuedParams.provider || nextTask.provider || undefined,
720
+ model: queuedParams.model || nextTask.model || undefined,
721
+ permission: queuedParams.permission || nextTask.permission || undefined,
722
+ reasoningEffort: queuedParams.reasoningEffort || nextTask.reasoningEffort || undefined,
723
+ text: queuedParams.text || undefined,
724
+ agentPreset: queuedParams.agentPreset || agentPreset,
613
725
  mintSessionId: mintSessionId || (() => `kanban-${nextTask.id}-${randomUUID()}`),
614
726
  createMessage: createMessage || ((msg) => msg),
615
727
  cwdOf,
616
728
  logger,
617
- agentPreset,
618
729
  permissions,
619
730
  workspaceRegistry,
620
731
  claimAs: claimAs || 'queue-dispatcher',
package/lib/order.js CHANGED
@@ -1,63 +1,33 @@
1
1
  // Fractional ordering index for cards.
2
2
  //
3
- // Naive approach stores integer index, and dragging card to the top of column
4
- // with ten items requires rewriting all ten. Here order is stored as string,
5
- // and new value is generated BETWEEN two neighbors, so drag-and-drop is always
6
- // a single UPDATE of one row regardless of how many cards are in column.
7
- //
8
- // String-based implementation is intentional. Floating-point numbers fail
9
- // after ~50 insertions into the same position when precision runs out.
10
- //
11
- // Key is read as fraction after implicit '0.': comparison is standard
12
- // lexicographical. Key never ends with first alphabet character to ensure
13
- // subsequent division remains possible.
14
-
15
- /** Alphabet in ascending character code order. */
16
- const DIGITS = '0123456789abcdefghijklmnopqrstuvwxyz'
17
- const FIRST = DIGITS[0]
18
-
19
- /** Value strictly between `a` and `b`; `b === undefined` means unbounded right. */
20
- function midpoint(a, b) {
21
- if (b !== undefined && a >= b) {
22
- throw new Error(`order violated: ${JSON.stringify(a)} not less than ${JSON.stringify(b)}`)
23
- }
24
- if (a.endsWith(FIRST) || (b !== undefined && b.endsWith(FIRST))) {
25
- throw new Error('order key cannot end with first alphabet symbol')
26
- }
27
-
28
- if (b !== undefined) {
29
- // Common prefix carried as-is, division continues on remainder
30
- // to avoid inflating key length unnecessarily.
31
- let n = 0
32
- while ((a[n] ?? FIRST) === b[n]) n += 1
33
- if (n > 0) return b.slice(0, n) + midpoint(a.slice(n), b.slice(n))
34
- }
35
-
36
- const from = a === '' ? 0 : DIGITS.indexOf(a[0])
37
- const to = b === undefined ? DIGITS.length : DIGITS.indexOf(b[0])
38
- if (from < 0 || to < 0) throw new Error('order key contains character outside alphabet')
3
+ // String-based implementation using rocicorp/fractional-indexing (CC0-1.0).
4
+ // Generates bounded-length order keys strictly between neighbors, avoiding
5
+ // O(N^2) string inflation on repeated appends/prepends.
39
6
 
40
- if (to - from > 1) {
41
- return DIGITS[Math.round(0.5 * (from + to))]
42
- }
43
- if (b !== undefined && b.length > 1) {
44
- return b.slice(0, 1)
45
- }
46
- // Adjacent symbols: take `a` digit and divide next position.
47
- return DIGITS[from] + midpoint(a.slice(1), undefined)
48
- }
7
+ import { generateKeyBetween, generateNKeysBetween } from './fractional-indexing.js'
49
8
 
50
9
  /**
51
10
  * Fractional order key strictly between two neighbors.
52
11
  *
53
- * Both neighbors are optional: without either returns default initial value;
54
- * without right returns value after `a`; without left returns value before `b`.
55
- *
56
12
  * @param {string} [a] left neighbor order key
57
13
  * @param {string} [b] right neighbor order key
58
14
  * @returns {string} key strictly between them
59
- * @throws if `a` is not less than `b` or key contains invalid character
15
+ * @throws if a >= b or key contains invalid character
60
16
  */
61
17
  export function keyBetween(a, b) {
62
- return midpoint(a ?? '', b ?? undefined)
18
+ if (a !== undefined && a !== null && b !== undefined && b !== null) {
19
+ if (a >= b) {
20
+ throw new Error(`order violated: ${JSON.stringify(a)} not less than ${JSON.stringify(b)}`)
21
+ }
22
+ }
23
+ if (typeof a === 'string' && /[^0-9a-zA-Z]/.test(a)) {
24
+ throw new Error('order key contains character outside alphabet')
25
+ }
26
+ if (typeof b === 'string' && /[^0-9a-zA-Z]/.test(b)) {
27
+ throw new Error('order key contains character outside alphabet')
28
+ }
29
+
30
+ return generateKeyBetween(a ?? null, b ?? null)
63
31
  }
32
+
33
+ export { generateKeyBetween, generateNKeysBetween }
package/lib/outbound.js CHANGED
@@ -19,22 +19,32 @@
19
19
  */
20
20
  export function planAssign(task, login) {
21
21
  if (typeof task?.issueNumber !== 'number') return []
22
- return [{
22
+ const op = {
23
23
  kind: 'assign',
24
24
  owner: task.owner,
25
25
  repo: task.repo,
26
26
  index: task.issueNumber,
27
27
  login: typeof login === 'string' ? login : '',
28
- }]
28
+ }
29
+ if (task.forge) op.provider = task.forge
30
+ else if (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com')) op.provider = 'github'
31
+ if (task.issueUrl) op.issueUrl = task.issueUrl
32
+ return [op]
29
33
  }
30
34
 
31
35
  export function planOutbound(task, column, config) {
32
36
  if (typeof task?.issueNumber !== 'number') return []
33
- // Closing issue is the only mutation sent to Gitea: it is a concrete action,
34
- // not redundant mirroring.
35
- return column === 'done'
36
- ? [{ kind: 'close', owner: task.owner, repo: task.repo, index: task.issueNumber }]
37
- : []
37
+ if (column !== 'done') return []
38
+ const op = {
39
+ kind: 'close',
40
+ owner: task.owner,
41
+ repo: task.repo,
42
+ index: task.issueNumber,
43
+ }
44
+ if (task.forge) op.provider = task.forge
45
+ else if (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com')) op.provider = 'github'
46
+ if (task.issueUrl) op.issueUrl = task.issueUrl
47
+ return [op]
38
48
  }
39
49
 
40
50
  /**
@@ -71,13 +81,19 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
71
81
  for (const item of pending) {
72
82
  try {
73
83
  if (item.op.kind === 'close') {
74
- await gitea.closeIssue({ owner: item.op.owner, repo: item.op.repo, index: item.op.index })
84
+ const closePayload = { owner: item.op.owner, repo: item.op.repo, index: item.op.index }
85
+ if (item.op.provider) closePayload.provider = item.op.provider
86
+ if (item.op.issueUrl) closePayload.issueUrl = item.op.issueUrl
87
+ await gitea.closeIssue(closePayload)
75
88
  }
76
89
  if (item.op.kind === 'assign') {
77
- await gitea.setAssignees({
90
+ const assignPayload = {
78
91
  owner: item.op.owner, repo: item.op.repo, index: item.op.index,
79
92
  logins: item.op.login === '' ? [] : [item.op.login],
80
- })
93
+ }
94
+ if (item.op.provider) assignPayload.provider = item.op.provider
95
+ if (item.op.issueUrl) assignPayload.issueUrl = item.op.issueUrl
96
+ await gitea.setAssignees(assignPayload)
81
97
  }
82
98
  sent += 1
83
99
  } catch (error) {
@@ -90,9 +106,10 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
90
106
  dropped += 1
91
107
  logger?.warn?.(`dsh-kanban: Gitea delivery failed permanently: ${error?.message}`)
92
108
  try {
93
- const task = store.getTask(item.taskId)
109
+ const currentStore = typeof store === 'function' ? store() : store
110
+ const task = currentStore?.getTask?.(item.taskId)
94
111
  if (task !== undefined) {
95
- store.addTransition({
112
+ currentStore?.addTransition?.({
96
113
  taskId: item.taskId,
97
114
  fromCol: task.column,
98
115
  toCol: task.column,
@@ -62,15 +62,22 @@ export function parseProgressDump(text) {
62
62
  const line = rawLine.trim()
63
63
  const lower = line.toLowerCase()
64
64
 
65
- if (lower.startsWith('goal:') || lower.startsWith('\u0446\u0435\u043b\u044c:')) {
65
+ if (lower.startsWith('goal:') || lower.startsWith('цель:')) {
66
66
  currentKey = 'goal'
67
67
  const val = line.slice(line.indexOf(':') + 1).trim()
68
68
  if (val) sections.goal.push(val)
69
- } else if (lower.startsWith('progress:') || lower.startsWith('\u043f\u0440\u043e\u0433\u0440\u0435\u0441\u0441:')) {
69
+ } else if (lower.startsWith('progress:') || lower.startsWith('done:') || lower.startsWith('прогресс:')) {
70
70
  currentKey = 'progress'
71
71
  const val = line.slice(line.indexOf(':') + 1).trim()
72
72
  if (val) sections.progress.push(val)
73
- } else if (lower.startsWith('next:') || lower.startsWith('next steps:') || lower.startsWith('\u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0435 \u0448\u0430\u0433\u0438:') || lower.startsWith('\u0448\u0430\u0433\u0438:')) {
73
+ } else if (
74
+ lower.startsWith('next:') ||
75
+ lower.startsWith('next steps:') ||
76
+ lower.startsWith('next step:') ||
77
+ lower.startsWith('следующие шаги:') ||
78
+ lower.startsWith('шаги:') ||
79
+ lower.startsWith('дальше:')
80
+ ) {
74
81
  currentKey = 'next'
75
82
  const val = line.slice(line.indexOf(':') + 1).trim()
76
83
  if (val) sections.next.push(val)
@@ -98,16 +105,14 @@ export function parseProgressDump(text) {
98
105
  */
99
106
  export function formatProgressDump(dump) {
100
107
  if (!dump) return ''
108
+ const g = dump.goal || ''
109
+ const p = dump.progress || ''
110
+ const n = dump.next || ''
101
111
  return [
102
112
  BEGIN_MARKER,
103
- 'Goal:',
104
- dump.goal || '—',
105
- '',
106
- 'Progress:',
107
- dump.progress || '—',
108
- '',
109
- 'Next steps:',
110
- dump.next || '—',
113
+ `Goal: ${g}`,
114
+ `Progress: ${p}`,
115
+ `Next steps: ${n}`,
111
116
  END_MARKER,
112
117
  ].join('\n')
113
118
  }
package/lib/routes.js CHANGED
@@ -171,6 +171,12 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
171
171
  export function applyMove({ store, config, id, column, beforeId, afterId, source = 'manual', detail = '' }) {
172
172
  const before = store.getTask(id)
173
173
  if (before === undefined) return { error: 'task-not-found', status: 404 }
174
+ if (column !== undefined && column !== null) {
175
+ const allowed = columnsOf(store.boardKind(before.board))
176
+ if (!allowed.includes(column)) {
177
+ return { error: 'invalid-column', status: 400 }
178
+ }
179
+ }
174
180
  const task = store.moveTask(id, { column, beforeId, afterId })
175
181
  if (task.column !== before.column) {
176
182
  store.addTransition({
@@ -229,15 +235,30 @@ export function updateTask({ store, id, input }) {
229
235
  patch.permission = input.permission
230
236
  if (input.permission !== current.permission) {
231
237
  patch.permissionConfirmedAt = 0
238
+ patch.permissionConfirmedTier = ''
239
+ }
240
+ }
241
+ if (typeof input?.permissionConfirmedTier === 'string') {
242
+ patch.permissionConfirmedTier = input.permissionConfirmedTier
243
+ }
244
+ if (typeof input?.reasoningEffort === 'string') {
245
+ const val = input.reasoningEffort.trim().toLowerCase()
246
+ if (['', 'low', 'medium', 'high', 'default', 'none', 'minimal', 'thorough'].includes(val)) {
247
+ patch.reasoningEffort = val
232
248
  }
233
249
  }
250
+ if (typeof input?.forge === 'string') {
251
+ const val = input.forge.trim().toLowerCase()
252
+ if (val === 'github' || val === 'gitea') patch.forge = val
253
+ }
234
254
  return { task: store.updateTask(id, patch) }
235
255
  }
236
256
 
237
- export function confirmPermission({ store, id }) {
257
+ export function confirmPermission({ store, id, tier }) {
238
258
  const task = store.getTask(id)
239
259
  if (task === undefined) return { error: 'task-not-found', status: 404 }
240
- return { task: store.updateTask(id, { permissionConfirmedAt: Date.now() }) }
260
+ const effectiveTier = typeof tier === 'string' && tier ? tier : (task.permission || 'default')
261
+ return { task: store.updateTask(id, { permissionConfirmedAt: Date.now(), permissionConfirmedTier: effectiveTier }) }
241
262
  }
242
263
 
243
264
  /** Known priority values. Empty string indicates unprioritized. */
@@ -399,27 +420,22 @@ export function importBoard({ store, input }) {
399
420
  for (const task of input.tasks) {
400
421
  if (typeof task?.id !== 'string' || task.id === '') continue
401
422
  if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
402
- store.createTask({
403
- id: task.id,
404
- board: task.board ?? 'main',
405
- column: task.column ?? 'backlog',
406
- title: task.title ?? '',
407
- body: task.body ?? '',
408
- owner: task.owner, repo: task.repo,
409
- issueNumber: task.issueNumber, issueUrl: task.issueUrl,
410
- labels: Array.isArray(task.labels) ? task.labels : [],
411
- branch: task.branch, worktree: task.worktree,
412
- sessionId: task.sessionId, model: task.model, provider: task.provider,
413
- waiting: task.waiting === true,
414
- plan: task.plan ?? '',
415
- labelColors: task.labelColors ?? {},
416
- author: task.author ?? '',
417
- priority: task.priority ?? '',
418
- dueAt: typeof task.dueAt === 'number' ? task.dueAt : 0,
419
- createdAt: task.createdAt,
420
- })
423
+ if (typeof store.restoreTask === 'function') {
424
+ store.restoreTask(task)
425
+ } else {
426
+ store.createTask(task)
427
+ }
421
428
  imported += 1
422
429
  }
430
+
431
+ for (const trans of Array.isArray(input.transitions) ? input.transitions : []) {
432
+ if (typeof trans?.taskId !== 'string' || trans.taskId === '') continue
433
+ if (typeof store.restoreTransition === 'function') {
434
+ store.restoreTransition(trans)
435
+ } else if (typeof store.addTransition === 'function') {
436
+ store.addTransition(trans)
437
+ }
438
+ }
423
439
  }
424
440
 
425
441
  if (typeof store.withTransaction === 'function') {
@@ -458,7 +474,12 @@ export function exportTasksCsv({ store, board = 'main' }) {
458
474
 
459
475
  function escapeCsv(val) {
460
476
  if (val === null || val === undefined) return ''
461
- const str = String(val)
477
+ let str = String(val)
478
+ // OWASP CSV Injection mitigation (#340)
479
+ const trimmed = str.replace(/^[\s\t\r\n]+/, '')
480
+ if (/^[=+\-@\t\r]/.test(str) || (trimmed.length > 0 && /^[=+\-@]/.test(trimmed))) {
481
+ str = `'${str}`
482
+ }
462
483
  if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
463
484
  return `"${str.replace(/"/g, '""')}"`
464
485
  }
@@ -803,10 +824,11 @@ export function handleToggleChecklistItem({ store, task, input }) {
803
824
  let updatedItem
804
825
  if (typeof item === 'string') {
805
826
  const checked = input?.checked !== undefined ? Boolean(input.checked) : true
806
- updatedItem = { text: item, done: checked }
827
+ updatedItem = { text: item, completed: checked, done: checked }
807
828
  } else {
808
- const checked = input?.checked !== undefined ? Boolean(input.checked) : !item.done
809
- updatedItem = { ...item, done: checked }
829
+ const isCurrentlyDone = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
830
+ const checked = input?.checked !== undefined ? Boolean(input.checked) : !isCurrentlyDone
831
+ updatedItem = { ...item, completed: checked, done: checked }
810
832
  }
811
833
  current[targetIndex] = updatedItem
812
834
  const updated = store.updateChecklist(task.id, current)