@goodandready/dsh-kanban 0.2.32 → 0.2.34

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/routes.js CHANGED
@@ -308,10 +308,23 @@ function withPlan(task, liveOf, config, now, sameSession) {
308
308
  try {
309
309
  if (typeof liveOf === 'function' && task.sessionId) live = liveOf(task.sessionId)
310
310
  } catch { live = undefined }
311
+ const liveStatus = typeof live === 'object' && live !== null ? live.status : live
312
+ const liveStep = typeof live === 'object' && live !== null
313
+ ? (live.step || live.activity || live.stateDetail || '')
314
+ : ''
315
+
316
+ const costMetrics = task.costMetrics || null
317
+ const costLimitExceeded = (config?.costMeterEnabled !== false && Number(config?.taskCostLimit) > 0 && costMetrics?.costUsd)
318
+ ? costMetrics.costUsd >= Number(config.taskCostLimit)
319
+ : Boolean(task.costLimitExceeded)
320
+
311
321
  return {
312
322
  ...task,
313
323
  plan: { items: planItems(plan), progress: planProgress(plan) },
314
- state: taskState({ task, live }),
324
+ state: taskState({ task, live: liveStatus }),
325
+ liveStep: liveStep || task.liveStep || '',
326
+ costMetrics,
327
+ costLimitExceeded,
315
328
  // Queue position calculated dynamically across active tasks in same session.
316
329
  queuePos: queuePosition(task, sameSession),
317
330
  // Label facets extracted on server for consistent client filtering.
@@ -344,7 +357,7 @@ function orderForBoard(tasks, columns) {
344
357
  /**
345
358
  * Build board response payload: columns in canonical order, tasks, and limits.
346
359
  */
347
- export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot }) {
360
+ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot, costMeterAvailable = false }) {
348
361
  // Uniform timestamp across snapshot calculation.
349
362
  const now = Date.now()
350
363
  // Tasks are sorted and grouped on server: repo grouping with active items first.
@@ -384,6 +397,8 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
384
397
  facets: facetsOf(tasks),
385
398
  // Sync telemetry state attached directly to board snapshot.
386
399
  sync: sync ?? { state: 'never' },
400
+ costMeterAvailable: Boolean(costMeterAvailable),
401
+ remoteMode: Boolean(config?.remoteMode),
387
402
  }
388
403
  }
389
404
 
@@ -753,7 +768,7 @@ export function exportTasksCsv({ store, board = 'main' }) {
753
768
  */
754
769
  export async function handleCleanupWorktree({
755
770
  store, task, workspaceRegistry, force, cleanupTaskWorktree,
756
- config, repoDir, cwdOf, env,
771
+ config, repoDir, cwdOf, env, gitRunner,
757
772
  }) {
758
773
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
759
774
  const out = await cleanupTaskWorktree({
@@ -765,6 +780,7 @@ export async function handleCleanupWorktree({
765
780
  repoDir,
766
781
  cwdOf,
767
782
  env,
783
+ gitRunner,
768
784
  })
769
785
  const status = out.status || (out.error ? (out.error === 'unfenced-worktree-path' ? 403 : 500) : 200)
770
786
  return { status, body: out }
@@ -786,32 +802,33 @@ export async function handleTaskDiff({ task, getTaskDiff, getTaskDiffInspector }
786
802
  /**
787
803
  * Discard file changes in task worktree (#371).
788
804
  */
789
- export async function handleTaskDiscard({ task, file, discardTaskFile }) {
805
+ export async function handleTaskDiscard({ task, file, discardTaskFile, gitRunner }) {
790
806
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
791
807
  if (!file || typeof file !== 'string') return { status: 400, body: { error: 'file-required' } }
792
808
  if (!task.worktree) return { status: 400, body: { error: 'no-worktree' } }
793
- const result = await discardTaskFile({ worktreePath: task.worktree, file })
809
+ const result = await discardTaskFile({ worktreePath: task.worktree, file, gitRunner })
794
810
  return { status: result.success ? 200 : 400, body: result }
795
811
  }
796
812
 
797
813
  /**
798
814
  * Get commit list for task.
799
815
  */
800
- export async function handleTaskCommits({ task, getTaskCommits }) {
816
+ export async function handleTaskCommits({ task, getTaskCommits, gitRunner }) {
801
817
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
802
- const commits = await getTaskCommits({ worktreePath: task.worktree })
818
+ const commits = await getTaskCommits({ worktreePath: task.worktree, gitRunner })
803
819
  return { status: 200, body: { commits } }
804
820
  }
805
821
 
806
822
  /**
807
823
  * Merge task worktree branch and transition to done.
808
824
  */
809
- export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
825
+ export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree, gitRunner }) {
810
826
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
811
827
  const mergeResult = await mergeTaskWorktree({
812
828
  task,
813
829
  targetBranch: typeof targetBranch === 'string' ? targetBranch.trim() : undefined,
814
830
  workspaceRegistry,
831
+ gitRunner,
815
832
  })
816
833
  if (!mergeResult.success) {
817
834
  return {
@@ -1119,4 +1136,75 @@ export async function handleCreateTaskPr({ store, gitea, task, input }) {
1119
1136
  } catch (err) {
1120
1137
  return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
1121
1138
  }
1122
- }
1139
+ }
1140
+ /**
1141
+ * Handle interactive live steering (nudge) for an active task agent (#373).
1142
+ *
1143
+ * @param {{
1144
+ * store: object,
1145
+ * id: string,
1146
+ * prompt: string,
1147
+ * agents?: object,
1148
+ * createMessage?: Function,
1149
+ * eventHub?: object,
1150
+ * now?: number
1151
+ * }} options
1152
+ * @returns {Promise<{ status: number, body: object }>}
1153
+ */
1154
+ export async function handleTaskNudge({ store, id, prompt, agents, createMessage, eventHub, now = Date.now() }) {
1155
+ if (!store || !id) return { status: 400, body: { error: 'invalid-parameters' } }
1156
+ const task = store.getTask(id)
1157
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
1158
+
1159
+ const text = typeof prompt === 'string' ? prompt.trim() : ''
1160
+ if (!text) return { status: 400, body: { error: 'prompt-required' } }
1161
+
1162
+ const sessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
1163
+ if (!sessionId) return { status: 409, body: { error: 'no-session' } }
1164
+
1165
+ let agent
1166
+ try { agent = agents?.get?.(sessionId) } catch { agent = undefined }
1167
+ if (!agent || typeof agent.followup !== 'function') {
1168
+ return { status: 409, body: { error: 'session-not-live' } }
1169
+ }
1170
+
1171
+ const msg = typeof createMessage === 'function'
1172
+ ? createMessage({
1173
+ content: [{ type: 'text', text }],
1174
+ source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: 'task-nudge' },
1175
+ })
1176
+ : { content: [{ type: 'text', text }] }
1177
+
1178
+ agent.followup(msg)
1179
+
1180
+ // Append user steering comment to task
1181
+ if (typeof store.addComment === 'function') {
1182
+ store.addComment(id, {
1183
+ author: 'user',
1184
+ role: 'user',
1185
+ text: `[Steering Nudge] ${text}`,
1186
+ })
1187
+ } else if (typeof store.addTaskComment === 'function') {
1188
+ store.addTaskComment(id, {
1189
+ author: 'user',
1190
+ role: 'user',
1191
+ text: `[Steering Nudge] ${text}`,
1192
+ })
1193
+ }
1194
+
1195
+ // Broadcast change via eventHub
1196
+ if (eventHub && typeof eventHub.broadcast === 'function') {
1197
+ eventHub.broadcast('task:change', { id, col: task.col || task.column })
1198
+ }
1199
+
1200
+ return {
1201
+ status: 200,
1202
+ body: {
1203
+ success: true,
1204
+ id,
1205
+ sessionId,
1206
+ prompt: text,
1207
+ nudgedAt: now,
1208
+ },
1209
+ }
1210
+ }
package/lib/store.js CHANGED
@@ -109,6 +109,7 @@ const WRITABLE = [
109
109
  'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
110
110
  'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge', 'queuedRequest',
111
111
  'verificationCommand', 'verificationStatus',
112
+ 'costMetrics', 'costLimitExceeded',
112
113
  ]
113
114
 
114
115
  /** Valid transition sources; unexpected values are rejected to preserve log integrity. */
@@ -162,6 +163,16 @@ function rowToTask(row) {
162
163
  reasoningEffort: rest.reasoningEffort ?? '',
163
164
  isEpic: isEpic === 1,
164
165
  parentId: parentId ?? '',
166
+ costMetrics: (() => {
167
+ if (typeof rest.costMetrics === 'string' && rest.costMetrics.trim()) {
168
+ try {
169
+ const val = JSON.parse(rest.costMetrics);
170
+ if (val && typeof val === 'object') return val;
171
+ } catch {}
172
+ }
173
+ return rest.costMetrics && typeof rest.costMetrics === 'object' ? rest.costMetrics : null;
174
+ })(),
175
+ costLimitExceeded: rest.costLimitExceeded === 1 || rest.costLimitExceeded === true,
165
176
  }
166
177
  }
167
178
 
@@ -252,6 +263,8 @@ export function openStore({ dir }) {
252
263
  ['queuedRequest', "TEXT NOT NULL DEFAULT ''"],
253
264
  ['verificationCommand', "TEXT NOT NULL DEFAULT ''"],
254
265
  ['verificationStatus', "TEXT NOT NULL DEFAULT ''"],
266
+ ['costMetrics', "TEXT NOT NULL DEFAULT ''"],
267
+ ['costLimitExceeded', "INTEGER NOT NULL DEFAULT 0"],
255
268
  ]) {
256
269
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
257
270
  }
@@ -653,6 +666,15 @@ export function openStore({ dir }) {
653
666
  })
654
667
  },
655
668
 
669
+ appendComment(id, comment) {
670
+ if (!comment || typeof comment !== 'object') return undefined
671
+ return this.addComment(id, {
672
+ author: comment.author || 'user',
673
+ role: comment.role || 'user',
674
+ text: comment.text || comment.body || '',
675
+ })
676
+ },
677
+
656
678
  updateChecklist(id, items) {
657
679
  const task = rowToTask(q.taskById.get(id))
658
680
  if (!task) return undefined
package/lib/worktree.js CHANGED
@@ -71,7 +71,32 @@ export function safeRepoKey(task) {
71
71
  /**
72
72
  * Resolve root directory for all managed worktrees.
73
73
  */
74
+ /**
75
+ * Check if worktree directory exists locally or remotely.
76
+ *
77
+ * @param {string} worktreePath
78
+ * @param {Function} [gitRunner]
79
+ * @returns {Promise<boolean>}
80
+ */
81
+ export async function safeWorktreeExists(worktreePath, gitRunner) {
82
+ if (!worktreePath) return false
83
+ if (existsSync(worktreePath)) return true
84
+ if (typeof gitRunner === 'function') {
85
+ try {
86
+ const res = await gitRunner(['rev-parse', '--is-inside-work-tree'], worktreePath)
87
+ return res.stdout?.trim() === 'true'
88
+ } catch {
89
+ return false
90
+ }
91
+ }
92
+ return false
93
+ }
94
+
74
95
  export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => process.cwd()) {
96
+ if (config?.remoteMode && config?.remoteRoot) {
97
+ const root = String(config.remoteRoot).trim().replace(/\/+$/, '')
98
+ return `${root}/.worktrees`
99
+ }
75
100
  const custom = typeof config?.worktreeRoot === 'string' ? config.worktreeRoot.trim() : ''
76
101
  if (custom !== '') {
77
102
  return isAbsolute(custom) ? resolve(custom) : resolve(join(cwdOf(), custom))
@@ -105,6 +130,12 @@ export function isPathFenced(targetPath, rootPath) {
105
130
  }
106
131
 
107
132
  export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
133
+ if (config?.remoteMode) {
134
+ const root = resolveWorktreeRoot(config, env, cwdOf)
135
+ const repoKey = safeRepoKey(task)
136
+ const taskId = typeof task?.id === 'string' && task.id.trim() !== '' ? task.id.trim() : 'task'
137
+ return `${root}/${repoKey}/${taskId}`
138
+ }
108
139
  const root = resolveWorktreeRoot(config, env, cwdOf)
109
140
  const repoKey = safeRepoKey(task)
110
141
  const taskId = typeof task?.id === 'string' && task.id.trim() !== '' ? task.id.trim() : 'task'
@@ -261,7 +292,8 @@ function isSubmoduleDrift(worktreePath, line) {
261
292
  * Excludes submodule noise and gitlink drift (#213).
262
293
  */
263
294
  export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitRunner }) {
264
- if (!worktreePath || !existsSync(worktreePath)) {
295
+ const exists = await safeWorktreeExists(worktreePath, gitRunner)
296
+ if (!worktreePath || !exists) {
265
297
  return { dirty: false, files: [] }
266
298
  }
267
299
  try {
@@ -289,7 +321,8 @@ export async function removeTaskWorktree({
289
321
  gitRunner = defaultGitRunner,
290
322
  rootPath,
291
323
  }) {
292
- if (!worktreePath || !existsSync(worktreePath)) {
324
+ const exists = await safeWorktreeExists(worktreePath, gitRunner)
325
+ if (!worktreePath || !exists) {
293
326
  return { removed: false, reason: 'not-found' }
294
327
  }
295
328
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.32",
3
+ "version": "0.2.34",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",