@goodandready/dsh-kanban 0.2.31 → 0.2.33

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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Token and cost telemetry integration with @goodandready/dsh-cost-meter (#374).
3
+ * All functions fail-soft without throwing when cost-meter is absent.
4
+ */
5
+
6
+ /**
7
+ * Check whether @goodandready/dsh-cost-meter plugin is active in current runtime.
8
+ *
9
+ * @param {object} ctx - Cordis context
10
+ * @returns {boolean}
11
+ */
12
+ export function isCostMeterAvailable(ctx) {
13
+ if (!ctx || typeof ctx !== 'object') return false
14
+ try {
15
+ if (ctx.costMeter || (typeof ctx.get === 'function' && ctx.get('costMeter'))) return true
16
+ if (ctx.settings && typeof ctx.settings.describe === 'function') {
17
+ const rows = ctx.settings.describe({ redactSecrets: true })
18
+ if (Array.isArray(rows) && rows.some((r) => r?.ns === 'dsh-cost-meter')) return true
19
+ }
20
+ if (ctx.registry && typeof ctx.registry[Symbol.iterator] === 'function') {
21
+ for (const [plugin] of ctx.registry) {
22
+ if (plugin === '@goodandready/dsh-cost-meter' || plugin?.name === '@goodandready/dsh-cost-meter') {
23
+ return true
24
+ }
25
+ }
26
+ }
27
+ return false
28
+ } catch {
29
+ return false
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Extract aggregated token usage from a session's costByModel projection view.
35
+ *
36
+ * @param {object} projectionView - View object from usageByModelProjection
37
+ * @returns {{
38
+ * totalTokens: number,
39
+ * uncachedInputTokens: number,
40
+ * outputTokens: number,
41
+ * cacheReadTokens: number,
42
+ * cacheWriteTokens: number,
43
+ * turnsCount: number,
44
+ * models: Array<string>
45
+ * }}
46
+ */
47
+ export function extractSessionUsage(projectionView) {
48
+ const result = {
49
+ totalTokens: 0,
50
+ uncachedInputTokens: 0,
51
+ outputTokens: 0,
52
+ cacheReadTokens: 0,
53
+ cacheWriteTokens: 0,
54
+ turnsCount: 0,
55
+ models: [],
56
+ }
57
+ if (!projectionView || typeof projectionView !== 'object') return result
58
+
59
+ const modelsList = Array.isArray(projectionView.models) ? projectionView.models : []
60
+ for (const m of modelsList) {
61
+ const key = (m.provider ? m.provider + '/' : '') + (m.model || '')
62
+ if (key && !result.models.includes(key)) result.models.push(key)
63
+ const slots = m.slots && typeof m.slots === 'object' ? m.slots : {}
64
+ for (const slotKey of Object.keys(slots)) {
65
+ const b = slots[slotKey] || {}
66
+ result.uncachedInputTokens += Number(b.uncachedInputTokens) || 0
67
+ result.outputTokens += Number(b.outputTokens) || 0
68
+ result.cacheReadTokens += Number(b.cacheReadTokens) || 0
69
+ result.cacheWriteTokens += Number(b.cacheWriteTokens) || 0
70
+ }
71
+ }
72
+
73
+ if (Array.isArray(projectionView.turns)) {
74
+ result.turnsCount = projectionView.turns.length
75
+ if (result.totalTokens === 0 && projectionView.turns.length > 0) {
76
+ for (const t of projectionView.turns) {
77
+ result.uncachedInputTokens += Number(t.uncachedInputTokens) || 0
78
+ result.outputTokens += Number(t.outputTokens) || 0
79
+ result.cacheReadTokens += Number(t.cacheReadTokens) || 0
80
+ result.cacheWriteTokens += Number(t.cacheWriteTokens) || 0
81
+ }
82
+ }
83
+ }
84
+
85
+ result.totalTokens = result.uncachedInputTokens + result.outputTokens + result.cacheReadTokens + result.cacheWriteTokens
86
+ return result
87
+ }
88
+
89
+ /**
90
+ * Calculate cost for token usage given rate schedule or fallback tariff.
91
+ * Default tariff: standard DeepSeek rates ($0.14/M input, $0.28/M output, $0.014/M cache hit).
92
+ *
93
+ * @param {{
94
+ * usage: object,
95
+ * schedule?: object,
96
+ * usdRate?: number,
97
+ * currency?: string
98
+ * }} options
99
+ * @returns {{
100
+ * costUsd: number,
101
+ * costFormatted: string,
102
+ * currency: string
103
+ * }}
104
+ */
105
+ export function calculateTokenCost({ usage, schedule, usdRate = 1, currency = '$' }) {
106
+ if (!usage || typeof usage !== 'object') {
107
+ return { costUsd: 0, costFormatted: currency + '0.00', currency }
108
+ }
109
+
110
+ const uncachedInput = Number(usage.uncachedInputTokens) || 0
111
+ const output = Number(usage.outputTokens) || 0
112
+ const cacheRead = Number(usage.cacheReadTokens) || 0
113
+ const cacheWrite = Number(usage.cacheWriteTokens) || 0
114
+
115
+ const rateInput = Number(schedule?.inputPerMillion ?? schedule?.input) || 0.14
116
+ const rateOutput = Number(schedule?.outputPerMillion ?? schedule?.output) || 0.28
117
+ const rateCacheRead = Number(schedule?.cacheReadPerMillion ?? schedule?.cacheHit) || 0.014
118
+ const rateCacheWrite = Number(schedule?.cacheWritePerMillion) || rateInput
119
+
120
+ const costUsd = (uncachedInput * rateInput + output * rateOutput + cacheRead * rateCacheRead + cacheWrite * rateCacheWrite) / 1_000_000
121
+ const converted = costUsd * (Number(usdRate) > 0 ? Number(usdRate) : 1)
122
+
123
+ const costFormatted = currency + (converted < 0.01 && converted > 0 ? '<0.01' : converted.toFixed(2))
124
+
125
+ return {
126
+ costUsd: Math.round(costUsd * 10000) / 10000,
127
+ costFormatted,
128
+ currency,
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Check whether task cost exceeds configured budget limit.
134
+ *
135
+ * @param {{ costUsd: number, limit: number }} options
136
+ * @returns {{ exceeded: boolean, limit: number, costUsd: number }}
137
+ */
138
+ export function checkCostLimit({ costUsd = 0, limit = 0 }) {
139
+ const numLimit = Number(limit) || 0
140
+ if (numLimit <= 0) return { exceeded: false, limit: 0, costUsd }
141
+ return {
142
+ exceeded: costUsd >= numLimit,
143
+ limit: numLimit,
144
+ costUsd,
145
+ }
146
+ }
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { verifyAndRecordGate } from './verification.js'
1
2
  import { registerPluginUpdater } from './updater.js'
2
3
  // dsh-kanban — task board for DeepSeek Harness.
3
4
  //
@@ -34,6 +35,7 @@ import {
34
35
  exportTasksCsv,
35
36
  handleCleanupWorktree,
36
37
  handleTaskDiff,
38
+ handleTaskDiscard,
37
39
  handleTaskCommits,
38
40
  handleTaskMerge,
39
41
  handleTaskWorktreeStatus,
@@ -49,7 +51,9 @@ import {
49
51
  handleCreateSubtask,
50
52
  handleToggleChecklistItem,
51
53
  handleCreateTaskPr,
54
+ handleTaskNudge,
52
55
  } from './routes.js'
56
+ import { isCostMeterAvailable } from './cost-telemetry.js'
53
57
  import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
54
58
  import {
55
59
  runTask, resolveModel, buildStartMessage,
@@ -865,7 +869,20 @@ export function apply(ctx, config) {
865
869
  /**
866
870
  * Return active agent state or undefined if session is inactive.
867
871
  */
868
- const liveOf = (sessionId) => ctx.agents.get(sessionId)?.status
872
+ const liveOf = (sessionId) => {
873
+ try {
874
+ const a = ctx.agents?.get?.(sessionId);
875
+ if (!a) return undefined;
876
+ return {
877
+ status: a.status || (typeof a.isIdle === 'function' && a.isIdle() ? 'idle' : 'running'),
878
+ state: a.state || a.lifecycleState,
879
+ step: a.step || a.currentStep || a.stateDetail || a.activity || '',
880
+ activity: a.activity || a.statusDetail || '',
881
+ };
882
+ } catch {
883
+ return undefined;
884
+ }
885
+ };
869
886
 
870
887
  ctx.effect(() => ctx.webServer.register({
871
888
  kind: 'exact',
@@ -883,6 +900,7 @@ export function apply(ctx, config) {
883
900
  sync: syncState.snapshot(),
884
901
  // Resolved project root passed with board payload for session launch modal.
885
902
  projectRoot: rootOf(getConfig()),
903
+ costMeterAvailable: isCostMeterAvailable(ctx),
886
904
  }))
887
905
  },
888
906
  }), 'dsh-kanban: board route')
@@ -1151,8 +1169,31 @@ export function apply(ctx, config) {
1151
1169
  try {
1152
1170
  const input = await readBody(req)
1153
1171
  if (req.method === 'POST' && action === 'move') {
1154
- // Manual move dispatches stop command to agent prior to updating card column.
1155
1172
  const was = store.getTask(id)
1173
+ if (was === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1174
+
1175
+ // Automated Verification Gate (#372): intercept transition to review
1176
+ if (input.column === 'review' && was.column !== 'review') {
1177
+ const gateResult = await verifyAndRecordGate({
1178
+ task: was,
1179
+ config: getConfig(),
1180
+ store,
1181
+ agents: ctx.agents,
1182
+ createMessage: createUserMessage,
1183
+ })
1184
+ if (!gateResult.allowed) {
1185
+ writeJson(res, 422, {
1186
+ error: 'verification-failed',
1187
+ command: gateResult.verification.command,
1188
+ exitCode: gateResult.verification.exitCode,
1189
+ log: gateResult.verification.logSnippet,
1190
+ task: store.getTask(id),
1191
+ })
1192
+ return
1193
+ }
1194
+ }
1195
+
1196
+ // Manual move dispatches stop command to agent prior to updating card column.
1156
1197
  const acted = was !== undefined && input.column !== was.column
1157
1198
  ? dispatchMove({
1158
1199
  agents: ctx.agents, task: was, column: input.column,
@@ -1193,8 +1234,32 @@ export function apply(ctx, config) {
1193
1234
  writeJson(res, resObj.status, resObj.body)
1194
1235
  return
1195
1236
  }
1237
+ if (req.method === 'POST' && action === 'discard') {
1238
+ const resObj = await handleTaskDiscard({
1239
+ task: store.getTask(id),
1240
+ file: input?.file,
1241
+ discardTaskFile,
1242
+ })
1243
+ if (resObj?.body?.success) {
1244
+ eventHub.broadcast('task:change', { taskId: id, action: 'discard', file: input?.file })
1245
+ }
1246
+ writeJson(res, resObj.status, resObj.body)
1247
+ return
1248
+ }
1249
+ if (req.method === 'POST' && (action === 'nudge' || (input && input.action === 'nudge'))) {
1250
+ const resObj = await handleTaskNudge({
1251
+ store,
1252
+ id,
1253
+ prompt: input?.prompt || input?.text,
1254
+ agents: ctx.agents,
1255
+ createMessage: createUserMessage,
1256
+ eventHub,
1257
+ })
1258
+ writeJson(res, resObj.status, resObj.body)
1259
+ return
1260
+ }
1196
1261
  if (req.method === 'GET' && action === 'diff') {
1197
- const resObj = await handleTaskDiff({ task: store.getTask(id), getTaskDiff })
1262
+ const resObj = await handleTaskDiff({ task: store.getTask(id), getTaskDiff, getTaskDiffInspector })
1198
1263
  writeJson(res, resObj.status, resObj.body)
1199
1264
  return
1200
1265
  }
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,7 @@ 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),
387
401
  }
388
402
  }
389
403
 
@@ -773,12 +787,27 @@ export async function handleCleanupWorktree({
773
787
  /**
774
788
  * Get working copy diff for task.
775
789
  */
776
- export async function handleTaskDiff({ task, getTaskDiff }) {
790
+ export async function handleTaskDiff({ task, getTaskDiff, getTaskDiffInspector }) {
777
791
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
792
+ if (typeof getTaskDiffInspector === 'function') {
793
+ const inspectorResult = await getTaskDiffInspector({ worktreePath: task.worktree })
794
+ return { status: 200, body: inspectorResult }
795
+ }
778
796
  const diffResult = await getTaskDiff({ worktreePath: task.worktree })
779
797
  return { status: 200, body: diffResult }
780
798
  }
781
799
 
800
+ /**
801
+ * Discard file changes in task worktree (#371).
802
+ */
803
+ export async function handleTaskDiscard({ task, file, discardTaskFile }) {
804
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
805
+ if (!file || typeof file !== 'string') return { status: 400, body: { error: 'file-required' } }
806
+ if (!task.worktree) return { status: 400, body: { error: 'no-worktree' } }
807
+ const result = await discardTaskFile({ worktreePath: task.worktree, file })
808
+ return { status: result.success ? 200 : 400, body: result }
809
+ }
810
+
782
811
  /**
783
812
  * Get commit list for task.
784
813
  */
@@ -1104,4 +1133,75 @@ export async function handleCreateTaskPr({ store, gitea, task, input }) {
1104
1133
  } catch (err) {
1105
1134
  return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
1106
1135
  }
1107
- }
1136
+ }
1137
+ /**
1138
+ * Handle interactive live steering (nudge) for an active task agent (#373).
1139
+ *
1140
+ * @param {{
1141
+ * store: object,
1142
+ * id: string,
1143
+ * prompt: string,
1144
+ * agents?: object,
1145
+ * createMessage?: Function,
1146
+ * eventHub?: object,
1147
+ * now?: number
1148
+ * }} options
1149
+ * @returns {Promise<{ status: number, body: object }>}
1150
+ */
1151
+ export async function handleTaskNudge({ store, id, prompt, agents, createMessage, eventHub, now = Date.now() }) {
1152
+ if (!store || !id) return { status: 400, body: { error: 'invalid-parameters' } }
1153
+ const task = store.getTask(id)
1154
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
1155
+
1156
+ const text = typeof prompt === 'string' ? prompt.trim() : ''
1157
+ if (!text) return { status: 400, body: { error: 'prompt-required' } }
1158
+
1159
+ const sessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
1160
+ if (!sessionId) return { status: 409, body: { error: 'no-session' } }
1161
+
1162
+ let agent
1163
+ try { agent = agents?.get?.(sessionId) } catch { agent = undefined }
1164
+ if (!agent || typeof agent.followup !== 'function') {
1165
+ return { status: 409, body: { error: 'session-not-live' } }
1166
+ }
1167
+
1168
+ const msg = typeof createMessage === 'function'
1169
+ ? createMessage({
1170
+ content: [{ type: 'text', text }],
1171
+ source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: 'task-nudge' },
1172
+ })
1173
+ : { content: [{ type: 'text', text }] }
1174
+
1175
+ agent.followup(msg)
1176
+
1177
+ // Append user steering comment to task
1178
+ if (typeof store.addComment === 'function') {
1179
+ store.addComment(id, {
1180
+ author: 'user',
1181
+ role: 'user',
1182
+ text: `[Steering Nudge] ${text}`,
1183
+ })
1184
+ } else if (typeof store.addTaskComment === 'function') {
1185
+ store.addTaskComment(id, {
1186
+ author: 'user',
1187
+ role: 'user',
1188
+ text: `[Steering Nudge] ${text}`,
1189
+ })
1190
+ }
1191
+
1192
+ // Broadcast change via eventHub
1193
+ if (eventHub && typeof eventHub.broadcast === 'function') {
1194
+ eventHub.broadcast('task:change', { id, col: task.col || task.column })
1195
+ }
1196
+
1197
+ return {
1198
+ status: 200,
1199
+ body: {
1200
+ success: true,
1201
+ id,
1202
+ sessionId,
1203
+ prompt: text,
1204
+ nudgedAt: now,
1205
+ },
1206
+ }
1207
+ }
package/lib/store.js CHANGED
@@ -108,6 +108,8 @@ const WRITABLE = [
108
108
  'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
109
109
  'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
110
110
  'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge', 'queuedRequest',
111
+ 'verificationCommand', 'verificationStatus',
112
+ 'costMetrics', 'costLimitExceeded',
111
113
  ]
112
114
 
113
115
  /** Valid transition sources; unexpected values are rejected to preserve log integrity. */
@@ -161,6 +163,16 @@ function rowToTask(row) {
161
163
  reasoningEffort: rest.reasoningEffort ?? '',
162
164
  isEpic: isEpic === 1,
163
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,
164
176
  }
165
177
  }
166
178
 
@@ -249,6 +261,10 @@ export function openStore({ dir }) {
249
261
  ['forge', "TEXT NOT NULL DEFAULT ''"],
250
262
  ['permissionConfirmedTier', "TEXT NOT NULL DEFAULT ''"],
251
263
  ['queuedRequest', "TEXT NOT NULL DEFAULT ''"],
264
+ ['verificationCommand', "TEXT NOT NULL DEFAULT ''"],
265
+ ['verificationStatus', "TEXT NOT NULL DEFAULT ''"],
266
+ ['costMetrics', "TEXT NOT NULL DEFAULT ''"],
267
+ ['costLimitExceeded', "INTEGER NOT NULL DEFAULT 0"],
252
268
  ]) {
253
269
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
254
270
  }
@@ -650,6 +666,15 @@ export function openStore({ dir }) {
650
666
  })
651
667
  },
652
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
+
653
678
  updateChecklist(id, items) {
654
679
  const task = rowToTask(q.taskById.get(id))
655
680
  if (!task) return undefined